mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into upstream/wind-stale
This commit is contained in:
120 files changed
+15719
-812
No files matched your search
@@ -205,6 +205,8 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
|
||||
| **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) |
|
||||
| **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
|
||||
| **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) |
|
||||
| **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" |
|
||||
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
|
||||
| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) |
|
||||
|
||||
@@ -277,6 +279,11 @@ 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).
|
||||
|
||||
### Sky background (`sky/`)
|
||||
|
||||
Sources, licences, the changes CC BY asks to state, hashes and build commands
|
||||
are in [`sky/README.md`](src/data/local_data/sky/README.md).
|
||||
|
||||
### DataSF Analysis Neighborhoods (`neighborhoods/`)
|
||||
|
||||
`neighborhoods/san-francisco.json` bundles the City & County of San Francisco's official
|
||||
|
||||
@@ -425,7 +425,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
||||
|
||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
||||
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
|
||||
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
|
||||
|
||||
|
||||
+3
-1
@@ -69,11 +69,13 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
|
||||
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
|
||||
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
|
||||
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
|
||||
- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one.
|
||||
- **Response-size caps and timeouts** on proxied responses.
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
|
||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
|
||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||
|
||||
+72
-1
@@ -47,6 +47,75 @@ export const BROWSER_HEADERS = Object.freeze({
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
|
||||
/**
|
||||
* Content-Security-Policy for every `/api` response. API routes return data,
|
||||
* never a document, so a response opened directly in a tab gets no origin and
|
||||
* can load nothing.
|
||||
*/
|
||||
export const API_CSP = "default-src 'none'; sandbox";
|
||||
|
||||
/** Response headers every `/api` response carries. */
|
||||
export const API_HEADERS = Object.freeze({
|
||||
'Content-Security-Policy': API_CSP,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether a request reaches an `/api` mount, by the router's own rule: the
|
||||
* prefix compared without case and followed by the end, `/` or `.`. The router
|
||||
* reads an absolute-form target's path without resolving dot segments, so the
|
||||
* path is taken here the same way (scheme and authority dropped, backslashes as
|
||||
* slashes), alongside the raw and the fully parsed path; any match counts.
|
||||
*/
|
||||
function isApiPath(url) {
|
||||
const raw = String(url || '/');
|
||||
const target = raw
|
||||
.trim()
|
||||
.split(/[?#]/, 1)[0]
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^[a-z][a-z0-9+.-]*:(?:\/\/[^/]*)?/i, '');
|
||||
let parsed = '';
|
||||
try {
|
||||
parsed = new URL(raw, 'http://localhost').pathname;
|
||||
} catch {
|
||||
/* the unparsed paths still decide */
|
||||
}
|
||||
return [raw.split(/[?#]/, 1)[0], target, parsed].some((path) =>
|
||||
/^\/api(?:$|[/.])/i.test(path),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin that sets API_HEADERS on every API response before any other
|
||||
* middleware runs, so success, error, partial, refused and unknown-route
|
||||
* answers all carry them. Vite's `server.headers` and `preview.headers` only
|
||||
* reach the documents and assets Vite serves itself. A route may still add
|
||||
* headers; a route's own Content-Security-Policy is sent as an additional
|
||||
* policy, which can only restrict further, never replace this one.
|
||||
*/
|
||||
export function apiResponseHeadersPlugin() {
|
||||
const install = (server) => {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (!isApiPath(req.url)) return next();
|
||||
for (const [name, value] of Object.entries(API_HEADERS))
|
||||
res.setHeader(name, value);
|
||||
const setHeader = res.setHeader.bind(res);
|
||||
res.setHeader = (name, value) =>
|
||||
String(name).toLowerCase() === 'content-security-policy' &&
|
||||
String(value) !== API_CSP
|
||||
? setHeader(name, `${API_CSP}, ${value}`)
|
||||
: setHeader(name, value);
|
||||
next();
|
||||
});
|
||||
};
|
||||
return {
|
||||
name: 'api-response-headers',
|
||||
enforce: 'pre',
|
||||
configureServer: install,
|
||||
configurePreviewServer: install,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build browser assets with explicit inputs; never load environment or providers. */
|
||||
export function createBrowserViteConfig({
|
||||
plugins = [],
|
||||
@@ -61,7 +130,9 @@ export function createBrowserViteConfig({
|
||||
} = {}) {
|
||||
return {
|
||||
plugins: [
|
||||
// First, so no provider route answers a Host the server does not allow.
|
||||
// First, so every API response, including a Host refusal, carries them.
|
||||
apiResponseHeadersPlugin(),
|
||||
// Next, so no provider route answers a Host the server does not allow.
|
||||
hostCheckPlugin(),
|
||||
cesium(),
|
||||
applicationHtmlPlugin(),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
- Replace the blocky JPEG sky around the globe with the 9,096 stars of the
|
||||
Yale Bright Star Catalogue, drawn as sharp points where they are at the
|
||||
scene time, over a soft Milky Way from ESO/S. Brunier (CC BY 4.0). The Sun
|
||||
and Moon stay; the anime style hides the Milky Way. Assets: 296 kB (gekh,
|
||||
#1243).
|
||||
@@ -0,0 +1,9 @@
|
||||
- Prevent malformed Overpass filters from blocking the local server, and reject
|
||||
cross-site or excessive query requests before reading their bodies
|
||||
(asturksever, #1264; fixes #1248).
|
||||
- Release unread responses from the optional Google-token probe so keyless
|
||||
browser validation can settle; the Overpass gate recognizes only that probe's
|
||||
expected 404 as an unavailable capability (asturksever, #1264).
|
||||
- Make tracking arrival checks respect the aircraft update cadence while checking
|
||||
orientation on the first reveal, avoiding false failures at faster render
|
||||
rates (asturksever, #1264).
|
||||
@@ -0,0 +1,4 @@
|
||||
- The Street Level SINCE note sits on the value's line, in its type and a
|
||||
size smaller: with KartaView lit it reads ANY DATE (by upload date). It
|
||||
had lost its size variable in the Street Level slim-down and rendered
|
||||
larger than the value, on a line of its own (asturksever, #1286).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Give ordinary sliders a shared native control and consistent track, thumb,
|
||||
value, focus and disabled styling while preserving feature behavior and
|
||||
specialized controls (mazy1998, #1287).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Unify segmented selectors for model coverage, detection allocation, drawing
|
||||
shape, Street View and imagery comparison while preserving their existing
|
||||
behavior. Keep compact labels such as FILL and FLAT on one line, and let
|
||||
collapsed Weather release space to the open rail panel. Reserve the full mode
|
||||
button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Give ordinary Scene and CCTV actions shared native buttons with consistent
|
||||
default, destructive and compact styling while preserving their existing
|
||||
behavior and specialized controls (mazy1998, #1302).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Share native dropdown and numeric-field styling across Scene, CCTV, Display
|
||||
and Radio controls, with a shared CCTV number-input builder that preserves
|
||||
existing precision, validation and commit behavior (mazy1998, #1305).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Share read-only status badge presentation across CCTV source/frame labels,
|
||||
calibration provenance and Weather coverage, preserving status meanings,
|
||||
palettes, translations and announcements while preparing the view contract
|
||||
for a future Vue migration (mazy1998, #1306).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Clarify shared field CSS ownership: CCTV owns calibration editor sizing,
|
||||
while Radio and Cyber supply explicit field palettes without changing the
|
||||
current appearance or native control behavior (mazy1998, #1309).
|
||||
@@ -0,0 +1 @@
|
||||
- Validate provider response formats, strengthen camera source checks, and apply consistent API response protections (samehkhamis, #1317).
|
||||
@@ -374,6 +374,15 @@ renderer, persistence or services. The facade retains panel visibility, share
|
||||
restore claims and render scheduling. Both controls are destroyed before the
|
||||
facade's asynchronous teardown can yield.
|
||||
|
||||
## Ordinary action buttons
|
||||
|
||||
`src/ui/actionButton.js` is a native, listener-free view helper used by Scene
|
||||
presentation and sharing-toolbar owners. It receives translated text and explicit
|
||||
presentation/accessibility properties. Feature controllers own action dispatch,
|
||||
state, persistence and cleanup. Static Scenes/CCTV templates use the same CSS
|
||||
surface and preserve IDs and layout hooks. The helper imports no application,
|
||||
renderer, storage or services; it belongs to the consuming package closures.
|
||||
|
||||
## Display controls
|
||||
|
||||
`ui/display` owns Display button, selector and slider subscriptions. It receives
|
||||
@@ -729,3 +738,32 @@ sources supply `{records, droppedCount, status, saturated}`; viewport filtering,
|
||||
exact-bound retry and rendering stay in the layer. Legacy cache saturation is
|
||||
decoded in the source. Source and geometry modules remain independent of Cesium,
|
||||
DOM and platform middleware.
|
||||
|
||||
### Native form fields
|
||||
|
||||
`src/ui/formField.js` constructs native numeric inputs without listeners or
|
||||
application state. Static selects and number inputs use the same `gev-field`
|
||||
surface, with explicit compact and inline densities. Feature owners retain
|
||||
option identity/order, bounds, parsing, draft values, input/change timing and
|
||||
teardown. CCTV effective-pose conversion and tuner/device operations remain
|
||||
feature responsibilities. A Vue replacement can reuse this contract and CSS;
|
||||
it replaces DOM construction, rather than adding a second state owner.
|
||||
|
||||
The shared stylesheet owns field surfaces and disabled opacity; feature and theme
|
||||
palettes use `--gev-field-background`, `--gev-field-border-color`,
|
||||
`--gev-field-color` and `--gev-field-disabled-opacity`. Radio retains its own
|
||||
compact density and arrow space. CCTV owns calibration editor width in
|
||||
`cctv.css`; the shared inline density does not impose a feature-specific width.
|
||||
Cyber supplies a shared-field palette explicitly and keeps its legacy native
|
||||
field rules separate, so Radio's palette remains an intentional exception.
|
||||
|
||||
### Read-only status badges
|
||||
|
||||
`src/ui/statusBadge.js` constructs/updates ordinary badges without listeners,
|
||||
status inference or live-region policy. It consumes translated text and controlled
|
||||
visibility; appearance variants share CSS rather than mapping machine states to
|
||||
universal severity. CCTV owns frame/source and calibration rules and their data
|
||||
attributes, while Weather owns coverage labels. The keyed rail-card owner keeps
|
||||
node identity, header focus, disclosure and teardown. The shared adapter never
|
||||
creates another state store or announces all card updates. A Vue view can replace
|
||||
this adapter and retain these presentation inputs and styles.
|
||||
+151
-8
@@ -30,6 +30,16 @@ time. This clears obsolete contacts and settles startup without manufacturing
|
||||
position timestamps. Stale, incomplete and malformed empty results retain the
|
||||
existing recovery behavior.
|
||||
|
||||
Every `/api` response on the dev and preview servers carries
|
||||
`Content-Security-Policy: default-src 'none'; sandbox` and
|
||||
`X-Content-Type-Options: nosniff`. `apiResponseHeadersPlugin` sets them ahead of
|
||||
the Host check and matches `/api` the way the router mounts it (any letter case, a
|
||||
`/` or `.` boundary, absolute-form targets), so errors, partial responses, Host
|
||||
refusals and unknown routes are covered too; a route's own policy is sent as an
|
||||
additional policy, never in place of it. The app document's policy is unchanged.
|
||||
The GBFS proxy relays only bodies that parse as JSON, re-serialized as
|
||||
`application/json; charset=utf-8`; anything else answers 502.
|
||||
|
||||
Catalog source contracts and their consuming layers are declared in
|
||||
`src/app/sourceComposition.js`. Omitted or null acquisition sources preserve layer
|
||||
registration and serialization, report unavailable, and are refused by the existing
|
||||
@@ -812,6 +822,15 @@ standalone entry supplies application-owned scene, ground and activation service
|
||||
Each layer owns its state and visibility listener; destruction cancels source
|
||||
reads and pending initialization. Malformed health responses retain prior health.
|
||||
Existing catalog fallback, camera poses, frame pacing and coverage controls remain.
|
||||
CCTV frames and the Street View fallback accept only JPEG, PNG, GIF, WebP and
|
||||
AVIF, sent under the canonical type; any other type falls through to the next
|
||||
fallback. Live media must match its feed kind (`video/mp4`, `video/webm`, or
|
||||
`multipart/x-mixed-replace` with a boundary) before health reports it connected,
|
||||
and follows redirects only on the camera's origin. Catalog image URLs, and
|
||||
redirects on those hosts, are pinned by parsed URL to the agency's image
|
||||
directory. Every `/api/cctv` route refuses cross-site browser requests but
|
||||
accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded
|
||||
request skips the key-bearing Street View fallback and gets the placeholder.
|
||||
|
||||
Traffic owns its rendered-surface observer only while enabled, removes it on disable/destroy, and caches surface identity without allocating on unchanged frames.
|
||||
|
||||
@@ -1179,6 +1198,24 @@ snapshots, lifecycle actions and row descriptors. Remount and teardown remove
|
||||
listeners and row subscriptions; obsolete completions do not repaint old rows.
|
||||
The clear control presents busy state while its existing action owns the transaction.
|
||||
|
||||
## Sky background
|
||||
|
||||
`src/sky/starrySky.js` fills Cesium's `Scene#skyBox` slot with 9,096 Yale
|
||||
Bright Star Catalogue stars and a soft ESO/S. Brunier Milky Way. Both layers
|
||||
rotate from ICRF/J2000 into the Earth-fixed frame at the scene clock, with
|
||||
a TEME fallback while Cesium's IAU 2006 tables load. Bundled assets and
|
||||
their provenance are documented in `src/data/local_data/sky/README.md`.
|
||||
|
||||
Anime fades out the Milky Way with its style intensity to avoid cel-shaded
|
||||
bands; the catalogue stars remain. A separate black 1×1 cube map preserves
|
||||
model environment lighting independently of the visible sky. The adapter
|
||||
is validated against Cesium 1.138.0 and must be revalidated on upgrades.
|
||||
|
||||
`scripts/qa-anime-sky.mjs` checks asset readiness, visible Normal sky and
|
||||
Anime band coverage against a Vite dev server.
|
||||
`scripts/qa-sky-model-lighting.mjs` compares a lit `airplane.glb` against
|
||||
black and white environment maps in isolated scenes.
|
||||
|
||||
## Model atmosphere on Apple Metal
|
||||
|
||||
Cesium's per-vertex model atmosphere is kept out of the pipeline on devices
|
||||
@@ -1224,6 +1261,84 @@ The application supplies actions and retains effect settings, restore claims and
|
||||
rendering. Native keyboard editing, model modes and current defaults are preserved.
|
||||
Destroying the UI removes these listeners before asynchronous teardown.
|
||||
|
||||
Ordinary sliders share the native range component in `ui/slider.js` and the
|
||||
`gev-quantitative-slider` surface in `ui/styles/slider.css`. Display, shader
|
||||
parameters, volume, replay speed, weather settings/history, imagery opacity and
|
||||
Street View's date filter use the same rail, thumb, value styling, keyboard focus and disabled state.
|
||||
Generated controls use `createSlider`; static templates apply the same class.
|
||||
Feature owners retain bounds, steps, formatted values, input/change timing and
|
||||
listener cleanup. The radio tuning dial, imagery comparison divider and event
|
||||
comparison/progression controls keep their specialized presentation and behavior.
|
||||
|
||||
## Shared control CSS ownership
|
||||
|
||||
Shared styles own ordinary control surfaces and interaction states; feature CSS
|
||||
owns surrounding layout and context-specific sizing. CCTV's calibration editor
|
||||
width lives in `ui/styles/cctv.css`, while the shared inline field variant owns
|
||||
only density and spinner treatment. Radio supplies its field palette and disabled
|
||||
opacity through the `--gev-field-*` variables, retaining compact typography and
|
||||
arrow space locally. Cyber supplies shared-field palette variables explicitly;
|
||||
its legacy native-field palette rules exclude `.gev-field`. These ownership
|
||||
boundaries preserve the current Tactical/Cyber appearance and native behavior.
|
||||
|
||||
## Ordinary status badges
|
||||
|
||||
CCTV source/frame labels, calibration provenance and Weather coverage labels use
|
||||
`ui/statusBadge.js` and the `gev-status-badge` CSS surface. The listener-free
|
||||
adapter accepts already-translated `label` and `hidden` values; pill, box and plain
|
||||
appearances describe shape/density only. Static CCTV markup uses the same classes
|
||||
as generated rail-card badges. Repeated identical updates do not rewrite text or
|
||||
replace nodes. Empty coverage remains hidden.
|
||||
|
||||
Feature owners retain the status rules, machine-state attributes, translations
|
||||
and announcement policy. CCTV frame acquisition (`idle/loading/error/ready`)
|
||||
remains separate from provider status: retained pixels do not imply a healthy
|
||||
feed. Calibration still distinguishes manually saved, catalog-curated and raw
|
||||
prior poses; unsaved edits override the label until save/reset. Coverage labels
|
||||
remain geographic scope, not health claims. Existing colors and the source
|
||||
badge's polite live region are preserved; coverage and calibration do not gain
|
||||
live regions. Interactive layer toggles, progress indicators and world overlays
|
||||
retain their specialized ownership. Vue can reuse the view contract and CSS while
|
||||
replacing DOM construction. Browser acceptance:
|
||||
`QA_BASE_URL=http://127.0.0.1:4189 node scripts/qa-status-badges.mjs`.
|
||||
|
||||
## Ordinary action buttons
|
||||
|
||||
Scene panel actions, shot LOAD/DEL actions, the EDIT DETAILS / SHARE SCENE
|
||||
bar and one-shot CCTV actions use `ui/actionButton.js` and the shared
|
||||
`gev-action-button` surface in `ui/styles/action-button.css`. Static templates
|
||||
apply the same classes as generated buttons. The native builder accepts translated
|
||||
text, default/danger variants, default/compact sizes and disabled/accessibility
|
||||
properties; it installs no listeners and owns no application state. Feature
|
||||
controllers retain callbacks, enable/disable rules, confirmation prompts,
|
||||
persistence and teardown. The smaller shot actions retain their existing sizing
|
||||
and hover treatment. Feature CSS still owns panel layout and translated wrapping.
|
||||
|
||||
CCTV enable, coverage, auto-hop, projection and adjustment toggles, editable
|
||||
calibration-value chips and the scene authoring dialogs retain their specialized
|
||||
controls. Existing `scene-btn` hooks remain compatible with Street Level and
|
||||
legacy toggles. The shared CSS, explicit input/action boundary and behavior tests
|
||||
can carry to a Vue view while Vue replaces native DOM construction.
|
||||
Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4179 node scripts/qa-action-buttons.mjs`;
|
||||
`qa-scene-controls.mjs` also covers real Scene authoring/playback with native Space capture.
|
||||
|
||||
## Segmented selectors
|
||||
|
||||
Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js`
|
||||
and the shared `gev-segmented` / `gev-segment` surface. Model coverage, label
|
||||
allocation, drawing shape, Street View fit/panorama filters and imagery mode keep
|
||||
their feature-owned values, actions and persistence. Painting a restored value
|
||||
does not emit a change or move focus. Existing keyboard policies remain explicit:
|
||||
Street View uses arrows/Home/End with one tab stop, imagery mode uses arrows from
|
||||
its controlled selection, and Display keeps its existing native button behavior.
|
||||
Pure value navigation is separate from DOM rendering for reuse by a future view.
|
||||
Segments keep their word minimum width: compact inline groups such as FIT/FILL
|
||||
and ALL/360°/FLAT must not break short labels mid-word. Collapsed Weather follows
|
||||
the same exclusive-rail hiding rule as the other readouts.
|
||||
The imagery mode row reserves 26px for the 24px buttons and group borders so
|
||||
the complete button and inset keyboard-focus outline remain visible.
|
||||
Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4173 node scripts/qa-segmented-controls.mjs`.
|
||||
|
||||
## Application shortcuts and shader parameter controls
|
||||
|
||||
`ui/input` supplies the bubbling application shortcut listener and generated
|
||||
@@ -2773,11 +2888,16 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> first later successful fetch. Smaller chunks reduce timeout risk; complete
|
||||
> camera batches still wait for their sequential requests, and unresolved
|
||||
> placement continues to use the existing prior until real heights arrive.
|
||||
> - **Overpass cache admission:** `/api/overpass` parses and sanitizes requests,
|
||||
> then checks fresh memory, identical in-flight work, and fresh disk entries
|
||||
> before invoking its local 90/min limiter. Cache and single-flight responses
|
||||
> therefore do not spend quota; upstream-bound misses retain the existing
|
||||
> limiter, configured-upstream, stale, and sanitization behavior. An unconfigured transport serves dated stale data or a non-retryable capability error.
|
||||
> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded
|
||||
> POSTs through the shared local-request gate, then limits requests to 300/min
|
||||
> per socket peer and 900/min globally before reading or validating bodies.
|
||||
> Tag-filter brackets are scanned once; nested or unmatched brackets return 400.
|
||||
> It then checks fresh memory, identical in-flight work, and fresh disk entries
|
||||
> before invoking its separate local 90/min upstream limiter. Cache and
|
||||
> single-flight responses therefore do not spend upstream quota; upstream-bound
|
||||
> misses retain the existing limiter, configured-upstream, stale, and sanitization
|
||||
> behavior. An unconfigured transport serves dated stale data or a non-retryable
|
||||
> capability error.
|
||||
> - **CCTV world-click focus:** clicking an in-world CCTV icon or ambient card
|
||||
> activates it and routes the camera flight through the panel FOCUS policy.
|
||||
> Aircraft/satellite tracking releases outside cockpit; cockpit retains the
|
||||
@@ -3685,8 +3805,8 @@ lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it ignores
|
||||
`fieldOfView`) and upload date and checks each answer the same way (when
|
||||
photos are near but none passes, the panel says so), and the cones pass on
|
||||
each photo's projection and upload date. The provider declares
|
||||
`sinceBasis: 'upload'`, so while it is lit the panel shows "KartaView: by
|
||||
upload date" under the SINCE slider (`sinceNote` in the UI state). Tiles come uncompressed and
|
||||
`sinceBasis: 'upload'`, so while it is lit the panel shows "(by
|
||||
upload date)" beside the SINCE value, as ANY DATE (by upload date) (`sinceNote` in the UI state). Tiles come uncompressed and
|
||||
uncacheable, so the last 24 decoded tiles are kept for 10 minutes while
|
||||
KartaView is lit (`createTileCache`, dropped when it is switched off); one
|
||||
that does not answer in 20 s is asked once more, and one that is not a line
|
||||
@@ -4593,6 +4713,12 @@ Retry-After; absent delays back off from 30 seconds to five minutes. Empty
|
||||
remain. Malformed capability-probe responses remain unknown and are retried after the existing 30-second backoff; valid configured decisions remain memoized.
|
||||
Consumer capability misses never schedule automatic retries.
|
||||
|
||||
Keyless browser validation also probes the optional Google tiles-token route.
|
||||
The token client cancels rejected response bodies before returning unavailable,
|
||||
so they do not remain pending in browser network observation. The Overpass gate
|
||||
accepts only that same-origin route's expected 404 console diagnostic; other
|
||||
HTTP and JavaScript errors still fail acceptance.
|
||||
|
||||
Operator-configured Overpass area and focus-footprint queries use body-level
|
||||
`out geom` (with `center` for focus), preserving relation member outlines.
|
||||
Member geometry is printed only inside a box around the point (6 km for
|
||||
@@ -5041,7 +5167,7 @@ pins and share links already supply complete boxes.
|
||||
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
|
||||
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
|
||||
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
|
||||
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
||||
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the first reveal within a bounded wall-clock window before the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
||||
|
||||
### TR-3B conversion Easter egg (August 2026)
|
||||
|
||||
@@ -5912,3 +6038,20 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye
|
||||
|
||||
Military fragments stitch by source identity. Different mapped parcels merge only
|
||||
when their footprints touch or contain one another, never across a seam tolerance gap.
|
||||
|
||||
### Shared native dropdowns and numeric fields
|
||||
|
||||
- Scene and CCTV selects share the default `gev-field` surface. HUD layout and
|
||||
draw colour retain compact density. Radio filter, tuner gain and FM frequency
|
||||
use the same base while retaining their feature-specific skins and layout.
|
||||
- The CCTV inline number editor uses `createNumberInput` with its existing
|
||||
precision and inline density. Enter/blur commit, Escape cancellation, frozen
|
||||
camera base, camera-switch guards and disposal remain with CCTV controls.
|
||||
- Options, values, native keyboard/typeahead, disabled attributes and browser
|
||||
validity remain native. Frequency bounds, tuner commit timing and calibration
|
||||
parsing are unchanged. Specialized tuner graphics and calibration chips
|
||||
retain their own controls. Provider settings and scene-authoring dialogs are
|
||||
outside this change.
|
||||
- `scripts/qa-form-fields.mjs` exercises native selections, number validity,
|
||||
calibration commit/cancel and stale-camera/disposal guards against an installed
|
||||
controller with fixture ports, plus default/Cyber and narrow translated layouts.
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build src/data/local_data/sky/bright_stars.json from the Yale Bright Star
|
||||
* Catalogue, 5th Revised Edition (Hoffleit & Warren 1991).
|
||||
*
|
||||
* Source: CDS VizieR catalogue V/50, https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50
|
||||
* Fetched: https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz
|
||||
* Format: fixed-width ASCII, 197 bytes per record, byte layout in
|
||||
* https://cdsarc.cds.unistra.fr/ftp/V/50/ReadMe
|
||||
* License: public domain (Yale University Observatory / NASA ADC); CDS asks
|
||||
* for an acknowledgement, which the Data attribution popover carries.
|
||||
*
|
||||
* Transform (deterministic):
|
||||
* 1. J2000 RA/Dec (bytes 76-90), V magnitude (103-107) and B−V (110-114).
|
||||
* 2. The 14 records without a J2000 position (novae and other objects later
|
||||
* found not to be stars) are dropped.
|
||||
* 3. RA/Dec rounded to 3 decimals of a degree (3.6″ — a fraction of a pixel
|
||||
* at any field of view the globe uses), magnitudes to 2.
|
||||
* 4. Missing B−V is stored as null; the renderer gives it a neutral colour.
|
||||
* 5. Records keep catalogue (HR) order for a stable diff.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-bright-stars.mjs [catalog | catalog.gz]
|
||||
* With no argument it downloads the catalogue from CDS.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SOURCE_URL = 'https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz';
|
||||
const OUT = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'src',
|
||||
'data',
|
||||
'local_data',
|
||||
'sky',
|
||||
'bright_stars.json',
|
||||
);
|
||||
|
||||
/** Slice 1-based inclusive byte columns the way the CDS ReadMe numbers them. */
|
||||
function columns(line, from, to) {
|
||||
return line.slice(from - 1, to).trim();
|
||||
}
|
||||
|
||||
/** Parse a fixed-width float; blank fields are null. */
|
||||
function parseField(text) {
|
||||
if (text === '') return null;
|
||||
const value = Number(text);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one BSC5 record. Returns null for records without a J2000 position
|
||||
* or V magnitude.
|
||||
*/
|
||||
export function parseBscRecord(line) {
|
||||
const hr = parseField(columns(line, 1, 4));
|
||||
const raH = parseField(columns(line, 76, 77));
|
||||
const raM = parseField(columns(line, 78, 79));
|
||||
const raS = parseField(columns(line, 80, 83));
|
||||
const sign = columns(line, 84, 84);
|
||||
const deD = parseField(columns(line, 85, 86));
|
||||
const deM = parseField(columns(line, 87, 88));
|
||||
const deS = parseField(columns(line, 89, 90));
|
||||
const vmag = parseField(columns(line, 103, 107));
|
||||
const bv = parseField(columns(line, 110, 114));
|
||||
if (
|
||||
hr === null ||
|
||||
raH === null ||
|
||||
raM === null ||
|
||||
raS === null ||
|
||||
deD === null ||
|
||||
deM === null ||
|
||||
deS === null ||
|
||||
vmag === null ||
|
||||
(sign !== '+' && sign !== '-')
|
||||
)
|
||||
return null;
|
||||
const raDeg = (raH + raM / 60 + raS / 3600) * 15;
|
||||
const decDeg = (sign === '-' ? -1 : 1) * (deD + deM / 60 + deS / 3600);
|
||||
return { hr, raDeg, decDeg, vmag, bv };
|
||||
}
|
||||
|
||||
const round = (value, decimals) => {
|
||||
const scale = 10 ** decimals;
|
||||
return Math.round(value * scale) / scale;
|
||||
};
|
||||
|
||||
/** Build the committed pack from the raw catalogue text. */
|
||||
export function buildStarPack(text, { fetched = null } = {}) {
|
||||
const stars = [];
|
||||
let dropped = 0;
|
||||
for (const line of text.split('\n')) {
|
||||
if (line.trim() === '') continue;
|
||||
const record = parseBscRecord(line);
|
||||
if (!record) {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
stars.push(
|
||||
round(record.raDeg, 3),
|
||||
round(record.decDeg, 3),
|
||||
round(record.vmag, 2),
|
||||
record.bv === null ? null : round(record.bv, 2),
|
||||
);
|
||||
}
|
||||
return {
|
||||
meta: {
|
||||
source:
|
||||
'Yale Bright Star Catalogue, 5th Revised Ed. (Hoffleit & Warren 1991), CDS V/50',
|
||||
url: SOURCE_URL,
|
||||
license: 'public domain',
|
||||
fetched,
|
||||
frame: 'ICRF/J2000, epoch 2000.0',
|
||||
columns: ['raDeg', 'decDeg', 'vmag', 'bv'],
|
||||
count: stars.length / 4,
|
||||
dropped,
|
||||
},
|
||||
stars,
|
||||
};
|
||||
}
|
||||
|
||||
async function readCatalogue(file) {
|
||||
if (!file) {
|
||||
const response = await fetch(SOURCE_URL);
|
||||
if (!response.ok) throw new Error(`${SOURCE_URL}: HTTP ${response.status}`);
|
||||
return zlib
|
||||
.gunzipSync(Buffer.from(await response.arrayBuffer()))
|
||||
.toString('latin1');
|
||||
}
|
||||
const bytes = fs.readFileSync(file);
|
||||
return (file.endsWith('.gz') ? zlib.gunzipSync(bytes) : bytes).toString(
|
||||
'latin1',
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [file] = process.argv.slice(2);
|
||||
const text = await readCatalogue(file);
|
||||
const pack = buildStarPack(text, {
|
||||
fetched: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
// One star per line keeps the JSON compact and the diff readable.
|
||||
const rows = [];
|
||||
for (let i = 0; i < pack.stars.length; i += 4)
|
||||
rows.push(JSON.stringify(pack.stars.slice(i, i + 4)).slice(1, -1));
|
||||
const body = `{"meta":${JSON.stringify(pack.meta)},\n"stars":[\n${rows.join(',\n')}\n]}\n`;
|
||||
fs.mkdirSync(path.dirname(OUT), { recursive: true });
|
||||
fs.writeFileSync(OUT, body);
|
||||
console.log(
|
||||
`wrote ${pack.meta.count} stars (${pack.meta.dropped} dropped) → ${path.relative(process.cwd(), OUT)} (${body.length} bytes)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
|
||||
@@ -0,0 +1,415 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build the soft Milky Way cube map in src/data/local_data/sky/ from the ESO
|
||||
* all-sky Milky Way panorama.
|
||||
*
|
||||
* Source page: https://www.eso.org/public/images/eso0932a/
|
||||
* Fetched: https://cdn.eso.org/images/original/eso0932a.tif
|
||||
* (6000×3000 8-bit sRGB TIFF, 27.7 MB; sha256 in README.md)
|
||||
* Credit: ESO/S. Brunier
|
||||
* License: CC BY 4.0 (https://www.eso.org/public/outreach/copyright/).
|
||||
* Modified: stars removed, blurred, reprojected to ICRF cube
|
||||
* faces and tone-mapped, as below.
|
||||
* Frame: plate carrée in galactic coordinates, centred on the Galactic
|
||||
* Centre, galactic longitude increasing to the left.
|
||||
*
|
||||
* Transform (deterministic):
|
||||
* 1. Resample to WORK_WIDTH×WORK_WIDTH/2, sRGB to linear light.
|
||||
* 2. Remove stars with a morphological opening: the renderer draws
|
||||
* catalogue stars itself, sharp and in the right place.
|
||||
* 3. Paint out the planets the 2008–2010 exposures caught (PLANETS).
|
||||
* 4. Blur, round on the sphere: σ = BLUR_DEG, widening to FAINT_BLUR_DEG
|
||||
* in the faint glow so unresolved stars leave no clumps.
|
||||
* 5. Resample into six WebGL cube faces (uploaded without flipY) with the
|
||||
* ICRF axes: +X to the March equinox, +Z to the north celestial pole.
|
||||
* 6. Median sky to black with a soft knee, brightest 0.01 % to ~97 %
|
||||
* white, on luminance to keep the hue; gamma 1/2.2, dither, WebP.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-milky-way.mjs <eso0932a.tif>
|
||||
* Needs the sharp dev dependency.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const FACE_SIZE = 1024;
|
||||
export const BLUR_DEG = 0.4;
|
||||
// Faint glow is grain of unresolved stars and needs the wide blur; bright
|
||||
// parts keep the fine one and their dust lanes. FAINT_RANGE (fraction of
|
||||
// black-to-white) is where the two blend.
|
||||
export const FAINT_BLUR_DEG = 2;
|
||||
const FAINT_RANGE = [0.005, 0.08];
|
||||
const WORK_WIDTH = 4096;
|
||||
const OPEN_RADIUS = 3;
|
||||
const WEBP_QUALITY = 96;
|
||||
// The median sky is black: above it the glow away from the plane reads as
|
||||
// grey blotches. SOFT_KNEE eases the cut in so it draws no edges.
|
||||
const BLACK_PERCENTILE = 0.5;
|
||||
const SOFT_KNEE = 0.03;
|
||||
const WHITE_PERCENTILE = 0.9999;
|
||||
const OUT_DIR = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'src',
|
||||
'data',
|
||||
'local_data',
|
||||
'sky',
|
||||
);
|
||||
|
||||
/**
|
||||
* Bright planets in the panorama, galactic longitude/latitude in degrees and
|
||||
* the radius painted over with the surrounding sky. By position both are
|
||||
* Jupiter, in Sagittarius (2008) and Aquarius (2010); no catalogue star lies
|
||||
* under either.
|
||||
*/
|
||||
export const PLANETS = Object.freeze([
|
||||
{ l: 13.4, b: -16.6, radiusDeg: 2.5 },
|
||||
{ l: 69.0, b: -58.4, radiusDeg: 2.5 },
|
||||
]);
|
||||
|
||||
/** Cube faces in WebGL upload order. */
|
||||
export const CUBE_FACES = Object.freeze([
|
||||
'positiveX',
|
||||
'negativeX',
|
||||
'positiveY',
|
||||
'negativeY',
|
||||
'positiveZ',
|
||||
'negativeZ',
|
||||
]);
|
||||
export const FACE_FILE_SUFFIX = Object.freeze({
|
||||
positiveX: 'px',
|
||||
negativeX: 'nx',
|
||||
positiveY: 'py',
|
||||
negativeY: 'ny',
|
||||
positiveZ: 'pz',
|
||||
negativeZ: 'nz',
|
||||
});
|
||||
|
||||
/**
|
||||
* ICRF (J2000 equatorial) → galactic rotation, rows = galactic axes
|
||||
* (Hipparcos catalogue, ESA SP-1200 vol. 1 §1.5.3).
|
||||
*/
|
||||
export const ICRF_TO_GALACTIC = Object.freeze([
|
||||
[-0.0548755604162154, -0.873437090234885, -0.4838350155487132],
|
||||
[0.4941094278755837, -0.4448296299600112, 0.7469822444972189],
|
||||
[-0.867666149019004, -0.1980763734312015, 0.4559837761750669],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Direction (not normalized) that the WebGL cube-map lookup sends to texel
|
||||
* coordinate (s, t) ∈ [0, 1]² of `face`, t = 0 being the first image row.
|
||||
* Inverse of the OpenGL ES 3.0 §3.8.10 major-axis table.
|
||||
*/
|
||||
export function cubeFaceDirection(face, s, t) {
|
||||
const sc = 2 * s - 1;
|
||||
const tc = 2 * t - 1;
|
||||
switch (face) {
|
||||
case 'positiveX':
|
||||
return [1, -tc, -sc];
|
||||
case 'negativeX':
|
||||
return [-1, -tc, sc];
|
||||
case 'positiveY':
|
||||
return [sc, 1, tc];
|
||||
case 'negativeY':
|
||||
return [sc, -1, -tc];
|
||||
case 'positiveZ':
|
||||
return [sc, -tc, 1];
|
||||
case 'negativeZ':
|
||||
return [-sc, -tc, -1];
|
||||
default:
|
||||
throw new RangeError(`Unknown cube face ${face}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rotate an ICRF direction into galactic coordinates. */
|
||||
export function icrfToGalactic([x, y, z]) {
|
||||
return ICRF_TO_GALACTIC.map((row) => row[0] * x + row[1] * y + row[2] * z);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a direction to (u, v) ∈ [0, 1)² of a plate carrée centred on
|
||||
* longitude 0 with longitude increasing to the left and v = 0 at +90°.
|
||||
*/
|
||||
export function directionToPlateCarree(x, y, z) {
|
||||
const longitude = Math.atan2(y, x);
|
||||
const latitude = Math.atan2(z, Math.hypot(x, y));
|
||||
let u = 0.5 - longitude / (2 * Math.PI);
|
||||
u -= Math.floor(u);
|
||||
return [u, 0.5 - latitude / Math.PI];
|
||||
}
|
||||
|
||||
/** Box widths whose three passes approximate a Gaussian of `sigma` (Kovesi). */
|
||||
function boxRadii(sigma) {
|
||||
const ideal = Math.sqrt((12 * sigma * sigma) / 3 + 1);
|
||||
let lower = Math.floor(ideal);
|
||||
if (lower % 2 === 0) lower -= 1;
|
||||
const upper = lower + 2;
|
||||
const m = Math.round(
|
||||
(12 * sigma * sigma - 3 * lower * lower - 12 * lower - 9) /
|
||||
(-4 * lower - 4),
|
||||
);
|
||||
return [0, 1, 2].map((i) => ((i < m ? lower : upper) - 1) / 2);
|
||||
}
|
||||
|
||||
/** Index along a line, wrapped or clamped at the ends. */
|
||||
function lineIndex(i, length, wrap) {
|
||||
if (wrap) return ((i % length) + length) % length;
|
||||
return i < 0 ? 0 : i >= length ? length - 1 : i;
|
||||
}
|
||||
|
||||
/** Running-sum box blur of one strided line. */
|
||||
function boxLine(src, dst, offset, stride, length, radius, wrap) {
|
||||
if (radius < 1) {
|
||||
for (let i = 0; i < length; i++)
|
||||
dst[offset + i * stride] = src[offset + i * stride];
|
||||
return;
|
||||
}
|
||||
const r = wrap ? Math.min(radius, Math.floor((length - 1) / 2)) : radius;
|
||||
const at = (i) => src[offset + lineIndex(i, length, wrap) * stride];
|
||||
let sum = 0;
|
||||
for (let i = -r; i <= r; i++) sum += at(i);
|
||||
const scale = 1 / (2 * r + 1);
|
||||
for (let i = 0; i < length; i++) {
|
||||
dst[offset + i * stride] = sum * scale;
|
||||
sum += at(i + r + 1) - at(i - r);
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimum or maximum over a (2r+1) window of one strided line. */
|
||||
function extremumLine(src, dst, offset, stride, length, radius, wrap, pick) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
let value = src[offset + lineIndex(i - radius, length, wrap) * stride];
|
||||
for (let k = -radius + 1; k <= radius; k++)
|
||||
value = pick(
|
||||
value,
|
||||
src[offset + lineIndex(i + k, length, wrap) * stride],
|
||||
);
|
||||
dst[offset + i * stride] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Separable minimum or maximum filter over a plate-carrée plane, in place. */
|
||||
function extremumPlane(plane, width, height, radius, pick) {
|
||||
const scratch = new Float32Array(plane.length);
|
||||
for (let y = 0; y < height; y++)
|
||||
extremumLine(plane, scratch, y * width, 1, width, radius, true, pick);
|
||||
for (let x = 0; x < width; x++)
|
||||
extremumLine(scratch, plane, x, width, height, radius, false, pick);
|
||||
}
|
||||
|
||||
/** Blur one plate-carrée channel in place, round on the sphere. */
|
||||
function blurPlane(plane, width, height, sigmaPx) {
|
||||
const scratch = new Float32Array(plane.length);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const latitude = (0.5 - (y + 0.5) / height) * Math.PI;
|
||||
const sigmaX = Math.min(
|
||||
width / 4,
|
||||
sigmaPx / Math.max(Math.cos(latitude), 1e-3),
|
||||
);
|
||||
for (const radius of boxRadii(sigmaX)) {
|
||||
boxLine(plane, scratch, y * width, 1, width, Math.round(radius), true);
|
||||
plane.set(scratch.subarray(y * width, (y + 1) * width), y * width);
|
||||
}
|
||||
}
|
||||
for (const radius of boxRadii(sigmaPx)) {
|
||||
for (let x = 0; x < width; x++)
|
||||
boxLine(plane, scratch, x, width, height, Math.round(radius), false);
|
||||
plane.set(scratch);
|
||||
}
|
||||
}
|
||||
|
||||
/** Unit vector for galactic longitude and latitude in degrees. */
|
||||
function galacticVector(l, b) {
|
||||
const lr = (l * Math.PI) / 180;
|
||||
const br = (b * Math.PI) / 180;
|
||||
return [
|
||||
Math.cos(br) * Math.cos(lr),
|
||||
Math.cos(br) * Math.sin(lr),
|
||||
Math.sin(br),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a disc of the sky with the median of the ring just outside it, per
|
||||
* channel. A median, not a mean: the planet's diffraction spikes reach far
|
||||
* past the disc and would lift a mean well above the sky. The blur that
|
||||
* follows hides the seam.
|
||||
*/
|
||||
function paintOut(planes, width, height, { l, b, radiusDeg }) {
|
||||
const centre = galacticVector(l, b);
|
||||
const inner = Math.cos((radiusDeg * Math.PI) / 180);
|
||||
const outer = Math.cos(((radiusDeg + 1) * Math.PI) / 180);
|
||||
const disc = [];
|
||||
const ring = [];
|
||||
const span = Math.ceil(((radiusDeg + 1) / 180) * height);
|
||||
const row = Math.round((0.5 - b / 180) * height);
|
||||
for (let y = Math.max(0, row - span); y < Math.min(height, row + span); y++) {
|
||||
const latitude = 90 - ((y + 0.5) * 180) / height;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const longitude = (0.5 - (x + 0.5) / width) * 360;
|
||||
const p = galacticVector(longitude, latitude);
|
||||
const cos = p[0] * centre[0] + p[1] * centre[1] + p[2] * centre[2];
|
||||
if (cos >= inner) disc.push(y * width + x);
|
||||
else if (cos >= outer) ring.push(y * width + x);
|
||||
}
|
||||
}
|
||||
for (const plane of planes) {
|
||||
const values = Float32Array.from(ring, (index) => plane[index]).sort();
|
||||
const median = values[values.length >> 1];
|
||||
for (const index of disc) plane[index] = median;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bilinear sample with longitude wrap and latitude clamp. */
|
||||
function samplePlane(plane, width, height, u, v) {
|
||||
const fx = u * width - 0.5;
|
||||
const fy = Math.min(Math.max(v * height - 0.5, 0), height - 1);
|
||||
const x0 = Math.floor(fx);
|
||||
const y0 = Math.floor(fy);
|
||||
const ax = fx - x0;
|
||||
const ay = fy - y0;
|
||||
const xa = ((x0 % width) + width) % width;
|
||||
const xb = (xa + 1) % width;
|
||||
const yb = Math.min(y0 + 1, height - 1);
|
||||
const top = plane[y0 * width + xa] * (1 - ax) + plane[y0 * width + xb] * ax;
|
||||
const bottom =
|
||||
plane[yb * width + xa] * (1 - ax) + plane[yb * width + xb] * ax;
|
||||
return top * (1 - ay) + bottom * ay;
|
||||
}
|
||||
|
||||
/** Deterministic hash noise in [-0.5, 0.5) for dithering. */
|
||||
function dither(index, channel) {
|
||||
let h = (index * 374761393 + channel * 668265263) | 0;
|
||||
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
||||
h ^= h >>> 16;
|
||||
return (h >>> 0) / 4294967296 - 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Black level with a soft knee: 0 at and below the level, then a quadratic
|
||||
* ease that joins the straight line, so faint sky fades out instead of
|
||||
* ending in a contour.
|
||||
*/
|
||||
export function softBlack(x, knee = SOFT_KNEE) {
|
||||
return x <= 0 ? 0 : (x * x) / (x + knee);
|
||||
}
|
||||
|
||||
export function smoothstep(edge0, edge1, x) {
|
||||
const t = Math.min(Math.max((x - edge0) / (edge1 - edge0), 0), 1);
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per pixel, keep `fine` where `weight(luminance of coarse)` is 1 and take
|
||||
* `coarse` where it is 0, blending in between. The weight follows the coarse
|
||||
* copy, so it is smooth itself and draws no seams along dark lanes. Writes
|
||||
* into `fine`.
|
||||
*/
|
||||
function blendByBrightness(fine, coarse, weight) {
|
||||
const [r] = fine;
|
||||
for (let i = 0; i < r.length; i++) {
|
||||
const w = weight((coarse[0][i] + coarse[1][i] + coarse[2][i]) / 3);
|
||||
for (let c = 0; c < 3; c++)
|
||||
fine[c][i] = coarse[c][i] + (fine[c][i] - coarse[c][i]) * w;
|
||||
}
|
||||
}
|
||||
|
||||
/** Value at `fraction` of the sorted luminance. */
|
||||
function luminancePercentile(planes, fraction) {
|
||||
const [r, g, b] = planes;
|
||||
const lum = new Float32Array(r.length);
|
||||
for (let i = 0; i < lum.length; i++) lum[i] = (r[i] + g[i] + b[i]) / 3;
|
||||
lum.sort();
|
||||
return lum[Math.min(lum.length - 1, Math.floor(fraction * lum.length))];
|
||||
}
|
||||
|
||||
const srgbToLinear = (value) =>
|
||||
value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
|
||||
async function decodePanorama(sharp, file) {
|
||||
const width = WORK_WIDTH;
|
||||
const height = WORK_WIDTH / 2;
|
||||
const raw = await sharp(file)
|
||||
.resize(width, height, { fit: 'fill', kernel: 'lanczos3' })
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
const lut = Float32Array.from({ length: 256 }, (_, i) =>
|
||||
srgbToLinear(i / 255),
|
||||
);
|
||||
const planes = [0, 1, 2].map(() => new Float32Array(width * height));
|
||||
for (let i = 0; i < width * height; i++)
|
||||
for (let c = 0; c < 3; c++) planes[c][i] = lut[raw[i * 3 + c]];
|
||||
return { width, height, planes };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [file] = process.argv.slice(2);
|
||||
if (!file) throw new Error('usage: build-milky-way.mjs <eso0932a.tif>');
|
||||
const { default: sharp } = await import('sharp');
|
||||
const { width, height, planes } = await decodePanorama(sharp, file);
|
||||
const sigmaPx = (BLUR_DEG / 180) * height;
|
||||
for (const plane of planes) {
|
||||
extremumPlane(plane, width, height, OPEN_RADIUS, Math.min);
|
||||
extremumPlane(plane, width, height, OPEN_RADIUS, Math.max);
|
||||
}
|
||||
// After the opening: it sinks the starry sky to the dark gaps between
|
||||
// stars, and a fill taken before it would stay brighter than the sky.
|
||||
for (const planet of PLANETS) paintOut(planes, width, height, planet);
|
||||
const wide = planes.map((plane) => Float32Array.from(plane));
|
||||
for (const plane of planes) blurPlane(plane, width, height, sigmaPx);
|
||||
for (const plane of wide)
|
||||
blurPlane(plane, width, height, (FAINT_BLUR_DEG / 180) * height);
|
||||
// Levels from the fine blur, so the blend weight and the output share
|
||||
// them; the blend moves the median only in the faint sky it smooths.
|
||||
const black = luminancePercentile(planes, BLACK_PERCENTILE);
|
||||
const white = luminancePercentile(planes, WHITE_PERCENTILE);
|
||||
blendByBrightness(planes, wide, (luminance) =>
|
||||
smoothstep(
|
||||
FAINT_RANGE[0],
|
||||
FAINT_RANGE[1],
|
||||
(luminance - black) / (white - black),
|
||||
),
|
||||
);
|
||||
let total = 0;
|
||||
for (const face of CUBE_FACES) {
|
||||
const pixels = Buffer.alloc(FACE_SIZE * FACE_SIZE * 3);
|
||||
for (let j = 0; j < FACE_SIZE; j++) {
|
||||
for (let i = 0; i < FACE_SIZE; i++) {
|
||||
const direction = cubeFaceDirection(
|
||||
face,
|
||||
(i + 0.5) / FACE_SIZE,
|
||||
(j + 0.5) / FACE_SIZE,
|
||||
);
|
||||
const [u, v] = directionToPlateCarree(...icrfToGalactic(direction));
|
||||
const index = j * FACE_SIZE + i;
|
||||
const rgb = [0, 1, 2].map((c) =>
|
||||
samplePlane(planes[c], width, height, u, v),
|
||||
);
|
||||
const luminance = (rgb[0] + rgb[1] + rgb[2]) / 3;
|
||||
const level = softBlack((luminance - black) / (white - black));
|
||||
for (let c = 0; c < 3; c++) {
|
||||
// Scale every channel by the luminance curve to keep the hue.
|
||||
const linear = luminance > 0 ? (rgb[c] / luminance) * level : 0;
|
||||
const encoded = Math.min(linear, 1) ** (1 / 2.2) * 255;
|
||||
pixels[index * 3 + c] = Math.min(
|
||||
255,
|
||||
Math.max(0, Math.round(encoded + dither(index, c))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const out = path.join(OUT_DIR, `milkyway_${FACE_FILE_SUFFIX[face]}.webp`);
|
||||
const info = await sharp(pixels, {
|
||||
raw: { width: FACE_SIZE, height: FACE_SIZE, channels: 3 },
|
||||
})
|
||||
.webp({ quality: WEBP_QUALITY, smartSubsample: true, effort: 6 })
|
||||
.toFile(out);
|
||||
total += info.size;
|
||||
console.log(`${path.relative(process.cwd(), out)}: ${info.size} bytes`);
|
||||
}
|
||||
console.log(`total ${total} bytes`);
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
|
||||
@@ -2,6 +2,8 @@
|
||||
".prettierrc.json",
|
||||
"package.json",
|
||||
"scripts/format-scope.json",
|
||||
"src/ui/styles/form-field.css",
|
||||
"scripts/qa-form-fields.mjs",
|
||||
"scripts/package-boundaries.json",
|
||||
"scripts/format.mjs",
|
||||
"scripts/next-layer-state-token.mjs",
|
||||
@@ -119,6 +121,7 @@
|
||||
"server/providers/cctv/normalize.js",
|
||||
"src/data/cctvMissingNumbers.test.mjs",
|
||||
"server/providers/cctv/sources.js",
|
||||
"src/data/cctvSourcePins.test.mjs",
|
||||
"server/providers/radio.js",
|
||||
"server/providers/radio/catalog.js",
|
||||
"server/providers/radio/constants.js",
|
||||
@@ -190,7 +193,10 @@
|
||||
"src/ui/visualInput.js",
|
||||
"src/ui/visualInput.test.mjs",
|
||||
"scripts/qa-visual-input.mjs",
|
||||
"scripts/qa-segmented-controls.mjs",
|
||||
"src/ui/displayControls.js",
|
||||
"src/ui/segmentedControl.test.mjs",
|
||||
"src/ui/styles/segmented-control.css",
|
||||
"src/ui/displayControls.test.mjs",
|
||||
"src/ui/cameraOrientationControls.js",
|
||||
"src/ui/cameraOrientationControls.test.mjs",
|
||||
@@ -278,6 +284,7 @@
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/sceneControls.test.mjs",
|
||||
"scripts/qa-scene-controls.mjs",
|
||||
"scripts/qa-action-buttons.mjs",
|
||||
"src/ui.js",
|
||||
"src/standalone/ui.js",
|
||||
"src/ui/applicationShell.js",
|
||||
@@ -301,6 +308,8 @@
|
||||
"src/ui/styles/status.css",
|
||||
"src/ui/styles/layers.css",
|
||||
"src/ui/styles/controls.css",
|
||||
"src/ui/styles/slider.css",
|
||||
"src/ui/styles/action-button.css",
|
||||
"src/ui/styles/cctv.css",
|
||||
"src/ui/styles/foundation.css",
|
||||
"src/ui/styles/first-run.css",
|
||||
@@ -1064,5 +1073,19 @@
|
||||
"src/tooling/changelogFragments.test.mjs",
|
||||
"src/data/gbfsProxyRateLimit.test.mjs",
|
||||
"src/data/routeSegment.test.mjs",
|
||||
"src/terrainHeightsBounds.test.mjs"
|
||||
"src/terrainHeightsBounds.test.mjs",
|
||||
"scripts/build-bright-stars.mjs",
|
||||
"scripts/build-milky-way.mjs",
|
||||
"src/sky/stars.test.mjs",
|
||||
"src/sky/starrySky.test.mjs",
|
||||
"src/tooling/skyAssets.test.mjs",
|
||||
"src/tooling/skyAssets.paintOut.test.mjs",
|
||||
"scripts/qa-anime-sky.mjs",
|
||||
"scripts/qa-sky-model-lighting.mjs",
|
||||
"server/providers/overpass/requestAdmission.test.mjs",
|
||||
"src/tooling/trackArrivalObservation.test.mjs",
|
||||
"src/ui/styles/status-badge.css",
|
||||
"src/ui/statusBadge.test.mjs",
|
||||
"src/ui/cctvBadges.test.mjs",
|
||||
"scripts/qa-status-badges.mjs"
|
||||
]
|
||||
@@ -22,7 +22,14 @@
|
||||
},
|
||||
"viewer": {
|
||||
"exports": ["./application/viewer"],
|
||||
"modules": ["src/app/viewer.js", "src/app/atmosphereCompat.js"],
|
||||
"modules": [
|
||||
"src/app/viewer.js",
|
||||
"src/app/atmosphereCompat.js",
|
||||
"src/sky/starrySky.js",
|
||||
"src/sky/stars.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/renderGovernor.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"vite-build": {
|
||||
@@ -187,6 +194,8 @@
|
||||
"exports": ["./server/providers/cctv"],
|
||||
"modules": [
|
||||
"server/providers/cctv.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"src/localRequestGate.mjs",
|
||||
"server/providers/cctv/cap.js",
|
||||
"server/providers/cctv/groundHeights.js",
|
||||
"server/providers/cctv/catalog.js",
|
||||
@@ -252,6 +261,7 @@
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
"server/providers/common/request.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"server/providers/overpass.js",
|
||||
"server/providers/overpass/cache.js",
|
||||
"server/providers/overpass/constants.js",
|
||||
@@ -261,6 +271,7 @@
|
||||
"server/providers/places/routes.js",
|
||||
"src/data/placeProviderPayloads.js",
|
||||
"src/data/routeSteps.js",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/sources/overpass.js",
|
||||
@@ -422,6 +433,7 @@
|
||||
"src/ui/visualInput.js",
|
||||
"src/ui/applicationShortcuts.js",
|
||||
"src/ui/styleParameters.js",
|
||||
"src/ui/slider.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
@@ -431,7 +443,7 @@
|
||||
},
|
||||
"display-controls": {
|
||||
"exports": ["./ui/display"],
|
||||
"modules": ["src/ui/displayControls.js"],
|
||||
"modules": ["src/ui/displayControls.js", "src/ui/segmentedControl.js"],
|
||||
"external": []
|
||||
},
|
||||
"visual-effects": {
|
||||
@@ -480,6 +492,7 @@
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCardBlocks.js",
|
||||
"src/ui/slider.js",
|
||||
"src/ui/chipGroup.js",
|
||||
"src/ui/rowList.js",
|
||||
"src/ui/railTimeline.js",
|
||||
@@ -496,7 +509,8 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/ui/keyGuidance.js",
|
||||
"src/ui/statusText.js"
|
||||
"src/ui/statusText.js",
|
||||
"src/ui/statusBadge.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -537,6 +551,7 @@
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/formField.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
@@ -545,7 +560,8 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js"
|
||||
"src/ui/unitLabels.js",
|
||||
"src/ui/statusBadge.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -612,6 +628,7 @@
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/actionButton.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
@@ -673,6 +690,7 @@
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/formField.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
@@ -701,6 +719,7 @@
|
||||
"src/ui/contextSubscriptions.js",
|
||||
"src/ui/contextTransactions.js",
|
||||
"src/ui/displayControls.js",
|
||||
"src/ui/segmentedControl.js",
|
||||
"src/ui/effects.js",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/layerPanel.js",
|
||||
@@ -734,11 +753,13 @@
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/actionButton.js",
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/shareRestoration.js",
|
||||
"src/ui/shellElements.js",
|
||||
"src/ui/shellFeedback.js",
|
||||
"src/ui/styleParameters.js",
|
||||
"src/ui/slider.js",
|
||||
"src/ui/surfaceKeyboard.js",
|
||||
"src/ui/uiLifetime.js",
|
||||
"src/ui/visualEffects.js",
|
||||
@@ -767,7 +788,8 @@
|
||||
"src/ui/unitLabels.js",
|
||||
"src/ui/keyGuidance.js",
|
||||
"src/ui/layerNames.js",
|
||||
"src/ui/statusText.js"
|
||||
"src/ui/statusText.js",
|
||||
"src/ui/statusBadge.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -845,6 +867,7 @@
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/actionButton.js",
|
||||
"src/contextModePolicy.js",
|
||||
"src/director/clock.js",
|
||||
"src/director/timeline.js",
|
||||
@@ -1716,6 +1739,8 @@
|
||||
"src/app/surfaceServices.js",
|
||||
"src/app/tools.js",
|
||||
"src/app/viewer.js",
|
||||
"src/sky/starrySky.js",
|
||||
"src/sky/stars.js",
|
||||
"src/app/atmosphereCompat.js",
|
||||
"src/bloom.js",
|
||||
"src/camera.js",
|
||||
@@ -2147,6 +2172,7 @@
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/formField.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
@@ -2176,6 +2202,7 @@
|
||||
"src/ui/contextSubscriptions.js",
|
||||
"src/ui/contextTransactions.js",
|
||||
"src/ui/displayControls.js",
|
||||
"src/ui/segmentedControl.js",
|
||||
"src/ui/effects.js",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/layerPanel.js",
|
||||
@@ -2209,11 +2236,13 @@
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/actionButton.js",
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/shareRestoration.js",
|
||||
"src/ui/shellElements.js",
|
||||
"src/ui/shellFeedback.js",
|
||||
"src/ui/styleParameters.js",
|
||||
"src/ui/slider.js",
|
||||
"src/ui/surfaceKeyboard.js",
|
||||
"src/ui/uiLifetime.js",
|
||||
"src/ui/visualEffects.js",
|
||||
@@ -2500,7 +2529,8 @@
|
||||
"src/voice/providerCapabilities.js",
|
||||
"src/voice/geminiSession.js",
|
||||
"src/voice/geminiAudio.js",
|
||||
"src/voice/geminiCaptureWorklet.js"
|
||||
"src/voice/geminiCaptureWorklet.js",
|
||||
"src/ui/statusBadge.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3207,11 +3237,13 @@
|
||||
"src/ui/applicationShortcuts.js",
|
||||
"src/ui/effects.js",
|
||||
"src/ui/styleParameters.js",
|
||||
"src/ui/slider.js",
|
||||
"src/ui/uiLifetime.js",
|
||||
"src/ui/visualEffects.js",
|
||||
"src/ui/visualInput.js",
|
||||
"src/ui/visualPresets.js",
|
||||
"src/ui/visualSettings.js",
|
||||
"src/ui/segmentedControl.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Scenes/CCTV action-button acceptance against dev or production preview:
|
||||
* QA_BASE_URL=http://127.0.0.1:4179 node scripts/qa-action-buttons.mjs
|
||||
* Uses the installed CCTV controller with fixture camera/action ports to prove
|
||||
* native dispatch/disabled/teardown without providers or world calibration writes.
|
||||
* Real Scene authoring/playback remains covered by qa-scene-controls.mjs.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let failures = 0;
|
||||
const check = (name, passed, detail) => {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) {
|
||||
failures++;
|
||||
console.log(JSON.stringify(detail));
|
||||
}
|
||||
};
|
||||
const shots = 'qa-shots/action-buttons';
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4179'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const inventory = await page.evaluate(() => {
|
||||
const ids = [
|
||||
'scene-new-btn',
|
||||
'scene-delete-btn',
|
||||
'scene-capture-btn',
|
||||
'scene-update-shot-btn',
|
||||
'scene-start-btn',
|
||||
'scene-stop-btn',
|
||||
'scene-next-btn',
|
||||
'scene-export-btn',
|
||||
'scene-import-btn',
|
||||
'scene-download-btn',
|
||||
'cctv-nearest-btn',
|
||||
'cctv-prev-btn',
|
||||
'cctv-next-btn',
|
||||
'cctv-focus-btn',
|
||||
'cctv-calib-save-btn',
|
||||
'cctv-calib-reset-btn',
|
||||
];
|
||||
const ordinary = ids.map((id) => document.getElementById(id));
|
||||
const generated = [
|
||||
...document.querySelectorAll(
|
||||
'.scene-shot-btn, [data-director-authoring] button',
|
||||
),
|
||||
];
|
||||
const toggles = [
|
||||
'cctv-enable-btn',
|
||||
'cctv-coverage-btn',
|
||||
'cctv-auto-hop-btn',
|
||||
'cctv-projection-btn',
|
||||
'cctv-adjust-btn',
|
||||
].map((id) => document.getElementById(id));
|
||||
return {
|
||||
shared: [...ordinary, ...generated].every(
|
||||
(node) =>
|
||||
node?.type === 'button' &&
|
||||
node.classList.contains('gev-action-button'),
|
||||
),
|
||||
generated: generated.length,
|
||||
layout: ordinary.every((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
return style.minWidth === '72px' && style.flexGrow === '1';
|
||||
}),
|
||||
togglesSeparate: toggles.every(
|
||||
(node) => node && !node.classList.contains('gev-action-button'),
|
||||
),
|
||||
compactDanger: !!document.querySelector(
|
||||
'.scene-shot-danger.gev-action-button--compact.gev-action-button--danger',
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'static and generated actions share the native component',
|
||||
inventory.shared && inventory.generated >= 4 && inventory.compactDanger,
|
||||
inventory,
|
||||
);
|
||||
check(
|
||||
'persistent CCTV toggles retain their existing controls',
|
||||
inventory.togglesSeparate,
|
||||
);
|
||||
check('ordinary actions retain their panel sizing', inventory.layout);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const owner = manager._cctvControls;
|
||||
owner._cctvUnsubscribe?.();
|
||||
owner._cctvUnsubscribe = null;
|
||||
window.__qaButtonCalls = [];
|
||||
const record = (...args) => window.__qaButtonCalls.push(args);
|
||||
owner.actions = {
|
||||
...owner.actions,
|
||||
isEnabled: () => true,
|
||||
toggleEnabled: async () => {
|
||||
record('enable');
|
||||
return true;
|
||||
},
|
||||
runExplicitFocus: (select, focus) => focus(select()),
|
||||
setParams: (params, options) => record('params', params, options),
|
||||
showToast: () => {},
|
||||
};
|
||||
owner.cctv = {
|
||||
focusNearest: () => {
|
||||
record('nearest');
|
||||
return 'a';
|
||||
},
|
||||
cycleCamera: (direction) => {
|
||||
record('cycle', direction);
|
||||
return 'b';
|
||||
},
|
||||
focusCamera: (id, seconds) => record('focus', id, seconds),
|
||||
};
|
||||
window.__qaCameraState = {
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
cameras: [
|
||||
{ id: 'a', city: 'Fixture', name: 'A' },
|
||||
{ id: 'b', city: 'Fixture', name: 'B' },
|
||||
],
|
||||
activeCamera: { id: 'a', name: 'A', basePose: {} },
|
||||
};
|
||||
owner._renderCctvState(window.__qaCameraState);
|
||||
manager.setPanelCollapsed('cctv-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
await page.focus('#cctv-next-btn');
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||
check(
|
||||
'native Space dispatches Next and its focus once',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
JSON.stringify(window.__qaButtonCalls) ===
|
||||
JSON.stringify([['enable'], ['cycle', 1], ['focus', 'b', 1.4]]),
|
||||
),
|
||||
);
|
||||
check(
|
||||
'native keyboard activation keeps a visible focus ring',
|
||||
await page.$eval(
|
||||
'#cctv-next-btn',
|
||||
(node) =>
|
||||
document.activeElement === node &&
|
||||
getComputedStyle(node).outlineStyle === 'solid',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.focus('#cctv-prev-btn');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||
check(
|
||||
'native Enter dispatches Previous once',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
JSON.stringify(window.__qaButtonCalls) ===
|
||||
JSON.stringify([['enable'], ['cycle', -1], ['focus', 'b', 1.4]]),
|
||||
),
|
||||
);
|
||||
|
||||
for (const [id, expected] of [
|
||||
['cctv-nearest-btn', [['enable'], ['nearest'], ['focus', 'a', 1.8]]],
|
||||
[
|
||||
'cctv-focus-btn',
|
||||
[
|
||||
['enable'],
|
||||
['focus', 'a', 1.9],
|
||||
['params', { selectedCameraId: 'a' }, { origin: 'user' }],
|
||||
],
|
||||
],
|
||||
]) {
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.click(`#${id}`);
|
||||
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
|
||||
const calls = await page.evaluate(() => window.__qaButtonCalls);
|
||||
check(
|
||||
`${id} retains its existing action and focus timing`,
|
||||
JSON.stringify(calls) === JSON.stringify(expected),
|
||||
calls,
|
||||
);
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.click('#cctv-calib-save-btn');
|
||||
await page.click('#cctv-calib-reset-btn');
|
||||
const calibration = await page.evaluate(() => window.__qaButtonCalls);
|
||||
check(
|
||||
'Save and Reset retain camera identity and user-origin dispatch',
|
||||
JSON.stringify(calibration) ===
|
||||
JSON.stringify([
|
||||
[
|
||||
'params',
|
||||
{ selectedCameraId: 'a', calibration: { cameraId: 'a', save: true } },
|
||||
{ origin: 'user' },
|
||||
],
|
||||
[
|
||||
'params',
|
||||
{
|
||||
selectedCameraId: 'a',
|
||||
calibration: { cameraId: 'a', reset: true },
|
||||
},
|
||||
{ origin: 'user' },
|
||||
],
|
||||
]),
|
||||
calibration,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaButtonCalls = [];
|
||||
window.__godsEyeView.styleManager._cctvControls._renderCctvState({
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
await page.click('#cctv-next-btn');
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const node = document.getElementById('cctv-next-btn');
|
||||
return node.disabled && Number(getComputedStyle(node).opacity) === 0.45;
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
check(
|
||||
'disabled actions stay inert and keep disabled presentation',
|
||||
await page.$eval(
|
||||
'#cctv-next-btn',
|
||||
(node) =>
|
||||
node.disabled &&
|
||||
getComputedStyle(node).opacity === '0.45' &&
|
||||
window.__qaButtonCalls.length === 0,
|
||||
),
|
||||
);
|
||||
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
for (const panel of ['scene-panel', 'cctv-panel']) {
|
||||
await page.evaluate((panel) => {
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}, panel);
|
||||
const fit = await page.$eval(`#${panel}`, (root) => {
|
||||
const buttons = [...root.querySelectorAll('.gev-action-button')];
|
||||
const rect = root.getBoundingClientRect();
|
||||
return {
|
||||
count: buttons.length,
|
||||
fits:
|
||||
buttons.length > 0 &&
|
||||
buttons.every((button) => {
|
||||
const box = button.getBoundingClientRect();
|
||||
return (
|
||||
box.width > 0 &&
|
||||
box.left >= rect.left &&
|
||||
box.right <= rect.right + 1 &&
|
||||
box.left >= 0 &&
|
||||
box.right <= innerWidth + 1 &&
|
||||
button.scrollWidth <= button.clientWidth
|
||||
);
|
||||
}),
|
||||
};
|
||||
});
|
||||
check(`${panel} actions fit at ${width}px`, fit.fits, fit);
|
||||
if (width !== 620)
|
||||
await page.screenshot({ path: `${shots}/${panel}-${width}.png` });
|
||||
}
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner.destroy();
|
||||
document.getElementById('cctv-next-btn').disabled = false;
|
||||
window.__qaButtonCalls = [];
|
||||
});
|
||||
await page.click('#cctv-next-btn');
|
||||
check(
|
||||
'destroyed controllers revoke action listeners',
|
||||
await page.evaluate(() => window.__qaButtonCalls.length === 0),
|
||||
);
|
||||
const localized = new URL(page.url());
|
||||
localized.searchParams.set('lang', 'ru');
|
||||
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
for (const panel of ['scene-panel', 'cctv-panel']) {
|
||||
await page.evaluate((panel) => {
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}, panel);
|
||||
const translated = await page.$eval(`#${panel}`, (root) => {
|
||||
const buttons = [...root.querySelectorAll('.gev-action-button')];
|
||||
return (
|
||||
document.documentElement.lang === 'ru' &&
|
||||
buttons.length > 0 &&
|
||||
buttons.every((node) => node.scrollWidth <= node.clientWidth)
|
||||
);
|
||||
});
|
||||
check(`Russian ${panel} action labels fit at 390px`, translated);
|
||||
await page.screenshot({ path: `${shots}/${panel}-ru-390.png` });
|
||||
}
|
||||
check('no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-anime-sky — the anime style must leave the sky without bands.
|
||||
*
|
||||
* Anime's luminance bands turn the faint Milky Way into blotches, so
|
||||
* src/ui/visualEffects.js fades the glow out with the style. Headless against
|
||||
* a Vite dev server, both sky assets must be ready. Normal must show the
|
||||
* Galactic Centre glow (at least 2 % of pixels at luma >= 10/255), then
|
||||
* Anime must retain stars while at most 0.5 % reaches the first cel band.
|
||||
* The Normal baseline measured 43 % in headless Chromium at 1000×700;
|
||||
* the 2 % floor leaves room for GPU differences while rejecting a black sky.
|
||||
*
|
||||
* Usage: node scripts/qa-anime-sky.mjs [--url http://localhost:4333/]
|
||||
* Exit: 0 pass, 1 fail, 2 the anime style was not reached.
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const i = process.argv.indexOf('--url');
|
||||
const URL_ =
|
||||
i > 0 && process.argv[i + 1] ? process.argv[i + 1] : 'http://localhost:4333/';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--ignore-gpu-blocklist'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const host = new URL(request.url()).hostname;
|
||||
if (host.endsWith('halfpixel.ai') || host.includes('worldfx'))
|
||||
request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', String(e).slice(0, 300)));
|
||||
await page.setViewport({ width: 1000, height: 700 });
|
||||
await page.evaluateOnNewDocument(() =>
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed'),
|
||||
);
|
||||
await page.goto(URL_, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.viewer?.scene, {
|
||||
timeout: 120000,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const sky = window.__godsEyeView.viewer.scene.skyBox;
|
||||
return (
|
||||
sky?._milkyWayCubeMap &&
|
||||
sky?._starCommand &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden')
|
||||
);
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const r = await page.evaluate(async () => {
|
||||
const entry = performance
|
||||
.getEntriesByType('resource')
|
||||
.map((e) => e.name)
|
||||
.find((n) => /\.vite\/deps\/cesium\.js/.test(n));
|
||||
if (!entry)
|
||||
throw new Error(
|
||||
'run against the Vite dev server (needs .vite/deps/cesium.js)',
|
||||
);
|
||||
const C = await import(entry),
|
||||
g = window.__godsEyeView,
|
||||
v = g.viewer,
|
||||
s = v.scene;
|
||||
g.styleManager.setStyle('normal', { userInitiated: true });
|
||||
const t = v.clock.currentTime;
|
||||
await C.Transforms.preloadIcrfFixed(
|
||||
new C.TimeInterval({
|
||||
start: C.JulianDate.addDays(t, -1, new C.JulianDate()),
|
||||
stop: C.JulianDate.addDays(t, 1, new C.JulianDate()),
|
||||
}),
|
||||
);
|
||||
const m = C.Transforms.computeIcrfToFixedMatrix(t);
|
||||
const ra = C.Math.toRadians(266.4),
|
||||
dec = C.Math.toRadians(-29);
|
||||
const look = C.Matrix3.multiplyByVector(
|
||||
m,
|
||||
new C.Cartesian3(
|
||||
Math.cos(dec) * Math.cos(ra),
|
||||
Math.cos(dec) * Math.sin(ra),
|
||||
Math.sin(dec),
|
||||
),
|
||||
new C.Cartesian3(),
|
||||
);
|
||||
const north = C.Matrix3.multiplyByVector(
|
||||
m,
|
||||
C.Cartesian3.UNIT_Z,
|
||||
new C.Cartesian3(),
|
||||
);
|
||||
const right = C.Cartesian3.normalize(
|
||||
C.Cartesian3.cross(look, north, new C.Cartesian3()),
|
||||
new C.Cartesian3(),
|
||||
);
|
||||
s.camera.cancelFlight();
|
||||
s.tweens.removeAll();
|
||||
s.camera.setView({
|
||||
destination: C.Cartesian3.multiplyByScalar(look, 3e7, new C.Cartesian3()),
|
||||
orientation: {
|
||||
direction: look,
|
||||
up: C.Cartesian3.cross(right, look, new C.Cartesian3()),
|
||||
},
|
||||
});
|
||||
const gl = s.context._gl,
|
||||
w = gl.drawingBufferWidth,
|
||||
h = gl.drawingBufferHeight,
|
||||
px = new Uint8Array(w * h * 4);
|
||||
const measure = async () => {
|
||||
await new Promise((res) => setTimeout(res, 2500));
|
||||
s.requestRender();
|
||||
s.render();
|
||||
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
|
||||
let banded = 0;
|
||||
let visible = 0;
|
||||
for (let i = 0; i < px.length; i += 4) {
|
||||
const luma = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
|
||||
if (luma >= 51) banded++;
|
||||
if (luma >= 10) visible++;
|
||||
}
|
||||
return { banded, visible };
|
||||
};
|
||||
const normal = await measure();
|
||||
g.styleManager.setStyle('anime', { userInitiated: true });
|
||||
const anime = await measure();
|
||||
let animeOn = false;
|
||||
for (let i = 0; i < s.postProcessStages.length; i++) {
|
||||
const stage = s.postProcessStages.get(i);
|
||||
if (stage.name === 'godsEyeView_anime')
|
||||
animeOn = stage.enabled && stage.uniforms.intensity > 0.99;
|
||||
}
|
||||
return {
|
||||
total: px.length / 4,
|
||||
normal,
|
||||
...anime,
|
||||
assetsReady: Boolean(s.skyBox._milkyWayCubeMap && s.skyBox._starCommand),
|
||||
visibility: s.skyBox.milkyWayVisibility,
|
||||
animeOn,
|
||||
};
|
||||
});
|
||||
const tag =
|
||||
!r.animeOn || !r.assetsReady
|
||||
? 'UNREACHED'
|
||||
: r.normal.visible >= 0.02 * r.total &&
|
||||
r.banded > 0 &&
|
||||
r.banded <= 0.005 * r.total &&
|
||||
r.visibility === 0
|
||||
? 'PASS'
|
||||
: 'FAIL';
|
||||
console.log(
|
||||
`${tag}: ${r.banded} of ${r.total} sky pixels at or above the first cel band (luma >= 0.2)\n ${JSON.stringify(r)}`,
|
||||
);
|
||||
process.exitCode = tag === 'PASS' ? 0 : tag === 'FAIL' ? 1 : 2;
|
||||
} catch (error) {
|
||||
console.error(`UNREACHED: ${error.message}`);
|
||||
process.exitCode = 2;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
#!/usr/bin/env node
|
||||
/** Native form-field regression gate. CCTV ports are fixtures, never live writes. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
let failures = 0;
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const check = (name, passed, detail) => {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) {
|
||||
failures++;
|
||||
console.log(JSON.stringify(detail));
|
||||
}
|
||||
};
|
||||
fs.mkdirSync('qa-shots/form-fields', { recursive: true });
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4183'}/?welcome=0`,
|
||||
{
|
||||
waitUntil: 'domcontentloaded',
|
||||
},
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const inventory = await page.evaluate(() => {
|
||||
const selects = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
'radio-filter',
|
||||
'sdr-gain-select',
|
||||
].map((id) => document.getElementById(id));
|
||||
return {
|
||||
ids: selects.map((node) => node.id),
|
||||
shared:
|
||||
selects.length === 6 &&
|
||||
selects.every((node) => node.classList.contains('gev-field')),
|
||||
native: selects.every(
|
||||
(node) =>
|
||||
node instanceof HTMLSelectElement && !node.hasAttribute('role'),
|
||||
),
|
||||
dark: selects.every(
|
||||
(node) => getComputedStyle(node).colorScheme === 'dark',
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'all six panel dropdowns retain native select semantics and shared surface',
|
||||
inventory.shared && inventory.native,
|
||||
inventory,
|
||||
);
|
||||
check('native option menus keep the dark scheme', inventory.dark);
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('pp-toggles', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager.setPanelCollapsed('scene-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager.setPanelCollapsed('cctv-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
window.__qaFormChanges = [];
|
||||
document
|
||||
.getElementById('scene-select')
|
||||
.addEventListener('change', (event) =>
|
||||
window.__qaFormChanges.push(event.target.value),
|
||||
);
|
||||
});
|
||||
const ownerSetup = await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner._cctvUnsubscribe?.();
|
||||
owner._cctvUnsubscribe = null;
|
||||
window.__qaFormCalls = [];
|
||||
owner.actions = {
|
||||
...owner.actions,
|
||||
isEnabled: () => true,
|
||||
toggleEnabled: async () => true,
|
||||
setParams: (...args) => window.__qaFormCalls.push(args),
|
||||
runExplicitFocus: (select, focus) => focus(select()),
|
||||
showToast: () => {},
|
||||
};
|
||||
owner.cctv = {
|
||||
setParams() {},
|
||||
focusCamera: (id) => window.__qaFormCalls.push(['focus', id]),
|
||||
selectCamera: (id) => id,
|
||||
};
|
||||
owner._cctvState = {
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
cameras: [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
],
|
||||
activeCamera: {
|
||||
id: 'a',
|
||||
headingDeg: 30,
|
||||
pitchDeg: -10,
|
||||
fovDeg: 45,
|
||||
rangeM: 120,
|
||||
mountHeightM: 8,
|
||||
calibration: {},
|
||||
basePose: {
|
||||
headingDeg: 20,
|
||||
pitchDeg: -10,
|
||||
fovDeg: 45,
|
||||
rangeM: 120,
|
||||
mountHeightM: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
owner._renderCctvState(owner._cctvState);
|
||||
const input = document.getElementById('sdr-frequency-input');
|
||||
return {
|
||||
min: input.min,
|
||||
max: input.max,
|
||||
step: input.step,
|
||||
type: input.type,
|
||||
shared: input.classList.contains('gev-field'),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'FM frequency retains native bounds, precision and shared surface',
|
||||
ownerSetup.min === '87.5' &&
|
||||
ownerSetup.max === '108' &&
|
||||
ownerSetup.step === '0.1' &&
|
||||
ownerSetup.type === 'number' &&
|
||||
ownerSetup.shared,
|
||||
ownerSetup,
|
||||
);
|
||||
const validity = await page.$eval('#sdr-frequency-input', (input) => {
|
||||
const original = input.value;
|
||||
input.value = '87.4';
|
||||
const below = input.validity.rangeUnderflow;
|
||||
input.value = '108.1';
|
||||
const above = input.validity.rangeOverflow;
|
||||
input.value = '98.55';
|
||||
const step = input.validity.stepMismatch;
|
||||
input.value = '';
|
||||
const empty = input.validity.valid;
|
||||
input.value = original;
|
||||
return below && above && step && empty;
|
||||
});
|
||||
check(
|
||||
'native numeric validity preserves underflow, overflow, steps and empty drafts',
|
||||
validity,
|
||||
);
|
||||
await page.select('#cctv-camera-select', 'b');
|
||||
await page.waitForFunction(() => window.__qaFormCalls.length === 2);
|
||||
const cameraSelection = await page.evaluate(() => window.__qaFormCalls);
|
||||
check(
|
||||
'camera dropdown retains selection, focus and user-origin dispatch',
|
||||
cameraSelection[0][0] === 'focus' &&
|
||||
cameraSelection[0][1] === 'b' &&
|
||||
cameraSelection[1][0].selectedCameraId === 'b' &&
|
||||
cameraSelection[1][1].origin === 'user',
|
||||
cameraSelection,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaFormCalls = [];
|
||||
document.getElementById('cctv-camera-select').disabled = true;
|
||||
});
|
||||
await page.focus('#scene-select');
|
||||
await page.focus('#cctv-camera-select');
|
||||
check(
|
||||
'disabled native selects cannot receive keyboard focus',
|
||||
await page.evaluate(
|
||||
() => document.activeElement.id !== 'cctv-camera-select',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('cctv-camera-select').disabled = false;
|
||||
});
|
||||
const begin = async (field) => {
|
||||
await page.evaluate((field) => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner._beginCctvCalValueEdit(
|
||||
document.querySelector(`[data-cal-field="${field}"]`),
|
||||
);
|
||||
}, field);
|
||||
};
|
||||
for (const [field, step, value] of [
|
||||
['heading', '0.1', '30.0'],
|
||||
['pitch', '0.1', '-10.0'],
|
||||
['fov', '1', '45'],
|
||||
['range', '1', '120'],
|
||||
['height', '1', '8'],
|
||||
['north', '0.1', '0.0'],
|
||||
['east', '0.1', '0.0'],
|
||||
]) {
|
||||
await begin(field);
|
||||
const result = await page.$eval('.cctv-cal-input', (input) => ({
|
||||
step: input.step,
|
||||
value: input.value,
|
||||
native: input.type === 'number',
|
||||
shared: input.classList.contains('gev-field'),
|
||||
label: input.getAttribute('aria-label'),
|
||||
focus: document.activeElement === input,
|
||||
}));
|
||||
check(
|
||||
`${field} editor retains precision, initial value and focus`,
|
||||
result.step === step &&
|
||||
result.value === value &&
|
||||
result.native &&
|
||||
result.shared &&
|
||||
!!result.label &&
|
||||
result.focus,
|
||||
result,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
check(
|
||||
'Escape cancels each field without a calibration write',
|
||||
await page.evaluate(() => window.__qaFormCalls.length === 0),
|
||||
);
|
||||
await begin('heading');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.type('.cctv-cal-input', '100');
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.styleManager._cctvControls._cctvState.activeCamera.basePose.headingDeg = 60;
|
||||
});
|
||||
await page.keyboard.press('Enter');
|
||||
const commit = await page.evaluate(() => window.__qaFormCalls);
|
||||
check(
|
||||
'Enter commits once against the frozen camera base',
|
||||
commit.length === 1 &&
|
||||
commit[0][0].calibration.patch.headingDeg === 80 &&
|
||||
commit[0][0].calibration.cameraId === 'a' &&
|
||||
commit[0][1].origin === 'user',
|
||||
commit,
|
||||
);
|
||||
await begin('north');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.type('.cctv-cal-input', '12.5');
|
||||
await page.$eval('.cctv-cal-input', (input) => input.blur());
|
||||
const blur = await page.evaluate(() => window.__qaFormCalls);
|
||||
check(
|
||||
'blur commits a decimal offset exactly once',
|
||||
blur.length === 2 && blur[1][0].calibration.patch.offsetNorthM === 12.5,
|
||||
blur,
|
||||
);
|
||||
await begin('east');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'an empty numeric draft does not write calibration',
|
||||
await page.evaluate(() => window.__qaFormCalls.length === 2),
|
||||
);
|
||||
await begin('heading');
|
||||
await page.type('.cctv-cal-input', '90');
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.styleManager._cctvControls._cctvState.activeCameraId =
|
||||
'b';
|
||||
});
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'a stale camera editor cannot commit to the next camera',
|
||||
await page.evaluate(() => window.__qaFormCalls.length === 2),
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
if (
|
||||
await page.$eval(
|
||||
'#draw-toggle',
|
||||
(node) => node.getAttribute('aria-pressed') !== 'true',
|
||||
)
|
||||
)
|
||||
await page.click('#draw-toggle');
|
||||
for (const lang of ['en']) {
|
||||
await page.evaluate((lang) => {
|
||||
document.documentElement.lang = lang;
|
||||
}, lang);
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
const layout = await page.evaluate(() => {
|
||||
const ids = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
];
|
||||
return ids.map((id) => {
|
||||
const node = document.getElementById(id),
|
||||
rect = node.getBoundingClientRect(),
|
||||
panel = node.closest('.panel-collapsible')?.getBoundingClientRect();
|
||||
return {
|
||||
id,
|
||||
width: rect.width,
|
||||
fits: rect.width > 0 && rect.width <= (panel?.width || innerWidth),
|
||||
radius: getComputedStyle(node).borderRadius,
|
||||
};
|
||||
});
|
||||
});
|
||||
check(
|
||||
`${lang} fields fit their panels at ${width}px`,
|
||||
layout.every((item) => item.fits),
|
||||
layout,
|
||||
);
|
||||
if (width === 390)
|
||||
await page.screenshot({
|
||||
path: `qa-shots/form-fields/${lang}-${width}.png`,
|
||||
});
|
||||
}
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.lang = 'en';
|
||||
document.documentElement.dataset.uiTheme = 'cyber';
|
||||
});
|
||||
const cyber = await page.$eval(
|
||||
'#scene-select',
|
||||
(node) => getComputedStyle(node).backgroundColor,
|
||||
);
|
||||
check(
|
||||
'Cyber keeps its native field theme',
|
||||
cyber === 'rgba(26, 35, 39, 0.96)',
|
||||
cyber,
|
||||
);
|
||||
await begin('heading');
|
||||
await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
window.__qaDetachedInput = document.querySelector('.cctv-cal-input');
|
||||
owner.destroy();
|
||||
window.__qaDetachedInput.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
check(
|
||||
'disposal removes the editor and prevents late commits',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!document.querySelector('.cctv-cal-input') &&
|
||||
window.__qaFormCalls.length === 2,
|
||||
),
|
||||
);
|
||||
// Scene selection can restore application settings. Exercise it after the
|
||||
// fixture-owned CCTV phase so those asynchronous effects cannot replace ports.
|
||||
page.once('dialog', (dialog) => dialog.accept('Baseline selection fixture'));
|
||||
await page.click('#scene-new-btn');
|
||||
await page.waitForFunction(() =>
|
||||
[...document.querySelectorAll('#scene-select option')].some(
|
||||
(option) => option.textContent === 'Baseline selection fixture',
|
||||
),
|
||||
);
|
||||
page.once('dialog', (dialog) => dialog.accept('QA selection fixture'));
|
||||
await page.click('#scene-new-btn');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('#scene-select option').length > 1,
|
||||
);
|
||||
const targetSceneId = await page.$eval(
|
||||
'#scene-select',
|
||||
(node) =>
|
||||
[...node.options].find(
|
||||
(option) => option.textContent === 'QA selection fixture',
|
||||
).value,
|
||||
);
|
||||
const baselineSceneId = await page.$eval(
|
||||
'#scene-select',
|
||||
(node) =>
|
||||
[...node.options].find(
|
||||
(option) => option.textContent === 'Baseline selection fixture',
|
||||
).value,
|
||||
);
|
||||
await page.select('#scene-select', baselineSceneId);
|
||||
await page.waitForFunction(
|
||||
(id) => window.__godsEyeView.sceneDirector._getSelectedScene().id !== id,
|
||||
{},
|
||||
targetSceneId,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaFormChanges = [];
|
||||
});
|
||||
await page.focus('#scene-select');
|
||||
// Typeahead is native even when headless Chrome does not drive OS popups.
|
||||
await page.keyboard.press('q');
|
||||
const sceneSelection = await page.evaluate(() => ({
|
||||
id: document.getElementById('scene-select').value,
|
||||
selected: window.__godsEyeView.sceneDirector._getSelectedScene().id,
|
||||
changes: window.__qaFormChanges,
|
||||
focus: document.activeElement.id,
|
||||
outline: getComputedStyle(document.activeElement).outlineStyle,
|
||||
}));
|
||||
check(
|
||||
'native typeahead reaches the Scene owner and keeps focus',
|
||||
sceneSelection.id === targetSceneId &&
|
||||
sceneSelection.selected === targetSceneId &&
|
||||
sceneSelection.changes.at(-1) === targetSceneId &&
|
||||
sceneSelection.focus === 'scene-select',
|
||||
sceneSelection,
|
||||
);
|
||||
check('keyboard focus remains visible', sceneSelection.outline === 'solid');
|
||||
const localized = new URL(page.url());
|
||||
localized.searchParams.set('lang', 'ru');
|
||||
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
if (
|
||||
await page.$eval(
|
||||
'#draw-toggle',
|
||||
(node) => node.getAttribute('aria-pressed') !== 'true',
|
||||
)
|
||||
)
|
||||
await page.click('#draw-toggle');
|
||||
const translated = await page.evaluate(() => {
|
||||
const nodes = [
|
||||
'scene-select',
|
||||
'cctv-camera-select',
|
||||
'hud-layout-select',
|
||||
'draw-color-select',
|
||||
].map((id) => document.getElementById(id));
|
||||
return (
|
||||
document.documentElement.lang === 'ru' &&
|
||||
nodes.every(
|
||||
(node) =>
|
||||
node.clientWidth > 0 &&
|
||||
node.getBoundingClientRect().width <=
|
||||
node.closest('.panel-collapsible').getBoundingClientRect().width,
|
||||
)
|
||||
);
|
||||
});
|
||||
check('real Russian labels and fields fit at 390px', translated);
|
||||
await page.screenshot({ path: 'qa-shots/form-fields/ru-390.png' });
|
||||
check('no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -306,7 +306,16 @@ try {
|
||||
await observeNetwork(page);
|
||||
page.on('pageerror', (error) => result.errors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error' && result.consoleErrors.length < 30)
|
||||
if (message.type() !== 'error') return;
|
||||
// A keyless server does not offer optional Google tokens. Keep every other
|
||||
// HTTP or JavaScript error visible, including failures of this route other
|
||||
// than its expected capability-miss 404.
|
||||
const optionalTokenMiss =
|
||||
message.location().url === new URL('/api/google/tiles-token', url).href &&
|
||||
/^Failed to load resource: the server responded with a status of 404\b/.test(
|
||||
message.text(),
|
||||
);
|
||||
if (!optionalTokenMiss && result.consoleErrors.length < 30)
|
||||
result.consoleErrors.push(message.text());
|
||||
});
|
||||
page.on('requestfailed', (request) => {
|
||||
|
||||
@@ -66,7 +66,8 @@ try {
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.click('#scene-capture-btn');
|
||||
await page.focus('#scene-capture-btn');
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('.scene-shot-row').length === 1,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env node
|
||||
/** Run against a dev server or built preview with QA_BASE_URL (default :4179). */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()),
|
||||
headless: true,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
const failures = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const check = (name, passed, detail = '') => {
|
||||
console.log(
|
||||
`[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
|
||||
);
|
||||
if (!passed) failures.push(name);
|
||||
};
|
||||
const base = process.env.QA_BASE_URL || 'http://localhost:4179';
|
||||
const shots = process.env.QA_SHOTS_DIR || 'qa-shots/segmented-controls';
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const surface = await page.evaluate(() => {
|
||||
const groups = [...document.querySelectorAll('[role="radiogroup"]')];
|
||||
const buttons = groups.flatMap((group) => [
|
||||
...group.querySelectorAll('[role="radio"]'),
|
||||
]);
|
||||
return {
|
||||
groups: groups.length,
|
||||
buttons: buttons.length,
|
||||
shared:
|
||||
groups.every((g) => g.classList.contains('gev-segmented')) &&
|
||||
buttons.every((b) => {
|
||||
const style = getComputedStyle(b);
|
||||
return (
|
||||
b.classList.contains('gev-segment') &&
|
||||
style.fontSize === '9px' &&
|
||||
style.minHeight === '24px' &&
|
||||
style.borderRadius === '0px'
|
||||
);
|
||||
}),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'all six one-of-many groups use the shared surface',
|
||||
surface.groups === 6 && surface.buttons === 15 && surface.shared,
|
||||
JSON.stringify(surface),
|
||||
);
|
||||
|
||||
await page.evaluate(() =>
|
||||
document.getElementById('models3d-mode-all').click(),
|
||||
);
|
||||
check(
|
||||
'model selection reaches both aircraft layers',
|
||||
await page.evaluate(() => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
return (
|
||||
['flights', 'military'].every(
|
||||
(id) => dm.layers.get(id).module.getParams().models3dMode === 'all',
|
||||
) &&
|
||||
document
|
||||
.getElementById('models3d-mode-all')
|
||||
.getAttribute('aria-checked') === 'true'
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.focus('#models3d-mode-all');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
check(
|
||||
'Display retains its click-only arrow policy',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.getElementById('models3d-mode-all')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
await page.focus('#models3d-mode-proximity');
|
||||
await page.keyboard.press('Space');
|
||||
check(
|
||||
'native Space selects model coverage',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.getElementById('models3d-mode-proximity')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
document.getElementById('detection-allocation-weighted').click(),
|
||||
);
|
||||
check(
|
||||
'allocation selection still updates application tuning',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.styleManager._detectionAllocationPreference ===
|
||||
'WEIGHTED' &&
|
||||
document
|
||||
.getElementById('detection-allocation-weighted')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
|
||||
await page.evaluate(() =>
|
||||
document.querySelector('[data-shape="pin"]').click(),
|
||||
);
|
||||
check(
|
||||
'drawing shape still uses its existing feature action',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__gevDrawTool?.shape === 'pin' &&
|
||||
document
|
||||
.querySelector('[data-shape="pin"]')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
|
||||
// Off-layer controls still own params; no catalog/provider request is needed.
|
||||
await page.evaluate(() =>
|
||||
document.querySelector('[data-sl-pano="flat"]').click(),
|
||||
);
|
||||
check(
|
||||
'panorama choice reaches the Street Level layer',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getParams().pano === 'flat',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
document.querySelector('[data-sl-pano="flat"]').dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Home',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
check(
|
||||
'Street Level Home retains selection and roving tab state',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getParams().pano === 'all' &&
|
||||
document
|
||||
.querySelector('[data-sl-pano="all"]')
|
||||
.getAttribute('tabindex') === '0',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
document.querySelector('[data-sl-render="fill"]').click(),
|
||||
);
|
||||
check(
|
||||
'fit/fill keeps its stored render preference',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
localStorage.getItem('gev:street-level:render-mode') === 'fill' &&
|
||||
document
|
||||
.querySelector('[data-sl-render="fill"]')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
|
||||
await page.evaluate(() =>
|
||||
document.querySelector('.ri-mode-btn[data-mode="ab"]').click(),
|
||||
);
|
||||
check(
|
||||
'imagery mode reaches its controlled layer state',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('recent-imagery')
|
||||
.module.getSnapshot().mode === 'ab',
|
||||
),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
document.getElementById('ri-mode').dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'ArrowRight',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
check(
|
||||
'imagery arrows wrap from the current selected value',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('recent-imagery')
|
||||
.module.getSnapshot().mode === 'image' &&
|
||||
document
|
||||
.querySelector('.ri-mode-btn[data-mode="image"]')
|
||||
.getAttribute('aria-checked') === 'true',
|
||||
),
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager._setDetectionAllocation('ELASTIC', {
|
||||
syncShare: false,
|
||||
persist: false,
|
||||
});
|
||||
document
|
||||
.getElementById('models3d-mode-proximity')
|
||||
.scrollIntoView({ block: 'center' });
|
||||
});
|
||||
await page.screenshot({ path: `${shots}/desktop.png` });
|
||||
await page.setViewport({ width: 620, height: 900 });
|
||||
await page.evaluate(() =>
|
||||
document
|
||||
.getElementById('models3d-mode-proximity')
|
||||
.scrollIntoView({ block: 'center' }),
|
||||
);
|
||||
await page.screenshot({ path: `${shots}/narrow.png` });
|
||||
check(
|
||||
'narrow selectors stay within their panel',
|
||||
await page.evaluate(() =>
|
||||
[...document.querySelectorAll('.pp-mode-seg')]
|
||||
.filter((g) => g.getClientRects().length)
|
||||
.every((group) => group.scrollWidth <= group.clientWidth + 1),
|
||||
),
|
||||
);
|
||||
const cyber = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
const previous = root.getAttribute('data-ui-theme');
|
||||
root.setAttribute('data-ui-theme', 'cyber');
|
||||
const selected = [
|
||||
...document.querySelectorAll('.gev-segment[aria-checked="true"]'),
|
||||
];
|
||||
// Check the final theme colors, independent of software-GL frame cadence.
|
||||
for (const button of selected)
|
||||
for (const animation of button.getAnimations()) animation.finish();
|
||||
const backgrounds = selected.map(
|
||||
(button) => getComputedStyle(button).backgroundColor,
|
||||
);
|
||||
const radii = [...document.querySelectorAll('.gev-segmented')].map(
|
||||
(group) => getComputedStyle(group).borderRadius,
|
||||
);
|
||||
const consistent =
|
||||
selected.length === 6 &&
|
||||
backgrounds.every((color) => color === backgrounds[0]) &&
|
||||
radii.every((radius) => radius === '1px');
|
||||
if (previous === null) root.removeAttribute('data-ui-theme');
|
||||
else root.setAttribute('data-ui-theme', previous);
|
||||
return { consistent, count: selected.length, backgrounds, radii };
|
||||
});
|
||||
check(
|
||||
'Cyber theme styles every selected segment consistently',
|
||||
cyber.consistent,
|
||||
JSON.stringify(cyber),
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
await dataManager.setEnabled('recent-imagery', true);
|
||||
styleManager.setPanelCollapsed('recent-imagery-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
await page.focus('.ri-mode-btn[aria-checked="true"]');
|
||||
const modeLayout = await page.evaluate(() => {
|
||||
const group = document.getElementById('ri-mode');
|
||||
const bounds = group.getBoundingClientRect();
|
||||
const style = getComputedStyle(group);
|
||||
const top = bounds.top + parseFloat(style.borderTopWidth);
|
||||
const bottom = bounds.bottom - parseFloat(style.borderBottomWidth);
|
||||
const buttons = [...group.querySelectorAll('[role="radio"]')];
|
||||
return {
|
||||
visible: bounds.width > 0 && bounds.height > 0,
|
||||
contained: buttons.every((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return rect.top >= top - 0.5 && rect.bottom <= bottom + 0.5;
|
||||
}),
|
||||
groupHeight: bounds.height,
|
||||
buttonHeights: buttons.map(
|
||||
(button) => button.getBoundingClientRect().height,
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
`imagery mode buttons and focus outline fit their row at ${width}px`,
|
||||
modeLayout.visible && modeLayout.contained,
|
||||
JSON.stringify(modeLayout),
|
||||
);
|
||||
}
|
||||
check('no uncaught browser errors', errors.length === 0, errors.join(' | '));
|
||||
} catch (error) {
|
||||
console.error('Browser errors:', errors);
|
||||
await page.screenshot({ path: `${shots}/failure.png` }).catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures.length} failures`);
|
||||
process.exitCode = failures.length ? 1 : 0;
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Lit airplane regression: StarrySky must match a black environment baseline.
|
||||
* A white environment is a positive control: it must visibly brighten the
|
||||
* same model. Each case uses a fresh scene and dynamic environment map.
|
||||
* Usage: node scripts/qa-sky-model-lighting.mjs [--url http://localhost:4333/]
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const i = process.argv.indexOf('--url');
|
||||
const base = i > 0 ? process.argv[i + 1] : 'http://localhost:4333/';
|
||||
const url = new URL('/__qa_sky_model__', base);
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--ignore-gpu-blocklist'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
page.on('pageerror', (error) => console.log('PAGEERR', error.message));
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (request.url() === url.href)
|
||||
request.respond({
|
||||
contentType: 'text/html',
|
||||
body: '<!doctype html><html><body></body></html>',
|
||||
});
|
||||
else if (new URL(request.url()).origin !== url.origin) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
await page.goto(url.href);
|
||||
const results = await page.evaluate(async () => {
|
||||
window.CESIUM_BASE_URL = '/cesium/';
|
||||
// Use StarrySky's exact Vite dependency URL, including its version query.
|
||||
const source = await fetch('/src/sky/starrySky.js').then((r) => r.text());
|
||||
const cesiumUrl = source.match(
|
||||
/from ["']([^"']*\/cesium\.js[^"']*)["']/,
|
||||
)?.[1];
|
||||
if (!cesiumUrl) throw new Error('run against the Vite dev server');
|
||||
const C = await import(cesiumUrl);
|
||||
const { StarrySky } = await import('/src/sky/starrySky.js');
|
||||
const time = C.JulianDate.fromIso8601('2026-10-19T00:00:00Z');
|
||||
const results = {};
|
||||
for (const kind of ['black', 'starry', 'white']) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 400;
|
||||
canvas.height = 300;
|
||||
document.body.append(canvas);
|
||||
const scene = new C.Scene({ canvas });
|
||||
scene.rethrowRenderErrors = true;
|
||||
let cubeMap;
|
||||
try {
|
||||
scene.backgroundColor = C.Color.BLACK;
|
||||
scene.light = new C.DirectionalLight({
|
||||
direction: new C.Cartesian3(1, 0, 0),
|
||||
intensity: 0.2,
|
||||
});
|
||||
if (kind === 'starry') {
|
||||
scene.skyBox = new StarrySky({ requestRender() {} });
|
||||
scene.skyBox._loading = true; // visible sky is outside this probe
|
||||
} else {
|
||||
const value = kind === 'white' ? 255 : 0;
|
||||
const face = {
|
||||
width: 1,
|
||||
height: 1,
|
||||
arrayBufferView: new Uint8Array([value, value, value, 255]),
|
||||
};
|
||||
cubeMap = new C.CubeMap({
|
||||
context: scene.context,
|
||||
source: Object.fromEntries(
|
||||
[...C.CubeMap.faceNames()].map((name) => [name, face]),
|
||||
),
|
||||
});
|
||||
scene.skyBox = { _cubeMap: cubeMap, update() {}, destroy() {} };
|
||||
}
|
||||
const position = C.Cartesian3.fromDegrees(0, 0, 10000);
|
||||
const model = await C.Model.fromGltfAsync({
|
||||
url: '/models/airplane.glb',
|
||||
modelMatrix: C.Transforms.eastNorthUpToFixedFrame(position),
|
||||
});
|
||||
scene.primitives.add(model);
|
||||
scene.camera.lookAt(position, new C.HeadingPitchRange(0.4, -0.5, 45));
|
||||
let framed = false;
|
||||
for (let frame = 0; frame < 240; frame++) {
|
||||
scene.initializeFrame();
|
||||
scene.render(time);
|
||||
if (model.ready && !framed) {
|
||||
scene.camera.viewBoundingSphere(
|
||||
model.boundingSphere,
|
||||
new C.HeadingPitchRange(
|
||||
0.4,
|
||||
-0.5,
|
||||
model.boundingSphere.radius * 3,
|
||||
),
|
||||
);
|
||||
framed = true;
|
||||
}
|
||||
await new Promise(requestAnimationFrame);
|
||||
}
|
||||
if (!model.ready || !model.environmentMapManager.radianceCubeMap)
|
||||
throw new Error(`${kind}: model or dynamic lighting did not load`);
|
||||
scene.initializeFrame();
|
||||
scene.render(time);
|
||||
const gl = scene.context._gl;
|
||||
const px = new Uint8Array(canvas.width * canvas.height * 4);
|
||||
gl.readPixels(
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
px,
|
||||
);
|
||||
let total = 0;
|
||||
let visible = 0;
|
||||
for (let p = 0; p < px.length; p += 4) {
|
||||
const luma = 0.299 * px[p] + 0.587 * px[p + 1] + 0.114 * px[p + 2];
|
||||
total += luma;
|
||||
if (luma > 1) visible++;
|
||||
}
|
||||
results[kind] = { total, visible };
|
||||
} finally {
|
||||
scene.destroy();
|
||||
cubeMap?.destroy();
|
||||
canvas.remove();
|
||||
}
|
||||
}
|
||||
return results;
|
||||
});
|
||||
console.log(JSON.stringify(results));
|
||||
assert.ok(results.black.visible > 100, 'the lit model must be visible');
|
||||
assert.ok(
|
||||
Math.abs(results.starry.total - results.black.total) <=
|
||||
results.black.total * 0.01,
|
||||
'StarrySky must match black environment lighting within 1%',
|
||||
);
|
||||
assert.ok(
|
||||
results.white.total > results.black.total * 1.03,
|
||||
'white environment control must brighten the model by at least 3%',
|
||||
);
|
||||
console.log(
|
||||
'PASS: lit airplane matches black sky; white control is brighter',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env node
|
||||
/** Badge acceptance on dev/preview. Camera state is a disposable controller fixture. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let checks = 0;
|
||||
let failures = 0;
|
||||
const check = (name, passed, detail) => {
|
||||
checks++;
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) {
|
||||
failures++;
|
||||
console.log(JSON.stringify(detail));
|
||||
}
|
||||
};
|
||||
const out = 'qa-shots/status-badges';
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
try {
|
||||
for (const lang of ['en', 'ru']) {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4189'}/?welcome=0&lang=${lang}`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, styleManager: manager } = window.__godsEyeView;
|
||||
await dataManager.setEnabled('wind', true);
|
||||
manager.setPanelCollapsed('weather-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager.setPanelCollapsed('cctv-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
const owner = manager._cctvControls;
|
||||
owner._cctvUnsubscribe?.();
|
||||
owner._cctvUnsubscribe = null;
|
||||
owner.actions = { ...owner.actions, isEnabled: () => true };
|
||||
owner._renderCctvState({
|
||||
enabled: true,
|
||||
activeCameraId: 'badge-fixture',
|
||||
cameras: [{ id: 'badge-fixture', city: 'QA', name: 'Badge fixture' }],
|
||||
activeCamera: {
|
||||
id: 'badge-fixture',
|
||||
city: 'QA',
|
||||
headingDeg: 30,
|
||||
pitchDeg: -10,
|
||||
fovDeg: 45,
|
||||
rangeM: 120,
|
||||
mountHeightM: 8,
|
||||
sourceKind: 'snapshot',
|
||||
sourceStatus: 'degraded',
|
||||
calBadge: 'curated',
|
||||
calDirty: false,
|
||||
},
|
||||
});
|
||||
window.__qaBadgeText =
|
||||
document.getElementById('cctv-source-badge').textContent;
|
||||
});
|
||||
await page.waitForSelector('.weather-coverage.gev-status-badge');
|
||||
const inventory = await page.evaluate(() => {
|
||||
const source = document.getElementById('cctv-source-badge');
|
||||
const quality = document.getElementById('cctv-quality-chip');
|
||||
const coverage = [...document.querySelectorAll('.weather-coverage')];
|
||||
return {
|
||||
shared: [source, quality, ...coverage].every((node) =>
|
||||
node.classList.contains('gev-status-badge'),
|
||||
),
|
||||
live:
|
||||
source.getAttribute('role') === 'status' &&
|
||||
source.getAttribute('aria-live') === 'polite',
|
||||
silent: [quality, ...coverage].every(
|
||||
(node) =>
|
||||
!node.hasAttribute('aria-live') &&
|
||||
!node.hasAttribute('role') &&
|
||||
!node.hasAttribute('tabindex'),
|
||||
),
|
||||
coverage:
|
||||
coverage.length > 0 &&
|
||||
coverage.every(
|
||||
(node) =>
|
||||
node.textContent &&
|
||||
!node.hidden &&
|
||||
node.classList.contains('gev-status-badge--plain'),
|
||||
),
|
||||
source: source.textContent,
|
||||
};
|
||||
});
|
||||
check(
|
||||
`${lang}: CCTV and populated Weather badges share the view`,
|
||||
inventory.shared && inventory.coverage,
|
||||
inventory,
|
||||
);
|
||||
check(
|
||||
`${lang}: existing announcement and read-only semantics remain intact`,
|
||||
inventory.live && inventory.silent,
|
||||
inventory,
|
||||
);
|
||||
check(
|
||||
`${lang}: source wording uses the active locale`,
|
||||
lang === 'en'
|
||||
? inventory.source === 'SNAPSHOT · DEGRADED'
|
||||
: inventory.source !== 'SNAPSHOT · DEGRADED',
|
||||
inventory,
|
||||
);
|
||||
|
||||
for (const state of ['loading', 'error', 'retained', 'ready', 'idle']) {
|
||||
const result = await page.evaluate((state) => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
owner._cctvFrame.dataset.loading = state === 'loading' ? 'true' : '';
|
||||
owner._cctvFrame.dataset.error = ['error', 'retained'].includes(state)
|
||||
? 'true'
|
||||
: '';
|
||||
owner._cctvFrameWrap.classList.toggle(
|
||||
'has-frame',
|
||||
state === 'retained',
|
||||
);
|
||||
owner._syncCctvSourceBadge(
|
||||
owner._cctvState.activeCamera,
|
||||
state !== 'idle',
|
||||
);
|
||||
const badge = document.getElementById('cctv-source-badge');
|
||||
return {
|
||||
state: badge.dataset.frameState,
|
||||
text: badge.textContent,
|
||||
color: getComputedStyle(badge).color,
|
||||
};
|
||||
}, state);
|
||||
const expected = {
|
||||
loading: ['loading', 'rgb(255, 229, 155)'],
|
||||
error: ['error', 'rgb(255, 176, 176)'],
|
||||
retained: ['ready', 'rgb(157, 239, 255)'],
|
||||
ready: ['ready', 'rgb(157, 239, 255)'],
|
||||
idle: ['idle', 'rgb(157, 239, 255)'],
|
||||
}[state];
|
||||
check(
|
||||
`${lang}: ${state} keeps its frame state and palette`,
|
||||
result.state === expected[0] &&
|
||||
result.color === expected[1] &&
|
||||
(!['ready', 'retained'].includes(state) ||
|
||||
result.text === inventory.source),
|
||||
result,
|
||||
);
|
||||
}
|
||||
for (const [calBadge, dirty, color] of [
|
||||
['raw-prior', false, 'rgb(143, 231, 255)'],
|
||||
['curated', false, 'rgb(255, 230, 162)'],
|
||||
['calibrated', false, 'rgb(173, 255, 191)'],
|
||||
['curated', true, 'rgb(255, 208, 138)'],
|
||||
]) {
|
||||
const result = await page.evaluate(
|
||||
({ calBadge, dirty }) => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
document.getElementById('cctv-next-btn').focus();
|
||||
owner._renderCctvState({
|
||||
...owner._cctvState,
|
||||
activeCamera: {
|
||||
...owner._cctvState.activeCamera,
|
||||
calBadge,
|
||||
calDirty: dirty,
|
||||
},
|
||||
});
|
||||
const badge = document.getElementById('cctv-quality-chip');
|
||||
return {
|
||||
state: badge.dataset.calBadge,
|
||||
color: getComputedStyle(badge).color,
|
||||
focus: document.activeElement.id,
|
||||
};
|
||||
},
|
||||
{ calBadge, dirty },
|
||||
);
|
||||
check(
|
||||
`${lang}: ${dirty ? 'edited' : calBadge} keeps provenance color and control focus`,
|
||||
result.state === (dirty ? 'edited' : calBadge) &&
|
||||
result.color === color &&
|
||||
result.focus === 'cctv-next-btn',
|
||||
result,
|
||||
);
|
||||
}
|
||||
for (const theme of ['tactical', 'cyber']) {
|
||||
await page.evaluate(
|
||||
(theme) => window.__godsEyeView.styleManager._setHudVariant(theme),
|
||||
theme,
|
||||
);
|
||||
for (const width of [1440, 620, 390]) {
|
||||
await page.setViewport({ width, height: 900 });
|
||||
for (const panelId of ['cctv-panel', 'weather-panel']) {
|
||||
await page.evaluate(async (panelId) => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
for (const other of ['cctv-panel', 'weather-panel']) {
|
||||
if (other !== panelId)
|
||||
manager.setPanelCollapsed(other, true, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}
|
||||
manager.setPanelCollapsed(panelId, false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
}, panelId);
|
||||
const layout = await page.evaluate((panelId) => {
|
||||
const selector =
|
||||
panelId === 'cctv-panel'
|
||||
? '#cctv-source-badge, #cctv-quality-chip'
|
||||
: '.weather-coverage';
|
||||
return [...document.querySelectorAll(selector)].map((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const panel = node.closest(`#${panelId}`).getBoundingClientRect();
|
||||
return {
|
||||
id: node.id || 'weather-coverage',
|
||||
text: node.textContent,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
scroll: node.scrollWidth,
|
||||
client: node.clientWidth,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
panelLeft: panel.left,
|
||||
panelRight: panel.right,
|
||||
fits:
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.left >= panel.left - 1 &&
|
||||
rect.right <= panel.right + 1 &&
|
||||
node.scrollWidth <= node.clientWidth + 1,
|
||||
};
|
||||
});
|
||||
}, panelId);
|
||||
check(
|
||||
`${lang}/${theme}: ${panelId} badges fit at ${width}px`,
|
||||
layout.length >= (panelId === 'cctv-panel' ? 2 : 1) &&
|
||||
layout.every((node) => node.fits),
|
||||
layout,
|
||||
);
|
||||
if (width === 390)
|
||||
await page.screenshot({
|
||||
path: `${out}/${lang}-${theme}-${panelId}-${width}.png`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const disposed = await page.evaluate(() => {
|
||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||
const badge = document.getElementById('cctv-quality-chip');
|
||||
const text = badge.textContent;
|
||||
owner.destroy();
|
||||
owner._renderCctvState(null);
|
||||
return badge.textContent === text;
|
||||
});
|
||||
check(`${lang}: disposal prevents late state painting`, disposed);
|
||||
}
|
||||
check('no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`${checks} checks; ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -661,6 +661,32 @@ async function main() {
|
||||
},
|
||||
);
|
||||
|
||||
await step(
|
||||
'FIT/FILL keeps native selection, keyboard focus and persistence with a photo open',
|
||||
async () => {
|
||||
await page.click('[data-sl-render="fill"]');
|
||||
await uiUntil(page, (u) => u.street.renderMode === 'fill');
|
||||
assert.equal(
|
||||
await page.evaluate(() =>
|
||||
localStorage.getItem('gev:street-level:render-mode'),
|
||||
),
|
||||
'fill',
|
||||
);
|
||||
await page.keyboard.press('Home');
|
||||
await uiUntil(page, (u) => u.street.renderMode === 'letterbox');
|
||||
assert.deepEqual(
|
||||
await page.$eval('[data-sl-render="letterbox"]', (node) => ({
|
||||
selected: node.getAttribute('aria-checked'),
|
||||
tabIndex: node.tabIndex,
|
||||
focused: document.activeElement === node,
|
||||
})),
|
||||
{ selected: 'true', tabIndex: 0, focused: true },
|
||||
);
|
||||
await page.keyboard.press('End');
|
||||
await uiUntil(page, (u) => u.street.renderMode === 'fill');
|
||||
},
|
||||
);
|
||||
|
||||
await step('× closes the photo and deselects its sequence', async () => {
|
||||
await press(page, '#sl-viewer-close');
|
||||
await uiUntil(
|
||||
@@ -673,6 +699,21 @@ async function main() {
|
||||
);
|
||||
});
|
||||
|
||||
await step(
|
||||
'panorama segments keep native arrow and Home navigation',
|
||||
async () => {
|
||||
await page.click('[data-sl-pano="flat"]');
|
||||
await uiUntil(page, (u) => u.filter.pano === 'flat');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await uiUntil(page, (u) => u.filter.pano === 'all');
|
||||
await page.keyboard.press('End');
|
||||
await uiUntil(page, (u) => u.filter.pano === 'flat');
|
||||
await page.keyboard.press('Home');
|
||||
await uiUntil(page, (u) => u.filter.pano === 'all');
|
||||
await waitForCoverage(page);
|
||||
},
|
||||
);
|
||||
|
||||
await keylessProviderStep({
|
||||
id: 'kartaview',
|
||||
name: 'KartaView',
|
||||
|
||||
@@ -183,6 +183,38 @@ try {
|
||||
return manager.bloomEnabled !== before;
|
||||
}),
|
||||
);
|
||||
const sliders = await page.evaluate(() => {
|
||||
const ordinary = [
|
||||
...document.querySelectorAll('input[type="range"]'),
|
||||
].filter(
|
||||
(input) => !input.matches('#radio-tuner-slider, .bhote-event-range'),
|
||||
);
|
||||
return {
|
||||
count: ordinary.length,
|
||||
shared: ordinary.every((input) =>
|
||||
input.classList.contains('gev-quantitative-slider'),
|
||||
),
|
||||
surface: ordinary.every((input) => {
|
||||
const style = getComputedStyle(input);
|
||||
return (
|
||||
style.height === '18px' &&
|
||||
style.appearance === 'none' &&
|
||||
style.padding === '0px' &&
|
||||
style.borderWidth === '0px' &&
|
||||
(!input.disabled ||
|
||||
(style.opacity === '0.42' && style.cursor === 'not-allowed'))
|
||||
);
|
||||
}),
|
||||
tunerSeparate: !document
|
||||
.getElementById('radio-tuner-slider')
|
||||
.classList.contains('gev-quantitative-slider'),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'ordinary sliders share one native surface',
|
||||
sliders.count > 10 && sliders.shared && sliders.surface,
|
||||
);
|
||||
check('radio tuning keeps its specialized control', sliders.tunerSeparate);
|
||||
fs.mkdirSync('qa-shots/visual-input', { recursive: true });
|
||||
await page.screenshot({ path: 'qa-shots/visual-input/desktop.png' });
|
||||
await page.setViewport({ width: 620, height: 900 });
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
||||
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
||||
|
||||
@@ -2695,16 +2696,17 @@ async function main() {
|
||||
// reappear wearing its stale/creation-north nose for up to 1 s).
|
||||
// Staged deterministically: park the camera nadir over the synthetic
|
||||
// cluster, let rotations settle, TAMPER a billboard's rotation, then
|
||||
// trigger each path with the camera idle. The idle-drift catch-up
|
||||
// (ROTATION_REFRESH_MS = 1 s) can coincidentally mask a regression in a
|
||||
// ~300 ms SwiftShader frame window, so a regression shows as an
|
||||
// intermittent (not guaranteed) failure — never a false failure.
|
||||
// trigger each path with the camera idle. The fleet runs every 80 ms,
|
||||
// independently of rendered FPS. Observe the first completed settle and
|
||||
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
|
||||
// rotation refresh can repair an incorrect first reveal.
|
||||
// ============================================================
|
||||
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
|
||||
const arrival = await evalPage(async () => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
|
||||
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
|
||||
// 3D models OFF for this phase: a model-handed-off billboard is hidden
|
||||
// and skips rotation updates entirely — the probes need live billboards
|
||||
// (this is also the app's default state the field report came from).
|
||||
@@ -2766,39 +2768,29 @@ async function main() {
|
||||
const bb = findBB(id);
|
||||
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
|
||||
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
|
||||
// (a) settle pass: tamper, then raise moveEnd with the camera IDLE —
|
||||
// the pose signature is unchanged, so only the moveEnd hook can fix
|
||||
// this before the 1 s catch-up.
|
||||
bb.rotation = r0 + 1.5;
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
await nextFrames(2);
|
||||
const afterMoveEnd = bb.rotation;
|
||||
// A real-GPU fleet tick can land near the edge of the frame budget and
|
||||
// advance the contact before the probe reads it. Compare with the
|
||||
// production projection at the CURRENT position, not the now-stale r0.
|
||||
const expectedMoveEnd = screenProjectedRotation(v.scene, bb.position, course, null);
|
||||
// (b) reveal pass: tamper + hide — the next fleet tick must flip it
|
||||
// visible AND correct the nose in that same tick (camera still idle,
|
||||
// no moveEnd raised). Diagnostic fields (round 5): record WHICH frame
|
||||
// the flip landed on (flipFrame; -1 = never within the extended
|
||||
// window) and whether a 3D model owns this id — distinguishes "tick
|
||||
// slow under load" from "occluder says hidden" from "model handoff".
|
||||
bb.rotation = r0 + 1.5;
|
||||
bb.show = false;
|
||||
let flipFrame = -1;
|
||||
for (let f = 1; f <= 6; f++) {
|
||||
await nextFrames(1);
|
||||
if (bb.show && flipFrame === -1) flipFrame = f;
|
||||
if (f >= 2 && flipFrame !== -1) break;
|
||||
}
|
||||
const expectedReveal = screenProjectedRotation(v.scene, bb.position, course, null);
|
||||
const observed = await observeArrivalRotation({
|
||||
scene: v.scene,
|
||||
startMoveEnd() {
|
||||
// Only moveEnd can repair the tampered angle at the idle pose.
|
||||
bb.rotation = r0 + 1.5;
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
},
|
||||
startReveal() {
|
||||
// Hide immediately after the confirmed settle frame. The next
|
||||
// eligible fleet tick must reveal AND re-aim in that same tick.
|
||||
bb.rotation = r0 + 1.5;
|
||||
bb.show = false;
|
||||
},
|
||||
read() {
|
||||
return {
|
||||
shown: bb.show,
|
||||
rotationError: angDiff(bb.rotation,
|
||||
screenProjectedRotation(v.scene, bb.position, course, null)),
|
||||
};
|
||||
},
|
||||
});
|
||||
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
||||
return {
|
||||
r0,
|
||||
dMoveEnd: angDiff(afterMoveEnd, expectedMoveEnd),
|
||||
dReveal: angDiff(bb.rotation, expectedReveal),
|
||||
shown: bb.show, flipFrame, hasModel,
|
||||
};
|
||||
return { r0, ...observed, hasModel };
|
||||
};
|
||||
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
|
||||
const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0;
|
||||
@@ -2807,22 +2799,13 @@ async function main() {
|
||||
military: await probe('bbb101', militaryCourse),
|
||||
};
|
||||
});
|
||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? a.error
|
||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} hasModel=${a.hasModel}`);
|
||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
|
||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`);
|
||||
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
|
||||
// Reveal budget re-pinned 2 → 4 frames (round 5): the diagnostic probe
|
||||
// shows the military flip consistently lands on frame 2 — the exact edge
|
||||
// of the old budget — so any load hiccup flaked it (flights lands frame
|
||||
// 1). The regression this protects against is a reveal that waits for
|
||||
// the 1 s idle catch-up (~30-60 frames) or never re-aims; 4 frames still
|
||||
// catches that with a wide margin, with zero-headroom flakes gone.
|
||||
const arrOk = (a) => !!a && !a.error && a.shown
|
||||
&& (a.flipFrame === -1 ? false : a.flipFrame <= 4)
|
||||
&& a.dMoveEnd * 180 / Math.PI < 10 && a.dReveal * 180 / Math.PI < 10;
|
||||
record(`arrival: moveEnd + reveal both re-aim within 4 frames, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrOk(arrival.flights), fmtArr(arrival.flights));
|
||||
record(`arrival: moveEnd + reveal both re-aim within 4 frames, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrOk(arrival.military), fmtArr(arrival.military));
|
||||
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
|
||||
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrivalRotationPassed(arrival.military), fmtArr(arrival.military));
|
||||
|
||||
// ============================================================
|
||||
// DISPLAY FLOOR (2026-08-19): a grounded contact must never render below
|
||||
|
||||
+63
-29
@@ -6,6 +6,8 @@ import {
|
||||
} from './cctv/normalize.js';
|
||||
import {
|
||||
buildSyntheticCctvSvg,
|
||||
cctvLiveMediaType,
|
||||
cctvRasterImageType,
|
||||
proxyMediaResponse,
|
||||
fetchCctvImageFromUpstream,
|
||||
fetchTxdotSnapshot,
|
||||
@@ -17,6 +19,8 @@ import {
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
} from './cctv/constants.js';
|
||||
import { sanitizeCctvRangeHeader } from './cctv/range.js';
|
||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
|
||||
import { createHlsPuller } from './cctv/stream.js';
|
||||
import { googleServerApiKey } from './places/google-key.js';
|
||||
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
|
||||
@@ -116,8 +120,15 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' },
|
||||
signal: AbortSignal.timeout(CCTV_FRAME_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
const svType = svResp.headers.get('content-type') || '';
|
||||
if (!svResp.ok || !svType.startsWith('image/')) return null;
|
||||
const svType = cctvRasterImageType(svResp.headers.get('content-type'));
|
||||
if (!svResp.ok || !svType) {
|
||||
try {
|
||||
await svResp.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -135,11 +146,15 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
});
|
||||
server.middlewares.use('/api/cctv', async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || '/', 'http://localhost');
|
||||
// Refuse cross-site requests before any catalog or upstream work. The
|
||||
// keyless gate keeps the routes reachable behind a reverse proxy; the
|
||||
// key-bearing Street View fallback below has its own check.
|
||||
if (admitSameSiteKeyless(req, res)) return;
|
||||
const sources = await getCctvSources();
|
||||
const sourceById = new Map(
|
||||
sources.map((source) => [source.id, source]),
|
||||
);
|
||||
const url = new URL(req.url || '/', 'http://localhost');
|
||||
|
||||
if (url.pathname === '/sources') {
|
||||
const body = {
|
||||
@@ -217,8 +232,8 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
const mediaUrl = source?.url || '';
|
||||
const feedType = normalizeFeedType(source?.feedType || 'image');
|
||||
// Live feeds only. A still camera is served by /frame, whose fetch
|
||||
// keeps redirects on the registered origin, accepts only image/* and
|
||||
// caps the body; relaying it here would bypass all three.
|
||||
// keeps redirects on the registered origin, accepts only raster
|
||||
// images and caps the body; relaying it here would bypass all three.
|
||||
if (!isVideoFeedType(feedType) && feedType !== 'mjpeg') {
|
||||
res.writeHead(404, {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -382,7 +397,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const contentType = upstream.headers.get('content-type') || '';
|
||||
if (!upstream.ok) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
@@ -407,31 +421,42 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isVideoFeedType(feedType) &&
|
||||
!(
|
||||
contentType.startsWith('video/') ||
|
||||
contentType.includes('mpegurl')
|
||||
)
|
||||
) {
|
||||
// The relayed type is the feed kind's own, checked before the
|
||||
// camera counts as healthy and before any byte is relayed.
|
||||
const contentType = cctvLiveMediaType(
|
||||
feedType,
|
||||
upstream.headers.get('content-type'),
|
||||
);
|
||||
if (!contentType) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
setHealth(cameraId, {
|
||||
status: 'degraded',
|
||||
sourceKind: 'upstream',
|
||||
label: source?.provider || 'Configured source',
|
||||
message: `Unexpected media type ${contentType || 'unknown'}`,
|
||||
message: 'Unexpected media type',
|
||||
});
|
||||
} else {
|
||||
setHealth(cameraId, {
|
||||
status: 'ok',
|
||||
sourceKind: isVideoFeedType(feedType) ? 'live' : 'snapshot',
|
||||
label: source?.provider || 'Configured source',
|
||||
message: isVideoFeedType(feedType)
|
||||
? 'Live stream connected'
|
||||
: 'Snapshot feed connected',
|
||||
res.writeHead(502, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Media proxy failed' }));
|
||||
return;
|
||||
}
|
||||
setHealth(cameraId, {
|
||||
status: 'ok',
|
||||
sourceKind: isVideoFeedType(feedType) ? 'live' : 'snapshot',
|
||||
label: source?.provider || 'Configured source',
|
||||
message: isVideoFeedType(feedType)
|
||||
? 'Live stream connected'
|
||||
: 'Snapshot feed connected',
|
||||
});
|
||||
|
||||
await proxyMediaResponse(res, upstream, {
|
||||
contentType,
|
||||
sourceHeader: isVideoFeedType(feedType)
|
||||
? 'live-media'
|
||||
: 'upstream-image',
|
||||
@@ -524,13 +549,22 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sv = await streetViewFallback({
|
||||
lat,
|
||||
lon,
|
||||
heading,
|
||||
fov,
|
||||
pitch,
|
||||
});
|
||||
// The Street View fallback spends the Google key, so a forwarded
|
||||
// request (whose client this server cannot see) gets the placeholder,
|
||||
// as if no key were configured. Any forwarding header counts, even an
|
||||
// empty one.
|
||||
const forwarded = PROXY_SIGNALS.some(
|
||||
(name) => req.headers?.[name] !== undefined,
|
||||
);
|
||||
const sv = forwarded
|
||||
? null
|
||||
: await streetViewFallback({
|
||||
lat,
|
||||
lon,
|
||||
heading,
|
||||
fov,
|
||||
pitch,
|
||||
});
|
||||
if (sv?.ok) {
|
||||
setHealth(cameraId, {
|
||||
status: 'degraded',
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import { hashSeed, escapeXml } from './normalize.js';
|
||||
import { hashSeed, escapeXml, pinnedCameraUrl } from './normalize.js';
|
||||
import {
|
||||
CALGARY_IMAGE_ORIGIN,
|
||||
CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
CCTV_FRAME_MAX_BODY_BYTES,
|
||||
CCTV_MEDIA_FETCH_TIMEOUT_MS,
|
||||
CCTV_MEDIA_IDLE_TIMEOUT_MS,
|
||||
CCTV_MEDIA_MAX_BODY_BYTES,
|
||||
HK_IMAGE_ORIGIN,
|
||||
KING_COUNTY_IMAGE_ORIGIN,
|
||||
NSW_IMAGE_ORIGIN,
|
||||
NSW_IMAGE_USER_AGENT,
|
||||
TALLINN_IMAGE_ORIGIN,
|
||||
TARKTEE_IMAGE_ORIGIN,
|
||||
TFL_IMAGE_ORIGIN,
|
||||
WARENDORF_IMAGE_ORIGINS,
|
||||
WSDOT_IMAGE_ORIGIN,
|
||||
} from './constants.js';
|
||||
/**
|
||||
* Generate a synthetic SVG billboard image for a CCTV camera placeholder.
|
||||
@@ -93,6 +101,73 @@ export function toReadable(body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a Content-Type value into its lower-case essence and parameters.
|
||||
*
|
||||
* @param {string|null|undefined} value
|
||||
* @returns {{essence:string,params:Map<string,string>}|null} Null when the
|
||||
* value is missing or is not a `type/subtype` media type.
|
||||
*/
|
||||
export function parseMediaType(value) {
|
||||
const [head, ...rest] = String(value ?? '').split(';');
|
||||
const essence = head.trim().toLowerCase();
|
||||
if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(essence)) return null;
|
||||
const params = new Map();
|
||||
for (const part of rest) {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
const name = part.slice(0, eq).trim().toLowerCase();
|
||||
let paramValue = part.slice(eq + 1).trim();
|
||||
if (/^".*"$/.test(paramValue)) paramValue = paramValue.slice(1, -1);
|
||||
if (name && !params.has(name)) params.set(name, paramValue);
|
||||
}
|
||||
return { essence, params };
|
||||
}
|
||||
|
||||
/** Still-image types a camera frame may be served as. */
|
||||
const CCTV_RASTER_IMAGE_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The canonical type of a raster camera frame, or null for any other type.
|
||||
*
|
||||
* @param {string|null|undefined} contentType
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function cctvRasterImageType(contentType) {
|
||||
const essence = parseMediaType(contentType)?.essence;
|
||||
return CCTV_RASTER_IMAGE_TYPES.has(essence) ? essence : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type to relay a live feed as, or null when the upstream type does not
|
||||
* match the feed kind. MJPEG keeps its part boundary, without which the body
|
||||
* cannot be split into frames.
|
||||
*
|
||||
* @param {string} feedType - Normalized feed type (mp4, webm or mjpeg).
|
||||
* @param {string|null|undefined} contentType
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function cctvLiveMediaType(feedType, contentType) {
|
||||
const parsed = parseMediaType(contentType);
|
||||
if (!parsed) return null;
|
||||
if (feedType === 'mp4' || feedType === 'webm')
|
||||
return parsed.essence === `video/${feedType}` ? parsed.essence : null;
|
||||
if (feedType !== 'mjpeg' || parsed.essence !== 'multipart/x-mixed-replace')
|
||||
return null;
|
||||
const boundary = parsed.params.get('boundary') || '';
|
||||
if (!/^[0-9A-Za-z'()+_,./:=? -]{0,69}[0-9A-Za-z'()+_,./:=?-]$/.test(boundary))
|
||||
return null;
|
||||
return /^[0-9A-Za-z'+_.-]+$/.test(boundary)
|
||||
? `multipart/x-mixed-replace; boundary=${boundary}`
|
||||
: `multipart/x-mixed-replace; boundary="${boundary}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipe an upstream fetch Response (image or video) to the client HTTP response.
|
||||
*
|
||||
@@ -104,6 +179,8 @@ export function toReadable(body) {
|
||||
* @param {Response} upstream - fetch() Response object.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.sourceHeader='upstream'] - Value for X-CCTV-Source header.
|
||||
* @param {string} [opts.contentType] - Validated type to send instead of the
|
||||
* upstream's own.
|
||||
* @param {number} [opts.idleTimeoutMs=CCTV_MEDIA_IDLE_TIMEOUT_MS] - Silence
|
||||
* allowed between upstream chunks before the stream is released. Injectable
|
||||
* only to keep the deadline unit-testable.
|
||||
@@ -114,10 +191,10 @@ export async function proxyMediaResponse(
|
||||
{
|
||||
sourceHeader = 'upstream',
|
||||
idleTimeoutMs = CCTV_MEDIA_IDLE_TIMEOUT_MS,
|
||||
contentType = upstream.headers.get('content-type') ||
|
||||
'application/octet-stream',
|
||||
} = {},
|
||||
) {
|
||||
const contentType =
|
||||
upstream.headers.get('content-type') || 'application/octet-stream';
|
||||
const cacheControl = upstream.headers.get('cache-control') || 'no-store';
|
||||
const contentLength = upstream.headers.get('content-length');
|
||||
const contentRange = upstream.headers.get('content-range');
|
||||
@@ -340,7 +417,14 @@ export async function fetchCctvMediaUpstream(
|
||||
if (downstream?.aborted) controller.abort();
|
||||
else downstream?.addEventListener?.('abort', onDownstreamAbort);
|
||||
try {
|
||||
return await fetchImpl(url, { headers, signal: controller.signal });
|
||||
// Redirects stay on the registered origin, as for frames.
|
||||
const upstream = await fetchWithinHost(
|
||||
url,
|
||||
{ headers, signal: controller.signal },
|
||||
fetchImpl,
|
||||
);
|
||||
if (!upstream) throw new Error('Media redirect refused');
|
||||
return upstream;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
downstream?.removeEventListener?.('abort', onDownstreamAbort);
|
||||
@@ -428,9 +512,40 @@ export async function fetchTxdotSnapshot(
|
||||
/** Redirect hops the frame path will follow, and only within the same host. */
|
||||
const MAX_SAME_HOST_REDIRECTS = 2;
|
||||
|
||||
/**
|
||||
* Directories camera catalogs are pinned to, by host. A redirect on one of
|
||||
* these hosts must stay inside the pinned directory too, so a same-origin
|
||||
* redirect cannot leave it (another bucket on a shared storage host).
|
||||
*/
|
||||
const CCTV_PINNED_PREFIXES_BY_HOST = new Map();
|
||||
for (const prefix of [
|
||||
TFL_IMAGE_ORIGIN,
|
||||
TALLINN_IMAGE_ORIGIN,
|
||||
TARKTEE_IMAGE_ORIGIN,
|
||||
...WARENDORF_IMAGE_ORIGINS,
|
||||
NSW_IMAGE_ORIGIN,
|
||||
CALGARY_IMAGE_ORIGIN,
|
||||
HK_IMAGE_ORIGIN,
|
||||
KING_COUNTY_IMAGE_ORIGIN,
|
||||
WSDOT_IMAGE_ORIGIN,
|
||||
]) {
|
||||
const host = new URL(prefix).hostname;
|
||||
CCTV_PINNED_PREFIXES_BY_HOST.set(host, [
|
||||
...(CCTV_PINNED_PREFIXES_BY_HOST.get(host) || []),
|
||||
prefix,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Whether a redirect target stays inside its host's pinned directory. */
|
||||
function withinPinnedPrefix(url) {
|
||||
const prefixes = CCTV_PINNED_PREFIXES_BY_HOST.get(url.hostname);
|
||||
return !prefixes || prefixes.some((p) => pinnedCameraUrl(url.href, p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a registered frame URL following redirects ONLY within the original
|
||||
* origin (scheme, host and port; at most MAX_SAME_HOST_REDIRECTS hops).
|
||||
* origin (scheme, host and port; at most MAX_SAME_HOST_REDIRECTS hops) and,
|
||||
* on a pinned camera host, within its pinned directory.
|
||||
* Default redirect-following would let an upstream steer a host-pinned
|
||||
* request, and its host-specific headers, to any origin, another port, or a
|
||||
* plaintext downgrade.
|
||||
@@ -471,7 +586,7 @@ export async function fetchWithinHost(url, init, fetchImpl = fetch) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (next.origin !== origin) return null;
|
||||
if (next.origin !== origin || !withinPinnedPrefix(next)) return null;
|
||||
current = next;
|
||||
}
|
||||
return null;
|
||||
@@ -543,9 +658,15 @@ export async function fetchCctvImageFromUpstream(
|
||||
fetchImpl,
|
||||
);
|
||||
if (!upstream) return null;
|
||||
const contentType = upstream.headers.get('content-type') || '';
|
||||
if (!upstream.ok || !contentType.startsWith('image/')) {
|
||||
controller.abort();
|
||||
const contentType = cctvRasterImageType(
|
||||
upstream.headers.get('content-type'),
|
||||
);
|
||||
if (!upstream.ok || !contentType) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const body = await readCappedResponseBytes(upstream, maxBytes);
|
||||
|
||||
@@ -659,3 +659,38 @@ export function isLikelyTorontoCoordinate(lat, lon) {
|
||||
lon <= -79.1
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A camera URL under a pinned directory, by parsed URL rather than string
|
||||
* prefix: the same scheme and exact host as `prefix`, no credentials, the
|
||||
* default port, and a normalized path inside the prefix's directory. Dot
|
||||
* segments (including encoded ones) and backslashes are resolved before the
|
||||
* path is compared, and an encoded slash or backslash is refused.
|
||||
*
|
||||
* @param {string} raw - Camera URL from a catalog or redirect.
|
||||
* @param {string} prefix - Pinned origin and directory, ending in `/`.
|
||||
* @returns {string|null} The normalized URL, or null when it is not pinned.
|
||||
*/
|
||||
export function pinnedCameraUrl(raw, prefix) {
|
||||
let url;
|
||||
let pin;
|
||||
try {
|
||||
url = new URL(String(raw ?? '').trim());
|
||||
pin = new URL(prefix);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
url.protocol !== pin.protocol ||
|
||||
url.hostname !== pin.hostname ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.port ||
|
||||
!url.pathname.startsWith(
|
||||
pin.pathname.endsWith('/') ? pin.pathname : `${pin.pathname}/`,
|
||||
) ||
|
||||
/%2f|%5c/i.test(url.pathname)
|
||||
)
|
||||
return null;
|
||||
return url.href;
|
||||
}
|
||||
@@ -149,6 +149,7 @@ import {
|
||||
cameraDisplayCode,
|
||||
rowArrayToObject,
|
||||
prioritizeSources,
|
||||
pinnedCameraUrl,
|
||||
} from './normalize.js';
|
||||
import { directionToHeading } from '../../../src/data/directionText.js';
|
||||
import {
|
||||
@@ -310,9 +311,12 @@ export async function loadCaltransSourcesFromOpenData() {
|
||||
const lon = toFiniteNumber(loc.longitude);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
|
||||
const imageUrl = String(cctv.imageData?.static?.currentImageURL || '');
|
||||
// Official-host pin (see JSDoc). Also drops records with no still image.
|
||||
if (!imageUrl.startsWith('https://cwwp2.dot.ca.gov/')) continue;
|
||||
const imageUrl = pinnedCameraUrl(
|
||||
cctv.imageData?.static?.currentImageURL,
|
||||
'https://cwwp2.dot.ca.gov/',
|
||||
);
|
||||
if (!imageUrl) continue;
|
||||
|
||||
const locationName = String(loc.locationName || '').trim();
|
||||
// Leading token of locationName is the stable camera code ("TV102 -- I-580 : …").
|
||||
@@ -417,8 +421,8 @@ export async function loadTflSourcesFromOpenData() {
|
||||
const lat = toFiniteNumber(place?.lat);
|
||||
const lon = toFiniteNumber(place?.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
const imageUrl = String(props.imageUrl || '');
|
||||
if (!imageUrl.startsWith(TFL_IMAGE_ORIGIN)) continue; // official-bucket pin
|
||||
const imageUrl = pinnedCameraUrl(props.imageUrl, TFL_IMAGE_ORIGIN);
|
||||
if (!imageUrl) continue; // official-bucket pin
|
||||
|
||||
// "JamCams_00002.00865" → "tfl-00002.00865" (provider-stable id).
|
||||
const rawId = String(place?.id || '').replace(/^JamCams_/, '');
|
||||
@@ -1127,13 +1131,15 @@ export function loadTallinnSourcesFromCatalog({
|
||||
// Rough Estonia/Tallinn metro sanity (allows nearby suburbs already in the pack).
|
||||
if (lat < 59.2 || lat > 59.7 || lon < 24.3 || lon > 25.4) continue;
|
||||
|
||||
const imageUrl =
|
||||
const imageUrl = pinnedCameraUrl(
|
||||
typeof item.url === 'string'
|
||||
? item.url.trim()
|
||||
? item.url
|
||||
: typeof item.snapshotUrl === 'string'
|
||||
? item.snapshotUrl.trim()
|
||||
: '';
|
||||
if (!imageUrl.startsWith(TALLINN_IMAGE_ORIGIN)) continue;
|
||||
? item.snapshotUrl
|
||||
: '',
|
||||
TALLINN_IMAGE_ORIGIN,
|
||||
);
|
||||
if (!imageUrl) continue;
|
||||
|
||||
const extractedHeading = toFiniteNumber(item.headingDeg, NaN);
|
||||
const hasHeading = Number.isFinite(extractedHeading);
|
||||
@@ -1235,8 +1241,8 @@ export function parseTarkteeDatexImages(xml) {
|
||||
body,
|
||||
);
|
||||
if (!refMatch || !urlMatch) continue;
|
||||
const url = urlMatch[1].trim();
|
||||
if (!url.startsWith(TARKTEE_IMAGE_ORIGIN)) continue;
|
||||
const url = pinnedCameraUrl(urlMatch[1], TARKTEE_IMAGE_ORIGIN);
|
||||
if (!url) continue;
|
||||
out.set(refMatch[1], url);
|
||||
}
|
||||
return out;
|
||||
@@ -1422,14 +1428,16 @@ export function loadWarendorfSourcesFromCatalog({
|
||||
for (const item of rows) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const id = typeof item.id === 'string' ? item.id.trim() : '';
|
||||
const url =
|
||||
const raw =
|
||||
typeof item.url === 'string'
|
||||
? item.url.trim()
|
||||
? item.url
|
||||
: typeof item.snapshotUrl === 'string'
|
||||
? item.snapshotUrl.trim()
|
||||
? item.snapshotUrl
|
||||
: '';
|
||||
if (!id || !WARENDORF_IMAGE_ORIGINS.some((o) => url.startsWith(o)))
|
||||
continue;
|
||||
const url = WARENDORF_IMAGE_ORIGINS.map((o) =>
|
||||
pinnedCameraUrl(raw, o),
|
||||
).find(Boolean);
|
||||
if (!id || !url) continue;
|
||||
const lat = typeof item.lat === 'number' ? item.lat : NaN;
|
||||
const lon = typeof item.lon === 'number' ? item.lon : NaN;
|
||||
if (!isPlausibleLatLon(lat, lon)) continue;
|
||||
@@ -1483,8 +1491,8 @@ export function nswCameraToSource(feature) {
|
||||
const lat = typeof coords?.[1] === 'number' ? coords[1] : NaN;
|
||||
if (!isLikelyNswCoordinate(lat, lon)) return null;
|
||||
const props = feature?.properties || {};
|
||||
const url = String(props.href || '').trim();
|
||||
if (!url.startsWith(NSW_IMAGE_ORIGIN)) return null;
|
||||
const url = pinnedCameraUrl(props.href, NSW_IMAGE_ORIGIN);
|
||||
if (!url) return null;
|
||||
// "N-E" -> "NE" for the compass lookup.
|
||||
const direction = String(props.direction || '')
|
||||
.trim()
|
||||
@@ -1578,8 +1586,7 @@ export function normalizeCalgaryImageUrl(raw) {
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
||||
parsed.protocol = 'https:';
|
||||
const upgraded = parsed.toString();
|
||||
return upgraded.startsWith(CALGARY_IMAGE_ORIGIN) ? upgraded : null;
|
||||
return pinnedCameraUrl(parsed.toString(), CALGARY_IMAGE_ORIGIN);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2085,8 +2092,7 @@ export function normalizeHkImageUrl(raw) {
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
||||
parsed.protocol = 'https:';
|
||||
const upgraded = parsed.toString();
|
||||
return upgraded.startsWith(HK_IMAGE_ORIGIN) ? upgraded : null;
|
||||
return pinnedCameraUrl(parsed.toString(), HK_IMAGE_ORIGIN);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2423,10 +2429,16 @@ export async function loadKingCountySourcesFromOpenData() {
|
||||
// county image origin, so they are skipped.
|
||||
if (owner === 'WSDOT') continue;
|
||||
// The handler is served over HTTPS; the feed still writes http://.
|
||||
const imageUrl = String(attrs.ImageURL || '')
|
||||
.trim()
|
||||
.replace(/^http:\/\/info\.kingcounty\.gov\//, KING_COUNTY_IMAGE_ORIGIN);
|
||||
if (!imageUrl.startsWith(KING_COUNTY_IMAGE_ORIGIN)) continue;
|
||||
const imageUrl = pinnedCameraUrl(
|
||||
String(attrs.ImageURL || '')
|
||||
.trim()
|
||||
.replace(
|
||||
/^http:\/\/info\.kingcounty\.gov\//,
|
||||
KING_COUNTY_IMAGE_ORIGIN,
|
||||
),
|
||||
KING_COUNTY_IMAGE_ORIGIN,
|
||||
);
|
||||
if (!imageUrl) continue;
|
||||
const [lon, lat] = Array.isArray(row?.geometry?.coordinates)
|
||||
? row.geometry.coordinates
|
||||
: [];
|
||||
@@ -2586,8 +2598,8 @@ export async function loadWsdotSourcesFromOpenData() {
|
||||
const attrs = row?.attributes;
|
||||
const rawId = attrs?.CameraID;
|
||||
if (!Number.isSafeInteger(rawId) || rawId <= 0) continue;
|
||||
const imageUrl = String(attrs.ImageURL || '').trim();
|
||||
if (!imageUrl.startsWith(WSDOT_IMAGE_ORIGIN)) continue;
|
||||
const imageUrl = pinnedCameraUrl(attrs.ImageURL, WSDOT_IMAGE_ORIGIN);
|
||||
if (!imageUrl) continue;
|
||||
const x = row?.geometry?.x;
|
||||
const y = row?.geometry?.y;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||||
|
||||
@@ -236,13 +236,26 @@ export function gbfsProxy({ ratePerMinute = GBFS_RATE_PER_MIN } = {}) {
|
||||
}
|
||||
|
||||
const upstream = await fetchGbfsUpstream(upstreamUrl.toString());
|
||||
// Only JSON is relayed, re-serialized under a fixed type: neither the
|
||||
// upstream's text nor its declared type reaches the browser.
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(upstream.body);
|
||||
} catch {
|
||||
res.writeHead(502, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'GBFS upstream returned non-JSON' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(upstream.status, {
|
||||
'Content-Type': upstream.contentType,
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': gbfsCacheControl(upstreamUrl.pathname),
|
||||
'X-GBFS-Upstream': upstreamUrl.hostname,
|
||||
'X-GBFS-Cache': 'MISS',
|
||||
});
|
||||
res.end(upstream.body);
|
||||
res.end(JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
res.writeHead(504, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import { readRequestBodyCapped } from './common/request.js';
|
||||
import { admitSameSite } from './common/same-site.js';
|
||||
import {
|
||||
OVERPASS_MAX_BODY_BYTES,
|
||||
OVERPASS_MAX_CONCURRENT,
|
||||
@@ -27,6 +28,14 @@ const _overpassInFlight = new Map();
|
||||
|
||||
let _overpassConcurrent = 0;
|
||||
|
||||
// Bound body-reading and validation work, including malformed requests and
|
||||
// cache hits. Keep the tighter upstream budget below independent of this gate.
|
||||
const _overpassRequestLimiter = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: 300,
|
||||
globalMax: 900,
|
||||
});
|
||||
|
||||
const _overpassRateLimiter = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: 90,
|
||||
@@ -94,6 +103,17 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (admitSameSite(req, res)) return;
|
||||
if (!_overpassRequestLimiter(clientKey(req))) {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'Retry-After': '60',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect POST body with a hard byte cap (Overpass QL queries are small)
|
||||
let body;
|
||||
try {
|
||||
|
||||
@@ -18,6 +18,29 @@ function isOverpassBoundaryQuery(cacheKey) {
|
||||
return /is_in\s*\(|\bpivot\b/i.test(String(cacheKey || ''));
|
||||
}
|
||||
|
||||
// Input has already had quoted literals and comments removed. Consume each
|
||||
// character once: a regex that searches for a closing bracket from every `[` is
|
||||
// quadratic on an unterminated filter. Nested or unmatched brackets are invalid.
|
||||
function stripTagFilters(text) {
|
||||
const parts = [];
|
||||
let start = 0;
|
||||
let inFilter = false;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (text[i] === '[') {
|
||||
if (inFilter) return null;
|
||||
parts.push(text.slice(start, i), ' ');
|
||||
inFilter = true;
|
||||
} else if (text[i] === ']') {
|
||||
if (!inFilter) return null;
|
||||
inFilter = false;
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if (inFilter) return null;
|
||||
parts.push(text.slice(start));
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + clamp an Overpass form body. Defends the generic proxy against
|
||||
* planet-scale abuse: requires exactly one `data` query in which EVERY element
|
||||
@@ -50,6 +73,10 @@ function sanitizeOverpassBody(rawBody) {
|
||||
// Blank quoted literals + strip comments in one lexer pass so a fake bound or a
|
||||
// `//` inside a string can't hide an unbounded selector (or satisfy a bound).
|
||||
const stripped = stripOverpassNoise(data);
|
||||
const withoutTags = stripTagFilters(stripped);
|
||||
if (withoutTags === null) {
|
||||
return { ok: false, error: 'Malformed Overpass tag filter' };
|
||||
}
|
||||
|
||||
// Reject oversized radii in EVERY around form — point `around:r,lat,lon` AND the
|
||||
// input-set form `around.set:r` — and parse the full numeric token so scientific
|
||||
@@ -93,20 +120,19 @@ function sanitizeOverpassBody(rawBody) {
|
||||
// statement. So `way[...]->.a` (global assigned to a set) is rejected, while the
|
||||
// app's `is_in(...)->.a; area.a[...]` and `area(id)->.x; rel(pivot.x)` validate.
|
||||
const boundedSets = new Set();
|
||||
for (let stmt of stripped.split(';')) {
|
||||
for (let stmt of withoutTags.split(';')) {
|
||||
stmt = stmt.trim();
|
||||
if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue;
|
||||
|
||||
// Strip output-set assignments (NOT input bounds), then strip bracket tag
|
||||
// filters so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) can never be
|
||||
// Strip output-set assignments (NOT input bounds). Bracket tag filters were
|
||||
// removed above so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot be
|
||||
// misread as a spatial bound. Bounds live in (...) / function calls / set
|
||||
// refs, never inside [...], so the probe loses nothing real.
|
||||
const outSets = [];
|
||||
const body = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
|
||||
const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
|
||||
outSets.push(name);
|
||||
return ' ';
|
||||
});
|
||||
const probe = body.replace(/\[[^\]]*\]/g, ' ');
|
||||
|
||||
// Reject element-in-area scans on the TAG-STRIPPED probe, so a tag filter
|
||||
// between the selector and the area filter (way["highway"](area.a)) can't hide
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Readable } from 'node:stream';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { overpassProxy } from '../overpass.js';
|
||||
import { sanitizeOverpassBody } from './query.js';
|
||||
|
||||
const form = (query) => `data=${encodeURIComponent(query)}`;
|
||||
|
||||
test('bracket validation rejects malformed filters without trusting their contents as bounds', () => {
|
||||
for (const query of [
|
||||
'node[',
|
||||
'node]',
|
||||
'node[[name]](around:20,1,1);out;',
|
||||
'node[name=around:20];out;',
|
||||
'node[is_in];out;',
|
||||
'node[name="(around:20,1,1)"];out;',
|
||||
'node(around:20,1,1);way[name];out;',
|
||||
'way[highway](area.a);out;',
|
||||
'node[->.a](around:20,1,1);way.a;out;',
|
||||
]) {
|
||||
assert.equal(sanitizeOverpassBody(form(query)).ok, false, query);
|
||||
}
|
||||
});
|
||||
|
||||
test('bounded queries preserve quoted brackets, comments, multiple tags and timeout clamping', () => {
|
||||
for (const query of [
|
||||
'[out:json][timeout:999];node[name="[Town]; ]"](around:20,1,1);out;',
|
||||
'/* [ */way[highway][name](30.1,-97.9,30.5,-97.5);out geom;',
|
||||
'is_in(30.2,-97.7)->.a;area.a[boundary="administrative"];out tags;',
|
||||
'area(3600039152)->.x;rel(pivot.x);out geom;',
|
||||
]) {
|
||||
const result = sanitizeOverpassBody(form(query));
|
||||
assert.equal(result.ok, true, query);
|
||||
assert.equal(
|
||||
new URLSearchParams(result.body).get('data'),
|
||||
query.replace('[timeout:999]', '[timeout:30]'),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a burst of maximum-size unterminated filters completes within a bounded process deadline', () => {
|
||||
// A child deadline can stop synchronous regex backtracking; a test timeout in
|
||||
// the same event loop cannot. The old parser takes several seconds for this.
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
`import { sanitizeOverpassBody } from ${JSON.stringify(new URL('./query.js', import.meta.url).href)};
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (sanitizeOverpassBody('data=node' + '['.repeat(24000)).ok) process.exit(1);
|
||||
}`,
|
||||
],
|
||||
{ timeout: 2000, encoding: 'utf8' },
|
||||
);
|
||||
assert.ifError(result.error);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
function handler(hook = 'configureServer') {
|
||||
let route;
|
||||
overpassProxy()[hook]({
|
||||
middlewares: {
|
||||
use(path, fn) {
|
||||
if (path === '/api/overpass') route = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
return route;
|
||||
}
|
||||
|
||||
async function call(
|
||||
route,
|
||||
{
|
||||
headers = {},
|
||||
peer = '127.0.0.1',
|
||||
body = 'data=node[',
|
||||
forbidRead = false,
|
||||
method = 'POST',
|
||||
url = '/',
|
||||
} = {},
|
||||
) {
|
||||
let reads = 0;
|
||||
const req = new Readable({
|
||||
read() {
|
||||
reads++;
|
||||
assert.equal(forbidRead, false, 'refused request must not read its body');
|
||||
this.push(Buffer.from(body));
|
||||
this.push(null);
|
||||
},
|
||||
});
|
||||
Object.assign(req, {
|
||||
method,
|
||||
url,
|
||||
headers: { host: 'localhost:4173', ...headers },
|
||||
socket: { remoteAddress: peer },
|
||||
});
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
setHeader(key, value) {
|
||||
this.headers[key.toLowerCase()] = value;
|
||||
},
|
||||
writeHead(status, values) {
|
||||
this.statusCode = status;
|
||||
for (const [key, value] of Object.entries(values))
|
||||
this.setHeader(key, value);
|
||||
},
|
||||
end(value) {
|
||||
this.body = value;
|
||||
},
|
||||
};
|
||||
await route(req, res);
|
||||
return { ...res, reads };
|
||||
}
|
||||
|
||||
for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
test(`${hook}: cross-site requests are rejected before consuming a body or fetching`, async (t) => {
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
const route = handler(hook);
|
||||
for (const headers of [
|
||||
{ origin: 'https://foreign.example', 'content-type': 'text/plain' },
|
||||
{ 'sec-fetch-site': 'cross-site' },
|
||||
{ 'sec-fetch-site': 'same-site' },
|
||||
{ origin: 'null' },
|
||||
{ 'x-forwarded-for': '127.0.0.1' },
|
||||
]) {
|
||||
const response = await call(route, { headers, forbidRead: true });
|
||||
assert.equal(response.statusCode, 403);
|
||||
assert.equal(response.reads, 0);
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('same-origin, LAN and non-browser requests still reach validation; GET status stays available', async () => {
|
||||
const route = handler();
|
||||
for (const headers of [
|
||||
{},
|
||||
{ origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' },
|
||||
{ host: '192.168.1.10:4173', origin: 'http://192.168.1.10:4173' },
|
||||
]) {
|
||||
const response = await call(route, { headers });
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.reads, 1);
|
||||
assert.match(response.body, /Malformed Overpass tag filter/);
|
||||
}
|
||||
const response = await call(route, {
|
||||
method: 'GET',
|
||||
url: '/status',
|
||||
forbidRead: true,
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(typeof JSON.parse(response.body).configured, 'boolean');
|
||||
});
|
||||
|
||||
test('invalid requests spend admission quota before parsing, including on an unconfigured server', async (t) => {
|
||||
let now = Date.now() + 120000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
assert.fail('unexpected upstream fetch'),
|
||||
);
|
||||
const route = handler();
|
||||
const peer = 'admission-limit-test';
|
||||
for (let i = 0; i < 300; i++) {
|
||||
assert.equal((await call(route, { peer })).statusCode, 400);
|
||||
}
|
||||
const denied = await call(route, { peer, forbidRead: true });
|
||||
assert.equal(denied.statusCode, 429);
|
||||
assert.equal(denied.reads, 0);
|
||||
assert.equal(denied.headers['retry-after'], '60');
|
||||
assert.equal((await call(route, { peer: 'other-peer' })).statusCode, 400);
|
||||
now += 60001;
|
||||
assert.equal((await call(route, { peer })).statusCode, 400);
|
||||
});
|
||||
+10
-10
@@ -30,6 +30,7 @@
|
||||
* at finish, deliberately and in one place — see `finishSpec` in drawMode.js.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { createSegmentedControl } from '../ui/segmentedControl.js';
|
||||
import { pickWorldFromScreen } from './annotationResolver.js';
|
||||
import {
|
||||
claimPointer,
|
||||
@@ -432,22 +433,21 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
const shapeButtons = [
|
||||
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
|
||||
];
|
||||
for (const btn of shapeButtons) {
|
||||
listen(btn, 'click', () => {
|
||||
shape = normalizeShape(btn.dataset.shape);
|
||||
for (const other of shapeButtons) {
|
||||
const on = other === btn;
|
||||
other.classList.toggle('active', on);
|
||||
other.setAttribute('aria-checked', String(on));
|
||||
}
|
||||
const shapeControl = createSegmentedControl({
|
||||
buttons: shapeButtons,
|
||||
valueOf: (button) => button.dataset.shape,
|
||||
listen,
|
||||
onChange: (value) => {
|
||||
shape = normalizeShape(value);
|
||||
shapeControl.update(shape);
|
||||
if (active) {
|
||||
generation += 1;
|
||||
session = createDrawSession(shape);
|
||||
cursor = null;
|
||||
syncPreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
listen(colorSelect, 'change', () => {
|
||||
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
|
||||
syncPreview();
|
||||
|
||||
@@ -219,6 +219,17 @@ test('the separate viewer export imports without constructing a browser viewer',
|
||||
assert.throws(() => createApplicationViewer({}), /containers are required/);
|
||||
});
|
||||
|
||||
test('the viewer gets the starry sky as an option, which keeps the Sun and Moon', async () => {
|
||||
const { applicationViewerOptions } = await import('./viewer.js');
|
||||
const { StarrySky } = await import('../sky/starrySky.js');
|
||||
const creditContainer = {};
|
||||
const options = applicationViewerOptions(creditContainer);
|
||||
// CesiumWidget skips scene.sun and scene.moon when skyBox is false.
|
||||
assert.ok(options.skyBox instanceof StarrySky);
|
||||
assert.equal(options.creditContainer, creditContainer);
|
||||
options.skyBox.destroy();
|
||||
});
|
||||
|
||||
function pinchFixture({ zoomEventTypes } = {}) {
|
||||
const handlers = new Set();
|
||||
const relayed = [];
|
||||
|
||||
+18
-6
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
|
||||
import { StarrySky } from '../sky/starrySky.js';
|
||||
|
||||
const PINCH_ZOOM_MULTIPLIER = 8;
|
||||
const MAX_PINCH_PIXEL_DELTA = 120;
|
||||
@@ -102,11 +103,9 @@ export function installTrackpadPinchZoom(
|
||||
};
|
||||
}
|
||||
|
||||
/** Create the standard globe viewer in caller-owned, visible containers. */
|
||||
export function createApplicationViewer({ container, creditContainer }) {
|
||||
if (!container || !creditContainer)
|
||||
throw new TypeError('Viewer and credit containers are required');
|
||||
const viewer = new Cesium.Viewer(container, {
|
||||
/** Cesium.Viewer options of the standard globe viewer. */
|
||||
export function applicationViewerOptions(creditContainer) {
|
||||
return {
|
||||
timeline: false,
|
||||
animation: false,
|
||||
baseLayerPicker: false,
|
||||
@@ -119,10 +118,23 @@ export function createApplicationViewer({ container, creditContainer }) {
|
||||
selectionIndicator: false,
|
||||
infoBox: false,
|
||||
baseLayer: false,
|
||||
// Replaces the blocky Tycho-2 default. An option, not `skyBox: false`,
|
||||
// which would also stop CesiumWidget from creating the Sun and Moon.
|
||||
skyBox: new StarrySky(),
|
||||
creditContainer,
|
||||
msaaSamples: 4,
|
||||
contextOptions: { webgl: { preserveDrawingBuffer: true } },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Create the standard globe viewer in caller-owned, visible containers. */
|
||||
export function createApplicationViewer({ container, creditContainer }) {
|
||||
if (!container || !creditContainer)
|
||||
throw new TypeError('Viewer and credit containers are required');
|
||||
const viewer = new Cesium.Viewer(
|
||||
container,
|
||||
applicationViewerOptions(creditContainer),
|
||||
);
|
||||
try {
|
||||
viewer.targetFrameRate = 60;
|
||||
// Before any tile builds a draw command: Cesium's per-vertex model
|
||||
|
||||
@@ -33,6 +33,14 @@ const VIDEO = {
|
||||
feedType: 'video',
|
||||
url: 'https://cams.upstream.invalid/live.mp4',
|
||||
};
|
||||
const WEBM = {
|
||||
id: 'webm-fixture',
|
||||
name: 'WebM fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'webm',
|
||||
url: 'https://cams.upstream.invalid/live.webm',
|
||||
};
|
||||
const MJPEG = {
|
||||
id: 'mjpeg-fixture',
|
||||
name: 'MJPEG fixture',
|
||||
@@ -69,7 +77,10 @@ function recordingResponse() {
|
||||
body: '',
|
||||
writeHead(status, headers) {
|
||||
res.statusCode = status;
|
||||
res.headers = headers || {};
|
||||
res.headers = { ...res.headers, ...headers };
|
||||
},
|
||||
setHeader(name, value) {
|
||||
res.headers[name] = value;
|
||||
},
|
||||
write(chunk) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
@@ -105,6 +116,7 @@ function mount(t, respond) {
|
||||
STILL,
|
||||
STILL_ALIAS,
|
||||
VIDEO,
|
||||
WEBM,
|
||||
MJPEG,
|
||||
]);
|
||||
process.env.CCTV_SOURCES_FILE = 'absent-source-file.json';
|
||||
@@ -143,9 +155,9 @@ function mount(t, respond) {
|
||||
});
|
||||
assert.ok(handler, 'the CCTV provider must mount a middleware');
|
||||
|
||||
const call = async (path) => {
|
||||
const call = async (path, headers = {}) => {
|
||||
const { res, done } = recordingResponse();
|
||||
await handler({ url: path, headers: {}, method: 'GET', on() {} }, res);
|
||||
await handler({ url: path, headers, method: 'GET', on() {} }, res);
|
||||
await done;
|
||||
return res;
|
||||
};
|
||||
@@ -268,3 +280,251 @@ test('/frame does not follow a still camera off its origin', async (t) => {
|
||||
[STILL.url],
|
||||
);
|
||||
});
|
||||
|
||||
/** An upstream Response whose body records whether it was cancelled. */
|
||||
function trackedUpstream({ status = 200, headers = {}, body = 'payload' }) {
|
||||
const state = { cancelled: false };
|
||||
const response = new Response(
|
||||
new ReadableStream(
|
||||
{
|
||||
pull(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(body));
|
||||
controller.close();
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 0 },
|
||||
),
|
||||
{ status, headers },
|
||||
);
|
||||
return { response, state };
|
||||
}
|
||||
|
||||
test('/media refuses an upstream type that does not match the feed kind', async (t) => {
|
||||
let current;
|
||||
const app = mount(t, () => current.response);
|
||||
for (const [camera, contentType, body] of [
|
||||
[VIDEO, 'text/html', '<!doctype html><p>x</p>'],
|
||||
[VIDEO, 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
|
||||
[VIDEO, 'application/vnd.apple.mpegurl', '#EXTM3U'],
|
||||
[VIDEO, 'video/webm', 'video'],
|
||||
[VIDEO, undefined, 'video'],
|
||||
[WEBM, 'video/mp4', 'video'],
|
||||
[MJPEG, 'multipart/x-mixed-replace', '--frame'],
|
||||
[MJPEG, 'multipart/x-mixed-replace; boundary=', '--frame'],
|
||||
[MJPEG, 'image/jpeg', 'jpeg'],
|
||||
[MJPEG, 'text/html; boundary=frame', '<!doctype html><p>x</p>'],
|
||||
]) {
|
||||
current = trackedUpstream({
|
||||
headers: contentType === undefined ? {} : { 'content-type': contentType },
|
||||
body,
|
||||
});
|
||||
const label = `${camera.id}: ${contentType}`;
|
||||
const res = await app.call(`/media/${camera.id}`);
|
||||
assert.equal(res.statusCode, 502, label);
|
||||
assert.equal(res.headers['Content-Type'], 'application/json', label);
|
||||
assert.deepEqual(JSON.parse(res.body), { error: 'Media proxy failed' });
|
||||
assert.equal(current.state.cancelled, true, `${label}: upstream cancelled`);
|
||||
const health = await app.health(camera.id);
|
||||
assert.equal(health.status, 'degraded', label);
|
||||
assert.equal(health.message, 'Unexpected media type', label);
|
||||
}
|
||||
});
|
||||
|
||||
test('/media relays each feed kind under its canonical type, including a partial response', async (t) => {
|
||||
let current;
|
||||
const app = mount(t, () => current);
|
||||
for (const [camera, upstream, expected] of [
|
||||
[VIDEO, { 'content-type': 'Video/MP4; codecs="avc1.42E01E"' }, 'video/mp4'],
|
||||
[
|
||||
VIDEO,
|
||||
{
|
||||
'content-type': 'video/mp4',
|
||||
'content-range': 'bytes 0-4/100',
|
||||
'content-length': '5',
|
||||
'accept-ranges': 'bytes',
|
||||
},
|
||||
'video/mp4',
|
||||
],
|
||||
[WEBM, { 'content-type': 'video/webm' }, 'video/webm'],
|
||||
[
|
||||
MJPEG,
|
||||
{ 'content-type': 'multipart/x-mixed-replace;boundary="frame"' },
|
||||
'multipart/x-mixed-replace; boundary=frame',
|
||||
],
|
||||
[
|
||||
MJPEG,
|
||||
{ 'content-type': 'multipart/x-mixed-replace; boundary="a b"' },
|
||||
'multipart/x-mixed-replace; boundary="a b"',
|
||||
],
|
||||
]) {
|
||||
const status = upstream['content-range'] ? 206 : 200;
|
||||
current = upstreamResponse({ status, headers: upstream, body: 'bytes' });
|
||||
const res = await app.call(`/media/${camera.id}`);
|
||||
assert.equal(res.statusCode, status, camera.id);
|
||||
assert.equal(res.headers['Content-Type'], expected);
|
||||
assert.equal(res.body, 'bytes');
|
||||
if (status === 206)
|
||||
assert.equal(res.headers['Content-Range'], 'bytes 0-4/100');
|
||||
assert.equal((await app.health(camera.id)).status, 'ok');
|
||||
}
|
||||
});
|
||||
|
||||
test('/media does not follow a live camera off its origin', async (t) => {
|
||||
const app = mount(t, ({ url }) =>
|
||||
url === VIDEO.url
|
||||
? upstreamResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://elsewhere.invalid/live.mp4' },
|
||||
body: '',
|
||||
})
|
||||
: upstreamResponse({
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: 'elsewhere',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${VIDEO.id}`);
|
||||
assert.equal(res.statusCode, 502);
|
||||
assert.doesNotMatch(res.body, /elsewhere/);
|
||||
assert.deepEqual(app.requests, [{ url: VIDEO.url, redirect: 'manual' }]);
|
||||
});
|
||||
|
||||
test('every CCTV route refuses cross-site requests and admits the app itself', async (t) => {
|
||||
const app = mount(t, ({ url }) =>
|
||||
upstreamResponse({
|
||||
headers: {
|
||||
'content-type': url === VIDEO.url ? 'video/mp4' : 'image/jpeg',
|
||||
},
|
||||
body: 'bytes',
|
||||
}),
|
||||
);
|
||||
const host = 'localhost:5173';
|
||||
const paths = [
|
||||
'/sources',
|
||||
'/health',
|
||||
`/stream/${STILL.id}`,
|
||||
`/frame/${STILL.id}`,
|
||||
`/media/${VIDEO.id}`,
|
||||
];
|
||||
for (const headers of [
|
||||
{ host, 'sec-fetch-site': 'cross-site' },
|
||||
{ host, 'sec-fetch-site': 'same-site' },
|
||||
{ host, origin: 'https://elsewhere.example' },
|
||||
{ host, origin: 'null' },
|
||||
])
|
||||
for (const path of paths) {
|
||||
const res = await app.call(path, headers);
|
||||
assert.equal(res.statusCode, 403, `${path} ${JSON.stringify(headers)}`);
|
||||
}
|
||||
assert.deepEqual(app.requests, [], 'a refused request reached an upstream');
|
||||
for (const headers of [
|
||||
{ host, 'sec-fetch-site': 'same-origin' },
|
||||
{ host, origin: `http://${host}` },
|
||||
{},
|
||||
]) {
|
||||
const frame = await app.call(`/frame/${STILL.id}`, headers);
|
||||
assert.equal(frame.statusCode, 200);
|
||||
assert.equal(frame.headers['X-CCTV-Source'], 'upstream-image');
|
||||
const media = await app.call(`/media/${VIDEO.id}`, headers);
|
||||
assert.equal(media.statusCode, 200);
|
||||
assert.equal(media.headers['Content-Type'], 'video/mp4');
|
||||
}
|
||||
});
|
||||
|
||||
test('/frame falls back past a non-raster upstream and Street View frame', async (t) => {
|
||||
let streetViewType;
|
||||
const app = mount(t, ({ url }) =>
|
||||
url.startsWith('https://maps.googleapis.com/')
|
||||
? trackedUpstream({
|
||||
headers:
|
||||
streetViewType === undefined
|
||||
? {}
|
||||
: { 'content-type': streetViewType },
|
||||
body: 'street-view',
|
||||
}).response
|
||||
: upstreamResponse({
|
||||
headers: { 'content-type': 'image/svg+xml' },
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
}),
|
||||
);
|
||||
process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key';
|
||||
for (const type of [
|
||||
'image/svg+xml',
|
||||
'text/html',
|
||||
'application/xml',
|
||||
undefined,
|
||||
]) {
|
||||
streetViewType = type;
|
||||
const res = await app.call(`/frame/${STILL.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'synthetic', String(type));
|
||||
assert.doesNotMatch(res.body, /street-view/);
|
||||
}
|
||||
streetViewType = 'image/JPEG; charset=x';
|
||||
const res = await app.call(`/frame/${STILL.id}`);
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'streetview');
|
||||
assert.equal(res.headers['Content-Type'], 'image/jpeg');
|
||||
assert.equal(res.body, 'street-view');
|
||||
});
|
||||
|
||||
test('a forwarded same-origin request reaches every CCTV route but not the Street View fallback', async (t) => {
|
||||
const app = mount(t, ({ url }) => {
|
||||
if (url.startsWith('https://maps.googleapis.com/'))
|
||||
return trackedUpstream({
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: 'street-view',
|
||||
}).response;
|
||||
return url === VIDEO.url
|
||||
? upstreamResponse({ headers: { 'content-type': 'video/mp4' } })
|
||||
: upstreamResponse({ status: 503, body: '' });
|
||||
});
|
||||
process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key';
|
||||
const host = 'localhost:5173';
|
||||
const streetViewCalls = () =>
|
||||
app.requests.filter(({ url }) =>
|
||||
url.startsWith('https://maps.googleapis.com/'),
|
||||
).length;
|
||||
for (const forwarded of [
|
||||
{ 'x-forwarded-for': '203.0.113.9' },
|
||||
{ via: '1.1 proxy' },
|
||||
{ forwarded: 'for=203.0.113.9' },
|
||||
{ 'x-real-ip': '203.0.113.9' },
|
||||
{ 'x-forwarded-for': '' },
|
||||
{ 'x-forwarded-for': ' ' },
|
||||
{ 'cf-connecting-ip': '' },
|
||||
]) {
|
||||
const headers = { host, 'sec-fetch-site': 'same-origin', ...forwarded };
|
||||
const label = JSON.stringify(forwarded);
|
||||
assert.equal((await app.call('/sources', headers)).statusCode, 200, label);
|
||||
const media = await app.call(`/media/${VIDEO.id}`, headers);
|
||||
assert.equal(media.statusCode, 200, label);
|
||||
assert.equal(media.headers['Content-Type'], 'video/mp4', label);
|
||||
const frame = await app.call(`/frame/${STILL.id}`, headers);
|
||||
assert.equal(frame.statusCode, 200, label);
|
||||
assert.equal(frame.headers['X-CCTV-Source'], 'synthetic', label);
|
||||
assert.equal(frame.headers['Content-Type'], 'image/svg+xml', label);
|
||||
// Cross-site stays refused whatever the forwarding headers say.
|
||||
for (const path of ['/sources', `/frame/${STILL.id}`, `/media/${VIDEO.id}`])
|
||||
assert.equal(
|
||||
(
|
||||
await app.call(path, {
|
||||
...forwarded,
|
||||
host,
|
||||
'sec-fetch-site': 'cross-site',
|
||||
})
|
||||
).statusCode,
|
||||
403,
|
||||
`${label} ${path}`,
|
||||
);
|
||||
}
|
||||
assert.equal(streetViewCalls(), 0, 'a forwarded request spent the key');
|
||||
const direct = await app.call(`/frame/${STILL.id}`, {
|
||||
host,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
});
|
||||
assert.equal(direct.headers['X-CCTV-Source'], 'streetview');
|
||||
assert.equal(direct.body, 'street-view');
|
||||
assert.equal(streetViewCalls(), 1);
|
||||
});
|
||||
@@ -259,3 +259,57 @@ test('rejected snapshot responses abort the upstream download', async () => {
|
||||
assert.equal(signal.aborted, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch accepts only raster image types and sends their canonical type', async () => {
|
||||
const frame = (contentType, body = 'frame') => {
|
||||
const state = { cancelled: false };
|
||||
const headers = contentType === null ? {} : { 'Content-Type': contentType };
|
||||
const response = new Response(new ReadableStream({
|
||||
pull(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(body));
|
||||
controller.close();
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
}, { highWaterMark: 0 }), { status: 200, headers });
|
||||
return { response, state };
|
||||
};
|
||||
for (const [contentType, canonical] of [
|
||||
['image/jpeg', 'image/jpeg'],
|
||||
['image/JPEG; charset=x', 'image/jpeg'],
|
||||
[' Image/PNG ', 'image/png'],
|
||||
['image/gif', 'image/gif'],
|
||||
['image/webp;q=1', 'image/webp'],
|
||||
['image/avif', 'image/avif'],
|
||||
]) {
|
||||
const { response } = frame(contentType);
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
fetchImpl: async () => response,
|
||||
});
|
||||
assert.equal(result?.contentType, canonical, contentType);
|
||||
assert.equal(result.body.toString(), 'frame');
|
||||
}
|
||||
for (const [contentType, body] of [
|
||||
['image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
|
||||
['Image/SVG+XML; charset=utf-8', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
|
||||
['text/html', '<!doctype html><p>x</p>'],
|
||||
['text/html; charset=utf-8', '<!doctype html><p>x</p>'],
|
||||
['application/xml', '<x/>'],
|
||||
['text/xml', '<x/>'],
|
||||
[null, 'frame'],
|
||||
['', 'frame'],
|
||||
['image', 'frame'],
|
||||
['image/', 'frame'],
|
||||
['image/jpeg/x', 'frame'],
|
||||
['image/jpeg, text/html', 'frame'],
|
||||
['application/octet-stream', 'frame'],
|
||||
]) {
|
||||
const { response, state } = frame(contentType, body);
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
fetchImpl: async () => response,
|
||||
});
|
||||
assert.equal(result, null, String(contentType));
|
||||
assert.equal(state.cancelled, true, `${contentType}: body cancelled`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// Camera catalogs are pinned to their agency's image directory. The pin is a
|
||||
// parsed-URL check, not a string prefix, so a path that only starts with the
|
||||
// directory before dot segments, an encoded separator, credentials or another
|
||||
// port are resolved never counts as inside it, and a redirect on a pinned host
|
||||
// cannot leave the directory either.
|
||||
//
|
||||
// Run with: npm test (node --test)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadTflSourcesFromOpenData } from '../../server/providers/cctv/sources.js';
|
||||
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
|
||||
import { pinnedCameraUrl } from '../../server/providers/cctv/normalize.js';
|
||||
|
||||
const BUCKET = 'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
|
||||
|
||||
/** One available JamCam whose frame is at `imageUrl`. */
|
||||
const jamCam = (n, imageUrl) => ({
|
||||
id: `JamCams_00001.0000${n}`,
|
||||
commonName: `JamCam ${n}`,
|
||||
lat: 51.5072,
|
||||
lon: -0.1276,
|
||||
additionalProperties: [
|
||||
{ key: 'available', value: 'true' },
|
||||
{ key: 'imageUrl', value: imageUrl },
|
||||
],
|
||||
});
|
||||
|
||||
test('TfL JamCams keep only frames inside the official bucket, by parsed URL', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const accepted = [
|
||||
[`${BUCKET}00001.00001.jpg`, `${BUCKET}00001.00001.jpg`],
|
||||
[`${BUCKET}00001.00002.jpg?t=1`, `${BUCKET}00001.00002.jpg?t=1`],
|
||||
[
|
||||
'https://s3-eu-west-1.amazonaws.com:443/jamcams.tfl.gov.uk/00001.00003.jpg',
|
||||
`${BUCKET}00001.00003.jpg`,
|
||||
],
|
||||
];
|
||||
const rejected = [
|
||||
`${BUCKET}../other/x.jpg`,
|
||||
`${BUCKET}%2e%2e/other/x.jpg`,
|
||||
`${BUCKET}%2E%2E/other/x.jpg`,
|
||||
`${BUCKET}.%2e/other/x.jpg`,
|
||||
`${BUCKET}..%2fother/x.jpg`,
|
||||
`${BUCKET}..%5Cother/x.jpg`,
|
||||
`${BUCKET}..\\other\\x.jpg`,
|
||||
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk.other/x.jpg',
|
||||
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk-other/x.jpg',
|
||||
'https://s3-eu-west-1.amazonaws.com/other/jamcams.tfl.gov.uk/x.jpg',
|
||||
'https://user:[email protected]/jamcams.tfl.gov.uk/x.jpg',
|
||||
'https://s3-eu-west-1.amazonaws.com:8443/jamcams.tfl.gov.uk/x.jpg',
|
||||
'http://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/x.jpg',
|
||||
'https://s3-eu-west-1.amazonaws.com.other.example/jamcams.tfl.gov.uk/x.jpg',
|
||||
'not a url',
|
||||
];
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json(
|
||||
[...accepted.map(([url]) => url), ...rejected].map((url, i) =>
|
||||
jamCam(i + 1, url),
|
||||
),
|
||||
),
|
||||
);
|
||||
const cameras = await loadTflSourcesFromOpenData();
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.url),
|
||||
accepted.map(([, normalized]) => normalized),
|
||||
);
|
||||
});
|
||||
|
||||
test('a redirect on a pinned camera host stays inside the pinned directory', async () => {
|
||||
const redirectTo = (location) => async (url) =>
|
||||
url === `${BUCKET}00001.00001.jpg`
|
||||
? new Response(null, { status: 302, headers: { location } })
|
||||
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
|
||||
for (const location of [
|
||||
'/jamcams.tfl.gov.uk.other/x.jpg',
|
||||
'/other/x.jpg',
|
||||
'/jamcams.tfl.gov.uk/../other/x.jpg',
|
||||
'/jamcams.tfl.gov.uk/%2e%2e/other/x.jpg',
|
||||
'/jamcams.tfl.gov.uk/..%2fother/x.jpg',
|
||||
])
|
||||
assert.equal(
|
||||
await fetchWithinHost(
|
||||
`${BUCKET}00001.00001.jpg`,
|
||||
{},
|
||||
redirectTo(location),
|
||||
),
|
||||
null,
|
||||
location,
|
||||
);
|
||||
const followed = await fetchWithinHost(
|
||||
`${BUCKET}00001.00001.jpg`,
|
||||
{},
|
||||
redirectTo('/jamcams.tfl.gov.uk/00001.00002.jpg'),
|
||||
);
|
||||
assert.equal(await followed.text(), 'frame');
|
||||
// A host with no pinned directory keeps the same-origin rule alone.
|
||||
const unpinned = await fetchWithinHost(
|
||||
'https://cams.example/a/frame.jpg',
|
||||
{},
|
||||
async (url) =>
|
||||
url === 'https://cams.example/a/frame.jpg'
|
||||
? new Response(null, { status: 302, headers: { location: '/b/x.jpg' } })
|
||||
: new Response('frame'),
|
||||
);
|
||||
assert.equal(await unpinned.text(), 'frame');
|
||||
});
|
||||
|
||||
test('a pin prefix without a trailing slash still names a directory', () => {
|
||||
assert.equal(
|
||||
pinnedCameraUrl('https://h.example/camsX/a.jpg', 'https://h.example/cams'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -429,6 +429,13 @@ export const DATA_CREDITS = [
|
||||
'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.',
|
||||
},
|
||||
// ── Bundled snapshots ───────────────────────────────────────────
|
||||
{
|
||||
key: 'sky-background',
|
||||
html:
|
||||
'Stars: <a href="https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50" target="_blank" rel="noopener">Yale Bright Star Catalogue</a> ' +
|
||||
'(Hoffleit & Warren, via CDS). Milky Way: <a href="https://www.eso.org/public/images/eso0932a/" target="_blank" rel="noopener">ESO/S. Brunier</a> ' +
|
||||
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>; stars removed, blurred and reprojected)',
|
||||
},
|
||||
{
|
||||
key: 'dams',
|
||||
html: 'Dams: Open Infrastructure Map',
|
||||
|
||||
@@ -304,3 +304,61 @@ test('GBFS rejects redirects and caps bodies through both server hooks', async (
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('GBFS relays only JSON, re-serialized under a fixed type, through both server hooks', async () => {
|
||||
const { gbfsProxy } = await import('../../server/providers/gbfs.js');
|
||||
const originalFetch = globalThis.fetch;
|
||||
try {
|
||||
for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
let handler;
|
||||
gbfsProxy()[hook]({
|
||||
middlewares: {
|
||||
use(_path, callback) {
|
||||
handler = callback;
|
||||
},
|
||||
},
|
||||
});
|
||||
const request = async (response) => {
|
||||
globalThis.fetch = async () => response;
|
||||
const res = {};
|
||||
await handler(
|
||||
{ method: 'GET', url: '/' + encodeURIComponent(STATION_URL) },
|
||||
{
|
||||
writeHead(code, headers) {
|
||||
Object.assign(res, { status: code, headers });
|
||||
},
|
||||
end(value) {
|
||||
res.body = value;
|
||||
},
|
||||
},
|
||||
);
|
||||
return res;
|
||||
};
|
||||
for (const [contentType, body] of [
|
||||
['text/html', '<!doctype html><p>x</p>'],
|
||||
['application/json', '<!doctype html><p>x</p>'],
|
||||
['image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
|
||||
['application/json', ''],
|
||||
]) {
|
||||
const res = await request(
|
||||
new Response(body, { headers: { 'Content-Type': contentType } }),
|
||||
);
|
||||
assert.equal(res.status, 502, `${hook}: ${contentType} ${body}`);
|
||||
assert.equal(res.headers['Content-Type'], 'application/json');
|
||||
assert.equal(typeof JSON.parse(res.body).error, 'string');
|
||||
assert.doesNotMatch(res.body, /doctype|svg/);
|
||||
}
|
||||
const res = await request(
|
||||
new Response(' { "data" : { "stations" : [] } } ', {
|
||||
headers: { 'Content-Type': 'text/html; charset=iso-8859-1' },
|
||||
}),
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers['Content-Type'], 'application/json; charset=utf-8');
|
||||
assert.equal(res.headers['Cache-Control'], 'no-store');
|
||||
assert.equal(res.body, STATION_BODY);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
# Sky background
|
||||
|
||||
Catalogue stars over a soft Milky Way, drawn by `src/sky/starrySky.js` in
|
||||
place of Cesium's blocky Tycho-2 sky box.
|
||||
|
||||
| File | Content | Size |
|
||||
| ---- | ------- | ---- |
|
||||
| `bright_stars.json` | 9,096 stars: J2000 RA/Dec, V magnitude, B−V | 235 KB |
|
||||
| `milkyway_{px,nx,py,ny,pz,nz}.webp` | Milky Way glow, six 1024² cube faces, no stars | 62 KB |
|
||||
|
||||
## Stars — Yale Bright Star Catalogue
|
||||
|
||||
**Source:** 5th Revised Edition (Hoffleit & Warren 1991), CDS catalogue
|
||||
[V/50](https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50), file
|
||||
`https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz` (sha256
|
||||
`3dc44b1e90be8fbe5bcc7656032560f51275f985c7e3f783c9028e1838ec7bed`, fetched
|
||||
2026-10-19).
|
||||
|
||||
**License:** public domain. CDS asks for an acknowledgement; the "Data
|
||||
attribution" popover carries it.
|
||||
|
||||
**Build:** `node scripts/build-bright-stars.mjs [catalog.gz]`. Drops the 14
|
||||
records without a J2000 position; rounds RA/Dec to 0.001° and magnitudes to
|
||||
0.01.
|
||||
|
||||
## Milky Way — ESO/S. Brunier
|
||||
|
||||
**Source:** ESO image [eso0932a](https://www.eso.org/public/images/eso0932a/),
|
||||
`https://cdn.eso.org/images/original/eso0932a.tif` (6000×3000 sRGB TIFF, sha256
|
||||
`10f209ab83e1fd89e7fa1ed70277ffc6ed19c43549f04ec637c6806d98aff035`, fetched
|
||||
2026-10-19).
|
||||
|
||||
**License:** [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
|
||||
**Credit: ESO/S. Brunier.**
|
||||
|
||||
**Changes made** (CC BY 4.0 asks for them): resampled to 4096×2048; stars
|
||||
removed; two images of Jupiter painted over; blurred (0.4°, 2° in the faint
|
||||
glow); reprojected to ICRF cube faces; black level at the median sky; saved as
|
||||
WebP. Build: `node scripts/build-milky-way.mjs <eso0932a.tif>`.
|
||||
|
||||
The panorama's stars sit up to ~2° off; that is invisible in the blurred glow,
|
||||
and every sharp star comes from the catalogue.
|
||||
|
||||
NASA SVS "Deep Star Maps 2020" was not used: it derives from Gaia DR2, which
|
||||
ESA licenses CC BY-NC 3.0 IGO.
|
||||
|
||||
## Frame
|
||||
|
||||
Both layers are in ICRF/J2000. The cube faces use the WebGL face layout, +X
|
||||
to the March equinox and +Z to the north celestial pole, uploaded without
|
||||
`flipY`.
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
@@ -29,5 +29,5 @@ test('the first-run checkbox keeps its native visible label', () => {
|
||||
|
||||
test('generated style sliders use the visible parameter label as their name', () => {
|
||||
assert.match(parameters, /label\.textContent\s*=\s*t\(metadata\.label\);/);
|
||||
assert.match(parameters, /slider\.setAttribute\(['"]aria-label['"],\s*t\(metadata\.label\)\)/);
|
||||
assert.match(parameters, /ariaLabel:\s*t\(metadata\.label\)/);
|
||||
});
|
||||
@@ -945,7 +945,7 @@
|
||||
"Image by {creator}": "Imagen de {creator}",
|
||||
"zoom in to see KartaView coverage": "acércate para ver la cobertura de KartaView",
|
||||
"no KartaView photos in view": "no hay fotos de KartaView a la vista",
|
||||
"{names}: by upload date": "{names}: por fecha de subida",
|
||||
"(by upload date)": "(por fecha de subida)",
|
||||
"{n} streets in view · click a line for its nearest photo": {
|
||||
"one": "{n} calle a la vista · haz clic en una línea para su foto más cercana",
|
||||
"many": "{n} calles a la vista · haz clic en una línea para su foto más cercana",
|
||||
|
||||
@@ -954,7 +954,7 @@
|
||||
"Image by {creator}": "Автор снимка: {creator}",
|
||||
"zoom in to see KartaView coverage": "приблизьтесь, чтобы увидеть покрытие KartaView",
|
||||
"no KartaView photos in view": "фотографий KartaView в зоне видимости нет",
|
||||
"{names}: by upload date": "{names}: по дате загрузки",
|
||||
"(by upload date)": "(по дате загрузки)",
|
||||
"{n} streets in view · click a line for its nearest photo": {
|
||||
"one": "{n} улица в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
|
||||
"few": "{n} улицы в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
|
||||
|
||||
@@ -69,7 +69,7 @@ test('focusable controls do not animate the global outline', () => {
|
||||
'#top-center-actions button {',
|
||||
'.data-toggle-chip {',
|
||||
'.scene-btn {',
|
||||
'.scene-shot-btn {',
|
||||
'.gev-action-button--compact {',
|
||||
]) {
|
||||
assert.doesNotMatch(
|
||||
ruleBody(selector),
|
||||
|
||||
@@ -141,7 +141,7 @@ export const FILTERED_OUT_MESSAGE = msg(
|
||||
'None of KartaView’s photos here pass the imagery filter',
|
||||
);
|
||||
/** Under the SINCE slider while KartaView is lit. */
|
||||
export const SINCE_NOTE = 'KartaView: by upload date';
|
||||
export const SINCE_NOTE = '(by upload date)';
|
||||
|
||||
/** The UTC calendar day of `ms`, as KartaView's startDate reads it. */
|
||||
function utcDay(ms) {
|
||||
|
||||
@@ -23,7 +23,7 @@ export function filtersOf(provider) {
|
||||
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
|
||||
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
|
||||
* @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
|
||||
* @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' puts a note under the slider while the provider is lit.
|
||||
* @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' notes "(by upload date)" beside the SINCE value while the provider is lit.
|
||||
* @property {(context: ProviderContext) => ProviderInstance} create
|
||||
*
|
||||
* @typedef {object} ProviderContext Handed to `create()` once by the core.
|
||||
|
||||
@@ -73,16 +73,14 @@ export function filterControls(providers) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The note under SINCE when a lit provider dates its imagery by upload,
|
||||
* e.g. "KartaView: by upload date", or null.
|
||||
* The note beside the SINCE value when a lit provider dates its imagery by
|
||||
* upload, "(by upload date)", or null. One source is lit at a time, so the
|
||||
* note needs no provider name.
|
||||
* @param {Array<ProviderSnapshot>} providers
|
||||
*/
|
||||
export function sinceNote(providers) {
|
||||
const names = providers
|
||||
.filter((p) => p.on && p.sinceBasis === 'upload')
|
||||
.map((p) => p.name);
|
||||
return names.length
|
||||
? t('{names}: by upload date', { names: names.join(', ') })
|
||||
return providers.some((p) => p.on && p.sinceBasis === 'upload')
|
||||
? t('(by upload date)')
|
||||
: null;
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ test('the filter controls show only for a lit provider that honours them', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('a lit provider that dates by upload puts a note under SINCE', () => {
|
||||
test('a lit provider that dates by upload notes it beside the SINCE value', () => {
|
||||
const kartaView = provider({
|
||||
id: 'kartaview',
|
||||
name: 'KartaView',
|
||||
@@ -75,7 +75,7 @@ test('a lit provider that dates by upload puts a note under SINCE', () => {
|
||||
});
|
||||
const note = (providers) => composeUIState({ ...base, providers }).sinceNote;
|
||||
assert.equal(note([provider()]), null, 'Mapillary: capture date');
|
||||
assert.equal(note([kartaView]), 'KartaView: by upload date');
|
||||
assert.equal(note([kartaView]), '(by upload date)');
|
||||
assert.equal(note([{ ...kartaView, on: false }]), null, 'only while lit');
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,10 @@ export function createGoogleTokenSource({
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
return null;
|
||||
}
|
||||
return parseToken(await response.json(), now());
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -50,6 +50,23 @@ test('requests refused for the same token renew it once', async () => {
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
test('a refused token response releases its unread body before returning', async () => {
|
||||
for (const status of [404, 429, 500]) {
|
||||
let cancelled = false;
|
||||
const response = new Response(
|
||||
new ReadableStream({
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
{ status },
|
||||
);
|
||||
const tokens = createGoogleTokenSource({ fetchImpl: async () => response });
|
||||
assert.equal(await tokens.token(), null);
|
||||
assert.equal(cancelled, true, `HTTP ${status} body released`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a server that does not offer tokens yields none', async () => {
|
||||
for (const answer of [
|
||||
new Response('Not found', { status: 404 }),
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { loadBundledJson } from '../data/bundledJson.js';
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
import { buildStarVertices, STAR_VERTEX_FLOATS } from './stars.js';
|
||||
|
||||
/**
|
||||
* Space background in the `Scene#skyBox` slot: catalogue stars as point
|
||||
* sprites over a soft Milky Way cube map, both turned by one ICRF→fixed
|
||||
* rotation at the scene clock (TEME until Cesium's tables load). Scene runs
|
||||
* the command from `update()` first in the environment pass, so the sky
|
||||
* stays behind everything without depth. Revalidate on engine upgrades.
|
||||
*/
|
||||
export const STARRY_SKY_CESIUM_VERSION = '1.138.0';
|
||||
|
||||
/** Brightness of the Milky Way glow; the stars carry the sky. */
|
||||
export const MILKY_WAY_INTENSITY = 0.4;
|
||||
/** Clock change, in seconds, below which the sky rotation is reused. */
|
||||
export const SKY_ROTATION_EPSILON_SECONDS = 1;
|
||||
|
||||
const STAR_PACK_URL = new URL(
|
||||
'../data/local_data/sky/bright_stars.json',
|
||||
import.meta.url,
|
||||
);
|
||||
const MILKY_WAY_FACE_URLS = Object.freeze({
|
||||
positiveX: new URL(
|
||||
'../data/local_data/sky/milkyway_px.webp',
|
||||
import.meta.url,
|
||||
),
|
||||
negativeX: new URL(
|
||||
'../data/local_data/sky/milkyway_nx.webp',
|
||||
import.meta.url,
|
||||
),
|
||||
positiveY: new URL(
|
||||
'../data/local_data/sky/milkyway_py.webp',
|
||||
import.meta.url,
|
||||
),
|
||||
negativeY: new URL(
|
||||
'../data/local_data/sky/milkyway_ny.webp',
|
||||
import.meta.url,
|
||||
),
|
||||
positiveZ: new URL(
|
||||
'../data/local_data/sky/milkyway_pz.webp',
|
||||
import.meta.url,
|
||||
),
|
||||
negativeZ: new URL(
|
||||
'../data/local_data/sky/milkyway_nz.webp',
|
||||
import.meta.url,
|
||||
),
|
||||
});
|
||||
|
||||
// Directions, not points: w = 0 drops the camera translation, and z just
|
||||
// under w keeps every fragment at the far plane, inside any frustum.
|
||||
const SKY_DIRECTION_GLSL = `
|
||||
uniform mat3 u_icrfToFixed;
|
||||
vec4 skyClipPosition(vec3 icrfDirection)
|
||||
{
|
||||
vec3 eye = czm_viewRotation * (u_icrfToFixed * icrfDirection);
|
||||
vec4 clip = czm_projection * vec4(eye, 0.0);
|
||||
clip.z = clip.w * 0.99999;
|
||||
return clip;
|
||||
}
|
||||
`;
|
||||
|
||||
const MILKY_WAY_VS = `${SKY_DIRECTION_GLSL}
|
||||
in vec3 position;
|
||||
out vec3 v_direction;
|
||||
void main()
|
||||
{
|
||||
gl_Position = skyClipPosition(position);
|
||||
v_direction = position;
|
||||
}
|
||||
`;
|
||||
|
||||
const MILKY_WAY_FS = `
|
||||
uniform samplerCube u_milkyWay;
|
||||
uniform float u_intensity;
|
||||
in vec3 v_direction;
|
||||
void main()
|
||||
{
|
||||
vec3 color = czm_textureCube(u_milkyWay, normalize(v_direction)).rgb * u_intensity;
|
||||
// Interleaved-gradient dither: the dim glow spans only a few 8-bit steps.
|
||||
float noise = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
|
||||
color = max(color + (noise - 0.5) / 255.0, 0.0);
|
||||
out_FragColor = vec4(czm_gammaCorrect(color), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const STAR_VS = `${SKY_DIRECTION_GLSL}
|
||||
in vec3 position;
|
||||
in float sigma;
|
||||
in vec4 color;
|
||||
out vec4 v_color;
|
||||
out float v_sigma;
|
||||
out float v_radius;
|
||||
void main()
|
||||
{
|
||||
gl_Position = skyClipPosition(position);
|
||||
// σ is in CSS pixels; the sprite holds ±3σ plus a pixel of margin.
|
||||
v_sigma = sigma * czm_pixelRatio;
|
||||
v_radius = ceil(3.0 * v_sigma) + 1.0;
|
||||
gl_PointSize = 2.0 * v_radius;
|
||||
v_color = color;
|
||||
}
|
||||
`;
|
||||
|
||||
const STAR_FS = `
|
||||
in vec4 v_color;
|
||||
in float v_sigma;
|
||||
in float v_radius;
|
||||
void main()
|
||||
{
|
||||
vec2 offset = (gl_PointCoord - 0.5) * (2.0 * v_radius);
|
||||
float alpha = v_color.a *
|
||||
exp(-0.5 * dot(offset, offset) / (v_sigma * v_sigma));
|
||||
if (alpha < 1.0 / 512.0) discard;
|
||||
out_FragColor = vec4(czm_gammaCorrect(v_color.rgb) * alpha, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
/** Pick the ICRF→fixed rotation for `time`, or TEME while tables load. */
|
||||
export function computeSkyRotation(time, result) {
|
||||
const { Transforms } = Cesium;
|
||||
const icrf = Transforms.computeIcrfToFixedMatrix(time, result);
|
||||
if (icrf) return { matrix: icrf, precise: true };
|
||||
return {
|
||||
matrix: Transforms.computeTemeToPseudoFixedMatrix(time, result),
|
||||
precise: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a cached rotation at `cachedTime` still serves `time`. */
|
||||
export function isSkyRotationCurrent(cachedTime, time) {
|
||||
return (
|
||||
cachedTime !== undefined &&
|
||||
Math.abs(Cesium.JulianDate.secondsDifference(time, cachedTime)) <
|
||||
SKY_ROTATION_EPSILON_SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
export class StarrySky {
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {() => void} [options.requestRender] renders one frame when an
|
||||
* asset or the precise rotation arrives; the idle governor by default.
|
||||
*/
|
||||
constructor({
|
||||
requestRender = () => governorRequestRender('sky-assets'),
|
||||
} = {}) {
|
||||
/** Matches `SkyBox#show`. */
|
||||
this.show = true;
|
||||
this.milkyWayIntensity = MILKY_WAY_INTENSITY;
|
||||
/** 0..1 multiplier a style sets to hide the glow (see visualEffects.js). */
|
||||
this.milkyWayVisibility = 1;
|
||||
this._requestRender = requestRender;
|
||||
this._rotation = Cesium.Matrix3.clone(Cesium.Matrix3.IDENTITY);
|
||||
this._rotationTime = undefined;
|
||||
this._rotationPrecise = false;
|
||||
this._icrfRequest = undefined;
|
||||
this._icrfUnavailable = false;
|
||||
this._icrfMissing = undefined;
|
||||
this._loading = false;
|
||||
this._starData = undefined;
|
||||
this._faceImages = undefined;
|
||||
// Scene reads `_cubeMap` for model lighting. Keep it dark as on main;
|
||||
// Cesium's fallback is white, and the ICRF glow is only a background.
|
||||
this._cubeMap = undefined;
|
||||
this._milkyWayCubeMap = undefined;
|
||||
this._milkyWayCommand = undefined;
|
||||
this._milkyWayAttributes = undefined;
|
||||
this._starCommand = undefined;
|
||||
this._useHdr = undefined;
|
||||
this._destroyed = false;
|
||||
const uniforms = {
|
||||
u_icrfToFixed: () => this._rotation,
|
||||
};
|
||||
this._milkyWayUniforms = {
|
||||
...uniforms,
|
||||
u_milkyWay: () => this._milkyWayCubeMap,
|
||||
u_intensity: () => this.milkyWayIntensity * this.milkyWayVisibility,
|
||||
};
|
||||
this._starUniforms = uniforms;
|
||||
const commands = [];
|
||||
this._commands = commands;
|
||||
// The environment pass reads `derivedCommands` (log depth, HDR, picking)
|
||||
// before calling `execute`; none apply to a far-plane background.
|
||||
this._compositeCommand = {
|
||||
derivedCommands: {},
|
||||
execute(context, passState) {
|
||||
for (const command of commands) command.execute(context, passState);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Start loading assets; failures leave a plain black sky. */
|
||||
_load() {
|
||||
if (this._loading) return;
|
||||
this._loading = true;
|
||||
loadBundledJson(STAR_PACK_URL)
|
||||
.then((pack) => {
|
||||
if (this._destroyed) return;
|
||||
this._starData = buildStarVertices(pack);
|
||||
this._requestRender();
|
||||
})
|
||||
.catch((error) =>
|
||||
console.warn('[sky] star catalogue unavailable', error),
|
||||
);
|
||||
const faces = Object.entries(MILKY_WAY_FACE_URLS);
|
||||
Promise.all(
|
||||
faces.map(([, url]) => Cesium.Resource.fetchImage({ url: url.href })),
|
||||
)
|
||||
.then((images) => {
|
||||
if (this._destroyed) return;
|
||||
this._faceImages = Object.fromEntries(
|
||||
faces.map(([face], index) => [face, images[index]]),
|
||||
);
|
||||
this._requestRender();
|
||||
})
|
||||
.catch((error) => console.warn('[sky] Milky Way unavailable', error));
|
||||
}
|
||||
|
||||
_updateRotation(time) {
|
||||
// An approximate rotation is final only while nothing better can come.
|
||||
const settled =
|
||||
this._rotationPrecise || this._icrfRequest || !this._canPreloadIcrf(time);
|
||||
if (settled && isSkyRotationCurrent(this._rotationTime, time)) return;
|
||||
const { precise } = computeSkyRotation(time, this._rotation);
|
||||
this._rotationPrecise = precise;
|
||||
this._rotationTime = Cesium.JulianDate.clone(time, this._rotationTime);
|
||||
if (!precise) this._preloadIcrf(time);
|
||||
}
|
||||
|
||||
/** Whether loading Cesium's IAU 2006 tables could still help at `time`. */
|
||||
_canPreloadIcrf(time) {
|
||||
return (
|
||||
!this._icrfUnavailable &&
|
||||
!(
|
||||
this._icrfMissing &&
|
||||
Cesium.TimeInterval.contains(this._icrfMissing, time)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Fetch Cesium's IAU 2006 tables around `time` once per span. */
|
||||
_preloadIcrf(time) {
|
||||
if (this._icrfRequest || !this._canPreloadIcrf(time)) return;
|
||||
const interval = new Cesium.TimeInterval({
|
||||
start: Cesium.JulianDate.addDays(time, -1, new Cesium.JulianDate()),
|
||||
stop: Cesium.JulianDate.addDays(time, 1, new Cesium.JulianDate()),
|
||||
});
|
||||
this._icrfRequest = Promise.resolve(
|
||||
Cesium.Transforms.preloadIcrfFixed(interval),
|
||||
)
|
||||
.then(
|
||||
() => {
|
||||
// The tables cover roughly 1975–2050; outside them the preload
|
||||
// resolves with nothing, and TEME stays for this span.
|
||||
if (!Cesium.Transforms.computeIcrfToFixedMatrix(time)) {
|
||||
this._icrfMissing = interval;
|
||||
return;
|
||||
}
|
||||
this._rotationTime = undefined;
|
||||
if (!this._destroyed) this._requestRender();
|
||||
},
|
||||
(error) => {
|
||||
// TEME is within a fraction of a degree; do not retry every frame.
|
||||
this._icrfUnavailable = true;
|
||||
console.warn('[sky] ICRF tables unavailable, using TEME', error);
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
this._icrfRequest = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
_createMilkyWayCommand(context) {
|
||||
const cubeMap = new Cesium.CubeMap({
|
||||
context,
|
||||
source: this._faceImages,
|
||||
flipY: false,
|
||||
});
|
||||
cubeMap.generateMipmap();
|
||||
cubeMap.sampler = new Cesium.Sampler({
|
||||
minificationFilter: Cesium.TextureMinificationFilter.LINEAR_MIPMAP_LINEAR,
|
||||
});
|
||||
this._milkyWayCubeMap = cubeMap;
|
||||
this._faceImages = undefined;
|
||||
const geometry = Cesium.BoxGeometry.createGeometry(
|
||||
Cesium.BoxGeometry.fromDimensions({
|
||||
dimensions: new Cesium.Cartesian3(2, 2, 2),
|
||||
vertexFormat: Cesium.VertexFormat.POSITION_ONLY,
|
||||
}),
|
||||
);
|
||||
const attributeLocations =
|
||||
Cesium.GeometryPipeline.createAttributeLocations(geometry);
|
||||
this._milkyWayCommand = new Cesium.DrawCommand({
|
||||
owner: this,
|
||||
vertexArray: Cesium.VertexArray.fromGeometry({
|
||||
context,
|
||||
geometry,
|
||||
attributeLocations,
|
||||
bufferUsage: Cesium.BufferUsage.STATIC_DRAW,
|
||||
}),
|
||||
renderState: Cesium.RenderState.fromCache({}),
|
||||
uniformMap: this._milkyWayUniforms,
|
||||
});
|
||||
this._milkyWayAttributes = attributeLocations;
|
||||
}
|
||||
|
||||
_createStarCommand(context) {
|
||||
const { count, vertices } = this._starData;
|
||||
this._starData = undefined;
|
||||
if (count === 0) return;
|
||||
const vertexBuffer = Cesium.Buffer.createVertexBuffer({
|
||||
context,
|
||||
typedArray: vertices,
|
||||
usage: Cesium.BufferUsage.STATIC_DRAW,
|
||||
});
|
||||
const stride = STAR_VERTEX_FLOATS * Float32Array.BYTES_PER_ELEMENT;
|
||||
const attribute = (index, components, offsetFloats) => ({
|
||||
index,
|
||||
vertexBuffer,
|
||||
componentsPerAttribute: components,
|
||||
componentDatatype: Cesium.ComponentDatatype.FLOAT,
|
||||
offsetInBytes: offsetFloats * Float32Array.BYTES_PER_ELEMENT,
|
||||
strideInBytes: stride,
|
||||
});
|
||||
this._starCommand = new Cesium.DrawCommand({
|
||||
owner: this,
|
||||
primitiveType: Cesium.PrimitiveType.POINTS,
|
||||
count,
|
||||
vertexArray: new Cesium.VertexArray({
|
||||
context,
|
||||
attributes: [
|
||||
attribute(0, 3, 0),
|
||||
attribute(1, 1, 3),
|
||||
attribute(2, 4, 4),
|
||||
],
|
||||
}),
|
||||
// Premultiplied additive: starlight adds to the Milky Way glow and
|
||||
// leaves the framebuffer alpha alone.
|
||||
renderState: Cesium.RenderState.fromCache({
|
||||
blending: {
|
||||
enabled: true,
|
||||
equationRgb: Cesium.BlendEquation.ADD,
|
||||
equationAlpha: Cesium.BlendEquation.ADD,
|
||||
functionSourceRgb: Cesium.BlendFunction.ONE,
|
||||
functionDestinationRgb: Cesium.BlendFunction.ONE,
|
||||
functionSourceAlpha: Cesium.BlendFunction.ZERO,
|
||||
functionDestinationAlpha: Cesium.BlendFunction.ONE,
|
||||
},
|
||||
}),
|
||||
uniformMap: this._starUniforms,
|
||||
});
|
||||
}
|
||||
|
||||
_updateShaders(context, useHdr) {
|
||||
if (this._useHdr === useHdr) return;
|
||||
this._useHdr = useHdr;
|
||||
const defines = useHdr ? ['HDR'] : [];
|
||||
for (const [command, vs, fs, attributeLocations] of [
|
||||
[
|
||||
this._milkyWayCommand,
|
||||
MILKY_WAY_VS,
|
||||
MILKY_WAY_FS,
|
||||
this._milkyWayAttributes,
|
||||
],
|
||||
[
|
||||
this._starCommand,
|
||||
STAR_VS,
|
||||
STAR_FS,
|
||||
{ position: 0, sigma: 1, color: 2 },
|
||||
],
|
||||
]) {
|
||||
if (!command) continue;
|
||||
command.shaderProgram?.destroy();
|
||||
command.shaderProgram = Cesium.ShaderProgram.fromCache({
|
||||
context,
|
||||
vertexShaderSource: vs,
|
||||
fragmentShaderSource: new Cesium.ShaderSource({
|
||||
defines,
|
||||
sources: [fs],
|
||||
}),
|
||||
attributeLocations,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** `Scene#skyBox` contract: the command to draw this frame, if any. */
|
||||
update(frameState, useHdr) {
|
||||
if (!this.show) return undefined;
|
||||
const { mode, passes, context } = frameState;
|
||||
if (mode !== Cesium.SceneMode.SCENE3D) return undefined;
|
||||
if (!passes.render) return undefined;
|
||||
if (!this._cubeMap) {
|
||||
const face = {
|
||||
width: 1,
|
||||
height: 1,
|
||||
arrayBufferView: new Uint8Array([0, 0, 0, 255]),
|
||||
};
|
||||
this._cubeMap = new Cesium.CubeMap({
|
||||
context,
|
||||
source: {
|
||||
positiveX: face,
|
||||
negativeX: face,
|
||||
positiveY: face,
|
||||
negativeY: face,
|
||||
positiveZ: face,
|
||||
negativeZ: face,
|
||||
},
|
||||
flipY: false,
|
||||
});
|
||||
}
|
||||
this._load();
|
||||
let rebuilt = false;
|
||||
if (this._faceImages && !this._milkyWayCommand) {
|
||||
this._createMilkyWayCommand(context);
|
||||
rebuilt = true;
|
||||
}
|
||||
if (this._starData && !this._starCommand) {
|
||||
this._createStarCommand(context);
|
||||
rebuilt = true;
|
||||
}
|
||||
if (rebuilt) this._useHdr = undefined;
|
||||
this._updateShaders(context, Boolean(useHdr));
|
||||
this._commands.length = 0;
|
||||
if (this._milkyWayCommand) this._commands.push(this._milkyWayCommand);
|
||||
if (this._starCommand) this._commands.push(this._starCommand);
|
||||
if (this._commands.length === 0) return undefined;
|
||||
this._updateRotation(frameState.time);
|
||||
return this._compositeCommand;
|
||||
}
|
||||
|
||||
isDestroyed() {
|
||||
return this._destroyed;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const command of [this._milkyWayCommand, this._starCommand]) {
|
||||
if (!command) continue;
|
||||
command.vertexArray?.destroy();
|
||||
command.shaderProgram?.destroy();
|
||||
}
|
||||
this._milkyWayCubeMap = this._milkyWayCubeMap?.destroy();
|
||||
this._cubeMap = this._cubeMap?.destroy();
|
||||
this._milkyWayCommand = undefined;
|
||||
this._starCommand = undefined;
|
||||
this._commands.length = 0;
|
||||
this._destroyed = true;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
MILKY_WAY_INTENSITY,
|
||||
STARRY_SKY_CESIUM_VERSION,
|
||||
SKY_ROTATION_EPSILON_SECONDS,
|
||||
StarrySky,
|
||||
computeSkyRotation,
|
||||
isSkyRotationCurrent,
|
||||
} from './starrySky.js';
|
||||
|
||||
const close = (actual, expected, epsilon = 1e-9) =>
|
||||
assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} vs ${expected}`);
|
||||
|
||||
test('the sky-box adapter was validated against this engine version', () => {
|
||||
assert.equal(
|
||||
Cesium.VERSION,
|
||||
STARRY_SKY_CESIUM_VERSION,
|
||||
'engine upgrades require revalidating the Scene#skyBox contract',
|
||||
);
|
||||
});
|
||||
|
||||
test('the ICRF rotation is preferred and TEME fills in while tables load', () => {
|
||||
const time = Cesium.JulianDate.fromIso8601('2026-10-19T00:00:00Z');
|
||||
const icrf = Cesium.Matrix3.fromRotationZ(0.3);
|
||||
const teme = Cesium.Matrix3.fromRotationZ(0.2);
|
||||
const { Transforms } = Cesium;
|
||||
const original = { ...Transforms };
|
||||
try {
|
||||
Transforms.computeIcrfToFixedMatrix = (_, result) =>
|
||||
Cesium.Matrix3.clone(icrf, result);
|
||||
Transforms.computeTemeToPseudoFixedMatrix = () =>
|
||||
assert.fail('TEME not needed');
|
||||
const precise = computeSkyRotation(time, new Cesium.Matrix3());
|
||||
assert.equal(precise.precise, true);
|
||||
assert.ok(Cesium.Matrix3.equals(precise.matrix, icrf));
|
||||
Transforms.computeIcrfToFixedMatrix = () => undefined;
|
||||
Transforms.computeTemeToPseudoFixedMatrix = (_, result) =>
|
||||
Cesium.Matrix3.clone(teme, result);
|
||||
const approximate = computeSkyRotation(time, new Cesium.Matrix3());
|
||||
assert.equal(approximate.precise, false);
|
||||
assert.ok(Cesium.Matrix3.equals(approximate.matrix, teme));
|
||||
} finally {
|
||||
Object.assign(Transforms, original);
|
||||
}
|
||||
});
|
||||
|
||||
test('the TEME fallback keeps the celestial pole on the Earth axis', () => {
|
||||
// TEME only spins about Z; ICRF differs from it by precession (~0.36° by
|
||||
// 2026), so the fallback sky is right up to a fraction of a degree.
|
||||
const time = Cesium.JulianDate.fromIso8601('2026-10-19T00:00:00Z');
|
||||
const teme = Cesium.Transforms.computeTemeToPseudoFixedMatrix(time);
|
||||
const pole = Cesium.Matrix3.multiplyByVector(
|
||||
teme,
|
||||
Cesium.Cartesian3.UNIT_Z,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
assert.ok(Math.abs(pole.z - 1) < 1e-9);
|
||||
});
|
||||
|
||||
test('the rotation is reused only within the clock epsilon', () => {
|
||||
const time = Cesium.JulianDate.fromIso8601('2026-10-19T00:00:00Z');
|
||||
const later = (seconds) =>
|
||||
Cesium.JulianDate.addSeconds(time, seconds, new Cesium.JulianDate());
|
||||
assert.equal(isSkyRotationCurrent(undefined, time), false);
|
||||
assert.equal(isSkyRotationCurrent(time, later(0.5)), true);
|
||||
assert.equal(isSkyRotationCurrent(time, later(-0.5)), true);
|
||||
assert.equal(
|
||||
isSkyRotationCurrent(time, later(SKY_ROTATION_EPSILON_SECONDS)),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('the sky draws nothing when hidden, outside 3D, or in a pick pass', () => {
|
||||
const sky = new StarrySky({ requestRender: () => {} });
|
||||
const frameState = (overrides) => ({
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
passes: { render: true },
|
||||
...overrides,
|
||||
});
|
||||
sky._loading = true; // keep the test off the network
|
||||
sky.show = false;
|
||||
assert.equal(sky.update(frameState()), undefined);
|
||||
sky.show = true;
|
||||
assert.equal(
|
||||
sky.update(frameState({ mode: Cesium.SceneMode.SCENE2D })),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
sky.update(frameState({ passes: { render: false, pick: true } })),
|
||||
undefined,
|
||||
);
|
||||
// Nothing has loaded yet, so even a render pass has no command.
|
||||
sky._cubeMap = { destroy() {} }; // no WebGL context in this unit test
|
||||
assert.equal(sky.update(frameState()), undefined);
|
||||
assert.equal(sky.isDestroyed(), false);
|
||||
assert.equal(sky.destroy(), undefined);
|
||||
assert.equal(sky.isDestroyed(), true);
|
||||
});
|
||||
|
||||
const frame = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
for (const iso of ['1970-01-01T00:00:00Z', '2060-01-01T00:00:00Z']) {
|
||||
test(`a clock outside the IAU 2006 tables (${iso}) settles on TEME instead of re-rendering every frame`, async () => {
|
||||
const time = Cesium.JulianDate.fromIso8601(iso);
|
||||
let renders = 0;
|
||||
let preloads = 0;
|
||||
const original = Cesium.Transforms.preloadIcrfFixed;
|
||||
Cesium.Transforms.preloadIcrfFixed = (interval) => {
|
||||
preloads += 1;
|
||||
return original(interval);
|
||||
};
|
||||
try {
|
||||
const sky = new StarrySky({ requestRender: () => (renders += 1) });
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
sky._updateRotation(time); // what update() does once per frame
|
||||
await frame();
|
||||
}
|
||||
assert.ok(
|
||||
renders <= 1 && preloads <= 1,
|
||||
`20 frames at a frozen clock -> ${preloads} preloads, ${renders} render requests (expected <= 1 each)`,
|
||||
);
|
||||
} finally {
|
||||
Cesium.Transforms.preloadIcrfFixed = original;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('the Milky Way glow scales with its visibility, for styles that hide it', () => {
|
||||
const sky = new StarrySky({ requestRender: () => {} });
|
||||
close(sky._milkyWayUniforms.u_intensity(), MILKY_WAY_INTENSITY);
|
||||
sky.milkyWayVisibility = 0;
|
||||
assert.equal(sky._milkyWayUniforms.u_intensity(), 0);
|
||||
sky.destroy();
|
||||
});
|
||||
|
||||
test('destroy releases both the background and model environment maps', () => {
|
||||
const sky = new StarrySky({ requestRender: () => {} });
|
||||
let destroyed = 0;
|
||||
sky._cubeMap = {
|
||||
destroy: () => {
|
||||
destroyed++;
|
||||
},
|
||||
};
|
||||
sky._milkyWayCubeMap = {
|
||||
destroy: () => {
|
||||
destroyed++;
|
||||
},
|
||||
};
|
||||
sky.destroy();
|
||||
assert.equal(destroyed, 2);
|
||||
assert.equal(sky._cubeMap, undefined);
|
||||
assert.equal(sky._milkyWayCubeMap, undefined);
|
||||
sky.destroy();
|
||||
assert.equal(destroyed, 2);
|
||||
});
|
||||
|
||||
test('a render update uploads six opaque black environment faces only once', () => {
|
||||
const uploads = [];
|
||||
const gl = {
|
||||
...Cesium.WebGLConstants,
|
||||
createTexture: () => ({}),
|
||||
activeTexture() {},
|
||||
bindTexture() {},
|
||||
texParameteri() {},
|
||||
pixelStorei() {},
|
||||
texImage2D: (...args) => uploads.push(args),
|
||||
deleteTexture() {},
|
||||
};
|
||||
const previous = Cesium.ContextLimits._maximumCubeMapSize;
|
||||
Cesium.ContextLimits._maximumCubeMapSize = 16;
|
||||
const sky = new StarrySky({ requestRender: () => {} });
|
||||
sky._loading = true;
|
||||
const state = {
|
||||
context: { _gl: gl },
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
passes: { render: true },
|
||||
};
|
||||
try {
|
||||
sky.update(state);
|
||||
const environment = sky._cubeMap;
|
||||
assert.ok(environment instanceof Cesium.CubeMap);
|
||||
assert.equal(uploads.length, 6);
|
||||
assert.equal(new Set(uploads.map((args) => args[0])).size, 6);
|
||||
for (const args of uploads) {
|
||||
assert.equal(args[3], 1);
|
||||
assert.equal(args[4], 1);
|
||||
assert.deepEqual([...args[8]], [0, 0, 0, 255]);
|
||||
}
|
||||
sky.update(state);
|
||||
assert.equal(sky._cubeMap, environment);
|
||||
assert.equal(uploads.length, 6);
|
||||
sky.destroy();
|
||||
assert.equal(environment.isDestroyed(), true);
|
||||
} finally {
|
||||
if (!sky.isDestroyed()) sky.destroy();
|
||||
Cesium.ContextLimits._maximumCubeMapSize = previous;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Bright-star appearance: catalogue pack → GPU vertex data.
|
||||
*
|
||||
* Pure functions, no Cesium. The pack is `local_data/sky/bright_stars.json`
|
||||
* (Yale Bright Star Catalogue, built by scripts/build-bright-stars.mjs): a
|
||||
* flat `[raDeg, decDeg, vmag, bv, …]` array in ICRF/J2000. Each star is a
|
||||
* small Gaussian sprite, sized in CSS pixels so it stays a crisp dot at any DPR.
|
||||
*
|
||||
* @module sky/stars
|
||||
*/
|
||||
|
||||
/** Brightness reference; fainter catalogue stars are drawn like it. */
|
||||
export const STAR_LIMIT_MAGNITUDE = 6.5;
|
||||
/** Gaussian σ of the faintest star, CSS pixels. */
|
||||
export const STAR_MIN_SIGMA_PX = 0.55;
|
||||
/** Gaussian σ cap for the brightest star, CSS pixels. */
|
||||
export const STAR_MAX_SIGMA_PX = 1.35;
|
||||
/** Peak opacity of the faintest star. */
|
||||
export const STAR_MIN_ALPHA = 0.22;
|
||||
/** B−V for stars the catalogue gives no colour (a solar-type white). */
|
||||
export const STAR_DEFAULT_BV = 0.6;
|
||||
/** Floats per star in the vertex buffer: xyz, σ, rgb, alpha. */
|
||||
export const STAR_VERTEX_FLOATS = 8;
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
// Fraction of the colour kept: the eye sees all but the most extreme stars
|
||||
// as nearly white, and saturated dots read as UI markers on the globe.
|
||||
const COLOR_SATURATION = 0.55;
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
/** ICRF unit vector for J2000 right ascension and declination in degrees. */
|
||||
export function raDecToUnitVector(raDeg, decDeg) {
|
||||
const ra = raDeg * DEG;
|
||||
const dec = decDeg * DEG;
|
||||
const cosDec = Math.cos(dec);
|
||||
return [cosDec * Math.cos(ra), cosDec * Math.sin(ra), Math.sin(dec)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Perceived brightness relative to the limit magnitude. Flux grows 2.512×
|
||||
* per magnitude; the square root compresses Sirius-to-6.5m (≈ 1600×) into a
|
||||
* range a display can show without the bright stars turning into discs.
|
||||
*/
|
||||
export function starBrightness(vmag) {
|
||||
const magnitude = Number.isFinite(vmag) ? vmag : STAR_LIMIT_MAGNITUDE;
|
||||
return (
|
||||
10 **
|
||||
(-0.2 * (Math.min(magnitude, STAR_LIMIT_MAGNITUDE) - STAR_LIMIT_MAGNITUDE))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprite parameters for a visual magnitude: `sigmaPx` is the Gaussian σ in
|
||||
* CSS pixels and `alpha` its peak opacity. Faint stars keep the minimum width
|
||||
* and fade; bright stars saturate and grow slowly.
|
||||
*/
|
||||
export function starAppearance(vmag) {
|
||||
const brightness = starBrightness(vmag);
|
||||
return {
|
||||
sigmaPx: clamp(
|
||||
STAR_MIN_SIGMA_PX * brightness ** 0.3,
|
||||
STAR_MIN_SIGMA_PX,
|
||||
STAR_MAX_SIGMA_PX,
|
||||
),
|
||||
alpha: clamp(STAR_MIN_ALPHA * brightness, STAR_MIN_ALPHA, 1),
|
||||
};
|
||||
}
|
||||
|
||||
/** Effective temperature from B−V (Ballesteros 2012). */
|
||||
export function bvToTemperature(bv) {
|
||||
const index = clamp(Number.isFinite(bv) ? bv : STAR_DEFAULT_BV, -0.4, 2.0);
|
||||
return 4600 * (1 / (0.92 * index + 1.7) + 1 / (0.92 * index + 0.62));
|
||||
}
|
||||
|
||||
/** Black-body colour of a temperature, sRGB 0..1 (Helland's fit). */
|
||||
export function temperatureToRgb(kelvin) {
|
||||
const t = clamp(kelvin, 1000, 40000) / 100;
|
||||
const red = t <= 66 ? 255 : 329.698727446 * (t - 60) ** -0.1332047592;
|
||||
const green =
|
||||
t <= 66
|
||||
? 99.4708025861 * Math.log(t) - 161.1195681661
|
||||
: 288.1221695283 * (t - 60) ** -0.0755148492;
|
||||
const blue =
|
||||
t >= 66
|
||||
? 255
|
||||
: t <= 19
|
||||
? 0
|
||||
: 138.5177312231 * Math.log(t - 10) - 305.0447927307;
|
||||
return [red, green, blue].map((channel) => clamp(channel, 0, 255) / 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display colour for a B−V index: the black-body hue, desaturated towards
|
||||
* white and normalized so the brightest channel is 1 (brightness belongs to
|
||||
* the magnitude alone).
|
||||
*/
|
||||
export function bvToRgb(bv) {
|
||||
const rgb = temperatureToRgb(bvToTemperature(bv)).map(
|
||||
(channel) => 1 - COLOR_SATURATION + COLOR_SATURATION * channel,
|
||||
);
|
||||
const peak = Math.max(...rgb);
|
||||
return rgb.map((channel) => channel / peak);
|
||||
}
|
||||
|
||||
/** Decode a catalogue pack into interleaved vertex data, `STAR_VERTEX_FLOATS` per star. */
|
||||
export function buildStarVertices({ stars }) {
|
||||
const count = stars.length / 4;
|
||||
const vertices = new Float32Array(count * STAR_VERTEX_FLOATS);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const [raDeg, decDeg, vmag, bv] = stars.slice(i * 4, i * 4 + 4);
|
||||
const { sigmaPx, alpha } = starAppearance(vmag);
|
||||
vertices.set(
|
||||
[...raDecToUnitVector(raDeg, decDeg), sigmaPx, ...bvToRgb(bv), alpha],
|
||||
i * STAR_VERTEX_FLOATS,
|
||||
);
|
||||
}
|
||||
return { count, vertices };
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import {
|
||||
STAR_LIMIT_MAGNITUDE,
|
||||
STAR_MAX_SIGMA_PX,
|
||||
STAR_MIN_ALPHA,
|
||||
STAR_MIN_SIGMA_PX,
|
||||
STAR_VERTEX_FLOATS,
|
||||
buildStarVertices,
|
||||
bvToRgb,
|
||||
bvToTemperature,
|
||||
raDecToUnitVector,
|
||||
starAppearance,
|
||||
starBrightness,
|
||||
} from './stars.js';
|
||||
|
||||
const close = (actual, expected, epsilon = 1e-9) =>
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) <= epsilon,
|
||||
`${actual} is not within ${epsilon} of ${expected}`,
|
||||
);
|
||||
|
||||
const skyFile = (name) =>
|
||||
new URL(`../data/local_data/sky/${name}`, import.meta.url);
|
||||
|
||||
test('right ascension and declination map onto the ICRF axes', () => {
|
||||
const equinox = raDecToUnitVector(0, 0);
|
||||
close(equinox[0], 1);
|
||||
close(equinox[1], 0);
|
||||
close(equinox[2], 0);
|
||||
const sixHours = raDecToUnitVector(90, 0);
|
||||
close(sixHours[0], 0);
|
||||
close(sixHours[1], 1);
|
||||
const pole = raDecToUnitVector(123, 90);
|
||||
close(pole[2], 1);
|
||||
const south = raDecToUnitVector(200, -30);
|
||||
close(Math.hypot(...south), 1);
|
||||
close(south[2], -0.5);
|
||||
});
|
||||
|
||||
test('brightness grows with each magnitude and stops at the catalogue limit', () => {
|
||||
close(starBrightness(STAR_LIMIT_MAGNITUDE), 1);
|
||||
close(starBrightness(STAR_LIMIT_MAGNITUDE + 1.5), 1);
|
||||
close(starBrightness(STAR_LIMIT_MAGNITUDE - 5), 10);
|
||||
close(starBrightness(Number.NaN), 1);
|
||||
});
|
||||
|
||||
test('brighter stars are never smaller or fainter, and both ends clamp', () => {
|
||||
let previous = starAppearance(8);
|
||||
assert.deepEqual(previous, {
|
||||
sigmaPx: STAR_MIN_SIGMA_PX,
|
||||
alpha: STAR_MIN_ALPHA,
|
||||
});
|
||||
for (let vmag = 7.5; vmag >= -1.5; vmag -= 0.25) {
|
||||
const next = starAppearance(vmag);
|
||||
assert.ok(next.sigmaPx >= previous.sigmaPx, `σ shrank at ${vmag}`);
|
||||
assert.ok(next.alpha >= previous.alpha, `alpha dropped at ${vmag}`);
|
||||
previous = next;
|
||||
}
|
||||
const sirius = starAppearance(-1.46);
|
||||
assert.equal(sirius.alpha, 1);
|
||||
assert.ok(sirius.sigmaPx <= STAR_MAX_SIGMA_PX);
|
||||
assert.ok(sirius.sigmaPx > starAppearance(2).sigmaPx);
|
||||
// Sprites stay point-like: ±3σ of the brightest star is a few pixels.
|
||||
assert.ok(6 * STAR_MAX_SIGMA_PX < 10);
|
||||
});
|
||||
|
||||
test('B−V colours run from blue-white to orange with the peak channel at 1', () => {
|
||||
close(bvToTemperature(0.65), 5_800, 120);
|
||||
close(bvToTemperature(0), 10_100, 200);
|
||||
const hot = bvToRgb(-0.3);
|
||||
const sunLike = bvToRgb(0.65);
|
||||
const cool = bvToRgb(1.6);
|
||||
for (const rgb of [hot, sunLike, cool]) {
|
||||
close(Math.max(...rgb), 1);
|
||||
assert.ok(Math.min(...rgb) > 0.5, 'colours stay desaturated');
|
||||
}
|
||||
assert.ok(hot[2] > hot[0], 'hot stars are blue');
|
||||
assert.ok(cool[0] > cool[2], 'cool stars are orange');
|
||||
assert.deepEqual(bvToRgb(null), bvToRgb(0.6));
|
||||
assert.deepEqual(bvToRgb(9), bvToRgb(2));
|
||||
});
|
||||
|
||||
test('vertex data is interleaved per star', () => {
|
||||
const { count, vertices } = buildStarVertices({
|
||||
stars: [0, 0, 1, 0.5, 90, 45, 3, null],
|
||||
});
|
||||
assert.equal(count, 2);
|
||||
assert.equal(vertices.length, 2 * STAR_VERTEX_FLOATS);
|
||||
assert.deepEqual(Array.from(vertices.subarray(0, 3)), [1, 0, 0]);
|
||||
close(vertices[STAR_VERTEX_FLOATS + 1], Math.SQRT1_2, 1e-6);
|
||||
close(vertices[STAR_VERTEX_FLOATS + 2], Math.SQRT1_2, 1e-6);
|
||||
close(vertices[3], starAppearance(1).sigmaPx, 1e-6);
|
||||
close(vertices[7], starAppearance(1).alpha, 1e-6);
|
||||
});
|
||||
|
||||
test('the committed catalogue holds the Yale Bright Star Catalogue', () => {
|
||||
const pack = JSON.parse(readFileSync(skyFile('bright_stars.json'), 'utf8'));
|
||||
assert.deepEqual(pack.meta.columns, ['raDeg', 'decDeg', 'vmag', 'bv']);
|
||||
assert.equal(pack.stars.length, pack.meta.count * 4);
|
||||
assert.equal(pack.meta.count, 9096);
|
||||
let brightest = 0;
|
||||
for (let i = 0; i < pack.stars.length; i += 4) {
|
||||
const [ra, dec, vmag] = pack.stars.slice(i, i + 4);
|
||||
assert.ok(ra >= 0 && ra < 360 && dec >= -90 && dec <= 90);
|
||||
assert.ok(Number.isFinite(vmag));
|
||||
if (vmag < pack.stars[brightest + 2]) brightest = i;
|
||||
}
|
||||
// Sirius, α CMa: 06h45m08.9s −16°42′58″, V = −1.46.
|
||||
assert.deepEqual(
|
||||
pack.stars.slice(brightest, brightest + 4),
|
||||
[101.287, -16.716, -1.46, 0],
|
||||
);
|
||||
assert.equal(buildStarVertices(pack).count, pack.meta.count);
|
||||
});
|
||||
|
||||
test('sky assets stay inside the 5 MB budget', () => {
|
||||
const files = [
|
||||
'bright_stars.json',
|
||||
...['px', 'nx', 'py', 'ny', 'pz', 'nz'].map(
|
||||
(face) => `milkyway_${face}.webp`,
|
||||
),
|
||||
];
|
||||
const total = files.reduce(
|
||||
(sum, name) => sum + statSync(skyFile(name)).size,
|
||||
0,
|
||||
);
|
||||
assert.ok(total < 5 * 1024 * 1024, `${total} bytes`);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
CUBE_FACES,
|
||||
FACE_FILE_SUFFIX,
|
||||
PLANETS,
|
||||
cubeFaceDirection,
|
||||
icrfToGalactic,
|
||||
} from '../../scripts/build-milky-way.mjs';
|
||||
import { raDecToUnitVector } from '../sky/stars.js';
|
||||
|
||||
const SKY = new URL('../data/local_data/sky/', import.meta.url);
|
||||
// Allowed excess of the painted disc over the surrounding sky, in 8-bit
|
||||
// levels of the encoded face: the blur and dither move values by a few.
|
||||
const TOLERANCE = 4;
|
||||
|
||||
test('painted-out planets leave no disc brighter than the sky around them', async () => {
|
||||
const centres = PLANETS.map((planet) => ({
|
||||
planet,
|
||||
vector: raDecToUnitVector(planet.l, planet.b),
|
||||
disc: [],
|
||||
ring: [],
|
||||
}));
|
||||
for (const face of CUBE_FACES) {
|
||||
const { data, info } = await sharp(
|
||||
new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY).pathname,
|
||||
)
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const size = info.width;
|
||||
for (let j = 0; j < size; j++) {
|
||||
for (let i = 0; i < size; i++) {
|
||||
const d = cubeFaceDirection(face, (i + 0.5) / size, (j + 0.5) / size);
|
||||
const n = Math.hypot(...d);
|
||||
const g = icrfToGalactic(d.map((x) => x / n));
|
||||
for (const centre of centres) {
|
||||
const cos =
|
||||
g[0] * centre.vector[0] +
|
||||
g[1] * centre.vector[1] +
|
||||
g[2] * centre.vector[2];
|
||||
const degrees = (Math.acos(Math.min(1, cos)) * 180) / Math.PI;
|
||||
const k = (j * size + i) * info.channels;
|
||||
const luma = (data[k] + data[k + 1] + data[k + 2]) / 3;
|
||||
if (degrees <= centre.planet.radiusDeg - 0.5) centre.disc.push(luma);
|
||||
else if (
|
||||
degrees >= centre.planet.radiusDeg + 1 &&
|
||||
degrees <= centre.planet.radiusDeg + 3.5
|
||||
)
|
||||
centre.ring.push(luma);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const median = (values) => values.sort((a, b) => a - b)[values.length >> 1];
|
||||
for (const { planet, disc, ring } of centres) {
|
||||
const discMean = disc.reduce((s, v) => s + v, 0) / disc.length;
|
||||
const ringMedian = median(ring);
|
||||
assert.ok(
|
||||
discMean <= ringMedian + TOLERANCE,
|
||||
`l=${planet.l} b=${planet.b}: disc mean ${discMean.toFixed(1)} vs surrounding sky ${ringMedian.toFixed(1)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildStarPack,
|
||||
parseBscRecord,
|
||||
} from '../../scripts/build-bright-stars.mjs';
|
||||
import {
|
||||
CUBE_FACES,
|
||||
cubeFaceDirection,
|
||||
directionToPlateCarree,
|
||||
icrfToGalactic,
|
||||
smoothstep,
|
||||
softBlack,
|
||||
} from '../../scripts/build-milky-way.mjs';
|
||||
import { raDecToUnitVector } from '../sky/stars.js';
|
||||
|
||||
// Verbatim CDS V/50 records: HR 1, Sirius (HR 2491) and a nova with no J2000
|
||||
// position. Columns matter, so the padding is part of the fixture.
|
||||
const HR_1 =
|
||||
' 1 BD+44 4550 3 36042 46 000001.1+444022000509.9+451345114.44-16.88 6.70 +0.07 +0.08 A1Vn -0.012-0.018 -018 195 4.2 21.6AC 3';
|
||||
const SIRIUS =
|
||||
'2491 9Alp CMaBD-16 1591 48915151881 257I 5423 064044.6-163444064508.9-164258227.22-08.88-1.46 0.00 -0.05 -0.03 A1Vm -0.553-1.205 +.375-008SBO 13 10.3 11.2AB 4*';
|
||||
const NOVA =
|
||||
' 92 NOVA 1572 B Cas *';
|
||||
|
||||
const close = (actual, expected, epsilon = 1e-9) =>
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) <= epsilon,
|
||||
`${actual} is not within ${epsilon} of ${expected}`,
|
||||
);
|
||||
|
||||
test('BSC5 records parse J2000 position, V magnitude and B−V', () => {
|
||||
const sirius = parseBscRecord(SIRIUS);
|
||||
assert.equal(sirius.hr, 2491);
|
||||
close(sirius.raDeg, (6 + 45 / 60 + 8.9 / 3600) * 15);
|
||||
close(sirius.decDeg, -(16 + 42 / 60 + 58 / 3600));
|
||||
assert.equal(sirius.vmag, -1.46);
|
||||
assert.equal(sirius.bv, 0);
|
||||
const first = parseBscRecord(HR_1);
|
||||
close(first.raDeg, (5 / 60 + 9.9 / 3600) * 15);
|
||||
close(first.decDeg, 45 + 13 / 60 + 45 / 3600);
|
||||
assert.equal(first.vmag, 6.7);
|
||||
assert.equal(first.bv, 0.07);
|
||||
assert.equal(parseBscRecord(NOVA), null);
|
||||
});
|
||||
|
||||
test('the star pack is a flat, rounded column array that counts its drops', () => {
|
||||
const pack = buildStarPack([HR_1, NOVA, SIRIUS, ''].join('\n'), {
|
||||
fetched: '2026-10-19',
|
||||
});
|
||||
assert.deepEqual(
|
||||
pack.stars,
|
||||
[1.291, 45.229, 6.7, 0.07, 101.287, -16.716, -1.46, 0],
|
||||
);
|
||||
assert.equal(pack.meta.count, 2);
|
||||
assert.equal(pack.meta.dropped, 1);
|
||||
assert.equal(pack.meta.fetched, '2026-10-19');
|
||||
});
|
||||
|
||||
test('cube faces use the WebGL major-axis layout', () => {
|
||||
// Each face centre looks down its own axis …
|
||||
const axes = {
|
||||
positiveX: [1, 0, 0],
|
||||
negativeX: [-1, 0, 0],
|
||||
positiveY: [0, 1, 0],
|
||||
negativeY: [0, -1, 0],
|
||||
positiveZ: [0, 0, 1],
|
||||
negativeZ: [0, 0, -1],
|
||||
};
|
||||
for (const face of CUBE_FACES)
|
||||
assert.deepEqual(
|
||||
cubeFaceDirection(face, 0.5, 0.5).map((v) => v + 0),
|
||||
axes[face],
|
||||
);
|
||||
// … and the first texel row and column follow OpenGL ES 3.0 §3.8.10:
|
||||
// on +X, s grows towards −Z and t towards −Y.
|
||||
assert.deepEqual(cubeFaceDirection('positiveX', 0, 0), [1, 1, 1]);
|
||||
assert.deepEqual(cubeFaceDirection('positiveZ', 1, 1), [1, -1, 1]);
|
||||
assert.deepEqual(cubeFaceDirection('negativeY', 0, 1), [-1, -1, -1]);
|
||||
assert.throws(() => cubeFaceDirection('up', 0, 0), RangeError);
|
||||
});
|
||||
|
||||
test('plate carrée is centred on longitude 0, growing to the left, north up', () => {
|
||||
assert.deepEqual(directionToPlateCarree(1, 0, 0), [0.5, 0.5]);
|
||||
const sixHours = directionToPlateCarree(0, 1, 0);
|
||||
close(sixHours[0], 0.25);
|
||||
const eighteenHours = directionToPlateCarree(0, -1, 0);
|
||||
close(eighteenHours[0], 0.75);
|
||||
close(directionToPlateCarree(0, 0, 1)[1], 0);
|
||||
close(directionToPlateCarree(0, 0, -1)[1], 1);
|
||||
const twelveHours = directionToPlateCarree(-1, 0, 0);
|
||||
assert.ok(twelveHours[0] === 0 || twelveHours[0] === 1);
|
||||
});
|
||||
|
||||
test('the galactic rotation puts the Galactic Centre and pole where the IAU does', () => {
|
||||
// Galactic origin l = b = 0 at RA 266.405°, Dec −28.936° (J2000).
|
||||
const centre = icrfToGalactic(raDecToUnitVector(266.40499, -28.93617));
|
||||
close(centre[0], 1, 1e-6);
|
||||
// North galactic pole at RA 192.85948°, Dec +27.12825°.
|
||||
const pole = icrfToGalactic(raDecToUnitVector(192.85948, 27.12825));
|
||||
close(pole[2], 1, 1e-6);
|
||||
// ESO's panorama: the Large Magellanic Cloud (l ≈ 280.5°, b ≈ −32.9°) sits
|
||||
// right of and below the centre.
|
||||
const [u, v] = directionToPlateCarree(
|
||||
...icrfToGalactic(raDecToUnitVector(80.894, -69.756)),
|
||||
);
|
||||
close(u, 0.5 + (360 - 280.47) / 360, 2e-3);
|
||||
close(v, 0.5 + 32.89 / 180, 2e-3);
|
||||
});
|
||||
|
||||
test('the black level eases in instead of cutting the faint sky into contours', () => {
|
||||
assert.equal(softBlack(-0.2), 0);
|
||||
assert.equal(softBlack(0), 0);
|
||||
// Continuous, rising, with no step at the black level.
|
||||
let previous = 0;
|
||||
for (let x = 0.001; x <= 1; x += 0.001) {
|
||||
const y = softBlack(x);
|
||||
assert.ok(y > previous && y - previous < 0.0011, `step at ${x.toFixed(3)}`);
|
||||
previous = y;
|
||||
}
|
||||
// Starts flat, then joins the straight line just below it.
|
||||
assert.ok(softBlack(0.003) < 0.0003);
|
||||
close(softBlack(1), 1 - 0.03, 1e-3);
|
||||
});
|
||||
|
||||
test('the faint-glow blend weight eases between its edges', () => {
|
||||
assert.equal(smoothstep(0.1, 0.2, 0), 0);
|
||||
assert.equal(smoothstep(0.1, 0.2, 0.1), 0);
|
||||
close(smoothstep(0.1, 0.2, 0.15), 0.5, 1e-9);
|
||||
assert.equal(smoothstep(0.1, 0.2, 0.2), 1);
|
||||
assert.equal(smoothstep(0.1, 0.2, 9), 1);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/** A reveal must beat the one-second idle rotation refresh, regardless of FPS. */
|
||||
export const ARRIVAL_OBSERVATION_MS = 500;
|
||||
|
||||
/**
|
||||
* Observe the first completed moveEnd frame and the first subsequent reveal.
|
||||
* The deadline starts when moveEnd is forced, before its rotation baseline, so
|
||||
* waiting for either frame cannot let the one-second idle refresh mask a bug.
|
||||
* `read` snapshots visibility and rotation error against the current position.
|
||||
*/
|
||||
export function observeArrivalRotation({
|
||||
scene,
|
||||
startMoveEnd,
|
||||
startReveal,
|
||||
read,
|
||||
now = () => performance.now(),
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = now();
|
||||
let moveEnd = null;
|
||||
let moveEndMs = null;
|
||||
let revealFrames = 0;
|
||||
let done = false;
|
||||
let remove;
|
||||
let timer;
|
||||
const cleanup = () => {
|
||||
done = true;
|
||||
remove?.();
|
||||
clearTimer(timer);
|
||||
};
|
||||
const finish = (result) => {
|
||||
if (done) return;
|
||||
cleanup();
|
||||
resolve(result);
|
||||
};
|
||||
const expired = () => ({
|
||||
error: `arrival ${moveEnd ? 'reveal' : 'moveEnd'} observation exceeded ${ARRIVAL_OBSERVATION_MS} ms`,
|
||||
elapsedMs: now() - startedAt,
|
||||
moveEndMs,
|
||||
flipFrame: -1,
|
||||
});
|
||||
remove = scene.postRender.addEventListener(() => {
|
||||
if (done) return;
|
||||
try {
|
||||
const elapsedMs = now() - startedAt;
|
||||
if (elapsedMs >= ARRIVAL_OBSERVATION_MS) {
|
||||
finish(expired());
|
||||
return;
|
||||
}
|
||||
const sample = read();
|
||||
if (moveEnd === null) {
|
||||
moveEnd = sample;
|
||||
moveEndMs = elapsedMs;
|
||||
startReveal();
|
||||
} else {
|
||||
revealFrames++;
|
||||
if (sample.shown) {
|
||||
// Keep the FIRST visible result, including a wrong angle. Never
|
||||
// wait for a subsequent tick to repair a failed reveal.
|
||||
finish({
|
||||
dMoveEnd: moveEnd.rotationError,
|
||||
dReveal: sample.rotationError,
|
||||
shown: sample.shown,
|
||||
flipFrame: revealFrames,
|
||||
moveEndMs,
|
||||
revealMs: elapsedMs - moveEndMs,
|
||||
elapsedMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
scene.requestRender();
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
timer = setTimer(() => finish(expired()), ARRIVAL_OBSERVATION_MS);
|
||||
try {
|
||||
startMoveEnd();
|
||||
scene.requestRender();
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Test the captured frames without retrying a bad first observation. */
|
||||
export function arrivalRotationPassed(observation) {
|
||||
return (
|
||||
!!observation &&
|
||||
!observation.error &&
|
||||
observation.shown === true &&
|
||||
observation.elapsedMs < ARRIVAL_OBSERVATION_MS &&
|
||||
(observation.dMoveEnd * 180) / Math.PI < 10 &&
|
||||
(observation.dReveal * 180) / Math.PI < 10
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ARRIVAL_OBSERVATION_MS,
|
||||
arrivalRotationPassed,
|
||||
observeArrivalRotation,
|
||||
} from './trackArrivalObservation.mjs';
|
||||
|
||||
function fixture() {
|
||||
let time = 0;
|
||||
let state = { shown: true, rotationError: 0 };
|
||||
const listeners = new Set();
|
||||
const timers = new Map();
|
||||
let renders = 0;
|
||||
let moveEnds = 0;
|
||||
const observation = observeArrivalRotation({
|
||||
scene: {
|
||||
postRender: {
|
||||
addEventListener(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
},
|
||||
requestRender() {
|
||||
renders++;
|
||||
},
|
||||
},
|
||||
startMoveEnd() {
|
||||
moveEnds++;
|
||||
state.rotationError = 1.5;
|
||||
},
|
||||
startReveal() {
|
||||
state = { shown: false, rotationError: 1.5 };
|
||||
},
|
||||
read: () => ({ ...state }),
|
||||
now: () => time,
|
||||
setTimer(callback, delay) {
|
||||
const handle = {};
|
||||
timers.set(handle, { callback, at: time + delay });
|
||||
return handle;
|
||||
},
|
||||
clearTimer(handle) {
|
||||
timers.delete(handle);
|
||||
},
|
||||
});
|
||||
return {
|
||||
observation,
|
||||
frame(at, nextState) {
|
||||
time = at;
|
||||
state = { ...state, ...nextState };
|
||||
for (const listener of [...listeners]) listener();
|
||||
},
|
||||
expire(at) {
|
||||
time = at;
|
||||
for (const timer of [...timers.values()])
|
||||
if (timer.at <= at) timer.callback();
|
||||
},
|
||||
get resources() {
|
||||
return {
|
||||
listeners: listeners.size,
|
||||
timers: timers.size,
|
||||
renders,
|
||||
moveEnds,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('80 ms fleet scheduling passes after more than four fast rendered frames', async () => {
|
||||
const f = fixture();
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
// The forced fleet tick was at 8 ms. A 125 FPS renderer can complete nine
|
||||
// more frames before the next 80 ms fleet interval becomes eligible.
|
||||
for (let at = 16; at < 88; at += 8) f.frame(at);
|
||||
f.frame(88, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.flipFrame, 10);
|
||||
assert.equal(result.moveEndMs, 8);
|
||||
assert.equal(result.revealMs, 80);
|
||||
assert.equal(result.elapsedMs, 88);
|
||||
assert.equal(arrivalRotationPassed(result), true);
|
||||
assert.equal(f.resources.listeners, 0);
|
||||
assert.equal(f.resources.timers, 0);
|
||||
assert.equal(f.resources.moveEnds, 1);
|
||||
});
|
||||
|
||||
test('a wrong first visible angle fails even if the next frame corrects it', async () => {
|
||||
const f = fixture();
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
f.frame(88, { shown: true, rotationError: 1.5 });
|
||||
f.frame(96, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.dReveal, 1.5);
|
||||
assert.equal(result.flipFrame, 1);
|
||||
assert.equal(result.elapsedMs, 88);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('a wrong first moveEnd angle is retained rather than repaired by reveal', async () => {
|
||||
const f = fixture();
|
||||
f.frame(8, { shown: true, rotationError: 1.5 });
|
||||
f.frame(88, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.dMoveEnd, 1.5);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('no reveal fails at a deadline before the one-second idle catch-up', async () => {
|
||||
const f = fixture();
|
||||
assert.equal(ARRIVAL_OBSERVATION_MS, 500);
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
f.frame(480);
|
||||
f.expire(500);
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /reveal.*500 ms/);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
assert.equal(f.resources.listeners, 0);
|
||||
assert.equal(f.resources.timers, 0);
|
||||
f.frame(1000, { shown: true, rotationError: 0 });
|
||||
assert.equal(result.flipFrame, -1);
|
||||
});
|
||||
|
||||
test('a late visible frame fails even if the timeout callback is delayed', async () => {
|
||||
const f = fixture();
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
f.frame(501, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /reveal.*500 ms/);
|
||||
assert.equal(result.elapsedMs, 501);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('the deadline includes moveEnd setup rather than restarting when hidden', async () => {
|
||||
const f = fixture();
|
||||
f.frame(400, { shown: true, rotationError: 0 });
|
||||
f.frame(600, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.moveEndMs, 400);
|
||||
assert.equal(result.elapsedMs, 600);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('missing moveEnd frames fail without waiting for a render callback', async () => {
|
||||
const f = fixture();
|
||||
f.expire(500);
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /moveEnd.*500 ms/);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
assert.equal(f.resources.listeners, 0);
|
||||
assert.equal(f.resources.timers, 0);
|
||||
});
|
||||
@@ -1,9 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { rm, writeFile } from 'node:fs/promises';
|
||||
import { request } from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { build, createServer, preview } from 'vite';
|
||||
import { BROWSER_CSP, createBrowserViteConfig } from '../../build/vite.js';
|
||||
import standaloneConfig, * as compatibility from '../../vite.config.js';
|
||||
import * as providers from '../../server/providers/local.js';
|
||||
import { apiNotFoundPlugin } from '../../server/standalone/api-not-found.js';
|
||||
import { makeFixtureRoot } from './fixtureRoot.mjs';
|
||||
|
||||
test('explicit build inputs preserve browser-only defines, plugin order and loopback protections', () => {
|
||||
const plugin = { name: 'fixture-provider' };
|
||||
@@ -12,10 +18,13 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
|
||||
googleApiKey: 'browser-fixture',
|
||||
cesiumToken: 'ion-fixture',
|
||||
});
|
||||
// The Host check runs before every other plugin's middleware.
|
||||
assert.equal(config.plugins[0].name, 'host-check');
|
||||
// API response headers, then the Host check, run before every other
|
||||
// plugin's middleware.
|
||||
assert.equal(config.plugins[0].name, 'api-response-headers');
|
||||
assert.equal(config.plugins[0].enforce, 'pre');
|
||||
assert.equal(config.plugins[3], plugin);
|
||||
assert.equal(config.plugins[1].name, 'host-check');
|
||||
assert.equal(config.plugins[1].enforce, 'pre');
|
||||
assert.equal(config.plugins[4], plugin);
|
||||
assert.equal(config.server.host, 'localhost');
|
||||
assert.equal(config.server.port, 4173);
|
||||
assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']);
|
||||
@@ -75,7 +84,7 @@ test('build helper does not discover environment values or construct local provi
|
||||
undefined,
|
||||
);
|
||||
assert.deepEqual(
|
||||
config.plugins.slice(3).map((plugin) => plugin.name),
|
||||
config.plugins.slice(4).map((plugin) => plugin.name),
|
||||
['embed-framing', 'panel-build'],
|
||||
);
|
||||
} finally {
|
||||
@@ -89,7 +98,7 @@ test('root config retains existing named exports and standalone provider order',
|
||||
assert.equal(compatibility[name], value, name);
|
||||
const config = standaloneConfig({ mode: 'test' });
|
||||
const standaloneProviders = config.plugins
|
||||
.slice(3, -5)
|
||||
.slice(4, -5)
|
||||
.map((plugin) => plugin.name);
|
||||
assert.equal(
|
||||
standaloneProviders.filter((name) => name === 'gemini-live-proxy').length,
|
||||
@@ -257,3 +266,129 @@ test('the CSP admits the event media embeds and the Maps JavaScript API, and no
|
||||
// X posts load in X's own frames.
|
||||
assert.ok(frames.includes('https://platform.twitter.com'));
|
||||
});
|
||||
|
||||
/** A raw GET, so the Host header and an absolute-form target are sent as given. */
|
||||
function rawGet(port, target, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: target,
|
||||
headers: { host: `localhost:${port}`, ...headers },
|
||||
},
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on('end', () =>
|
||||
resolve({ status: res.statusCode, headers: res.headers }),
|
||||
);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
test('every API response carries the API policy and nosniff on real dev and preview servers', async (t) => {
|
||||
const policy = "default-src 'none'; sandbox";
|
||||
const root = await makeFixtureRoot('gev-api-headers-');
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
await writeFile(
|
||||
path.join(root, 'index.html'),
|
||||
'<!doctype html><title>Fixture</title><p>x</p>',
|
||||
);
|
||||
const install = (server) => {
|
||||
server.middlewares.use('/api/fixture', (req, res) => {
|
||||
const route = new URL(req.url, 'http://localhost').pathname;
|
||||
if (route === '/ok') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end('{}');
|
||||
} else if (route === '/error') {
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end('{"error":"fixture"}');
|
||||
} else if (route === '/partial') {
|
||||
res.writeHead(206, { 'Content-Range': 'bytes 0-0/2' });
|
||||
res.end('x');
|
||||
} else if (route === '/own-policy') {
|
||||
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
|
||||
res.end('{}');
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Security-Policy': 'default-src *' });
|
||||
res.end('{}');
|
||||
}
|
||||
});
|
||||
};
|
||||
const fixture = {
|
||||
name: 'fixture-provider',
|
||||
configureServer: install,
|
||||
configurePreviewServer: install,
|
||||
};
|
||||
const built = createBrowserViteConfig({ plugins: [fixture] });
|
||||
const base = {
|
||||
root,
|
||||
configFile: false,
|
||||
envFile: false,
|
||||
publicDir: false,
|
||||
logLevel: 'silent',
|
||||
};
|
||||
await build(base);
|
||||
for (const isPreview of [false, true]) {
|
||||
const server = await (isPreview ? preview : createServer)({
|
||||
...base,
|
||||
plugins: [
|
||||
...built.plugins.filter(
|
||||
(plugin) =>
|
||||
plugin === fixture ||
|
||||
['api-response-headers', 'host-check'].includes(plugin.name),
|
||||
),
|
||||
apiNotFoundPlugin(),
|
||||
],
|
||||
server: { host: '127.0.0.1', port: 0, hmr: false },
|
||||
preview: { host: '127.0.0.1', port: 0 },
|
||||
});
|
||||
if (!isPreview) await server.listen();
|
||||
const { port } = server.httpServer.address();
|
||||
const mode = isPreview ? 'preview' : 'dev';
|
||||
try {
|
||||
for (const [target, status, csp = policy, headers] of [
|
||||
['/api/fixture/ok', 200],
|
||||
['/api/fixture/error', 500],
|
||||
['/api/fixture/partial', 206],
|
||||
['/api/nope', 404],
|
||||
['/api', 404],
|
||||
['/api/fixture/ok?x=1', 200],
|
||||
// Spellings the router mounts as the same routes.
|
||||
['/API/fixture/ok', 200],
|
||||
['/Api/fixture/error', 500],
|
||||
['/aPi/nope', 404],
|
||||
['/API', 404],
|
||||
['/api.x', 404],
|
||||
[`http://localhost:${port}/api/fixture/ok`, 200],
|
||||
[`http://localhost:${port}/API/nope`, 404],
|
||||
// The router keeps dot segments in an absolute-form target's path.
|
||||
['http://elsewhere.example/api/../x', 404],
|
||||
['http://elsewhere.example/API/%2e%2e/x', 404],
|
||||
['/api/fixture/ok', 403, policy, { host: 'elsewhere.example' }],
|
||||
// A route's own policy is added to the API policy, never replaces it.
|
||||
['/api/fixture/own-policy', 200, `${policy}, frame-ancestors 'none'`],
|
||||
['/api/fixture/own-policy-head', 200, `${policy}, default-src *`],
|
||||
]) {
|
||||
const res = await rawGet(port, target, headers);
|
||||
const label = `${mode} ${target} ${JSON.stringify(headers || {})}`;
|
||||
assert.equal(res.status, status, label);
|
||||
assert.equal(res.headers['content-security-policy'], csp, label);
|
||||
assert.equal(res.headers['x-content-type-options'], 'nosniff', label);
|
||||
}
|
||||
// The app document and other paths keep the server's own headers.
|
||||
for (const target of ['/', '/apiary', '/src/api/x.js'])
|
||||
assert.notEqual(
|
||||
(await rawGet(port, target)).headers['content-security-policy'],
|
||||
policy,
|
||||
`${mode} ${target}`,
|
||||
);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Create an ordinary native action button. Static templates use the same CSS
|
||||
* classes. Labels are already translated; feature owners retain callbacks,
|
||||
* disabled-state updates and listener cleanup. Construction binds no listeners.
|
||||
* @param {object} options
|
||||
* @param {Document} [options.documentRef] Document that owns the button.
|
||||
* @param {string} options.label Visible text, never interpreted as markup.
|
||||
* @param {string} [options.className] Additional feature/layout hooks.
|
||||
* @param {'default'|'danger'} [options.variant]
|
||||
* @param {'default'|'compact'} [options.size]
|
||||
* @param {boolean} [options.disabled]
|
||||
* @param {string} [options.ariaLabel] Accessible name when visible text needs context.
|
||||
* @param {string} [options.title]
|
||||
* @returns {HTMLButtonElement}
|
||||
*/
|
||||
export function createActionButton({
|
||||
documentRef = globalThis.document,
|
||||
label,
|
||||
className = '',
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
title,
|
||||
}) {
|
||||
const button = documentRef.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = [
|
||||
'gev-action-button',
|
||||
variant === 'danger' && 'gev-action-button--danger',
|
||||
size === 'compact' && 'gev-action-button--compact',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
button.textContent = label;
|
||||
button.disabled = disabled;
|
||||
if (ariaLabel !== undefined) button.setAttribute('aria-label', ariaLabel);
|
||||
if (title !== undefined) button.title = title;
|
||||
return button;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { syncSegmentedControl } from './segmentedControl.js';
|
||||
|
||||
/** Own the shared aircraft display preference and its controls. */
|
||||
export class AircraftDisplay {
|
||||
constructor({ elements, readDataManager, layout }) {
|
||||
@@ -28,12 +30,9 @@ export class AircraftDisplay {
|
||||
this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity';
|
||||
this._syncModels3dButtonState();
|
||||
this._models3dModeRow?.classList.toggle('visible', this._models3dEnabled);
|
||||
for (const button of this._models3dModeBtns || []) {
|
||||
if (!button) continue;
|
||||
const active = button.dataset.mode === this._models3dMode;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-checked', String(active));
|
||||
}
|
||||
syncSegmentedControl(this._models3dModeBtns, this._models3dMode, {
|
||||
valueOf: (button) => button.dataset.mode,
|
||||
});
|
||||
this._layoutRightPanels();
|
||||
}
|
||||
|
||||
@@ -59,12 +58,9 @@ export class AircraftDisplay {
|
||||
const normalized = mode === 'all' ? 'all' : 'proximity';
|
||||
this._models3dMode = normalized;
|
||||
this._setModels3dParams({ models3dMode: normalized });
|
||||
for (const button of this._models3dModeBtns) {
|
||||
if (!button) continue;
|
||||
const active = button.dataset.mode === normalized;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-checked', String(active));
|
||||
}
|
||||
syncSegmentedControl(this._models3dModeBtns, normalized, {
|
||||
valueOf: (button) => button.dataset.mode,
|
||||
});
|
||||
this._syncModels3dButtonState();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CctvControls } from './cctvControls.js';
|
||||
import { railFixture } from './railTestFixture.mjs';
|
||||
import { setLocale, t } from '../i18n/index.js';
|
||||
|
||||
function fixture(t) {
|
||||
const { document } = railFixture();
|
||||
const controls = new CctvControls({
|
||||
elements: {
|
||||
_cctvQualityChip: document.createElement('span'),
|
||||
_cctvSourceBadge: document.createElement('div'),
|
||||
},
|
||||
cctv: {},
|
||||
actions: { isEnabled: () => true, setPanelCollapsed() {} },
|
||||
});
|
||||
t.after(() => controls.destroy());
|
||||
return { controls, document };
|
||||
}
|
||||
|
||||
test('source and frame states retain their distinct meanings, including retained pixels', (testContext) => {
|
||||
const { controls, document } = fixture(testContext);
|
||||
const camera = { sourceKind: 'snapshot', sourceStatus: 'degraded' };
|
||||
controls._cctvFrame = document.createElement('img');
|
||||
controls._cctvFrameWrap = document.createElement('div');
|
||||
controls._cctvFrame.removeAttribute = () => {};
|
||||
for (const node of [controls._cctvFrame, controls._cctvFrameWrap]) {
|
||||
node.classList.remove = (...names) =>
|
||||
names.forEach((name) => node.classList.toggle(name, false));
|
||||
}
|
||||
const expect = (label, state, active = camera, enabled = true) => {
|
||||
controls._syncCctvSourceBadge(active, enabled);
|
||||
assert.equal(controls._cctvSourceBadge.textContent, label);
|
||||
assert.equal(controls._cctvSourceBadge.dataset.frameState, state);
|
||||
};
|
||||
expect('SOURCE · UNKNOWN', 'idle', camera, false);
|
||||
expect('SOURCE · UNKNOWN', 'idle', null);
|
||||
controls._cctvFrame.dataset.loading = 'true';
|
||||
expect('FRAME · LOADING', 'loading');
|
||||
controls._cctvFrame.dataset.loading = '';
|
||||
controls._cctvFrame.dataset.error = 'true';
|
||||
expect('FRAME · UNAVAILABLE', 'error');
|
||||
// A refresh failure keeps the settled pixels and the provider's source status.
|
||||
controls._cctvFrameWrap.classList.toggle('has-frame', true);
|
||||
expect('SNAPSHOT · DEGRADED', 'ready');
|
||||
controls._cctvFrame.dataset.loading = 'true';
|
||||
expect('SNAPSHOT · DEGRADED', 'ready');
|
||||
controls._cctvFrame.dataset.loading = '';
|
||||
controls._cctvFrame.dataset.error = '';
|
||||
for (const status of ['ok', 'error', 'unknown', 'vendor-specific']) {
|
||||
expect(`SNAPSHOT · ${status.toUpperCase()}`, 'ready', {
|
||||
...camera,
|
||||
sourceStatus: status,
|
||||
});
|
||||
}
|
||||
expect('LIVE · OK', 'ready', { feedType: 'live', sourceStatus: 'ok' });
|
||||
});
|
||||
|
||||
test('calibration labels follow supplied provenance and dirty/save/reset transitions without writes to the camera', (testContext) => {
|
||||
const { controls } = fixture(testContext);
|
||||
const cases = [
|
||||
['raw-prior', false, 'CAL · RAW PRIOR'],
|
||||
['curated', false, 'CAL · CURATED'],
|
||||
['curated', true, 'CAL · EDITED (UNSAVED)'],
|
||||
['calibrated', false, 'CAL · CALIBRATED'],
|
||||
['calibrated', true, 'CAL · EDITED (UNSAVED)'],
|
||||
['raw-prior', false, 'CAL · RAW PRIOR'],
|
||||
[null, false, 'CAL · --'],
|
||||
];
|
||||
for (const [calBadge, calDirty, label] of cases) {
|
||||
const activeCamera = Object.freeze({ id: 'a', calBadge, calDirty });
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
activeCamera,
|
||||
});
|
||||
assert.equal(controls._cctvQualityChip.textContent, label);
|
||||
assert.equal(
|
||||
controls._cctvQualityChip.dataset.calBadge,
|
||||
calDirty ? 'edited' : calBadge || '',
|
||||
);
|
||||
}
|
||||
controls._renderCctvState(null);
|
||||
assert.equal(controls._cctvQualityChip.textContent, 'CAL · --');
|
||||
assert.equal(controls._cctvQualityChip.dataset.calBadge, '');
|
||||
});
|
||||
|
||||
test('localization changes badge copy while preserving machine state ids', (testContext) => {
|
||||
const { controls } = fixture(testContext);
|
||||
testContext.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
controls._syncCctvSourceBadge(
|
||||
{ sourceKind: 'snapshot', sourceStatus: 'degraded' },
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
controls._cctvSourceBadge.textContent,
|
||||
`${t('SNAPSHOT')} · ${t('DEGRADED')}`,
|
||||
);
|
||||
assert.notEqual(controls._cctvSourceBadge.textContent, 'SNAPSHOT · DEGRADED');
|
||||
assert.equal(controls._cctvSourceBadge.dataset.frameState, 'ready');
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
activeCamera: { id: 'a', calBadge: 'curated', calDirty: true },
|
||||
});
|
||||
assert.equal(
|
||||
controls._cctvQualityChip.textContent,
|
||||
t('CAL · EDITED (UNSAVED)'),
|
||||
);
|
||||
assert.equal(controls._cctvQualityChip.dataset.calBadge, 'edited');
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatNumber, t, msg } from '../i18n/index.js';
|
||||
import { createNumberInput } from './formField.js';
|
||||
|
||||
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
|
||||
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
||||
@@ -117,11 +118,12 @@ export function _beginCctvCalValueEdit(chip) {
|
||||
const basePose = { ...activeCamera.basePose };
|
||||
const editListeners = new AbortController();
|
||||
const startValue = field.get(activeCamera);
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.step = field.decimals > 0 ? '0.1' : '1';
|
||||
input.value = Number(startValue).toFixed(field.decimals);
|
||||
input.className = 'cctv-cal-input';
|
||||
const input = createNumberInput({
|
||||
className: 'gev-field--inline cctv-cal-input',
|
||||
step: field.decimals > 0 ? '0.1' : '1',
|
||||
value: Number(startValue).toFixed(field.decimals),
|
||||
ariaLabel: t(field.label),
|
||||
});
|
||||
chip.textContent = `${t(field.label)} `;
|
||||
chip.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
@@ -105,6 +105,9 @@ function calibrationFixture(t) {
|
||||
Object.assign(input, {
|
||||
focus() {},
|
||||
select() {},
|
||||
setAttribute(name, value) {
|
||||
this[name] = value;
|
||||
},
|
||||
remove() {
|
||||
this.parent.input = null;
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { updateStatusBadge } from './statusBadge.js';
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
@@ -164,19 +165,21 @@ export function _settleCctvFrame(token, src, ok) {
|
||||
export function _syncCctvSourceBadge(activeCamera, enabled) {
|
||||
if (!this._cctvSourceBadge) return;
|
||||
if (!enabled || !activeCamera) {
|
||||
this._cctvSourceBadge.textContent = t('SOURCE · UNKNOWN');
|
||||
updateStatusBadge(this._cctvSourceBadge, { label: t('SOURCE · UNKNOWN') });
|
||||
this._cctvSourceBadge.dataset.frameState = 'idle';
|
||||
return;
|
||||
}
|
||||
const hasDisplayedFrame =
|
||||
this._cctvFrameWrap?.classList.contains('has-frame');
|
||||
if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) {
|
||||
this._cctvSourceBadge.textContent = t('FRAME · LOADING');
|
||||
updateStatusBadge(this._cctvSourceBadge, { label: t('FRAME · LOADING') });
|
||||
this._cctvSourceBadge.dataset.frameState = 'loading';
|
||||
return;
|
||||
}
|
||||
if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) {
|
||||
this._cctvSourceBadge.textContent = t('FRAME · UNAVAILABLE');
|
||||
updateStatusBadge(this._cctvSourceBadge, {
|
||||
label: t('FRAME · UNAVAILABLE'),
|
||||
});
|
||||
this._cctvSourceBadge.dataset.frameState = 'error';
|
||||
return;
|
||||
}
|
||||
@@ -184,6 +187,6 @@ export function _syncCctvSourceBadge(activeCamera, enabled) {
|
||||
activeCamera.sourceKind || activeCamera.feedType || 'unknown',
|
||||
);
|
||||
const status = cctvSourceStatusLabel(activeCamera.sourceStatus || 'unknown');
|
||||
this._cctvSourceBadge.textContent = `${kind} · ${status}`;
|
||||
updateStatusBadge(this._cctvSourceBadge, { label: `${kind} · ${status}` });
|
||||
this._cctvSourceBadge.dataset.frameState = 'ready';
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { updateStatusBadge } from './statusBadge.js';
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
|
||||
|
||||
@@ -137,11 +138,13 @@ export function _renderCctvState(state) {
|
||||
// promotes to CALIBRATED, RESET CAL clears.
|
||||
const badge = activeCamera?.calBadge || null;
|
||||
const dirty = !!activeCamera?.calDirty;
|
||||
this._cctvQualityChip.textContent = dirty
|
||||
? t('CAL · EDITED (UNSAVED)')
|
||||
: t('CAL · {badge}', {
|
||||
badge: this._calBadgeLabel(badge),
|
||||
});
|
||||
updateStatusBadge(this._cctvQualityChip, {
|
||||
label: dirty
|
||||
? t('CAL · EDITED (UNSAVED)')
|
||||
: t('CAL · {badge}', {
|
||||
badge: this._calBadgeLabel(badge),
|
||||
}),
|
||||
});
|
||||
this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || '';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createSegmentedControl } from './segmentedControl.js';
|
||||
|
||||
/**
|
||||
* Bind Display controls to application actions. Inputs remain native DOM controls;
|
||||
* the caller owns settings and effect state. Destroy before replacing the view.
|
||||
@@ -44,12 +46,25 @@ export function bindDisplayControls({ elements, actions }) {
|
||||
listen(elements[name], 'input', action, integer);
|
||||
for (const el of elements.styleButtons || [])
|
||||
listen(el, 'click', 'setStyle', (el) => el.dataset.style);
|
||||
for (const el of elements.allocationButtons || [])
|
||||
listen(el, 'click', 'setAllocation', (el) => el.dataset.allocation);
|
||||
for (const el of elements.modelModeButtons || [])
|
||||
listen(el, 'click', 'setModelsMode', (el) =>
|
||||
el.dataset.mode === 'all' ? 'all' : 'proximity',
|
||||
);
|
||||
for (const [buttons, valueOf, action] of [
|
||||
[
|
||||
elements.allocationButtons || [],
|
||||
(el) => el.dataset.allocation,
|
||||
'setAllocation',
|
||||
],
|
||||
[
|
||||
elements.modelModeButtons || [],
|
||||
(el) => (el.dataset.mode === 'all' ? 'all' : 'proximity'),
|
||||
'setModelsMode',
|
||||
],
|
||||
]) {
|
||||
const control = createSegmentedControl({
|
||||
buttons,
|
||||
valueOf,
|
||||
onChange: (value) => actions[action](value),
|
||||
});
|
||||
removers.push(() => control.destroy());
|
||||
}
|
||||
for (const el of elements.fadeSliders || []) listen(el, 'input', 'setFade');
|
||||
return {
|
||||
destroy() {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Build a native numeric field without listeners, parsing or commit policy.
|
||||
* Owners retain drafts, validation, input/change timing and disposal. Static
|
||||
* selects and number inputs share the gev-field surface in their templates;
|
||||
* options remain owned by each feature (no replacement popup or keyboard code).
|
||||
* @param {object} options
|
||||
* @param {Document} [options.documentRef]
|
||||
* @param {string} [options.className] Additional density/layout hooks.
|
||||
* @param {number|string} [options.min]
|
||||
* @param {number|string} [options.max]
|
||||
* @param {number|string} [options.step]
|
||||
* @param {number|string} [options.value]
|
||||
* @param {boolean} [options.disabled]
|
||||
* @param {string} [options.ariaLabel] Already translated accessible name.
|
||||
* @returns {HTMLInputElement}
|
||||
*/
|
||||
export function createNumberInput({
|
||||
documentRef = globalThis.document,
|
||||
className = '',
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
} = {}) {
|
||||
const input = documentRef.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = `gev-field${className ? ` ${className}` : ''}`;
|
||||
for (const [key, setting] of Object.entries({ min, max, step, value })) {
|
||||
if (setting !== undefined) input[key] = setting;
|
||||
}
|
||||
input.disabled = disabled;
|
||||
if (ariaLabel !== undefined) input.setAttribute('aria-label', ariaLabel);
|
||||
return input;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { t } from '../i18n/index.js';
|
||||
import { syncChipGroup } from './chipGroup.js';
|
||||
import { syncRowList } from './rowList.js';
|
||||
import { formatScaleLabel, unitLabel } from './unitLabels.js';
|
||||
import { createSlider } from './slider.js';
|
||||
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
@@ -90,12 +91,11 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
|
||||
'rail-card-setting-range-label',
|
||||
block.rangeRow,
|
||||
);
|
||||
block.rangeInput = make(
|
||||
'input',
|
||||
'rail-card-setting-range',
|
||||
block.rangeRow,
|
||||
);
|
||||
block.rangeInput.type = 'range';
|
||||
block.rangeInput = createSlider({
|
||||
documentRef: document,
|
||||
className: 'rail-card-setting-range',
|
||||
});
|
||||
block.rangeRow.appendChild(block.rangeInput);
|
||||
block.rangeValue = make(
|
||||
'span',
|
||||
'rail-card-setting-range-value',
|
||||
|
||||
+11
-7
@@ -1,4 +1,5 @@
|
||||
import { createRailCardBlocks } from './railCardBlocks.js';
|
||||
import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
|
||||
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
@@ -53,11 +54,12 @@ export function createRailCards({
|
||||
'rail-card-title rail-card-nowrap',
|
||||
heading,
|
||||
);
|
||||
const badge = make(
|
||||
'span',
|
||||
`rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`,
|
||||
labels,
|
||||
);
|
||||
const badge = createStatusBadge({
|
||||
documentRef: document,
|
||||
appearance: 'plain',
|
||||
className: `rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`,
|
||||
});
|
||||
labels.appendChild(badge);
|
||||
const disclosure = make('span', 'rail-card-disclosure', header);
|
||||
disclosure.setAttribute('aria-hidden', 'true');
|
||||
const compact = make('span', 'rail-card-compact', header);
|
||||
@@ -90,8 +92,10 @@ export function createRailCards({
|
||||
row.title.setAttribute('title', card.title);
|
||||
set(row.icon, 'textContent', card.icon || '');
|
||||
set(row.icon, 'hidden', !card.icon);
|
||||
set(row.badge, 'textContent', card.badge || '');
|
||||
set(row.badge, 'hidden', !card.badge);
|
||||
updateStatusBadge(row.badge, {
|
||||
label: card.badge || '',
|
||||
hidden: !card.badge,
|
||||
});
|
||||
const open = card.open !== false;
|
||||
set(row.element.dataset, 'open', String(open));
|
||||
row.element.classList.toggle('is-open', open);
|
||||
|
||||
+10
-8
@@ -1,4 +1,5 @@
|
||||
import { formatUtcDayMonth, t } from '../i18n/index.js';
|
||||
import { createSlider } from './slider.js';
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
};
|
||||
@@ -43,14 +44,15 @@ export function createRailTimeline({
|
||||
return node;
|
||||
};
|
||||
const previous = makeButton('‹', t('Earlier observation'), row);
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.className = `gev-quantitative-slider${sliderClassName ? ` ${sliderClassName}` : ''}`;
|
||||
slider.min = '0';
|
||||
slider.max = '0';
|
||||
slider.step = '1';
|
||||
slider.value = '0';
|
||||
slider.setAttribute('aria-label', t('Observed history'));
|
||||
const slider = createSlider({
|
||||
documentRef: document,
|
||||
className: sliderClassName,
|
||||
min: '0',
|
||||
max: '0',
|
||||
step: '1',
|
||||
value: '0',
|
||||
ariaLabel: t('Observed history'),
|
||||
});
|
||||
row.appendChild(slider);
|
||||
const next = makeButton('›', t('Later observation'), row);
|
||||
const controls = document.createElement('div');
|
||||
|
||||
+20
-33
@@ -12,6 +12,8 @@ import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
|
||||
import { createRailCardBlocks } from './railCardBlocks.js';
|
||||
import { createRailCards } from './railCards.js';
|
||||
import { createImagerySplit } from './imagerySplit.js';
|
||||
import { createSlider } from './slider.js';
|
||||
import { createSegmentedControl } from './segmentedControl.js';
|
||||
import {
|
||||
PRODUCTS,
|
||||
hhmm,
|
||||
@@ -286,13 +288,13 @@ export function createRecentImageryPanel({
|
||||
const selection = el('section', 'ri-block ri-selection', root);
|
||||
selection.id = 'ri-selection';
|
||||
selection.setAttribute('aria-label', t('On the map'));
|
||||
const modeGroup = el('div', 'ri-mode', selection);
|
||||
const modeGroup = el('div', 'gev-segmented ri-mode', selection);
|
||||
modeGroup.id = 'ri-mode';
|
||||
modeGroup.setAttribute('role', 'radiogroup');
|
||||
modeGroup.setAttribute('aria-label', t('Mode'));
|
||||
const modeButtons = new Map(
|
||||
MODE_LABELS.map(([mode, label]) => {
|
||||
const node = el('button', 'data-toggle-chip ri-mode-btn', modeGroup);
|
||||
const node = el('button', 'gev-segment ri-mode-btn', modeGroup);
|
||||
node.type = 'button';
|
||||
node.textContent = t(label);
|
||||
node.dataset.mode = mode;
|
||||
@@ -300,6 +302,15 @@ export function createRecentImageryPanel({
|
||||
return [mode, node];
|
||||
}),
|
||||
);
|
||||
const modeControl = createSegmentedControl({
|
||||
buttons: modeButtons.values(),
|
||||
valueOf: (button) => button.dataset.mode,
|
||||
onChange: (mode) => layer.setMode(mode),
|
||||
keyboard: 'arrows',
|
||||
keyTarget: modeGroup,
|
||||
readValue: () => snapshot.mode,
|
||||
listen,
|
||||
});
|
||||
const slotRows = {};
|
||||
for (const slotId of ['a', 'b']) {
|
||||
const row = el('div', 'ri-slot', selection);
|
||||
@@ -318,16 +329,17 @@ export function createRecentImageryPanel({
|
||||
const opacityLabel = el('label', 'ri-opacity-label', controls);
|
||||
el('span', '', opacityLabel, t('OPACITY'));
|
||||
const opacityValue = el('span', 'ri-opacity-value', opacityLabel, '100%');
|
||||
const opacity = el('input', 'ri-opacity', controls);
|
||||
opacity.id = 'ri-opacity';
|
||||
Object.assign(opacity, {
|
||||
type: 'range',
|
||||
const opacity = createSlider({
|
||||
documentRef: document,
|
||||
className: 'ri-opacity',
|
||||
min: '0',
|
||||
max: '100',
|
||||
step: '1',
|
||||
value: '100',
|
||||
ariaLabel: t('Imagery opacity'),
|
||||
});
|
||||
opacity.setAttribute('aria-label', t('Imagery opacity'));
|
||||
opacity.id = 'ri-opacity';
|
||||
controls.appendChild(opacity);
|
||||
opacityLabel.htmlFor = 'ri-opacity';
|
||||
// Its slot is always there; it is only usable while a swipe is live.
|
||||
const swap = el('button', 'data-toggle-chip ri-swap', controls, t('SWAP'));
|
||||
@@ -668,12 +680,7 @@ export function createRecentImageryPanel({
|
||||
function renderSelection() {
|
||||
const { mode } = snapshot;
|
||||
set(selection.dataset, 'mode', mode);
|
||||
for (const [id, node] of modeButtons) {
|
||||
const on = id === mode;
|
||||
attribute(node, 'aria-checked', String(on));
|
||||
toggle(node, 'active', on);
|
||||
set(node, 'tabIndex', on ? 0 : -1);
|
||||
}
|
||||
modeControl.update(mode, { rovingFocus: true });
|
||||
const ab = mode === 'ab';
|
||||
for (const slotId of ['a', 'b']) {
|
||||
const { row, tag, value, unpin } = slotRows[slotId];
|
||||
@@ -1065,11 +1072,6 @@ export function createRecentImageryPanel({
|
||||
strip.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
const mode = target?.closest?.('.ri-mode-btn');
|
||||
if (mode) {
|
||||
layer.setMode(mode.dataset.mode);
|
||||
return;
|
||||
}
|
||||
if (target?.closest?.('.ri-swap') && !swap.disabled) {
|
||||
layer.swapSides?.();
|
||||
return;
|
||||
@@ -1086,21 +1088,6 @@ export function createRecentImageryPanel({
|
||||
if (event.target === opacity) layer.setAlpha(Number(opacity.value) / 100);
|
||||
});
|
||||
|
||||
listen(modeGroup, 'keydown', (event) => {
|
||||
const order = MODE_LABELS.map(([mode]) => mode);
|
||||
const step =
|
||||
{ ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[event.key] ||
|
||||
0;
|
||||
if (!step) return;
|
||||
event.preventDefault();
|
||||
const next =
|
||||
order[
|
||||
(order.indexOf(snapshot.mode) + step + order.length) % order.length
|
||||
];
|
||||
layer.setMode(next);
|
||||
modeButtons.get(next)?.focus?.();
|
||||
});
|
||||
|
||||
listen(strip, 'keydown', (event) => {
|
||||
const total = snapshot.candidates.length;
|
||||
const index = snapshot.focusIndex || 0;
|
||||
|
||||
@@ -412,7 +412,7 @@ test('the body is a fixed stack of seven blocks in a fixed order, each with a fi
|
||||
assert.match(rule('.recent-imagery-readout .ri-card'), /height: 112px/);
|
||||
assert.match(
|
||||
rule('.recent-imagery-readout .ri-selection'),
|
||||
/grid-template-rows: 20px 20px 20px/,
|
||||
/grid-template-rows: 26px 20px 20px/,
|
||||
);
|
||||
assert.match(rule('.recent-imagery-readout .ri-slot'), /height: 20px/);
|
||||
assert.match(rule('.recent-imagery-readout .ri-controls'), /height: 22px/);
|
||||
@@ -445,7 +445,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h
|
||||
/**
|
||||
* Every block that must keep its height: the node carrying the class, that
|
||||
* class, and the rule it gets its height from. The mode row is the first
|
||||
* 20 px track of the selection grid. The details header has no height of
|
||||
* 26 px track of the selection grid. The details header has no height of
|
||||
* its own: it is the last block, so it moves nothing below it, and its
|
||||
* title is held to one line.
|
||||
*/
|
||||
@@ -491,7 +491,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h
|
||||
mode.parentNode.children[0] === mode ? mode.parentNode : null,
|
||||
'ri-selection',
|
||||
readout('.ri-selection'),
|
||||
/grid-template-rows: 20px 20px 20px/,
|
||||
/grid-template-rows: 26px 20px 20px/,
|
||||
],
|
||||
'ri-slot-a': [
|
||||
f.byId('ri-slot-a'),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Scene panel elements and text-only project presentation. */
|
||||
import { formatNumber, msg, t } from '../i18n/index.js';
|
||||
import { createActionButton } from './actionButton.js';
|
||||
|
||||
// Labels for what a shot stores; anything else shows as stored. Style names
|
||||
// match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot
|
||||
@@ -121,13 +122,18 @@ export function renderSceneShots(
|
||||
});
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'scene-shot-actions';
|
||||
const loadButton = document.createElement('button');
|
||||
loadButton.className = 'scene-shot-btn';
|
||||
loadButton.textContent = t('LOAD');
|
||||
const loadButton = createActionButton({
|
||||
label: t('LOAD'),
|
||||
className: 'scene-shot-btn',
|
||||
size: 'compact',
|
||||
});
|
||||
listen(loadButton, 'click', () => load(scene.id, shot.id));
|
||||
const deleteButton = document.createElement('button');
|
||||
deleteButton.className = 'scene-shot-btn scene-shot-danger';
|
||||
deleteButton.textContent = t('DEL');
|
||||
const deleteButton = createActionButton({
|
||||
label: t('DEL'),
|
||||
className: 'scene-shot-btn scene-shot-danger',
|
||||
size: 'compact',
|
||||
variant: 'danger',
|
||||
});
|
||||
listen(deleteButton, 'click', () => remove(scene.id, shot.id));
|
||||
actions.appendChild(loadButton);
|
||||
actions.appendChild(deleteButton);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
import { createActionButton } from './actionButton.js';
|
||||
|
||||
/** Small owned modal for scene authoring and import review; text never becomes markup. */
|
||||
export function createSceneDialog(title, onClose) {
|
||||
@@ -185,10 +186,7 @@ export function mountSceneSharing(
|
||||
];
|
||||
const removers = [];
|
||||
for (const [text, fn] of entries) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = text;
|
||||
b.className = 'scene-btn';
|
||||
const b = createActionButton({ label: text, className: 'scene-btn' });
|
||||
b.addEventListener('click', fn);
|
||||
removers.push(() => b.removeEventListener('click', fn));
|
||||
bar.append(b);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
||||
|
||||
/** Pure value navigation, also usable by a future template-based view. */
|
||||
export function nextSegmentValue(values, value, key, { homeEnd = true } = {}) {
|
||||
if (!values.length) return undefined;
|
||||
if (homeEnd && key === 'Home') return values[0];
|
||||
if (homeEnd && key === 'End') return values.at(-1);
|
||||
const step = STEPS[key];
|
||||
if (!step) return undefined;
|
||||
return values[(values.indexOf(value) + step + values.length) % values.length];
|
||||
}
|
||||
|
||||
/** Paint controlled selection without emitting changes or moving focus. */
|
||||
export function syncSegmentedControl(
|
||||
buttons,
|
||||
value,
|
||||
{ valueOf, activeClass = 'active', rovingFocus = false } = {},
|
||||
) {
|
||||
for (const button of buttons || []) {
|
||||
if (!button) continue;
|
||||
const selected = valueOf(button) === value;
|
||||
if (button.classList.contains(activeClass) !== selected)
|
||||
button.classList.toggle(activeClass, selected);
|
||||
const set = (name, content) => {
|
||||
if (button.getAttribute?.(name) !== content)
|
||||
button.setAttribute(name, content);
|
||||
};
|
||||
set('aria-checked', String(selected));
|
||||
if (rovingFocus) set('tabindex', selected ? '0' : '-1');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlled segmented-selector DOM adapter. Feature owners supply values and
|
||||
* receive changes; rendering a restored value never triggers the callback.
|
||||
* `keyboard` retains each existing group's policy: none, arrows, or radio
|
||||
* (arrows plus Home/End). Pointer and native Enter/Space share the click path.
|
||||
* An injected listener owner keeps the feature's existing teardown boundary.
|
||||
* @param {object} options
|
||||
* @param {Iterable<HTMLButtonElement>} options.buttons
|
||||
* @param {Function} options.valueOf Maps a button to the feature's value.
|
||||
* @param {Function} options.onChange Receives value and originating button.
|
||||
* @param {'none'|'arrows'|'radio'} [options.keyboard]
|
||||
* @param {HTMLElement} [options.keyTarget] Delegated keyboard target, if any.
|
||||
* @param {Function} [options.readValue] Current controlled value for delegation.
|
||||
* @param {Function} [options.listen] Feature-owned event registration.
|
||||
* @returns {{ update: Function, destroy: Function }}
|
||||
*/
|
||||
export function createSegmentedControl({
|
||||
buttons,
|
||||
valueOf,
|
||||
onChange,
|
||||
keyboard = 'none',
|
||||
keyTarget,
|
||||
readValue,
|
||||
listen,
|
||||
}) {
|
||||
const options = [...buttons].filter(Boolean);
|
||||
const removers = [];
|
||||
let destroyed = false;
|
||||
const bind = (target, type, handler) => {
|
||||
const owned = (event) => {
|
||||
if (!destroyed) handler(event);
|
||||
};
|
||||
if (listen) listen(target, type, owned);
|
||||
else {
|
||||
target.addEventListener(type, owned);
|
||||
removers.push(() => target.removeEventListener(type, owned));
|
||||
}
|
||||
};
|
||||
for (const button of options)
|
||||
bind(button, 'click', () => {
|
||||
if (!button.disabled) onChange(valueOf(button), button);
|
||||
});
|
||||
const navigate = (event, from) => {
|
||||
const enabled = options.filter((button) => !button.disabled);
|
||||
const values = enabled.map(valueOf);
|
||||
const next = nextSegmentValue(values, from, event.key, {
|
||||
homeEnd: keyboard === 'radio',
|
||||
});
|
||||
if (next === undefined) return;
|
||||
const button = enabled.find((option) => valueOf(option) === next);
|
||||
event.preventDefault();
|
||||
if (keyTarget) {
|
||||
button.click();
|
||||
button.focus();
|
||||
} else {
|
||||
button.focus();
|
||||
if (next !== from) button.click();
|
||||
}
|
||||
};
|
||||
if (keyboard !== 'none') {
|
||||
if (keyTarget)
|
||||
bind(keyTarget, 'keydown', (event) => navigate(event, readValue()));
|
||||
else
|
||||
for (const button of options)
|
||||
bind(button, 'keydown', (event) => navigate(event, valueOf(button)));
|
||||
}
|
||||
return {
|
||||
update(value, presentation) {
|
||||
if (!destroyed)
|
||||
syncSegmentedControl(options, value, { valueOf, ...presentation });
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
for (const remove of removers.splice(0)) remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { railFixture } from './railTestFixture.mjs';
|
||||
import {
|
||||
createSegmentedControl,
|
||||
nextSegmentValue,
|
||||
} from './segmentedControl.js';
|
||||
|
||||
function fixture(options = {}) {
|
||||
let writes = 0;
|
||||
const f = railFixture(() => writes++);
|
||||
const buttons = ['a', 'b', 'c'].map((value) => {
|
||||
const button = f.document.createElement('button');
|
||||
button.dataset.value = value;
|
||||
f.container.appendChild(button);
|
||||
return button;
|
||||
});
|
||||
const calls = [];
|
||||
const control = createSegmentedControl({
|
||||
buttons,
|
||||
valueOf: (button) => button.dataset.value,
|
||||
onChange: (value) => calls.push(value),
|
||||
...options,
|
||||
});
|
||||
const key = (target, value) => {
|
||||
const event = new Event('keydown', { cancelable: true });
|
||||
Object.defineProperty(event, 'key', { value });
|
||||
target.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
};
|
||||
return { ...f, buttons, calls, control, key, writes: () => writes };
|
||||
}
|
||||
|
||||
test('controlled restoration updates selection without emitting or stealing focus', () => {
|
||||
const f = fixture();
|
||||
f.buttons[0].focus();
|
||||
f.control.update('b', { rovingFocus: true });
|
||||
assert.deepEqual(f.calls, []);
|
||||
assert.equal(f.document.activeElement, f.buttons[0]);
|
||||
assert.deepEqual(
|
||||
f.buttons.map((b) => b.getAttribute('aria-checked')),
|
||||
['false', 'true', 'false'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.buttons.map((b) => b.getAttribute('tabindex')),
|
||||
['-1', '0', '-1'],
|
||||
);
|
||||
const before = f.writes();
|
||||
f.control.update('b', { rovingFocus: true });
|
||||
assert.equal(
|
||||
f.writes(),
|
||||
before,
|
||||
'unchanged renders must not retrigger rail observers',
|
||||
);
|
||||
f.control.destroy();
|
||||
});
|
||||
|
||||
test('clicks emit current values once, including reselection; disabled controls do not emit', () => {
|
||||
const f = fixture();
|
||||
f.control.update('a');
|
||||
f.buttons[0].click();
|
||||
f.buttons[0].click();
|
||||
f.buttons[1].dataset.value = 'renamed';
|
||||
f.buttons[1].click();
|
||||
f.buttons[2].disabled = true;
|
||||
f.buttons[2].dispatchEvent(new Event('click'));
|
||||
assert.deepEqual(f.calls, ['a', 'a', 'renamed']);
|
||||
assert.equal(
|
||||
f.buttons[0].getAttribute('aria-checked'),
|
||||
'true',
|
||||
'the owner controls selection',
|
||||
);
|
||||
f.control.destroy();
|
||||
});
|
||||
|
||||
test('radio navigation wraps, skips disabled choices, and retains Home/End semantics', () => {
|
||||
const f = fixture({ keyboard: 'radio' });
|
||||
f.buttons[1].disabled = true;
|
||||
assert.equal(f.key(f.buttons[0], 'ArrowRight'), true);
|
||||
assert.equal(f.document.activeElement, f.buttons[2]);
|
||||
assert.deepEqual(f.calls, ['c']);
|
||||
f.key(f.buttons[2], 'ArrowDown');
|
||||
assert.equal(f.document.activeElement, f.buttons[0]);
|
||||
f.key(f.buttons[0], 'Home');
|
||||
assert.deepEqual(
|
||||
f.calls,
|
||||
['c', 'a'],
|
||||
'Home on the first button does not reselect it',
|
||||
);
|
||||
f.key(f.buttons[0], 'End');
|
||||
assert.equal(f.document.activeElement, f.buttons[2]);
|
||||
assert.deepEqual(f.calls, ['c', 'a', 'c']);
|
||||
f.control.destroy();
|
||||
});
|
||||
|
||||
test('legacy click-only groups leave arrows and tab order to their existing owner', () => {
|
||||
const f = fixture();
|
||||
f.control.update('b');
|
||||
assert.equal(f.key(f.buttons[0], 'ArrowRight'), false);
|
||||
assert.equal(f.key(f.buttons[0], 'Home'), false);
|
||||
assert.deepEqual(f.calls, []);
|
||||
assert.ok(
|
||||
f.buttons.every((button) => button.getAttribute('tabindex') === null),
|
||||
);
|
||||
f.control.destroy();
|
||||
});
|
||||
|
||||
test('delegated imagery keys navigate from the controlled value, not stale focus', () => {
|
||||
const f = railFixture();
|
||||
const buttons = ['a', 'b', 'c'].map((value) => {
|
||||
const button = f.document.createElement('button');
|
||||
button.dataset.value = value;
|
||||
f.container.appendChild(button);
|
||||
return button;
|
||||
});
|
||||
let selected = 'c';
|
||||
const calls = [];
|
||||
const control = createSegmentedControl({
|
||||
buttons,
|
||||
valueOf: (b) => b.dataset.value,
|
||||
onChange: (value) => {
|
||||
calls.push(value);
|
||||
selected = value;
|
||||
},
|
||||
keyboard: 'arrows',
|
||||
keyTarget: f.container,
|
||||
readValue: () => selected,
|
||||
});
|
||||
const event = new Event('keydown', { cancelable: true });
|
||||
Object.defineProperty(event, 'key', { value: 'ArrowRight' });
|
||||
f.container.dispatchEvent(event);
|
||||
assert.deepEqual(calls, ['a']);
|
||||
assert.equal(f.document.activeElement, buttons[0]);
|
||||
const home = new Event('keydown', { cancelable: true });
|
||||
Object.defineProperty(home, 'key', { value: 'Home' });
|
||||
f.container.dispatchEvent(home);
|
||||
assert.equal(home.defaultPrevented, false);
|
||||
assert.deepEqual(calls, ['a']);
|
||||
control.destroy();
|
||||
});
|
||||
|
||||
test('disposal revokes detached controls and replacement subscriptions fire once', () => {
|
||||
const f = fixture({ keyboard: 'radio' });
|
||||
f.control.destroy();
|
||||
const next = [];
|
||||
const replacement = createSegmentedControl({
|
||||
buttons: f.buttons,
|
||||
valueOf: (b) => b.dataset.value,
|
||||
onChange: (value) => next.push(value),
|
||||
keyboard: 'radio',
|
||||
});
|
||||
f.buttons[0].click();
|
||||
f.key(f.buttons[0], 'End');
|
||||
assert.deepEqual(f.calls, []);
|
||||
assert.deepEqual(next, ['a', 'c']);
|
||||
replacement.destroy();
|
||||
replacement.destroy();
|
||||
f.buttons[1].click();
|
||||
f.key(f.buttons[0], 'End');
|
||||
assert.deepEqual(next, ['a', 'c']);
|
||||
});
|
||||
|
||||
test('pure value navigation can be reused without a DOM', () => {
|
||||
assert.equal(nextSegmentValue(['fit', 'fill'], 'fill', 'ArrowRight'), 'fit');
|
||||
assert.equal(nextSegmentValue(['fit', 'fill'], 'fit', 'ArrowUp'), 'fill');
|
||||
assert.equal(nextSegmentValue(['fit', 'fill'], 'fill', 'Home'), 'fit');
|
||||
assert.equal(nextSegmentValue(['fit', 'fill'], 'fit', 'End'), 'fill');
|
||||
assert.equal(
|
||||
nextSegmentValue(['fit', 'fill'], 'fit', 'Home', { homeEnd: false }),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(nextSegmentValue([], 'fit', 'ArrowRight'), undefined);
|
||||
assert.equal(nextSegmentValue(['fit'], 'fit', 'Escape'), undefined);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Build the shared native slider. Feature owners retain their input/change
|
||||
* listeners, formatting and teardown; this component installs no handlers.
|
||||
* Static sliders use the same gev-quantitative-slider class in their template.
|
||||
* @param {object} options
|
||||
* @param {Document} [options.documentRef] Document that owns the input.
|
||||
* @param {string} [options.className] Additional feature/layout hooks.
|
||||
* @param {number|string} [options.min]
|
||||
* @param {number|string} [options.max]
|
||||
* @param {number|string} [options.step]
|
||||
* @param {number|string} [options.value]
|
||||
* @param {string} [options.ariaLabel] Already translated accessible name.
|
||||
* @returns {HTMLInputElement}
|
||||
*/
|
||||
export function createSlider({
|
||||
documentRef = globalThis.document,
|
||||
className = '',
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
ariaLabel,
|
||||
} = {}) {
|
||||
const input = documentRef.createElement('input');
|
||||
input.type = 'range';
|
||||
input.className = `gev-quantitative-slider${className ? ` ${className}` : ''}`;
|
||||
for (const [key, setting] of Object.entries({ min, max, step, value })) {
|
||||
if (setting !== undefined) input[key] = setting;
|
||||
}
|
||||
if (ariaLabel !== undefined) input.setAttribute('aria-label', ariaLabel);
|
||||
return input;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Ordinary read-only badge view. Labels are already translated. Feature owners
|
||||
* retain status derivation, machine-state attributes and announcement policy.
|
||||
* No listeners, timers, state inference or automatic live regions are installed.
|
||||
* Vue can replace this DOM adapter while retaining the label/hidden contract
|
||||
* and the gev-status-badge CSS surface.
|
||||
* @param {object} options
|
||||
* @param {Document} [options.documentRef]
|
||||
* @param {string} [options.label]
|
||||
* @param {'pill'|'box'|'plain'} [options.appearance]
|
||||
* @param {string} [options.className] Feature/layout hooks.
|
||||
* @param {boolean} [options.hidden]
|
||||
* @returns {HTMLSpanElement}
|
||||
*/
|
||||
export function createStatusBadge({
|
||||
documentRef = globalThis.document,
|
||||
label = '',
|
||||
appearance = 'pill',
|
||||
className = '',
|
||||
hidden = false,
|
||||
} = {}) {
|
||||
const badge = documentRef.createElement('span');
|
||||
badge.className = [
|
||||
'gev-status-badge',
|
||||
appearance === 'box' && 'gev-status-badge--box',
|
||||
appearance === 'plain' && 'gev-status-badge--plain',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
updateStatusBadge(badge, { label, hidden });
|
||||
return badge;
|
||||
}
|
||||
|
||||
/** Paint controlled text/visibility without replacing nodes or redundant writes. */
|
||||
export function updateStatusBadge(badge, { label, hidden = false }) {
|
||||
if (badge.textContent !== label) badge.textContent = label;
|
||||
if (badge.hidden !== hidden) badge.hidden = hidden;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
|
||||
import { createRailCards } from './railCards.js';
|
||||
import { railFixture } from './railTestFixture.mjs';
|
||||
|
||||
test('badges paint literal translated text without installing announcement or interaction semantics', () => {
|
||||
const { document } = railFixture();
|
||||
for (const appearance of ['pill', 'box', 'plain']) {
|
||||
const badge = createStatusBadge({
|
||||
documentRef: document,
|
||||
appearance,
|
||||
className: 'feature-hook',
|
||||
label: '<img src=x onerror=alert(1)> · НЕИЗВЕСТНО',
|
||||
});
|
||||
assert.equal(badge.tagName, 'SPAN');
|
||||
assert.equal(
|
||||
badge.textContent,
|
||||
'<img src=x onerror=alert(1)> · НЕИЗВЕСТНО',
|
||||
);
|
||||
assert.equal(badge.children.length, 0);
|
||||
assert.equal(badge.classList.contains('gev-status-badge'), true);
|
||||
assert.equal(badge.classList.contains('feature-hook'), true);
|
||||
assert.equal(badge.getAttribute('role'), null);
|
||||
assert.equal(badge.getAttribute('aria-live'), null);
|
||||
assert.equal(badge.getAttribute('tabindex'), null);
|
||||
assert.equal(
|
||||
badge.classList.contains(`gev-status-badge--${appearance}`),
|
||||
appearance !== 'pill',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('painting preserves feature state, live regions and focus with zero identical writes', () => {
|
||||
let writes = 0;
|
||||
const { document } = railFixture(() => writes++);
|
||||
const badge = createStatusBadge({ documentRef: document, hidden: true });
|
||||
badge.setAttribute('role', 'status');
|
||||
badge.setAttribute('aria-live', 'polite');
|
||||
badge.dataset.frameState = 'ready';
|
||||
const focusOwner = document.createElement('button');
|
||||
focusOwner.focus();
|
||||
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
|
||||
assert.equal(badge.hidden, false);
|
||||
writes = 0;
|
||||
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
|
||||
assert.equal(writes, 0);
|
||||
assert.equal(badge.getAttribute('role'), 'status');
|
||||
assert.equal(badge.getAttribute('aria-live'), 'polite');
|
||||
assert.equal(badge.dataset.frameState, 'ready');
|
||||
assert.equal(document.activeElement, focusOwner);
|
||||
});
|
||||
|
||||
test('coverage badges keep keyed identity, hide empty coverage and never claim feed health', () => {
|
||||
const f = railFixture();
|
||||
const view = createRailCards({ ...f, badgeClassName: 'weather-coverage' });
|
||||
const card = { id: 'radar', title: 'Rain radar', badge: 'CONUS', blocks: [] };
|
||||
view.update([card]);
|
||||
const badge = f.find((node) => node.classList.contains('gev-status-badge'));
|
||||
const header = f.container.children[0].children[0];
|
||||
header.focus();
|
||||
assert.equal(badge.classList.contains('gev-status-badge--plain'), true);
|
||||
assert.equal(badge.classList.contains('weather-coverage'), true);
|
||||
view.update([{ ...card, badge: '' }]);
|
||||
assert.equal(badge.hidden, true);
|
||||
view.update([{ ...card, badge: 'Global · 1° grid' }]);
|
||||
assert.equal(badge.hidden, false);
|
||||
assert.equal(badge.textContent, 'Global · 1° grid');
|
||||
assert.equal(
|
||||
f.find((node) => node.classList.contains('gev-status-badge')),
|
||||
badge,
|
||||
);
|
||||
assert.deepEqual({ ...badge.dataset }, {});
|
||||
assert.equal(f.document.activeElement, header);
|
||||
view.destroy();
|
||||
view.update([card]);
|
||||
assert.equal(f.container.children.length, 0);
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
|
||||
import { syncChipGroup } from './chipGroup.js';
|
||||
import {
|
||||
createSegmentedControl,
|
||||
syncSegmentedControl,
|
||||
} from './segmentedControl.js';
|
||||
import {
|
||||
presentStreetLevelPanel,
|
||||
SINCE_STOPS,
|
||||
@@ -9,12 +13,6 @@ import { t, translateKnown } from '../i18n/index.js';
|
||||
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
|
||||
/** <body> classes of the modes that hide every panel; an expanded photo leaves with them. */
|
||||
const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode'];
|
||||
const RADIO_STEPS = Object.freeze({
|
||||
ArrowRight: 1,
|
||||
ArrowDown: 1,
|
||||
ArrowLeft: -1,
|
||||
ArrowUp: -1,
|
||||
});
|
||||
|
||||
// Renders run on every layer notification, and the rail's MutationObserver
|
||||
// relays out on each attribute write: write only what changed.
|
||||
@@ -106,13 +104,13 @@ export class StreetLevelControls {
|
||||
}
|
||||
this._toggleProvider(button.dataset.chipId);
|
||||
});
|
||||
for (const button of el.panoButtons) {
|
||||
this.listen(button, 'click', () =>
|
||||
this._setParams({ pano: button.dataset.slPano }),
|
||||
);
|
||||
}
|
||||
this._bindRadioGroup(el.panoButtons);
|
||||
this._bindRadioGroup(el.renderButtons);
|
||||
createSegmentedControl({
|
||||
buttons: el.panoButtons,
|
||||
valueOf: (button) => button.dataset.slPano,
|
||||
onChange: (pano) => this._setParams({ pano }),
|
||||
keyboard: 'radio',
|
||||
listen: (target, type, handler) => this.listen(target, type, handler),
|
||||
});
|
||||
// The readout follows the thumb; coverage is rebuilt once, on release.
|
||||
const sinceDays = () =>
|
||||
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
|
||||
@@ -160,17 +158,20 @@ export class StreetLevelControls {
|
||||
this._exitExpanded();
|
||||
});
|
||||
} else setProp(el.viewerExpand, 'hidden', true);
|
||||
for (const button of el.renderButtons) {
|
||||
this.listen(button, 'click', () => {
|
||||
const mode = button.dataset.slRender;
|
||||
createSegmentedControl({
|
||||
buttons: el.renderButtons,
|
||||
valueOf: (button) => button.dataset.slRender,
|
||||
keyboard: 'radio',
|
||||
listen: (target, type, handler) => this.listen(target, type, handler),
|
||||
onChange: (mode) => {
|
||||
this.layer.setViewerRenderMode?.(mode);
|
||||
try {
|
||||
localStorage.setItem(RENDER_MODE_KEY, mode);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
try {
|
||||
const stored = localStorage.getItem(RENDER_MODE_KEY);
|
||||
if (stored === 'fill' || stored === 'letterbox')
|
||||
@@ -185,24 +186,6 @@ export class StreetLevelControls {
|
||||
}
|
||||
}
|
||||
|
||||
/** ARIA radio keys. Selecting clicks the radio, so keys and pointer share a path. */
|
||||
_bindRadioGroup(buttons) {
|
||||
buttons.forEach((button, index) => {
|
||||
this.listen(button, 'keydown', (event) => {
|
||||
let next = null;
|
||||
if (event.key in RADIO_STEPS) {
|
||||
const count = buttons.length;
|
||||
next = buttons[(index + RADIO_STEPS[event.key] + count) % count];
|
||||
} else if (event.key === 'Home') next = buttons[0];
|
||||
else if (event.key === 'End') next = buttons[buttons.length - 1];
|
||||
if (!next) return;
|
||||
event.preventDefault();
|
||||
next.focus();
|
||||
if (next !== button) next.click();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refit the viewer once per frame. Skips a hidden (0×0) viewer, which would
|
||||
* ask for tiles at z=NaN; the observer fires again once it has a size.
|
||||
@@ -416,15 +399,6 @@ export class StreetLevelControls {
|
||||
setProp(el.errorText, 'textContent', view.error || '');
|
||||
}
|
||||
|
||||
_renderRadios(buttons, isChecked) {
|
||||
for (const button of buttons) {
|
||||
const checked = isChecked(button);
|
||||
button.classList.toggle('is-active', checked);
|
||||
setAttr(button, 'aria-checked', String(checked));
|
||||
setAttr(button, 'tabindex', checked ? '0' : '-1');
|
||||
}
|
||||
}
|
||||
|
||||
_renderFilters(view) {
|
||||
const el = this._elements;
|
||||
const shows = view.filterControls || { pano: true, since: true };
|
||||
@@ -434,10 +408,11 @@ export class StreetLevelControls {
|
||||
setProp(el.sinceNote, 'textContent', view.sinceNote || '');
|
||||
setProp(el.sinceNote, 'hidden', !view.sinceNote);
|
||||
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
|
||||
this._renderRadios(
|
||||
el.panoButtons,
|
||||
(button) => button.dataset.slPano === view.filter.pano,
|
||||
);
|
||||
syncSegmentedControl(el.panoButtons, view.filter.pano, {
|
||||
valueOf: (button) => button.dataset.slPano,
|
||||
activeClass: 'is-active',
|
||||
rovingFocus: true,
|
||||
});
|
||||
if (el.sinceRange) {
|
||||
// Never move the thumb under the user's hand; once the committed value
|
||||
// matches the thumb, show the full readout with its cut-off date.
|
||||
@@ -467,10 +442,11 @@ export class StreetLevelControls {
|
||||
setProp(wrap, 'hidden', !viewer.open);
|
||||
}
|
||||
setProp(el.viewerPlaceholder, 'hidden', !viewer.loading);
|
||||
this._renderRadios(
|
||||
el.renderButtons,
|
||||
(button) => button.dataset.slRender === viewer.renderMode,
|
||||
);
|
||||
syncSegmentedControl(el.renderButtons, viewer.renderMode, {
|
||||
valueOf: (button) => button.dataset.slRender,
|
||||
activeClass: 'is-active',
|
||||
rovingFocus: true,
|
||||
});
|
||||
// Google's viewer always fills its frame: no FIT/FILL to offer.
|
||||
const renderGroup = el.renderButtons[0]?.parentElement;
|
||||
if (renderGroup) setProp(renderGroup, 'hidden', !viewer.renderModes);
|
||||
|
||||
@@ -981,9 +981,9 @@ test('a note under SINCE says what date it compares, only while one is given', (
|
||||
const { layer } = stubPanel(dom, state);
|
||||
const note = () => dom.root.querySelector('#sl-since-note');
|
||||
assert.equal(note().hidden, true, 'none for Mapillary');
|
||||
layer.publish({ ...state, sinceNote: 'KartaView: by upload date' });
|
||||
layer.publish({ ...state, sinceNote: '(by upload date)' });
|
||||
assert.equal(note().hidden, false);
|
||||
assert.equal(note().textContent, 'KartaView: by upload date');
|
||||
assert.equal(note().textContent, '(by upload date)');
|
||||
layer.publish(state);
|
||||
assert.equal(note().hidden, true);
|
||||
// The panel's own markup has it, read out with the slider.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatNumber, t } from '../i18n/index.js';
|
||||
import { createSlider } from './slider.js';
|
||||
|
||||
/** A slider value in the UI language: 0.85 in English, 0,85 in Spanish. */
|
||||
const formatParam = (value, digits) =>
|
||||
@@ -41,14 +42,15 @@ export function createStyleParameters({
|
||||
const label = documentRef.createElement('span');
|
||||
label.className = 'param-label';
|
||||
label.textContent = t(metadata.label);
|
||||
const slider = documentRef.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.className = 'param-slider';
|
||||
slider.setAttribute('aria-label', t(metadata.label));
|
||||
slider.min = metadata.min;
|
||||
slider.max = metadata.max;
|
||||
slider.step = metadata.max <= 1 ? '0.01' : '0.1';
|
||||
slider.value = readValue(name);
|
||||
const slider = createSlider({
|
||||
documentRef,
|
||||
className: 'param-slider',
|
||||
ariaLabel: t(metadata.label),
|
||||
min: metadata.min,
|
||||
max: metadata.max,
|
||||
step: metadata.max <= 1 ? '0.01' : '0.1',
|
||||
value: readValue(name),
|
||||
});
|
||||
const digits = metadata.max <= 1 ? 2 : 1;
|
||||
const valueDisplay = documentRef.createElement('span');
|
||||
valueDisplay.className = 'param-value';
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/* Ordinary actions share one surface; feature classes own placement and width.
|
||||
* Legacy scene-btn hooks keep untouched toggles/Street Level controls stable. */
|
||||
.gev-action-button,
|
||||
.scene-btn {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 7px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 1.2px;
|
||||
padding: 6px 9px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
opacity var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gev-action-button:hover:not(:disabled),
|
||||
.scene-btn:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: rgba(0, 212, 255, 0.14);
|
||||
}
|
||||
|
||||
.gev-action-button:disabled,
|
||||
.scene-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.gev-action-button--danger:hover:not(:disabled),
|
||||
.scene-btn-danger:hover:not(:disabled) {
|
||||
color: #ff7272;
|
||||
border-color: #ff7272;
|
||||
background: rgba(255, 88, 88, 0.15);
|
||||
}
|
||||
|
||||
/* Shot-row actions retain their smaller footprint and quieter hover treatment. */
|
||||
.gev-action-button--compact {
|
||||
border-radius: 6px;
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.8px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.gev-action-button--compact:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--glass-border-hover);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.gev-action-button--compact.gev-action-button--danger:hover:not(:disabled) {
|
||||
color: #ff7e7e;
|
||||
border-color: #ff7e7e;
|
||||
}
|
||||
+22
-72
@@ -109,6 +109,10 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cctv-cal-input {
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
.cctv-cal-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
@@ -129,26 +133,6 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
}
|
||||
|
||||
.cctv-cal-input {
|
||||
width: 58px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.7px;
|
||||
color: var(--text-primary);
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border: 1px solid rgba(0, 212, 255, 0.5);
|
||||
border-radius: 4px;
|
||||
padding: 1px 3px;
|
||||
outline: none;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.cctv-cal-input::-webkit-outer-spin-button,
|
||||
.cctv-cal-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cctv-calibration-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -162,26 +146,9 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
||||
#cctv-camera-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 7px;
|
||||
color: var(--text-primary);
|
||||
padding: 6px 8px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#cctv-camera-select:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
#cctv-camera-select:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
#cctv-enable-btn.active,
|
||||
@@ -194,41 +161,32 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
||||
}
|
||||
|
||||
#cctv-quality-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 90px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 1px;
|
||||
color: #8fe7ff;
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
text-transform: uppercase;
|
||||
--status-badge-border: rgba(0, 212, 255, 0.2);
|
||||
--status-badge-color: #8fe7ff;
|
||||
--status-badge-background: rgba(0, 212, 255, 0.08);
|
||||
}
|
||||
|
||||
/* CAL badge states (cctv-v2 §3b, amended by LOCKED §9.2 — panel-only, no
|
||||
in-world tint). Three visually distinct states: calibrated / curated /
|
||||
raw-prior. */
|
||||
#cctv-quality-chip[data-cal-badge='calibrated'] {
|
||||
color: #adffbf;
|
||||
border-color: rgba(141, 255, 135, 0.42);
|
||||
background: rgba(141, 255, 135, 0.12);
|
||||
--status-badge-color: #adffbf;
|
||||
--status-badge-border: rgba(141, 255, 135, 0.42);
|
||||
--status-badge-background: rgba(141, 255, 135, 0.12);
|
||||
}
|
||||
|
||||
#cctv-quality-chip[data-cal-badge='curated'] {
|
||||
color: #ffe6a2;
|
||||
border-color: rgba(255, 217, 122, 0.35);
|
||||
background: rgba(255, 217, 122, 0.1);
|
||||
--status-badge-color: #ffe6a2;
|
||||
--status-badge-border: rgba(255, 217, 122, 0.35);
|
||||
--status-badge-background: rgba(255, 217, 122, 0.1);
|
||||
}
|
||||
|
||||
/* Unsaved live edits (save-gated persistence, viewshed design §3e). */
|
||||
#cctv-quality-chip[data-cal-badge='edited'] {
|
||||
color: #ffd08a;
|
||||
border-color: rgba(255, 176, 82, 0.5);
|
||||
background: rgba(255, 176, 82, 0.14);
|
||||
--status-badge-color: #ffd08a;
|
||||
--status-badge-border: rgba(255, 176, 82, 0.5);
|
||||
--status-badge-background: rgba(255, 176, 82, 0.14);
|
||||
}
|
||||
|
||||
.cctv-frame-wrap {
|
||||
@@ -346,26 +304,18 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
||||
|
||||
#cctv-source-badge {
|
||||
align-self: flex-start;
|
||||
border: 1px solid rgba(0, 212, 255, 0.32);
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 212, 255, 0.09);
|
||||
color: #9defff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.3px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
#cctv-source-badge[data-frame-state='loading'] {
|
||||
color: #ffe59b;
|
||||
border-color: rgba(255, 205, 92, 0.45);
|
||||
background: rgba(255, 205, 92, 0.1);
|
||||
--status-badge-color: #ffe59b;
|
||||
--status-badge-border: rgba(255, 205, 92, 0.45);
|
||||
--status-badge-background: rgba(255, 205, 92, 0.1);
|
||||
}
|
||||
|
||||
#cctv-source-badge[data-frame-state='error'] {
|
||||
color: #ffb0b0;
|
||||
border-color: rgba(255, 102, 102, 0.5);
|
||||
background: rgba(255, 72, 72, 0.11);
|
||||
--status-badge-color: #ffb0b0;
|
||||
--status-badge-border: rgba(255, 102, 102, 0.5);
|
||||
--status-badge-background: rgba(255, 72, 72, 0.11);
|
||||
}
|
||||
|
||||
.cctv-summary-label {
|
||||
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user