mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge branch 'main' into codex/fix-initial-forecast-supersession
This commit is contained in:
124 files changed
+18538
-595
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
|
||||
|
||||
@@ -177,6 +177,16 @@ Publishing it wider is the [Sharing an instance](#-sharing-an-instance) opt-in,
|
||||
|
||||
When you widen the publish, also set `GEV_KEY_SETUP_TRUSTED_PEERS=none` so the launcher trusts no gateway address.
|
||||
|
||||
```bash
|
||||
GEV_KEY_SETUP_TRUSTED_PEERS=none docker compose up
|
||||
```
|
||||
|
||||
Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from your
|
||||
shell or the project's `.env`; an exported shell value takes precedence. You can
|
||||
also put `GEV_KEY_SETUP_TRUSTED_PEERS=none` in `.env` for a persistent setting.
|
||||
Unset or blank trusted peers retain automatic gateway discovery; `none` explicitly
|
||||
disables it. The startup log confirms that no additional peers are trusted.
|
||||
|
||||
<details>
|
||||
<summary>Running on Linux, a NAS, or behind a reverse proxy</summary>
|
||||
|
||||
|
||||
+8
-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.
|
||||
@@ -98,6 +100,11 @@ The dev server is a **key broker**: every server-side key above is spendable by
|
||||
`compose.yaml` publishes on `127.0.0.1`: **widen that mapping and every LAN
|
||||
client arrives from the same gateway address, which hands them Provider
|
||||
Settings too.** Treat publishing beyond loopback as the LAN opt-in above.
|
||||
Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from
|
||||
the shell or project `.env` before the launcher runs. Set trusted peers to
|
||||
`none` when widening the publish: it becomes an explicit empty list before
|
||||
Vite loads configuration, so the gateway cannot regain Provider Settings
|
||||
access. Unset or blank values preserve automatic gateway discovery.
|
||||
- **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider.
|
||||
- **Pinokio LAN and Cloudflare sharing are refused.** The current supported
|
||||
Pinokio release re-reads sharing state when an app registers its Open URL and
|
||||
|
||||
+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,3 @@
|
||||
- Honor Docker Compose trusted-peer and allowed-host settings from the shell
|
||||
or project `.env`, and let `GEV_KEY_SETUP_TRUSTED_PEERS=none` disable gateway
|
||||
access to Provider Settings before Vite starts (asturksever, #1265).
|
||||
@@ -0,0 +1 @@
|
||||
- Coalesce refreshes and cool down opensky failures (asturksever, #1269).
|
||||
@@ -0,0 +1 @@
|
||||
- Reject inherited style keys during restore (asturksever, #1271).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Add Simplified Chinese (zh-CN) to the language picker, with localized dates
|
||||
and readable Chinese labels. Based on translations by @abiyeenzo (#914)
|
||||
and @EDIT-Aeterna (#979).
|
||||
(German, @gekh, #1298).
|
||||
@@ -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).
|
||||
@@ -5,6 +5,11 @@ services:
|
||||
# lands in the bind-mounted repo, so run as you, not root. UID/GID are
|
||||
# unset on Docker Desktop, where the 1000 fallback is ignored anyway.
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
# Forward explicit shell/.env policy before the launcher discovers a gateway.
|
||||
# Unset values stay unset so the default loopback-only install still works.
|
||||
environment:
|
||||
- GEV_KEY_SETUP_TRUSTED_PEERS
|
||||
- GEV_ALLOWED_HOSTS
|
||||
ports:
|
||||
# Publish to loopback only. Docker's default ("4173:4173") would bind
|
||||
# every host interface, which quietly turns the LAN warning in the README
|
||||
|
||||
@@ -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.
|
||||
+126
-8
@@ -32,6 +32,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
|
||||
@@ -815,6 +825,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.
|
||||
|
||||
@@ -1182,6 +1201,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
|
||||
@@ -1236,6 +1273,58 @@ 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`
|
||||
@@ -2802,11 +2891,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
|
||||
@@ -3447,7 +3541,7 @@ test:track` 43 tracking invariants · headless QA harnesses under
|
||||
|
||||
### Interface localization (October 2026)
|
||||
|
||||
Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives in
|
||||
Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The code lives in
|
||||
`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow:
|
||||
[`docs/TRANSLATORS.md`](./TRANSLATORS.md).
|
||||
|
||||
@@ -3458,7 +3552,8 @@ Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives
|
||||
value). English has no catalog; every other locale is
|
||||
`src/i18n/locales/<code>.json`, and a missing entry falls back to English.
|
||||
- **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) →
|
||||
`navigator.languages` → English. `?lang=` is not stored, and share links drop
|
||||
`navigator.languages` → English. Chinese tags map to `zh-CN`; tags containing
|
||||
`Hant`, `TW`, `HK` or `MO` are skipped. `?lang=` is not stored, and share links drop
|
||||
it, so a recipient keeps their own language. The globe picker in the
|
||||
bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops
|
||||
`?lang=` (keeps it only when storage is blocked) and reloads. It asks first
|
||||
@@ -4622,6 +4717,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
|
||||
@@ -5070,7 +5171,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)
|
||||
|
||||
@@ -5941,3 +6042,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.
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Translating God's Eye View
|
||||
|
||||
Shipped languages: **English** (source and fallback), **Spanish** (`es`) and **Russian** (`ru`).
|
||||
Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`) and **Simplified Chinese** (`zh-CN`).
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -63,4 +63,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth
|
||||
|
||||
## Language notes
|
||||
|
||||
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md). Read it before translating, and add a term there when you settle on one.
|
||||
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md), [Simplified Chinese](./translations/zh-CN.md). Read it before translating, and add a term there when you settle on one.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Simplified Chinese (`zh-CN`)
|
||||
|
||||
Catalog: `src/i18n/locales/zh-CN.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
|
||||
|
||||
Translation sources: abiyeenzo (#914) and EDIT-Aeterna (#979).
|
||||
|
||||
## Style
|
||||
|
||||
- Mainland usage and Simplified characters. Address the user neutrally, without 您/你 where a plain imperative works: 「点击地图」, 「重试」.
|
||||
- Chinese has no case: an UPPERCASE English HUD label becomes the same short Chinese label. Keep labels as short as the English: 「实时」, 「过期」, 「关」.
|
||||
- Full-width punctuation in Chinese text: ,。:;?!()“”. A dash is 「——」 without spaces; keep `·` as the HUD separator and `…` for ellipsis.
|
||||
- One space between Chinese and Latin letters or digits: 「AIS 船舶」, 「24 小时」, 「{count} 个」. No space next to full-width punctuation.
|
||||
- Units: km, m, km/h, m/s, kn, ft, MW, °, % stay as written; time words are 小时, 分钟, 秒 after a number (「5 分钟前」).
|
||||
- Product names, provider names and codes stay as they are: Google, Cesium, CelesTrak, NOAA, FIRMS, AIS, ADS-B, GNSS, CCTV, HUD, CRT, NVG, FLIR, WHOT/BHOT, Ironbow, CONUS.
|
||||
|
||||
## Plurals
|
||||
|
||||
`Intl.PluralRules('zh-CN')` has one category, `other`: a `tn()` entry has only that form. Use a measure word next to the number where Chinese needs one: 「{count} 个密钥」, 「{count} 架飞机」, 「{count} 艘船」.
|
||||
|
||||
## Glossary
|
||||
|
||||
| English | 中文 |
|
||||
| ------------------------------------- | --------------------------- |
|
||||
| layer / data layer | 图层 / 数据图层 |
|
||||
| feed | 数据源 |
|
||||
| source / data source | 来源 / 数据源 |
|
||||
| LIVE (status) / live data | 实时 / 实时数据 |
|
||||
| SIMULATED | 模拟 |
|
||||
| stale / degraded / unavailable | 过期 / 降级 / 不可用 |
|
||||
| retry | 重试 |
|
||||
| key (API key) / POWER UP | 密钥(API 密钥)/ 增强 |
|
||||
| Provider Settings | 服务商设置 |
|
||||
| globe / map / map source / basemap | 地球 / 地图 / 地图源 / 底图 |
|
||||
| aircraft / flight / military flights | 飞机 / 航班 / 军用航班 |
|
||||
| vessel, ship | 船舶 |
|
||||
| satellite | 卫星 |
|
||||
| launch (rocket) / space mission | 发射 / 太空任务 |
|
||||
| contact (tracked object) | 目标 |
|
||||
| installation, site | 设施 |
|
||||
| camera / CCTV camera | 摄像头 / CCTV 摄像头 |
|
||||
| earthquake / active fire | 地震 / 活跃火点 |
|
||||
| scene / shot | 场景 / 镜头 |
|
||||
| briefing / panel / dock | 简报 / 面板 / 停靠栏 |
|
||||
| preset / visual preset | 预设 / 视觉预设 |
|
||||
| Display / HUD layout | 显示 / HUD 布局 |
|
||||
| Tactical / Operator / Minimal / Cyber | 战术 / 操作员 / 精简 / 赛博 |
|
||||
| Scope (circular viewport mask) / keyhole | 视域 |
|
||||
| Clean UI | 纯净界面 |
|
||||
| cockpit | 座舱 |
|
||||
| heading / altitude / ground speed | 航向 / 高度 / 地速 |
|
||||
| map heading / North up | 方位 / 正北朝上 |
|
||||
| Street Level / Street View (Google) | 街景 / 街景(Google) |
|
||||
| Recent Imagery | 近期影像 |
|
||||
| Global Context / Context (panel) | 全局态势 / 态势 |
|
||||
| radio (station) | 电台 |
|
||||
| transit / bikeshare | 公共交通 / 共享单车 |
|
||||
| Previous / Next | 上一个 / 下一个 |
|
||||
| compass points (N, NNE, …) | 北, 北东北, … |
|
||||
| weather field / FIELD (selector) | 气象场 / 要素 |
|
||||
| weather model / forecast lead hours | 模式 / 预报时效 |
|
||||
| valid (forecast time) | 有效时间 |
|
||||
| scene cloud / mosaic | 整景云量 / 镶嵌图 |
|
||||
| pin (Recent Imagery) | 固定 |
|
||||
| THIS (voice pointer) | 所指 |
|
||||
| outline / trace an outline | 轮廓 / 绘制轮廓 |
|
||||
| mark (map annotation) | 标注 |
|
||||
| camera badge | 摄像头标记 |
|
||||
| provider / embedded provider controls | 服务商 / 平台控件 |
|
||||
| share (a view or link) | 分享 |
|
||||
| AI agent | AI 智能体 |
|
||||
| stage (rocket) / downrange | 子级 / 航程 |
|
||||
| capture shot / camera move | 保存镜头 / 运镜 |
|
||||
| story beat / evidence beat | 事件节点 / 证据节点 |
|
||||
| camera pose | 位姿 |
|
||||
| forecast uncertainty cone | 预报不确定范围 |
|
||||
| seed (catalog data) | 预置 |
|
||||
| frame (bring into view) | 将…纳入视野 |
|
||||
@@ -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();
|
||||
@@ -48,7 +48,8 @@ export function defaultGatewayFromRouteTable(text) {
|
||||
|
||||
/**
|
||||
* Decide this launch's trusted-peer list. An operator value always wins, so a
|
||||
* compose override stays authoritative; otherwise the gateway alone is
|
||||
* compose override stays authoritative; `none` trusts no additional peers.
|
||||
* An unset or blank value means the gateway alone is
|
||||
* trusted. No gateway trusts nothing and Provider Settings stays loopback-only,
|
||||
* exactly as under `npm run dev`.
|
||||
* @param {{env?: NodeJS.ProcessEnv, routeTable?: string}} input
|
||||
@@ -56,6 +57,7 @@ export function defaultGatewayFromRouteTable(text) {
|
||||
*/
|
||||
export function resolveTrustedPeers({ env = process.env, routeTable = '' } = {}) {
|
||||
const explicit = String(env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim();
|
||||
if (explicit.toLowerCase() === 'none') return '';
|
||||
if (explicit) return explicit;
|
||||
return defaultGatewayFromRouteTable(routeTable) ?? '';
|
||||
}
|
||||
@@ -109,15 +111,17 @@ async function start() {
|
||||
String(process.env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(),
|
||||
);
|
||||
const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() });
|
||||
// Set even an empty list before Vite loads .env: the resolved launcher policy
|
||||
// must remain authoritative when there is no trusted gateway.
|
||||
process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers;
|
||||
if (trustedPeers) {
|
||||
// Set before Vite is imported: the dev server snapshots process.env during
|
||||
// configuration, and the admission gate reads it per request.
|
||||
process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers;
|
||||
console.log(
|
||||
fromOperator
|
||||
? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).`
|
||||
: `[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`,
|
||||
);
|
||||
} else if (fromOperator) {
|
||||
console.log('[Docker] Provider Settings trusts no additional peers (GEV_KEY_SETUP_TRUSTED_PEERS=none).');
|
||||
} else {
|
||||
console.log('[Docker] No default gateway; Provider Settings stays loopback-only.');
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -281,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",
|
||||
@@ -305,6 +309,7 @@
|
||||
"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",
|
||||
@@ -1068,5 +1073,20 @@
|
||||
"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",
|
||||
"src/dockerTrustedPeers.test.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",
|
||||
@@ -426,7 +437,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -451,7 +463,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -467,7 +480,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -496,9 +510,11 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/ui/keyGuidance.js",
|
||||
"src/ui/statusText.js"
|
||||
"src/ui/statusText.js",
|
||||
"src/ui/statusBadge.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -514,7 +530,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -529,7 +546,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -539,6 +557,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",
|
||||
@@ -547,7 +566,9 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js"
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/ui/statusBadge.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -571,6 +592,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/layerNames.js"
|
||||
],
|
||||
"external": []
|
||||
@@ -604,6 +626,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -614,10 +637,12 @@
|
||||
"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",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": []
|
||||
@@ -675,6 +700,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",
|
||||
@@ -737,6 +763,7 @@
|
||||
"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",
|
||||
@@ -768,10 +795,12 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"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"]
|
||||
},
|
||||
@@ -849,6 +878,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",
|
||||
@@ -885,6 +915,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -950,6 +981,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -995,7 +1027,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1055,7 +1088,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1084,6 +1118,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/layers/recentImagery/display.js"
|
||||
],
|
||||
@@ -1112,7 +1147,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1143,6 +1179,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium", "satellite.js"]
|
||||
@@ -1174,7 +1211,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1216,6 +1254,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/layers/installations/classLabel.js"
|
||||
],
|
||||
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
|
||||
@@ -1250,6 +1289,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -1298,7 +1338,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium", "pbf", "@mapbox/vector-tile"]
|
||||
},
|
||||
@@ -1326,7 +1367,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1373,7 +1415,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["hls.js", "cesium"]
|
||||
},
|
||||
@@ -1434,6 +1477,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js"
|
||||
],
|
||||
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
||||
@@ -1461,7 +1505,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
@@ -1500,7 +1545,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1521,7 +1567,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1576,7 +1623,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1632,6 +1680,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/statusText.js",
|
||||
"src/voice/providerCommands.js",
|
||||
"src/voice/providerCapabilities.js",
|
||||
@@ -1720,6 +1769,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",
|
||||
@@ -2151,6 +2202,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",
|
||||
@@ -2214,6 +2266,7 @@
|
||||
"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",
|
||||
@@ -2489,6 +2542,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/layers/recentImagery/display.js",
|
||||
"src/ui/keyGuidance.js",
|
||||
@@ -2506,7 +2560,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",
|
||||
@@ -3063,6 +3118,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/layers/recentImagery/display.js",
|
||||
"src/layers/installations/classLabel.js",
|
||||
@@ -3169,6 +3225,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/index.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -3189,7 +3246,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -3223,7 +3281,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -3246,7 +3305,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -3337,6 +3397,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -3391,6 +3452,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/statusText.js",
|
||||
"src/voice/providerCapabilities.js"
|
||||
],
|
||||
@@ -3643,7 +3705,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium", "egm96-universal", "pbf"]
|
||||
},
|
||||
@@ -3685,7 +3748,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium", "pbf"]
|
||||
},
|
||||
@@ -3737,7 +3801,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -3753,6 +3818,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/statusText.js",
|
||||
"src/ui/layerNames.js"
|
||||
],
|
||||
@@ -3945,6 +4011,7 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/statusText.js",
|
||||
"src/ui/layerNames.js"
|
||||
],
|
||||
|
||||
@@ -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,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;
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { readResponseTextCapped } from '../common/http.js';
|
||||
import {
|
||||
coalesceProxyRequest,
|
||||
readResponseTextCapped,
|
||||
} from '../common/http.js';
|
||||
|
||||
/**
|
||||
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
|
||||
@@ -19,6 +22,7 @@ import { readResponseTextCapped } from '../common/http.js';
|
||||
export function adsbLolProxy() {
|
||||
/** @type {string|null} Cached upstream JSON body. */
|
||||
let _cache = null;
|
||||
const inFlight = new Map();
|
||||
/** @type {number} Epoch-ms when the cache was populated. */
|
||||
let _cacheAt = 0;
|
||||
/** @type {number} Epoch-ms until which upstream is not contacted. */
|
||||
@@ -104,60 +108,73 @@ export function adsbLolProxy() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS);
|
||||
const upstream = await fetch('https://api.adsb.lol/v2/mil', {
|
||||
headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' },
|
||||
signal,
|
||||
});
|
||||
if (upstream.ok) {
|
||||
const refresh = coalesceProxyRequest(inFlight, 'global', async () => {
|
||||
const result = (status, body, cacheStatus, extra = {}) => ({
|
||||
status,
|
||||
body,
|
||||
cacheStatus,
|
||||
extra,
|
||||
});
|
||||
const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS);
|
||||
const upstream = await fetch('https://api.adsb.lol/v2/mil', {
|
||||
headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' },
|
||||
signal,
|
||||
});
|
||||
if (upstream.ok) {
|
||||
const body = await readResponseTextCapped(
|
||||
upstream,
|
||||
ADSBLOL_MIL_MAX_RESPONSE_BYTES,
|
||||
signal,
|
||||
);
|
||||
_cache = body;
|
||||
_cacheAt = Date.now();
|
||||
_cooldownUntil = 0;
|
||||
_cooldownStatus = 0;
|
||||
return result(200, body, 'MISS');
|
||||
}
|
||||
if (upstream.status === 429 || upstream.status >= 500) {
|
||||
const failedAt = Date.now();
|
||||
_cooldownUntil = failedAt + cooldownFor(upstream, failedAt);
|
||||
_cooldownStatus = upstream.status;
|
||||
console.warn(
|
||||
`[adsb.lol Proxy] upstream ${upstream.status}; cooling down ${Math.round((_cooldownUntil - Date.now()) / 1000)} s${_cache ? ', serving stale' : ''}`,
|
||||
);
|
||||
if (_cache) {
|
||||
// Do not wait for a failing response body before serving usable data.
|
||||
upstream.body?.cancel().catch(() => {});
|
||||
return result(200, _cache, 'STALE', {
|
||||
'X-ADS-B-Upstream-Status': String(upstream.status),
|
||||
'X-ADS-B-Retry-After-Seconds': String(
|
||||
Math.ceil((_cooldownUntil - Date.now()) / 1000),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
const body = await readResponseTextCapped(
|
||||
upstream,
|
||||
ADSBLOL_MIL_MAX_RESPONSE_BYTES,
|
||||
signal,
|
||||
);
|
||||
_cache = body;
|
||||
_cacheAt = Date.now();
|
||||
_cooldownUntil = 0;
|
||||
_cooldownStatus = 0;
|
||||
serve(res, 200, body, 'MISS');
|
||||
return;
|
||||
}
|
||||
if (upstream.status === 429 || upstream.status >= 500) {
|
||||
const failedAt = Date.now();
|
||||
_cooldownUntil = failedAt + cooldownFor(upstream, failedAt);
|
||||
_cooldownStatus = upstream.status;
|
||||
console.warn(
|
||||
`[adsb.lol Proxy] upstream ${upstream.status}; cooling down ${Math.round((_cooldownUntil - Date.now()) / 1000)} s${_cache ? ', serving stale' : ''}`,
|
||||
return result(
|
||||
upstream.status,
|
||||
body,
|
||||
'MISS',
|
||||
_cooldownUntil > Date.now()
|
||||
? {
|
||||
'Retry-After': String(
|
||||
Math.ceil((_cooldownUntil - Date.now()) / 1000),
|
||||
),
|
||||
}
|
||||
: {},
|
||||
);
|
||||
if (_cache) {
|
||||
// Do not wait for a failing response body before serving usable data.
|
||||
upstream.body?.cancel().catch(() => {});
|
||||
serve(res, 200, _cache, 'STALE', {
|
||||
'X-ADS-B-Upstream-Status': String(upstream.status),
|
||||
'X-ADS-B-Retry-After-Seconds': String(
|
||||
Math.ceil((_cooldownUntil - Date.now()) / 1000),
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const body = await readResponseTextCapped(
|
||||
upstream,
|
||||
ADSBLOL_MIL_MAX_RESPONSE_BYTES,
|
||||
signal,
|
||||
);
|
||||
});
|
||||
const response = await refresh.promise;
|
||||
serve(
|
||||
res,
|
||||
upstream.status,
|
||||
body,
|
||||
'MISS',
|
||||
_cooldownUntil > Date.now()
|
||||
? {
|
||||
'Retry-After': String(
|
||||
Math.ceil((_cooldownUntil - Date.now()) / 1000),
|
||||
),
|
||||
}
|
||||
: {},
|
||||
response.status,
|
||||
response.body,
|
||||
response.cacheStatus,
|
||||
response.extra,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[adsb.lol Proxy]', e.message);
|
||||
|
||||
@@ -67,15 +67,24 @@ async function fetchOpenSkyStates(headers) {
|
||||
try {
|
||||
const signal = AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS);
|
||||
const upstream = await fetch(OPENSKY_STATES_URL, { headers, signal });
|
||||
if (upstream.status === 429) {
|
||||
if (upstream.status === 429 || upstream.status >= 500) {
|
||||
// Start the cooldown from the headers and drop the rejected body
|
||||
// unread, so a stalled or oversized 429 body cannot skip it.
|
||||
_openskyCooldownUntil =
|
||||
Date.now() + openSkyRateLimitCooldownMs(upstream);
|
||||
Date.now() +
|
||||
(upstream.status === 429
|
||||
? openSkyRateLimitCooldownMs(upstream)
|
||||
: 15000);
|
||||
_openskyCooldownStatus = upstream.status;
|
||||
void upstream.body?.cancel().catch(() => {});
|
||||
return {
|
||||
upstream,
|
||||
body: JSON.stringify({ error: 'OpenSky rate limited.' }),
|
||||
body: JSON.stringify({
|
||||
error:
|
||||
upstream.status === 429
|
||||
? 'OpenSky rate limited.'
|
||||
: 'OpenSky upstream unavailable.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
const body = await readResponseTextCapped(
|
||||
@@ -109,6 +118,8 @@ async function fetchOpenSkyStates(headers) {
|
||||
let _openskyTtlMs = OPENSKY_CACHE_MS;
|
||||
/** @type {number} Epoch-ms before which no upstream fetch is attempted. */
|
||||
let _openskyCooldownUntil = 0;
|
||||
let _openskyCooldownStatus = 429;
|
||||
const _openskySnapshotInFlight = new Map();
|
||||
/**
|
||||
* Picks the cache TTL from the remaining daily credit budget.
|
||||
* Client polls every 30 s, so tiers ≤30 s cost the same 480 credits/h; the
|
||||
@@ -460,7 +471,9 @@ export function openSkyProxy() {
|
||||
requestedMode: cachedMeta.requestedMode || requestedMode,
|
||||
usedMode: cachedMeta.usedMode || 'unknown',
|
||||
reason: isStale
|
||||
? 'rate_limited_serving_stale'
|
||||
? _openskyCooldownStatus === 429
|
||||
? 'rate_limited_serving_stale'
|
||||
: `upstream_http_${_openskyCooldownStatus}_serving_stale`
|
||||
: cachedMeta.reason || 'cached',
|
||||
staleSeconds: isStale
|
||||
? (now - _openskyCacheTime) / 1000
|
||||
@@ -487,80 +500,140 @@ export function openSkyProxy() {
|
||||
)
|
||||
return;
|
||||
res.writeHead(
|
||||
429,
|
||||
_openskyCooldownStatus,
|
||||
buildOpenSkyHeaders({
|
||||
cacheStatus: 'COOLDOWN',
|
||||
requestedMode,
|
||||
usedMode: 'none',
|
||||
reason: 'rate_limited',
|
||||
reason:
|
||||
_openskyCooldownStatus === 429
|
||||
? 'rate_limited'
|
||||
: `upstream_http_${_openskyCooldownStatus}`,
|
||||
retryAfterSeconds: (_openskyCooldownUntil - now) / 1000,
|
||||
}),
|
||||
);
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'OpenSky rate limited; proxy cooling down.',
|
||||
error: 'OpenSky upstream unavailable; proxy cooling down.',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const basicUser = process.env.OPENSKY_USERNAME || '';
|
||||
const basicPass = process.env.OPENSKY_PASSWORD || '';
|
||||
const hasBasicCreds = Boolean(basicUser && basicPass);
|
||||
const headers = { Accept: 'application/json' };
|
||||
let usedMode = 'anon';
|
||||
let reason = 'forced_anonymous';
|
||||
const refresh = coalesceProxyRequest(
|
||||
_openskySnapshotInFlight,
|
||||
'global',
|
||||
async () => {
|
||||
const basicUser = process.env.OPENSKY_USERNAME || '';
|
||||
const basicPass = process.env.OPENSKY_PASSWORD || '';
|
||||
const hasBasicCreds = Boolean(basicUser && basicPass);
|
||||
const headers = { Accept: 'application/json' };
|
||||
let usedMode = 'anon';
|
||||
let reason = 'forced_anonymous';
|
||||
|
||||
if (requestedMode === 'basic') {
|
||||
if (hasBasicCreds) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
|
||||
usedMode = 'basic';
|
||||
reason = 'basic_credentials';
|
||||
} else {
|
||||
reason = 'missing_basic_creds';
|
||||
}
|
||||
} else if (requestedMode === 'oauth') {
|
||||
const token = await getOpenSkyToken();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
usedMode = 'oauth';
|
||||
reason = 'oauth_token';
|
||||
} else {
|
||||
reason = 'oauth_invalid_or_missing';
|
||||
}
|
||||
} else if (requestedMode === 'auto') {
|
||||
const token = await getOpenSkyToken();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
usedMode = 'oauth';
|
||||
reason = 'oauth_token';
|
||||
} else if (hasBasicCreds) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
|
||||
usedMode = 'basic';
|
||||
reason = 'oauth_unavailable_fallback_basic';
|
||||
} else {
|
||||
reason = 'missing_oauth_and_basic_creds';
|
||||
}
|
||||
}
|
||||
if (requestedMode === 'basic') {
|
||||
if (hasBasicCreds) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
|
||||
usedMode = 'basic';
|
||||
reason = 'basic_credentials';
|
||||
} else {
|
||||
reason = 'missing_basic_creds';
|
||||
}
|
||||
} else if (requestedMode === 'oauth') {
|
||||
const token = await getOpenSkyToken();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
usedMode = 'oauth';
|
||||
reason = 'oauth_token';
|
||||
} else {
|
||||
reason = 'oauth_invalid_or_missing';
|
||||
}
|
||||
} else if (requestedMode === 'auto') {
|
||||
const token = await getOpenSkyToken();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
usedMode = 'oauth';
|
||||
reason = 'oauth_token';
|
||||
} else if (hasBasicCreds) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
|
||||
usedMode = 'basic';
|
||||
reason = 'oauth_unavailable_fallback_basic';
|
||||
} else {
|
||||
reason = 'missing_oauth_and_basic_creds';
|
||||
}
|
||||
}
|
||||
|
||||
let { upstream, body } = await fetchOpenSkyStates(headers);
|
||||
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
|
||||
if (
|
||||
(upstream.status === 401 || upstream.status === 403) &&
|
||||
requestedMode === 'auto' &&
|
||||
usedMode === 'oauth' &&
|
||||
hasBasicCreds
|
||||
) {
|
||||
const retryHeaders = {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
|
||||
};
|
||||
({ upstream, body } = await fetchOpenSkyStates(retryHeaders));
|
||||
usedMode = 'basic';
|
||||
reason = 'oauth_rejected_fallback_basic';
|
||||
}
|
||||
let { upstream, body } = await fetchOpenSkyStates(headers);
|
||||
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
|
||||
if (
|
||||
(upstream.status === 401 || upstream.status === 403) &&
|
||||
requestedMode === 'auto' &&
|
||||
usedMode === 'oauth' &&
|
||||
hasBasicCreds
|
||||
) {
|
||||
const retryHeaders = {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
|
||||
};
|
||||
({ upstream, body } = await fetchOpenSkyStates(retryHeaders));
|
||||
usedMode = 'basic';
|
||||
reason = 'oauth_rejected_fallback_basic';
|
||||
}
|
||||
|
||||
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
|
||||
const sourceEpochMs = upstream.ok
|
||||
? openSkySourceEpochMs(body)
|
||||
: null;
|
||||
// Refine the reason string to reflect the actual outcome
|
||||
if (upstream.ok && reason === 'forced_anonymous') {
|
||||
reason = 'anonymous_ok';
|
||||
} else if (
|
||||
upstream.ok &&
|
||||
usedMode === 'basic' &&
|
||||
reason === 'basic_credentials'
|
||||
) {
|
||||
reason = 'basic_ok';
|
||||
} else if (
|
||||
upstream.ok &&
|
||||
usedMode === 'oauth' &&
|
||||
reason === 'oauth_token'
|
||||
) {
|
||||
reason = 'oauth_ok';
|
||||
}
|
||||
|
||||
// Only cache successful responses — error responses (401/403/429/5xx)
|
||||
// should not be served from cache on subsequent requests
|
||||
if (upstream.ok) {
|
||||
_openskyCacheBody = body;
|
||||
_openskyCacheStatus = upstream.status;
|
||||
_openskyCacheTime = now;
|
||||
_openskyCacheSourceEpochMs = sourceEpochMs;
|
||||
_openskyCacheMeta = {
|
||||
requestedMode,
|
||||
usedMode,
|
||||
reason,
|
||||
};
|
||||
// Credit governor: adapt the cache TTL to the remaining daily
|
||||
// budget so a continuously-open app stretches its polls instead of
|
||||
// exhausting the quota mid-day. Success also clears any cooldown.
|
||||
const remaining = Number(
|
||||
upstream.headers.get('x-rate-limit-remaining'),
|
||||
);
|
||||
_openskyTtlMs = openskyAdaptiveTtlMs(remaining);
|
||||
_openskyCooldownUntil = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
upstream,
|
||||
body,
|
||||
usedMode,
|
||||
reason,
|
||||
hasBasicCreds,
|
||||
sourceEpochMs,
|
||||
};
|
||||
},
|
||||
);
|
||||
let { upstream, body, usedMode, reason, hasBasicCreds, sourceEpochMs } =
|
||||
await refresh.promise;
|
||||
if (
|
||||
upstream.ok &&
|
||||
openSkySourceIsStale(sourceEpochMs, now) &&
|
||||
@@ -571,20 +644,16 @@ export function openSkyProxy() {
|
||||
'opensky_snapshot_stale_regional_fallback',
|
||||
))
|
||||
) {
|
||||
// Keep the last global snapshot available as a fail-soft cache,
|
||||
// but do not label or render it as a fresh live result.
|
||||
_openskyCacheBody = body;
|
||||
_openskyCacheStatus = upstream.status;
|
||||
_openskyCacheTime = now;
|
||||
_openskyCacheSourceEpochMs = sourceEpochMs;
|
||||
_openskyCacheMeta = { requestedMode, usedMode, reason };
|
||||
return;
|
||||
}
|
||||
if (upstream.status === 429) {
|
||||
reason = 'rate_limited';
|
||||
if (upstream.status === 429 || upstream.status >= 500) {
|
||||
reason =
|
||||
upstream.status === 429
|
||||
? 'rate_limited'
|
||||
: `upstream_http_${upstream.status}`;
|
||||
// Credit governor: fetchOpenSkyStates already started the cooldown
|
||||
// from OpenSky's retry-after — no upstream attempts until then.
|
||||
const cooldownMs = openSkyRateLimitCooldownMs(upstream);
|
||||
const cooldownMs = Math.max(0, _openskyCooldownUntil - Date.now());
|
||||
// Serve the last-good body instead of the 429 when we have one —
|
||||
// the layer keeps rendering (STALE-cued) instead of dying.
|
||||
if (_openskyCacheBody && _openskyCacheStatus === 200) {
|
||||
@@ -594,7 +663,7 @@ export function openSkyProxy() {
|
||||
cacheStatus: 'STALE',
|
||||
requestedMode,
|
||||
usedMode,
|
||||
reason: 'rate_limited_serving_stale',
|
||||
reason: `${reason}_serving_stale`,
|
||||
staleSeconds: (now - _openskyCacheTime) / 1000,
|
||||
retryAfterSeconds: cooldownMs / 1000,
|
||||
}),
|
||||
@@ -652,45 +721,6 @@ export function openSkyProxy() {
|
||||
}
|
||||
}
|
||||
|
||||
// Refine the reason string to reflect the actual outcome
|
||||
if (upstream.ok && reason === 'forced_anonymous') {
|
||||
reason = 'anonymous_ok';
|
||||
} else if (
|
||||
upstream.ok &&
|
||||
usedMode === 'basic' &&
|
||||
reason === 'basic_credentials'
|
||||
) {
|
||||
reason = 'basic_ok';
|
||||
} else if (
|
||||
upstream.ok &&
|
||||
usedMode === 'oauth' &&
|
||||
reason === 'oauth_token'
|
||||
) {
|
||||
reason = 'oauth_ok';
|
||||
}
|
||||
|
||||
// Only cache successful responses — error responses (401/403/429/5xx)
|
||||
// should not be served from cache on subsequent requests
|
||||
if (upstream.ok) {
|
||||
_openskyCacheBody = body;
|
||||
_openskyCacheStatus = upstream.status;
|
||||
_openskyCacheTime = now;
|
||||
_openskyCacheSourceEpochMs = sourceEpochMs;
|
||||
_openskyCacheMeta = {
|
||||
requestedMode,
|
||||
usedMode,
|
||||
reason,
|
||||
};
|
||||
// Credit governor: adapt the cache TTL to the remaining daily
|
||||
// budget so a continuously-open app stretches its polls instead of
|
||||
// exhausting the quota mid-day. Success also clears any cooldown.
|
||||
const remaining = Number(
|
||||
upstream.headers.get('x-rate-limit-remaining'),
|
||||
);
|
||||
_openskyTtlMs = openskyAdaptiveTtlMs(remaining);
|
||||
_openskyCooldownUntil = 0;
|
||||
}
|
||||
|
||||
res.writeHead(
|
||||
upstream.status,
|
||||
buildOpenSkyHeaders({
|
||||
|
||||
+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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -412,14 +412,18 @@ const IMAGERY_LABEL_NAMES = Object.freeze([
|
||||
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
|
||||
]);
|
||||
|
||||
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
|
||||
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021, 2021年10月16日. */
|
||||
function imageryDateText(observedAt) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
|
||||
if (!match) return '';
|
||||
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
|
||||
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase();
|
||||
const year = part({ year: 'numeric' });
|
||||
// Year-first languages (2021年10月16日) keep their own order and units.
|
||||
const whole = part({ day: 'numeric', month: 'short', year: 'numeric' });
|
||||
if (whole.startsWith(year)) return whole;
|
||||
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${year}`.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1029,7 +1033,7 @@ function createPanel(event, handlers) {
|
||||
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
|
||||
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
|
||||
</div>
|
||||
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> ${escapeMarkup(t('Clouds are preserved from the source imagery.'))}</span></p>
|
||||
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note">${escapeMarkup(t(' Clouds are preserved from the source imagery.'))}</span></p>
|
||||
`;
|
||||
|
||||
const refs = collectPanelReferences(panel);
|
||||
|
||||
@@ -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 |
@@ -72,6 +72,7 @@ test('the Docker install path keeps its loopback-only, launcher-owned contract',
|
||||
|
||||
const compose = readFileSync(new URL('../compose.yaml', import.meta.url), 'utf8');
|
||||
assert.match(compose, /^\s+- "127\.0\.0\.1:4173:4173"$/m, 'the port is published on the host loopback only');
|
||||
assert.doesNotMatch(compose, /GEV_KEY_SETUP_TRUSTED_PEERS/, 'trust is derived at launch, never hard-coded');
|
||||
assert.match(compose, /^\s+- GEV_KEY_SETUP_TRUSTED_PEERS$/m, 'the operator policy reaches the launcher without a hard-coded address');
|
||||
assert.match(compose, /^\s+- GEV_ALLOWED_HOSTS$/m, 'the host allowlist reaches Vite');
|
||||
assert.doesNotMatch(compose, /network_mode/, 'the container keeps its own network namespace');
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import { resolveTrustedPeers } from '../scripts/docker-start.mjs';
|
||||
|
||||
const ROUTES = 'Iface Destination Gateway Flags\neth0 00000000 0100A8C0 0003\n';
|
||||
const launcher = fileURLToPath(
|
||||
new URL('../scripts/docker-start.mjs', import.meta.url),
|
||||
);
|
||||
const gateUrl = new URL('./keySetupCore.mjs', import.meta.url).href;
|
||||
|
||||
test('none resolves to an empty trust list even with a valid container gateway', () => {
|
||||
for (const value of ['none', ' NONE ', 'None']) {
|
||||
assert.equal(
|
||||
resolveTrustedPeers({
|
||||
env: { GEV_KEY_SETUP_TRUSTED_PEERS: value },
|
||||
routeTable: ROUTES,
|
||||
}),
|
||||
'',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function launchWithPolicy(value, routeTable = ROUTES) {
|
||||
// Run the real entry point and routing-table reader. Replace only the Linux
|
||||
// route file and Vite's listening server so this works on macOS and CI too.
|
||||
const fakeVite = `
|
||||
import { admitKeySetupRequest } from ${JSON.stringify(gateUrl)};
|
||||
const trustedAtImport = process.env.GEV_KEY_SETUP_TRUSTED_PEERS;
|
||||
export async function createServer() {
|
||||
const request = {
|
||||
method: 'POST', hostHeader: 'localhost:4173',
|
||||
origin: 'http://localhost:4173', contentType: 'application/json',
|
||||
env: { GEV_KEY_SETUP_TRUSTED_PEERS: process.env.GEV_KEY_SETUP_TRUSTED_PEERS }
|
||||
};
|
||||
console.log('POLICY ' + JSON.stringify({
|
||||
trustedAtImport,
|
||||
allowedHosts: process.env.GEV_ALLOWED_HOSTS,
|
||||
gateway: admitKeySetupRequest({ ...request, remoteAddress: '192.168.0.1' }),
|
||||
loopback: admitKeySetupRequest({ ...request, remoteAddress: '127.0.0.1' }),
|
||||
foreignOrigin: admitKeySetupRequest({ ...request, remoteAddress: '192.168.0.1', origin: 'https://foreign.example' })
|
||||
}));
|
||||
return { async listen() {}, printUrls() {}, async close() {} };
|
||||
}
|
||||
`;
|
||||
const preload = `
|
||||
import fs from 'node:fs';
|
||||
import { registerHooks, syncBuiltinESMExports } from 'node:module';
|
||||
const originalRead = fs.readFileSync;
|
||||
fs.readFileSync = (file, ...args) => file === '/proc/net/route'
|
||||
? ${JSON.stringify(routeTable)} : originalRead(file, ...args);
|
||||
syncBuiltinESMExports();
|
||||
registerHooks({ resolve(specifier, context, next) {
|
||||
if (specifier === 'vite') return {
|
||||
url: ${JSON.stringify(`data:text/javascript,${encodeURIComponent(fakeVite)}`)}, shortCircuit: true
|
||||
};
|
||||
return next(specifier, context);
|
||||
}});
|
||||
`;
|
||||
const env = { ...process.env, GEV_ALLOWED_HOSTS: 'globe.example.lan' };
|
||||
delete env.GEV_KEY_SETUP_TRUSTED_PEERS;
|
||||
if (value !== undefined) env.GEV_KEY_SETUP_TRUSTED_PEERS = value;
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'--import',
|
||||
`data:text/javascript,${encodeURIComponent(preload)}`,
|
||||
launcher,
|
||||
],
|
||||
{ env, encoding: 'utf8', timeout: 10000 },
|
||||
);
|
||||
assert.ifError(child.error);
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
const line = child.stdout
|
||||
.split('\n')
|
||||
.find((line) => line.startsWith('POLICY '));
|
||||
assert.ok(line, child.stdout);
|
||||
return { ...JSON.parse(line.slice(7)), output: child.stdout };
|
||||
}
|
||||
|
||||
test('the real Docker entry point seals none before importing Vite and the setup gate refuses the gateway', () => {
|
||||
const result = launchWithPolicy('none');
|
||||
assert.equal(result.trustedAtImport, '');
|
||||
assert.equal(result.gateway.status, 403);
|
||||
assert.equal(result.gateway.code, 'not-loopback');
|
||||
assert.equal(result.loopback.ok, true);
|
||||
assert.equal(result.allowedHosts, 'globe.example.lan');
|
||||
assert.match(result.output, /trusts no additional peers/);
|
||||
assert.doesNotMatch(result.output, /No default gateway/);
|
||||
});
|
||||
|
||||
test('unset, blank and explicit peers retain gateway admission without weakening Origin checks', () => {
|
||||
for (const value of [undefined, '', ' ', '192.168.0.1']) {
|
||||
const result = launchWithPolicy(value);
|
||||
assert.equal(result.trustedAtImport, '192.168.0.1');
|
||||
assert.equal(result.gateway.ok, true);
|
||||
assert.equal(result.foreignOrigin.ok, false);
|
||||
}
|
||||
const result = launchWithPolicy(undefined, '');
|
||||
assert.equal(result.trustedAtImport, '');
|
||||
assert.equal(result.gateway.status, 403);
|
||||
assert.match(result.output, /No default gateway/);
|
||||
});
|
||||
|
||||
test('Compose resolves shell and project dotenv policy before the launcher starts', (t) => {
|
||||
const version = spawnSync('docker', ['compose', 'version'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
});
|
||||
if (version.error || version.status !== 0)
|
||||
return t.skip('Docker Compose CLI is not installed');
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-compose-policy-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
writeFileSync(
|
||||
path.join(root, 'compose.yaml'),
|
||||
readFileSync(new URL('../compose.yaml', import.meta.url)),
|
||||
);
|
||||
const baseEnv = { ...process.env };
|
||||
for (const key of Object.keys(baseEnv)) {
|
||||
if (
|
||||
key.startsWith('COMPOSE_') ||
|
||||
['GEV_KEY_SETUP_TRUSTED_PEERS', 'GEV_ALLOWED_HOSTS'].includes(key)
|
||||
)
|
||||
delete baseEnv[key];
|
||||
}
|
||||
function config(dotenv = '', overrides = {}) {
|
||||
writeFileSync(path.join(root, '.env'), dotenv);
|
||||
const child = spawnSync(
|
||||
'docker',
|
||||
[
|
||||
'compose',
|
||||
'-f',
|
||||
'compose.yaml',
|
||||
'--env-file',
|
||||
'.env',
|
||||
'config',
|
||||
'--format',
|
||||
'json',
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
env: { ...baseEnv, ...overrides },
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
assert.ifError(child.error);
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
return JSON.parse(child.stdout).services['gods-eye-view'].environment || {};
|
||||
}
|
||||
const unset = config();
|
||||
assert.ok(unset.GEV_KEY_SETUP_TRUSTED_PEERS == null);
|
||||
assert.ok(unset.GEV_ALLOWED_HOSTS == null);
|
||||
const dotenv =
|
||||
'GEV_KEY_SETUP_TRUSTED_PEERS=none\nGEV_ALLOWED_HOSTS=globe.example.lan\n';
|
||||
assert.deepEqual(config(dotenv), {
|
||||
GEV_KEY_SETUP_TRUSTED_PEERS: 'none',
|
||||
GEV_ALLOWED_HOSTS: 'globe.example.lan',
|
||||
});
|
||||
assert.equal(
|
||||
config(dotenv, { GEV_KEY_SETUP_TRUSTED_PEERS: '10.9.8.7' })
|
||||
.GEV_KEY_SETUP_TRUSTED_PEERS,
|
||||
'10.9.8.7',
|
||||
);
|
||||
assert.equal(
|
||||
config('', { GEV_KEY_SETUP_TRUSTED_PEERS: 'none' })
|
||||
.GEV_KEY_SETUP_TRUSTED_PEERS,
|
||||
'none',
|
||||
);
|
||||
assert.equal(
|
||||
config(dotenv, { GEV_KEY_SETUP_TRUSTED_PEERS: '' })
|
||||
.GEV_KEY_SETUP_TRUSTED_PEERS,
|
||||
'',
|
||||
);
|
||||
});
|
||||
@@ -15,7 +15,9 @@ export function hudQuantity(value, unit, digits = 0) {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
return `${number}${unitLabel(unit).toUpperCase()}`;
|
||||
const label = unitLabel(unit);
|
||||
if (getLocale() === 'zh-CN') return label ? `${number} ${label}` : number;
|
||||
return `${number}${label.toUpperCase()}`;
|
||||
}
|
||||
|
||||
/** One hemisphere-tagged coordinate, e.g. `21.33N` / `21,33С`. */
|
||||
@@ -35,7 +37,16 @@ export const LOCALIZED_LOCALITY = Object.freeze({
|
||||
}),
|
||||
sector: ({ latDeg, lonDeg }) =>
|
||||
t('SECTOR {lat} {lon}', {
|
||||
lat: hudCoordinate(latDeg, t('N'), t('S')),
|
||||
lon: hudCoordinate(lonDeg, t('E'), t('W')),
|
||||
// Hemisphere letters, not compass words: 21.33N, never 21.33北.
|
||||
lat: hudCoordinate(
|
||||
latDeg,
|
||||
t('N', { context: 'hemisphere' }),
|
||||
t('S', { context: 'hemisphere' }),
|
||||
),
|
||||
lon: hudCoordinate(
|
||||
lonDeg,
|
||||
t('E', { context: 'hemisphere' }),
|
||||
t('W', { context: 'hemisphere' }),
|
||||
),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getLocale, setLocale } from './index.js';
|
||||
import { createModel } from '../layers/firms/model.js';
|
||||
import { createQueries } from '../layers/firms/queries.js';
|
||||
|
||||
const cases = [
|
||||
[0, '不到 1 分钟前', '<1m ago'],
|
||||
[120000, '2 分钟前', '2m ago'],
|
||||
[7200000, '2 小时前', '2h ago'],
|
||||
];
|
||||
|
||||
for (const [age, zh, en] of cases) {
|
||||
test(`FIRMS live age ${age} ms has one relative-time suffix`, (context) => {
|
||||
const now = 1800000000000;
|
||||
context.mock.method(Date, 'now', () => now);
|
||||
const previousLocale = getLocale();
|
||||
const layerState = {
|
||||
_loading: false,
|
||||
_stale: false,
|
||||
_keyRequired: false,
|
||||
_error: null,
|
||||
_lastUpdate: now - age,
|
||||
_count: 1,
|
||||
_cellCount: 0,
|
||||
_fires: [],
|
||||
};
|
||||
const model = createModel({ services: { anchors: {} } });
|
||||
const { methods } = createQueries({
|
||||
layerState,
|
||||
components: { model },
|
||||
config: {},
|
||||
});
|
||||
try {
|
||||
setLocale('en');
|
||||
assert.equal(methods.getStats().loadingLabel, `LIVE · updated ${en}`);
|
||||
setLocale('zh-CN');
|
||||
assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`);
|
||||
} finally {
|
||||
setLocale(previousLocale);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -35,6 +35,12 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () =>
|
||||
assert.equal(normalizeLocale(' RU '), 'ru');
|
||||
assert.equal(normalizeLocale('en_GB'), 'en');
|
||||
assert.equal(normalizeLocale('xx'), null);
|
||||
// Simplified Chinese by default; Traditional tags wait for zh-TW.
|
||||
assert.equal(normalizeLocale('zh'), 'zh-CN');
|
||||
assert.equal(normalizeLocale('zh-CN'), 'zh-CN');
|
||||
assert.equal(normalizeLocale('zh-Hans-SG'), 'zh-CN');
|
||||
assert.equal(normalizeLocale('zh-TW'), null);
|
||||
assert.equal(normalizeLocale('zh-Hant-HK'), null);
|
||||
assert.equal(normalizeLocale(undefined), null);
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -18,8 +18,9 @@ import {
|
||||
} from './locale.js';
|
||||
import es from './locales/es.json' with { type: 'json' };
|
||||
import ru from './locales/ru.json' with { type: 'json' };
|
||||
import zhCN from './locales/zh-CN.json' with { type: 'json' };
|
||||
|
||||
const CATALOGS = Object.freeze({ es, ru });
|
||||
const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN });
|
||||
|
||||
let currentLocale = DEFAULT_LOCALE;
|
||||
|
||||
|
||||
+10
-3
@@ -8,25 +8,32 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
|
||||
export const LOCALES = Object.freeze(['en', 'es', 'ru']);
|
||||
export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN']);
|
||||
|
||||
/** Each locale named in its own language, for the selector. */
|
||||
export const LOCALE_NAMES = Object.freeze({
|
||||
en: 'English',
|
||||
es: 'Español',
|
||||
ru: 'Русский',
|
||||
'zh-CN': '简体中文',
|
||||
});
|
||||
|
||||
/** Right-to-left locales. */
|
||||
const RTL_LOCALES = Object.freeze([]);
|
||||
|
||||
/**
|
||||
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru'.
|
||||
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru',
|
||||
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function normalizeLocale(candidate) {
|
||||
if (typeof candidate !== 'string') return null;
|
||||
const primary = candidate.trim().toLowerCase().split(/[-_]/)[0];
|
||||
const [primary, ...rest] = candidate.trim().toLowerCase().split(/[-_]/);
|
||||
// Traditional Chinese readers are not given Simplified: no zh-TW yet.
|
||||
if (primary === 'zh')
|
||||
return rest.some((part) => ['hant', 'tw', 'hk', 'mo'].includes(part))
|
||||
? null
|
||||
: 'zh-CN';
|
||||
return LOCALES.includes(primary) ? primary : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1086,7 +1086,6 @@
|
||||
"FIELD REPORTS · {count}": "INFORMES DE CAMPO · {count}",
|
||||
"GEOLOCATIONS · {credit}": "GEOLOCALIZACIONES · {credit}",
|
||||
"↗ OPEN PUBLIC MAP": "↗ ABRIR MAPA PÚBLICO",
|
||||
"Clouds are preserved from the source imagery.": "Las nubes provienen de las imágenes de origen.",
|
||||
"Show the Nepal context callout without a border or country fill": "Muestra la etiqueta de contexto de Nepal sin frontera ni relleno del país",
|
||||
"Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Presenta Nepal, dibuja su frontera y luego revela el incidente del Bhote Koshi",
|
||||
"CITIES": "CIUDADES",
|
||||
@@ -2937,5 +2936,12 @@
|
||||
"Gemini access was refused. Check the API key and model access.": "Se denegó el acceso a Gemini. Comprueba la clave de API y el acceso al modelo.",
|
||||
"Gemini Live is unavailable for the configured model.": "Gemini Live no está disponible para el modelo configurado.",
|
||||
"Token request is too large": "La solicitud de credencial es demasiado grande",
|
||||
"Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío"
|
||||
"Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío",
|
||||
" Clouds are preserved from the source imagery.": " Las nubes provienen de las imágenes de origen.",
|
||||
"hemisphere|N": "N",
|
||||
"hemisphere|S": "S",
|
||||
"hemisphere|E": "E",
|
||||
"hemisphere|W": "O",
|
||||
"OK": "OK",
|
||||
"{category} ({count})": "{category} ({count})"
|
||||
}
|
||||
@@ -1100,7 +1100,6 @@
|
||||
"FIELD REPORTS · {count}": "СООБЩЕНИЯ С МЕСТА · {count}",
|
||||
"GEOLOCATIONS · {credit}": "ГЕОЛОКАЦИИ · {credit}",
|
||||
"↗ OPEN PUBLIC MAP": "↗ ОТКРЫТЬ ПУБЛИЧНУЮ КАРТУ",
|
||||
"Clouds are preserved from the source imagery.": "Облака оставлены такими, как на исходных снимках.",
|
||||
"Show the Nepal context callout without a border or country fill": "Показать выноску с контекстом Непала без границы и заливки страны",
|
||||
"Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Представить Непал, нарисовать его границу, затем показать инцидент на Bhote Koshi",
|
||||
"CITIES": "ГОРОДА",
|
||||
@@ -3004,5 +3003,12 @@
|
||||
"Gemini access was refused. Check the API key and model access.": "Доступ к Gemini запрещён. Проверьте ключ API и доступ к модели.",
|
||||
"Gemini Live is unavailable for the configured model.": "Gemini Live недоступен для выбранной модели.",
|
||||
"Token request is too large": "Запрос токена слишком велик",
|
||||
"Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON"
|
||||
"Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON",
|
||||
" Clouds are preserved from the source imagery.": " Облака оставлены такими, как на исходных снимках.",
|
||||
"hemisphere|N": "С",
|
||||
"hemisphere|S": "Ю",
|
||||
"hemisphere|E": "В",
|
||||
"hemisphere|W": "З",
|
||||
"OK": "НОРМА",
|
||||
"{category} ({count})": "{category} ({count})"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -63,6 +63,7 @@
|
||||
"NVG",
|
||||
"Noir",
|
||||
"Normal",
|
||||
"OK",
|
||||
"POS: {time}Z",
|
||||
"Plan",
|
||||
"Pop",
|
||||
@@ -98,6 +99,9 @@
|
||||
"fire confidence|nominal",
|
||||
"ft",
|
||||
"hPa",
|
||||
"hemisphere|E",
|
||||
"hemisphere|N",
|
||||
"hemisphere|S",
|
||||
"installation class|BASE",
|
||||
"km",
|
||||
"km/h",
|
||||
@@ -133,5 +137,60 @@
|
||||
"REC",
|
||||
"VS",
|
||||
"WHOT/BHOT"
|
||||
],
|
||||
"zh-CN": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CCTV",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"FLIR",
|
||||
"FOV",
|
||||
"FOV {value}°",
|
||||
"FT",
|
||||
"GEO",
|
||||
"HDG",
|
||||
"HDG {value}°",
|
||||
"House",
|
||||
"Ironbow",
|
||||
"KTS",
|
||||
"Landsat 8/9",
|
||||
"MM",
|
||||
"NVG",
|
||||
"REC",
|
||||
"SRC {value}",
|
||||
"Sentinel-2",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"USB {rate} msg/s",
|
||||
"WHOT/BHOT",
|
||||
"dB",
|
||||
"ft",
|
||||
"hPa",
|
||||
"hemisphere|E",
|
||||
"hemisphere|N",
|
||||
"hemisphere|S",
|
||||
"hemisphere|W",
|
||||
"km",
|
||||
"km/h",
|
||||
"kt",
|
||||
"kts",
|
||||
"mph",
|
||||
"{city} CCTV",
|
||||
"{rate} msg/s",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
"{value} KM/S",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, t } from './index.js';
|
||||
import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js';
|
||||
import { formatCockpitDistance } from '../ui/cockpitPresentation.js';
|
||||
import { cctvSourceStatusLabel } from '../ui/cctvFrames.js';
|
||||
import { READOUT_WORDS } from '../layers/recentImagery/display.js';
|
||||
import { createModel } from '../layers/firms/model.js';
|
||||
import { createQueries } from '../layers/firms/queries.js';
|
||||
import { renderRadioState } from '../ui/radioPresentation.js';
|
||||
|
||||
test('Chinese HUD and cockpit units keep their case without spacing coordinates', () => {
|
||||
try {
|
||||
setLocale('zh-CN');
|
||||
assert.equal(hudQuantity(0, 'km'), '0 km');
|
||||
assert.equal(hudQuantity(2069, 'km', 1), '2069.0 km');
|
||||
assert.equal(formatCockpitDistance(14000), '14\u00a0km');
|
||||
assert.equal(LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 }),
|
||||
'扇区 21.33N 77.50W');
|
||||
setLocale('en');
|
||||
assert.equal(hudQuantity(0, 'km'), '0KM');
|
||||
assert.equal(hudQuantity(2069, 'km', 1), '2069.0KM');
|
||||
assert.equal(formatCockpitDistance(14000), '14\u00a0KM');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('FIRMS stale cache age uses localized durations while English stays compact', () => {
|
||||
const now = Date.now();
|
||||
const layerState = { _stale: true };
|
||||
const components = {
|
||||
model: createModel({ services: { anchors: {} } }),
|
||||
};
|
||||
const { methods } = createQueries({ layerState, components, config: {} });
|
||||
try {
|
||||
for (const [hours, zh, en] of [[0.5, '不到 1 小时', '<1h'], [2, '2 小时', '2h'], [72, '3 天', '3d']]) {
|
||||
layerState._lastUpdate = now - hours * 3600000;
|
||||
setLocale('zh-CN');
|
||||
const expected = t('STALE · cached {age}', { age: zh });
|
||||
assert.equal(methods.getStats().loadingLabel, expected);
|
||||
assert.equal(methods.getStats().error, expected);
|
||||
setLocale('en');
|
||||
assert.equal(methods.getStats().loadingLabel, `STALE · cached ${en}`);
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('Recent Imagery spaces numeric Chinese ages and retains natural relative dates', () => {
|
||||
try {
|
||||
setLocale('zh-CN');
|
||||
assert.equal(READOUT_WORDS.daysAgo(9), '9 天前');
|
||||
assert.equal(READOUT_WORDS.daysAgo(0), '今天');
|
||||
assert.equal(READOUT_WORDS.daysAgo(1), '昨天');
|
||||
setLocale('en');
|
||||
assert.equal(READOUT_WORDS.daysAgo(9), '9 days ago');
|
||||
assert.equal(READOUT_WORDS.daysAgo(0), 'today');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV translates the healthy status and preserves unknown provider codes', () => {
|
||||
try {
|
||||
for (const [locale, expected] of [['zh-CN', '正常'], ['ru', 'НОРМА'], ['es', 'OK'], ['en', 'OK']]) {
|
||||
setLocale(locale);
|
||||
assert.equal(cctvSourceStatusLabel('ok'), expected);
|
||||
assert.equal(cctvSourceStatusLabel('CUSTOM'), 'CUSTOM');
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('radio option text and accessible name use the same localized parentheses', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const element = () => ({
|
||||
style: {}, dataset: {}, attributes: {},
|
||||
classList: { contains: () => true, toggle() {} },
|
||||
setAttribute(name, value) { this.attributes[name] = value; },
|
||||
replaceChildren(...children) { this.children = children; },
|
||||
});
|
||||
globalThis.document = { createElement: element, getElementById: () => null };
|
||||
try {
|
||||
for (const [locale, label, expected] of [['zh-CN', '全部', '全部(750)'], ['en', 'All', 'All (750)']]) {
|
||||
setLocale(locale);
|
||||
const host = {
|
||||
_radioPanel: element(), _radioFilter: element(),
|
||||
_syncContextRadioLauncherState() {},
|
||||
actions: { getLifecycle: () => null, scheduleLayout() {} },
|
||||
};
|
||||
renderRadioState.call(host, {
|
||||
enabled: true, categories: [{ id: 'all', label, count: 750, color: '' }],
|
||||
filter: 'all', filteredCount: 750, stationCount: 750, volume: 1,
|
||||
});
|
||||
const option = host._radioFilter.children[0];
|
||||
assert.equal(option.textContent, `● ${expected}`);
|
||||
assert.equal(option.attributes['aria-label'], expected);
|
||||
}
|
||||
} finally {
|
||||
globalThis.document = originalDocument;
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -591,6 +591,7 @@ export function createModel({
|
||||
formatLatLon,
|
||||
formatFrp,
|
||||
formatAge,
|
||||
formatAgeLabel,
|
||||
formatAgoMinutes,
|
||||
confidenceBucket,
|
||||
};
|
||||
|
||||
@@ -64,7 +64,8 @@ export function createQueries({
|
||||
const staleText = layerState._lastUpdate
|
||||
? t('STALE · cached {age}', {
|
||||
age:
|
||||
components.model.formatAge(now - layerState._lastUpdate) || '<1h',
|
||||
components.model.formatAgeLabel(now - layerState._lastUpdate) ||
|
||||
t('<1h', { context: 'age' }),
|
||||
})
|
||||
: t('STALE');
|
||||
let loadingLabel = '';
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
* day's readout. The model (`model.js`) stays English and i18n-free because AI
|
||||
* tool services import it outside the browser; this module is page-only.
|
||||
*/
|
||||
import { formatDate, formatRelativeTime, t } from '../../i18n/index.js';
|
||||
import {
|
||||
formatDate,
|
||||
formatRelativeTime,
|
||||
getLocale,
|
||||
t,
|
||||
} from '../../i18n/index.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
import { dayTime } from './model.js';
|
||||
|
||||
@@ -26,7 +31,10 @@ export const displayMetres = (value) => formatQuantity(value, 'm');
|
||||
/** The readout's words in the page's language; the model keeps them English. */
|
||||
export const READOUT_WORDS = Object.freeze({
|
||||
date: (day) => displayDay(day, LONG_DAY),
|
||||
daysAgo: (delta) => formatRelativeTime(delta > 0 ? -delta : 0, 'day'),
|
||||
daysAgo: (delta) => {
|
||||
const text = formatRelativeTime(delta > 0 ? -delta : 0, 'day');
|
||||
return getLocale() === 'zh-CN' ? text.replace(/(\d)(?=天)/g, '$1 ') : text;
|
||||
},
|
||||
// "Sentinel-2" never breaks at its hyphen: a word joiner follows it
|
||||
// (JetBrains Mono has no U+2011 non-breaking hyphen).
|
||||
sensor: (sensor) =>
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -778,3 +778,20 @@ test('visual input listeners are revoked before asynchronous UI teardown', () =>
|
||||
assert.match(VisualSettings.prototype.stop.toString(), /this\._styleParameters\?\.destroy\(\)/);
|
||||
assert.match(VisualSettings.prototype.stop.toString(), /this\._visualEffects\.stop\(\)/);
|
||||
});
|
||||
|
||||
|
||||
test('share restore rejects inherited styles while continuing later fields', () => {
|
||||
for (const style of ['constructor', '__proto__', 'toString', 'hasOwnProperty', 'unknown']) {
|
||||
const restored = makeManager(`#lat=10&lon=20&style=${style}&cr=1`).parseInitialHash();
|
||||
assert.equal(restored.style, 'normal');
|
||||
assert.equal(restored.celestialRing, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('programmatic invalid styles are ignored before changing visual state', () => {
|
||||
const target = { activeStyle: 'normal' };
|
||||
for (const value of ['constructor', '__proto__', 'toString', 'hasOwnProperty', 'unknown', {}, null, () => {}]) {
|
||||
assert.doesNotThrow(() => VisualSettings.prototype.setStyle.call(target, value));
|
||||
assert.equal(target.activeStyle, 'normal');
|
||||
}
|
||||
});
|
||||
+3
-1
@@ -175,7 +175,9 @@ export class ShareLinkManager {
|
||||
parseOr(params.get('dd'), 50),
|
||||
50,
|
||||
);
|
||||
const style = URL_TO_STYLE[params.get('style')] || 'normal';
|
||||
const style = Object.hasOwn(URL_TO_STYLE, params.get('style'))
|
||||
? URL_TO_STYLE[params.get('style')]
|
||||
: 'normal';
|
||||
const decodedLayerState = decodeLayerStateParams(params);
|
||||
const state = {
|
||||
lat,
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -583,3 +583,124 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
|
||||
assert.equal(res2.statusCode, 502);
|
||||
assert.equal(callCount, 1);
|
||||
});
|
||||
|
||||
test('global aircraft snapshots coalesce cold and expired callers and clear rejected flights', async (t) => {
|
||||
environment(t, { OPENSKY_AUTH_MODE: 'anon' });
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(console, 'error', () => {});
|
||||
let now = Date.now();
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
for (const kind of ['opensky', 'military']) {
|
||||
const module =
|
||||
kind === 'opensky'
|
||||
? await import(
|
||||
`../../server/providers/aircraft/opensky.js?coalesce=${Math.random()}`
|
||||
)
|
||||
: await import('../../server/providers/aircraft/adsb-lol.js');
|
||||
const request = install(
|
||||
kind === 'opensky' ? module.openSkyProxy() : module.adsbLolProxy(),
|
||||
);
|
||||
const route = kind === 'opensky' ? '/api/flights' : '/api/military';
|
||||
let calls = 0;
|
||||
let release;
|
||||
let blocked;
|
||||
let fail = false;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls++;
|
||||
await blocked;
|
||||
if (fail) throw new Error('fixture connection failed');
|
||||
return Response.json(
|
||||
{ time: now / 1000, states: [], ac: [] },
|
||||
{ headers: { 'x-rate-limit-remaining': '5000' } },
|
||||
);
|
||||
});
|
||||
for (const phase of ['cold', 'expired', 'failed', 'recovered']) {
|
||||
now += 13000;
|
||||
fail = phase === 'failed';
|
||||
blocked = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const before = calls;
|
||||
const pending = Array.from({ length: 5 }, () => request(route));
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(calls - before, 1, `${kind} ${phase}: one refresh starts`);
|
||||
release();
|
||||
const responses = await Promise.all(pending);
|
||||
assert.equal(calls - before, fail && kind === 'opensky' ? 2 : 1);
|
||||
assert.ok(responses.every((res) => res.statusCode === 200));
|
||||
assert.ok(responses.every((res) => res.body === responses[0].body));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenSky 5xx retains dated last-good data, cancels error bodies and cools down before recovery', async (t) => {
|
||||
environment(t, { OPENSKY_AUTH_MODE: 'anon' });
|
||||
let now = Date.now();
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
for (const status of [502, 503, 504]) {
|
||||
const module = await import(
|
||||
`../../server/providers/aircraft/opensky.js?server-error=${status}`
|
||||
);
|
||||
const request = install(module.openSkyProxy());
|
||||
let fail = false;
|
||||
let calls = 0;
|
||||
let cancelled = false;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls++;
|
||||
if (fail)
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
{ status },
|
||||
);
|
||||
return Response.json(
|
||||
{ time: now / 1000, states: [] },
|
||||
{ headers: { 'x-rate-limit-remaining': '5000' } },
|
||||
);
|
||||
});
|
||||
const good = await request('/api/flights');
|
||||
now += 10000;
|
||||
fail = true;
|
||||
const stale = await request('/api/flights');
|
||||
assert.equal(stale.statusCode, 200);
|
||||
assert.equal(stale.body, good.body);
|
||||
assert.equal(stale.headers['x-opensky-cache'], 'STALE');
|
||||
assert.equal(
|
||||
stale.headers['x-opensky-auth-reason'],
|
||||
`upstream_http_${status}_serving_stale`,
|
||||
);
|
||||
assert.equal(stale.headers['x-opensky-stale-seconds'], '10');
|
||||
assert.equal(cancelled, true);
|
||||
const cooling = await request('/api/flights');
|
||||
assert.equal(cooling.body, good.body);
|
||||
assert.equal(
|
||||
cooling.headers['x-opensky-auth-reason'],
|
||||
`upstream_http_${status}_serving_stale`,
|
||||
);
|
||||
assert.equal(calls, 2);
|
||||
now += 16000;
|
||||
fail = false;
|
||||
assert.equal(
|
||||
(await request('/api/flights')).headers['x-opensky-cache'],
|
||||
'MISS',
|
||||
);
|
||||
assert.equal(calls, 3);
|
||||
}
|
||||
const coldModule =
|
||||
await import('../../server/providers/aircraft/opensky.js?cold-server-error');
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls++;
|
||||
return new Response('<html>failure</html>', { status: 503 });
|
||||
});
|
||||
const cold = install(coldModule.openSkyProxy());
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const response = await cold('/api/flights');
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.match(JSON.parse(response.body).error, /OpenSky/);
|
||||
}
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
@@ -19,6 +20,7 @@ const SOURCE_KIND_LABELS = Object.freeze({
|
||||
});
|
||||
|
||||
const SOURCE_STATUS_LABELS = Object.freeze({
|
||||
ok: msg('OK'),
|
||||
degraded: msg('DEGRADED'),
|
||||
error: msg('ERROR'),
|
||||
unknown: msg('UNKNOWN'),
|
||||
@@ -51,7 +53,7 @@ export function cctvSourceKindLabel(kind) {
|
||||
: key.toUpperCase();
|
||||
}
|
||||
|
||||
/** "DEGRADED" / "ПЕРЕБОИ"; OK and unknown codes stay as uppercase codes. */
|
||||
/** "DEGRADED" / "ПЕРЕБОИ"; unknown codes stay as uppercase codes. */
|
||||
export function cctvSourceStatusLabel(status) {
|
||||
const key = String(status || 'unknown').toLowerCase();
|
||||
return SOURCE_STATUS_LABELS[key]
|
||||
@@ -164,19 +166,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 +188,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,6 +1,6 @@
|
||||
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
|
||||
import * as Cesium from 'cesium';
|
||||
import { formatNumber, t, tn } from '../i18n/index.js';
|
||||
import { formatNumber, getLocale, t, tn } from '../i18n/index.js';
|
||||
import { unitLabel } from './unitLabels.js';
|
||||
import {
|
||||
bearingBetweenCoordinates,
|
||||
@@ -15,7 +15,7 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
|
||||
window: ({ label, radiusKm }) =>
|
||||
t('{label} · {radius} AIR/SEA WINDOW', {
|
||||
label,
|
||||
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${unitLabel('km').toUpperCase()}`,
|
||||
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale() === 'zh-CN' ? unitLabel('km') : unitLabel('km').toUpperCase()}`,
|
||||
}),
|
||||
installations: ({ scope, coverage }) =>
|
||||
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Pure formatting and fixed presentation constants used by Cockpit components. */
|
||||
import { formatNumber, t, msg } from '../i18n/index.js';
|
||||
import { formatNumber, getLocale, t, msg } from '../i18n/index.js';
|
||||
import { formatQuantity } from './unitLabels.js';
|
||||
|
||||
export const COCKPIT_HEADING_SLEW_DPS = 28;
|
||||
@@ -145,10 +145,11 @@ export function formatCockpitCoordinate(value, positive, negative) {
|
||||
*/
|
||||
export function formatCockpitDistance(distanceM) {
|
||||
const digits = distanceM < 10000 ? 1 : 0;
|
||||
return formatQuantity(distanceM / 1000, 'km', {
|
||||
const distance = formatQuantity(distanceM / 1000, 'km', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
}).toUpperCase();
|
||||
});
|
||||
return getLocale() === 'zh-CN' ? distance : distance.toUpperCase();
|
||||
}
|
||||
|
||||
export function formatCockpitWindDirection(value) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['ru', 'en', 'es'],
|
||||
['ru', 'en', 'es', 'zh-CN'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
codes(
|
||||
@@ -50,7 +50,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['es', 'en', 'ru'],
|
||||
['es', 'en', 'ru', 'zh-CN'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
codes(
|
||||
@@ -60,7 +60,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['en', 'es', 'ru'],
|
||||
['en', 'es', 'ru', 'zh-CN'],
|
||||
);
|
||||
const rows = buildLocaleOptions({
|
||||
locales: MANY,
|
||||
@@ -263,7 +263,7 @@ test('keyboard model: open on the current language, move, choose, close', () =>
|
||||
assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true });
|
||||
assert.equal(model.activeOption.code, 'en');
|
||||
model.keydown({ key: 'End' });
|
||||
assert.equal(model.activeOption.code, 'es');
|
||||
assert.equal(model.activeOption.code, 'zh-CN');
|
||||
model.keydown({ key: 'Home' });
|
||||
assert.equal(model.activeOption.code, 'ru');
|
||||
model.keydown({ key: 'ArrowUp' });
|
||||
|
||||
@@ -114,13 +114,14 @@ export function renderRadioState(state) {
|
||||
...state.categories.map((category) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = category.id;
|
||||
option.textContent = `● ${category.label} (${category.count})`;
|
||||
const label = t('{category} ({count})', {
|
||||
category: category.label,
|
||||
count: category.count,
|
||||
});
|
||||
option.textContent = `● ${label}`;
|
||||
option.dataset.radioColor = category.color;
|
||||
option.style.color = category.color;
|
||||
option.setAttribute(
|
||||
'aria-label',
|
||||
`${category.label} (${category.count})`,
|
||||
);
|
||||
option.setAttribute('aria-label', label);
|
||||
return option;
|
||||
}),
|
||||
);
|
||||
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user