diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 31753ade5..5c0e80afb 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -31,7 +31,7 @@ How to read this: | **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) | | **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow | | **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) | -| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" | +| **CelesTrak** | Satellite TLEs (SGP4) and SATCAT records (object type, operational status, owner) that file each satellite under a class | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" | | **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) | | **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) | | **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" | @@ -263,9 +263,12 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | **Québec CCTV source catalog** (1,120 locations) | `public/quebec-cameras.json` | Mixed: MTQ locations CC BY 4.0; Montréal camera nodes ODbL 1.0; scenic records retain per-record provenance. No video files are bundled or cached. | Location data is per-record; video remains hosted by Québec 511 and its platform terms apply; scenic source pages may have separate terms | MTQ / Données Québec CC BY 4.0; © OpenStreetMap contributors (ODbL); scenic operator and position source shown in the selected-camera panel | | **World camera image subset** (2,230 locations) | `config/cctv_sources.world-image-cameras.json` | Mixed: ODOT TripCheck public images (ODOT permits use with credit) and MLIT site content under terms compatible with CC BY 4.0. Frames are not bundled or persisted; the server holds a bounded in-memory cache. | Per-source terms apply; NZTA Journey Planner web-cache rows are excluded; Vegagerðin rows are served by the Iceland pack instead | Oregon Department of Transportation (Camera courtesy of ODOT) and Japan Ministry of Land, Infrastructure, Transport and Tourism (出典:国土交通省 川の防災情報) | | **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" | +| **UCS Satellite Database** UCS-Satellite-Database 5-1-2023 (3,564 records; operator, owner country, purpose) | `ucs_satellites/` | **Free, unrestricted use** (UCS database manual, section 3) | ✅ (acknowledgement requested) | "UCS Satellite Database (UCS-Satellite-Database 5-1-2023), https://www.ucsusa.org/satellite_database" | +| **Imaging sensor pack** (46 Earth-observation platforms: swath, resolution) | `imaging_sensors/` | Individual specification facts from operator and agency pages, cited per entry | ✅ (facts only) | "Imaging sensor swaths: operator and agency specifications" | | **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | | **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) | | **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | +| **Natural Earth populated places** (7,342 points: name, position, rank) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | | **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) | | **US Census Bureau places** (32,629 city, town and CDP polygons) | `us_census_places/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) | | **Who's On First neighborhoods** (61,441 polygons) | `wof_neighborhoods/` | Per source: CC BY, ODC-By, Open Government licences, CC0, PDDL, public domain (folder `ATTRIBUTION.md`) | ✅ (attribution) | "Neighborhood boundaries: Who's On First and contributors"; per-source credits in the folder | @@ -327,6 +330,14 @@ transaction quota. Requires a free `FIRMS_MAP_KEY` (https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it. The former bundled 2026-05-25 snapshot was removed 2026-07-16. +### UCS Satellite Database (`ucs_satellites/`) + +Operator, owner country and purpose for active satellites, keyed by NORAD number, from the Union of Concerned Scientists Satellite Database release **UCS-Satellite-Database 5-1-2023** (data current through 2023-05-01; https://www.ucsusa.org/satellite_database). The satellites layer joins it to CelesTrak positions and SATCAT records to file each satellite as a station, imaging, telecom, Starlink, probe or debris object, and to name its operator and owner. Section 3 of the UCS database manual: "The Database is free and its use is unrestricted. We request that its use be acknowledged and referenced in written materials. References should include the version of the Database that was used, which is indicated by the name of the Excel file, and a link to or URL for the webpage www.ucsusa.org/satellite_database." The app acknowledges it in the Data attribution popover. UCS has paused updates since this release; Starlink rows are dropped (Starlink is classed by name). Build: `scripts/build-ucs-satellites.py`; curation in the folder README. + +### Imaging sensor pack (`imaging_sensors/`) + +Swath widths and ground resolutions for 46 Earth-observation platforms (Landsat, Sentinel, MODIS, VIIRS, GOES, Himawari, Gaofen, CBERS, RADARSAT, TerraSAR-X, COSMO-SkyMed, ALOS, KOMPSAT, SPOT, Pléiades, WorldView, SkySat, PlanetScope, ICEYE, Capella, Umbra and others), compiled 2026-11-04 from the operator and agency pages cited per entry. The orbital view draws each satellite's sensor swath to scale and caps its zoom at that swath. Facts only; no text or images copied. A swath is confirmed on an operator, agency (ESA's eoPortal included) or academic page; resellers are not cited. The CEOS MIM Database (CC BY 4.0) is not yet used. Sources and unverified values: the folder README. + ### Natural Earth physical regions (`natural_earth/`) Curated from the **Natural Earth 10m physical vectors** (https://www.naturalearthdata.com/ — @@ -346,6 +357,16 @@ https://www.naturalearthdata.com/about/terms-of-use/). We credit anyway: "Made w Earth". Registration in the in-app `dataCredits.js` attribution list ships with the resolver wiring (see below). +### Natural Earth populated places (`natural_earth/populated_places.json`) + +Natural Earth 10m populated places (`ne_10m_populated_places_simple`, the same pinned +`nvkelso/natural-earth-vector` commit and SHA-256 as the admin-1 build reads), reduced to one +row per place: name, latitude and longitude rounded to 3 decimals, `scalerank` and `rank_max` +(7,342 places, 229 KB). From 8x the orbital view names the most prominent place within half +its width ("Sichuan, China · near Chengdu"), offline, with the states and provinces pack giving +the division. Public domain; credited "Made with Natural Earth" with the other packs. Rebuild: +`node scripts/build-admin-packs.mjs --only places`; provenance in the folder README. + ### Sky background (`sky/`) Sources, licences, the changes CC BY asks to state, hashes and build commands diff --git a/README.md b/README.md index a11ab8eb7..fa9749edf 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea 7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_ 8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_ -**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe. +**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` CCTV · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe. --- @@ -436,7 +436,7 @@ Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer ad | 📡 **GNSS Integrity** | Regional navigation accuracy scores; anomalies do not confirm interference | adsb.lol, GPSJAM aggregation | 🟢 | | 🔥 **Fire Perimeters** | Incident boundaries and reported containment | WFIGS / NIFC | 🟢 | | 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | -| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 | +| 🛰️ **Satellites** | About 1,300 objects in six classes (Stations, Imaging, Telecom, Starlink, Probes, Debris) with a toggle and a live count each; Starlink loads its whole shell | CelesTrak | 🟢 | | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | diff --git a/changelog.d/1512-world-clock-timeline.md b/changelog.d/1512-world-clock-timeline.md new file mode 100644 index 000000000..a43231150 --- /dev/null +++ b/changelog.d/1512-world-clock-timeline.md @@ -0,0 +1,5 @@ +- One world clock now drives everything with a time of its own: the sky, + the HUD clock, satellites, launches and weather. A timeline along the + bottom of the screen, shown while one of those layers is on, plays, pauses + and scrubs it, with a tick for each launch or frame and a way back to + live. Share links made away from live carry the time (samehkhamis, #1512). diff --git a/changelog.d/1513-timeline-launches-speed-buffering.md b/changelog.d/1513-timeline-launches-speed-buffering.md new file mode 100644 index 000000000..e84d8e205 --- /dev/null +++ b/changelog.d/1513-timeline-launches-speed-buffering.md @@ -0,0 +1,7 @@ +- The timeline brings launches up as the time passes them, offers play + speeds from real time to a week a second, and plays smoothly, holding only + when the next weather frame has yet to load, with a spinner by the time + while it waits. The time rides in a bubble over the handle. The track zooms + with a scroll or a pinch, drags along, and goes to the time under a tap, on + a phone as on a desktop. Zooming out on the globe stops while the globe is + still in view instead of leaving it a speck (samehkhamis, #1513). diff --git a/changelog.d/1524-tools-heading-wrap.md b/changelog.d/1524-tools-heading-wrap.md new file mode 100644 index 000000000..3647bf9a8 --- /dev/null +++ b/changelog.d/1524-tools-heading-wrap.md @@ -0,0 +1,3 @@ +- The agent and voice query tools wrap whole-degree directions into 0–359, so + a vessel course or heading, aircraft course, transit bearing, camera heading + or satellite azimuth that rounds to 360 reads 0 (@quanticsoul4772, #1524). diff --git a/changelog.d/1547-nominatim-cache-mode-windows.md b/changelog.d/1547-nominatim-cache-mode-windows.md new file mode 100644 index 000000000..9a48b1ee6 --- /dev/null +++ b/changelog.d/1547-nominatim-cache-mode-windows.md @@ -0,0 +1,3 @@ +- The Nominatim disk-cache test checks the cache directory and file modes + only where POSIX modes exist, so it no longer always fails on Windows + (quanticsoul4772, #1547). diff --git a/changelog.d/1557-cockpit-ship-bridge.md b/changelog.d/1557-cockpit-ship-bridge.md new file mode 100644 index 000000000..f07545c84 --- /dev/null +++ b/changelog.d/1557-cockpit-ship-bridge.md @@ -0,0 +1,4 @@ +- Ride the selected AIS vessel from its bridge: an eye height by vessel type, + dead reckoning between fixes, a bridge HUD with a 3–24 NM radar, look-around + with the arrow keys, and a waterline read from the scene so river boats sit on + the river rather than at sea level (Denis Groshev, @ZloyS, #1557). diff --git a/changelog.d/1558-satellite-classes.md b/changelog.d/1558-satellite-classes.md new file mode 100644 index 000000000..baaed9bff --- /dev/null +++ b/changelog.d/1558-satellite-classes.md @@ -0,0 +1,4 @@ +- File satellites under six classes, Stations, Imaging, Telecom, Starlink, + Probes and Debris, each with a toggle; all but Starlink show by default. + Classes come from CelesTrak SATCAT, the UCS Satellite Database and name rules + (Denis Groshev, @ZloyS, #1558). diff --git a/changelog.d/1559-cockpit-orbital-view.md b/changelog.d/1559-cockpit-orbital-view.md new file mode 100644 index 000000000..71c5eebd5 --- /dev/null +++ b/changelog.d/1559-cockpit-orbital-view.md @@ -0,0 +1,4 @@ +- Ride a tracked satellite in an orbital view: a track-up nadir camera, 1–8× + zoom, off-nadir tilt with an approximate swath, a CURRENTLY OVER line, and the + owner's flag, operator and TLE identity. `C` no longer enters or leaves first + person; double-click and the entry buttons do (Denis Groshev, @ZloyS, #1559). diff --git a/changelog.d/1560-orbital-sensor-zoom.md b/changelog.d/1560-orbital-sensor-zoom.md new file mode 100644 index 000000000..4d9a2d0db --- /dev/null +++ b/changelog.d/1560-orbital-sensor-zoom.md @@ -0,0 +1,4 @@ +- Zoom imaging satellites past 8×, up to 128×, as far as their sensor's + confirmed swath allows at the live altitude, with a to-scale swath strip, a + SENSOR step, mouse-wheel zoom, imagery pre-loaded ahead of the ground track and + the TLE's age (Denis Groshev, @ZloyS, #1560). diff --git a/changelog.d/1569-perimeters-sub-acre-size.md b/changelog.d/1569-perimeters-sub-acre-size.md new file mode 100644 index 000000000..5ee4ffa98 --- /dev/null +++ b/changelog.d/1569-perimeters-sub-acre-size.md @@ -0,0 +1,3 @@ +- Show a fire perimeter under one acre at its reported size. The incident card + rounded WFIGS sizes to whole acres, so a 0.25-acre fire read "0 ac" + (Rudy Celekli, @rudycelekli, #1569). diff --git a/changelog.d/1580-orbital-ground-focus.md b/changelog.d/1580-orbital-ground-focus.md new file mode 100644 index 000000000..0df7cb384 --- /dev/null +++ b/changelog.d/1580-orbital-ground-focus.md @@ -0,0 +1,4 @@ +- Hold the orbital camera on a mapped site as the satellite passes over it, + from a click on its marker or its Live Signals row, and list nearby sites + and satellites in orbit's Live Signals, where a row focuses a site or rides + a satellite (Denis Groshev, @ZloyS, #1580). diff --git a/changelog.d/1611-fr-catalog-review.md b/changelog.d/1611-fr-catalog-review.md new file mode 100644 index 000000000..dad302092 --- /dev/null +++ b/changelog.d/1611-fr-catalog-review.md @@ -0,0 +1,2 @@ +- French catalog review: missing narrow no-break spaces, apostrophes, one + mistranslated message and the Starlink shell wording (abiyeenzo, #1611). diff --git a/changelog.d/1634-layer-state-registered-only.md b/changelog.d/1634-layer-state-registered-only.md new file mode 100644 index 000000000..4d8eaa897 --- /dev/null +++ b/changelog.d/1634-layer-state-registered-only.md @@ -0,0 +1,3 @@ +- An application built with fewer layers no longer restores, saves or shares a + layer it does not have when a link or saved state from a full build names it + (samehkhamis, #1634). diff --git a/changelog.d/1636-draw-mark-actions.md b/changelog.d/1636-draw-mark-actions.md new file mode 100644 index 000000000..edc3dd6eb --- /dev/null +++ b/changelog.d/1636-draw-mark-actions.md @@ -0,0 +1,4 @@ +- Draw rows take an application's own actions: `createApplicationTools({ + drawMarkActions })` adds buttons to drawn areas' and lines' rows, each given + the shape's `[lon, lat]` geometry, with what it answers shown under the row. + The standalone build passes none and is unchanged (samehkhamis, #1636). diff --git a/changelog.d/1637-orbital-cockpit-followups.md b/changelog.d/1637-orbital-cockpit-followups.md new file mode 100644 index 000000000..984e25d6a --- /dev/null +++ b/changelog.d/1637-orbital-cockpit-followups.md @@ -0,0 +1,4 @@ +- Keep the orbital cockpit's sensor strip steady through a zoom step, load the + satellite owner flags at startup from one generated 15 KB sheet, and name + "Place names" in the loading status while the place-name packs load on first + lookup (Sameh Khamis, @samehkhamis, #1637). diff --git a/changelog.d/795-opensky-compress-cached-body.md b/changelog.d/795-opensky-compress-cached-body.md new file mode 100644 index 000000000..3dff6a908 --- /dev/null +++ b/changelog.d/795-opensky-compress-cached-body.md @@ -0,0 +1,5 @@ +- Compress the live flight snapshot on the wire. `/api/flights` gzips its + cached global snapshot and its regional adsb.lol fallback once per refresh + and sends that frame to clients that offer gzip: about 860 KB becomes about + 250 KB. Other clients get the same bytes uncompressed, and both answers carry + `Vary: Accept-Encoding` (daikaginza, #795). diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index f0bb9e979..e5b337a51 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -163,7 +163,8 @@ local composition mounts them in the same order. Importing the entry performs no acquisition; factory calls create independent cache state. `gods-eye-view/sources/space` owns only fixed upstream URL construction: the -CelesTrak group/TLE query and Launch Library's recent 30-day detailed feed. +CelesTrak group/TLE and SATCAT record queries and Launch Library's recent +30-day detailed feed. Callers supply the group or end date and own validation, credentials, transport, response limits and cache policy. The boundary gate checks this portable entry separately from the Node providers. Satellite rendering and launch replay remain diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index afd4f200e..7a99413ce 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1524,6 +1524,297 @@ retained Cockpit actions cannot restart a disposed controller. Input, subscripti and queued panel work stop before asynchronous layer restoration; final camera and portal cleanup follows that restoration. +### Ship bridge view + +With no aircraft tracked, Cockpit can ride the selected AIS vessel instead. +Entry needs only the layer of the vessel it rides, Live AIS Vessels, and a +selected vessel; Contacts and every other layer may be off, before or during +the bridge (`bridgeEntryAllowed` in `src/contextModePolicy.js`). A Context mode +change still in flight blocks entry until it settles. Double-clicking the +selected vessel enters it, and the entry button reads BRIDGE with a ship icon +both in Contacts (`#cockpit-entry`) and under the selected vessel's Intel HUD +readout in the bottom-right corner (`#cockpit-entry-readout`, shown with the +HUD on, in its Tactical and Cyber layouts). Turning Live AIS Vessels off ends +the bridge: the layer then has no subject (`getCockpitSubject` returns null). +The bridge picture uses AIS alone, so no other feed being off makes it partial. +Voice (`control_cockpit`) and the selection entry transaction stay aircraft-only; +voice `enter` on a bridge or in an orbital view is refused before it touches +tracking, with the reason to exit it first. +The vessel layer supplies the subject (`getCockpitSubject`), hides that ship's +sprite, card, detection bracket and trail while it is held +(`setCockpitSubject`), and +ignores scene clicks so open water cannot deselect it. The eye sits on the AIS +position at a representative bridge height for the AIS type family +(`src/layers/vessels/cockpitSubject.js`: cargo and tanker 30 m, passenger 25 m, +tug 7 m, fishing 6 m, ferry and AIS "Other" (codes 90–99, or a feed's text +type "Other") 4 m, sailing and pleasure 3 m; 12 m when the type names no +family, including the reserved and wing-in-ground codes the vessel layer also +files under OTHER), pitched 2° down with 2 m clearance over the shared ground floor +(`src/cockpitSubjects.js`). The camera faces true heading (course over ground +when heading is missing) and moves along course over ground. A ship reporting +neither has no direction: the camera keeps the heading it had on entry, the +heading readout shows `---`, the visor reads NO HEADING · AIS, the entry signal +names the ship without a heading, no bow or stern mark is drawn, and the eye +stays on the fix rather than moving along a made-up course. The vessel layer +redraws a ship only on its 60-second refresh, so the camera's target is the last +fix dead-reckoned along course over ground for up to 180 s, and the existing +inertial anchor absorbs each new fix. The fix's age is read without comparing +the two clocks (`vesselFixAgeSec`): the time since this client first received +the fix, plus how far it trailed that refresh's newest fix, so a client clock +running fast or slow neither projects the ship nor marks it stale, and a fix +the feed keeps re-serving from its cache goes on ageing into STALE FEED. A +stale vessel, or a projection past that limit, holds the camera on the target. +The vessel layer floats ships on the geoid, which on a river or lake can lie +well below the water (the Seine through Paris is ~27 m up), so the bridge also +reads the rendered surface around its ship: the ship's position and a ring of +eight probes 30 m out, the lowest probe taken as the water when it is 2 m to +4,000 m above sea level. The first read starts from the tiles already loaded, then +the full-detail read replaces it; it repeats every 5 s. Only one full-detail +read runs at a time: while one is still running, a refresh reads the loaded +tiles instead, and the slow read applies whenever it lands. Once a +full-detail read has answered, loaded-tile reads no longer change the height; +before then they may find raised water but never take it back to the sea. +A read that sampled nothing, or failed, keeps the height it had and is +tried again on the next refresh. + +The HUD keeps its frame, vision styles, briefing, weather and Display/Radio +utilities. It names the ship, reads `{type} · LIVE AIS · EYE {height}`, shows +speed over ground in knots, true heading and course over ground in place of +altitude, hides the altitude tape, and shows the crew-entered AIS destination as +a Reported Voyage card without an origin or direction arrow. The arrow keys +look around the bridge in 5° steps (Shift for 45°): Left and Right turn the +view all the way round, Up and Down pitch it up to ±90°, and the camera eases +there unless the user prefers reduced motion. Home or `0`, or the LOOK AHEAD +button shown while the view is turned, bring it back over the bow at the rest +pitch. The keys stay with a focused text field, slider, tab, radio group, list +or menu. The heading tape is a straight strip that scrolls continuously with +the camera, so the caret reads the view's exact heading, while the readout +above it keeps the ship's true heading; a ring with a centre dot marks the bow +(prograde) and a ring with a cross the stern (retrograde) at their bearings +from the caret. The bridge radar +shows nearby AIS ships heading-up and opens at 3 NM +(`DEFAULT_RADAR_RANGE_NM` in `src/cockpitRadar.js`); its range button steps +3, 6, 12 and 24 NM and the chosen range is kept across bridges. Own ship +sits where the camera rides (its fix dead-reckoned) and contacts at their +last fixes, where the vessel layer draws them, so the scope matches the view +out of the window. Contact +Previous/Next and Live Signals aircraft handoffs are refused on the bridge and +the Contact card stays hidden. Exit returns the camera to the vessel's map view +through the layer's own focus. Tracking an aircraft or losing the vessel +selection ends the bridge without refocusing the ship. `src/ui/cockpitBridge.test.mjs` +covers entry, readouts, refusal and exit. + +### Orbital view + +With a satellite tracked and no aircraft tracked, Cockpit can ride the +satellite instead. Entry needs only the Satellites layer +(`orbitEntryAllowed` in `src/contextModePolicy.js`); Contacts and every other +layer may be off. A Context mode change in flight blocks entry until it +settles. Debris (rocket bodies, fragments and dead satellites; see the +satellite class toggles) can be tracked but never entered: no ORBIT entry is +offered and double-click refuses it. A tracked aircraft still takes +entry first, and a tracked satellite +takes it before a selected vessel. Double-clicking the tracked satellite +enters it, and the entry button reads +ORBIT with a satellite icon in Contacts (`#cockpit-entry`) and under the Intel +HUD readout (`#cockpit-entry-readout`). Voice (`control_cockpit`) stays +aircraft-only; its state reports an orbital view in use as a `satellite` +subject. `gev:cockpit-mode-changed` carries layer `satellites` and the NORAD +id. + +The camera sits on the satellite's own per-frame SGP4 position +(`getCockpitPosition`, the sample the dot uses) and looks at the Earth's +centre track-up: the ground track points to the top of the frame, so the +picture scrolls along it (`nadirOrientation` in `src/cockpitOrbital.js`). A +satellite with no ground track (a geostationary one) is shown north-up, and +over a pole the previous frame's up is kept. It updates on every rendered +frame, with no ground clamp. Entry opens in the plain optical vision style +(the other styles stay one click away) and leases the map for flat imagery: +with Google 3D tiles shown, which stay coarse from hundreds of kilometres up, +the map switches to Esri imagery, and exit puts it back unless the operator +picked another map meanwhile; another owner already holding the map (an +imagery comparison) keeps it as it is. While orbit holds the lease, Recent +Imagery cannot take it: turned on in orbit, the layer reports the comparison +in use until, after exit, it is turned off and on again or its shown days +are cleared and picked again. The Cockpit cloud pass is off in orbit +(it fades out above 24 km); cloud cover from orbit is the Weather layer's, +and the saved cloud preference stands for the next aircraft or bridge. The layer hides the satellite's dot, docked +companions, its own orbit ring and its detection bracket, and draws every +other orbit ring (the ISS ring included) at 30% of its alpha; other +satellites' points and labels stay. Scene clicks and the layer's Escape are +ignored by the satellites layer while it is ridden (a click on a mapped +site focuses it; see below). The optical zoom steps 1×, 2×, 4× and 8× for +every satellite with `+` (or `=`) and `-` (or `_`), the panel's buttons and +the mouse wheel (one step per notch; trackpad scrolling is summed to a step): +the 1× frame is 60° vertical in low orbit and fits the Earth's disk with +margin from high orbits, each step divides the tangent of the half-angle, and +Cesium's `frustum.fov` is set from it for the canvas aspect. A step animates +over 400 ms with a focus-pull blur and a reticle lock tick, and cuts at once +under `prefers-reduced-motion`. An imaging satellite matched to an entry of +the bundled sensor pack (`src/data/local_data/imaging_sensors/`, by NORAD +number or name in `src/layers/satellites/sensors.js`) whose narrowest swath +is confirmed also gets 16×, 32×, 64× and 128×, each only while the view at +the satellite's live altitude and the window's shape is still at least as +wide as that swath (`orbitalMaxZoom` in `src/cockpitOrbital.js`); when the +cap falls below the zoom shown, the view steps back to it. Unconfirmed +swaths add no steps, but are still drawn, read out and offered to SENSOR. +The sensor's swath is drawn to scale as a strip along the ground track (its +width follows the view through a zoom step, not ahead of it) with +a SENSOR SWATH readout, and a SENSOR button zooms to exactly that width when +it lies between 1× and the cap. No strip is drawn for a geostationary view +(no ground track; its sensors image areas, not strips) or a SAR (it images +to one side of nadir, not under it); the readout stays. Tilted off nadir +the strip stays (view and swath widen alike), the readout scales as the swath +readout does (`SENSOR SWATH ≈ 191 KM`), and SENSOR, a nadir framing, is +hidden; tilted past the Earth's limb neither the sensor nor the swath +reads. A swath is confirmed only on the operator's, an agency's (ESA's +eoPortal included) or an academic page for its platform. From +16×, with flat imagery on the globe (not Google 3D tiles) and the view at +nadir, every 2 s the view asks the base imagery layer for the tiles it will +need 5 to 20 s ahead along the ground track (`prefetchImageryAhead` in +`src/ui/cockpitOrbitalView.js`), so the browser has them cached when the +view arrives. The level is the one Cesium will draw there +(`imageryLevelForView` in `src/cockpitOrbital.js`, following Cesium's own +choice: the terrain level whose error spans at most the globe's maximum +screen-space error, then the imagery level whose texels are nearest that +error, about one to two screen pixels per texel). It asks for at most 12 +tiles at a time through Cesium's request scheduler, throttled per server and +at the lowest priority, so a request is declined while the imagery server's +slots are taken by the tiles on screen and is the first dropped when the +scheduler is full (a declined or dropped tile is asked for again later, one that failed to load is not); it remembers the last 512 tiles asked for and does not ask +for those again, starts over on another base map (cancelling the old map's +pending requests), and cancels the requests still pending on exit. Across the track it samples the centre line, a third +out and the frame's edges. While tilted off nadir the view looks away from +the track, and with the world clock more than a minute from now (paused or +played back) it will not pass where the look-ahead points, so nothing is +pre-loaded. The arrows tilt the view off nadir in 5° steps +along the frame's axes (Up/Down tilt forward/aft along the ground track, +Left/Right across it, each axis to ±45°; Shift takes no larger step); Home, +`0` or NADIR return to nadir. While tilted the panel shows the total angle +from nadir, acos(cos along · cos across), before each axis +(`OFF-NADIR 18.0° · FWD 10° · RIGHT 15°`; N, S, E and W for a north-up view) +and a NADIR button, and the swath readout turns approximate +(`SWATH ≈ 26 KM`): an estimate of the ground width at the view's centre, the +nadir footprint scaled by 1/cos² of the tilt across the frame and 1/cos of +the tilt along it (`offNadirSwathScale`, flat Earth). At nadir it reads the +footprint without ≈. After a zoom step lands, the focus pull holds until the +imagery under the view has loaded (at most 2.5 s, `zoomSettleHolding`), then +the reticle locks. The look keys and zoom keys keep the bridge's focus +guards. + +In orbit the briefing's Live Signals page (OBSERVED / MAPPED PINGS) lists +what is around the satellite, as the aircraft Cockpit lists the contacts +of its Contacts snapshot (`src/ui/cockpitOrbitalSignals.js`): five rows at +most, rebuilt once a second with stable keys, so a row keeps its place and +time. TRACK ACQUIRED stays first. Then: + +- **Sites**: the Mapped Installations layer's loaded records around the + sub-satellite point (`getNearby`, asked only as far as the reach extends + over the ground, and never past 3,000 km, so from a high orbit only the + sites within 3,000 km of the point below are listed) that face the + satellite and lie within the off-nadir reach, nearest to nadir first, the focused site always among them. A row + reads the site's name over its class and off-nadir angle; its button + (`Keep the camera on …`) focuses the site, and on the focused site's row, + marked as the subject is (`FOCUS · …`, `Release the camera from …`), ends + the focus. With Mapped Installations off the layer keeps its records, so + nothing is listed then: a SITES STANDBY row says to turn the layer on. +- **Satellites**: the nearest shown satellites to the ridden one, debris + left out (the satellites layer's bounded `getNearestPositions`, measured + from the satellite itself), with their bearing from the top of the frame + and distance. A row (`Ride …`) rides that satellite. While there is a + satellite ridden before this one, a RETURN · *name* row goes back to it; if + that satellite has left the catalogue it is dropped and RETURN offers the + one before. Riding stays in orbit: the satellites layer tracks the new + satellite (`trackById`), the Cockpit takes the camera back at once and + marks it as the ridden subject, any ground focus ends with the view easing + back to nadir, and the camera hops to the new satellite along the great + circle over 1.2 s (cut under reduced motion), keeping the zoom. Until the + camera is on the new satellite the page shows only TRACK ACQUIRED and + RETURN (and SITES STANDBY with Mapped Installations off), then rebuilds + at once. The new satellite's TRACK ACQUIRED carries the time it was + ridden. The hop starts from where the camera was, which does not follow + the old satellite on. + +Sites take two rows while satellites fill the rest, more when there are +fewer satellites. The Contact card stays hidden in orbit. + +The focus (`src/ui/cockpitOrbitalFocus.js`, geometry in +`src/cockpitOrbitalFocus.js`) aims the look each frame from the satellite's +position to the site's Earth-fixed position in the same track-up nadir +frame the arrows tilt, so the site stays at the centre as the satellite +moves. The turn onto it (and between sites) eases what is left of the swing +with the arrows' easing on a slower clock (250 ms time constant, about +0.75 s to 95%), then holds the site exactly, and cuts under +`prefers-reduced-motion`. A single click on a site's marker focuses it too: +the pick is resolved through the layer's own pick ids (`findByPick`: a named +marker's `{ installationId }` or a site entity). A click anywhere else +(a site beyond the reach included), the +focused site's row, NADIR, Home or `0` ends the focus and the view eases +back to nadir on the same clock; an arrow ends it (or the ease back) and +tilts on from where the view is. The layer reloads its thinned view as the +camera moves (about once a second in orbit), so a site in view can leave +its records for a moment: while the layer is on, a site seen in the last +10 s stays a candidate (sites do not move), so the rows hold still and a +row's site can be focused, and a focused site keeps its last position. The focus ends by +itself, easing back, when the site leaves the reach (either tilt axis past +±45°, or the site below the satellite's horizon), when the layer is turned +off, when another satellite is ridden, and with the orbital view. A site +beyond the reach cannot be focused. While focused the readout gives the +single off-nadir angle to the site, as image metadata does +(`OFF-NADIR 23.4°`, updated on the HUD's beat and not announced), names it +in a polite live region (`FOCUS · Camp Alpha`), and the swath reads +approximate as for a tilt, scaled by the tilt onto the site. Clicks still +reach Cesium's own handler and never untrack the satellite; a click a tool +owns (drawing) is ignored, and the draw tool still borrows the click while +it draws. A double-click on a site keeps or starts the focus on it and +never reaches Cesium's double-click, which would track the site and end the +view (the Cockpit's double-click routing in `src/ui/cockpitInput.js`); any +other double-click goes on as before. `src/ui/cockpitOrbitalFocus.test.mjs` +covers the rows, the easing in and out, the reach release, switching sites +and satellites, the hop, the layer turned off, click handling and the +readout, against the layers' method shapes. + +The HUD (`#cockpit-hud[data-subject='satellite']`, body class +`cockpit-orbit`) mounts `CockpitOrbital` over the picture: name, class, +altitude, orbital speed, sub-satellite point, TRACK UP (NORTH UP without a +ground track) with a pointer that turns to keep north marked N on the frame, +zoom and swath. Under +the vision selector a "CURRENTLY OVER" band names what the sub-satellite point +is over, looked up offline from the bundled Natural Earth packs +(`src/data/placeBelow.js`: on land the first-level division as Natural Earth +names it and the country in the reader's language through `Intl.DisplayNames`, +"Sichuan, China", and from 8× the most prominent populated place within half +the view's smaller side, so it is in the picture, "Sichuan, China · near +Chengdu", from a bundled pack of Natural Earth populated places loaded on +first use (each pack loads on the first lookup that needs it, and the +top-centre loading status lists "Place names" until it arrives); else the +smallest named sea, gulf, strait, channel, sound or bay; +else the ocean; nothing in a coastline gap), at most every 2 s and only after +20 km of ground travel, or at once when a zoom step changes how much ground +the view shows. A division or populated-places pack that fails to load drops +only that part (the country still shows); a lookup that fails outright hides +the band rather than leave the last place up, and the next HUD beat looks +again. The readout card puts +the owner country's flag (drawn from one 15 KB sheet of every owner +country's flag, built from `flag-icons` by `scripts/build-owner-flags.mjs` +and loaded at startup, so a ride never waits for a flag; if the sheet will +not load the country code shows as a badge) or a text badge for an organisation before the name, the operator +under it, and a NORAD · COSPAR · TLE-epoch line, ending with the element +set's age (`<1 H OLD`, then whole hours, then whole days: the gap, either +side, between the epoch and the world-clock time the satellite is shown at), whose TLE button +unfolds the two element lines; each row is left out when the satellites layer does not +know it. The +aircraft and bridge instruments, tapes, compass, radar, look controls, +position readout, route card and Contact card are hidden; the vision-style +switcher, briefing card (Live Signals as above, Local and Regional for the sub-satellite point; the +regional brief refreshes at most once a minute) and the reset and EXIT ORBIT +buttons stay, square-cornered like the bridge. Exit (`Escape`, EXIT +ORBIT) restores the camera's own field of view exactly and returns to the +satellite's follow camera through the layer's `refocusTrackedById`. Losing +the subject (tracking stopped, Satellites off) or tracking another target +ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs` +covers entry, camera, zoom, look, HUD and exit. + ## Context coordination Context controls own Contacts/Space Missions state, entry and exit transactions, @@ -3414,7 +3705,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > It uses the `radar` symbol; both tabs are reachable with Tab and Left/Right arrows switch between them. Its action row > places the single Cockpit entry before Search Nearby Sites. Cockpit removes > the duplicate floating map entry and topline exit; the bottom-center -> `EXIT COCKPIT` control (offset downward by a `-95px` bottom margin) plus `Escape`/`C` own exit, with entry/exit focus +> `EXIT COCKPIT` control (offset downward by a `-95px` bottom margin) plus `Escape` own exit, with entry/exit focus > transfer and failure-safe shortcut routing. The exit control sits at the > bottom-center compass position. A Cockpit-only control strip sits > directly above Live Signals, anchored 12px under the REC readout it shares @@ -4368,7 +4659,7 @@ its criteria cannot be silently ignored. | Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles | | Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | | Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog | -| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | +| Satellites | CelesTrak TLE + SATCAT; bundled UCS pack (purpose) | `src/data/satellites.js` | `/api/celestrak` + `/api/celestrak-satcat` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Iceland (Vegagerðin / IRCA) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | @@ -4416,6 +4707,10 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot. Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. +The incident card rounds the WFIGS size to whole acres, except under one acre, +where it keeps the reported hundredths: a 0.25-acre fire reads `0.25 ac`, not +`0 ac`. + Prohibited & Restricted Airspace is off by default (share-link token `8`) and sits in Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last @@ -5373,11 +5668,13 @@ silently demoting every later lookup for the session. - **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.” - **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder. - **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them. -- **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode. -- **Satellite classes** (`src/data/satelliteClass.js`): every satellite is classified from the CelesTrak group it was ingested with — no extra fetch and no heuristics — into STATION (warm white), NAV (cyan; GPS/GLONASS/Galileo deliberately share one color so the GNSS family reads as one thing), GEO (violet), VISUAL (muted blue-gray catch-all), and COMMS (dim slate, dense Starlink shell only). That module is the single source of truth for class, label, and color, so the dot, the card, and the legend swatch cannot disagree. Two deliberate palette rules: no class may sit in the 40–48° amber band, which is the app-wide known-military convention; and COMMS stays far below VISUAL in Rec.601 luminance so the dense shell stays separable when NVG/FLIR collapse the scene to one channel. The ISS keeps its long-standing red hero dot rather than the STATION color — it carries a permanent name label and its card still reads `STATION · ISS`. `satelliteClassOf` is the single owner of that ISS rule, so the card label and the legend tally can never disagree: during a stations-feed outage the ISS is ingested as `visual`, and both surfaces still file it under STATION. -- Class is also a **text field**, not just a color: `NAV · GPS` / `GEO` / `COMMS · STARLINK` / `STATION · ISS` leads the tracked card's detail block and replaces the raw CelesTrak tag on the detection-overlay label. Because that canvas composites above the post-FX chain, the class stays readable in NVG/FLIR after the in-scene dot colors are flattened. -- The satellites row in DATA LAYERS carries per-layer sub-controls (`DataLayerManager._syncRowControls`, the first consumer of the optional `getRowControls()` layer hook): a **DENSE** chip exposing the existing `catalog` param, and a swatch legend with live per-class counts. Default is the sparse core catalog. The chip is stateless — it declares the params to apply and the manager owns the write — so the Space Missions capture/restore path over the same param stays authoritative. Controls stay hidden while the layer is off. An explicit CORE or DENSE choice participates in versioned local and share-link state; temporary Space Missions overrides do not. -- **The DENSE chip reports the dense LOAD, not the catalog param.** The param flips synchronously while the Starlink shell takes seconds to arrive over a chunked load, and CelesTrak 502s that feed regularly. So the chip reads `DENSE ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `DENSE ✕` with the reason on hover when the load fails — a failure also reverts `catalog` to `core`, drops any partial chunk, and leaves the chip clickable to retry. A load is judged by points added, not by HTTP status: a 200 carrying an empty body, a passed-through HTML error page, or only TLEs the core catalog already owns fails with the same revert semantics as a 502. Any explicit request for `core` clears a latched error even when the mode does not change, so a Space Missions restore of an already-core snapshot never leaves the user with a failure they did not cause. Because the load settles asynchronously, the layer pushes a re-render through the optional `setRowControlsListener()` hook; nothing else would repaint that row before the 5-minute catalog refresh, so the count and legend would otherwise sit stale. +- **Satellites**: core catalog from CelesTrak's stations, visual, GPS, GLONASS, Galileo, GEO, resource, weather, planet, SAR and science groups, tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode. +- **Satellite classes** (`src/layers/satellites/classes.js`): every satellite is filed under one of six classes, each with its own point colour, legend swatch and toggle: STATIONS (warm white), IMAGING (cyan), TELECOM (violet; communications and navigation, so GPS, GLONASS, Galileo and BeiDou read as one family), STARLINK (dim slate), PROBES (muted blue-gray) and DEBRIS (receding brown-gray). That module is the single source of truth for class, label and colour, so the dot, the card and the legend swatch cannot disagree. Two deliberate palette rules: no class may sit in the 40–48° amber band, which is the app-wide known-military convention; and STARLINK stays far below PROBES in Rec.601 luminance so the dense shell stays separable when NVG/FLIR collapse the scene to one channel. The ISS keeps its long-standing red hero dot rather than the STATIONS colour — it carries a permanent name label and its card still reads `STATIONS · ISS`; it is a station whichever group ingested it, so a stations-feed outage cannot refile it. `src/data/satelliteClass.js` keeps the group-based labels beneath the classes: the constellation subtype and a group's own label for a satellite with no class. +- Class is also a **text field**, not just a color: `TELECOM · GPS` / `STARLINK` / `STATIONS · ISS` leads the tracked card's detail block and replaces the raw CelesTrak tag on the detection-overlay label. Because that canvas composites above the post-FX chain, the class stays readable in NVG/FLIR after the in-scene dot colors are flattened. +- The satellites row in DATA LAYERS carries per-layer sub-controls (`DataLayerManager._syncRowControls`, the first consumer of the optional `getRowControls()` layer hook): one chip per class (the STARLINK chip exposes the existing `catalog` param), a swatch legend with live per-class counts, and an info line while SATCAT is stale or unavailable (`Classes may lag: SATCAT is a cached copy` / `Classes may lag: SATCAT unavailable`). Default is the sparse core catalog. The chips are stateless — each declares the params to apply and the manager owns the write — so the Space Missions capture/restore path over the same param stays authoritative. Controls stay hidden while the layer is off. An explicit CORE or DENSE choice participates in versioned local and share-link state; temporary Space Missions overrides do not. +- **Class toggles** (`src/layers/satellites/classes.js`): the layer row carries one chip per class — STATIONS, IMAGING, TELECOM (communications and navigation), PROBES (science, technology development and demonstration, unknown purpose) and DEBRIS (rocket bodies, fragments, unknown objects launched before last year and dead payloads) on by default, so the layer shows what it did before classes; STARLINK off, as DENSE was. Points of a hidden class stay built and are only hidden, so a toggle needs no reload; hidden classes are left out of the detection overlay, the voice/framing position lists and analyst answers (the tracked satellite still answers), and the legend keys only the classes on screen. The class comes, strongest evidence first, from CelesTrak SATCAT (object type and operational status, fetched per group through `/api/celestrak-satcat/`, cached a day by the proxy; the layer keeps a current read six hours rather than asking on every five-minute update), the bundled UCS Satellite Database pack (UCS-Satellite-Database 5-1-2023: operator, owner country and purpose; `src/data/local_data/ucs_satellites/`), name patterns for satellites launched after May 2023, and finally the CelesTrak groups the TLE came in (a mission group such as `weather` before the orbit group `geo`; a UCS purpose of "Unknown" counts as none). Only crewed stations and the craft that visit them, by name, are STATIONS: the CubeSats CelesTrak's stations group also carries after their deployment from the ISS are classed like any other satellite. A payload whose SATCAT record gives no status, with no UCS record and no recognisable name, that launched 20 or more years ago (SATCAT's launch date, else the TLE designator's year) is presumed dead and filed as debris; without a SATCAT record (a failed read) nothing is presumed. To cover imaging launched after the UCS snapshot the core catalog also reads CelesTrak's `resource`, `weather`, `planet`, `sar` and `science` groups. Debris can be tracked but is never ridden: the orbital entry gate (`orbitEntryAllowed`) refuses it, so no ORBIT entry is offered and double-click does nothing for it. Retired stations and the shuttles have re-entered and have no orbit to show. An explicit class choice is kept in local and share-link state as the `classes` option, one letter per class shown (`sitpd`: stations, imaging, telecom, probes, debris; `0`: none; `src/data/satelliteClassMask.js`), and the layer takes those letters back through `setParams({classes})`; STARLINK maps to the existing `catalog: 'dense'` param instead. +- The satellite Cockpit subject (`getCockpitSubject()`) also carries `satelliteClass`, `cosparId` (from SATCAT, else TLE line 1), `tle` (`{line1, line2, epochMs}`), `owner` (`{countryCode, label}`: ISO 3166-1 alpha-2 for a single country, `null` code for organisations such as ESA, EUMETSAT or Intelsat and for joint owners) and `operator` (UCS; SpaceX for Starlink). UCS names the operator's country and wins over the SATCAT owner code; CelesTrak's CIS (former USSR) is filed under Russia. +- **The STARLINK chip (formerly DENSE) reports the dense LOAD, not the catalog param.** The param flips synchronously while the Starlink shell takes seconds to arrive over a chunked load, and CelesTrak 502s that feed regularly. So the chip reads `STARLINK ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `STARLINK ✕` with the reason on hover when the load fails — a failure also reverts `catalog` to `core`, drops any partial chunk, and leaves the chip clickable to retry. A load is judged by points added, not by HTTP status: a 200 carrying an empty body, a passed-through HTML error page, or only TLEs the core catalog already owns fails with the same revert semantics as a 502. Any explicit request for `core` clears a latched error even when the mode does not change, so a Space Missions restore of an already-core snapshot never leaves the user with a failure they did not cause. Because the load settles asynchronously, the layer pushes a re-render through the optional `setRowControlsListener()` hook; nothing else would repaint that row before the 5-minute catalog refresh, so the count and legend would otherwise sit stale. - **A dependency owner takes the row with it.** Space Missions borrows this layer for TLE lookup with `showPoints:false`; while points are hidden the layer returns empty row controls, so the legend never describes an empty sky and the chip cannot accept a write that the owner's restore would silently revert. - The detection-overlay record cache (`_detectionObjects`) is cleared with the catalog on every rebuild: it stamps id/class at creation only, and a rebuild can re-tag a satellite when a partial CelesTrak outage changes which group wins dedupe. - **Catalog numbers above 99999** can be written in a TLE as Alpha-5: a letter A–Z, skipping I and O, for the first two digits, so `A0000` is 100000 and `Z9999` is 339999. CelesTrak's TLE feed carries no satellite above 99999 (that needs the OMM feed, #1191), so this is compatibility with Alpha-5 TLEs. `src/sources/tle.js` decodes the field through one shared helper: `noradIdFromSatnum()` for the satellites layer, which keys and deduplicates its catalog by that number, and `tleCatalogNumber()` for the satellite tools. Plain digits read as before (`noradIdFromSatnum()` accepts any length, as an OMM satrec carries the number unpadded); a malformed field — a sign, decimal point, hex, exponent, embedded space, stray or lowercase letter, or blank — is rejected rather than partly read, as `NaN` in the layer and `null` in the tools. @@ -5627,6 +5924,13 @@ and unreachable upstream (502/504) separately from road geometry. explicit-empty form); unknown option tokens are ignored. Restoration settles independently per layer so one failed or unavailable source cannot block its siblings. +- An application that registers fewer layers (an embedder with a smaller + catalog) keeps only its own: `LayerStateCoordinator` drops enabled-layer ids + the application did not register before it normalizes a share, saved state or + explicit change, so such a layer is never restored, written to local storage + or carried into a link this application shares. Options that depend on a + dropped layer (a tracked selection) go with it. An application with every + layer registered is unchanged. - Layer tokens are permanent public compatibility identifiers. The reservation JSON ledger at `src/data/layerStateTokenReservations.json` pins all existing one-character assignments (all letters plus `0`, `1` and @@ -6010,7 +6314,8 @@ request viewbox and continues searching without that geographic preference. - UTILITIES ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (icon buttons for Area, Line and Pin) and a colour swatch for the next shape, click the vertices on the real world, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone. - **Drawn shapes are listed under Draw like Import's files** (`src/ui/views/panels/DrawMarkList.vue`, rows worded by `presentDrawMarks` in `src/annotations/drawMarks.js`): one row per hand-drawn mark (`manual: true`; voice marks are not listed) with its colour swatch, its name, and show/hide, fly-to and remove. The draw tool publishes the rows with the rest of its controls and answers the row actions (`renameDrawMark`, `recolorDrawMark`, `toggleDrawMark`, `flyToDrawMark`, `removeDrawMark`) through the display store; it republishes on the engine's `onChange`, so a voice clear or an expiry updates the list. Rows are keyed and updated in place: a board change never takes a name being typed (the field keeps its draft while focused) or the button under a click. The name is saved on Enter or on leaving the field, once; Esc restores it, before anything else and with the row's colours open too. Both keep focus in the field, so neither drops it to the page or leaves Draw. The swatch opens the five colours right after it (shown at the end of the row), with the current colour focused; the arrows, Home and End try another, and a click or Esc closes them and returns focus to the swatch. Both colour groups (the next shape's and a row's) are one `DrawSwatches` radio group with a single tab stop, coloured from `DRAW_COLOR_CSS`; on touch screens each dot answers a 24 px target. An unnamed shape shows its kind, and an unnamed line its length; a renamed line keeps its length (`Ridge walk — 950 m`). **Hidden** keeps a mark on the board at alpha 0, and the world renderer takes every one of its entities out (`show = false`), so no material that keeps its own colour (the line's flowing stroke) still paints it; the screen renderer does not project a hidden callout, so it cannot push a visible label aside; `fly_route` skips a hidden route; and with every mark hidden the annotations render hold is released. **Fly to** is an immediate navigation (`runImmediateNavigation('drawing', …)`), like Import's: Cockpit refuses it and a running orbit, follow or track lets go first. A line is framed by its whole path, across the antimeridian too. -- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations, navigation, publish, connect })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its controls are Vue views in the UTILITIES panel (`DrawControls.vue`, `DrawMarkList.vue`) that render what the tool publishes to the display store and call its actions. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam. +- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations, navigation, publish, connect, markActions })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its controls are Vue views in the UTILITIES panel (`DrawControls.vue`, `DrawMarkList.vue`) that render what the tool publishes to the display store and call its actions. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam. +- **Row actions are an application's own.** `createApplicationTools({ drawMarkActions })` hands `initDrawTool` buttons for drawn areas' and lines' rows; the standalone shell passes none, so the open-source build shows no new button and makes no new request. Each action (`drawMarkActions` in `src/annotations/drawMarks.js`) is `{ id, icon, label(name), shapes: ['area', 'line'], accepts?(geometry, shape), run({ id, shape, name, title, geometry }) }`: the icon a Material Symbols name, the label worded by the application, `name` the person's own and `title` the row's (the name, else `Area` or `Line · 950 m`), the geometry `drawMarkGeometry`'s `[lon, lat]` `Polygon` (closed; a `MultiPolygon` for an area handle with several parts) or `LineString`, longitudes wrapped into [-180, 180]. A pin, and a shape that crosses the antimeridian or has a part that cannot be given whole, has no geometry and is offered nothing. Malformed actions and repeated ids are dropped once, at construction. What `run` returns or resolves to is a note under the row (`role=status`; past 200 characters it is cut at a word with an ellipsis); the row is busy (`aria-disabled`, no second press) until it settles, a rejection or a throw clears the note and is logged (so an action words its own refusals and resolves with them), and a shape's note goes with the shape, so a late answer never lands on a removed one. - Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, appear in `.list()`, and clear with `clear_annotations` / `.clear()`. A drawn shape is never merged with another as a duplicate: every one is drawn on purpose, and every one is unnamed until its row names it, so two pins dropped close together are two pins. - **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it. - **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen". @@ -6186,6 +6491,7 @@ apply to `public/` assets or built-preview serving. - `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded. - `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity. - `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. +- `/api/flights` negotiates gzip. The cached global snapshot and the regional adsb.lol fallback are each gzipped once per refresh (about 860 KB becomes about 250 KB). Clients that offer gzip get that frame; other clients, and bodies under 1 KiB or that gzip cannot shrink, get identity bytes. Both answers carry `Vary: Accept-Encoding`. Uncached bodies, such as auth failures, are never sent under a cached frame's encoding. - Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (400 for an invalid hex, otherwise `503 history_unavailable` with `Retry-After: 1800` and `Cache-Control: no-store`, no upstream request; military tracking uses its locally accumulated trail). - Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. - `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message. @@ -6270,21 +6576,24 @@ apply to `public/` assets or built-preview serving. `src/contextModePolicy.js`). Contacts is not required, and the other aircraft layer, Live AIS Vessels and every other layer may be turned on or off before or during a session. A Context mode change still in flight blocks entry until - it settles. Turning off the layer the view rides ends it. Riding an aircraft - while Contacts is on adopts its layer as the user's own, as a direct selection - does (`adoptLayerVisibility`), so Contacts ending, by its own switch or one of - its layers going off, restores the pre-Contacts layers but keeps the ridden - one and the view; Live Signals then drops the rows Contacts built and shows - `CONTEXT STANDBY`. The visible Cockpit actions, the `C` shortcut and voice use - the same gate; voice entry still opens Contacts first, as its layer-targeted - entry navigates through Contacts. -- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session. + it settles. Turning off the layer the view rides ends it. Riding an aircraft, + or a vessel from its bridge, while Contacts is on adopts its layer as the + user's own, as a direct selection does (`adoptLayerVisibility`), so Contacts + ending, by its own switch or one of its layers going off, restores the + pre-Contacts layers but keeps the ridden one and the view; Live Signals then + drops the rows Contacts built and shows `CONTEXT STANDBY`. The visible + Cockpit actions, double-click and voice use the same gate; voice entry still + opens Contacts first, as its layer-targeted entry navigates through Contacts. + The ship bridge and the orbital view have their own own-layer gates + (`bridgeEntryAllowed`, `orbitEntryAllowed`; see + [Ship bridge view](#ship-bridge-view) and [Orbital view](#orbital-view)). +- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `Escape` or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session. - **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. -- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. +- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls are the way in and out; Cockpit claims no `C` shortcut, so `C` keeps toggling CCTV, also inside a first-person view. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. - Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`. -- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`. +- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts, and the query tools' vessel `course_deg` and `heading_deg`, aircraft `course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite `rise_azimuth_deg` and `azimuth_deg`, round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`. - **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material. diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md index 5728d1ec3..bf69653f9 100644 --- a/docs/SHARE-LINK-FORMAT.md +++ b/docs/SHARE-LINK-FORMAT.md @@ -119,6 +119,7 @@ probability of about 255 in 256 (many of them already fail as base64url). | 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 | +| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z | 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. @@ -132,6 +133,10 @@ style also extends the pinned style-defaults table. `hudVariant` code 1 (`operator`) is retired: the app no longer offers that layout and restores it as `tactical`. The code stays reserved. +Field 28 is the world time the link was made at, written only when it was away +from live. A link without it opens live. Unlike field 8 it may lie ahead of the +link's creation (a forecast moment), and it opens paused at that time. + Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3 `celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on). diff --git a/index.html b/index.html index f5e3adc83..291b99708 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 dd60d3383..9ab7b43f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@happy-dom/global-registrator": "^20.14.6", "@vitejs/plugin-vue": "^5.2.4", "@vue/test-utils": "^2.5.1", + "flag-icons": "7.5.0", "happy-dom": "^20.14.6", "prettier": "3.9.6", "puppeteer": "^25.16.0", @@ -2411,6 +2412,13 @@ } } }, + "node_modules/flag-icons": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz", + "integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==", + "dev": true, + "license": "MIT" + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", diff --git a/package.json b/package.json index ffc554d7b..76808c084 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@happy-dom/global-registrator": "^20.14.6", "@vitejs/plugin-vue": "^5.2.4", "@vue/test-utils": "^2.5.1", + "flag-icons": "7.5.0", "happy-dom": "^20.14.6", "prettier": "3.9.6", "puppeteer": "^25.16.0", @@ -90,6 +91,7 @@ "./infrastructure/lod": "./src/data/localGeojsonLod.js", "./application": "./src/app/application.js", "./application/viewer": "./src/app/viewer.js", + "./time": "./src/time/worldClock.js", "./build/vite": { "node": "./build/vite.js" }, diff --git a/scripts/build-admin-packs.mjs b/scripts/build-admin-packs.mjs index d7f647384..9fe319ee0 100644 --- a/scripts/build-admin-packs.mjs +++ b/scripts/build-admin-packs.mjs @@ -7,13 +7,16 @@ * Natural Earth 10m admin-1 states and provinces (public domain). * src/data/local_data/us_census_counties/counties.json * US Census Bureau cartographic boundary counties (public domain). + * src/data/local_data/natural_earth/populated_places.json + * Natural Earth 10m populated places as points, for the orbital view's + * "near " (`src/data/placeBelow.js`; public domain). * * Both sources are pinned by URL and SHA-256, so a rerun reproduces the packs * byte for byte. Downloads are cached outside the repository (default: the * system temp directory) and are never committed. * * Usage: - * node scripts/build-admin-packs.mjs [--cache ] [--only countries|admin1|counties] + * node scripts/build-admin-packs.mjs [--cache ] [--only countries|admin1|counties|places] * * The provenance READMEs beside each pack record these parameters. */ @@ -101,6 +104,9 @@ export const PARAMS = Object.freeze({ elsewhereMinPopulation: 50_000, cityStateMaxKm2: 1_000, }, + // Every populated place, kept as a point: name, position and the two + // prominence ranks the orbital view picks "near " by. + places: { decimals: 3 }, }); const args = process.argv.slice(2); @@ -688,6 +694,47 @@ async function buildAdmin1() { }; } +async function buildPlaces() { + const params = PARAMS.places; + const source = await fetchPinned('places'); + const factor = 10 ** params.decimals; + const round = (value) => Math.round(value * factor) / factor; + const places = JSON.parse(source.bytes.toString('utf8')) + .features.map((f) => f.properties) + .filter( + (p) => + p.name && + Number.isFinite(p.latitude) && + Number.isFinite(p.longitude) && + Number.isFinite(p.scalerank), + ) + .map((p) => [ + p.name, + round(p.latitude), + round(p.longitude), + p.scalerank, + Number.isFinite(p.rank_max) ? p.rank_max : 0, + ]) + .sort((a, b) => a[3] - b[3] || b[4] - a[4] || a[0].localeCompare(b[0])); + return { + meta: { + title: 'Natural Earth populated places', + source: 'ne_10m_populated_places_simple', + url: SOURCES.places.url, + sha256: source.digest, + commit: NE_COMMIT, + license: + 'Public domain (https://www.naturalearthdata.com/about/terms-of-use/)', + schema: + 'places[]: [name, latitude, longitude, scalerank (0 most prominent .. 10), rank_max (population class, 0..14; higher is larger)]', + decimals: params.decimals, + curation: params, + script: 'scripts/build-admin-packs.mjs', + }, + places, + }; +} + async function buildCounties() { const params = PARAMS.counties; const source = await fetchPinned('counties'); @@ -768,4 +815,13 @@ if (isMain) { 'src/data/local_data/us_census_counties/counties.json', await buildCounties(), ); + if (!only || only === 'places') { + const relative = 'src/data/local_data/natural_earth/populated_places.json'; + const pack = await buildPlaces(); + const text = JSON.stringify(pack) + '\n'; + await writeFile(path.join(ROOT, relative), text); + console.log( + `${relative}: ${pack.places.length} places, ${Buffer.byteLength(text)} bytes`, + ); + } } diff --git a/scripts/build-owner-flags.mjs b/scripts/build-owner-flags.mjs new file mode 100644 index 000000000..9ca7ed083 --- /dev/null +++ b/scripts/build-owner-flags.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/** + * Build the owner flag sheet the orbital Cockpit marks a satellite's owner + * country with (`src/ui/ownerFlag.js`): every flag in OWNER_COUNTRY_CODES + * (`src/layers/satellites/classes.js`), drawn from the flag-icons 4:3 SVGs + * at twice the readout's 20 × 15 px, in one PNG the app loads at startup. + * + * src/data/local_data/owner_flags/owner_flags.png the sheet + * src/ui/ownerFlagSheet.js its layout (generated) + * + * Flag i (in the sorted code list) sits at column i % COLUMNS, row + * floor(i / COLUMNS), each cell followed by a GUTTER of transparent pixels. A rerun with the same flag-icons and sharp versions + * reproduces both files. + * + * Usage: node scripts/build-owner-flags.mjs + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as prettier from 'prettier'; +import sharp from 'sharp'; +import { OWNER_COUNTRY_CODES } from '../src/layers/satellites/classes.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const require = createRequire(import.meta.url); +const FLAGS_DIR = path.join( + path.dirname(require.resolve('flag-icons/package.json')), + 'flags/4x3', +); +const FLAG_ICONS_VERSION = require('flag-icons/package.json').version; + +/** One flag's cell, CSS pixels × 2. */ +export const CELL = Object.freeze({ width: 40, height: 30 }); +/** + * Transparent sheet pixels after each flag, so a browser smoothing a scaled + * flag (a 125% display) never reaches into its neighbour. + */ +export const GUTTER = 2; +export const COLUMNS = 16; + +const SHEET = path.join( + ROOT, + 'src/data/local_data/owner_flags/owner_flags.png', +); +const LAYOUT = path.join(ROOT, 'src/ui/ownerFlagSheet.js'); + +async function flagCell(code) { + const svg = await readFile(path.join(FLAGS_DIR, `${code.toLowerCase()}.svg`)); + return sharp(svg, { density: 300 }) + .resize(CELL.width, CELL.height, { fit: 'fill' }) + .png() + .toBuffer(); +} + +const codes = [...OWNER_COUNTRY_CODES]; +const rows = Math.ceil(codes.length / COLUMNS); +const pitchX = CELL.width + GUTTER; +const pitchY = CELL.height + GUTTER; +const cells = await Promise.all(codes.map(flagCell)); +const sheet = await sharp({ + create: { + width: COLUMNS * pitchX, + height: rows * pitchY, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, +}) + .composite( + cells.map((input, i) => ({ + input, + left: (i % COLUMNS) * pitchX, + top: Math.floor(i / COLUMNS) * pitchY, + })), + ) + .png({ compressionLevel: 9, palette: true, quality: 100, effort: 10 }) + .toBuffer(); +await writeFile(SHEET, sheet); + +const layout = `// Generated by scripts/build-owner-flags.mjs from flag-icons ${FLAG_ICONS_VERSION}; do not edit. +/** The owner flag sheet's layout: flag i at column i % columns, row i / columns. */ +export const OWNER_FLAG_SHEET = Object.freeze({ + cellWidthPx: ${CELL.width}, + cellHeightPx: ${CELL.height}, + gutterPx: ${GUTTER}, + columns: ${COLUMNS}, + rows: ${rows}, + codes: Object.freeze(${JSON.stringify(codes)}), +}); +`; +await writeFile( + LAYOUT, + await prettier.format(layout, { + ...(await prettier.resolveConfig(LAYOUT)), + filepath: LAYOUT, + }), +); +console.log( + `Wrote ${codes.length} flags (${COLUMNS} × ${rows}), ${(sheet.length / 1024).toFixed(1)} KB.`, +); diff --git a/scripts/build-ucs-satellites.py b/scripts/build-ucs-satellites.py new file mode 100644 index 000000000..86fc9063e --- /dev/null +++ b/scripts/build-ucs-satellites.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +'''Maintainer-only UCS Satellite Database pack build (openpyxl). + +Reads the UCS Excel release and writes the compact pack the satellites layer +loads: one row per satellite, keyed by NORAD number, with string-table indexes +for the repeated text columns. Starlink rows are left out: the layer files +every STARLINK object under its own class and operator without them. + +Usage: python scripts/build-ucs-satellites.py "/UCS-Satellite-Database 5-1-2023.xlsx" +''' +import argparse +import json +import logging +from pathlib import Path +import re + +import openpyxl + +LOG = logging.getLogger('build-ucs-satellites') + +ROOT = Path(__file__).resolve().parent.parent +PACK = ROOT / 'src/data/local_data/ucs_satellites' +VERSION = 'UCS-Satellite-Database 5-1-2023' +DATA_CURRENT_TO = '2023-05-01' +URL = 'https://www.ucsusa.org/satellite_database' + +# Output column -> spreadsheet header. +COLUMNS = { + 'operator': 'Operator/Owner', + 'operatorCountry': 'Country of Operator/Owner', + 'purpose': 'Purpose', +} +STARLINK = re.compile(r'\bstarlink\b', re.I) + + +def clean(value: object) -> str: + '''One cell as tidy text; UCS's placeholders for nothing become ''.''' + text = re.sub(r'\s+', ' ', str(value or '')).strip() + return '' if text.lower() in ('', 'none', 'n/a') else text + + +def build(xlsx: Path) -> dict: + '''Read the release's first sheet into the pack the layer loads.''' + sheet = openpyxl.load_workbook(xlsx, read_only=True, data_only=True).worksheets[0] + rows = sheet.iter_rows(values_only=True) + header = {str(h).strip(): i for i, h in enumerate(next(rows)) if h} + names = [header['Current Official Name of Satellite'], header['Name of Satellite, Alternate Names']] + norad_col = header['NORAD Number'] + tables = {key: [''] for key in COLUMNS} + index = {key: {'': 0} for key in COLUMNS} + records = {} + source_rows = skipped_starlink = invalid_norad = 0 + for row in rows: + if not any(row): + continue + source_rows += 1 + if any(STARLINK.search(clean(row[i])) for i in names): + skipped_starlink += 1 + continue + try: + norad = int(float(row[norad_col])) + except (TypeError, ValueError): + invalid_norad += 1 + continue + if norad <= 0: + invalid_norad += 1 + continue + record = [norad] + for key, column in COLUMNS.items(): + text = clean(row[header[column]]) + if text not in index[key]: + index[key][text] = len(tables[key]) + tables[key].append(text) + record.append(index[key][text]) + # A duplicate NORAD number keeps the first row, as UCS lists it. + records.setdefault(norad, record) + ordered = [records[n] for n in sorted(records)] + return { + 'meta': { + 'source': 'Union of Concerned Scientists Satellite Database', + 'version': VERSION, + 'url': URL, + 'dataCurrentTo': DATA_CURRENT_TO, + 'license': 'Free, unrestricted use; acknowledgement requested (UCS database manual, section 3)', + 'curation': { + 'sourceRows': source_rows, + 'starlinkRowsDropped': skipped_starlink, + 'invalidNoradDropped': invalid_norad, + 'duplicateNoradDropped': source_rows - skipped_starlink - invalid_norad - len(ordered), + 'columns': COLUMNS, + }, + 'recordCount': len(ordered), + }, + 'fields': ['noradId', *COLUMNS], + 'strings': tables, + 'records': ordered, + } + + +def main() -> None: + '''Build the pack from the release given on the command line.''' + logging.basicConfig(level=logging.INFO, format='%(message)s') + parser = argparse.ArgumentParser(description='Build the UCS satellite pack from the UCS Excel release.') + parser.add_argument('xlsx', type=Path, help='UCS-Satellite-Database .xlsx') + pack = build(parser.parse_args().xlsx) + PACK.mkdir(parents=True, exist_ok=True) + out = PACK / 'ucs_satellites.json' + # LF on every platform: the repository stores text with LF endings. + out.write_text( + json.dumps(pack, ensure_ascii=False, separators=(',', ':')) + '\n', + encoding='utf-8', + newline='\n', + ) + LOG.info('%s: %d records, %d bytes', out.relative_to(ROOT), pack['meta']['recordCount'], out.stat().st_size) + + +if __name__ == '__main__': + main() diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b23ab75b0..2bb7a20ae 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -328,6 +328,12 @@ "src/ui/styles/responsive.css", "src/ui/styles/voice-cost.css", "src/ui/styles/cockpit.css", + "src/ui/styles/cockpitBridge.css", + "src/ui/styles/cockpitRadar.css", + "src/ui/styles/cockpitOrbital.css", + "src/layers/satellites/sensors.js", + "src/layers/satellites/sensors.test.mjs", + "src/data/imagingSensors.js", "src/ui/styles/command-dock-compact.css", "src/app/stateChannel.js", "src/app/stateChannel.test.mjs", @@ -1120,6 +1126,7 @@ "src/sky/starrySky.test.mjs", "src/tooling/skyAssets.test.mjs", "src/tooling/skyAssets.paintOut.test.mjs", + "src/tooling/responseCompression.test.mjs", "scripts/qa-anime-sky.mjs", "scripts/qa-sky-model-lighting.mjs", "server/providers/overpass/requestAdmission.test.mjs", @@ -1357,5 +1364,10 @@ "src/layers/wildlifeSightings/cache.integration.test.mjs", "src/layers/wildlifeSightings/density.test.mjs", "src/layers/wildlifeSightings/selection.test.mjs", - "server/providers/inaturalist/proxy.fixture.mjs" + "server/providers/inaturalist/proxy.fixture.mjs", + "scripts/build-owner-flags.mjs", + "src/ui/ownerFlagSheet.js", + "src/data/placePacksLoading.test.mjs", + "src/ui/placePacksLoading.js", + "src/ui/placePacksLoading.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e224cd87e..521973631 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -39,6 +39,10 @@ "src/ui/views/panels/CockpitBriefing.vue", "src/ui/views/panels/CockpitContext.vue", "src/ui/views/panels/CockpitRoute.vue", + "src/ui/views/panels/CockpitRadar.vue", + "src/ui/views/panels/CockpitOrbital.vue", + "src/ui/ownerFlag.js", + "src/ui/ownerFlagSheet.js", "src/ui/cockpitPresentation.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", @@ -155,10 +159,19 @@ "src/ui/stores/recording.js", "src/ui/styleParameters.js", "src/ui/i18n.js", + "src/time/worldClock.js", + "src/ui/kit/UiIcon.vue", + "src/ui/stores/worldTime.js", + "src/ui/views/panels/WorldTimeline.vue", "src/ui/surfaceKeyboard.js" ], "external": ["vue", "pinia", "vue-i18n"] }, + "time": { + "exports": ["./time"], + "modules": ["src/time/worldClock.js"], + "external": [] + }, "viewer": { "exports": ["./application/viewer"], "modules": [ @@ -192,6 +205,7 @@ "modules": [ "src/sources/adsbFields.js", "server/providers/live.js", + "server/providers/common/compress.js", "server/providers/common/http.js", "server/providers/common/query.js", "server/providers/aircraft/opensky.js", @@ -890,6 +904,19 @@ "src/ui/cockpitInstrumentState.js", "src/ui/cockpitRollingValue.js", "src/ui/cockpitCamera.js", + "src/ui/cockpitOrbitalView.js", + "src/ui/cockpitOrbitalFocus.js", + "src/ui/cockpitOrbitalSignals.js", + "src/ui/cockpitOrbitalSatellites.js", + "src/cockpitOrbitalSatellites.js", + "src/layers/installations/classLabel.js", + "src/data/installationNames.js", + "src/i18n/marker.js", + "src/data/placeBelow.js", + "src/layers/satellites/sensors.js", + "src/data/adminBoundaries.js", + "src/data/bundledJson.js", + "src/data/retryableLoad.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", @@ -904,6 +931,13 @@ "src/cockpitMath.js", "src/cockpitUtilityLayout.js", "src/cockpitVisionPolicy.js", + "src/cockpitSubjects.js", + "src/cockpitRadar.js", + "src/cockpitLook.js", + "src/cockpitOrbital.js", + "src/cockpitOrbitalFocus.js", + "src/geoDistance.js", + "src/data/motionModel.js", "src/ui/panelRailGeometry.js", "src/i18n/index.js", "src/i18n/locale.js", @@ -972,6 +1006,13 @@ "src/cockpitTracking.js", "src/cockpitUtilityLayout.js", "src/cockpitVisionPolicy.js", + "src/cockpitSubjects.js", + "src/cockpitRadar.js", + "src/cockpitLook.js", + "src/cockpitOrbital.js", + "src/cockpitOrbitalFocus.js", + "src/geoDistance.js", + "src/data/motionModel.js", "src/contactsDetectionPolicy.js", "src/contextModePolicy.js", "src/data/detectionPolicy.js", @@ -979,6 +1020,7 @@ "src/data/idSwitches.js", "src/data/firmsIntensity.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/data/militaryAwarenessEngine.js", @@ -1021,6 +1063,20 @@ "src/ui/cockpitInstrumentState.js", "src/ui/cockpitRollingValue.js", "src/ui/cockpitCamera.js", + "src/ui/cockpitOrbitalView.js", + "src/ui/cockpitOrbitalFocus.js", + "src/ui/cockpitOrbitalSignals.js", + "src/ui/cockpitOrbitalSatellites.js", + "src/cockpitOrbitalSatellites.js", + "src/layers/installations/classLabel.js", + "src/data/placeBelow.js", + "src/ui/placePacksLoading.js", + "src/ui/ownerFlag.js", + "src/ui/ownerFlagSheet.js", + "src/layers/satellites/sensors.js", + "src/data/adminBoundaries.js", + "src/data/bundledJson.js", + "src/data/retryableLoad.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", @@ -1179,6 +1235,7 @@ "src/ui/stores/globeActions.js", "src/ui/stores/recording.js", "src/ui/i18n.js", + "src/time/worldClock.js", "src/ui/kit/UiSegmented.vue", "src/ui/wildlifeSightingCard.js", "src/layers/wildlifeSightings/records.js", @@ -1200,6 +1257,8 @@ "src/ui/styles/location.css", "src/ui/styles/status.css", "src/ui/styles/cockpit.css", + "src/ui/styles/cockpitBridge.css", + "src/ui/styles/cockpitRadar.css", "src/ui/styles/overlays.css", "src/ui/styles/layers.css", "src/ui/styles/radio.css", @@ -1358,6 +1417,7 @@ "src/data/idSwitches.js", "src/data/firmsIntensity.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", "src/data/aircraftModelLighting.js", @@ -1399,7 +1459,9 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/ui/unitLabels.js" + "src/ui/unitLabels.js", + "src/time/worldClock.js", + "src/time/julian.js" ], "external": ["cesium"] }, @@ -1421,6 +1483,7 @@ "src/data/idSwitches.js", "src/data/firmsIntensity.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", "src/data/aircraftModelLighting.js", @@ -1456,7 +1519,9 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js", + "src/time/julian.js" ], "external": ["cesium"] }, @@ -1475,6 +1540,7 @@ "src/data/idSwitches.js", "src/data/firmsIntensity.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -1515,6 +1581,8 @@ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js", "src/layers/vessels/snapshotRenderer.js", + "src/layers/vessels/cockpitSubject.js", + "src/geoDistance.js", "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", @@ -1614,9 +1682,11 @@ "exports": ["./layers/satellites"], "modules": [ "src/data/satelliteClass.js", + "src/data/satelliteClassMask.js", "src/data/satellitePass.js", "src/sources/tle.js", "src/layers/satellites/catalog.js", + "src/layers/satellites/cockpitSubject.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", "src/layers/satellites/ingestion.js", @@ -1645,7 +1715,14 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/ui/unitLabels.js" + "src/ui/unitLabels.js", + "src/layers/satellites/classes.js", + "src/layers/satellites/sensors.js", + "src/data/ucsSatellites.js", + "src/data/imagingSensors.js", + "src/data/bundledJson.js", + "src/data/retryableLoad.js", + "src/time/worldClock.js" ], "external": ["cesium", "satellite.js"] }, @@ -1685,7 +1762,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js" ], "external": ["cesium"] }, @@ -2010,7 +2088,8 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/layers/streetLevel/providers/mapillary/policy.js" + "src/layers/streetLevel/providers/mapillary/policy.js", + "src/time/worldClock.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] }, @@ -2058,6 +2137,7 @@ "src/data/idSwitches.js", "src/data/firmsIntensity.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateTokenReservations.json", "src/data/radioCountry.js", "src/layers/radio/catalogModel.js", @@ -2383,6 +2463,11 @@ "src/cockpitTracking.js", "src/cockpitUtilityLayout.js", "src/cockpitVisionPolicy.js", + "src/cockpitSubjects.js", + "src/cockpitRadar.js", + "src/cockpitLook.js", + "src/cockpitOrbital.js", + "src/cockpitOrbitalFocus.js", "src/contactsDetectionPolicy.js", "src/contextModePolicy.js", "src/creditKeyboard.js", @@ -2447,6 +2532,7 @@ "src/data/labelArbiter.js", "src/data/idSwitches.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/data/lifecycle.js", @@ -2676,6 +2762,7 @@ "src/layers/radio/tuningNoise.js", "src/layers/radio/volume.js", "src/layers/satellites/catalog.js", + "src/layers/satellites/cockpitSubject.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", "src/layers/satellites/ingestion.js", @@ -2825,6 +2912,15 @@ "src/ui/cockpitInstrumentState.js", "src/ui/cockpitRollingValue.js", "src/ui/cockpitCamera.js", + "src/ui/cockpitOrbitalView.js", + "src/ui/cockpitOrbitalFocus.js", + "src/ui/cockpitOrbitalSignals.js", + "src/ui/cockpitOrbitalSatellites.js", + "src/cockpitOrbitalSatellites.js", + "src/data/placeBelow.js", + "src/ui/placePacksLoading.js", + "src/ui/ownerFlag.js", + "src/ui/ownerFlagSheet.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", @@ -2944,6 +3040,7 @@ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js", "src/layers/vessels/snapshotRenderer.js", + "src/layers/vessels/cockpitSubject.js", "src/data/inputOwnership.js", "src/annotations/drawMarks.js", "src/annotations/drawMode.js", @@ -3284,6 +3381,9 @@ "src/ui/stores/globeActions.js", "src/ui/stores/recording.js", "src/ui/i18n.js", + "src/time/viewerClock.js", + "src/time/worldClock.js", + "src/time/julian.js", "src/annotations/outlineRungs.js", "src/sources/openFreeMapOutlines.js", "src/sources/nominatimOutlines.js", @@ -3329,7 +3429,11 @@ "src/ui/wildlifeSightingPlacement.js", "src/overlays/worldOverlayChrome.js", "src/layers/wildlifeSightings/photoText.js", - "src/layers/wildlifeSightings/performance.js" + "src/layers/wildlifeSightings/performance.js", + "src/layers/satellites/classes.js", + "src/layers/satellites/sensors.js", + "src/data/ucsSatellites.js", + "src/data/imagingSensors.js" ], "external": [ "hls.js", @@ -3441,6 +3545,7 @@ "src/data/labelArbiter.js", "src/data/idSwitches.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateTokenReservations.json", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", @@ -3642,6 +3747,7 @@ "src/layers/radio/tuningNoise.js", "src/layers/radio/volume.js", "src/layers/satellites/catalog.js", + "src/layers/satellites/cockpitSubject.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", "src/layers/satellites/ingestion.js", @@ -3729,6 +3835,7 @@ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js", "src/layers/vessels/snapshotRenderer.js", + "src/layers/vessels/cockpitSubject.js", "src/data/inputOwnership.js", "src/annotations/worldAnnotationRenderer.js", "src/cameraGroundGuard.js", @@ -3946,6 +4053,8 @@ "src/ui/stores/bhoteKoshiPanel.js", "src/hudReadouts.js", "src/ui/i18n.js", + "src/time/worldClock.js", + "src/time/julian.js", "src/annotations/outlineRungs.js", "src/sources/openFreeMapOutlines.js", "src/sources/nominatimOutlines.js", @@ -3979,7 +4088,11 @@ "src/layers/wildlifeSightings/sprites.js", "src/overlays/worldOverlayChrome.js", "src/layers/wildlifeSightings/photoText.js", - "src/layers/wildlifeSightings/performance.js" + "src/layers/wildlifeSightings/performance.js", + "src/layers/satellites/classes.js", + "src/layers/satellites/sensors.js", + "src/data/ucsSatellites.js", + "src/data/imagingSensors.js" ], "external": [ "hls.js", @@ -4113,6 +4226,7 @@ "src/data/idSwitches.js", "src/data/firmsIntensity.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/loadingFeedback.js", @@ -4132,7 +4246,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js" ], "external": [] }, @@ -4145,6 +4260,14 @@ "src/ui/radioPanelPlacement.js", "src/bloom.js", "src/cockpitVisionPolicy.js", + "src/cockpitSubjects.js", + "src/cockpitRadar.js", + "src/cockpitLook.js", + "src/cockpitOrbital.js", + "src/cockpitOrbitalFocus.js", + "src/cockpitOrbitalSatellites.js", + "src/geoDistance.js", + "src/data/motionModel.js", "src/contactsDetectionPolicy.js", "src/data/detectionPolicy.js", "src/styles/anime.js", @@ -4476,7 +4599,15 @@ }, "satellites-source": { "exports": ["./layers/satellites/source"], - "modules": ["src/layers/satellites/source.js"], + "modules": [ + "src/layers/satellites/source.js", + "src/layers/satellites/classes.js", + "src/layers/satellites/sensors.js", + "src/data/ucsSatellites.js", + "src/data/imagingSensors.js", + "src/data/bundledJson.js", + "src/data/retryableLoad.js" + ], "external": [] }, "traffic-source-factory": { @@ -4675,6 +4806,7 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js", "src/overlays/worldOverlayChrome.js" ], "external": ["cesium", "egm96-universal", "pbf"] @@ -4728,7 +4860,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js" ], "external": ["cesium", "pbf"] }, @@ -4845,12 +4978,14 @@ "src/view/index.js", "src/data/firmsIntensity.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateTokenReservations.json", "src/data/idSwitches.js", "src/data/detectionPolicy.js", "src/scopeMaskPolicy.js", "src/i18n/marker.js", - "src/visualStylePolicy.js" + "src/visualStylePolicy.js", + "src/time/worldClock.js" ], "external": [] }, @@ -4970,6 +5105,7 @@ "src/data/idSwitches.js", "src/data/vesselTypes.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateTokenReservations.json", "src/sources/gbfsStations.js", "src/sources/tle.js", @@ -5093,7 +5229,11 @@ "src/layers/alpr/records.js", "src/layers/alpr/tileRecords.js", "src/sources/infrastructureData.js", - "src/sources/eventPacks.js" + "src/sources/eventPacks.js", + "src/layers/satellites/classes.js", + "src/layers/satellites/sensors.js", + "src/data/ucsSatellites.js", + "src/data/imagingSensors.js" ], "external": ["@mapbox/vector-tile", "pbf"] }, @@ -5104,6 +5244,7 @@ "src/data/idSwitches.js", "src/data/firmsIntensity.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateTokenReservations.json", "src/visualStylePolicy.js" ], diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index e00dadfc3..16c345db5 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -409,6 +409,15 @@ async function main() { status: 'ready', })); } + // CelesTrak SATCAT, which the satellite classes read beside the TLEs. + // An empty CSV (header only) keeps the run hermetic: the classes fall + // back to names and groups, as they do when SATCAT is unavailable. + if (isAppRequest && url.pathname.startsWith('/api/celestrak-satcat/')) { + return Promise.resolve(new Response( + 'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,LAUNCH_DATE\n', + { status: 200, headers: { 'Content-Type': 'text/csv' } }, + )); + } // CelesTrak TLE groups. Served from a fixed two-satellite catalog so // the satellite assertions are deterministic and the run never depends // on an upstream that rate-limits (it was answering 403 the night this diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index f7019d752..b1b07c9d2 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -1,6 +1,7 @@ import fs from 'node:fs'; import os from 'node:os'; import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js'; +import { encodedBody, gzipBody } from '../common/compress.js'; import { coalesceProxyRequest, readResponseJsonCapped, @@ -18,6 +19,11 @@ let _openskyTokenExpiry = 0; let _openskyTokenPromise = null; /** @type {string|null} Cached upstream response body (JSON text). */ let _openskyCacheBody = null; +/** + * @type {Buffer|null} Gzip of `_openskyCacheBody`, written together with it. + * Null when the body is not worth compressing. + */ +let _openskyCacheGzip = null; /** @type {number} HTTP status of the cached response. */ let _openskyCacheStatus = 0; /** @type {number} Epoch-ms when the response was cached. */ @@ -399,8 +405,12 @@ async function fetchAdsbLolPointFallback(req) { ADSBLOL_POINT_MAX_RESPONSE_BYTES, ); const normalized = normalizeAdsbLolPointResponse(payload); + const body = JSON.stringify(normalized); const record = { - body: JSON.stringify(normalized), + body, + // Compressed once per entry; ADSBLOL_POINT_CACHE_MAX bounds the + // extra memory. + gzip: await gzipBody(body), cachedAt: Date.now(), count: normalized.states.length, }; @@ -429,6 +439,7 @@ async function fetchAdsbLolPointFallback(req) { async function serveAdsbLolPointFallback(req, res, requestedMode, reason) { const fallback = await fetchAdsbLolPointFallback(req); if (!fallback) return false; + const encoded = encodedBody(req, fallback.body, fallback.gzip); res.writeHead(200, { ...buildOpenSkyHeaders({ cacheStatus: fallback.cacheStatus, @@ -439,8 +450,9 @@ async function serveAdsbLolPointFallback(req, res, requestedMode, reason) { 'X-Flight-Source': 'adsb.lol', 'X-Flight-Coverage': `${ADSBLOL_POINT_RADIUS_NM}nm regional fallback`, 'X-Flight-Count': String(fallback.count), + ...encoded.headers, }); - res.end(fallback.body); + res.end(encoded.body); return true; } @@ -514,9 +526,13 @@ export function openSkyProxy() { reason: 'cached', }; const isStale = now - _openskyCacheTime >= _openskyTtlMs; - res.writeHead( - _openskyCacheStatus || 200, - buildOpenSkyHeaders({ + const encoded = encodedBody( + req, + _openskyCacheBody, + _openskyCacheGzip, + ); + res.writeHead(_openskyCacheStatus || 200, { + ...buildOpenSkyHeaders({ cacheStatus: isStale ? 'STALE' : 'HIT', requestedMode: cachedMeta.requestedMode || requestedMode, usedMode: cachedMeta.usedMode || 'unknown', @@ -532,8 +548,9 @@ export function openSkyProxy() { ? (_openskyCooldownUntil - now) / 1000 : undefined, }), - ); - res.end(_openskyCacheBody); + ...encoded.headers, + }); + res.end(encoded.body); return; } // Cooling down with nothing cached (cold start into a rate limit): @@ -628,7 +645,11 @@ export function openSkyProxy() { // Only cache successful responses — error responses (401/403/429/5xx) // should not be served from cache on subsequent requests if (upstream.ok) { + // Compress before writing either slot, so a request arriving + // mid-refresh still reads a body with its own frame. + const frame = await gzipBody(body); _openskyCacheBody = body; + _openskyCacheGzip = frame; _openskyCacheStatus = upstream.status; _openskyCacheTime = now; _openskyCacheSourceEpochMs = sourceEpochMs; @@ -681,9 +702,13 @@ export function openSkyProxy() { // Serve the last-good body instead of the 429 when we have one — // the layer keeps rendering (STALE-cued) instead of dying. if (_openskyCacheBody && _openskyCacheStatus === 200) { - res.writeHead( - 200, - buildOpenSkyHeaders({ + const encoded = encodedBody( + req, + _openskyCacheBody, + _openskyCacheGzip, + ); + res.writeHead(200, { + ...buildOpenSkyHeaders({ cacheStatus: 'STALE', requestedMode, usedMode, @@ -691,8 +716,9 @@ export function openSkyProxy() { staleSeconds: (now - _openskyCacheTime) / 1000, retryAfterSeconds: cooldownMs / 1000, }), - ); - res.end(_openskyCacheBody); + ...encoded.headers, + }); + res.end(encoded.body); return; } } @@ -734,16 +760,23 @@ export function openSkyProxy() { } } - res.writeHead( - upstream.status, - buildOpenSkyHeaders({ + // The frame belongs to this body only if this body was cached. An + // uncached error must not borrow the previous snapshot's frame. + const encoded = encodedBody( + req, + body, + _openskyCacheBody === body ? _openskyCacheGzip : null, + ); + res.writeHead(upstream.status, { + ...buildOpenSkyHeaders({ cacheStatus: 'MISS', requestedMode, usedMode, reason, }), - ); - res.end(body); + ...encoded.headers, + }); + res.end(encoded.body); } catch (e) { console.error('[OpenSky Proxy]', e.message); if (_openskyCacheBody) { @@ -754,17 +787,22 @@ export function openSkyProxy() { usedMode: 'unknown', reason: 'cached_stale', }; - res.writeHead( - _openskyCacheStatus || 200, - buildOpenSkyHeaders({ + const encoded = encodedBody( + req, + _openskyCacheBody, + _openskyCacheGzip, + ); + res.writeHead(_openskyCacheStatus || 200, { + ...buildOpenSkyHeaders({ cacheStatus: 'STALE', requestedMode: cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT, usedMode: cachedMeta.usedMode || 'unknown', reason: cachedMeta.reason || 'cached_stale', }), - ); - res.end(_openskyCacheBody); + ...encoded.headers, + }); + res.end(encoded.body); return; } const requestedMode = normalizeOpenSkyAuthMode( diff --git a/server/providers/common/compress.js b/server/providers/common/compress.js new file mode 100644 index 000000000..cbce21c6e --- /dev/null +++ b/server/providers/common/compress.js @@ -0,0 +1,81 @@ +import { gzip } from 'node:zlib'; +import { promisify } from 'node:util'; + +const gzipAsync = promisify(gzip); + +/** + * Opt-in gzip for text bodies a proxy already holds in memory. + * + * Not a middleware: `cctv/media.js` streams bodies and answers range requests, + * and several providers forward binary upstream types that gzip only grows. + * `encodedBody` returns the body with its headers so the two cannot disagree. + */ + +/** Skip compression for bodies smaller than 1 KiB. */ +const MIN_COMPRESSED_BYTES = 1024; + +/** + * RFC 9110 section 12.4.2 `qvalue`. Matching the whole token keeps `q=1oops` + * and `q=2` unreadable instead of rounding them into acceptance. + */ +const QVALUE = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/; + +/** + * Whether `Accept-Encoding` offers gzip. Anything unreadable (no header, a bad + * q value, `x-gzip`) reads as no: every client can decode identity. + * + * @param {import('node:http').IncomingMessage} request + * @returns {boolean} + */ +export function acceptsGzip(request) { + const header = request?.headers?.['accept-encoding']; + if (typeof header !== 'string') return false; + return header.split(',').some((part) => { + const [token, ...parameters] = part.split(';'); + if (token.trim().toLowerCase() !== 'gzip') return false; + const quality = parameters + .map((parameter) => parameter.trim().toLowerCase()) + .find((parameter) => parameter.startsWith('q=')); + if (quality === undefined) return true; + // `q=0` names gzip and refuses it. + const value = quality.slice(2); + return QVALUE.test(value) && Number(value) > 0; + }); +} + +/** + * Gzip a body once for a caller that caches it. Async, so the ~860 KB OpenSky + * snapshot does not block other requests while it compresses. + * + * Returns null, meaning "serve identity", when the body is under 1 KiB or gzip + * does not shrink it. + * + * @param {string|Buffer} body - the exact bytes the identity response sends. + * @returns {Promise} + */ +export async function gzipBody(body) { + const raw = Buffer.isBuffer(body) ? body : Buffer.from(body); + if (raw.byteLength < MIN_COMPRESSED_BYTES) return null; + const compressed = await gzipAsync(raw); + return compressed.byteLength < raw.byteLength ? compressed : null; +} + +/** + * Pick the body and the headers that describe it. `Vary` is on both branches + * so a shared cache never serves one encoding to a client that asked for the + * other. + * + * @param {import('node:http').IncomingMessage} request + * @param {string|Buffer} identity - the uncompressed body. + * @param {Buffer|null} compressed - `gzipBody(identity)`, or null. + * @returns {{body: string|Buffer, headers: Record}} + */ +export function encodedBody(request, identity, compressed) { + if (compressed && acceptsGzip(request)) { + return { + body: compressed, + headers: { 'Content-Encoding': 'gzip', Vary: 'Accept-Encoding' }, + }; + } + return { body: identity, headers: { Vary: 'Accept-Encoding' } }; +} diff --git a/server/providers/space/celestrak.js b/server/providers/space/celestrak.js index 013fce1b1..9845da26e 100644 --- a/server/providers/space/celestrak.js +++ b/server/providers/space/celestrak.js @@ -1,6 +1,9 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; -import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js'; +import { + celestrakSatcatUrl, + celestrakTleUrl, +} from '../../../src/data/spaceProviderRequests.js'; import { providerCooldownMs } from './cooldown.js'; import { admitSameSiteKeyless } from '../common/same-site.js'; import { readResponseTextCapped } from '../common/http.js'; @@ -22,84 +25,131 @@ import { readResponseTextCapped } from '../common/http.js'; * 6 h; on upstream failure the freshest stale copy is served (a stale TLE * beats an empty satellites layer). Pattern mirrors openSkyProxy's * cache+serve-stale. Adapted from skylight's TleStore (MIT). + * + * The same plugin serves CelesTrak SATCAT records for the satellites layer's + * groups at `/api/celestrak-satcat/` (object type, operational status + * and owner, which file each object under a class). The catalog changes + * slowly, so SATCAT is cached for a day. CelesTrak throttles the client, not + * the endpoint, so a 403 or 429 holds both routes off; any other failure + * holds off only the route that failed, so a bad SATCAT answer never stops + * TLE refreshes. */ export function celestrakProxy() { const TLE_TTL_MS = 6 * 3600_000; + const SATCAT_TTL_MS = 24 * 3600_000; const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); - const groups = new Set([ + /** The satellites layer's core groups (src/layers/satellites/policy.js). */ + const coreGroups = [ 'stations', 'visual', 'gps-ops', 'glo-ops', 'galileo', 'geo', - 'starlink', - // Space Missions (`getActiveTle`) reads the full active catalog. - 'active', - ]); - let cooldownUntil = 0; - const mem = new Map(); // group -> { at: epochMs, body: string } - const inflight = new Map(); // group -> Promise<{at, body}|null> - - const diskPath = (group) => path.join(CACHE_DIR, `celestrak-${group}.json`); - - async function readDisk(group) { - try { - const parsed = JSON.parse(await fsp.readFile(diskPath(group), 'utf8')); - if (typeof parsed?.body === 'string' && Number.isFinite(parsed?.at)) - return parsed; - } catch { - /* no disk cache yet */ - } - return null; - } - - async function writeDisk(group, entry) { - try { - await fsp.mkdir(CACHE_DIR, { recursive: true }); - await fsp.writeFile(diskPath(group), JSON.stringify(entry), 'utf8'); - } catch (err) { - console.warn('[celestrak-proxy] cache write failed'); - } - } + 'resource', + 'weather', + 'planet', + 'sar', + 'science', + ]; + const feeds = [ + { + route: '/api/celestrak', + ttlMs: TLE_TTL_MS, + cacheHeader: 'x-tle-cache', + cachedAtHeader: 'X-TLE-Cached-At', + diskName: (group) => `celestrak-${group}.json`, + upstreamUrl: celestrakTleUrl, + groups: new Set([ + ...coreGroups, + 'starlink', + // Space Missions (`getActiveTle`) reads the full active catalog. + 'active', + ]), + // An upstream error page parses to zero TLEs — treat as failure, keep cache. + validate: (body) => /^1 /m.test(body), + invalid: 'no TLE lines in response', + }, + { + route: '/api/celestrak-satcat', + ttlMs: SATCAT_TTL_MS, + cacheHeader: 'x-satcat-cache', + cachedAtHeader: 'X-SATCAT-Cached-At', + diskName: (group) => `celestrak-satcat-${group}.json`, + upstreamUrl: celestrakSatcatUrl, + groups: new Set(coreGroups), + // A real answer, even for an empty group, starts with the CSV header. + validate: (body) => /^OBJECT_NAME,/.test(body), + invalid: 'no SATCAT header in response', + }, + ]; + // CelesTrak throttling (403/429) is per client, so it holds every feed + // off; any other failure holds off only the feed that failed, so a bad + // SATCAT answer never blocks the TLEs the layer cannot do without. + let hostCooldownUntil = 0; /** The largest group (`active`) measures ~2.7 MB; this bounds a runaway body. */ const CELESTRAK_MAX_RESPONSE_BYTES = 16 * 1024 * 1024; - async function fetchUpstream(group) { - const url = celestrakTleUrl(group); - const signal = AbortSignal.timeout(20000); - const res = await fetch(url.toString(), { - signal, - // CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a - // descriptive User-Agent with a contact point. - headers: { - 'User-Agent': - 'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)', - }, - }); - if (!res.ok) { - // Group refreshes run in parallel: a later failure without Retry-After - // must never shorten a longer deadline an earlier one already set. - cooldownUntil = Math.max( - cooldownUntil, - Date.now() + providerCooldownMs(res), - ); - void res.body?.cancel().catch(() => {}); - throw new Error(`HTTP ${res.status}`); - } - const body = await readResponseTextCapped( - res, - CELESTRAK_MAX_RESPONSE_BYTES, - signal, - ); - // An upstream error page parses to zero TLEs — treat as failure, keep cache. - if (!/^1 /m.test(body)) throw new Error('no TLE lines in response'); - return { at: Date.now(), body }; - } + function createFeedHandler(feed) { + let feedCooldownUntil = 0; + const cooldownUntil = () => Math.max(hostCooldownUntil, feedCooldownUntil); + const mem = new Map(); // group -> { at: epochMs, body: string } + const inflight = new Map(); // group -> Promise<{at, body}|null> + const diskPath = (group) => path.join(CACHE_DIR, feed.diskName(group)); - const installMiddleware = (server) => { - server.middlewares.use('/api/celestrak', async (req, res) => { + async function readDisk(group) { + try { + const parsed = JSON.parse(await fsp.readFile(diskPath(group), 'utf8')); + if (typeof parsed?.body === 'string' && Number.isFinite(parsed?.at)) + return parsed; + } catch { + /* no disk cache yet */ + } + return null; + } + + async function writeDisk(group, entry) { + try { + await fsp.mkdir(CACHE_DIR, { recursive: true }); + await fsp.writeFile(diskPath(group), JSON.stringify(entry), 'utf8'); + } catch (err) { + console.warn('[celestrak-proxy] cache write failed'); + } + } + + async function fetchUpstream(group) { + const url = feed.upstreamUrl(group); + const signal = AbortSignal.timeout(20000); + const res = await fetch(url.toString(), { + signal, + // CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a + // descriptive User-Agent with a contact point. + headers: { + 'User-Agent': + 'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)', + }, + }); + if (!res.ok) { + // Group refreshes run in parallel: a later failure without Retry-After + // must never shorten a longer deadline an earlier one already set. + const until = Date.now() + providerCooldownMs(res); + if (res.status === 403 || res.status === 429) + hostCooldownUntil = Math.max(hostCooldownUntil, until); + else feedCooldownUntil = Math.max(feedCooldownUntil, until); + void res.body?.cancel().catch(() => {}); + throw new Error(`HTTP ${res.status}`); + } + const body = await readResponseTextCapped( + res, + CELESTRAK_MAX_RESPONSE_BYTES, + signal, + ); + if (!feed.validate(body)) throw new Error(feed.invalid); + return { at: Date.now(), body }; + } + + return async (req, res) => { const group = String(req.url || '') .replace(/^\//, '') .split('?')[0]; @@ -111,7 +161,7 @@ export function celestrakProxy() { res.end(); return; } - if (!groups.has(group)) { + if (!feed.groups.has(group)) { res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('invalid group'); return; @@ -123,15 +173,15 @@ export function celestrakProxy() { if (res.headersSent) return; res.writeHead(status, { 'Content-Type': 'text/plain', - 'x-tle-cache': cacheStatus, + [feed.cacheHeader]: cacheStatus, 'Cache-Control': 'no-store', ...(entry - ? { 'X-TLE-Cached-At': new Date(entry.at).toISOString() } + ? { [feed.cachedAtHeader]: new Date(entry.at).toISOString() } : {}), - ...(cooldownUntil > 0 && Date.now() < cooldownUntil + ...(cooldownUntil() > 0 && Date.now() < cooldownUntil() ? { 'Retry-After': String( - Math.ceil((cooldownUntil - Date.now()) / 1000), + Math.ceil((cooldownUntil() - Date.now()) / 1000), ), } : {}), @@ -145,11 +195,11 @@ export function celestrakProxy() { entry = await readDisk(group); if (entry) mem.set(group, entry); } - if (entry && now - entry.at < TLE_TTL_MS) { + if (entry && now - entry.at < feed.ttlMs) { send(200, entry.body, 'HIT', entry); return; } - if (Date.now() < cooldownUntil) { + if (Date.now() < cooldownUntil()) { send( entry ? 200 : 502, entry?.body || 'celestrak upstream cooling down', @@ -169,8 +219,8 @@ export function celestrakProxy() { return fresh; }) .catch((err) => { - if (Date.now() >= cooldownUntil) - cooldownUntil = Date.now() + 60000; + if (Date.now() >= feedCooldownUntil) + feedCooldownUntil = Date.now() + 60000; console.warn( '[celestrak-proxy] refresh failed — serving cache if any', ); @@ -191,7 +241,13 @@ export function celestrakProxy() { console.error('[celestrak-proxy] request failed'); send(500, 'celestrak proxy error', 'ERROR'); } - }); + }; + } + + const handlers = feeds.map((feed) => [feed.route, createFeedHandler(feed)]); + const installMiddleware = (server) => { + for (const [route, handler] of handlers) + server.middlewares.use(route, handler); }; return { name: 'celestrak-proxy', diff --git a/src/annotations/drawMarks.js b/src/annotations/drawMarks.js index 8a51500b0..725c07aa1 100644 --- a/src/annotations/drawMarks.js +++ b/src/annotations/drawMarks.js @@ -43,24 +43,167 @@ export function drawMarkFallbackName(mark) { : shape; } +/** The kind of shape a row action is offered for: `area` or `line`. */ +const ACTION_SHAPES = Object.freeze({ area: 'area', route: 'line' }); + +const lonLat = (point) => + Array.isArray(point) + ? [Number(point[0]), Number(point[1])] + : [Number(point?.lon), Number(point?.lat)]; +const finitePairs = (points) => + (Array.isArray(points) ? points : []) + .map(lonLat) + .filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat)); +/** A longitude in [-180, 180]; an unwrapped outline can carry ±540. */ +const wrapLon = (lon) => + lon >= -180 && lon <= 180 ? lon : ((((lon + 180) % 360) + 360) % 360) - 180; +/** Whether consecutive positions step across the antimeridian once wrapped. */ +const crossesAntimeridian = (points) => + points.some( + (point, index) => + index > 0 && Math.abs(point[0] - points[index - 1][0]) > 180, + ); +/** Positions wrapped into range, or null when the sequence crosses ±180°. */ +const inRange = (points) => { + if (points.some(([, lat]) => Math.abs(lat) > 90)) return null; + const wrapped = points.map(([lon, lat]) => [wrapLon(lon), lat]); + return crossesAntimeridian(wrapped) ? null : wrapped; +}; +const closed = (ring) => { + if (ring.length < 3) return ring; + const [first, last] = [ring[0], ring[ring.length - 1]]; + return first[0] === last[0] && first[1] === last[1] ? ring : [...ring, first]; +}; + +/** + * A drawn shape's geometry as GeoJSON in `[lon, lat]` degrees, longitudes in + * [-180, 180]: an area is a closed `Polygon` (a `MultiPolygon` when an area + * handle has several parts), a line a `LineString`. A pin, a shape too small + * to be one, or one that crosses the antimeridian has none, so it is offered + * no action rather than a shape that wraps the wrong way round the world. + */ +export function drawMarkGeometry(mark) { + if (mark?.type === 'route') { + const path = inRange(finitePairs(mark.path)); + return path?.length >= 2 ? { type: 'LineString', coordinates: path } : null; + } + if (mark?.type !== 'area') return null; + const ringOf = (points) => { + const ring = inRange(closed(finitePairs(points))); + return ring?.length >= 4 ? ring : null; + }; + if (Array.isArray(mark.polygons) && mark.polygons.length) { + const polygons = []; + for (const rings of mark.polygons) { + const parts = (Array.isArray(rings) ? rings : []).map(ringOf); + // A part that cannot be given whole is no part: never a hole dropped. + if (!parts.length || parts.some((ring) => !ring)) return null; + polygons.push(parts); + } + return polygons.length > 1 + ? { type: 'MultiPolygon', coordinates: polygons } + : { type: 'Polygon', coordinates: polygons[0] }; + } + const ring = ringOf(mark.ring); + return ring ? { type: 'Polygon', coordinates: [ring] } : null; +} + +/** + * The actions an application adds to Draw's rows, checked once: each has an + * `id`, a Material Symbols `icon`, a `label(name)` worded by the application, + * the `shapes` it is for (`area`, `line`), an optional `accepts(geometry)` and + * `run({ id, shape, name, title, geometry })` (`name` the person's own, `title` + * as the row shows it). Malformed or repeated ids are dropped. + */ +export function drawMarkActions(actions = []) { + const kept = new Map(); + for (const action of Array.isArray(actions) ? actions : []) { + const wellFormed = + typeof action?.id === 'string' && + /^[a-z][a-z0-9-]{0,39}$/.test(action.id) && + typeof action.icon === 'string' && + typeof action.label === 'function' && + typeof action.run === 'function' && + Array.isArray(action.shapes); + if (!wellFormed || kept.has(action.id)) continue; + kept.set( + action.id, + Object.freeze({ + id: action.id, + icon: action.icon, + label: action.label, + shapes: Object.freeze( + action.shapes.filter((shape) => + Object.values(ACTION_SHAPES).includes(shape), + ), + ), + accepts: + typeof action.accepts === 'function' ? action.accepts : () => true, + run: action.run, + }), + ); + } + return Object.freeze([...kept.values()]); +} + +/** The actions from `drawMarkActions` that `mark` is offered, with its geometry. */ +export function drawMarkActionsFor(mark, actions = []) { + const shape = ACTION_SHAPES[mark?.type]; + if (!shape || !actions.length) return { shape, geometry: null, actions: [] }; + const geometry = drawMarkGeometry(mark); + if (!geometry) return { shape, geometry, actions: [] }; + return { + shape, + geometry, + actions: actions.filter((action) => { + if (!action.shapes.includes(shape)) return false; + try { + return action.accepts(geometry, shape) === true; + } catch { + return false; + } + }), + }; +} + +/** An action's words for a row; an application's label that throws shows its id. */ +function actionLabel(action, title) { + try { + const text = action.label(title); + return typeof text === 'string' && text.trim() ? text.trim() : action.id; + } catch { + return action.id; + } +} + /** * The rows for the hand-drawn marks on the board, in board order. Text is * worded here, in the current language; the view only lays it out. * @param {object[]} marks The engine's `list()`. + * @param {ReturnType} [actions] The application's row actions. + * @param {Map} [notes] What an action said on a shape. */ -export function presentDrawMarks(marks = []) { +export function presentDrawMarks(marks = [], actions = [], notes = new Map()) { return marks .filter((mark) => mark?.manual === true) .map((mark) => { const fallback = drawMarkFallbackName(mark); const title = mark.name || fallback; const visible = !mark.hidden; + const offered = drawMarkActionsFor(mark, actions).actions; return { id: mark.id, name: mark.name || '', placeholder: fallback, color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary', hidden: !visible, + busy: notes.get(mark.id)?.busy === true, + note: offered.length ? notes.get(mark.id)?.text || '' : '', + actions: offered.map((action) => ({ + id: action.id, + icon: action.icon, + label: actionLabel(action, title), + })), labels: { name: t('Name of {name}', { name: title }), swatch: t('Change the colour of {name}', { name: title }), diff --git a/src/annotations/drawMarks.test.mjs b/src/annotations/drawMarks.test.mjs index 60662abf8..3c5b70377 100644 --- a/src/annotations/drawMarks.test.mjs +++ b/src/annotations/drawMarks.test.mjs @@ -3,7 +3,10 @@ import assert from 'node:assert/strict'; import { DRAW_COLORS, DRAW_COLOR_CSS, + drawMarkActions, + drawMarkActionsFor, drawMarkFallbackName, + drawMarkGeometry, presentDrawMarks, } from './drawMarks.js'; @@ -78,3 +81,219 @@ test('rows list only hand-drawn marks, in board order, with their words', () => test('every colour has its swatch', () => { assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]); }); + +test("a drawn shape's geometry is GeoJSON in [lon, lat]: a closed area, a line, no pin", () => { + assert.deepEqual( + drawMarkGeometry({ + type: 'area', + ring: [ + [0, 0], + [1, 0], + [1, 1], + [0, 0], + ], + }), + { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 0], + ], + ], + }, + ); + assert.deepEqual( + drawMarkGeometry({ + type: 'area', + ring: [ + [0, 0], + [1, 0], + [1, 1], + ], + }).coordinates[0].at(-1), + [0, 0], + 'an open ring is closed', + ); + assert.equal( + drawMarkGeometry({ + type: 'area', + ring: [ + [0, 0], + [1, 0], + ], + }), + null, + ); + assert.deepEqual( + drawMarkGeometry({ + type: 'route', + path: [ + { lon: 1, lat: 2, height: 9 }, + { lon: 3, lat: 4, height: 0 }, + ], + }), + { + type: 'LineString', + coordinates: [ + [1, 2], + [3, 4], + ], + }, + ); + assert.equal( + drawMarkGeometry({ type: 'pin', longitude: 1, latitude: 2 }), + null, + ); + const square = (x) => [ + [x, 0], + [x + 1, 0], + [x + 1, 1], + [x, 0], + ]; + assert.deepEqual( + drawMarkGeometry({ + type: 'area', + ring: square(0), + polygons: [[square(0)], [square(5)]], + }), + { type: 'MultiPolygon', coordinates: [[square(0)], [square(5)]] }, + 'an area handle with several parts keeps them all', + ); + assert.equal( + drawMarkGeometry({ + type: 'area', + polygons: [ + [ + square(0), + [ + [0.2, 0.2], + [0.3, 0.2], + ], + ], + ], + }), + null, + 'a hole that cannot be given whole is never dropped silently', + ); +}); + +test('longitudes are wrapped into range, and a shape across the antimeridian has no geometry', () => { + assert.deepEqual( + drawMarkGeometry({ + type: 'route', + path: [ + [370, 1], + [371, 1], + ], + }).coordinates, + [ + [10, 1], + [11, 1], + ], + ); + assert.deepEqual( + drawMarkGeometry({ + type: 'area', + ring: [ + [180, 0], + [179, 0], + [179, 1], + ], + }).coordinates[0][0], + [180, 0], + 'the antimeridian itself stays put', + ); + assert.equal( + drawMarkGeometry({ + type: 'area', + ring: [ + [179.9, 0], + [-179.9, 0], + [-179.9, 1], + ], + }), + null, + ); + assert.equal( + drawMarkGeometry({ + type: 'route', + path: [ + [179.5, 0], + [180.5, 0], + ], + }), + null, + ); + assert.equal( + drawMarkGeometry({ + type: 'route', + path: [ + [0, 91], + [1, 0], + ], + }), + null, + ); +}); + +test('row actions are checked once: malformed ones and repeated ids are dropped', () => { + const run = () => {}; + const label = () => 'x'; + const actions = drawMarkActions([ + { id: 'Bad Id', icon: 'a', label, run, shapes: ['area'] }, + { id: 'tag', icon: 'a', label: 'not a function', run, shapes: ['area'] }, + { + id: 'tag', + icon: 'sell', + label, + run, + shapes: ['area', 'line', 'pin'], + }, + { id: 'tag', icon: 'other', label, run, shapes: ['area'] }, + null, + ]); + assert.deepEqual( + actions.map(({ id, icon, shapes }) => ({ id, icon, shapes: [...shapes] })), + [{ id: 'tag', icon: 'sell', shapes: ['area', 'line'] }], + ); + assert.deepEqual(drawMarkActions('nope'), []); + const area = { + type: 'area', + ring: [ + [0, 0], + [1, 0], + [1, 1], + ], + }; + const offer = drawMarkActionsFor(area, actions); + assert.equal(offer.shape, 'area'); + assert.deepEqual( + offer.actions.map((a) => a.id), + ['tag'], + ); + const refusing = drawMarkActions([ + { + id: 'measure', + icon: 'a', + label, + run, + shapes: ['area'], + accepts: () => { + throw new Error('no'); + }, + }, + ]); + assert.deepEqual( + drawMarkActionsFor(area, refusing).actions, + [], + 'an accepts that throws offers nothing', + ); + assert.deepEqual( + presentDrawMarks([{ id: 'p', type: 'pin', manual: true }], actions)[0] + .actions, + [], + ); +}); diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index a022fdcc9..f44d30753 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -50,9 +50,28 @@ import { removeLastVertex, } from './drawMode.js'; -import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js'; +import { + DRAW_COLORS, + DRAW_COLOR_CSS, + drawMarkActions, + drawMarkActionsFor, + drawMarkFallbackName, + presentDrawMarks, +} from './drawMarks.js'; import { onLocaleChange, t, translateKnown } from '../i18n/index.js'; +/** The longest note a row action leaves under its row, in characters. */ +const NOTE_MAX = 200; +/** An action's answer as a row note: trimmed, and cut at a word with an ellipsis. */ +function noteText(text) { + if (typeof text !== 'string') return ''; + const trimmed = text.trim(); + if (trimmed.length <= NOTE_MAX) return trimmed; + const cut = trimmed.slice(0, NOTE_MAX - 1); + const space = cut.lastIndexOf(' '); + return `${(space > NOTE_MAX / 2 ? cut.slice(0, space) : cut).trimEnd()}…`; +} + /** The id this tool claims the pointer under. */ const DRAW_POINTER_OWNER = 'draw'; const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview'; @@ -67,7 +86,10 @@ const PREVIEW = DRAW_COLOR_CSS; * through the actions it gives `connect`, which returns their disconnect. * A row's fly-to is an immediate navigation through `navigation`, like * Import's, so Cockpit can refuse it and a running orbit or follow lets go. - * @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps + * `markActions` are the application's own buttons on an area's or a line's + * row (drawMarks.js `drawMarkActions`); a press runs the action with the + * shape's id, kind, name and geometry (drawMarks.js `drawMarkGeometry`). + * @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function, markActions?: object[]}} deps * @returns {{destroy: Function}|null} */ export function initDrawTool({ @@ -76,7 +98,12 @@ export function initDrawTool({ navigation = null, publish = () => {}, connect = () => () => {}, + markActions = [], }) { + const rowActions = drawMarkActions(markActions); + // What a row action said last on each drawn shape: `{ run, busy, text }`. + const notes = new Map(); + let noteRun = 0; // A page without the Draw controls (the Display controls view omitted) has // no draw tool. if (!viewer || !annotations || !document.getElementById('draw-toggle')) @@ -115,14 +142,19 @@ export function initDrawTool({ () => null, ); - const present = () => + const present = () => { + const marks = annotations.list?.() || []; + // A note goes with its shape. + for (const id of notes.keys()) + if (!marks.some((mark) => mark.id === id)) notes.delete(id); publish({ active, shape, color, hint, - marks: presentDrawMarks(annotations.list?.() || []), + marks: presentDrawMarks(marks, rowActions, notes), }); + }; // The hint is kept as the call that words it, so a language switch can // word it again. let hintText = () => ''; @@ -450,6 +482,56 @@ export function initDrawTool({ return annotations.frame?.(id) ?? false; } + /** + * Run the application's row action `actionId` on drawn shape `id`, if the + * shape is still on the board and still offered it. Returns whether it ran. + * What the action returns, or resolves to, is the row's note (a measurement, + * "saved"): the row is busy until it settles, a newer press or the shape's + * removal makes a late answer moot, and a failure clears the note, so an + * action words its own refusals and resolves with them. + */ + function runMarkAction(id, actionId) { + const mark = (annotations.list?.() || []).find( + (entry) => entry.id === id && entry.manual === true, + ); + const offer = drawMarkActionsFor(mark, rowActions); + const action = offer.actions.find((entry) => entry.id === actionId); + if (!action || notes.get(mark.id)?.busy) return false; + const run = ++noteRun; + const settle = (text) => { + if (destroyed || notes.get(mark.id)?.run !== run) return; + notes.set(mark.id, { + run, + busy: false, + text: noteText(text), + }); + present(); + }; + const fail = (error) => { + console.warn(`[draw] ${actionId} failed`, error); + settle(''); + }; + notes.set(mark.id, { run, busy: true, text: '' }); + let answer; + try { + answer = action.run({ + id: mark.id, + shape: offer.shape, + name: mark.name || '', + title: mark.name || drawMarkFallbackName(mark), + geometry: offer.geometry, + }); + } catch (error) { + fail(error); + return false; + } + if (typeof answer?.then === 'function') { + present(); + Promise.resolve(answer).then(settle, fail); + } else settle(answer); + return true; + } + /** Draw `next` from now on; an open shape starts over in it. */ function chooseShape(next) { shape = normalizeShape(next); @@ -478,6 +560,7 @@ export function initDrawTool({ }, flyToDrawMark: flyToMark, removeDrawMark: (id) => annotations.remove?.(id), + runDrawMarkAction: runMarkAction, }); // The rows follow the board: a shape drawn, renamed, hidden or removed // here, by voice or by a clear. @@ -500,13 +583,15 @@ export function initDrawTool({ setActive, setShape: chooseShape, /** The hand-drawn marks the list shows, as its rows. */ - marks: () => presentDrawMarks(annotations.list?.() || []), + marks: () => + presentDrawMarks(annotations.list?.() || [], rowActions, notes), setColor(next) { color = DRAW_COLORS.includes(next) ? next : 'primary'; present(); syncPreview(); }, flyToMark, + runMarkAction, /** Test seam: add a vertex from lon/lat as if it had been clicked. */ addVertex(lon, lat, height = 0) { if (!session) return false; diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index 811142193..4cef4d4fe 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -223,7 +223,7 @@ function fakeAnnotations() { } /** Stand up a tool against fresh doubles, with globals restored afterwards. */ -function harness({ navigation = null } = {}) { +function harness({ navigation = null, markActions = [] } = {}) { resetPointerOwnership(); const { byId, documentDouble } = fakeDom(); const viewer = fakeViewer(); @@ -239,6 +239,7 @@ function harness({ navigation = null } = {}) { viewer, annotations, navigation, + markActions, publish: (view) => (shown.view = view), connect: (actions) => { controls.actions = actions; @@ -576,6 +577,133 @@ test('each drawn shape is a row whose actions reach the board, and fly-to is a n } }); +test('an application\'s row actions show on the shapes they are for and run with the shape', async () => { + const ran = []; + const h = harness({ + markActions: [ + { + id: 'tag', + icon: 'sell', + label: (name) => `Tag ${name}`, + shapes: ['area', 'line'], + run: (shape) => ran.push(['tag', shape]), + }, + { + id: 'measure', + icon: 'straighten', + label: (name) => `Measure ${name}`, + shapes: ['area'], + accepts: (geometry) => geometry.type === 'Polygon', + run: (shape) => ran.push(['measure', shape]), + }, + ], + }); + try { + const board = h.annotations; + board.marks.push( + { id: 'r1', type: 'area', manual: true, name: 'Strait', color: 'primary', hidden: false, ring: [[29, 41], [29.1, 41], [29.1, 41.1]] }, + { id: 'l1', type: 'route', manual: true, name: null, label: '9 km', color: 'amber', hidden: false, path: [{ lon: 29, lat: 41.05, height: 0 }, { lon: 29.1, lat: 41.05, height: 0 }] }, + { id: 'p1', type: 'pin', manual: true, name: 'Camp', color: 'red', hidden: false }, + ); + board.changed(); + assert.deepEqual( + h.shown.view.marks.map(({ id, actions }) => [id, actions]), + [ + ['r1', [ + { id: 'tag', icon: 'sell', label: 'Tag Strait' }, + { id: 'measure', icon: 'straighten', label: 'Measure Strait' }, + ]], + ['l1', [{ id: 'tag', icon: 'sell', label: 'Tag Line · 9 km' }]], + ['p1', []], + ], + ); + assert.equal(h.act('runDrawMarkAction', 'l1', 'tag'), true); + assert.equal(h.act('runDrawMarkAction', 'l1', 'measure'), false, 'a line is not offered an area action'); + assert.equal(h.act('runDrawMarkAction', 'p1', 'tag'), false); + assert.equal(h.act('runDrawMarkAction', 'gone', 'tag'), false); + assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true); + assert.deepEqual(ran, [ + ['tag', { id: 'l1', shape: 'line', name: '', title: 'Line · 9 km', geometry: { type: 'LineString', coordinates: [[29, 41.05], [29.1, 41.05]] } }], + ['measure', { id: 'r1', shape: 'area', name: 'Strait', title: 'Strait', geometry: { type: 'Polygon', coordinates: [[[29, 41], [29.1, 41], [29.1, 41.1], [29, 41]]] } }], + ]); + } finally { + h.restore(); + } +}); + +test('an action that throws is reported and leaves the row alone', async () => { + const h = harness({ + markActions: [ + { id: 'boom', icon: 'bolt', label: () => { throw new Error('label'); }, shapes: ['area'], run: () => { throw new Error('run'); } }, + ], + }); + const warn = console.warn; + const warned = []; + console.warn = (...args) => warned.push(args[0]); + try { + h.annotations.marks.push({ id: 'r1', type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] }); + h.annotations.changed(); + assert.deepEqual(h.shown.view.marks[0].actions, [{ id: 'boom', icon: 'bolt', label: 'boom' }]); + assert.equal(h.act('runDrawMarkAction', 'r1', 'boom'), false); + assert.deepEqual(warned, ['[draw] boom failed']); + } finally { + console.warn = warn; + h.restore(); + } +}); + +test('what a row action answers is its row\'s note; the row is busy until then, and a removed shape drops it', async () => { + const pending = []; + const h = harness({ + markActions: [ + { + id: 'measure', + icon: 'straighten', + label: (name) => `Measure ${name}`, + shapes: ['area'], + run: () => new Promise((resolve, reject) => pending.push({ resolve, reject })), + }, + { id: 'now', icon: 'bolt', label: () => 'Now', shapes: ['area'], run: () => ' 12 km² ' }, + { id: 'long', icon: 'notes', label: () => 'Long', shapes: ['area'], run: () => `${'word '.repeat(60)}end` }, + ], + }); + const warn = console.warn; + console.warn = () => {}; + try { + const board = h.annotations; + const area = (id) => ({ id, type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] }); + board.marks.push(area('r1'), area('r2')); + board.changed(); + const row = (id) => h.shown.view.marks.find((entry) => entry.id === id); + assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), true); + assert.deepEqual([row('r1').busy, row('r1').note], [false, '12 km²']); + h.act('runDrawMarkAction', 'r1', 'long'); + assert.ok(row('r1').note.length <= 200 && row('r1').note.endsWith('word…'), 'a long note is cut at a word'); + assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true); + assert.deepEqual([row('r1').busy, row('r1').note], [true, '']); + assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), false, 'a busy row takes no second press'); + pending[0].resolve('412 km², 38 km of edge'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual([row('r1').busy, row('r1').note], [false, '412 km², 38 km of edge']); + h.act('runDrawMarkAction', 'r1', 'measure'); + pending[1].reject(new Error('refused')); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual([row('r1').busy, row('r1').note], [false, ''], 'a failure clears the note'); + h.act('runDrawMarkAction', 'r2', 'measure'); + board.marks.splice(1, 1); + board.changed(); + pending[2].resolve('late'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(row('r2'), undefined); + board.marks.push(area('r2')); + board.changed(); + assert.deepEqual([row('r2').busy, row('r2').note], [false, ''], 'a late answer never reaches a shape that went'); + } finally { + console.warn = warn; + h.restore(); + } +}); + test('Backspace on the page removes the last vertex', () => { const h = harness(); try { diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index f368d260e..a76b37bd4 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -1,5 +1,6 @@ import { resolveCatalogSources } from './sourceComposition.js'; import { createWeatherClock } from '../layers/weather/clock.js'; +import { worldClock } from '../time/worldClock.js'; import { createWeatherLayer } from '../layers/weather/index.js'; import { createApplicationCyclones } from './layers/cyclones.js'; import { createWindLayer } from '../layers/wind/index.js'; @@ -78,7 +79,7 @@ export function createApplicationCatalog({ const sourceComposition = resolveCatalogSources(sources); sources = sourceComposition.sources; const militaryRegistry = createMilitaryRegistry(); - const weatherClock = createWeatherClock(); + const weatherClock = createWeatherClock({ world: worldClock }); const dispose = () => { signal.removeEventListener('abort', dispose); militaryRegistry.dispose(); diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js index 46983953b..72071e86a 100644 --- a/src/app/layers/gnss.js +++ b/src/app/layers/gnss.js @@ -1,6 +1,10 @@ import { createGnssIntegrityLayer } from '../../layers/gnss/index.js'; +import { worldTimeFor } from '../../time/worldClock.js'; /** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */ export function createApplicationGnssIntegrity(options) { - return createGnssIntegrityLayer(options); + return createGnssIntegrityLayer({ + worldNow: worldTimeFor('gnss-integrity'), + ...options, + }); } diff --git a/src/app/layers/recentImagery.js b/src/app/layers/recentImagery.js index cc4659727..6a730bc7c 100644 --- a/src/app/layers/recentImagery.js +++ b/src/app/layers/recentImagery.js @@ -2,6 +2,9 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js'; import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js'; import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js'; import { governorRequestRender } from '../../renderGovernor.js'; +import { worldTimeFor } from '../../time/worldClock.js'; + +const imageryTime = worldTimeFor('recent-imagery'); /** Construct imagery presentation over one explicitly supplied acquisition source. */ export function createApplicationRecentImagery({ source }) { @@ -15,5 +18,6 @@ export function createApplicationRecentImagery({ source }) { thumbnails: createThumbnailLoader({ requestThumbnail: (...args) => source.getThumbnail(...args), }), + now: () => new Date(imageryTime()), }); } diff --git a/src/app/scene.js b/src/app/scene.js index 8792a1f4d..b9799b8ac 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -15,6 +15,8 @@ import { governorRequestRender, } from '../renderGovernor.js'; import { describeError } from './errors.js'; +import { worldClock, worldTimeFor } from '../time/worldClock.js'; +import { followWorldTime } from '../time/viewerClock.js'; import { onLocaleChange, t } from '../i18n/index.js'; /** Construct the application globe using the caller's local configuration. */ @@ -59,6 +61,12 @@ export async function createApplicationScene({ if (!viewer.isDestroyed()) viewer.destroy(); }); defer(installTrackpadPinchZoom(viewer)); + defer( + followWorldTime(viewer, worldTimeFor('sky'), { + subscribe: worldClock.subscribe, + requestRender: () => governorRequestRender('world-time'), + }), + ); defer(installFreeLookHeightLimit(viewer)); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); diff --git a/src/app/tools.js b/src/app/tools.js index 68033947d..8925151ad 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -35,6 +35,7 @@ export function createApplicationTools({ startChrome, onSceneDirector, sceneDataPacks, + drawMarkActions = [], signal, defer, }) { @@ -80,12 +81,15 @@ export function createApplicationTools({ // while a session is open, so its teardown belongs to the application // lifetime rather than to whoever last pressed the button. A row's fly-to // goes through the shell's navigation policy, as Import's does. + // `drawMarkActions` are an application's own buttons on a drawn area's or + // line's row (annotations/drawMarks.js); the standalone shell adds none. const drawTool = initDrawTool({ viewer, annotations, navigation: styleManager, publish: (view) => displayStore.publishDraw(view), connect: connectDisplay, + markActions: drawMarkActions, }); defer(() => drawTool?.destroy()); // UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its diff --git a/src/app/viewer.js b/src/app/viewer.js index fc75c0498..e4288e8a3 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -7,6 +7,48 @@ import { isFreeGlobeCamera, } from '../globeInView.js'; +/** + * At the farthest a hand can zoom out, the globe is this share of the + * screen's height across. Cesium sets no limit, so zooming on left the globe + * a speck among the stars. + */ +const MIN_GLOBE_SHARE = 0.3; + +/** + * The farthest a hand can zoom out, in metres above the ellipsoid, for a + * vertical field of view. It follows the screen's shape, as the app's own + * whole-globe view does, so that view always lies within it. + * @param {number} fovy Vertical field of view, radians. + */ +export function zoomOutLimit(fovy) { + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const angular = Math.atan(MIN_GLOBE_SHARE * Math.tan(fovy / 2)); + return radius / Math.sin(angular) - radius; +} + +/** + * Hold a hand's zoom out to `zoomOutLimit` on the globe, each frame, since the + * field of view follows the screen's shape. The limit never sits below the + * camera: one already past it (a flight, a screen turned) stops there rather + * than jumping in at the next zoom. Flat maps keep Cesium's own, unlimited. + * @returns {() => void} Stop limiting. + */ +export function limitZoomOut(viewer) { + const { scene, camera } = viewer; + return scene.preRender.addEventListener(() => { + const controller = scene.screenSpaceCameraController; + const fovy = camera.frustum.fovy; + if (scene.mode !== Cesium.SceneMode.SCENE3D || !Number.isFinite(fovy)) { + controller.maximumZoomDistance = Number.POSITIVE_INFINITY; + return; + } + controller.maximumZoomDistance = Math.max( + zoomOutLimit(fovy), + camera.positionCartographic?.height ?? 0, + ); + }); +} + const PINCH_ZOOM_MULTIPLIER = 8; const MAX_PINCH_PIXEL_DELTA = 120; @@ -372,6 +414,7 @@ export function createApplicationViewer({ container, creditContainer }) { // required on touch-first screens. Mouse/trackpad sensitivity stays native. if (globalThis.matchMedia?.('(pointer: coarse)').matches) viewer.scene.screenSpaceCameraController.zoomFactor *= 3; + limitZoomOut(viewer); // Before any tile builds a draw command: Cesium's per-vertex model // atmosphere fails to LINK on Apple's Metal backend and kills the // render loop. See app/atmosphereCompat.js. diff --git a/src/app/viewer.test.mjs b/src/app/viewer.test.mjs new file mode 100644 index 000000000..94d80e20a --- /dev/null +++ b/src/app/viewer.test.mjs @@ -0,0 +1,73 @@ +// A hand zooming out stops while the globe is still a fair share of the +// screen, on any screen's shape, rather than leaving it a speck. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { limitZoomOut, zoomOutLimit } from './viewer.js'; + +// The share of the screen's height the globe spans from `altitude`. +function globeShare(altitude, fovy) { + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const angular = Math.asin(radius / (radius + altitude)); + return Math.tan(angular) / Math.tan(fovy / 2); +} + +test("zooming out stops with the globe 30% of the screen's height across", () => { + for (const fovy of [Math.PI / 3, 0.4, 0.2]) { + const limit = zoomOutLimit(fovy); + assert.ok(Math.abs(globeShare(limit, fovy) - 0.3) < 1e-9, `fovy ${fovy}`); + } +}); + +test('a wide screen, whose view is narrower, may zoom out farther', () => { + assert.ok(zoomOutLimit(0.4) > zoomOutLimit(Math.PI / 3)); + assert.ok(zoomOutLimit(Math.PI / 3) > 20_000_000, 'past the views a link opens'); +}); + +function fakeViewer({ fovy = Math.PI / 3, height = 10_000_000 } = {}) { + const scene = { + mode: Cesium.SceneMode.SCENE3D, + preRender: new Cesium.Event(), + screenSpaceCameraController: { maximumZoomDistance: Infinity }, + }; + const camera = { + frustum: { fovy }, + positionCartographic: { height }, + }; + return { scene, camera }; +} + +test('the limit follows the screen, and holds a camera already past it', () => { + const viewer = fakeViewer(); + const controller = viewer.scene.screenSpaceCameraController; + const stop = limitZoomOut(viewer); + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, zoomOutLimit(Math.PI / 3)); + + // A wider screen narrows the view, and the globe may go farther. + viewer.camera.frustum.fovy = 0.4; + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4)); + + // Flown past it, the next zoom out stops there instead of jumping in. + viewer.camera.positionCartographic.height = zoomOutLimit(0.4) * 2; + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2); + + stop(); + viewer.camera.positionCartographic.height = 0; + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2); +}); + +test('flat maps keep zooming out unlimited', () => { + const viewer = fakeViewer(); + const controller = viewer.scene.screenSpaceCameraController; + limitZoomOut(viewer); + viewer.scene.preRender.raiseEvent(); + assert.ok(Number.isFinite(controller.maximumZoomDistance)); + viewer.scene.mode = Cesium.SceneMode.SCENE2D; + viewer.camera.frustum = {}; + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, Infinity); +}); diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 398c51c34..1c41139c8 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -42,7 +42,7 @@ function ordered(source, needles, label) { test('Cockpit takeover invalidates deferred work before camera cancellation', () => { const enter = cockpitEnter.toString(); ordered(enter, [ - 'if (!info || !entity?.position) return false;', + 'if (!info || (!vessel && !satellite && !entity?.position)) return false;', 'this.onCameraTakeover?.();', 'this.viewer.camera.cancelFlight();', 'this.viewer.trackedEntity = undefined;', @@ -106,7 +106,8 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () => ); ordered(transaction, [ /if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/, - 'if (!entryError) entered = Boolean(cockpitView.enter());', + // Aircraft-only: a selected vessel must not stand in for a voice target. + "if (!entryError) entered = Boolean(cockpitView.enter({ kind: 'aircraft' }));", ], 'Cockpit entry transaction'); // No direct camera control: every mutation goes through a layer tracker or // the cockpit controller, both of which stamp. diff --git a/src/celestialRing.js b/src/celestialRing.js index c031593d4..525ded44e 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -1,5 +1,9 @@ import * as Cesium from 'cesium'; import { governorRequestRender } from './renderGovernor.js'; +import { worldClock, worldTimeFor } from './time/worldClock.js'; + +// The ring shows the sun and moon at the time the sky is drawn. +const skyTime = worldTimeFor('sky'); /** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */ export const KEYHOLE_OUTER_RADIUS = 1.05; @@ -28,6 +32,9 @@ export const KEYHOLE_LABEL_FEATHER_MAX_RATIO = 0.4; */ export const KEYHOLE_OUTSIDE_OPACITY_DEFAULT = 0.01; +// How far the world time moves before the sun and moon move with it: a +// minute moves the sun a quarter of a degree. +const EPHEMERIS_WORLD_STEP_MS = 60_000; const RING_INSET_PX = 11; const MARKER_INSET_PX = 36; const COLLIDING_MARKER_EXTRA_INSET_PX = 32; @@ -445,6 +452,20 @@ export class CelestialRing { if (typeof document !== 'undefined' && document.hidden) return; governorRequestRender('celestial-ephemeris'); }, 60_000); + // A jump in the world time moves the sun and moon at once. + this._skyDate = new Date(); + this._skyTime = new Cesium.JulianDate(); + // Played through, the sky moves on each minute of world time it passes. + this._ephemerisWorldMs = null; + this._unsubscribeWorldTime = worldClock.subscribe(({ timeMs }) => { + if ( + this._ephemerisWorldMs !== null && + Math.abs(timeMs - this._ephemerisWorldMs) < EPHEMERIS_WORLD_STEP_MS + ) + return; + this._ephemerisWorldMs = timeMs; + this._ephemerisDirty = true; + }); this.setEnabled(this.enabled); } @@ -801,7 +822,8 @@ export class CelestialRing { return; } - const time = Cesium.JulianDate.now(); + this._skyDate.setTime(skyTime()); + const time = Cesium.JulianDate.fromDate(this._skyDate, this._skyTime); if (!this._updateEphemeris(time)) return; this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) }); const camera = this.viewer.camera; @@ -890,6 +912,8 @@ export class CelestialRing { clearInterval(this._ephemerisTimer); this._ephemerisTimer = null; } + this._unsubscribeWorldTime?.(); + this._unsubscribeWorldTime = null; if (this._removePostRender) this._removePostRender(); this._removePostRender = null; if (this._shown()) { diff --git a/src/cockpitCloudEffects.js b/src/cockpitCloudEffects.js index c9117b7d3..c1cc796b4 100644 --- a/src/cockpitCloudEffects.js +++ b/src/cockpitCloudEffects.js @@ -170,6 +170,18 @@ export function cockpitWeatherEnabledFromStoredValue(value) { return value === '1'; } +/** + * Whether the cloud pass runs for a Cockpit subject's layer. Not in orbit: + * the pass fades out above 24 km (weatherAltitudeFactors), so a satellite + * would pay for it and see nothing. The saved preference is left as it is + * for the next aircraft or bridge; cloud cover from orbit is the Weather + * layer's. + * @param {string|null|undefined} layerId + */ +export function cockpitCloudsShowFor(layerId) { + return layerId !== 'satellites'; +} + /** * Weather-backed volumetric cloud pass that exists only while cockpit mode is * active. It owns no Cesium fog/post-process stages and cannot affect map mode. @@ -207,10 +219,12 @@ class CockpitCloudEffectsController { this.enabled = this.readEnabledPreference(); this.onResize = () => this.resize(); + this.orbit = false; this.onCockpitMode = (event) => { const active = event?.detail?.active ?? document.body.classList.contains('cockpit-mode'); + this.orbit = active && !cockpitCloudsShowFor(event?.detail?.layerId); if (active && this.enabled) this.start(); else this.stop(); }; @@ -344,7 +358,8 @@ class CockpitCloudEffectsController { this.destroyed || !this.enabled || this.frame !== null || - this.suspended + this.suspended || + this.orbit ) return; if (!this.gl) { diff --git a/src/cockpitCloudEffects.test.mjs b/src/cockpitCloudEffects.test.mjs index 18a0ed394..651b3fb27 100644 --- a/src/cockpitCloudEffects.test.mjs +++ b/src/cockpitCloudEffects.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { cockpitCloudRenderSize, + cockpitCloudsShowFor, cockpitWeatherRefreshDue, cockpitWeatherEnabledFromStoredValue, } from './cockpitCloudEffects.js'; @@ -23,6 +24,13 @@ test('cockpit weather defaults off and enables only from an explicit saved opt-i assert.equal(cockpitWeatherEnabledFromStoredValue('1'), true); }); +test('the cloud pass stays off in orbit and runs for aircraft and bridges', () => { + assert.equal(cockpitCloudsShowFor('satellites'), false); + for (const layerId of ['flights', 'military', 'ais-live-vessels', null]) { + assert.equal(cockpitCloudsShowFor(layerId), true, String(layerId)); + } +}); + test('cockpit weather refreshes after time or meaningful movement', () => { const anchor = { latitude: 30, longitude: -97 }; assert.equal(cockpitWeatherRefreshDue({ diff --git a/src/cockpitLook.js b/src/cockpitLook.js new file mode 100644 index 000000000..e0b133b34 --- /dev/null +++ b/src/cockpitLook.js @@ -0,0 +1,244 @@ +/** + * Bridge look-around policy: how the arrow keys turn the bridge camera away + * from the bow and back, and how a turned view is described. Yaw is relative + * to the ship's heading; pitch is the view's absolute pitch. Pure: no DOM, no + * Cesium and no translated text; the Cockpit words the readout. + */ + +/** One arrow press turns the view this far. */ +export const LOOK_STEP_DEG = 5; + +/** One Shift+arrow press turns the view this far: two reach either beam. */ +export const LOOK_COARSE_STEP_DEG = 45; + +/** The view pitches no further than straight up or straight down. */ +export const LOOK_PITCH_LIMIT_DEG = 90; + +/** + * The eased view's time constant. Three of them (about 180 ms) close 95% of + * a step, so a press glides rather than snaps and a held key turns smoothly. + */ +export const LOOK_EASE_TAU_MS = 60; + +/** Closer than this the eased view lands on its target. */ +const LOOK_SETTLE_DEG = 0.05; + +/** Arrow key → the axis it turns and the direction it turns it. */ +const LOOK_ARROWS = Object.freeze({ + ArrowLeft: Object.freeze({ axis: 'yaw', sign: -1 }), + ArrowRight: Object.freeze({ axis: 'yaw', sign: 1 }), + ArrowUp: Object.freeze({ axis: 'pitch', sign: 1 }), + ArrowDown: Object.freeze({ axis: 'pitch', sign: -1 }), +}); + +/** + * Wrap a yaw to (-180, 180]: negative to port, positive to starboard. + * @param {number} deg Yaw in degrees; a non-finite yaw reads as ahead. + * @returns {number} + */ +export function wrapLookYaw(deg) { + if (!Number.isFinite(deg)) return 0; + const wrapped = (((deg % 360) + 360) % 360) - 360; + const yaw = wrapped <= -180 ? wrapped + 360 : wrapped; + return yaw === 0 ? 0 : yaw; +} + +/** + * Clamp a view pitch to [-90, 90]. + * @param {number} deg Pitch in degrees; a non-finite pitch reads as level. + * @returns {number} + */ +export function clampLookPitch(deg) { + if (!Number.isFinite(deg)) return 0; + const pitch = Math.min( + LOOK_PITCH_LIMIT_DEG, + Math.max(-LOOK_PITCH_LIMIT_DEG, deg), + ); + return pitch === 0 ? 0 : pitch; +} + +/** + * The step an arrow key asks for, or null for any other key. + * @param {string} key `KeyboardEvent.key`. + * @param {boolean} [coarse] Shift is held: step 45° instead of 5°. + * @returns {{axis: 'yaw'|'pitch', sign: 1|-1, stepDeg: number}|null} + */ +export function lookKeyStep(key, coarse = false) { + const arrow = LOOK_ARROWS[key]; + if (!arrow) return null; + return { + ...arrow, + stepDeg: coarse ? LOOK_COARSE_STEP_DEG : LOOK_STEP_DEG, + }; +} + +/** + * Move one value to the next multiple of `stepDeg` in the direction `sign`. + * A value off the grid (the bridge's -2° rest pitch) lands on it first, so + * the readout shows round angles. + */ +function nextOnGrid(value, sign, stepDeg) { + const cells = value / stepDeg; + const next = + sign > 0 ? Math.floor(cells + 1e-9) + 1 : Math.ceil(cells - 1e-9) - 1; + return next * stepDeg; +} + +/** + * Apply one key step to a look target. + * @param {{yawDeg: number, pitchDeg: number}} target Current look target. + * @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step From + * {@link lookKeyStep}. + * @returns {{yawDeg: number, pitchDeg: number}} The new target: yaw wrapped + * to (-180, 180], pitch clamped to [-90, 90]. + */ +export function stepLook(target, step) { + const yawDeg = wrapLookYaw(target?.yawDeg ?? 0); + const pitchDeg = clampLookPitch(target?.pitchDeg ?? 0); + if (!step) return { yawDeg, pitchDeg }; + if (step.axis === 'yaw') + return { + yawDeg: wrapLookYaw(nextOnGrid(yawDeg, step.sign, step.stepDeg)), + pitchDeg, + }; + return { + yawDeg, + pitchDeg: clampLookPitch(nextOnGrid(pitchDeg, step.sign, step.stepDeg)), + }; +} + +/** + * Advance the eased view toward its target. Yaw takes the shorter way + * round; with `immediate` (reduced motion) the view jumps to the target. + * @param {{yawDeg: number, pitchDeg: number}} current Eased view now. + * @param {{yawDeg: number, pitchDeg: number}} target Look target. + * @param {number} dtMs Milliseconds since the previous step. + * @param {{immediate?: boolean}} [options] + * @returns {{yawDeg: number, pitchDeg: number}} + */ +export function easeLook(current, target, dtMs, { immediate = false } = {}) { + const goalYaw = wrapLookYaw(target?.yawDeg ?? 0); + const goalPitch = clampLookPitch(target?.pitchDeg ?? 0); + if (immediate || !current) return { yawDeg: goalYaw, pitchDeg: goalPitch }; + const fraction = + Number.isFinite(dtMs) && dtMs > 0 + ? 1 - Math.exp(-dtMs / LOOK_EASE_TAU_MS) + : 0; + const fromYaw = wrapLookYaw(current.yawDeg); + const yawError = wrapLookYaw(goalYaw - fromYaw); + const pitchError = goalPitch - clampLookPitch(current.pitchDeg); + const yawDeg = + Math.abs(yawError * (1 - fraction)) < LOOK_SETTLE_DEG + ? goalYaw + : wrapLookYaw(fromYaw + yawError * fraction); + const pitchDeg = + Math.abs(pitchError * (1 - fraction)) < LOOK_SETTLE_DEG + ? goalPitch + : clampLookPitch(current.pitchDeg + pitchError * fraction); + return { yawDeg, pitchDeg }; +} + +/** + * Whether a look differs from the rest view (ahead at `restPitchDeg`). + * @param {{yawDeg: number, pitchDeg: number}} look + * @param {number} restPitchDeg The camera profile's pitch. + * @returns {boolean} + */ +export function lookIsOffset(look, restPitchDeg) { + return ( + wrapLookYaw(look?.yawDeg ?? 0) !== 0 || + clampLookPitch(look?.pitchDeg ?? restPitchDeg) !== + clampLookPitch(restPitchDeg) + ); +} + +/** + * The parts of a look readout, for the Cockpit to word. A part is null when + * that axis rests: yaw ahead, or pitch at the rest pitch. + * @param {{yawDeg: number, pitchDeg: number}} look Look target. + * @param {number} restPitchDeg The camera profile's pitch. + * @returns {{side: 'port'|'starboard'|'astern'|null, bearingDeg: number, + * elevation: 'up'|'down'|'level'|null, angleDeg: number}} `bearingDeg` is + * the angle off the bow (0–180) and `angleDeg` the angle off level (0–90), + * both rounded to whole degrees. + */ +export function lookReadoutParts(look, restPitchDeg) { + const yaw = Math.round(wrapLookYaw(look?.yawDeg ?? 0)); + const pitch = Math.round(clampLookPitch(look?.pitchDeg ?? restPitchDeg)); + const pitchOffset = + clampLookPitch(look?.pitchDeg ?? restPitchDeg) !== + clampLookPitch(restPitchDeg); + let side = null; + if (Math.abs(yaw) >= 180) side = 'astern'; + else if (yaw > 0) side = 'starboard'; + else if (yaw < 0) side = 'port'; + let elevation = null; + if (pitchOffset) elevation = pitch > 0 ? 'up' : pitch < 0 ? 'down' : 'level'; + return { + side, + bearingDeg: Math.abs(yaw), + elevation, + angleDeg: Math.abs(pitch), + }; +} + +/** + * The orbital view's off-nadir limit on each axis. The orbital look reuses + * the bridge's `{yawDeg, pitchDeg}` shape over a fixed nadir frame: yaw tilts + * the view east (positive) or west, pitch tilts it north (positive) or + * south. Single 5° steps only; the bridge keeps its own limits. + */ +export const ORBITAL_LOOK_LIMIT_DEG = 45; + +/** Clamp one orbital look axis to ±ORBITAL_LOOK_LIMIT_DEG; NaN rests. */ +function clampOrbitalAxis(deg) { + if (!Number.isFinite(deg)) return 0; + const value = Math.min( + ORBITAL_LOOK_LIMIT_DEG, + Math.max(-ORBITAL_LOOK_LIMIT_DEG, deg), + ); + return value === 0 ? 0 : value; +} + +/** + * Clamp an orbital look to the off-nadir limit on both axes. + * @param {{yawDeg: number, pitchDeg: number}} look East and north tilt. + * @returns {{yawDeg: number, pitchDeg: number}} + */ +export function clampOrbitalLook(look) { + return { + yawDeg: clampOrbitalAxis(look?.yawDeg), + pitchDeg: clampOrbitalAxis(look?.pitchDeg), + }; +} + +/** + * Apply one arrow step to an orbital look: the bridge's stepping on the + * same 5° grid, then the off-nadir clamp. + * @param {{yawDeg: number, pitchDeg: number}} target Current look target. + * @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step From + * {@link lookKeyStep}. + * @returns {{yawDeg: number, pitchDeg: number}} + */ +export function stepOrbitalLook(target, step) { + return clampOrbitalLook(stepLook(clampOrbitalLook(target), step)); +} + +/** + * The parts of an off-nadir readout, for the Cockpit to word: the north or + * south tilt and the east or west tilt, each null at rest, in whole degrees. + * @param {{yawDeg: number, pitchDeg: number}} look Orbital look target. + * @returns {{northSouth: 'N'|'S'|null, northSouthDeg: number, + * eastWest: 'E'|'W'|null, eastWestDeg: number}} + */ +export function orbitalLookReadoutParts(look) { + const { yawDeg, pitchDeg } = clampOrbitalLook(look); + const north = Math.round(pitchDeg); + const east = Math.round(yawDeg); + return { + northSouth: north > 0 ? 'N' : north < 0 ? 'S' : null, + northSouthDeg: Math.abs(north), + eastWest: east > 0 ? 'E' : east < 0 ? 'W' : null, + eastWestDeg: Math.abs(east), + }; +} diff --git a/src/cockpitLook.test.mjs b/src/cockpitLook.test.mjs new file mode 100644 index 000000000..27f79e319 --- /dev/null +++ b/src/cockpitLook.test.mjs @@ -0,0 +1,195 @@ +// src/cockpitLook.test.mjs — the bridge look-around policy: arrow steps, +// yaw wrapping, pitch limits, easing and the readout's parts. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + LOOK_COARSE_STEP_DEG, + LOOK_STEP_DEG, + ORBITAL_LOOK_LIMIT_DEG, + clampLookPitch, + clampOrbitalLook, + easeLook, + lookIsOffset, + lookKeyStep, + lookReadoutParts, + orbitalLookReadoutParts, + stepLook, + stepOrbitalLook, + wrapLookYaw, +} from './cockpitLook.js'; + +const REST = { yawDeg: 0, pitchDeg: -2 }; + +/** Press one key `count` times from `start`. */ +function press(start, key, count = 1, coarse = false) { + let look = start; + for (let index = 0; index < count; index += 1) + look = stepLook(look, lookKeyStep(key, coarse)); + return look; +} + +test('yaw wraps to (-180, 180] and pitch clamps to [-90, 90]', () => { + assert.equal(wrapLookYaw(0), 0); + assert.equal(wrapLookYaw(-0), 0); + assert.equal(wrapLookYaw(180), 180); + assert.equal(wrapLookYaw(-180), 180); + assert.equal(wrapLookYaw(185), -175); + assert.equal(wrapLookYaw(-185), 175); + assert.equal(wrapLookYaw(720 + 45), 45); + assert.equal(wrapLookYaw(NaN), 0); + assert.equal(clampLookPitch(120), 90); + assert.equal(clampLookPitch(-120), -90); + assert.equal(clampLookPitch(-0), 0); + assert.equal(clampLookPitch(Infinity), 0); +}); + +test('only the arrows step, 5° plain and 45° with Shift', () => { + assert.deepEqual(lookKeyStep('ArrowLeft'), { + axis: 'yaw', + sign: -1, + stepDeg: LOOK_STEP_DEG, + }); + assert.deepEqual(lookKeyStep('ArrowUp', true), { + axis: 'pitch', + sign: 1, + stepDeg: LOOK_COARSE_STEP_DEG, + }); + assert.equal(lookKeyStep('ArrowDown').sign, -1); + assert.equal(lookKeyStep('ArrowRight').sign, 1); + for (const key of ['Home', '0', 'a', 'Escape', undefined]) + assert.equal(lookKeyStep(key), null); +}); + +test('arrows turn the yaw in 5° steps and Shift reaches either beam in two', () => { + assert.deepEqual(press(REST, 'ArrowRight'), { yawDeg: 5, pitchDeg: -2 }); + assert.deepEqual(press(REST, 'ArrowLeft', 3), { yawDeg: -15, pitchDeg: -2 }); + assert.equal(press(REST, 'ArrowRight', 2, true).yawDeg, 90); + assert.equal(press(REST, 'ArrowLeft', 2, true).yawDeg, -90); + // A coarse step from off the 45° grid lands on it. + assert.equal(press({ ...REST, yawDeg: 5 }, 'ArrowRight', 1, true).yawDeg, 45); + // Past astern the yaw wraps to the other side. + assert.equal(press(REST, 'ArrowRight', 4, true).yawDeg, 180); + assert.equal(press(REST, 'ArrowRight', 5, true).yawDeg, -135); + assert.equal(press(REST, 'ArrowLeft', 37).yawDeg, 175); +}); + +test('pitch steps onto the 5° grid from the rest pitch and stops at ±90°', () => { + assert.equal(press(REST, 'ArrowUp').pitchDeg, 0); + assert.equal(press(REST, 'ArrowUp', 3).pitchDeg, 10); + assert.equal(press(REST, 'ArrowDown').pitchDeg, -5); + assert.equal(press(REST, 'ArrowUp', 40).pitchDeg, 90); + assert.equal(press(REST, 'ArrowDown', 40).pitchDeg, -90); + assert.equal(press(REST, 'ArrowUp', 3, true).pitchDeg, 90); + assert.equal(press(REST, 'ArrowDown', 2, true).pitchDeg, -90); + assert.equal(press(REST, 'ArrowUp').yawDeg, 0, 'pitch leaves the yaw'); +}); + +test('the eased view closes most of a step in about 180 ms', () => { + const target = { yawDeg: 90, pitchDeg: -2 }; + let view = { ...REST }; + view = easeLook(view, target, 180); + assert.ok(view.yawDeg > 85 && view.yawDeg < 90, `yaw ${view.yawDeg}`); + for (let step = 0; step < 20; step += 1) view = easeLook(view, target, 50); + assert.deepEqual(view, target, 'it lands exactly on the target'); + assert.deepEqual(easeLook(REST, target, 0), REST, 'no time, no motion'); +}); + +test('the eased yaw takes the short way across astern', () => { + const view = easeLook( + { yawDeg: 170, pitchDeg: 0 }, + { yawDeg: -170, pitchDeg: 0 }, + 30, + ); + assert.ok(view.yawDeg > 170 || view.yawDeg < -170, `yaw ${view.yawDeg}`); +}); + +test('reduced motion jumps straight to the target', () => { + const target = { yawDeg: -45, pitchDeg: 30 }; + assert.deepEqual(easeLook(REST, target, 1, { immediate: true }), target); +}); + +test('the readout names the side, the bearing and the elevation', () => { + assert.equal(lookIsOffset(REST, -2), false); + assert.equal(lookIsOffset({ yawDeg: 0, pitchDeg: 0 }, -2), true); + assert.equal(lookIsOffset({ yawDeg: 5, pitchDeg: -2 }, -2), true); + assert.deepEqual(lookReadoutParts({ yawDeg: 45, pitchDeg: 10 }, -2), { + side: 'starboard', + bearingDeg: 45, + elevation: 'up', + angleDeg: 10, + }); + assert.deepEqual(lookReadoutParts({ yawDeg: -90, pitchDeg: -2 }, -2), { + side: 'port', + bearingDeg: 90, + elevation: null, + angleDeg: 2, + }); + assert.deepEqual(lookReadoutParts({ yawDeg: 0, pitchDeg: -30 }, -2), { + side: null, + bearingDeg: 0, + elevation: 'down', + angleDeg: 30, + }); + assert.equal( + lookReadoutParts({ yawDeg: 0, pitchDeg: 0 }, -2).elevation, + 'level', + ); + assert.equal( + lookReadoutParts({ yawDeg: 180, pitchDeg: -2 }, -2).side, + 'astern', + ); +}); + +test('an orbital look steps 5° per arrow and stops at 45° on each axis', () => { + let look = { yawDeg: 0, pitchDeg: 0 }; + for (let index = 0; index < 3; index += 1) + look = stepOrbitalLook(look, lookKeyStep('ArrowUp')); + assert.deepEqual(look, { yawDeg: 0, pitchDeg: 15 }); + look = stepOrbitalLook(look, lookKeyStep('ArrowRight')); + assert.deepEqual(look, { yawDeg: 5, pitchDeg: 15 }); + for (let index = 0; index < 20; index += 1) { + look = stepOrbitalLook(look, lookKeyStep('ArrowDown')); + look = stepOrbitalLook(look, lookKeyStep('ArrowLeft')); + } + assert.deepEqual(look, { + yawDeg: -ORBITAL_LOOK_LIMIT_DEG, + pitchDeg: -ORBITAL_LOOK_LIMIT_DEG, + }); + assert.deepEqual(clampOrbitalLook({ yawDeg: 90, pitchDeg: NaN }), { + yawDeg: 45, + pitchDeg: 0, + }); + assert.deepEqual(clampOrbitalLook({ yawDeg: 180, pitchDeg: -60 }), { + yawDeg: 45, + pitchDeg: -45, + }); +}); + +test('the orbital readout names north or south and east or west', () => { + assert.deepEqual(orbitalLookReadoutParts({ yawDeg: 15, pitchDeg: 10 }), { + northSouth: 'N', + northSouthDeg: 10, + eastWest: 'E', + eastWestDeg: 15, + }); + assert.deepEqual(orbitalLookReadoutParts({ yawDeg: -5, pitchDeg: 0 }), { + northSouth: null, + northSouthDeg: 0, + eastWest: 'W', + eastWestDeg: 5, + }); + assert.equal( + orbitalLookReadoutParts({ yawDeg: 0, pitchDeg: -45 }).northSouth, + 'S', + ); +}); + +test('the bridge look keeps its own limits beside the orbital clamp', () => { + const look = stepLook({ yawDeg: 45, pitchDeg: 45 }, lookKeyStep('ArrowUp')); + assert.deepEqual(look, { yawDeg: 45, pitchDeg: 50 }); + assert.equal( + stepLook({ yawDeg: 45, pitchDeg: 0 }, lookKeyStep('ArrowRight', true)) + .yawDeg, + 90, + ); +}); diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index aa06193ba..06db3aeff 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -79,7 +79,7 @@ test('Cockpit has one reset action beside its bottom exit path', () => { assert.doesNotMatch(html, /id="cockpit-exit"/); assert.match( html, - /id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape C"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/, + /id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/, ); const topCenterActions = html.match( /