Merge branch 'main' into codex/fix-share-style-keys

This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-22 23:23:49 +01:00
commit 5cc250495f
118 files changed
+18492 -593

No files matched your search

+11
View File
@@ -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
View File
@@ -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
+7 -3
View File
@@ -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);
+263 -3
View File
@@ -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);
});
+54
View File
@@ -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`);
}
});
+114
View File
@@ -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,
);
});
+7
View File
@@ -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 &amp; 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',
+58
View File
@@ -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;
}
});
+51
View File
@@ -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

+2 -1
View File
@@ -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');
});
+179
View File
@@ -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,
'',
);
});
+14 -3
View File
@@ -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' }),
),
}),
});
+43
View File
@@ -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);
}
});
}
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+8 -2
View File
@@ -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})"
}
+8 -2
View File
@@ -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
+59
View File
@@ -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"
]
}
+106
View File
@@ -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');
}
});
+1 -1
View File
@@ -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),
+1
View File
@@ -591,6 +591,7 @@ export function createModel({
formatLatLon,
formatFrp,
formatAge,
formatAgeLabel,
formatAgoMinutes,
confidenceBucket,
};
+2 -1
View File
@@ -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 = '';
+10 -2
View File
@@ -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) =>
+4 -1
View File
@@ -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;
+17
View File
@@ -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 }),
+451
View File
@@ -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;
}
}
+200
View File
@@ -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;
}
});
+121
View File
@@ -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 };
}
+130
View File
@@ -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`);
});
+121
View File
@@ -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);
});
+64
View File
@@ -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)}`,
);
}
});
+132
View File
@@ -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);
});
+100
View File
@@ -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);
});
+140 -5
View File
@@ -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();
}
}
});
+41
View File
@@ -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;
}
+112
View File
@@ -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');
});
+7 -5
View File
@@ -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();
+3
View File
@@ -105,6 +105,9 @@ function calibrationFixture(t) {
Object.assign(input, {
focus() {},
select() {},
setAttribute(name, value) {
this[name] = value;
},
remove() {
this.parent.input = null;
},
+9 -5
View File
@@ -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';
}
+8 -5
View File
@@ -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 || '';
}
+2 -2
View File
@@ -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 }),
+4 -3
View File
@@ -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) {
+36
View File
@@ -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;
}
+4 -4
View File
@@ -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' });
+6 -5
View File
@@ -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;
}),
);
+11 -7
View File
@@ -1,4 +1,5 @@
import { createRailCardBlocks } from './railCardBlocks.js';
import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
@@ -53,11 +54,12 @@ export function createRailCards({
'rail-card-title rail-card-nowrap',
heading,
);
const badge = make(
'span',
`rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`,
labels,
);
const badge = createStatusBadge({
documentRef: document,
appearance: 'plain',
className: `rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`,
});
labels.appendChild(badge);
const disclosure = make('span', 'rail-card-disclosure', header);
disclosure.setAttribute('aria-hidden', 'true');
const compact = make('span', 'rail-card-compact', header);
@@ -90,8 +92,10 @@ export function createRailCards({
row.title.setAttribute('title', card.title);
set(row.icon, 'textContent', card.icon || '');
set(row.icon, 'hidden', !card.icon);
set(row.badge, 'textContent', card.badge || '');
set(row.badge, 'hidden', !card.badge);
updateStatusBadge(row.badge, {
label: card.badge || '',
hidden: !card.badge,
});
const open = card.open !== false;
set(row.element.dataset, 'open', String(open));
row.element.classList.toggle('is-open', open);
+12 -6
View File
@@ -1,5 +1,6 @@
/** Scene panel elements and text-only project presentation. */
import { formatNumber, msg, t } from '../i18n/index.js';
import { createActionButton } from './actionButton.js';
// Labels for what a shot stores; anything else shows as stored. Style names
// match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot
@@ -121,13 +122,18 @@ export function renderSceneShots(
});
const actions = document.createElement('div');
actions.className = 'scene-shot-actions';
const loadButton = document.createElement('button');
loadButton.className = 'scene-shot-btn';
loadButton.textContent = t('LOAD');
const loadButton = createActionButton({
label: t('LOAD'),
className: 'scene-shot-btn',
size: 'compact',
});
listen(loadButton, 'click', () => load(scene.id, shot.id));
const deleteButton = document.createElement('button');
deleteButton.className = 'scene-shot-btn scene-shot-danger';
deleteButton.textContent = t('DEL');
const deleteButton = createActionButton({
label: t('DEL'),
className: 'scene-shot-btn scene-shot-danger',
size: 'compact',
variant: 'danger',
});
listen(deleteButton, 'click', () => remove(scene.id, shot.id));
actions.appendChild(loadButton);
actions.appendChild(deleteButton);
+2 -4
View File
@@ -1,4 +1,5 @@
import { t, tn } from '../i18n/index.js';
import { createActionButton } from './actionButton.js';
/** Small owned modal for scene authoring and import review; text never becomes markup. */
export function createSceneDialog(title, onClose) {
@@ -185,10 +186,7 @@ export function mountSceneSharing(
];
const removers = [];
for (const [text, fn] of entries) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = text;
b.className = 'scene-btn';
const b = createActionButton({ label: text, className: 'scene-btn' });
b.addEventListener('click', fn);
removers.push(() => b.removeEventListener('click', fn));
bar.append(b);
+39
View File
@@ -0,0 +1,39 @@
/**
* Ordinary read-only badge view. Labels are already translated. Feature owners
* retain status derivation, machine-state attributes and announcement policy.
* No listeners, timers, state inference or automatic live regions are installed.
* Vue can replace this DOM adapter while retaining the label/hidden contract
* and the gev-status-badge CSS surface.
* @param {object} options
* @param {Document} [options.documentRef]
* @param {string} [options.label]
* @param {'pill'|'box'|'plain'} [options.appearance]
* @param {string} [options.className] Feature/layout hooks.
* @param {boolean} [options.hidden]
* @returns {HTMLSpanElement}
*/
export function createStatusBadge({
documentRef = globalThis.document,
label = '',
appearance = 'pill',
className = '',
hidden = false,
} = {}) {
const badge = documentRef.createElement('span');
badge.className = [
'gev-status-badge',
appearance === 'box' && 'gev-status-badge--box',
appearance === 'plain' && 'gev-status-badge--plain',
className,
]
.filter(Boolean)
.join(' ');
updateStatusBadge(badge, { label, hidden });
return badge;
}
/** Paint controlled text/visibility without replacing nodes or redundant writes. */
export function updateStatusBadge(badge, { label, hidden = false }) {
if (badge.textContent !== label) badge.textContent = label;
if (badge.hidden !== hidden) badge.hidden = hidden;
}
+78
View File
@@ -0,0 +1,78 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
import { createRailCards } from './railCards.js';
import { railFixture } from './railTestFixture.mjs';
test('badges paint literal translated text without installing announcement or interaction semantics', () => {
const { document } = railFixture();
for (const appearance of ['pill', 'box', 'plain']) {
const badge = createStatusBadge({
documentRef: document,
appearance,
className: 'feature-hook',
label: '<img src=x onerror=alert(1)> · НЕИЗВЕСТНО',
});
assert.equal(badge.tagName, 'SPAN');
assert.equal(
badge.textContent,
'<img src=x onerror=alert(1)> · НЕИЗВЕСТНО',
);
assert.equal(badge.children.length, 0);
assert.equal(badge.classList.contains('gev-status-badge'), true);
assert.equal(badge.classList.contains('feature-hook'), true);
assert.equal(badge.getAttribute('role'), null);
assert.equal(badge.getAttribute('aria-live'), null);
assert.equal(badge.getAttribute('tabindex'), null);
assert.equal(
badge.classList.contains(`gev-status-badge--${appearance}`),
appearance !== 'pill',
);
}
});
test('painting preserves feature state, live regions and focus with zero identical writes', () => {
let writes = 0;
const { document } = railFixture(() => writes++);
const badge = createStatusBadge({ documentRef: document, hidden: true });
badge.setAttribute('role', 'status');
badge.setAttribute('aria-live', 'polite');
badge.dataset.frameState = 'ready';
const focusOwner = document.createElement('button');
focusOwner.focus();
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
assert.equal(badge.hidden, false);
writes = 0;
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
assert.equal(writes, 0);
assert.equal(badge.getAttribute('role'), 'status');
assert.equal(badge.getAttribute('aria-live'), 'polite');
assert.equal(badge.dataset.frameState, 'ready');
assert.equal(document.activeElement, focusOwner);
});
test('coverage badges keep keyed identity, hide empty coverage and never claim feed health', () => {
const f = railFixture();
const view = createRailCards({ ...f, badgeClassName: 'weather-coverage' });
const card = { id: 'radar', title: 'Rain radar', badge: 'CONUS', blocks: [] };
view.update([card]);
const badge = f.find((node) => node.classList.contains('gev-status-badge'));
const header = f.container.children[0].children[0];
header.focus();
assert.equal(badge.classList.contains('gev-status-badge--plain'), true);
assert.equal(badge.classList.contains('weather-coverage'), true);
view.update([{ ...card, badge: '' }]);
assert.equal(badge.hidden, true);
view.update([{ ...card, badge: 'Global · 1° grid' }]);
assert.equal(badge.hidden, false);
assert.equal(badge.textContent, 'Global · 1° grid');
assert.equal(
f.find((node) => node.classList.contains('gev-status-badge')),
badge,
);
assert.deepEqual({ ...badge.dataset }, {});
assert.equal(f.document.activeElement, header);
view.destroy();
view.update([card]);
assert.equal(f.container.children.length, 0);
});
+59
View File
@@ -0,0 +1,59 @@
/* Ordinary actions share one surface; feature classes own placement and width.
* Legacy scene-btn hooks keep untouched toggles/Street Level controls stable. */
.gev-action-button,
.scene-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1.2px;
padding: 6px 9px;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast),
opacity var(--transition-fast);
white-space: nowrap;
}
.gev-action-button:hover:not(:disabled),
.scene-btn:hover:not(:disabled) {
color: var(--accent);
border-color: var(--accent);
background: rgba(0, 212, 255, 0.14);
}
.gev-action-button:disabled,
.scene-btn:disabled {
opacity: 0.45;
cursor: default;
}
.gev-action-button--danger:hover:not(:disabled),
.scene-btn-danger:hover:not(:disabled) {
color: #ff7272;
border-color: #ff7272;
background: rgba(255, 88, 88, 0.15);
}
/* Shot-row actions retain their smaller footprint and quieter hover treatment. */
.gev-action-button--compact {
border-radius: 6px;
font-size: 8px;
letter-spacing: 0.8px;
padding: 4px 6px;
}
.gev-action-button--compact:hover:not(:disabled) {
color: var(--text-primary);
border-color: var(--glass-border-hover);
background: rgba(255, 255, 255, 0.05);
}
.gev-action-button--compact.gev-action-button--danger:hover:not(:disabled) {
color: #ff7e7e;
border-color: #ff7e7e;
}
+22 -72
View File
@@ -109,6 +109,10 @@ html:not([lang='en']) .cctv-controls .scene-btn {
gap: 5px;
}
.cctv-cal-input {
width: 58px;
}
.cctv-cal-value {
font-family: var(--font-mono);
font-size: 9px;
@@ -129,26 +133,6 @@ html:not([lang='en']) .cctv-controls .scene-btn {
background: rgba(0, 212, 255, 0.08);
}
.cctv-cal-input {
width: 58px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.7px;
color: var(--text-primary);
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.5);
border-radius: 4px;
padding: 1px 3px;
outline: none;
-moz-appearance: textfield;
}
.cctv-cal-input::-webkit-outer-spin-button,
.cctv-cal-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.cctv-calibration-actions {
margin-top: 8px;
}
@@ -162,26 +146,9 @@ html:not([lang='en']) .cctv-controls .scene-btn {
#cctv-camera-select {
flex: 1;
min-width: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
color: var(--text-primary);
padding: 6px 8px;
font-family: var(--font-sans);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
outline: none;
}
#cctv-camera-select:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: -3px;
}
#cctv-camera-select:disabled {
opacity: 0.45;
}
#cctv-enable-btn.active,
@@ -194,41 +161,32 @@ html:not([lang='en']) .cctv-controls .scene-btn {
}
#cctv-quality-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 90px;
padding: 6px 8px;
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 8px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1px;
color: #8fe7ff;
background: rgba(0, 212, 255, 0.08);
text-transform: uppercase;
--status-badge-border: rgba(0, 212, 255, 0.2);
--status-badge-color: #8fe7ff;
--status-badge-background: rgba(0, 212, 255, 0.08);
}
/* CAL badge states (cctv-v2 §3b, amended by LOCKED §9.2 — panel-only, no
in-world tint). Three visually distinct states: calibrated / curated /
raw-prior. */
#cctv-quality-chip[data-cal-badge='calibrated'] {
color: #adffbf;
border-color: rgba(141, 255, 135, 0.42);
background: rgba(141, 255, 135, 0.12);
--status-badge-color: #adffbf;
--status-badge-border: rgba(141, 255, 135, 0.42);
--status-badge-background: rgba(141, 255, 135, 0.12);
}
#cctv-quality-chip[data-cal-badge='curated'] {
color: #ffe6a2;
border-color: rgba(255, 217, 122, 0.35);
background: rgba(255, 217, 122, 0.1);
--status-badge-color: #ffe6a2;
--status-badge-border: rgba(255, 217, 122, 0.35);
--status-badge-background: rgba(255, 217, 122, 0.1);
}
/* Unsaved live edits (save-gated persistence, viewshed design §3e). */
#cctv-quality-chip[data-cal-badge='edited'] {
color: #ffd08a;
border-color: rgba(255, 176, 82, 0.5);
background: rgba(255, 176, 82, 0.14);
--status-badge-color: #ffd08a;
--status-badge-border: rgba(255, 176, 82, 0.5);
--status-badge-background: rgba(255, 176, 82, 0.14);
}
.cctv-frame-wrap {
@@ -346,26 +304,18 @@ html:not([lang='en']) .cctv-controls .scene-btn {
#cctv-source-badge {
align-self: flex-start;
border: 1px solid rgba(0, 212, 255, 0.32);
border-radius: 999px;
background: rgba(0, 212, 255, 0.09);
color: #9defff;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1.3px;
padding: 4px 8px;
}
#cctv-source-badge[data-frame-state='loading'] {
color: #ffe59b;
border-color: rgba(255, 205, 92, 0.45);
background: rgba(255, 205, 92, 0.1);
--status-badge-color: #ffe59b;
--status-badge-border: rgba(255, 205, 92, 0.45);
--status-badge-background: rgba(255, 205, 92, 0.1);
}
#cctv-source-badge[data-frame-state='error'] {
color: #ffb0b0;
border-color: rgba(255, 102, 102, 0.5);
background: rgba(255, 72, 72, 0.11);
--status-badge-color: #ffb0b0;
--status-badge-border: rgba(255, 102, 102, 0.5);
--status-badge-background: rgba(255, 72, 72, 0.11);
}
.cctv-summary-label {
+51
View File
@@ -0,0 +1,51 @@
/* Tight tracking keeps Chinese labels readable; preserve Latin tracking for
intel HUD readouts and content marked translate="no", including its containers. */
html:lang(zh)
body
:not(
#intel-hud,
#intel-hud *,
[translate='no'],
[translate='no'] *,
:has([translate='no'])
) {
letter-spacing: 0.02em !important;
}
/* Keep small Chinese labels legible at a minimum of 9px. */
html:lang(zh) #command-dock .gev-mic-label,
html:lang(zh) #command-dock #gev-voice-detail,
html:lang(zh) .gev-voice-tier-btn,
html:lang(zh) .panel-layer-toggle {
font-size: 9px;
}
/* A two-glyph button label must not break between its glyphs. */
html:lang(zh) .pp-mode-btn {
white-space: nowrap;
}
/* Keep unit symbols in their catalog case, including the cockpit readouts. */
html:lang(zh) #intel-hud *,
html:lang(zh) #cockpit-hud * {
text-transform: none;
}
/* Move short travel modes (步行 / 骑行) to the next line as whole words. */
html:lang(zh) [data-layer-id='directions'] .data-toggle-meta {
word-break: keep-all;
overflow-wrap: break-word;
}
/* Leave room for the complete sensor name and resolution on two lines. */
html:lang(zh) .recent-imagery-readout .ri-strip {
height: 131px;
}
html:lang(zh) .recent-imagery-readout .ri-card {
height: 123px;
grid-template-rows: 42px 13px 22px 11px 18px;
}
html:lang(zh) .recent-imagery-readout .ri-card-sensor {
white-space: normal;
text-overflow: clip;
}
-10
View File
@@ -727,16 +727,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
.pp-select {
flex: 1;
min-width: 0;
height: 24px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.6px;
padding: 2px 6px;
outline: none;
}
/* DISPLAY ▸ Draw: shape segment, label field, colour, and the one-line hint */
+11 -1
View File
@@ -171,6 +171,7 @@
.dock-tray-toggle,
.dock-popover,
.scene-btn,
.gev-action-button:not(.gev-action-button--compact),
.context-mode-button,
.map-stack-chip,
.key-setup-chip,
@@ -543,12 +544,21 @@
text-shadow: none;
}
:root[data-ui-theme='cyber'] :where(.pp-select, select, input, textarea) {
:root[data-ui-theme='cyber']
:where(.pp-select, select, input, textarea):where(:not(.gev-field)) {
border-color: rgba(206, 222, 226, 0.24);
background-color: rgba(26, 35, 39, 0.96);
color: var(--text-primary);
}
/* Shared fields consume theme palettes through their own surface rule.
Feature palettes (Radio) retain precedence without restyling native fields. */
:root[data-ui-theme='cyber'] .gev-field {
--gev-field-border-color: rgba(206, 222, 226, 0.24);
--gev-field-background: rgba(26, 35, 39, 0.96);
--gev-field-color: var(--text-primary);
}
:root[data-ui-theme='cyber']
:where(
.panel-title,
+50
View File
@@ -0,0 +1,50 @@
/* Native selects and numeric inputs. This file owns the field surface/states;
features and themes supply palette variables and retain their own layout. */
.gev-field {
min-width: 0;
background: var(--gev-field-background, rgba(255, 255, 255, 0.06));
border: 1px solid var(--gev-field-border-color, rgba(255, 255, 255, 0.14));
border-radius: 7px;
color: var(--gev-field-color, var(--text-primary));
padding: 6px 8px;
font-family: var(--font-sans);
font-size: 12px;
outline: none;
}
.gev-field:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: -3px;
}
.gev-field:disabled {
opacity: var(--gev-field-disabled-opacity, 0.45);
}
.gev-field--compact {
height: 24px;
border-radius: 6px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.6px;
padding: 2px 6px;
}
/* Compact inline editors share density and hide native number spinners.
Their containing feature sets width to fit the surrounding readout. */
.gev-field--inline {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.7px;
--gev-field-background: rgba(0, 212, 255, 0.1);
--gev-field-border-color: rgba(0, 212, 255, 0.5);
border-radius: 4px;
padding: 1px 3px;
-moz-appearance: textfield;
}
.gev-field--inline::-webkit-outer-spin-button,
.gev-field--inline::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
+10 -6
View File
@@ -49,13 +49,15 @@
min-height: 36px;
}
#radio-filter {
min-width: 0;
/* Radio owns its compact density and arrow space; shared fields paint the
palette and disabled state, including this intentional theme exception. */
padding: 7px 24px 7px 8px;
border: 1px solid rgba(65, 220, 231, 0.28);
border-radius: 5px;
color: #c5faff;
background: rgba(5, 24, 31, 0.88);
font: 9px/1.2 var(--font-mono);
--gev-field-border-color: rgba(65, 220, 231, 0.28);
--gev-field-color: #c5faff;
--gev-field-background: rgba(5, 24, 31, 0.88);
--gev-field-disabled-opacity: 0.42;
}
.radio-station-card {
display: grid;
@@ -273,11 +275,13 @@ html:not([lang='en']) .radio-transport button {
color: #fff;
outline: none;
}
.radio-transport button:disabled,
#radio-filter:disabled {
.radio-transport button:disabled {
opacity: 0.42;
cursor: not-allowed;
}
#radio-filter:disabled {
cursor: not-allowed;
}
#radio-play-btn.active {
color: #fff;
border-color: rgba(101, 247, 255, 0.8);
-66
View File
@@ -153,14 +153,6 @@
#scene-select {
flex: 1;
min-width: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
color: var(--text-primary);
padding: 6px 8px;
font-family: var(--font-sans);
font-size: 12px;
outline: none;
}
/* A translated scene name needs the width: the select takes its own row,
@@ -184,21 +176,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
}
.scene-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1.2px;
padding: 6px 9px;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast),
opacity var(--transition-fast);
white-space: nowrap;
flex: 1;
min-width: 72px;
}
@@ -295,49 +272,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
gap: 6px;
}
.scene-shot-btn {
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.8px;
padding: 4px 6px;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
.scene-shot-btn:hover {
color: var(--text-primary);
border-color: var(--glass-border-hover);
}
.scene-shot-btn.scene-shot-danger:hover {
color: #ff7e7e;
border-color: #ff7e7e;
}
.scene-btn:hover:not(:disabled) {
color: var(--accent);
border-color: var(--accent);
background: rgba(0, 212, 255, 0.14);
}
.scene-btn:disabled {
opacity: 0.45;
cursor: default;
}
.scene-btn-danger:hover:not(:disabled) {
color: #ff7272;
border-color: #ff7272;
background: rgba(255, 88, 88, 0.15);
}
.scene-progress {
margin-top: 8px;
width: 100%;
+43
View File
@@ -0,0 +1,43 @@
/* Read-only badges. Feature CSS supplies meaning through explicit colors;
appearance variants change density/shape without interpreting status codes. */
.gev-status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
padding: 4px 8px;
border: 1px solid var(--status-badge-border, rgba(0, 212, 255, 0.32));
border-radius: 999px;
background: var(--status-badge-background, rgba(0, 212, 255, 0.09));
color: var(--status-badge-color, #9defff);
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1.3px;
overflow-wrap: anywhere;
}
.gev-status-badge--box {
padding: 6px 8px;
border-radius: 8px;
font-size: 9px;
letter-spacing: 1px;
text-transform: uppercase;
}
.gev-status-badge--plain {
padding: 0;
border: 0;
border-radius: 0;
background: none;
color: var(--status-badge-color, var(--text-dim));
font-size: 9px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
/* Explicitly beat the component display rule, including in older browsers. */
.gev-status-badge[hidden] {
display: none;
}
-4
View File
@@ -133,7 +133,6 @@
margin-left: auto;
text-align: right;
}
.rail-card-badge,
.rail-card-line,
.rail-timeline-readout {
font-family: var(--font-mono);
@@ -304,9 +303,6 @@ html:not([lang='en']) .rail-card-zero::after {
outline: 1px solid var(--accent);
outline-offset: 3px;
}
.rail-card-badge {
text-transform: uppercase;
}
.rail-card-compact {
grid-column: 1 / -1;
min-width: 0;
+3 -3
View File
@@ -121,7 +121,7 @@
<div class="radio-directory-row">
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
<select id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label>
<select class="gev-field" id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label>
<option value="all" data-i18n>All</option>
</select>
</div>
@@ -180,7 +180,7 @@
</div>
<div class="sdr-gain-row">
<label for="sdr-gain-select" data-i18n>GAIN</label>
<select id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode" data-i18n-aria-label>
<select class="gev-field" id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode" data-i18n-aria-label>
<option value="auto" data-i18n>AUTO</option>
<option value="0.0" translate="no">0.0 dB</option>
<option value="0.9" translate="no">0.9 dB</option>
@@ -221,7 +221,7 @@
</div>
<div class="sdr-frequency-row">
<label for="sdr-frequency-input" data-i18n>FM MHZ</label>
<input id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" />
<input class="gev-field" id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" />
<button id="sdr-tune-btn" type="button" data-i18n>TUNE</button>
</div>
<div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations" data-i18n-aria-label>
+2 -2
View File
@@ -13,7 +13,7 @@
</button>
<div class="pp-slider-row" id="hud-layout-row">
<span class="pp-slider-mini-label" data-i18n>Layout</span>
<select id="hud-layout-select" class="pp-select" aria-label="HUD layout" data-i18n-aria-label>
<select id="hud-layout-select" class="pp-select gev-field gev-field--compact" aria-label="HUD layout" data-i18n-aria-label>
<option value="tactical" data-i18n>Tactical</option>
<option value="operator" data-i18n>Operator</option>
<option value="minimal" data-i18n>Minimal</option>
@@ -136,7 +136,7 @@
</div>
<div class="pp-slider-row" id="draw-label-row">
<input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false">
<select class="pp-select draw-color-select" id="draw-color-select" aria-label="Colour of the drawn shape" data-i18n-aria-label>
<select class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" aria-label="Colour of the drawn shape" data-i18n-aria-label>
<option value="primary" data-i18n>Primary</option>
<option value="amber" data-i18n>Amber</option>
<option value="cyan" data-i18n>Cyan</option>
+20 -20
View File
@@ -27,25 +27,25 @@
<img id="cctv-frame" alt="CCTV feed frame" />
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
</div>
<div id="cctv-source-badge" role="status" aria-live="polite" data-i18n>SOURCE · UNKNOWN</div>
<div id="cctv-source-badge" class="gev-status-badge" role="status" aria-live="polite" data-i18n>SOURCE · UNKNOWN</div>
<div id="cctv-meta" data-i18n>Enable CCTV to load camera intersections</div>
<div class="cctv-controls">
<button id="cctv-enable-btn" class="scene-btn" data-i18n>CCTV OFF</button>
<button id="cctv-nearest-btn" class="scene-btn" data-i18n>NEAREST</button>
<button id="cctv-nearest-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEAREST</button>
</div>
<div class="cctv-controls">
<button id="cctv-prev-btn" class="scene-btn" data-i18n>PREV</button>
<select id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label></select>
<button id="cctv-next-btn" class="scene-btn" data-i18n>NEXT</button>
<button id="cctv-prev-btn" type="button" class="gev-action-button scene-btn" data-i18n>PREV</button>
<select class="gev-field" id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label></select>
<button id="cctv-next-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEXT</button>
</div>
<div class="cctv-controls">
<button id="cctv-focus-btn" class="scene-btn" data-i18n>FOCUS</button>
<button id="cctv-focus-btn" type="button" class="gev-action-button scene-btn" data-i18n>FOCUS</button>
<button id="cctv-coverage-btn" class="scene-btn" data-i18n>COVERAGE OFF</button>
<button id="cctv-auto-hop-btn" class="scene-btn" data-i18n>AUTO HOP OFF</button>
</div>
<div class="cctv-controls">
<button id="cctv-projection-btn" class="scene-btn" data-i18n>PROJECTION ON</button>
<span id="cctv-quality-chip" data-i18n-runtime>CAL · --</span>
<span id="cctv-quality-chip" class="gev-status-badge gev-status-badge--box" data-i18n-runtime>CAL · --</span>
</div>
<div class="cctv-calibration-block">
<div class="cctv-calibration-header">
@@ -62,8 +62,8 @@
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type" data-i18n-title>ΔE --</button>
</div>
<div class="cctv-controls cctv-calibration-actions">
<button id="cctv-calib-save-btn" class="scene-btn" data-i18n>SAVE CAL</button>
<button id="cctv-calib-reset-btn" class="scene-btn" data-i18n>RESET CAL</button>
<button id="cctv-calib-save-btn" type="button" class="gev-action-button scene-btn" data-i18n>SAVE CAL</button>
<button id="cctv-calib-reset-btn" type="button" class="gev-action-button scene-btn" data-i18n>RESET CAL</button>
</div>
</div>
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
@@ -152,24 +152,24 @@
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel" data-i18n-title>+</button>
</div>
<div class="scene-controls">
<select id="scene-select" aria-label="Scene recipe" data-i18n-aria-label></select>
<button id="scene-new-btn" class="scene-btn" data-i18n>NEW</button>
<button id="scene-delete-btn" class="scene-btn scene-btn-danger" data-i18n>DEL</button>
<select class="gev-field" id="scene-select" aria-label="Scene recipe" data-i18n-aria-label></select>
<button id="scene-new-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEW</button>
<button id="scene-delete-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" data-i18n>DEL</button>
</div>
<div class="scene-controls">
<button id="scene-capture-btn" class="scene-btn" data-i18n>CAPTURE SHOT</button>
<button id="scene-update-shot-btn" class="scene-btn" data-i18n>UPDATE SHOT</button>
<button id="scene-capture-btn" type="button" class="gev-action-button scene-btn" data-i18n>CAPTURE SHOT</button>
<button id="scene-update-shot-btn" type="button" class="gev-action-button scene-btn" data-i18n>UPDATE SHOT</button>
</div>
<div id="scene-shot-list" class="scene-shot-list"></div>
<div class="scene-controls">
<button id="scene-start-btn" class="scene-btn" data-i18n>START</button>
<button id="scene-stop-btn" class="scene-btn scene-btn-danger" data-i18n>STOP</button>
<button id="scene-next-btn" class="scene-btn" data-i18n>NEXT</button>
<button id="scene-start-btn" type="button" class="gev-action-button scene-btn" data-i18n>START</button>
<button id="scene-stop-btn" type="button" class="gev-action-button gev-action-button--danger scene-btn scene-btn-danger" data-i18n>STOP</button>
<button id="scene-next-btn" type="button" class="gev-action-button scene-btn" data-i18n>NEXT</button>
</div>
<div class="scene-controls">
<button id="scene-export-btn" class="scene-btn" data-i18n>EXPORT PRESETS</button>
<button id="scene-import-btn" class="scene-btn" data-i18n>IMPORT</button>
<button id="scene-download-btn" class="scene-btn" data-i18n>RUN LOG</button>
<button id="scene-export-btn" type="button" class="gev-action-button scene-btn" data-i18n>EXPORT PRESETS</button>
<button id="scene-import-btn" type="button" class="gev-action-button scene-btn" data-i18n>IMPORT</button>
<button id="scene-download-btn" type="button" class="gev-action-button scene-btn" data-i18n>RUN LOG</button>
<input type="file" id="scene-import-file" accept="application/json,.json" hidden />
</div>
<div class="scene-progress">
+7
View File
@@ -112,6 +112,13 @@ export class VisualEffects {
if (this.stopped || !stage) return;
stage.uniforms.intensity = value;
stage.enabled = value > 0.001;
// Anime's bands turn the faint Milky Way into blotches, and depth cannot
// tell the sky from far geometry, so the glow fades with the style.
if (
stage === this.stages.anime &&
this.viewer.scene.skyBox?.milkyWayVisibility !== undefined
)
this.viewer.scene.skyBox.milkyWayVisibility = 1 - value;
if (stage.enabled && stage.uniforms.time !== undefined)
this.startAnimationLoop();
this.requestRender('style-stage');
+36 -1
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { VisualEffects } from './visualEffects.js';
import { MILKY_WAY_INTENSITY, StarrySky } from '../sky/starrySky.js';
import {
GLOBAL_POST_DEFAULTS,
STYLE_PRESET_DEFAULTS,
@@ -36,7 +37,9 @@ function fixture() {
},
};
const effects = new VisualEffects({
viewer: { scene: { postProcessStages: pipeline } },
viewer: {
scene: { postProcessStages: pipeline, skyBox: { milkyWayVisibility: 1 } },
},
requestRender: (reason) => requests.push(reason),
holdRender: (reason) => holds.add(reason),
releaseRender: (reason) => holds.delete(reason),
@@ -158,6 +161,38 @@ test('direct Cockpit intensity writes are reconciled before rendering', () => {
f.effects.destroy();
});
test('the anime stage fades the Milky Way out with its own intensity', () => {
const f = fixture();
const sky = new StarrySky({ requestRender: () => {} });
const glow = () => sky._milkyWayUniforms.u_intensity();
f.effects.viewer.scene.skyBox = sky;
f.effects.initStyles();
f.effects.setStageIntensity(f.effects.stages.thermal, 1);
assert.equal(glow(), MILKY_WAY_INTENSITY, 'other styles keep the glow');
f.effects.setStageIntensity(f.effects.stages.anime, 0.25);
assert.equal(glow(), MILKY_WAY_INTENSITY * 0.75);
f.effects.setStageIntensity(f.effects.stages.anime, 1);
assert.equal(glow(), 0);
f.effects.setStageIntensity(f.effects.stages.anime, 0);
assert.equal(glow(), MILKY_WAY_INTENSITY);
sky.destroy();
f.effects.destroy();
});
for (const skyBox of [undefined, null, {}]) {
test(`anime works without StarrySky (${JSON.stringify(skyBox)})`, () => {
const f = fixture();
f.effects.viewer.scene.skyBox = skyBox;
f.effects.initStyles();
f.effects.setStageIntensity(f.effects.stages.anime, 1);
assert.equal(f.effects.stages.anime.enabled, true);
f.effects.setStageIntensity(f.effects.stages.anime, 0);
assert.equal(f.effects.stages.anime.enabled, false);
if (skyBox) assert.equal('milkyWayVisibility' in skyBox, false);
f.effects.destroy();
});
}
test('bloom retains its dead zone, normalized intensity and independent toggle', () => {
const f = fixture();
f.effects.initPostProcess();