mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into codex/fix-share-style-keys
This commit is contained in:
118 files changed
+18492
-593
No files matched your search
@@ -219,6 +219,17 @@ test('the separate viewer export imports without constructing a browser viewer',
|
||||
assert.throws(() => createApplicationViewer({}), /containers are required/);
|
||||
});
|
||||
|
||||
test('the viewer gets the starry sky as an option, which keeps the Sun and Moon', async () => {
|
||||
const { applicationViewerOptions } = await import('./viewer.js');
|
||||
const { StarrySky } = await import('../sky/starrySky.js');
|
||||
const creditContainer = {};
|
||||
const options = applicationViewerOptions(creditContainer);
|
||||
// CesiumWidget skips scene.sun and scene.moon when skyBox is false.
|
||||
assert.ok(options.skyBox instanceof StarrySky);
|
||||
assert.equal(options.creditContainer, creditContainer);
|
||||
options.skyBox.destroy();
|
||||
});
|
||||
|
||||
function pinchFixture({ zoomEventTypes } = {}) {
|
||||
const handlers = new Set();
|
||||
const relayed = [];
|
||||
|
||||
+18
-6
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
|
||||
import { StarrySky } from '../sky/starrySky.js';
|
||||
|
||||
const PINCH_ZOOM_MULTIPLIER = 8;
|
||||
const MAX_PINCH_PIXEL_DELTA = 120;
|
||||
@@ -102,11 +103,9 @@ export function installTrackpadPinchZoom(
|
||||
};
|
||||
}
|
||||
|
||||
/** Create the standard globe viewer in caller-owned, visible containers. */
|
||||
export function createApplicationViewer({ container, creditContainer }) {
|
||||
if (!container || !creditContainer)
|
||||
throw new TypeError('Viewer and credit containers are required');
|
||||
const viewer = new Cesium.Viewer(container, {
|
||||
/** Cesium.Viewer options of the standard globe viewer. */
|
||||
export function applicationViewerOptions(creditContainer) {
|
||||
return {
|
||||
timeline: false,
|
||||
animation: false,
|
||||
baseLayerPicker: false,
|
||||
@@ -119,10 +118,23 @@ export function createApplicationViewer({ container, creditContainer }) {
|
||||
selectionIndicator: false,
|
||||
infoBox: false,
|
||||
baseLayer: false,
|
||||
// Replaces the blocky Tycho-2 default. An option, not `skyBox: false`,
|
||||
// which would also stop CesiumWidget from creating the Sun and Moon.
|
||||
skyBox: new StarrySky(),
|
||||
creditContainer,
|
||||
msaaSamples: 4,
|
||||
contextOptions: { webgl: { preserveDrawingBuffer: true } },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Create the standard globe viewer in caller-owned, visible containers. */
|
||||
export function createApplicationViewer({ container, creditContainer }) {
|
||||
if (!container || !creditContainer)
|
||||
throw new TypeError('Viewer and credit containers are required');
|
||||
const viewer = new Cesium.Viewer(
|
||||
container,
|
||||
applicationViewerOptions(creditContainer),
|
||||
);
|
||||
try {
|
||||
viewer.targetFrameRate = 60;
|
||||
// Before any tile builds a draw command: Cesium's per-vertex model
|
||||
|
||||
@@ -412,14 +412,18 @@ const IMAGERY_LABEL_NAMES = Object.freeze([
|
||||
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
|
||||
]);
|
||||
|
||||
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
|
||||
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021, 2021年10月16日. */
|
||||
function imageryDateText(observedAt) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
|
||||
if (!match) return '';
|
||||
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
|
||||
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase();
|
||||
const year = part({ year: 'numeric' });
|
||||
// Year-first languages (2021年10月16日) keep their own order and units.
|
||||
const whole = part({ day: 'numeric', month: 'short', year: 'numeric' });
|
||||
if (whole.startsWith(year)) return whole;
|
||||
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${year}`.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1029,7 +1033,7 @@ function createPanel(event, handlers) {
|
||||
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
|
||||
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
|
||||
</div>
|
||||
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> ${escapeMarkup(t('Clouds are preserved from the source imagery.'))}</span></p>
|
||||
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note">${escapeMarkup(t(' Clouds are preserved from the source imagery.'))}</span></p>
|
||||
`;
|
||||
|
||||
const refs = collectPanelReferences(panel);
|
||||
|
||||
@@ -33,6 +33,14 @@ const VIDEO = {
|
||||
feedType: 'video',
|
||||
url: 'https://cams.upstream.invalid/live.mp4',
|
||||
};
|
||||
const WEBM = {
|
||||
id: 'webm-fixture',
|
||||
name: 'WebM fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'webm',
|
||||
url: 'https://cams.upstream.invalid/live.webm',
|
||||
};
|
||||
const MJPEG = {
|
||||
id: 'mjpeg-fixture',
|
||||
name: 'MJPEG fixture',
|
||||
@@ -69,7 +77,10 @@ function recordingResponse() {
|
||||
body: '',
|
||||
writeHead(status, headers) {
|
||||
res.statusCode = status;
|
||||
res.headers = headers || {};
|
||||
res.headers = { ...res.headers, ...headers };
|
||||
},
|
||||
setHeader(name, value) {
|
||||
res.headers[name] = value;
|
||||
},
|
||||
write(chunk) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
@@ -105,6 +116,7 @@ function mount(t, respond) {
|
||||
STILL,
|
||||
STILL_ALIAS,
|
||||
VIDEO,
|
||||
WEBM,
|
||||
MJPEG,
|
||||
]);
|
||||
process.env.CCTV_SOURCES_FILE = 'absent-source-file.json';
|
||||
@@ -143,9 +155,9 @@ function mount(t, respond) {
|
||||
});
|
||||
assert.ok(handler, 'the CCTV provider must mount a middleware');
|
||||
|
||||
const call = async (path) => {
|
||||
const call = async (path, headers = {}) => {
|
||||
const { res, done } = recordingResponse();
|
||||
await handler({ url: path, headers: {}, method: 'GET', on() {} }, res);
|
||||
await handler({ url: path, headers, method: 'GET', on() {} }, res);
|
||||
await done;
|
||||
return res;
|
||||
};
|
||||
@@ -268,3 +280,251 @@ test('/frame does not follow a still camera off its origin', async (t) => {
|
||||
[STILL.url],
|
||||
);
|
||||
});
|
||||
|
||||
/** An upstream Response whose body records whether it was cancelled. */
|
||||
function trackedUpstream({ status = 200, headers = {}, body = 'payload' }) {
|
||||
const state = { cancelled: false };
|
||||
const response = new Response(
|
||||
new ReadableStream(
|
||||
{
|
||||
pull(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(body));
|
||||
controller.close();
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 0 },
|
||||
),
|
||||
{ status, headers },
|
||||
);
|
||||
return { response, state };
|
||||
}
|
||||
|
||||
test('/media refuses an upstream type that does not match the feed kind', async (t) => {
|
||||
let current;
|
||||
const app = mount(t, () => current.response);
|
||||
for (const [camera, contentType, body] of [
|
||||
[VIDEO, 'text/html', '<!doctype html><p>x</p>'],
|
||||
[VIDEO, 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
|
||||
[VIDEO, 'application/vnd.apple.mpegurl', '#EXTM3U'],
|
||||
[VIDEO, 'video/webm', 'video'],
|
||||
[VIDEO, undefined, 'video'],
|
||||
[WEBM, 'video/mp4', 'video'],
|
||||
[MJPEG, 'multipart/x-mixed-replace', '--frame'],
|
||||
[MJPEG, 'multipart/x-mixed-replace; boundary=', '--frame'],
|
||||
[MJPEG, 'image/jpeg', 'jpeg'],
|
||||
[MJPEG, 'text/html; boundary=frame', '<!doctype html><p>x</p>'],
|
||||
]) {
|
||||
current = trackedUpstream({
|
||||
headers: contentType === undefined ? {} : { 'content-type': contentType },
|
||||
body,
|
||||
});
|
||||
const label = `${camera.id}: ${contentType}`;
|
||||
const res = await app.call(`/media/${camera.id}`);
|
||||
assert.equal(res.statusCode, 502, label);
|
||||
assert.equal(res.headers['Content-Type'], 'application/json', label);
|
||||
assert.deepEqual(JSON.parse(res.body), { error: 'Media proxy failed' });
|
||||
assert.equal(current.state.cancelled, true, `${label}: upstream cancelled`);
|
||||
const health = await app.health(camera.id);
|
||||
assert.equal(health.status, 'degraded', label);
|
||||
assert.equal(health.message, 'Unexpected media type', label);
|
||||
}
|
||||
});
|
||||
|
||||
test('/media relays each feed kind under its canonical type, including a partial response', async (t) => {
|
||||
let current;
|
||||
const app = mount(t, () => current);
|
||||
for (const [camera, upstream, expected] of [
|
||||
[VIDEO, { 'content-type': 'Video/MP4; codecs="avc1.42E01E"' }, 'video/mp4'],
|
||||
[
|
||||
VIDEO,
|
||||
{
|
||||
'content-type': 'video/mp4',
|
||||
'content-range': 'bytes 0-4/100',
|
||||
'content-length': '5',
|
||||
'accept-ranges': 'bytes',
|
||||
},
|
||||
'video/mp4',
|
||||
],
|
||||
[WEBM, { 'content-type': 'video/webm' }, 'video/webm'],
|
||||
[
|
||||
MJPEG,
|
||||
{ 'content-type': 'multipart/x-mixed-replace;boundary="frame"' },
|
||||
'multipart/x-mixed-replace; boundary=frame',
|
||||
],
|
||||
[
|
||||
MJPEG,
|
||||
{ 'content-type': 'multipart/x-mixed-replace; boundary="a b"' },
|
||||
'multipart/x-mixed-replace; boundary="a b"',
|
||||
],
|
||||
]) {
|
||||
const status = upstream['content-range'] ? 206 : 200;
|
||||
current = upstreamResponse({ status, headers: upstream, body: 'bytes' });
|
||||
const res = await app.call(`/media/${camera.id}`);
|
||||
assert.equal(res.statusCode, status, camera.id);
|
||||
assert.equal(res.headers['Content-Type'], expected);
|
||||
assert.equal(res.body, 'bytes');
|
||||
if (status === 206)
|
||||
assert.equal(res.headers['Content-Range'], 'bytes 0-4/100');
|
||||
assert.equal((await app.health(camera.id)).status, 'ok');
|
||||
}
|
||||
});
|
||||
|
||||
test('/media does not follow a live camera off its origin', async (t) => {
|
||||
const app = mount(t, ({ url }) =>
|
||||
url === VIDEO.url
|
||||
? upstreamResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://elsewhere.invalid/live.mp4' },
|
||||
body: '',
|
||||
})
|
||||
: upstreamResponse({
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: 'elsewhere',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${VIDEO.id}`);
|
||||
assert.equal(res.statusCode, 502);
|
||||
assert.doesNotMatch(res.body, /elsewhere/);
|
||||
assert.deepEqual(app.requests, [{ url: VIDEO.url, redirect: 'manual' }]);
|
||||
});
|
||||
|
||||
test('every CCTV route refuses cross-site requests and admits the app itself', async (t) => {
|
||||
const app = mount(t, ({ url }) =>
|
||||
upstreamResponse({
|
||||
headers: {
|
||||
'content-type': url === VIDEO.url ? 'video/mp4' : 'image/jpeg',
|
||||
},
|
||||
body: 'bytes',
|
||||
}),
|
||||
);
|
||||
const host = 'localhost:5173';
|
||||
const paths = [
|
||||
'/sources',
|
||||
'/health',
|
||||
`/stream/${STILL.id}`,
|
||||
`/frame/${STILL.id}`,
|
||||
`/media/${VIDEO.id}`,
|
||||
];
|
||||
for (const headers of [
|
||||
{ host, 'sec-fetch-site': 'cross-site' },
|
||||
{ host, 'sec-fetch-site': 'same-site' },
|
||||
{ host, origin: 'https://elsewhere.example' },
|
||||
{ host, origin: 'null' },
|
||||
])
|
||||
for (const path of paths) {
|
||||
const res = await app.call(path, headers);
|
||||
assert.equal(res.statusCode, 403, `${path} ${JSON.stringify(headers)}`);
|
||||
}
|
||||
assert.deepEqual(app.requests, [], 'a refused request reached an upstream');
|
||||
for (const headers of [
|
||||
{ host, 'sec-fetch-site': 'same-origin' },
|
||||
{ host, origin: `http://${host}` },
|
||||
{},
|
||||
]) {
|
||||
const frame = await app.call(`/frame/${STILL.id}`, headers);
|
||||
assert.equal(frame.statusCode, 200);
|
||||
assert.equal(frame.headers['X-CCTV-Source'], 'upstream-image');
|
||||
const media = await app.call(`/media/${VIDEO.id}`, headers);
|
||||
assert.equal(media.statusCode, 200);
|
||||
assert.equal(media.headers['Content-Type'], 'video/mp4');
|
||||
}
|
||||
});
|
||||
|
||||
test('/frame falls back past a non-raster upstream and Street View frame', async (t) => {
|
||||
let streetViewType;
|
||||
const app = mount(t, ({ url }) =>
|
||||
url.startsWith('https://maps.googleapis.com/')
|
||||
? trackedUpstream({
|
||||
headers:
|
||||
streetViewType === undefined
|
||||
? {}
|
||||
: { 'content-type': streetViewType },
|
||||
body: 'street-view',
|
||||
}).response
|
||||
: upstreamResponse({
|
||||
headers: { 'content-type': 'image/svg+xml' },
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
}),
|
||||
);
|
||||
process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key';
|
||||
for (const type of [
|
||||
'image/svg+xml',
|
||||
'text/html',
|
||||
'application/xml',
|
||||
undefined,
|
||||
]) {
|
||||
streetViewType = type;
|
||||
const res = await app.call(`/frame/${STILL.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'synthetic', String(type));
|
||||
assert.doesNotMatch(res.body, /street-view/);
|
||||
}
|
||||
streetViewType = 'image/JPEG; charset=x';
|
||||
const res = await app.call(`/frame/${STILL.id}`);
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'streetview');
|
||||
assert.equal(res.headers['Content-Type'], 'image/jpeg');
|
||||
assert.equal(res.body, 'street-view');
|
||||
});
|
||||
|
||||
test('a forwarded same-origin request reaches every CCTV route but not the Street View fallback', async (t) => {
|
||||
const app = mount(t, ({ url }) => {
|
||||
if (url.startsWith('https://maps.googleapis.com/'))
|
||||
return trackedUpstream({
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: 'street-view',
|
||||
}).response;
|
||||
return url === VIDEO.url
|
||||
? upstreamResponse({ headers: { 'content-type': 'video/mp4' } })
|
||||
: upstreamResponse({ status: 503, body: '' });
|
||||
});
|
||||
process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key';
|
||||
const host = 'localhost:5173';
|
||||
const streetViewCalls = () =>
|
||||
app.requests.filter(({ url }) =>
|
||||
url.startsWith('https://maps.googleapis.com/'),
|
||||
).length;
|
||||
for (const forwarded of [
|
||||
{ 'x-forwarded-for': '203.0.113.9' },
|
||||
{ via: '1.1 proxy' },
|
||||
{ forwarded: 'for=203.0.113.9' },
|
||||
{ 'x-real-ip': '203.0.113.9' },
|
||||
{ 'x-forwarded-for': '' },
|
||||
{ 'x-forwarded-for': ' ' },
|
||||
{ 'cf-connecting-ip': '' },
|
||||
]) {
|
||||
const headers = { host, 'sec-fetch-site': 'same-origin', ...forwarded };
|
||||
const label = JSON.stringify(forwarded);
|
||||
assert.equal((await app.call('/sources', headers)).statusCode, 200, label);
|
||||
const media = await app.call(`/media/${VIDEO.id}`, headers);
|
||||
assert.equal(media.statusCode, 200, label);
|
||||
assert.equal(media.headers['Content-Type'], 'video/mp4', label);
|
||||
const frame = await app.call(`/frame/${STILL.id}`, headers);
|
||||
assert.equal(frame.statusCode, 200, label);
|
||||
assert.equal(frame.headers['X-CCTV-Source'], 'synthetic', label);
|
||||
assert.equal(frame.headers['Content-Type'], 'image/svg+xml', label);
|
||||
// Cross-site stays refused whatever the forwarding headers say.
|
||||
for (const path of ['/sources', `/frame/${STILL.id}`, `/media/${VIDEO.id}`])
|
||||
assert.equal(
|
||||
(
|
||||
await app.call(path, {
|
||||
...forwarded,
|
||||
host,
|
||||
'sec-fetch-site': 'cross-site',
|
||||
})
|
||||
).statusCode,
|
||||
403,
|
||||
`${label} ${path}`,
|
||||
);
|
||||
}
|
||||
assert.equal(streetViewCalls(), 0, 'a forwarded request spent the key');
|
||||
const direct = await app.call(`/frame/${STILL.id}`, {
|
||||
host,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
});
|
||||
assert.equal(direct.headers['X-CCTV-Source'], 'streetview');
|
||||
assert.equal(direct.body, 'street-view');
|
||||
assert.equal(streetViewCalls(), 1);
|
||||
});
|
||||
@@ -259,3 +259,57 @@ test('rejected snapshot responses abort the upstream download', async () => {
|
||||
assert.equal(signal.aborted, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch accepts only raster image types and sends their canonical type', async () => {
|
||||
const frame = (contentType, body = 'frame') => {
|
||||
const state = { cancelled: false };
|
||||
const headers = contentType === null ? {} : { 'Content-Type': contentType };
|
||||
const response = new Response(new ReadableStream({
|
||||
pull(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(body));
|
||||
controller.close();
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
}, { highWaterMark: 0 }), { status: 200, headers });
|
||||
return { response, state };
|
||||
};
|
||||
for (const [contentType, canonical] of [
|
||||
['image/jpeg', 'image/jpeg'],
|
||||
['image/JPEG; charset=x', 'image/jpeg'],
|
||||
[' Image/PNG ', 'image/png'],
|
||||
['image/gif', 'image/gif'],
|
||||
['image/webp;q=1', 'image/webp'],
|
||||
['image/avif', 'image/avif'],
|
||||
]) {
|
||||
const { response } = frame(contentType);
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
fetchImpl: async () => response,
|
||||
});
|
||||
assert.equal(result?.contentType, canonical, contentType);
|
||||
assert.equal(result.body.toString(), 'frame');
|
||||
}
|
||||
for (const [contentType, body] of [
|
||||
['image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
|
||||
['Image/SVG+XML; charset=utf-8', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
|
||||
['text/html', '<!doctype html><p>x</p>'],
|
||||
['text/html; charset=utf-8', '<!doctype html><p>x</p>'],
|
||||
['application/xml', '<x/>'],
|
||||
['text/xml', '<x/>'],
|
||||
[null, 'frame'],
|
||||
['', 'frame'],
|
||||
['image', 'frame'],
|
||||
['image/', 'frame'],
|
||||
['image/jpeg/x', 'frame'],
|
||||
['image/jpeg, text/html', 'frame'],
|
||||
['application/octet-stream', 'frame'],
|
||||
]) {
|
||||
const { response, state } = frame(contentType, body);
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
fetchImpl: async () => response,
|
||||
});
|
||||
assert.equal(result, null, String(contentType));
|
||||
assert.equal(state.cancelled, true, `${contentType}: body cancelled`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// Camera catalogs are pinned to their agency's image directory. The pin is a
|
||||
// parsed-URL check, not a string prefix, so a path that only starts with the
|
||||
// directory before dot segments, an encoded separator, credentials or another
|
||||
// port are resolved never counts as inside it, and a redirect on a pinned host
|
||||
// cannot leave the directory either.
|
||||
//
|
||||
// Run with: npm test (node --test)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadTflSourcesFromOpenData } from '../../server/providers/cctv/sources.js';
|
||||
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
|
||||
import { pinnedCameraUrl } from '../../server/providers/cctv/normalize.js';
|
||||
|
||||
const BUCKET = 'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
|
||||
|
||||
/** One available JamCam whose frame is at `imageUrl`. */
|
||||
const jamCam = (n, imageUrl) => ({
|
||||
id: `JamCams_00001.0000${n}`,
|
||||
commonName: `JamCam ${n}`,
|
||||
lat: 51.5072,
|
||||
lon: -0.1276,
|
||||
additionalProperties: [
|
||||
{ key: 'available', value: 'true' },
|
||||
{ key: 'imageUrl', value: imageUrl },
|
||||
],
|
||||
});
|
||||
|
||||
test('TfL JamCams keep only frames inside the official bucket, by parsed URL', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const accepted = [
|
||||
[`${BUCKET}00001.00001.jpg`, `${BUCKET}00001.00001.jpg`],
|
||||
[`${BUCKET}00001.00002.jpg?t=1`, `${BUCKET}00001.00002.jpg?t=1`],
|
||||
[
|
||||
'https://s3-eu-west-1.amazonaws.com:443/jamcams.tfl.gov.uk/00001.00003.jpg',
|
||||
`${BUCKET}00001.00003.jpg`,
|
||||
],
|
||||
];
|
||||
const rejected = [
|
||||
`${BUCKET}../other/x.jpg`,
|
||||
`${BUCKET}%2e%2e/other/x.jpg`,
|
||||
`${BUCKET}%2E%2E/other/x.jpg`,
|
||||
`${BUCKET}.%2e/other/x.jpg`,
|
||||
`${BUCKET}..%2fother/x.jpg`,
|
||||
`${BUCKET}..%5Cother/x.jpg`,
|
||||
`${BUCKET}..\\other\\x.jpg`,
|
||||
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk.other/x.jpg',
|
||||
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk-other/x.jpg',
|
||||
'https://s3-eu-west-1.amazonaws.com/other/jamcams.tfl.gov.uk/x.jpg',
|
||||
'https://user:[email protected]/jamcams.tfl.gov.uk/x.jpg',
|
||||
'https://s3-eu-west-1.amazonaws.com:8443/jamcams.tfl.gov.uk/x.jpg',
|
||||
'http://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/x.jpg',
|
||||
'https://s3-eu-west-1.amazonaws.com.other.example/jamcams.tfl.gov.uk/x.jpg',
|
||||
'not a url',
|
||||
];
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json(
|
||||
[...accepted.map(([url]) => url), ...rejected].map((url, i) =>
|
||||
jamCam(i + 1, url),
|
||||
),
|
||||
),
|
||||
);
|
||||
const cameras = await loadTflSourcesFromOpenData();
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.url),
|
||||
accepted.map(([, normalized]) => normalized),
|
||||
);
|
||||
});
|
||||
|
||||
test('a redirect on a pinned camera host stays inside the pinned directory', async () => {
|
||||
const redirectTo = (location) => async (url) =>
|
||||
url === `${BUCKET}00001.00001.jpg`
|
||||
? new Response(null, { status: 302, headers: { location } })
|
||||
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
|
||||
for (const location of [
|
||||
'/jamcams.tfl.gov.uk.other/x.jpg',
|
||||
'/other/x.jpg',
|
||||
'/jamcams.tfl.gov.uk/../other/x.jpg',
|
||||
'/jamcams.tfl.gov.uk/%2e%2e/other/x.jpg',
|
||||
'/jamcams.tfl.gov.uk/..%2fother/x.jpg',
|
||||
])
|
||||
assert.equal(
|
||||
await fetchWithinHost(
|
||||
`${BUCKET}00001.00001.jpg`,
|
||||
{},
|
||||
redirectTo(location),
|
||||
),
|
||||
null,
|
||||
location,
|
||||
);
|
||||
const followed = await fetchWithinHost(
|
||||
`${BUCKET}00001.00001.jpg`,
|
||||
{},
|
||||
redirectTo('/jamcams.tfl.gov.uk/00001.00002.jpg'),
|
||||
);
|
||||
assert.equal(await followed.text(), 'frame');
|
||||
// A host with no pinned directory keeps the same-origin rule alone.
|
||||
const unpinned = await fetchWithinHost(
|
||||
'https://cams.example/a/frame.jpg',
|
||||
{},
|
||||
async (url) =>
|
||||
url === 'https://cams.example/a/frame.jpg'
|
||||
? new Response(null, { status: 302, headers: { location: '/b/x.jpg' } })
|
||||
: new Response('frame'),
|
||||
);
|
||||
assert.equal(await unpinned.text(), 'frame');
|
||||
});
|
||||
|
||||
test('a pin prefix without a trailing slash still names a directory', () => {
|
||||
assert.equal(
|
||||
pinnedCameraUrl('https://h.example/camsX/a.jpg', 'https://h.example/cams'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -429,6 +429,13 @@ export const DATA_CREDITS = [
|
||||
'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.',
|
||||
},
|
||||
// ── Bundled snapshots ───────────────────────────────────────────
|
||||
{
|
||||
key: 'sky-background',
|
||||
html:
|
||||
'Stars: <a href="https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50" target="_blank" rel="noopener">Yale Bright Star Catalogue</a> ' +
|
||||
'(Hoffleit & Warren, via CDS). Milky Way: <a href="https://www.eso.org/public/images/eso0932a/" target="_blank" rel="noopener">ESO/S. Brunier</a> ' +
|
||||
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>; stars removed, blurred and reprojected)',
|
||||
},
|
||||
{
|
||||
key: 'dams',
|
||||
html: 'Dams: Open Infrastructure Map',
|
||||
|
||||
@@ -304,3 +304,61 @@ test('GBFS rejects redirects and caps bodies through both server hooks', async (
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('GBFS relays only JSON, re-serialized under a fixed type, through both server hooks', async () => {
|
||||
const { gbfsProxy } = await import('../../server/providers/gbfs.js');
|
||||
const originalFetch = globalThis.fetch;
|
||||
try {
|
||||
for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
let handler;
|
||||
gbfsProxy()[hook]({
|
||||
middlewares: {
|
||||
use(_path, callback) {
|
||||
handler = callback;
|
||||
},
|
||||
},
|
||||
});
|
||||
const request = async (response) => {
|
||||
globalThis.fetch = async () => response;
|
||||
const res = {};
|
||||
await handler(
|
||||
{ method: 'GET', url: '/' + encodeURIComponent(STATION_URL) },
|
||||
{
|
||||
writeHead(code, headers) {
|
||||
Object.assign(res, { status: code, headers });
|
||||
},
|
||||
end(value) {
|
||||
res.body = value;
|
||||
},
|
||||
},
|
||||
);
|
||||
return res;
|
||||
};
|
||||
for (const [contentType, body] of [
|
||||
['text/html', '<!doctype html><p>x</p>'],
|
||||
['application/json', '<!doctype html><p>x</p>'],
|
||||
['image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
|
||||
['application/json', ''],
|
||||
]) {
|
||||
const res = await request(
|
||||
new Response(body, { headers: { 'Content-Type': contentType } }),
|
||||
);
|
||||
assert.equal(res.status, 502, `${hook}: ${contentType} ${body}`);
|
||||
assert.equal(res.headers['Content-Type'], 'application/json');
|
||||
assert.equal(typeof JSON.parse(res.body).error, 'string');
|
||||
assert.doesNotMatch(res.body, /doctype|svg/);
|
||||
}
|
||||
const res = await request(
|
||||
new Response(' { "data" : { "stations" : [] } } ', {
|
||||
headers: { 'Content-Type': 'text/html; charset=iso-8859-1' },
|
||||
}),
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers['Content-Type'], 'application/json; charset=utf-8');
|
||||
assert.equal(res.headers['Cache-Control'], 'no-store');
|
||||
assert.equal(res.body, STATION_BODY);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
# Sky background
|
||||
|
||||
Catalogue stars over a soft Milky Way, drawn by `src/sky/starrySky.js` in
|
||||
place of Cesium's blocky Tycho-2 sky box.
|
||||
|
||||
| File | Content | Size |
|
||||
| ---- | ------- | ---- |
|
||||
| `bright_stars.json` | 9,096 stars: J2000 RA/Dec, V magnitude, B−V | 235 KB |
|
||||
| `milkyway_{px,nx,py,ny,pz,nz}.webp` | Milky Way glow, six 1024² cube faces, no stars | 62 KB |
|
||||
|
||||
## Stars — Yale Bright Star Catalogue
|
||||
|
||||
**Source:** 5th Revised Edition (Hoffleit & Warren 1991), CDS catalogue
|
||||
[V/50](https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50), file
|
||||
`https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz` (sha256
|
||||
`3dc44b1e90be8fbe5bcc7656032560f51275f985c7e3f783c9028e1838ec7bed`, fetched
|
||||
2026-10-19).
|
||||
|
||||
**License:** public domain. CDS asks for an acknowledgement; the "Data
|
||||
attribution" popover carries it.
|
||||
|
||||
**Build:** `node scripts/build-bright-stars.mjs [catalog.gz]`. Drops the 14
|
||||
records without a J2000 position; rounds RA/Dec to 0.001° and magnitudes to
|
||||
0.01.
|
||||
|
||||
## Milky Way — ESO/S. Brunier
|
||||
|
||||
**Source:** ESO image [eso0932a](https://www.eso.org/public/images/eso0932a/),
|
||||
`https://cdn.eso.org/images/original/eso0932a.tif` (6000×3000 sRGB TIFF, sha256
|
||||
`10f209ab83e1fd89e7fa1ed70277ffc6ed19c43549f04ec637c6806d98aff035`, fetched
|
||||
2026-10-19).
|
||||
|
||||
**License:** [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
|
||||
**Credit: ESO/S. Brunier.**
|
||||
|
||||
**Changes made** (CC BY 4.0 asks for them): resampled to 4096×2048; stars
|
||||
removed; two images of Jupiter painted over; blurred (0.4°, 2° in the faint
|
||||
glow); reprojected to ICRF cube faces; black level at the median sky; saved as
|
||||
WebP. Build: `node scripts/build-milky-way.mjs <eso0932a.tif>`.
|
||||
|
||||
The panorama's stars sit up to ~2° off; that is invisible in the blurred glow,
|
||||
and every sharp star comes from the catalogue.
|
||||
|
||||
NASA SVS "Deep Star Maps 2020" was not used: it derives from Gaia DR2, which
|
||||
ESA licenses CC BY-NC 3.0 IGO.
|
||||
|
||||
## Frame
|
||||
|
||||
Both layers are in ICRF/J2000. The cube faces use the WebGL face layout, +X
|
||||
to the March equinox and +Z to the north celestial pole, uploaded without
|
||||
`flipY`.
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
@@ -72,6 +72,7 @@ test('the Docker install path keeps its loopback-only, launcher-owned contract',
|
||||
|
||||
const compose = readFileSync(new URL('../compose.yaml', import.meta.url), 'utf8');
|
||||
assert.match(compose, /^\s+- "127\.0\.0\.1:4173:4173"$/m, 'the port is published on the host loopback only');
|
||||
assert.doesNotMatch(compose, /GEV_KEY_SETUP_TRUSTED_PEERS/, 'trust is derived at launch, never hard-coded');
|
||||
assert.match(compose, /^\s+- GEV_KEY_SETUP_TRUSTED_PEERS$/m, 'the operator policy reaches the launcher without a hard-coded address');
|
||||
assert.match(compose, /^\s+- GEV_ALLOWED_HOSTS$/m, 'the host allowlist reaches Vite');
|
||||
assert.doesNotMatch(compose, /network_mode/, 'the container keeps its own network namespace');
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import { resolveTrustedPeers } from '../scripts/docker-start.mjs';
|
||||
|
||||
const ROUTES = 'Iface Destination Gateway Flags\neth0 00000000 0100A8C0 0003\n';
|
||||
const launcher = fileURLToPath(
|
||||
new URL('../scripts/docker-start.mjs', import.meta.url),
|
||||
);
|
||||
const gateUrl = new URL('./keySetupCore.mjs', import.meta.url).href;
|
||||
|
||||
test('none resolves to an empty trust list even with a valid container gateway', () => {
|
||||
for (const value of ['none', ' NONE ', 'None']) {
|
||||
assert.equal(
|
||||
resolveTrustedPeers({
|
||||
env: { GEV_KEY_SETUP_TRUSTED_PEERS: value },
|
||||
routeTable: ROUTES,
|
||||
}),
|
||||
'',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function launchWithPolicy(value, routeTable = ROUTES) {
|
||||
// Run the real entry point and routing-table reader. Replace only the Linux
|
||||
// route file and Vite's listening server so this works on macOS and CI too.
|
||||
const fakeVite = `
|
||||
import { admitKeySetupRequest } from ${JSON.stringify(gateUrl)};
|
||||
const trustedAtImport = process.env.GEV_KEY_SETUP_TRUSTED_PEERS;
|
||||
export async function createServer() {
|
||||
const request = {
|
||||
method: 'POST', hostHeader: 'localhost:4173',
|
||||
origin: 'http://localhost:4173', contentType: 'application/json',
|
||||
env: { GEV_KEY_SETUP_TRUSTED_PEERS: process.env.GEV_KEY_SETUP_TRUSTED_PEERS }
|
||||
};
|
||||
console.log('POLICY ' + JSON.stringify({
|
||||
trustedAtImport,
|
||||
allowedHosts: process.env.GEV_ALLOWED_HOSTS,
|
||||
gateway: admitKeySetupRequest({ ...request, remoteAddress: '192.168.0.1' }),
|
||||
loopback: admitKeySetupRequest({ ...request, remoteAddress: '127.0.0.1' }),
|
||||
foreignOrigin: admitKeySetupRequest({ ...request, remoteAddress: '192.168.0.1', origin: 'https://foreign.example' })
|
||||
}));
|
||||
return { async listen() {}, printUrls() {}, async close() {} };
|
||||
}
|
||||
`;
|
||||
const preload = `
|
||||
import fs from 'node:fs';
|
||||
import { registerHooks, syncBuiltinESMExports } from 'node:module';
|
||||
const originalRead = fs.readFileSync;
|
||||
fs.readFileSync = (file, ...args) => file === '/proc/net/route'
|
||||
? ${JSON.stringify(routeTable)} : originalRead(file, ...args);
|
||||
syncBuiltinESMExports();
|
||||
registerHooks({ resolve(specifier, context, next) {
|
||||
if (specifier === 'vite') return {
|
||||
url: ${JSON.stringify(`data:text/javascript,${encodeURIComponent(fakeVite)}`)}, shortCircuit: true
|
||||
};
|
||||
return next(specifier, context);
|
||||
}});
|
||||
`;
|
||||
const env = { ...process.env, GEV_ALLOWED_HOSTS: 'globe.example.lan' };
|
||||
delete env.GEV_KEY_SETUP_TRUSTED_PEERS;
|
||||
if (value !== undefined) env.GEV_KEY_SETUP_TRUSTED_PEERS = value;
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'--import',
|
||||
`data:text/javascript,${encodeURIComponent(preload)}`,
|
||||
launcher,
|
||||
],
|
||||
{ env, encoding: 'utf8', timeout: 10000 },
|
||||
);
|
||||
assert.ifError(child.error);
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
const line = child.stdout
|
||||
.split('\n')
|
||||
.find((line) => line.startsWith('POLICY '));
|
||||
assert.ok(line, child.stdout);
|
||||
return { ...JSON.parse(line.slice(7)), output: child.stdout };
|
||||
}
|
||||
|
||||
test('the real Docker entry point seals none before importing Vite and the setup gate refuses the gateway', () => {
|
||||
const result = launchWithPolicy('none');
|
||||
assert.equal(result.trustedAtImport, '');
|
||||
assert.equal(result.gateway.status, 403);
|
||||
assert.equal(result.gateway.code, 'not-loopback');
|
||||
assert.equal(result.loopback.ok, true);
|
||||
assert.equal(result.allowedHosts, 'globe.example.lan');
|
||||
assert.match(result.output, /trusts no additional peers/);
|
||||
assert.doesNotMatch(result.output, /No default gateway/);
|
||||
});
|
||||
|
||||
test('unset, blank and explicit peers retain gateway admission without weakening Origin checks', () => {
|
||||
for (const value of [undefined, '', ' ', '192.168.0.1']) {
|
||||
const result = launchWithPolicy(value);
|
||||
assert.equal(result.trustedAtImport, '192.168.0.1');
|
||||
assert.equal(result.gateway.ok, true);
|
||||
assert.equal(result.foreignOrigin.ok, false);
|
||||
}
|
||||
const result = launchWithPolicy(undefined, '');
|
||||
assert.equal(result.trustedAtImport, '');
|
||||
assert.equal(result.gateway.status, 403);
|
||||
assert.match(result.output, /No default gateway/);
|
||||
});
|
||||
|
||||
test('Compose resolves shell and project dotenv policy before the launcher starts', (t) => {
|
||||
const version = spawnSync('docker', ['compose', 'version'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
});
|
||||
if (version.error || version.status !== 0)
|
||||
return t.skip('Docker Compose CLI is not installed');
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-compose-policy-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
writeFileSync(
|
||||
path.join(root, 'compose.yaml'),
|
||||
readFileSync(new URL('../compose.yaml', import.meta.url)),
|
||||
);
|
||||
const baseEnv = { ...process.env };
|
||||
for (const key of Object.keys(baseEnv)) {
|
||||
if (
|
||||
key.startsWith('COMPOSE_') ||
|
||||
['GEV_KEY_SETUP_TRUSTED_PEERS', 'GEV_ALLOWED_HOSTS'].includes(key)
|
||||
)
|
||||
delete baseEnv[key];
|
||||
}
|
||||
function config(dotenv = '', overrides = {}) {
|
||||
writeFileSync(path.join(root, '.env'), dotenv);
|
||||
const child = spawnSync(
|
||||
'docker',
|
||||
[
|
||||
'compose',
|
||||
'-f',
|
||||
'compose.yaml',
|
||||
'--env-file',
|
||||
'.env',
|
||||
'config',
|
||||
'--format',
|
||||
'json',
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
env: { ...baseEnv, ...overrides },
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
assert.ifError(child.error);
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
return JSON.parse(child.stdout).services['gods-eye-view'].environment || {};
|
||||
}
|
||||
const unset = config();
|
||||
assert.ok(unset.GEV_KEY_SETUP_TRUSTED_PEERS == null);
|
||||
assert.ok(unset.GEV_ALLOWED_HOSTS == null);
|
||||
const dotenv =
|
||||
'GEV_KEY_SETUP_TRUSTED_PEERS=none\nGEV_ALLOWED_HOSTS=globe.example.lan\n';
|
||||
assert.deepEqual(config(dotenv), {
|
||||
GEV_KEY_SETUP_TRUSTED_PEERS: 'none',
|
||||
GEV_ALLOWED_HOSTS: 'globe.example.lan',
|
||||
});
|
||||
assert.equal(
|
||||
config(dotenv, { GEV_KEY_SETUP_TRUSTED_PEERS: '10.9.8.7' })
|
||||
.GEV_KEY_SETUP_TRUSTED_PEERS,
|
||||
'10.9.8.7',
|
||||
);
|
||||
assert.equal(
|
||||
config('', { GEV_KEY_SETUP_TRUSTED_PEERS: 'none' })
|
||||
.GEV_KEY_SETUP_TRUSTED_PEERS,
|
||||
'none',
|
||||
);
|
||||
assert.equal(
|
||||
config(dotenv, { GEV_KEY_SETUP_TRUSTED_PEERS: '' })
|
||||
.GEV_KEY_SETUP_TRUSTED_PEERS,
|
||||
'',
|
||||
);
|
||||
});
|
||||
@@ -15,7 +15,9 @@ export function hudQuantity(value, unit, digits = 0) {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
return `${number}${unitLabel(unit).toUpperCase()}`;
|
||||
const label = unitLabel(unit);
|
||||
if (getLocale() === 'zh-CN') return label ? `${number} ${label}` : number;
|
||||
return `${number}${label.toUpperCase()}`;
|
||||
}
|
||||
|
||||
/** One hemisphere-tagged coordinate, e.g. `21.33N` / `21,33С`. */
|
||||
@@ -35,7 +37,16 @@ export const LOCALIZED_LOCALITY = Object.freeze({
|
||||
}),
|
||||
sector: ({ latDeg, lonDeg }) =>
|
||||
t('SECTOR {lat} {lon}', {
|
||||
lat: hudCoordinate(latDeg, t('N'), t('S')),
|
||||
lon: hudCoordinate(lonDeg, t('E'), t('W')),
|
||||
// Hemisphere letters, not compass words: 21.33N, never 21.33北.
|
||||
lat: hudCoordinate(
|
||||
latDeg,
|
||||
t('N', { context: 'hemisphere' }),
|
||||
t('S', { context: 'hemisphere' }),
|
||||
),
|
||||
lon: hudCoordinate(
|
||||
lonDeg,
|
||||
t('E', { context: 'hemisphere' }),
|
||||
t('W', { context: 'hemisphere' }),
|
||||
),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getLocale, setLocale } from './index.js';
|
||||
import { createModel } from '../layers/firms/model.js';
|
||||
import { createQueries } from '../layers/firms/queries.js';
|
||||
|
||||
const cases = [
|
||||
[0, '不到 1 分钟前', '<1m ago'],
|
||||
[120000, '2 分钟前', '2m ago'],
|
||||
[7200000, '2 小时前', '2h ago'],
|
||||
];
|
||||
|
||||
for (const [age, zh, en] of cases) {
|
||||
test(`FIRMS live age ${age} ms has one relative-time suffix`, (context) => {
|
||||
const now = 1800000000000;
|
||||
context.mock.method(Date, 'now', () => now);
|
||||
const previousLocale = getLocale();
|
||||
const layerState = {
|
||||
_loading: false,
|
||||
_stale: false,
|
||||
_keyRequired: false,
|
||||
_error: null,
|
||||
_lastUpdate: now - age,
|
||||
_count: 1,
|
||||
_cellCount: 0,
|
||||
_fires: [],
|
||||
};
|
||||
const model = createModel({ services: { anchors: {} } });
|
||||
const { methods } = createQueries({
|
||||
layerState,
|
||||
components: { model },
|
||||
config: {},
|
||||
});
|
||||
try {
|
||||
setLocale('en');
|
||||
assert.equal(methods.getStats().loadingLabel, `LIVE · updated ${en}`);
|
||||
setLocale('zh-CN');
|
||||
assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`);
|
||||
} finally {
|
||||
setLocale(previousLocale);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -35,6 +35,12 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () =>
|
||||
assert.equal(normalizeLocale(' RU '), 'ru');
|
||||
assert.equal(normalizeLocale('en_GB'), 'en');
|
||||
assert.equal(normalizeLocale('xx'), null);
|
||||
// Simplified Chinese by default; Traditional tags wait for zh-TW.
|
||||
assert.equal(normalizeLocale('zh'), 'zh-CN');
|
||||
assert.equal(normalizeLocale('zh-CN'), 'zh-CN');
|
||||
assert.equal(normalizeLocale('zh-Hans-SG'), 'zh-CN');
|
||||
assert.equal(normalizeLocale('zh-TW'), null);
|
||||
assert.equal(normalizeLocale('zh-Hant-HK'), null);
|
||||
assert.equal(normalizeLocale(undefined), null);
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -18,8 +18,9 @@ import {
|
||||
} from './locale.js';
|
||||
import es from './locales/es.json' with { type: 'json' };
|
||||
import ru from './locales/ru.json' with { type: 'json' };
|
||||
import zhCN from './locales/zh-CN.json' with { type: 'json' };
|
||||
|
||||
const CATALOGS = Object.freeze({ es, ru });
|
||||
const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN });
|
||||
|
||||
let currentLocale = DEFAULT_LOCALE;
|
||||
|
||||
|
||||
+10
-3
@@ -8,25 +8,32 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
|
||||
export const LOCALES = Object.freeze(['en', 'es', 'ru']);
|
||||
export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN']);
|
||||
|
||||
/** Each locale named in its own language, for the selector. */
|
||||
export const LOCALE_NAMES = Object.freeze({
|
||||
en: 'English',
|
||||
es: 'Español',
|
||||
ru: 'Русский',
|
||||
'zh-CN': '简体中文',
|
||||
});
|
||||
|
||||
/** Right-to-left locales. */
|
||||
const RTL_LOCALES = Object.freeze([]);
|
||||
|
||||
/**
|
||||
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru'.
|
||||
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru',
|
||||
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function normalizeLocale(candidate) {
|
||||
if (typeof candidate !== 'string') return null;
|
||||
const primary = candidate.trim().toLowerCase().split(/[-_]/)[0];
|
||||
const [primary, ...rest] = candidate.trim().toLowerCase().split(/[-_]/);
|
||||
// Traditional Chinese readers are not given Simplified: no zh-TW yet.
|
||||
if (primary === 'zh')
|
||||
return rest.some((part) => ['hant', 'tw', 'hk', 'mo'].includes(part))
|
||||
? null
|
||||
: 'zh-CN';
|
||||
return LOCALES.includes(primary) ? primary : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1086,7 +1086,6 @@
|
||||
"FIELD REPORTS · {count}": "INFORMES DE CAMPO · {count}",
|
||||
"GEOLOCATIONS · {credit}": "GEOLOCALIZACIONES · {credit}",
|
||||
"↗ OPEN PUBLIC MAP": "↗ ABRIR MAPA PÚBLICO",
|
||||
"Clouds are preserved from the source imagery.": "Las nubes provienen de las imágenes de origen.",
|
||||
"Show the Nepal context callout without a border or country fill": "Muestra la etiqueta de contexto de Nepal sin frontera ni relleno del país",
|
||||
"Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Presenta Nepal, dibuja su frontera y luego revela el incidente del Bhote Koshi",
|
||||
"CITIES": "CIUDADES",
|
||||
@@ -2937,5 +2936,12 @@
|
||||
"Gemini access was refused. Check the API key and model access.": "Se denegó el acceso a Gemini. Comprueba la clave de API y el acceso al modelo.",
|
||||
"Gemini Live is unavailable for the configured model.": "Gemini Live no está disponible para el modelo configurado.",
|
||||
"Token request is too large": "La solicitud de credencial es demasiado grande",
|
||||
"Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío"
|
||||
"Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío",
|
||||
" Clouds are preserved from the source imagery.": " Las nubes provienen de las imágenes de origen.",
|
||||
"hemisphere|N": "N",
|
||||
"hemisphere|S": "S",
|
||||
"hemisphere|E": "E",
|
||||
"hemisphere|W": "O",
|
||||
"OK": "OK",
|
||||
"{category} ({count})": "{category} ({count})"
|
||||
}
|
||||
@@ -1100,7 +1100,6 @@
|
||||
"FIELD REPORTS · {count}": "СООБЩЕНИЯ С МЕСТА · {count}",
|
||||
"GEOLOCATIONS · {credit}": "ГЕОЛОКАЦИИ · {credit}",
|
||||
"↗ OPEN PUBLIC MAP": "↗ ОТКРЫТЬ ПУБЛИЧНУЮ КАРТУ",
|
||||
"Clouds are preserved from the source imagery.": "Облака оставлены такими, как на исходных снимках.",
|
||||
"Show the Nepal context callout without a border or country fill": "Показать выноску с контекстом Непала без границы и заливки страны",
|
||||
"Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Представить Непал, нарисовать его границу, затем показать инцидент на Bhote Koshi",
|
||||
"CITIES": "ГОРОДА",
|
||||
@@ -3004,5 +3003,12 @@
|
||||
"Gemini access was refused. Check the API key and model access.": "Доступ к Gemini запрещён. Проверьте ключ API и доступ к модели.",
|
||||
"Gemini Live is unavailable for the configured model.": "Gemini Live недоступен для выбранной модели.",
|
||||
"Token request is too large": "Запрос токена слишком велик",
|
||||
"Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON"
|
||||
"Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON",
|
||||
" Clouds are preserved from the source imagery.": " Облака оставлены такими, как на исходных снимках.",
|
||||
"hemisphere|N": "С",
|
||||
"hemisphere|S": "Ю",
|
||||
"hemisphere|E": "В",
|
||||
"hemisphere|W": "З",
|
||||
"OK": "НОРМА",
|
||||
"{category} ({count})": "{category} ({count})"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -63,6 +63,7 @@
|
||||
"NVG",
|
||||
"Noir",
|
||||
"Normal",
|
||||
"OK",
|
||||
"POS: {time}Z",
|
||||
"Plan",
|
||||
"Pop",
|
||||
@@ -98,6 +99,9 @@
|
||||
"fire confidence|nominal",
|
||||
"ft",
|
||||
"hPa",
|
||||
"hemisphere|E",
|
||||
"hemisphere|N",
|
||||
"hemisphere|S",
|
||||
"installation class|BASE",
|
||||
"km",
|
||||
"km/h",
|
||||
@@ -133,5 +137,60 @@
|
||||
"REC",
|
||||
"VS",
|
||||
"WHOT/BHOT"
|
||||
],
|
||||
"zh-CN": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CCTV",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"FLIR",
|
||||
"FOV",
|
||||
"FOV {value}°",
|
||||
"FT",
|
||||
"GEO",
|
||||
"HDG",
|
||||
"HDG {value}°",
|
||||
"House",
|
||||
"Ironbow",
|
||||
"KTS",
|
||||
"Landsat 8/9",
|
||||
"MM",
|
||||
"NVG",
|
||||
"REC",
|
||||
"SRC {value}",
|
||||
"Sentinel-2",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"USB {rate} msg/s",
|
||||
"WHOT/BHOT",
|
||||
"dB",
|
||||
"ft",
|
||||
"hPa",
|
||||
"hemisphere|E",
|
||||
"hemisphere|N",
|
||||
"hemisphere|S",
|
||||
"hemisphere|W",
|
||||
"km",
|
||||
"km/h",
|
||||
"kt",
|
||||
"kts",
|
||||
"mph",
|
||||
"{city} CCTV",
|
||||
"{rate} msg/s",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
"{value} KM/S",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, t } from './index.js';
|
||||
import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js';
|
||||
import { formatCockpitDistance } from '../ui/cockpitPresentation.js';
|
||||
import { cctvSourceStatusLabel } from '../ui/cctvFrames.js';
|
||||
import { READOUT_WORDS } from '../layers/recentImagery/display.js';
|
||||
import { createModel } from '../layers/firms/model.js';
|
||||
import { createQueries } from '../layers/firms/queries.js';
|
||||
import { renderRadioState } from '../ui/radioPresentation.js';
|
||||
|
||||
test('Chinese HUD and cockpit units keep their case without spacing coordinates', () => {
|
||||
try {
|
||||
setLocale('zh-CN');
|
||||
assert.equal(hudQuantity(0, 'km'), '0 km');
|
||||
assert.equal(hudQuantity(2069, 'km', 1), '2069.0 km');
|
||||
assert.equal(formatCockpitDistance(14000), '14\u00a0km');
|
||||
assert.equal(LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 }),
|
||||
'扇区 21.33N 77.50W');
|
||||
setLocale('en');
|
||||
assert.equal(hudQuantity(0, 'km'), '0KM');
|
||||
assert.equal(hudQuantity(2069, 'km', 1), '2069.0KM');
|
||||
assert.equal(formatCockpitDistance(14000), '14\u00a0KM');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('FIRMS stale cache age uses localized durations while English stays compact', () => {
|
||||
const now = Date.now();
|
||||
const layerState = { _stale: true };
|
||||
const components = {
|
||||
model: createModel({ services: { anchors: {} } }),
|
||||
};
|
||||
const { methods } = createQueries({ layerState, components, config: {} });
|
||||
try {
|
||||
for (const [hours, zh, en] of [[0.5, '不到 1 小时', '<1h'], [2, '2 小时', '2h'], [72, '3 天', '3d']]) {
|
||||
layerState._lastUpdate = now - hours * 3600000;
|
||||
setLocale('zh-CN');
|
||||
const expected = t('STALE · cached {age}', { age: zh });
|
||||
assert.equal(methods.getStats().loadingLabel, expected);
|
||||
assert.equal(methods.getStats().error, expected);
|
||||
setLocale('en');
|
||||
assert.equal(methods.getStats().loadingLabel, `STALE · cached ${en}`);
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('Recent Imagery spaces numeric Chinese ages and retains natural relative dates', () => {
|
||||
try {
|
||||
setLocale('zh-CN');
|
||||
assert.equal(READOUT_WORDS.daysAgo(9), '9 天前');
|
||||
assert.equal(READOUT_WORDS.daysAgo(0), '今天');
|
||||
assert.equal(READOUT_WORDS.daysAgo(1), '昨天');
|
||||
setLocale('en');
|
||||
assert.equal(READOUT_WORDS.daysAgo(9), '9 days ago');
|
||||
assert.equal(READOUT_WORDS.daysAgo(0), 'today');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV translates the healthy status and preserves unknown provider codes', () => {
|
||||
try {
|
||||
for (const [locale, expected] of [['zh-CN', '正常'], ['ru', 'НОРМА'], ['es', 'OK'], ['en', 'OK']]) {
|
||||
setLocale(locale);
|
||||
assert.equal(cctvSourceStatusLabel('ok'), expected);
|
||||
assert.equal(cctvSourceStatusLabel('CUSTOM'), 'CUSTOM');
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('radio option text and accessible name use the same localized parentheses', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const element = () => ({
|
||||
style: {}, dataset: {}, attributes: {},
|
||||
classList: { contains: () => true, toggle() {} },
|
||||
setAttribute(name, value) { this.attributes[name] = value; },
|
||||
replaceChildren(...children) { this.children = children; },
|
||||
});
|
||||
globalThis.document = { createElement: element, getElementById: () => null };
|
||||
try {
|
||||
for (const [locale, label, expected] of [['zh-CN', '全部', '全部(750)'], ['en', 'All', 'All (750)']]) {
|
||||
setLocale(locale);
|
||||
const host = {
|
||||
_radioPanel: element(), _radioFilter: element(),
|
||||
_syncContextRadioLauncherState() {},
|
||||
actions: { getLifecycle: () => null, scheduleLayout() {} },
|
||||
};
|
||||
renderRadioState.call(host, {
|
||||
enabled: true, categories: [{ id: 'all', label, count: 750, color: '' }],
|
||||
filter: 'all', filteredCount: 750, stationCount: 750, volume: 1,
|
||||
});
|
||||
const option = host._radioFilter.children[0];
|
||||
assert.equal(option.textContent, `● ${expected}`);
|
||||
assert.equal(option.attributes['aria-label'], expected);
|
||||
}
|
||||
} finally {
|
||||
globalThis.document = originalDocument;
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -69,7 +69,7 @@ test('focusable controls do not animate the global outline', () => {
|
||||
'#top-center-actions button {',
|
||||
'.data-toggle-chip {',
|
||||
'.scene-btn {',
|
||||
'.scene-shot-btn {',
|
||||
'.gev-action-button--compact {',
|
||||
]) {
|
||||
assert.doesNotMatch(
|
||||
ruleBody(selector),
|
||||
|
||||
@@ -591,6 +591,7 @@ export function createModel({
|
||||
formatLatLon,
|
||||
formatFrp,
|
||||
formatAge,
|
||||
formatAgeLabel,
|
||||
formatAgoMinutes,
|
||||
confidenceBucket,
|
||||
};
|
||||
|
||||
@@ -64,7 +64,8 @@ export function createQueries({
|
||||
const staleText = layerState._lastUpdate
|
||||
? t('STALE · cached {age}', {
|
||||
age:
|
||||
components.model.formatAge(now - layerState._lastUpdate) || '<1h',
|
||||
components.model.formatAgeLabel(now - layerState._lastUpdate) ||
|
||||
t('<1h', { context: 'age' }),
|
||||
})
|
||||
: t('STALE');
|
||||
let loadingLabel = '';
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
* day's readout. The model (`model.js`) stays English and i18n-free because AI
|
||||
* tool services import it outside the browser; this module is page-only.
|
||||
*/
|
||||
import { formatDate, formatRelativeTime, t } from '../../i18n/index.js';
|
||||
import {
|
||||
formatDate,
|
||||
formatRelativeTime,
|
||||
getLocale,
|
||||
t,
|
||||
} from '../../i18n/index.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
import { dayTime } from './model.js';
|
||||
|
||||
@@ -26,7 +31,10 @@ export const displayMetres = (value) => formatQuantity(value, 'm');
|
||||
/** The readout's words in the page's language; the model keeps them English. */
|
||||
export const READOUT_WORDS = Object.freeze({
|
||||
date: (day) => displayDay(day, LONG_DAY),
|
||||
daysAgo: (delta) => formatRelativeTime(delta > 0 ? -delta : 0, 'day'),
|
||||
daysAgo: (delta) => {
|
||||
const text = formatRelativeTime(delta > 0 ? -delta : 0, 'day');
|
||||
return getLocale() === 'zh-CN' ? text.replace(/(\d)(?=天)/g, '$1 ') : text;
|
||||
},
|
||||
// "Sentinel-2" never breaks at its hyphen: a word joiner follows it
|
||||
// (JetBrains Mono has no U+2011 non-breaking hyphen).
|
||||
sensor: (sensor) =>
|
||||
|
||||
@@ -38,7 +38,10 @@ export function createGoogleTokenSource({
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
return null;
|
||||
}
|
||||
return parseToken(await response.json(), now());
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -50,6 +50,23 @@ test('requests refused for the same token renew it once', async () => {
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
test('a refused token response releases its unread body before returning', async () => {
|
||||
for (const status of [404, 429, 500]) {
|
||||
let cancelled = false;
|
||||
const response = new Response(
|
||||
new ReadableStream({
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
{ status },
|
||||
);
|
||||
const tokens = createGoogleTokenSource({ fetchImpl: async () => response });
|
||||
assert.equal(await tokens.token(), null);
|
||||
assert.equal(cancelled, true, `HTTP ${status} body released`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a server that does not offer tokens yields none', async () => {
|
||||
for (const answer of [
|
||||
new Response('Not found', { status: 404 }),
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { loadBundledJson } from '../data/bundledJson.js';
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
import { buildStarVertices, STAR_VERTEX_FLOATS } from './stars.js';
|
||||
|
||||
/**
|
||||
* Space background in the `Scene#skyBox` slot: catalogue stars as point
|
||||
* sprites over a soft Milky Way cube map, both turned by one ICRF→fixed
|
||||
* rotation at the scene clock (TEME until Cesium's tables load). Scene runs
|
||||
* the command from `update()` first in the environment pass, so the sky
|
||||
* stays behind everything without depth. Revalidate on engine upgrades.
|
||||
*/
|
||||
export const STARRY_SKY_CESIUM_VERSION = '1.138.0';
|
||||
|
||||
/** Brightness of the Milky Way glow; the stars carry the sky. */
|
||||
export const MILKY_WAY_INTENSITY = 0.4;
|
||||
/** Clock change, in seconds, below which the sky rotation is reused. */
|
||||
export const SKY_ROTATION_EPSILON_SECONDS = 1;
|
||||
|
||||
const STAR_PACK_URL = new URL(
|
||||
'../data/local_data/sky/bright_stars.json',
|
||||
import.meta.url,
|
||||
);
|
||||
const MILKY_WAY_FACE_URLS = Object.freeze({
|
||||
positiveX: new URL(
|
||||
'../data/local_data/sky/milkyway_px.webp',
|
||||
import.meta.url,
|
||||
),
|
||||
negativeX: new URL(
|
||||
'../data/local_data/sky/milkyway_nx.webp',
|
||||
import.meta.url,
|
||||
),
|
||||
positiveY: new URL(
|
||||
'../data/local_data/sky/milkyway_py.webp',
|
||||
import.meta.url,
|
||||
),
|
||||
negativeY: new URL(
|
||||
'../data/local_data/sky/milkyway_ny.webp',
|
||||
import.meta.url,
|
||||
),
|
||||
positiveZ: new URL(
|
||||
'../data/local_data/sky/milkyway_pz.webp',
|
||||
import.meta.url,
|
||||
),
|
||||
negativeZ: new URL(
|
||||
'../data/local_data/sky/milkyway_nz.webp',
|
||||
import.meta.url,
|
||||
),
|
||||
});
|
||||
|
||||
// Directions, not points: w = 0 drops the camera translation, and z just
|
||||
// under w keeps every fragment at the far plane, inside any frustum.
|
||||
const SKY_DIRECTION_GLSL = `
|
||||
uniform mat3 u_icrfToFixed;
|
||||
vec4 skyClipPosition(vec3 icrfDirection)
|
||||
{
|
||||
vec3 eye = czm_viewRotation * (u_icrfToFixed * icrfDirection);
|
||||
vec4 clip = czm_projection * vec4(eye, 0.0);
|
||||
clip.z = clip.w * 0.99999;
|
||||
return clip;
|
||||
}
|
||||
`;
|
||||
|
||||
const MILKY_WAY_VS = `${SKY_DIRECTION_GLSL}
|
||||
in vec3 position;
|
||||
out vec3 v_direction;
|
||||
void main()
|
||||
{
|
||||
gl_Position = skyClipPosition(position);
|
||||
v_direction = position;
|
||||
}
|
||||
`;
|
||||
|
||||
const MILKY_WAY_FS = `
|
||||
uniform samplerCube u_milkyWay;
|
||||
uniform float u_intensity;
|
||||
in vec3 v_direction;
|
||||
void main()
|
||||
{
|
||||
vec3 color = czm_textureCube(u_milkyWay, normalize(v_direction)).rgb * u_intensity;
|
||||
// Interleaved-gradient dither: the dim glow spans only a few 8-bit steps.
|
||||
float noise = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
|
||||
color = max(color + (noise - 0.5) / 255.0, 0.0);
|
||||
out_FragColor = vec4(czm_gammaCorrect(color), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const STAR_VS = `${SKY_DIRECTION_GLSL}
|
||||
in vec3 position;
|
||||
in float sigma;
|
||||
in vec4 color;
|
||||
out vec4 v_color;
|
||||
out float v_sigma;
|
||||
out float v_radius;
|
||||
void main()
|
||||
{
|
||||
gl_Position = skyClipPosition(position);
|
||||
// σ is in CSS pixels; the sprite holds ±3σ plus a pixel of margin.
|
||||
v_sigma = sigma * czm_pixelRatio;
|
||||
v_radius = ceil(3.0 * v_sigma) + 1.0;
|
||||
gl_PointSize = 2.0 * v_radius;
|
||||
v_color = color;
|
||||
}
|
||||
`;
|
||||
|
||||
const STAR_FS = `
|
||||
in vec4 v_color;
|
||||
in float v_sigma;
|
||||
in float v_radius;
|
||||
void main()
|
||||
{
|
||||
vec2 offset = (gl_PointCoord - 0.5) * (2.0 * v_radius);
|
||||
float alpha = v_color.a *
|
||||
exp(-0.5 * dot(offset, offset) / (v_sigma * v_sigma));
|
||||
if (alpha < 1.0 / 512.0) discard;
|
||||
out_FragColor = vec4(czm_gammaCorrect(v_color.rgb) * alpha, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
/** Pick the ICRF→fixed rotation for `time`, or TEME while tables load. */
|
||||
export function computeSkyRotation(time, result) {
|
||||
const { Transforms } = Cesium;
|
||||
const icrf = Transforms.computeIcrfToFixedMatrix(time, result);
|
||||
if (icrf) return { matrix: icrf, precise: true };
|
||||
return {
|
||||
matrix: Transforms.computeTemeToPseudoFixedMatrix(time, result),
|
||||
precise: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a cached rotation at `cachedTime` still serves `time`. */
|
||||
export function isSkyRotationCurrent(cachedTime, time) {
|
||||
return (
|
||||
cachedTime !== undefined &&
|
||||
Math.abs(Cesium.JulianDate.secondsDifference(time, cachedTime)) <
|
||||
SKY_ROTATION_EPSILON_SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
export class StarrySky {
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {() => void} [options.requestRender] renders one frame when an
|
||||
* asset or the precise rotation arrives; the idle governor by default.
|
||||
*/
|
||||
constructor({
|
||||
requestRender = () => governorRequestRender('sky-assets'),
|
||||
} = {}) {
|
||||
/** Matches `SkyBox#show`. */
|
||||
this.show = true;
|
||||
this.milkyWayIntensity = MILKY_WAY_INTENSITY;
|
||||
/** 0..1 multiplier a style sets to hide the glow (see visualEffects.js). */
|
||||
this.milkyWayVisibility = 1;
|
||||
this._requestRender = requestRender;
|
||||
this._rotation = Cesium.Matrix3.clone(Cesium.Matrix3.IDENTITY);
|
||||
this._rotationTime = undefined;
|
||||
this._rotationPrecise = false;
|
||||
this._icrfRequest = undefined;
|
||||
this._icrfUnavailable = false;
|
||||
this._icrfMissing = undefined;
|
||||
this._loading = false;
|
||||
this._starData = undefined;
|
||||
this._faceImages = undefined;
|
||||
// Scene reads `_cubeMap` for model lighting. Keep it dark as on main;
|
||||
// Cesium's fallback is white, and the ICRF glow is only a background.
|
||||
this._cubeMap = undefined;
|
||||
this._milkyWayCubeMap = undefined;
|
||||
this._milkyWayCommand = undefined;
|
||||
this._milkyWayAttributes = undefined;
|
||||
this._starCommand = undefined;
|
||||
this._useHdr = undefined;
|
||||
this._destroyed = false;
|
||||
const uniforms = {
|
||||
u_icrfToFixed: () => this._rotation,
|
||||
};
|
||||
this._milkyWayUniforms = {
|
||||
...uniforms,
|
||||
u_milkyWay: () => this._milkyWayCubeMap,
|
||||
u_intensity: () => this.milkyWayIntensity * this.milkyWayVisibility,
|
||||
};
|
||||
this._starUniforms = uniforms;
|
||||
const commands = [];
|
||||
this._commands = commands;
|
||||
// The environment pass reads `derivedCommands` (log depth, HDR, picking)
|
||||
// before calling `execute`; none apply to a far-plane background.
|
||||
this._compositeCommand = {
|
||||
derivedCommands: {},
|
||||
execute(context, passState) {
|
||||
for (const command of commands) command.execute(context, passState);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Start loading assets; failures leave a plain black sky. */
|
||||
_load() {
|
||||
if (this._loading) return;
|
||||
this._loading = true;
|
||||
loadBundledJson(STAR_PACK_URL)
|
||||
.then((pack) => {
|
||||
if (this._destroyed) return;
|
||||
this._starData = buildStarVertices(pack);
|
||||
this._requestRender();
|
||||
})
|
||||
.catch((error) =>
|
||||
console.warn('[sky] star catalogue unavailable', error),
|
||||
);
|
||||
const faces = Object.entries(MILKY_WAY_FACE_URLS);
|
||||
Promise.all(
|
||||
faces.map(([, url]) => Cesium.Resource.fetchImage({ url: url.href })),
|
||||
)
|
||||
.then((images) => {
|
||||
if (this._destroyed) return;
|
||||
this._faceImages = Object.fromEntries(
|
||||
faces.map(([face], index) => [face, images[index]]),
|
||||
);
|
||||
this._requestRender();
|
||||
})
|
||||
.catch((error) => console.warn('[sky] Milky Way unavailable', error));
|
||||
}
|
||||
|
||||
_updateRotation(time) {
|
||||
// An approximate rotation is final only while nothing better can come.
|
||||
const settled =
|
||||
this._rotationPrecise || this._icrfRequest || !this._canPreloadIcrf(time);
|
||||
if (settled && isSkyRotationCurrent(this._rotationTime, time)) return;
|
||||
const { precise } = computeSkyRotation(time, this._rotation);
|
||||
this._rotationPrecise = precise;
|
||||
this._rotationTime = Cesium.JulianDate.clone(time, this._rotationTime);
|
||||
if (!precise) this._preloadIcrf(time);
|
||||
}
|
||||
|
||||
/** Whether loading Cesium's IAU 2006 tables could still help at `time`. */
|
||||
_canPreloadIcrf(time) {
|
||||
return (
|
||||
!this._icrfUnavailable &&
|
||||
!(
|
||||
this._icrfMissing &&
|
||||
Cesium.TimeInterval.contains(this._icrfMissing, time)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Fetch Cesium's IAU 2006 tables around `time` once per span. */
|
||||
_preloadIcrf(time) {
|
||||
if (this._icrfRequest || !this._canPreloadIcrf(time)) return;
|
||||
const interval = new Cesium.TimeInterval({
|
||||
start: Cesium.JulianDate.addDays(time, -1, new Cesium.JulianDate()),
|
||||
stop: Cesium.JulianDate.addDays(time, 1, new Cesium.JulianDate()),
|
||||
});
|
||||
this._icrfRequest = Promise.resolve(
|
||||
Cesium.Transforms.preloadIcrfFixed(interval),
|
||||
)
|
||||
.then(
|
||||
() => {
|
||||
// The tables cover roughly 1975–2050; outside them the preload
|
||||
// resolves with nothing, and TEME stays for this span.
|
||||
if (!Cesium.Transforms.computeIcrfToFixedMatrix(time)) {
|
||||
this._icrfMissing = interval;
|
||||
return;
|
||||
}
|
||||
this._rotationTime = undefined;
|
||||
if (!this._destroyed) this._requestRender();
|
||||
},
|
||||
(error) => {
|
||||
// TEME is within a fraction of a degree; do not retry every frame.
|
||||
this._icrfUnavailable = true;
|
||||
console.warn('[sky] ICRF tables unavailable, using TEME', error);
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
this._icrfRequest = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
_createMilkyWayCommand(context) {
|
||||
const cubeMap = new Cesium.CubeMap({
|
||||
context,
|
||||
source: this._faceImages,
|
||||
flipY: false,
|
||||
});
|
||||
cubeMap.generateMipmap();
|
||||
cubeMap.sampler = new Cesium.Sampler({
|
||||
minificationFilter: Cesium.TextureMinificationFilter.LINEAR_MIPMAP_LINEAR,
|
||||
});
|
||||
this._milkyWayCubeMap = cubeMap;
|
||||
this._faceImages = undefined;
|
||||
const geometry = Cesium.BoxGeometry.createGeometry(
|
||||
Cesium.BoxGeometry.fromDimensions({
|
||||
dimensions: new Cesium.Cartesian3(2, 2, 2),
|
||||
vertexFormat: Cesium.VertexFormat.POSITION_ONLY,
|
||||
}),
|
||||
);
|
||||
const attributeLocations =
|
||||
Cesium.GeometryPipeline.createAttributeLocations(geometry);
|
||||
this._milkyWayCommand = new Cesium.DrawCommand({
|
||||
owner: this,
|
||||
vertexArray: Cesium.VertexArray.fromGeometry({
|
||||
context,
|
||||
geometry,
|
||||
attributeLocations,
|
||||
bufferUsage: Cesium.BufferUsage.STATIC_DRAW,
|
||||
}),
|
||||
renderState: Cesium.RenderState.fromCache({}),
|
||||
uniformMap: this._milkyWayUniforms,
|
||||
});
|
||||
this._milkyWayAttributes = attributeLocations;
|
||||
}
|
||||
|
||||
_createStarCommand(context) {
|
||||
const { count, vertices } = this._starData;
|
||||
this._starData = undefined;
|
||||
if (count === 0) return;
|
||||
const vertexBuffer = Cesium.Buffer.createVertexBuffer({
|
||||
context,
|
||||
typedArray: vertices,
|
||||
usage: Cesium.BufferUsage.STATIC_DRAW,
|
||||
});
|
||||
const stride = STAR_VERTEX_FLOATS * Float32Array.BYTES_PER_ELEMENT;
|
||||
const attribute = (index, components, offsetFloats) => ({
|
||||
index,
|
||||
vertexBuffer,
|
||||
componentsPerAttribute: components,
|
||||
componentDatatype: Cesium.ComponentDatatype.FLOAT,
|
||||
offsetInBytes: offsetFloats * Float32Array.BYTES_PER_ELEMENT,
|
||||
strideInBytes: stride,
|
||||
});
|
||||
this._starCommand = new Cesium.DrawCommand({
|
||||
owner: this,
|
||||
primitiveType: Cesium.PrimitiveType.POINTS,
|
||||
count,
|
||||
vertexArray: new Cesium.VertexArray({
|
||||
context,
|
||||
attributes: [
|
||||
attribute(0, 3, 0),
|
||||
attribute(1, 1, 3),
|
||||
attribute(2, 4, 4),
|
||||
],
|
||||
}),
|
||||
// Premultiplied additive: starlight adds to the Milky Way glow and
|
||||
// leaves the framebuffer alpha alone.
|
||||
renderState: Cesium.RenderState.fromCache({
|
||||
blending: {
|
||||
enabled: true,
|
||||
equationRgb: Cesium.BlendEquation.ADD,
|
||||
equationAlpha: Cesium.BlendEquation.ADD,
|
||||
functionSourceRgb: Cesium.BlendFunction.ONE,
|
||||
functionDestinationRgb: Cesium.BlendFunction.ONE,
|
||||
functionSourceAlpha: Cesium.BlendFunction.ZERO,
|
||||
functionDestinationAlpha: Cesium.BlendFunction.ONE,
|
||||
},
|
||||
}),
|
||||
uniformMap: this._starUniforms,
|
||||
});
|
||||
}
|
||||
|
||||
_updateShaders(context, useHdr) {
|
||||
if (this._useHdr === useHdr) return;
|
||||
this._useHdr = useHdr;
|
||||
const defines = useHdr ? ['HDR'] : [];
|
||||
for (const [command, vs, fs, attributeLocations] of [
|
||||
[
|
||||
this._milkyWayCommand,
|
||||
MILKY_WAY_VS,
|
||||
MILKY_WAY_FS,
|
||||
this._milkyWayAttributes,
|
||||
],
|
||||
[
|
||||
this._starCommand,
|
||||
STAR_VS,
|
||||
STAR_FS,
|
||||
{ position: 0, sigma: 1, color: 2 },
|
||||
],
|
||||
]) {
|
||||
if (!command) continue;
|
||||
command.shaderProgram?.destroy();
|
||||
command.shaderProgram = Cesium.ShaderProgram.fromCache({
|
||||
context,
|
||||
vertexShaderSource: vs,
|
||||
fragmentShaderSource: new Cesium.ShaderSource({
|
||||
defines,
|
||||
sources: [fs],
|
||||
}),
|
||||
attributeLocations,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** `Scene#skyBox` contract: the command to draw this frame, if any. */
|
||||
update(frameState, useHdr) {
|
||||
if (!this.show) return undefined;
|
||||
const { mode, passes, context } = frameState;
|
||||
if (mode !== Cesium.SceneMode.SCENE3D) return undefined;
|
||||
if (!passes.render) return undefined;
|
||||
if (!this._cubeMap) {
|
||||
const face = {
|
||||
width: 1,
|
||||
height: 1,
|
||||
arrayBufferView: new Uint8Array([0, 0, 0, 255]),
|
||||
};
|
||||
this._cubeMap = new Cesium.CubeMap({
|
||||
context,
|
||||
source: {
|
||||
positiveX: face,
|
||||
negativeX: face,
|
||||
positiveY: face,
|
||||
negativeY: face,
|
||||
positiveZ: face,
|
||||
negativeZ: face,
|
||||
},
|
||||
flipY: false,
|
||||
});
|
||||
}
|
||||
this._load();
|
||||
let rebuilt = false;
|
||||
if (this._faceImages && !this._milkyWayCommand) {
|
||||
this._createMilkyWayCommand(context);
|
||||
rebuilt = true;
|
||||
}
|
||||
if (this._starData && !this._starCommand) {
|
||||
this._createStarCommand(context);
|
||||
rebuilt = true;
|
||||
}
|
||||
if (rebuilt) this._useHdr = undefined;
|
||||
this._updateShaders(context, Boolean(useHdr));
|
||||
this._commands.length = 0;
|
||||
if (this._milkyWayCommand) this._commands.push(this._milkyWayCommand);
|
||||
if (this._starCommand) this._commands.push(this._starCommand);
|
||||
if (this._commands.length === 0) return undefined;
|
||||
this._updateRotation(frameState.time);
|
||||
return this._compositeCommand;
|
||||
}
|
||||
|
||||
isDestroyed() {
|
||||
return this._destroyed;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const command of [this._milkyWayCommand, this._starCommand]) {
|
||||
if (!command) continue;
|
||||
command.vertexArray?.destroy();
|
||||
command.shaderProgram?.destroy();
|
||||
}
|
||||
this._milkyWayCubeMap = this._milkyWayCubeMap?.destroy();
|
||||
this._cubeMap = this._cubeMap?.destroy();
|
||||
this._milkyWayCommand = undefined;
|
||||
this._starCommand = undefined;
|
||||
this._commands.length = 0;
|
||||
this._destroyed = true;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
MILKY_WAY_INTENSITY,
|
||||
STARRY_SKY_CESIUM_VERSION,
|
||||
SKY_ROTATION_EPSILON_SECONDS,
|
||||
StarrySky,
|
||||
computeSkyRotation,
|
||||
isSkyRotationCurrent,
|
||||
} from './starrySky.js';
|
||||
|
||||
const close = (actual, expected, epsilon = 1e-9) =>
|
||||
assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} vs ${expected}`);
|
||||
|
||||
test('the sky-box adapter was validated against this engine version', () => {
|
||||
assert.equal(
|
||||
Cesium.VERSION,
|
||||
STARRY_SKY_CESIUM_VERSION,
|
||||
'engine upgrades require revalidating the Scene#skyBox contract',
|
||||
);
|
||||
});
|
||||
|
||||
test('the ICRF rotation is preferred and TEME fills in while tables load', () => {
|
||||
const time = Cesium.JulianDate.fromIso8601('2026-10-19T00:00:00Z');
|
||||
const icrf = Cesium.Matrix3.fromRotationZ(0.3);
|
||||
const teme = Cesium.Matrix3.fromRotationZ(0.2);
|
||||
const { Transforms } = Cesium;
|
||||
const original = { ...Transforms };
|
||||
try {
|
||||
Transforms.computeIcrfToFixedMatrix = (_, result) =>
|
||||
Cesium.Matrix3.clone(icrf, result);
|
||||
Transforms.computeTemeToPseudoFixedMatrix = () =>
|
||||
assert.fail('TEME not needed');
|
||||
const precise = computeSkyRotation(time, new Cesium.Matrix3());
|
||||
assert.equal(precise.precise, true);
|
||||
assert.ok(Cesium.Matrix3.equals(precise.matrix, icrf));
|
||||
Transforms.computeIcrfToFixedMatrix = () => undefined;
|
||||
Transforms.computeTemeToPseudoFixedMatrix = (_, result) =>
|
||||
Cesium.Matrix3.clone(teme, result);
|
||||
const approximate = computeSkyRotation(time, new Cesium.Matrix3());
|
||||
assert.equal(approximate.precise, false);
|
||||
assert.ok(Cesium.Matrix3.equals(approximate.matrix, teme));
|
||||
} finally {
|
||||
Object.assign(Transforms, original);
|
||||
}
|
||||
});
|
||||
|
||||
test('the TEME fallback keeps the celestial pole on the Earth axis', () => {
|
||||
// TEME only spins about Z; ICRF differs from it by precession (~0.36° by
|
||||
// 2026), so the fallback sky is right up to a fraction of a degree.
|
||||
const time = Cesium.JulianDate.fromIso8601('2026-10-19T00:00:00Z');
|
||||
const teme = Cesium.Transforms.computeTemeToPseudoFixedMatrix(time);
|
||||
const pole = Cesium.Matrix3.multiplyByVector(
|
||||
teme,
|
||||
Cesium.Cartesian3.UNIT_Z,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
assert.ok(Math.abs(pole.z - 1) < 1e-9);
|
||||
});
|
||||
|
||||
test('the rotation is reused only within the clock epsilon', () => {
|
||||
const time = Cesium.JulianDate.fromIso8601('2026-10-19T00:00:00Z');
|
||||
const later = (seconds) =>
|
||||
Cesium.JulianDate.addSeconds(time, seconds, new Cesium.JulianDate());
|
||||
assert.equal(isSkyRotationCurrent(undefined, time), false);
|
||||
assert.equal(isSkyRotationCurrent(time, later(0.5)), true);
|
||||
assert.equal(isSkyRotationCurrent(time, later(-0.5)), true);
|
||||
assert.equal(
|
||||
isSkyRotationCurrent(time, later(SKY_ROTATION_EPSILON_SECONDS)),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('the sky draws nothing when hidden, outside 3D, or in a pick pass', () => {
|
||||
const sky = new StarrySky({ requestRender: () => {} });
|
||||
const frameState = (overrides) => ({
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
passes: { render: true },
|
||||
...overrides,
|
||||
});
|
||||
sky._loading = true; // keep the test off the network
|
||||
sky.show = false;
|
||||
assert.equal(sky.update(frameState()), undefined);
|
||||
sky.show = true;
|
||||
assert.equal(
|
||||
sky.update(frameState({ mode: Cesium.SceneMode.SCENE2D })),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
sky.update(frameState({ passes: { render: false, pick: true } })),
|
||||
undefined,
|
||||
);
|
||||
// Nothing has loaded yet, so even a render pass has no command.
|
||||
sky._cubeMap = { destroy() {} }; // no WebGL context in this unit test
|
||||
assert.equal(sky.update(frameState()), undefined);
|
||||
assert.equal(sky.isDestroyed(), false);
|
||||
assert.equal(sky.destroy(), undefined);
|
||||
assert.equal(sky.isDestroyed(), true);
|
||||
});
|
||||
|
||||
const frame = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
for (const iso of ['1970-01-01T00:00:00Z', '2060-01-01T00:00:00Z']) {
|
||||
test(`a clock outside the IAU 2006 tables (${iso}) settles on TEME instead of re-rendering every frame`, async () => {
|
||||
const time = Cesium.JulianDate.fromIso8601(iso);
|
||||
let renders = 0;
|
||||
let preloads = 0;
|
||||
const original = Cesium.Transforms.preloadIcrfFixed;
|
||||
Cesium.Transforms.preloadIcrfFixed = (interval) => {
|
||||
preloads += 1;
|
||||
return original(interval);
|
||||
};
|
||||
try {
|
||||
const sky = new StarrySky({ requestRender: () => (renders += 1) });
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
sky._updateRotation(time); // what update() does once per frame
|
||||
await frame();
|
||||
}
|
||||
assert.ok(
|
||||
renders <= 1 && preloads <= 1,
|
||||
`20 frames at a frozen clock -> ${preloads} preloads, ${renders} render requests (expected <= 1 each)`,
|
||||
);
|
||||
} finally {
|
||||
Cesium.Transforms.preloadIcrfFixed = original;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('the Milky Way glow scales with its visibility, for styles that hide it', () => {
|
||||
const sky = new StarrySky({ requestRender: () => {} });
|
||||
close(sky._milkyWayUniforms.u_intensity(), MILKY_WAY_INTENSITY);
|
||||
sky.milkyWayVisibility = 0;
|
||||
assert.equal(sky._milkyWayUniforms.u_intensity(), 0);
|
||||
sky.destroy();
|
||||
});
|
||||
|
||||
test('destroy releases both the background and model environment maps', () => {
|
||||
const sky = new StarrySky({ requestRender: () => {} });
|
||||
let destroyed = 0;
|
||||
sky._cubeMap = {
|
||||
destroy: () => {
|
||||
destroyed++;
|
||||
},
|
||||
};
|
||||
sky._milkyWayCubeMap = {
|
||||
destroy: () => {
|
||||
destroyed++;
|
||||
},
|
||||
};
|
||||
sky.destroy();
|
||||
assert.equal(destroyed, 2);
|
||||
assert.equal(sky._cubeMap, undefined);
|
||||
assert.equal(sky._milkyWayCubeMap, undefined);
|
||||
sky.destroy();
|
||||
assert.equal(destroyed, 2);
|
||||
});
|
||||
|
||||
test('a render update uploads six opaque black environment faces only once', () => {
|
||||
const uploads = [];
|
||||
const gl = {
|
||||
...Cesium.WebGLConstants,
|
||||
createTexture: () => ({}),
|
||||
activeTexture() {},
|
||||
bindTexture() {},
|
||||
texParameteri() {},
|
||||
pixelStorei() {},
|
||||
texImage2D: (...args) => uploads.push(args),
|
||||
deleteTexture() {},
|
||||
};
|
||||
const previous = Cesium.ContextLimits._maximumCubeMapSize;
|
||||
Cesium.ContextLimits._maximumCubeMapSize = 16;
|
||||
const sky = new StarrySky({ requestRender: () => {} });
|
||||
sky._loading = true;
|
||||
const state = {
|
||||
context: { _gl: gl },
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
passes: { render: true },
|
||||
};
|
||||
try {
|
||||
sky.update(state);
|
||||
const environment = sky._cubeMap;
|
||||
assert.ok(environment instanceof Cesium.CubeMap);
|
||||
assert.equal(uploads.length, 6);
|
||||
assert.equal(new Set(uploads.map((args) => args[0])).size, 6);
|
||||
for (const args of uploads) {
|
||||
assert.equal(args[3], 1);
|
||||
assert.equal(args[4], 1);
|
||||
assert.deepEqual([...args[8]], [0, 0, 0, 255]);
|
||||
}
|
||||
sky.update(state);
|
||||
assert.equal(sky._cubeMap, environment);
|
||||
assert.equal(uploads.length, 6);
|
||||
sky.destroy();
|
||||
assert.equal(environment.isDestroyed(), true);
|
||||
} finally {
|
||||
if (!sky.isDestroyed()) sky.destroy();
|
||||
Cesium.ContextLimits._maximumCubeMapSize = previous;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Bright-star appearance: catalogue pack → GPU vertex data.
|
||||
*
|
||||
* Pure functions, no Cesium. The pack is `local_data/sky/bright_stars.json`
|
||||
* (Yale Bright Star Catalogue, built by scripts/build-bright-stars.mjs): a
|
||||
* flat `[raDeg, decDeg, vmag, bv, …]` array in ICRF/J2000. Each star is a
|
||||
* small Gaussian sprite, sized in CSS pixels so it stays a crisp dot at any DPR.
|
||||
*
|
||||
* @module sky/stars
|
||||
*/
|
||||
|
||||
/** Brightness reference; fainter catalogue stars are drawn like it. */
|
||||
export const STAR_LIMIT_MAGNITUDE = 6.5;
|
||||
/** Gaussian σ of the faintest star, CSS pixels. */
|
||||
export const STAR_MIN_SIGMA_PX = 0.55;
|
||||
/** Gaussian σ cap for the brightest star, CSS pixels. */
|
||||
export const STAR_MAX_SIGMA_PX = 1.35;
|
||||
/** Peak opacity of the faintest star. */
|
||||
export const STAR_MIN_ALPHA = 0.22;
|
||||
/** B−V for stars the catalogue gives no colour (a solar-type white). */
|
||||
export const STAR_DEFAULT_BV = 0.6;
|
||||
/** Floats per star in the vertex buffer: xyz, σ, rgb, alpha. */
|
||||
export const STAR_VERTEX_FLOATS = 8;
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
// Fraction of the colour kept: the eye sees all but the most extreme stars
|
||||
// as nearly white, and saturated dots read as UI markers on the globe.
|
||||
const COLOR_SATURATION = 0.55;
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
/** ICRF unit vector for J2000 right ascension and declination in degrees. */
|
||||
export function raDecToUnitVector(raDeg, decDeg) {
|
||||
const ra = raDeg * DEG;
|
||||
const dec = decDeg * DEG;
|
||||
const cosDec = Math.cos(dec);
|
||||
return [cosDec * Math.cos(ra), cosDec * Math.sin(ra), Math.sin(dec)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Perceived brightness relative to the limit magnitude. Flux grows 2.512×
|
||||
* per magnitude; the square root compresses Sirius-to-6.5m (≈ 1600×) into a
|
||||
* range a display can show without the bright stars turning into discs.
|
||||
*/
|
||||
export function starBrightness(vmag) {
|
||||
const magnitude = Number.isFinite(vmag) ? vmag : STAR_LIMIT_MAGNITUDE;
|
||||
return (
|
||||
10 **
|
||||
(-0.2 * (Math.min(magnitude, STAR_LIMIT_MAGNITUDE) - STAR_LIMIT_MAGNITUDE))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprite parameters for a visual magnitude: `sigmaPx` is the Gaussian σ in
|
||||
* CSS pixels and `alpha` its peak opacity. Faint stars keep the minimum width
|
||||
* and fade; bright stars saturate and grow slowly.
|
||||
*/
|
||||
export function starAppearance(vmag) {
|
||||
const brightness = starBrightness(vmag);
|
||||
return {
|
||||
sigmaPx: clamp(
|
||||
STAR_MIN_SIGMA_PX * brightness ** 0.3,
|
||||
STAR_MIN_SIGMA_PX,
|
||||
STAR_MAX_SIGMA_PX,
|
||||
),
|
||||
alpha: clamp(STAR_MIN_ALPHA * brightness, STAR_MIN_ALPHA, 1),
|
||||
};
|
||||
}
|
||||
|
||||
/** Effective temperature from B−V (Ballesteros 2012). */
|
||||
export function bvToTemperature(bv) {
|
||||
const index = clamp(Number.isFinite(bv) ? bv : STAR_DEFAULT_BV, -0.4, 2.0);
|
||||
return 4600 * (1 / (0.92 * index + 1.7) + 1 / (0.92 * index + 0.62));
|
||||
}
|
||||
|
||||
/** Black-body colour of a temperature, sRGB 0..1 (Helland's fit). */
|
||||
export function temperatureToRgb(kelvin) {
|
||||
const t = clamp(kelvin, 1000, 40000) / 100;
|
||||
const red = t <= 66 ? 255 : 329.698727446 * (t - 60) ** -0.1332047592;
|
||||
const green =
|
||||
t <= 66
|
||||
? 99.4708025861 * Math.log(t) - 161.1195681661
|
||||
: 288.1221695283 * (t - 60) ** -0.0755148492;
|
||||
const blue =
|
||||
t >= 66
|
||||
? 255
|
||||
: t <= 19
|
||||
? 0
|
||||
: 138.5177312231 * Math.log(t - 10) - 305.0447927307;
|
||||
return [red, green, blue].map((channel) => clamp(channel, 0, 255) / 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display colour for a B−V index: the black-body hue, desaturated towards
|
||||
* white and normalized so the brightest channel is 1 (brightness belongs to
|
||||
* the magnitude alone).
|
||||
*/
|
||||
export function bvToRgb(bv) {
|
||||
const rgb = temperatureToRgb(bvToTemperature(bv)).map(
|
||||
(channel) => 1 - COLOR_SATURATION + COLOR_SATURATION * channel,
|
||||
);
|
||||
const peak = Math.max(...rgb);
|
||||
return rgb.map((channel) => channel / peak);
|
||||
}
|
||||
|
||||
/** Decode a catalogue pack into interleaved vertex data, `STAR_VERTEX_FLOATS` per star. */
|
||||
export function buildStarVertices({ stars }) {
|
||||
const count = stars.length / 4;
|
||||
const vertices = new Float32Array(count * STAR_VERTEX_FLOATS);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const [raDeg, decDeg, vmag, bv] = stars.slice(i * 4, i * 4 + 4);
|
||||
const { sigmaPx, alpha } = starAppearance(vmag);
|
||||
vertices.set(
|
||||
[...raDecToUnitVector(raDeg, decDeg), sigmaPx, ...bvToRgb(bv), alpha],
|
||||
i * STAR_VERTEX_FLOATS,
|
||||
);
|
||||
}
|
||||
return { count, vertices };
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import {
|
||||
STAR_LIMIT_MAGNITUDE,
|
||||
STAR_MAX_SIGMA_PX,
|
||||
STAR_MIN_ALPHA,
|
||||
STAR_MIN_SIGMA_PX,
|
||||
STAR_VERTEX_FLOATS,
|
||||
buildStarVertices,
|
||||
bvToRgb,
|
||||
bvToTemperature,
|
||||
raDecToUnitVector,
|
||||
starAppearance,
|
||||
starBrightness,
|
||||
} from './stars.js';
|
||||
|
||||
const close = (actual, expected, epsilon = 1e-9) =>
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) <= epsilon,
|
||||
`${actual} is not within ${epsilon} of ${expected}`,
|
||||
);
|
||||
|
||||
const skyFile = (name) =>
|
||||
new URL(`../data/local_data/sky/${name}`, import.meta.url);
|
||||
|
||||
test('right ascension and declination map onto the ICRF axes', () => {
|
||||
const equinox = raDecToUnitVector(0, 0);
|
||||
close(equinox[0], 1);
|
||||
close(equinox[1], 0);
|
||||
close(equinox[2], 0);
|
||||
const sixHours = raDecToUnitVector(90, 0);
|
||||
close(sixHours[0], 0);
|
||||
close(sixHours[1], 1);
|
||||
const pole = raDecToUnitVector(123, 90);
|
||||
close(pole[2], 1);
|
||||
const south = raDecToUnitVector(200, -30);
|
||||
close(Math.hypot(...south), 1);
|
||||
close(south[2], -0.5);
|
||||
});
|
||||
|
||||
test('brightness grows with each magnitude and stops at the catalogue limit', () => {
|
||||
close(starBrightness(STAR_LIMIT_MAGNITUDE), 1);
|
||||
close(starBrightness(STAR_LIMIT_MAGNITUDE + 1.5), 1);
|
||||
close(starBrightness(STAR_LIMIT_MAGNITUDE - 5), 10);
|
||||
close(starBrightness(Number.NaN), 1);
|
||||
});
|
||||
|
||||
test('brighter stars are never smaller or fainter, and both ends clamp', () => {
|
||||
let previous = starAppearance(8);
|
||||
assert.deepEqual(previous, {
|
||||
sigmaPx: STAR_MIN_SIGMA_PX,
|
||||
alpha: STAR_MIN_ALPHA,
|
||||
});
|
||||
for (let vmag = 7.5; vmag >= -1.5; vmag -= 0.25) {
|
||||
const next = starAppearance(vmag);
|
||||
assert.ok(next.sigmaPx >= previous.sigmaPx, `σ shrank at ${vmag}`);
|
||||
assert.ok(next.alpha >= previous.alpha, `alpha dropped at ${vmag}`);
|
||||
previous = next;
|
||||
}
|
||||
const sirius = starAppearance(-1.46);
|
||||
assert.equal(sirius.alpha, 1);
|
||||
assert.ok(sirius.sigmaPx <= STAR_MAX_SIGMA_PX);
|
||||
assert.ok(sirius.sigmaPx > starAppearance(2).sigmaPx);
|
||||
// Sprites stay point-like: ±3σ of the brightest star is a few pixels.
|
||||
assert.ok(6 * STAR_MAX_SIGMA_PX < 10);
|
||||
});
|
||||
|
||||
test('B−V colours run from blue-white to orange with the peak channel at 1', () => {
|
||||
close(bvToTemperature(0.65), 5_800, 120);
|
||||
close(bvToTemperature(0), 10_100, 200);
|
||||
const hot = bvToRgb(-0.3);
|
||||
const sunLike = bvToRgb(0.65);
|
||||
const cool = bvToRgb(1.6);
|
||||
for (const rgb of [hot, sunLike, cool]) {
|
||||
close(Math.max(...rgb), 1);
|
||||
assert.ok(Math.min(...rgb) > 0.5, 'colours stay desaturated');
|
||||
}
|
||||
assert.ok(hot[2] > hot[0], 'hot stars are blue');
|
||||
assert.ok(cool[0] > cool[2], 'cool stars are orange');
|
||||
assert.deepEqual(bvToRgb(null), bvToRgb(0.6));
|
||||
assert.deepEqual(bvToRgb(9), bvToRgb(2));
|
||||
});
|
||||
|
||||
test('vertex data is interleaved per star', () => {
|
||||
const { count, vertices } = buildStarVertices({
|
||||
stars: [0, 0, 1, 0.5, 90, 45, 3, null],
|
||||
});
|
||||
assert.equal(count, 2);
|
||||
assert.equal(vertices.length, 2 * STAR_VERTEX_FLOATS);
|
||||
assert.deepEqual(Array.from(vertices.subarray(0, 3)), [1, 0, 0]);
|
||||
close(vertices[STAR_VERTEX_FLOATS + 1], Math.SQRT1_2, 1e-6);
|
||||
close(vertices[STAR_VERTEX_FLOATS + 2], Math.SQRT1_2, 1e-6);
|
||||
close(vertices[3], starAppearance(1).sigmaPx, 1e-6);
|
||||
close(vertices[7], starAppearance(1).alpha, 1e-6);
|
||||
});
|
||||
|
||||
test('the committed catalogue holds the Yale Bright Star Catalogue', () => {
|
||||
const pack = JSON.parse(readFileSync(skyFile('bright_stars.json'), 'utf8'));
|
||||
assert.deepEqual(pack.meta.columns, ['raDeg', 'decDeg', 'vmag', 'bv']);
|
||||
assert.equal(pack.stars.length, pack.meta.count * 4);
|
||||
assert.equal(pack.meta.count, 9096);
|
||||
let brightest = 0;
|
||||
for (let i = 0; i < pack.stars.length; i += 4) {
|
||||
const [ra, dec, vmag] = pack.stars.slice(i, i + 4);
|
||||
assert.ok(ra >= 0 && ra < 360 && dec >= -90 && dec <= 90);
|
||||
assert.ok(Number.isFinite(vmag));
|
||||
if (vmag < pack.stars[brightest + 2]) brightest = i;
|
||||
}
|
||||
// Sirius, α CMa: 06h45m08.9s −16°42′58″, V = −1.46.
|
||||
assert.deepEqual(
|
||||
pack.stars.slice(brightest, brightest + 4),
|
||||
[101.287, -16.716, -1.46, 0],
|
||||
);
|
||||
assert.equal(buildStarVertices(pack).count, pack.meta.count);
|
||||
});
|
||||
|
||||
test('sky assets stay inside the 5 MB budget', () => {
|
||||
const files = [
|
||||
'bright_stars.json',
|
||||
...['px', 'nx', 'py', 'ny', 'pz', 'nz'].map(
|
||||
(face) => `milkyway_${face}.webp`,
|
||||
),
|
||||
];
|
||||
const total = files.reduce(
|
||||
(sum, name) => sum + statSync(skyFile(name)).size,
|
||||
0,
|
||||
);
|
||||
assert.ok(total < 5 * 1024 * 1024, `${total} bytes`);
|
||||
});
|
||||
@@ -583,3 +583,124 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
|
||||
assert.equal(res2.statusCode, 502);
|
||||
assert.equal(callCount, 1);
|
||||
});
|
||||
|
||||
test('global aircraft snapshots coalesce cold and expired callers and clear rejected flights', async (t) => {
|
||||
environment(t, { OPENSKY_AUTH_MODE: 'anon' });
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(console, 'error', () => {});
|
||||
let now = Date.now();
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
for (const kind of ['opensky', 'military']) {
|
||||
const module =
|
||||
kind === 'opensky'
|
||||
? await import(
|
||||
`../../server/providers/aircraft/opensky.js?coalesce=${Math.random()}`
|
||||
)
|
||||
: await import('../../server/providers/aircraft/adsb-lol.js');
|
||||
const request = install(
|
||||
kind === 'opensky' ? module.openSkyProxy() : module.adsbLolProxy(),
|
||||
);
|
||||
const route = kind === 'opensky' ? '/api/flights' : '/api/military';
|
||||
let calls = 0;
|
||||
let release;
|
||||
let blocked;
|
||||
let fail = false;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls++;
|
||||
await blocked;
|
||||
if (fail) throw new Error('fixture connection failed');
|
||||
return Response.json(
|
||||
{ time: now / 1000, states: [], ac: [] },
|
||||
{ headers: { 'x-rate-limit-remaining': '5000' } },
|
||||
);
|
||||
});
|
||||
for (const phase of ['cold', 'expired', 'failed', 'recovered']) {
|
||||
now += 13000;
|
||||
fail = phase === 'failed';
|
||||
blocked = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const before = calls;
|
||||
const pending = Array.from({ length: 5 }, () => request(route));
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(calls - before, 1, `${kind} ${phase}: one refresh starts`);
|
||||
release();
|
||||
const responses = await Promise.all(pending);
|
||||
assert.equal(calls - before, fail && kind === 'opensky' ? 2 : 1);
|
||||
assert.ok(responses.every((res) => res.statusCode === 200));
|
||||
assert.ok(responses.every((res) => res.body === responses[0].body));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenSky 5xx retains dated last-good data, cancels error bodies and cools down before recovery', async (t) => {
|
||||
environment(t, { OPENSKY_AUTH_MODE: 'anon' });
|
||||
let now = Date.now();
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
for (const status of [502, 503, 504]) {
|
||||
const module = await import(
|
||||
`../../server/providers/aircraft/opensky.js?server-error=${status}`
|
||||
);
|
||||
const request = install(module.openSkyProxy());
|
||||
let fail = false;
|
||||
let calls = 0;
|
||||
let cancelled = false;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls++;
|
||||
if (fail)
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
{ status },
|
||||
);
|
||||
return Response.json(
|
||||
{ time: now / 1000, states: [] },
|
||||
{ headers: { 'x-rate-limit-remaining': '5000' } },
|
||||
);
|
||||
});
|
||||
const good = await request('/api/flights');
|
||||
now += 10000;
|
||||
fail = true;
|
||||
const stale = await request('/api/flights');
|
||||
assert.equal(stale.statusCode, 200);
|
||||
assert.equal(stale.body, good.body);
|
||||
assert.equal(stale.headers['x-opensky-cache'], 'STALE');
|
||||
assert.equal(
|
||||
stale.headers['x-opensky-auth-reason'],
|
||||
`upstream_http_${status}_serving_stale`,
|
||||
);
|
||||
assert.equal(stale.headers['x-opensky-stale-seconds'], '10');
|
||||
assert.equal(cancelled, true);
|
||||
const cooling = await request('/api/flights');
|
||||
assert.equal(cooling.body, good.body);
|
||||
assert.equal(
|
||||
cooling.headers['x-opensky-auth-reason'],
|
||||
`upstream_http_${status}_serving_stale`,
|
||||
);
|
||||
assert.equal(calls, 2);
|
||||
now += 16000;
|
||||
fail = false;
|
||||
assert.equal(
|
||||
(await request('/api/flights')).headers['x-opensky-cache'],
|
||||
'MISS',
|
||||
);
|
||||
assert.equal(calls, 3);
|
||||
}
|
||||
const coldModule =
|
||||
await import('../../server/providers/aircraft/opensky.js?cold-server-error');
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls++;
|
||||
return new Response('<html>failure</html>', { status: 503 });
|
||||
});
|
||||
const cold = install(coldModule.openSkyProxy());
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const response = await cold('/api/flights');
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.match(JSON.parse(response.body).error, /OpenSky/);
|
||||
}
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
CUBE_FACES,
|
||||
FACE_FILE_SUFFIX,
|
||||
PLANETS,
|
||||
cubeFaceDirection,
|
||||
icrfToGalactic,
|
||||
} from '../../scripts/build-milky-way.mjs';
|
||||
import { raDecToUnitVector } from '../sky/stars.js';
|
||||
|
||||
const SKY = new URL('../data/local_data/sky/', import.meta.url);
|
||||
// Allowed excess of the painted disc over the surrounding sky, in 8-bit
|
||||
// levels of the encoded face: the blur and dither move values by a few.
|
||||
const TOLERANCE = 4;
|
||||
|
||||
test('painted-out planets leave no disc brighter than the sky around them', async () => {
|
||||
const centres = PLANETS.map((planet) => ({
|
||||
planet,
|
||||
vector: raDecToUnitVector(planet.l, planet.b),
|
||||
disc: [],
|
||||
ring: [],
|
||||
}));
|
||||
for (const face of CUBE_FACES) {
|
||||
const { data, info } = await sharp(
|
||||
new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY).pathname,
|
||||
)
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const size = info.width;
|
||||
for (let j = 0; j < size; j++) {
|
||||
for (let i = 0; i < size; i++) {
|
||||
const d = cubeFaceDirection(face, (i + 0.5) / size, (j + 0.5) / size);
|
||||
const n = Math.hypot(...d);
|
||||
const g = icrfToGalactic(d.map((x) => x / n));
|
||||
for (const centre of centres) {
|
||||
const cos =
|
||||
g[0] * centre.vector[0] +
|
||||
g[1] * centre.vector[1] +
|
||||
g[2] * centre.vector[2];
|
||||
const degrees = (Math.acos(Math.min(1, cos)) * 180) / Math.PI;
|
||||
const k = (j * size + i) * info.channels;
|
||||
const luma = (data[k] + data[k + 1] + data[k + 2]) / 3;
|
||||
if (degrees <= centre.planet.radiusDeg - 0.5) centre.disc.push(luma);
|
||||
else if (
|
||||
degrees >= centre.planet.radiusDeg + 1 &&
|
||||
degrees <= centre.planet.radiusDeg + 3.5
|
||||
)
|
||||
centre.ring.push(luma);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const median = (values) => values.sort((a, b) => a - b)[values.length >> 1];
|
||||
for (const { planet, disc, ring } of centres) {
|
||||
const discMean = disc.reduce((s, v) => s + v, 0) / disc.length;
|
||||
const ringMedian = median(ring);
|
||||
assert.ok(
|
||||
discMean <= ringMedian + TOLERANCE,
|
||||
`l=${planet.l} b=${planet.b}: disc mean ${discMean.toFixed(1)} vs surrounding sky ${ringMedian.toFixed(1)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildStarPack,
|
||||
parseBscRecord,
|
||||
} from '../../scripts/build-bright-stars.mjs';
|
||||
import {
|
||||
CUBE_FACES,
|
||||
cubeFaceDirection,
|
||||
directionToPlateCarree,
|
||||
icrfToGalactic,
|
||||
smoothstep,
|
||||
softBlack,
|
||||
} from '../../scripts/build-milky-way.mjs';
|
||||
import { raDecToUnitVector } from '../sky/stars.js';
|
||||
|
||||
// Verbatim CDS V/50 records: HR 1, Sirius (HR 2491) and a nova with no J2000
|
||||
// position. Columns matter, so the padding is part of the fixture.
|
||||
const HR_1 =
|
||||
' 1 BD+44 4550 3 36042 46 000001.1+444022000509.9+451345114.44-16.88 6.70 +0.07 +0.08 A1Vn -0.012-0.018 -018 195 4.2 21.6AC 3';
|
||||
const SIRIUS =
|
||||
'2491 9Alp CMaBD-16 1591 48915151881 257I 5423 064044.6-163444064508.9-164258227.22-08.88-1.46 0.00 -0.05 -0.03 A1Vm -0.553-1.205 +.375-008SBO 13 10.3 11.2AB 4*';
|
||||
const NOVA =
|
||||
' 92 NOVA 1572 B Cas *';
|
||||
|
||||
const close = (actual, expected, epsilon = 1e-9) =>
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) <= epsilon,
|
||||
`${actual} is not within ${epsilon} of ${expected}`,
|
||||
);
|
||||
|
||||
test('BSC5 records parse J2000 position, V magnitude and B−V', () => {
|
||||
const sirius = parseBscRecord(SIRIUS);
|
||||
assert.equal(sirius.hr, 2491);
|
||||
close(sirius.raDeg, (6 + 45 / 60 + 8.9 / 3600) * 15);
|
||||
close(sirius.decDeg, -(16 + 42 / 60 + 58 / 3600));
|
||||
assert.equal(sirius.vmag, -1.46);
|
||||
assert.equal(sirius.bv, 0);
|
||||
const first = parseBscRecord(HR_1);
|
||||
close(first.raDeg, (5 / 60 + 9.9 / 3600) * 15);
|
||||
close(first.decDeg, 45 + 13 / 60 + 45 / 3600);
|
||||
assert.equal(first.vmag, 6.7);
|
||||
assert.equal(first.bv, 0.07);
|
||||
assert.equal(parseBscRecord(NOVA), null);
|
||||
});
|
||||
|
||||
test('the star pack is a flat, rounded column array that counts its drops', () => {
|
||||
const pack = buildStarPack([HR_1, NOVA, SIRIUS, ''].join('\n'), {
|
||||
fetched: '2026-10-19',
|
||||
});
|
||||
assert.deepEqual(
|
||||
pack.stars,
|
||||
[1.291, 45.229, 6.7, 0.07, 101.287, -16.716, -1.46, 0],
|
||||
);
|
||||
assert.equal(pack.meta.count, 2);
|
||||
assert.equal(pack.meta.dropped, 1);
|
||||
assert.equal(pack.meta.fetched, '2026-10-19');
|
||||
});
|
||||
|
||||
test('cube faces use the WebGL major-axis layout', () => {
|
||||
// Each face centre looks down its own axis …
|
||||
const axes = {
|
||||
positiveX: [1, 0, 0],
|
||||
negativeX: [-1, 0, 0],
|
||||
positiveY: [0, 1, 0],
|
||||
negativeY: [0, -1, 0],
|
||||
positiveZ: [0, 0, 1],
|
||||
negativeZ: [0, 0, -1],
|
||||
};
|
||||
for (const face of CUBE_FACES)
|
||||
assert.deepEqual(
|
||||
cubeFaceDirection(face, 0.5, 0.5).map((v) => v + 0),
|
||||
axes[face],
|
||||
);
|
||||
// … and the first texel row and column follow OpenGL ES 3.0 §3.8.10:
|
||||
// on +X, s grows towards −Z and t towards −Y.
|
||||
assert.deepEqual(cubeFaceDirection('positiveX', 0, 0), [1, 1, 1]);
|
||||
assert.deepEqual(cubeFaceDirection('positiveZ', 1, 1), [1, -1, 1]);
|
||||
assert.deepEqual(cubeFaceDirection('negativeY', 0, 1), [-1, -1, -1]);
|
||||
assert.throws(() => cubeFaceDirection('up', 0, 0), RangeError);
|
||||
});
|
||||
|
||||
test('plate carrée is centred on longitude 0, growing to the left, north up', () => {
|
||||
assert.deepEqual(directionToPlateCarree(1, 0, 0), [0.5, 0.5]);
|
||||
const sixHours = directionToPlateCarree(0, 1, 0);
|
||||
close(sixHours[0], 0.25);
|
||||
const eighteenHours = directionToPlateCarree(0, -1, 0);
|
||||
close(eighteenHours[0], 0.75);
|
||||
close(directionToPlateCarree(0, 0, 1)[1], 0);
|
||||
close(directionToPlateCarree(0, 0, -1)[1], 1);
|
||||
const twelveHours = directionToPlateCarree(-1, 0, 0);
|
||||
assert.ok(twelveHours[0] === 0 || twelveHours[0] === 1);
|
||||
});
|
||||
|
||||
test('the galactic rotation puts the Galactic Centre and pole where the IAU does', () => {
|
||||
// Galactic origin l = b = 0 at RA 266.405°, Dec −28.936° (J2000).
|
||||
const centre = icrfToGalactic(raDecToUnitVector(266.40499, -28.93617));
|
||||
close(centre[0], 1, 1e-6);
|
||||
// North galactic pole at RA 192.85948°, Dec +27.12825°.
|
||||
const pole = icrfToGalactic(raDecToUnitVector(192.85948, 27.12825));
|
||||
close(pole[2], 1, 1e-6);
|
||||
// ESO's panorama: the Large Magellanic Cloud (l ≈ 280.5°, b ≈ −32.9°) sits
|
||||
// right of and below the centre.
|
||||
const [u, v] = directionToPlateCarree(
|
||||
...icrfToGalactic(raDecToUnitVector(80.894, -69.756)),
|
||||
);
|
||||
close(u, 0.5 + (360 - 280.47) / 360, 2e-3);
|
||||
close(v, 0.5 + 32.89 / 180, 2e-3);
|
||||
});
|
||||
|
||||
test('the black level eases in instead of cutting the faint sky into contours', () => {
|
||||
assert.equal(softBlack(-0.2), 0);
|
||||
assert.equal(softBlack(0), 0);
|
||||
// Continuous, rising, with no step at the black level.
|
||||
let previous = 0;
|
||||
for (let x = 0.001; x <= 1; x += 0.001) {
|
||||
const y = softBlack(x);
|
||||
assert.ok(y > previous && y - previous < 0.0011, `step at ${x.toFixed(3)}`);
|
||||
previous = y;
|
||||
}
|
||||
// Starts flat, then joins the straight line just below it.
|
||||
assert.ok(softBlack(0.003) < 0.0003);
|
||||
close(softBlack(1), 1 - 0.03, 1e-3);
|
||||
});
|
||||
|
||||
test('the faint-glow blend weight eases between its edges', () => {
|
||||
assert.equal(smoothstep(0.1, 0.2, 0), 0);
|
||||
assert.equal(smoothstep(0.1, 0.2, 0.1), 0);
|
||||
close(smoothstep(0.1, 0.2, 0.15), 0.5, 1e-9);
|
||||
assert.equal(smoothstep(0.1, 0.2, 0.2), 1);
|
||||
assert.equal(smoothstep(0.1, 0.2, 9), 1);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/** A reveal must beat the one-second idle rotation refresh, regardless of FPS. */
|
||||
export const ARRIVAL_OBSERVATION_MS = 500;
|
||||
|
||||
/**
|
||||
* Observe the first completed moveEnd frame and the first subsequent reveal.
|
||||
* The deadline starts when moveEnd is forced, before its rotation baseline, so
|
||||
* waiting for either frame cannot let the one-second idle refresh mask a bug.
|
||||
* `read` snapshots visibility and rotation error against the current position.
|
||||
*/
|
||||
export function observeArrivalRotation({
|
||||
scene,
|
||||
startMoveEnd,
|
||||
startReveal,
|
||||
read,
|
||||
now = () => performance.now(),
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = now();
|
||||
let moveEnd = null;
|
||||
let moveEndMs = null;
|
||||
let revealFrames = 0;
|
||||
let done = false;
|
||||
let remove;
|
||||
let timer;
|
||||
const cleanup = () => {
|
||||
done = true;
|
||||
remove?.();
|
||||
clearTimer(timer);
|
||||
};
|
||||
const finish = (result) => {
|
||||
if (done) return;
|
||||
cleanup();
|
||||
resolve(result);
|
||||
};
|
||||
const expired = () => ({
|
||||
error: `arrival ${moveEnd ? 'reveal' : 'moveEnd'} observation exceeded ${ARRIVAL_OBSERVATION_MS} ms`,
|
||||
elapsedMs: now() - startedAt,
|
||||
moveEndMs,
|
||||
flipFrame: -1,
|
||||
});
|
||||
remove = scene.postRender.addEventListener(() => {
|
||||
if (done) return;
|
||||
try {
|
||||
const elapsedMs = now() - startedAt;
|
||||
if (elapsedMs >= ARRIVAL_OBSERVATION_MS) {
|
||||
finish(expired());
|
||||
return;
|
||||
}
|
||||
const sample = read();
|
||||
if (moveEnd === null) {
|
||||
moveEnd = sample;
|
||||
moveEndMs = elapsedMs;
|
||||
startReveal();
|
||||
} else {
|
||||
revealFrames++;
|
||||
if (sample.shown) {
|
||||
// Keep the FIRST visible result, including a wrong angle. Never
|
||||
// wait for a subsequent tick to repair a failed reveal.
|
||||
finish({
|
||||
dMoveEnd: moveEnd.rotationError,
|
||||
dReveal: sample.rotationError,
|
||||
shown: sample.shown,
|
||||
flipFrame: revealFrames,
|
||||
moveEndMs,
|
||||
revealMs: elapsedMs - moveEndMs,
|
||||
elapsedMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
scene.requestRender();
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
timer = setTimer(() => finish(expired()), ARRIVAL_OBSERVATION_MS);
|
||||
try {
|
||||
startMoveEnd();
|
||||
scene.requestRender();
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Test the captured frames without retrying a bad first observation. */
|
||||
export function arrivalRotationPassed(observation) {
|
||||
return (
|
||||
!!observation &&
|
||||
!observation.error &&
|
||||
observation.shown === true &&
|
||||
observation.elapsedMs < ARRIVAL_OBSERVATION_MS &&
|
||||
(observation.dMoveEnd * 180) / Math.PI < 10 &&
|
||||
(observation.dReveal * 180) / Math.PI < 10
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ARRIVAL_OBSERVATION_MS,
|
||||
arrivalRotationPassed,
|
||||
observeArrivalRotation,
|
||||
} from './trackArrivalObservation.mjs';
|
||||
|
||||
function fixture() {
|
||||
let time = 0;
|
||||
let state = { shown: true, rotationError: 0 };
|
||||
const listeners = new Set();
|
||||
const timers = new Map();
|
||||
let renders = 0;
|
||||
let moveEnds = 0;
|
||||
const observation = observeArrivalRotation({
|
||||
scene: {
|
||||
postRender: {
|
||||
addEventListener(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
},
|
||||
requestRender() {
|
||||
renders++;
|
||||
},
|
||||
},
|
||||
startMoveEnd() {
|
||||
moveEnds++;
|
||||
state.rotationError = 1.5;
|
||||
},
|
||||
startReveal() {
|
||||
state = { shown: false, rotationError: 1.5 };
|
||||
},
|
||||
read: () => ({ ...state }),
|
||||
now: () => time,
|
||||
setTimer(callback, delay) {
|
||||
const handle = {};
|
||||
timers.set(handle, { callback, at: time + delay });
|
||||
return handle;
|
||||
},
|
||||
clearTimer(handle) {
|
||||
timers.delete(handle);
|
||||
},
|
||||
});
|
||||
return {
|
||||
observation,
|
||||
frame(at, nextState) {
|
||||
time = at;
|
||||
state = { ...state, ...nextState };
|
||||
for (const listener of [...listeners]) listener();
|
||||
},
|
||||
expire(at) {
|
||||
time = at;
|
||||
for (const timer of [...timers.values()])
|
||||
if (timer.at <= at) timer.callback();
|
||||
},
|
||||
get resources() {
|
||||
return {
|
||||
listeners: listeners.size,
|
||||
timers: timers.size,
|
||||
renders,
|
||||
moveEnds,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('80 ms fleet scheduling passes after more than four fast rendered frames', async () => {
|
||||
const f = fixture();
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
// The forced fleet tick was at 8 ms. A 125 FPS renderer can complete nine
|
||||
// more frames before the next 80 ms fleet interval becomes eligible.
|
||||
for (let at = 16; at < 88; at += 8) f.frame(at);
|
||||
f.frame(88, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.flipFrame, 10);
|
||||
assert.equal(result.moveEndMs, 8);
|
||||
assert.equal(result.revealMs, 80);
|
||||
assert.equal(result.elapsedMs, 88);
|
||||
assert.equal(arrivalRotationPassed(result), true);
|
||||
assert.equal(f.resources.listeners, 0);
|
||||
assert.equal(f.resources.timers, 0);
|
||||
assert.equal(f.resources.moveEnds, 1);
|
||||
});
|
||||
|
||||
test('a wrong first visible angle fails even if the next frame corrects it', async () => {
|
||||
const f = fixture();
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
f.frame(88, { shown: true, rotationError: 1.5 });
|
||||
f.frame(96, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.dReveal, 1.5);
|
||||
assert.equal(result.flipFrame, 1);
|
||||
assert.equal(result.elapsedMs, 88);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('a wrong first moveEnd angle is retained rather than repaired by reveal', async () => {
|
||||
const f = fixture();
|
||||
f.frame(8, { shown: true, rotationError: 1.5 });
|
||||
f.frame(88, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.dMoveEnd, 1.5);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('no reveal fails at a deadline before the one-second idle catch-up', async () => {
|
||||
const f = fixture();
|
||||
assert.equal(ARRIVAL_OBSERVATION_MS, 500);
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
f.frame(480);
|
||||
f.expire(500);
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /reveal.*500 ms/);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
assert.equal(f.resources.listeners, 0);
|
||||
assert.equal(f.resources.timers, 0);
|
||||
f.frame(1000, { shown: true, rotationError: 0 });
|
||||
assert.equal(result.flipFrame, -1);
|
||||
});
|
||||
|
||||
test('a late visible frame fails even if the timeout callback is delayed', async () => {
|
||||
const f = fixture();
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
f.frame(501, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /reveal.*500 ms/);
|
||||
assert.equal(result.elapsedMs, 501);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('the deadline includes moveEnd setup rather than restarting when hidden', async () => {
|
||||
const f = fixture();
|
||||
f.frame(400, { shown: true, rotationError: 0 });
|
||||
f.frame(600, { shown: true, rotationError: 0 });
|
||||
const result = await f.observation;
|
||||
assert.equal(result.moveEndMs, 400);
|
||||
assert.equal(result.elapsedMs, 600);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
});
|
||||
|
||||
test('missing moveEnd frames fail without waiting for a render callback', async () => {
|
||||
const f = fixture();
|
||||
f.expire(500);
|
||||
const result = await f.observation;
|
||||
assert.match(result.error, /moveEnd.*500 ms/);
|
||||
assert.equal(arrivalRotationPassed(result), false);
|
||||
assert.equal(f.resources.listeners, 0);
|
||||
assert.equal(f.resources.timers, 0);
|
||||
});
|
||||
@@ -1,9 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { rm, writeFile } from 'node:fs/promises';
|
||||
import { request } from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { build, createServer, preview } from 'vite';
|
||||
import { BROWSER_CSP, createBrowserViteConfig } from '../../build/vite.js';
|
||||
import standaloneConfig, * as compatibility from '../../vite.config.js';
|
||||
import * as providers from '../../server/providers/local.js';
|
||||
import { apiNotFoundPlugin } from '../../server/standalone/api-not-found.js';
|
||||
import { makeFixtureRoot } from './fixtureRoot.mjs';
|
||||
|
||||
test('explicit build inputs preserve browser-only defines, plugin order and loopback protections', () => {
|
||||
const plugin = { name: 'fixture-provider' };
|
||||
@@ -12,10 +18,13 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
|
||||
googleApiKey: 'browser-fixture',
|
||||
cesiumToken: 'ion-fixture',
|
||||
});
|
||||
// The Host check runs before every other plugin's middleware.
|
||||
assert.equal(config.plugins[0].name, 'host-check');
|
||||
// API response headers, then the Host check, run before every other
|
||||
// plugin's middleware.
|
||||
assert.equal(config.plugins[0].name, 'api-response-headers');
|
||||
assert.equal(config.plugins[0].enforce, 'pre');
|
||||
assert.equal(config.plugins[3], plugin);
|
||||
assert.equal(config.plugins[1].name, 'host-check');
|
||||
assert.equal(config.plugins[1].enforce, 'pre');
|
||||
assert.equal(config.plugins[4], plugin);
|
||||
assert.equal(config.server.host, 'localhost');
|
||||
assert.equal(config.server.port, 4173);
|
||||
assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']);
|
||||
@@ -75,7 +84,7 @@ test('build helper does not discover environment values or construct local provi
|
||||
undefined,
|
||||
);
|
||||
assert.deepEqual(
|
||||
config.plugins.slice(3).map((plugin) => plugin.name),
|
||||
config.plugins.slice(4).map((plugin) => plugin.name),
|
||||
['embed-framing', 'panel-build'],
|
||||
);
|
||||
} finally {
|
||||
@@ -89,7 +98,7 @@ test('root config retains existing named exports and standalone provider order',
|
||||
assert.equal(compatibility[name], value, name);
|
||||
const config = standaloneConfig({ mode: 'test' });
|
||||
const standaloneProviders = config.plugins
|
||||
.slice(3, -5)
|
||||
.slice(4, -5)
|
||||
.map((plugin) => plugin.name);
|
||||
assert.equal(
|
||||
standaloneProviders.filter((name) => name === 'gemini-live-proxy').length,
|
||||
@@ -257,3 +266,129 @@ test('the CSP admits the event media embeds and the Maps JavaScript API, and no
|
||||
// X posts load in X's own frames.
|
||||
assert.ok(frames.includes('https://platform.twitter.com'));
|
||||
});
|
||||
|
||||
/** A raw GET, so the Host header and an absolute-form target are sent as given. */
|
||||
function rawGet(port, target, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: target,
|
||||
headers: { host: `localhost:${port}`, ...headers },
|
||||
},
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on('end', () =>
|
||||
resolve({ status: res.statusCode, headers: res.headers }),
|
||||
);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
test('every API response carries the API policy and nosniff on real dev and preview servers', async (t) => {
|
||||
const policy = "default-src 'none'; sandbox";
|
||||
const root = await makeFixtureRoot('gev-api-headers-');
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
await writeFile(
|
||||
path.join(root, 'index.html'),
|
||||
'<!doctype html><title>Fixture</title><p>x</p>',
|
||||
);
|
||||
const install = (server) => {
|
||||
server.middlewares.use('/api/fixture', (req, res) => {
|
||||
const route = new URL(req.url, 'http://localhost').pathname;
|
||||
if (route === '/ok') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end('{}');
|
||||
} else if (route === '/error') {
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end('{"error":"fixture"}');
|
||||
} else if (route === '/partial') {
|
||||
res.writeHead(206, { 'Content-Range': 'bytes 0-0/2' });
|
||||
res.end('x');
|
||||
} else if (route === '/own-policy') {
|
||||
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
|
||||
res.end('{}');
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Security-Policy': 'default-src *' });
|
||||
res.end('{}');
|
||||
}
|
||||
});
|
||||
};
|
||||
const fixture = {
|
||||
name: 'fixture-provider',
|
||||
configureServer: install,
|
||||
configurePreviewServer: install,
|
||||
};
|
||||
const built = createBrowserViteConfig({ plugins: [fixture] });
|
||||
const base = {
|
||||
root,
|
||||
configFile: false,
|
||||
envFile: false,
|
||||
publicDir: false,
|
||||
logLevel: 'silent',
|
||||
};
|
||||
await build(base);
|
||||
for (const isPreview of [false, true]) {
|
||||
const server = await (isPreview ? preview : createServer)({
|
||||
...base,
|
||||
plugins: [
|
||||
...built.plugins.filter(
|
||||
(plugin) =>
|
||||
plugin === fixture ||
|
||||
['api-response-headers', 'host-check'].includes(plugin.name),
|
||||
),
|
||||
apiNotFoundPlugin(),
|
||||
],
|
||||
server: { host: '127.0.0.1', port: 0, hmr: false },
|
||||
preview: { host: '127.0.0.1', port: 0 },
|
||||
});
|
||||
if (!isPreview) await server.listen();
|
||||
const { port } = server.httpServer.address();
|
||||
const mode = isPreview ? 'preview' : 'dev';
|
||||
try {
|
||||
for (const [target, status, csp = policy, headers] of [
|
||||
['/api/fixture/ok', 200],
|
||||
['/api/fixture/error', 500],
|
||||
['/api/fixture/partial', 206],
|
||||
['/api/nope', 404],
|
||||
['/api', 404],
|
||||
['/api/fixture/ok?x=1', 200],
|
||||
// Spellings the router mounts as the same routes.
|
||||
['/API/fixture/ok', 200],
|
||||
['/Api/fixture/error', 500],
|
||||
['/aPi/nope', 404],
|
||||
['/API', 404],
|
||||
['/api.x', 404],
|
||||
[`http://localhost:${port}/api/fixture/ok`, 200],
|
||||
[`http://localhost:${port}/API/nope`, 404],
|
||||
// The router keeps dot segments in an absolute-form target's path.
|
||||
['http://elsewhere.example/api/../x', 404],
|
||||
['http://elsewhere.example/API/%2e%2e/x', 404],
|
||||
['/api/fixture/ok', 403, policy, { host: 'elsewhere.example' }],
|
||||
// A route's own policy is added to the API policy, never replaces it.
|
||||
['/api/fixture/own-policy', 200, `${policy}, frame-ancestors 'none'`],
|
||||
['/api/fixture/own-policy-head', 200, `${policy}, default-src *`],
|
||||
]) {
|
||||
const res = await rawGet(port, target, headers);
|
||||
const label = `${mode} ${target} ${JSON.stringify(headers || {})}`;
|
||||
assert.equal(res.status, status, label);
|
||||
assert.equal(res.headers['content-security-policy'], csp, label);
|
||||
assert.equal(res.headers['x-content-type-options'], 'nosniff', label);
|
||||
}
|
||||
// The app document and other paths keep the server's own headers.
|
||||
for (const target of ['/', '/apiary', '/src/api/x.js'])
|
||||
assert.notEqual(
|
||||
(await rawGet(port, target)).headers['content-security-policy'],
|
||||
policy,
|
||||
`${mode} ${target}`,
|
||||
);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Create an ordinary native action button. Static templates use the same CSS
|
||||
* classes. Labels are already translated; feature owners retain callbacks,
|
||||
* disabled-state updates and listener cleanup. Construction binds no listeners.
|
||||
* @param {object} options
|
||||
* @param {Document} [options.documentRef] Document that owns the button.
|
||||
* @param {string} options.label Visible text, never interpreted as markup.
|
||||
* @param {string} [options.className] Additional feature/layout hooks.
|
||||
* @param {'default'|'danger'} [options.variant]
|
||||
* @param {'default'|'compact'} [options.size]
|
||||
* @param {boolean} [options.disabled]
|
||||
* @param {string} [options.ariaLabel] Accessible name when visible text needs context.
|
||||
* @param {string} [options.title]
|
||||
* @returns {HTMLButtonElement}
|
||||
*/
|
||||
export function createActionButton({
|
||||
documentRef = globalThis.document,
|
||||
label,
|
||||
className = '',
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
title,
|
||||
}) {
|
||||
const button = documentRef.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = [
|
||||
'gev-action-button',
|
||||
variant === 'danger' && 'gev-action-button--danger',
|
||||
size === 'compact' && 'gev-action-button--compact',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
button.textContent = label;
|
||||
button.disabled = disabled;
|
||||
if (ariaLabel !== undefined) button.setAttribute('aria-label', ariaLabel);
|
||||
if (title !== undefined) button.title = title;
|
||||
return button;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CctvControls } from './cctvControls.js';
|
||||
import { railFixture } from './railTestFixture.mjs';
|
||||
import { setLocale, t } from '../i18n/index.js';
|
||||
|
||||
function fixture(t) {
|
||||
const { document } = railFixture();
|
||||
const controls = new CctvControls({
|
||||
elements: {
|
||||
_cctvQualityChip: document.createElement('span'),
|
||||
_cctvSourceBadge: document.createElement('div'),
|
||||
},
|
||||
cctv: {},
|
||||
actions: { isEnabled: () => true, setPanelCollapsed() {} },
|
||||
});
|
||||
t.after(() => controls.destroy());
|
||||
return { controls, document };
|
||||
}
|
||||
|
||||
test('source and frame states retain their distinct meanings, including retained pixels', (testContext) => {
|
||||
const { controls, document } = fixture(testContext);
|
||||
const camera = { sourceKind: 'snapshot', sourceStatus: 'degraded' };
|
||||
controls._cctvFrame = document.createElement('img');
|
||||
controls._cctvFrameWrap = document.createElement('div');
|
||||
controls._cctvFrame.removeAttribute = () => {};
|
||||
for (const node of [controls._cctvFrame, controls._cctvFrameWrap]) {
|
||||
node.classList.remove = (...names) =>
|
||||
names.forEach((name) => node.classList.toggle(name, false));
|
||||
}
|
||||
const expect = (label, state, active = camera, enabled = true) => {
|
||||
controls._syncCctvSourceBadge(active, enabled);
|
||||
assert.equal(controls._cctvSourceBadge.textContent, label);
|
||||
assert.equal(controls._cctvSourceBadge.dataset.frameState, state);
|
||||
};
|
||||
expect('SOURCE · UNKNOWN', 'idle', camera, false);
|
||||
expect('SOURCE · UNKNOWN', 'idle', null);
|
||||
controls._cctvFrame.dataset.loading = 'true';
|
||||
expect('FRAME · LOADING', 'loading');
|
||||
controls._cctvFrame.dataset.loading = '';
|
||||
controls._cctvFrame.dataset.error = 'true';
|
||||
expect('FRAME · UNAVAILABLE', 'error');
|
||||
// A refresh failure keeps the settled pixels and the provider's source status.
|
||||
controls._cctvFrameWrap.classList.toggle('has-frame', true);
|
||||
expect('SNAPSHOT · DEGRADED', 'ready');
|
||||
controls._cctvFrame.dataset.loading = 'true';
|
||||
expect('SNAPSHOT · DEGRADED', 'ready');
|
||||
controls._cctvFrame.dataset.loading = '';
|
||||
controls._cctvFrame.dataset.error = '';
|
||||
for (const status of ['ok', 'error', 'unknown', 'vendor-specific']) {
|
||||
expect(`SNAPSHOT · ${status.toUpperCase()}`, 'ready', {
|
||||
...camera,
|
||||
sourceStatus: status,
|
||||
});
|
||||
}
|
||||
expect('LIVE · OK', 'ready', { feedType: 'live', sourceStatus: 'ok' });
|
||||
});
|
||||
|
||||
test('calibration labels follow supplied provenance and dirty/save/reset transitions without writes to the camera', (testContext) => {
|
||||
const { controls } = fixture(testContext);
|
||||
const cases = [
|
||||
['raw-prior', false, 'CAL · RAW PRIOR'],
|
||||
['curated', false, 'CAL · CURATED'],
|
||||
['curated', true, 'CAL · EDITED (UNSAVED)'],
|
||||
['calibrated', false, 'CAL · CALIBRATED'],
|
||||
['calibrated', true, 'CAL · EDITED (UNSAVED)'],
|
||||
['raw-prior', false, 'CAL · RAW PRIOR'],
|
||||
[null, false, 'CAL · --'],
|
||||
];
|
||||
for (const [calBadge, calDirty, label] of cases) {
|
||||
const activeCamera = Object.freeze({ id: 'a', calBadge, calDirty });
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
activeCamera,
|
||||
});
|
||||
assert.equal(controls._cctvQualityChip.textContent, label);
|
||||
assert.equal(
|
||||
controls._cctvQualityChip.dataset.calBadge,
|
||||
calDirty ? 'edited' : calBadge || '',
|
||||
);
|
||||
}
|
||||
controls._renderCctvState(null);
|
||||
assert.equal(controls._cctvQualityChip.textContent, 'CAL · --');
|
||||
assert.equal(controls._cctvQualityChip.dataset.calBadge, '');
|
||||
});
|
||||
|
||||
test('localization changes badge copy while preserving machine state ids', (testContext) => {
|
||||
const { controls } = fixture(testContext);
|
||||
testContext.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
controls._syncCctvSourceBadge(
|
||||
{ sourceKind: 'snapshot', sourceStatus: 'degraded' },
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
controls._cctvSourceBadge.textContent,
|
||||
`${t('SNAPSHOT')} · ${t('DEGRADED')}`,
|
||||
);
|
||||
assert.notEqual(controls._cctvSourceBadge.textContent, 'SNAPSHOT · DEGRADED');
|
||||
assert.equal(controls._cctvSourceBadge.dataset.frameState, 'ready');
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
activeCamera: { id: 'a', calBadge: 'curated', calDirty: true },
|
||||
});
|
||||
assert.equal(
|
||||
controls._cctvQualityChip.textContent,
|
||||
t('CAL · EDITED (UNSAVED)'),
|
||||
);
|
||||
assert.equal(controls._cctvQualityChip.dataset.calBadge, 'edited');
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatNumber, t, msg } from '../i18n/index.js';
|
||||
import { createNumberInput } from './formField.js';
|
||||
|
||||
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
|
||||
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
||||
@@ -117,11 +118,12 @@ export function _beginCctvCalValueEdit(chip) {
|
||||
const basePose = { ...activeCamera.basePose };
|
||||
const editListeners = new AbortController();
|
||||
const startValue = field.get(activeCamera);
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.step = field.decimals > 0 ? '0.1' : '1';
|
||||
input.value = Number(startValue).toFixed(field.decimals);
|
||||
input.className = 'cctv-cal-input';
|
||||
const input = createNumberInput({
|
||||
className: 'gev-field--inline cctv-cal-input',
|
||||
step: field.decimals > 0 ? '0.1' : '1',
|
||||
value: Number(startValue).toFixed(field.decimals),
|
||||
ariaLabel: t(field.label),
|
||||
});
|
||||
chip.textContent = `${t(field.label)} `;
|
||||
chip.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
@@ -105,6 +105,9 @@ function calibrationFixture(t) {
|
||||
Object.assign(input, {
|
||||
focus() {},
|
||||
select() {},
|
||||
setAttribute(name, value) {
|
||||
this[name] = value;
|
||||
},
|
||||
remove() {
|
||||
this.parent.input = null;
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { updateStatusBadge } from './statusBadge.js';
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
@@ -19,6 +20,7 @@ const SOURCE_KIND_LABELS = Object.freeze({
|
||||
});
|
||||
|
||||
const SOURCE_STATUS_LABELS = Object.freeze({
|
||||
ok: msg('OK'),
|
||||
degraded: msg('DEGRADED'),
|
||||
error: msg('ERROR'),
|
||||
unknown: msg('UNKNOWN'),
|
||||
@@ -51,7 +53,7 @@ export function cctvSourceKindLabel(kind) {
|
||||
: key.toUpperCase();
|
||||
}
|
||||
|
||||
/** "DEGRADED" / "ПЕРЕБОИ"; OK and unknown codes stay as uppercase codes. */
|
||||
/** "DEGRADED" / "ПЕРЕБОИ"; unknown codes stay as uppercase codes. */
|
||||
export function cctvSourceStatusLabel(status) {
|
||||
const key = String(status || 'unknown').toLowerCase();
|
||||
return SOURCE_STATUS_LABELS[key]
|
||||
@@ -164,19 +166,21 @@ export function _settleCctvFrame(token, src, ok) {
|
||||
export function _syncCctvSourceBadge(activeCamera, enabled) {
|
||||
if (!this._cctvSourceBadge) return;
|
||||
if (!enabled || !activeCamera) {
|
||||
this._cctvSourceBadge.textContent = t('SOURCE · UNKNOWN');
|
||||
updateStatusBadge(this._cctvSourceBadge, { label: t('SOURCE · UNKNOWN') });
|
||||
this._cctvSourceBadge.dataset.frameState = 'idle';
|
||||
return;
|
||||
}
|
||||
const hasDisplayedFrame =
|
||||
this._cctvFrameWrap?.classList.contains('has-frame');
|
||||
if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) {
|
||||
this._cctvSourceBadge.textContent = t('FRAME · LOADING');
|
||||
updateStatusBadge(this._cctvSourceBadge, { label: t('FRAME · LOADING') });
|
||||
this._cctvSourceBadge.dataset.frameState = 'loading';
|
||||
return;
|
||||
}
|
||||
if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) {
|
||||
this._cctvSourceBadge.textContent = t('FRAME · UNAVAILABLE');
|
||||
updateStatusBadge(this._cctvSourceBadge, {
|
||||
label: t('FRAME · UNAVAILABLE'),
|
||||
});
|
||||
this._cctvSourceBadge.dataset.frameState = 'error';
|
||||
return;
|
||||
}
|
||||
@@ -184,6 +188,6 @@ export function _syncCctvSourceBadge(activeCamera, enabled) {
|
||||
activeCamera.sourceKind || activeCamera.feedType || 'unknown',
|
||||
);
|
||||
const status = cctvSourceStatusLabel(activeCamera.sourceStatus || 'unknown');
|
||||
this._cctvSourceBadge.textContent = `${kind} · ${status}`;
|
||||
updateStatusBadge(this._cctvSourceBadge, { label: `${kind} · ${status}` });
|
||||
this._cctvSourceBadge.dataset.frameState = 'ready';
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { updateStatusBadge } from './statusBadge.js';
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
|
||||
|
||||
@@ -137,11 +138,13 @@ export function _renderCctvState(state) {
|
||||
// promotes to CALIBRATED, RESET CAL clears.
|
||||
const badge = activeCamera?.calBadge || null;
|
||||
const dirty = !!activeCamera?.calDirty;
|
||||
this._cctvQualityChip.textContent = dirty
|
||||
? t('CAL · EDITED (UNSAVED)')
|
||||
: t('CAL · {badge}', {
|
||||
badge: this._calBadgeLabel(badge),
|
||||
});
|
||||
updateStatusBadge(this._cctvQualityChip, {
|
||||
label: dirty
|
||||
? t('CAL · EDITED (UNSAVED)')
|
||||
: t('CAL · {badge}', {
|
||||
badge: this._calBadgeLabel(badge),
|
||||
}),
|
||||
});
|
||||
this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || '';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
|
||||
import * as Cesium from 'cesium';
|
||||
import { formatNumber, t, tn } from '../i18n/index.js';
|
||||
import { formatNumber, getLocale, t, tn } from '../i18n/index.js';
|
||||
import { unitLabel } from './unitLabels.js';
|
||||
import {
|
||||
bearingBetweenCoordinates,
|
||||
@@ -15,7 +15,7 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
|
||||
window: ({ label, radiusKm }) =>
|
||||
t('{label} · {radius} AIR/SEA WINDOW', {
|
||||
label,
|
||||
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${unitLabel('km').toUpperCase()}`,
|
||||
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale() === 'zh-CN' ? unitLabel('km') : unitLabel('km').toUpperCase()}`,
|
||||
}),
|
||||
installations: ({ scope, coverage }) =>
|
||||
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Pure formatting and fixed presentation constants used by Cockpit components. */
|
||||
import { formatNumber, t, msg } from '../i18n/index.js';
|
||||
import { formatNumber, getLocale, t, msg } from '../i18n/index.js';
|
||||
import { formatQuantity } from './unitLabels.js';
|
||||
|
||||
export const COCKPIT_HEADING_SLEW_DPS = 28;
|
||||
@@ -145,10 +145,11 @@ export function formatCockpitCoordinate(value, positive, negative) {
|
||||
*/
|
||||
export function formatCockpitDistance(distanceM) {
|
||||
const digits = distanceM < 10000 ? 1 : 0;
|
||||
return formatQuantity(distanceM / 1000, 'km', {
|
||||
const distance = formatQuantity(distanceM / 1000, 'km', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
}).toUpperCase();
|
||||
});
|
||||
return getLocale() === 'zh-CN' ? distance : distance.toUpperCase();
|
||||
}
|
||||
|
||||
export function formatCockpitWindDirection(value) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Build a native numeric field without listeners, parsing or commit policy.
|
||||
* Owners retain drafts, validation, input/change timing and disposal. Static
|
||||
* selects and number inputs share the gev-field surface in their templates;
|
||||
* options remain owned by each feature (no replacement popup or keyboard code).
|
||||
* @param {object} options
|
||||
* @param {Document} [options.documentRef]
|
||||
* @param {string} [options.className] Additional density/layout hooks.
|
||||
* @param {number|string} [options.min]
|
||||
* @param {number|string} [options.max]
|
||||
* @param {number|string} [options.step]
|
||||
* @param {number|string} [options.value]
|
||||
* @param {boolean} [options.disabled]
|
||||
* @param {string} [options.ariaLabel] Already translated accessible name.
|
||||
* @returns {HTMLInputElement}
|
||||
*/
|
||||
export function createNumberInput({
|
||||
documentRef = globalThis.document,
|
||||
className = '',
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
} = {}) {
|
||||
const input = documentRef.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = `gev-field${className ? ` ${className}` : ''}`;
|
||||
for (const [key, setting] of Object.entries({ min, max, step, value })) {
|
||||
if (setting !== undefined) input[key] = setting;
|
||||
}
|
||||
input.disabled = disabled;
|
||||
if (ariaLabel !== undefined) input.setAttribute('aria-label', ariaLabel);
|
||||
return input;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['ru', 'en', 'es'],
|
||||
['ru', 'en', 'es', 'zh-CN'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
codes(
|
||||
@@ -50,7 +50,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['es', 'en', 'ru'],
|
||||
['es', 'en', 'ru', 'zh-CN'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
codes(
|
||||
@@ -60,7 +60,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['en', 'es', 'ru'],
|
||||
['en', 'es', 'ru', 'zh-CN'],
|
||||
);
|
||||
const rows = buildLocaleOptions({
|
||||
locales: MANY,
|
||||
@@ -263,7 +263,7 @@ test('keyboard model: open on the current language, move, choose, close', () =>
|
||||
assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true });
|
||||
assert.equal(model.activeOption.code, 'en');
|
||||
model.keydown({ key: 'End' });
|
||||
assert.equal(model.activeOption.code, 'es');
|
||||
assert.equal(model.activeOption.code, 'zh-CN');
|
||||
model.keydown({ key: 'Home' });
|
||||
assert.equal(model.activeOption.code, 'ru');
|
||||
model.keydown({ key: 'ArrowUp' });
|
||||
|
||||
@@ -114,13 +114,14 @@ export function renderRadioState(state) {
|
||||
...state.categories.map((category) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = category.id;
|
||||
option.textContent = `● ${category.label} (${category.count})`;
|
||||
const label = t('{category} ({count})', {
|
||||
category: category.label,
|
||||
count: category.count,
|
||||
});
|
||||
option.textContent = `● ${label}`;
|
||||
option.dataset.radioColor = category.color;
|
||||
option.style.color = category.color;
|
||||
option.setAttribute(
|
||||
'aria-label',
|
||||
`${category.label} (${category.count})`,
|
||||
);
|
||||
option.setAttribute('aria-label', label);
|
||||
return option;
|
||||
}),
|
||||
);
|
||||
|
||||
+11
-7
@@ -1,4 +1,5 @@
|
||||
import { createRailCardBlocks } from './railCardBlocks.js';
|
||||
import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
|
||||
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
@@ -53,11 +54,12 @@ export function createRailCards({
|
||||
'rail-card-title rail-card-nowrap',
|
||||
heading,
|
||||
);
|
||||
const badge = make(
|
||||
'span',
|
||||
`rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`,
|
||||
labels,
|
||||
);
|
||||
const badge = createStatusBadge({
|
||||
documentRef: document,
|
||||
appearance: 'plain',
|
||||
className: `rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`,
|
||||
});
|
||||
labels.appendChild(badge);
|
||||
const disclosure = make('span', 'rail-card-disclosure', header);
|
||||
disclosure.setAttribute('aria-hidden', 'true');
|
||||
const compact = make('span', 'rail-card-compact', header);
|
||||
@@ -90,8 +92,10 @@ export function createRailCards({
|
||||
row.title.setAttribute('title', card.title);
|
||||
set(row.icon, 'textContent', card.icon || '');
|
||||
set(row.icon, 'hidden', !card.icon);
|
||||
set(row.badge, 'textContent', card.badge || '');
|
||||
set(row.badge, 'hidden', !card.badge);
|
||||
updateStatusBadge(row.badge, {
|
||||
label: card.badge || '',
|
||||
hidden: !card.badge,
|
||||
});
|
||||
const open = card.open !== false;
|
||||
set(row.element.dataset, 'open', String(open));
|
||||
row.element.classList.toggle('is-open', open);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Scene panel elements and text-only project presentation. */
|
||||
import { formatNumber, msg, t } from '../i18n/index.js';
|
||||
import { createActionButton } from './actionButton.js';
|
||||
|
||||
// Labels for what a shot stores; anything else shows as stored. Style names
|
||||
// match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot
|
||||
@@ -121,13 +122,18 @@ export function renderSceneShots(
|
||||
});
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'scene-shot-actions';
|
||||
const loadButton = document.createElement('button');
|
||||
loadButton.className = 'scene-shot-btn';
|
||||
loadButton.textContent = t('LOAD');
|
||||
const loadButton = createActionButton({
|
||||
label: t('LOAD'),
|
||||
className: 'scene-shot-btn',
|
||||
size: 'compact',
|
||||
});
|
||||
listen(loadButton, 'click', () => load(scene.id, shot.id));
|
||||
const deleteButton = document.createElement('button');
|
||||
deleteButton.className = 'scene-shot-btn scene-shot-danger';
|
||||
deleteButton.textContent = t('DEL');
|
||||
const deleteButton = createActionButton({
|
||||
label: t('DEL'),
|
||||
className: 'scene-shot-btn scene-shot-danger',
|
||||
size: 'compact',
|
||||
variant: 'danger',
|
||||
});
|
||||
listen(deleteButton, 'click', () => remove(scene.id, shot.id));
|
||||
actions.appendChild(loadButton);
|
||||
actions.appendChild(deleteButton);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
import { createActionButton } from './actionButton.js';
|
||||
|
||||
/** Small owned modal for scene authoring and import review; text never becomes markup. */
|
||||
export function createSceneDialog(title, onClose) {
|
||||
@@ -185,10 +186,7 @@ export function mountSceneSharing(
|
||||
];
|
||||
const removers = [];
|
||||
for (const [text, fn] of entries) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = text;
|
||||
b.className = 'scene-btn';
|
||||
const b = createActionButton({ label: text, className: 'scene-btn' });
|
||||
b.addEventListener('click', fn);
|
||||
removers.push(() => b.removeEventListener('click', fn));
|
||||
bar.append(b);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Ordinary read-only badge view. Labels are already translated. Feature owners
|
||||
* retain status derivation, machine-state attributes and announcement policy.
|
||||
* No listeners, timers, state inference or automatic live regions are installed.
|
||||
* Vue can replace this DOM adapter while retaining the label/hidden contract
|
||||
* and the gev-status-badge CSS surface.
|
||||
* @param {object} options
|
||||
* @param {Document} [options.documentRef]
|
||||
* @param {string} [options.label]
|
||||
* @param {'pill'|'box'|'plain'} [options.appearance]
|
||||
* @param {string} [options.className] Feature/layout hooks.
|
||||
* @param {boolean} [options.hidden]
|
||||
* @returns {HTMLSpanElement}
|
||||
*/
|
||||
export function createStatusBadge({
|
||||
documentRef = globalThis.document,
|
||||
label = '',
|
||||
appearance = 'pill',
|
||||
className = '',
|
||||
hidden = false,
|
||||
} = {}) {
|
||||
const badge = documentRef.createElement('span');
|
||||
badge.className = [
|
||||
'gev-status-badge',
|
||||
appearance === 'box' && 'gev-status-badge--box',
|
||||
appearance === 'plain' && 'gev-status-badge--plain',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
updateStatusBadge(badge, { label, hidden });
|
||||
return badge;
|
||||
}
|
||||
|
||||
/** Paint controlled text/visibility without replacing nodes or redundant writes. */
|
||||
export function updateStatusBadge(badge, { label, hidden = false }) {
|
||||
if (badge.textContent !== label) badge.textContent = label;
|
||||
if (badge.hidden !== hidden) badge.hidden = hidden;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
|
||||
import { createRailCards } from './railCards.js';
|
||||
import { railFixture } from './railTestFixture.mjs';
|
||||
|
||||
test('badges paint literal translated text without installing announcement or interaction semantics', () => {
|
||||
const { document } = railFixture();
|
||||
for (const appearance of ['pill', 'box', 'plain']) {
|
||||
const badge = createStatusBadge({
|
||||
documentRef: document,
|
||||
appearance,
|
||||
className: 'feature-hook',
|
||||
label: '<img src=x onerror=alert(1)> · НЕИЗВЕСТНО',
|
||||
});
|
||||
assert.equal(badge.tagName, 'SPAN');
|
||||
assert.equal(
|
||||
badge.textContent,
|
||||
'<img src=x onerror=alert(1)> · НЕИЗВЕСТНО',
|
||||
);
|
||||
assert.equal(badge.children.length, 0);
|
||||
assert.equal(badge.classList.contains('gev-status-badge'), true);
|
||||
assert.equal(badge.classList.contains('feature-hook'), true);
|
||||
assert.equal(badge.getAttribute('role'), null);
|
||||
assert.equal(badge.getAttribute('aria-live'), null);
|
||||
assert.equal(badge.getAttribute('tabindex'), null);
|
||||
assert.equal(
|
||||
badge.classList.contains(`gev-status-badge--${appearance}`),
|
||||
appearance !== 'pill',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('painting preserves feature state, live regions and focus with zero identical writes', () => {
|
||||
let writes = 0;
|
||||
const { document } = railFixture(() => writes++);
|
||||
const badge = createStatusBadge({ documentRef: document, hidden: true });
|
||||
badge.setAttribute('role', 'status');
|
||||
badge.setAttribute('aria-live', 'polite');
|
||||
badge.dataset.frameState = 'ready';
|
||||
const focusOwner = document.createElement('button');
|
||||
focusOwner.focus();
|
||||
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
|
||||
assert.equal(badge.hidden, false);
|
||||
writes = 0;
|
||||
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
|
||||
assert.equal(writes, 0);
|
||||
assert.equal(badge.getAttribute('role'), 'status');
|
||||
assert.equal(badge.getAttribute('aria-live'), 'polite');
|
||||
assert.equal(badge.dataset.frameState, 'ready');
|
||||
assert.equal(document.activeElement, focusOwner);
|
||||
});
|
||||
|
||||
test('coverage badges keep keyed identity, hide empty coverage and never claim feed health', () => {
|
||||
const f = railFixture();
|
||||
const view = createRailCards({ ...f, badgeClassName: 'weather-coverage' });
|
||||
const card = { id: 'radar', title: 'Rain radar', badge: 'CONUS', blocks: [] };
|
||||
view.update([card]);
|
||||
const badge = f.find((node) => node.classList.contains('gev-status-badge'));
|
||||
const header = f.container.children[0].children[0];
|
||||
header.focus();
|
||||
assert.equal(badge.classList.contains('gev-status-badge--plain'), true);
|
||||
assert.equal(badge.classList.contains('weather-coverage'), true);
|
||||
view.update([{ ...card, badge: '' }]);
|
||||
assert.equal(badge.hidden, true);
|
||||
view.update([{ ...card, badge: 'Global · 1° grid' }]);
|
||||
assert.equal(badge.hidden, false);
|
||||
assert.equal(badge.textContent, 'Global · 1° grid');
|
||||
assert.equal(
|
||||
f.find((node) => node.classList.contains('gev-status-badge')),
|
||||
badge,
|
||||
);
|
||||
assert.deepEqual({ ...badge.dataset }, {});
|
||||
assert.equal(f.document.activeElement, header);
|
||||
view.destroy();
|
||||
view.update([card]);
|
||||
assert.equal(f.container.children.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/* Ordinary actions share one surface; feature classes own placement and width.
|
||||
* Legacy scene-btn hooks keep untouched toggles/Street Level controls stable. */
|
||||
.gev-action-button,
|
||||
.scene-btn {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 7px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 1.2px;
|
||||
padding: 6px 9px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
opacity var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gev-action-button:hover:not(:disabled),
|
||||
.scene-btn:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: rgba(0, 212, 255, 0.14);
|
||||
}
|
||||
|
||||
.gev-action-button:disabled,
|
||||
.scene-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.gev-action-button--danger:hover:not(:disabled),
|
||||
.scene-btn-danger:hover:not(:disabled) {
|
||||
color: #ff7272;
|
||||
border-color: #ff7272;
|
||||
background: rgba(255, 88, 88, 0.15);
|
||||
}
|
||||
|
||||
/* Shot-row actions retain their smaller footprint and quieter hover treatment. */
|
||||
.gev-action-button--compact {
|
||||
border-radius: 6px;
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.8px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.gev-action-button--compact:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--glass-border-hover);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.gev-action-button--compact.gev-action-button--danger:hover:not(:disabled) {
|
||||
color: #ff7e7e;
|
||||
border-color: #ff7e7e;
|
||||
}
|
||||
+22
-72
@@ -109,6 +109,10 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cctv-cal-input {
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
.cctv-cal-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
@@ -129,26 +133,6 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
}
|
||||
|
||||
.cctv-cal-input {
|
||||
width: 58px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.7px;
|
||||
color: var(--text-primary);
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border: 1px solid rgba(0, 212, 255, 0.5);
|
||||
border-radius: 4px;
|
||||
padding: 1px 3px;
|
||||
outline: none;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.cctv-cal-input::-webkit-outer-spin-button,
|
||||
.cctv-cal-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cctv-calibration-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -162,26 +146,9 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
||||
#cctv-camera-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 7px;
|
||||
color: var(--text-primary);
|
||||
padding: 6px 8px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#cctv-camera-select:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
#cctv-camera-select:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
#cctv-enable-btn.active,
|
||||
@@ -194,41 +161,32 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
||||
}
|
||||
|
||||
#cctv-quality-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 90px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 1px;
|
||||
color: #8fe7ff;
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
text-transform: uppercase;
|
||||
--status-badge-border: rgba(0, 212, 255, 0.2);
|
||||
--status-badge-color: #8fe7ff;
|
||||
--status-badge-background: rgba(0, 212, 255, 0.08);
|
||||
}
|
||||
|
||||
/* CAL badge states (cctv-v2 §3b, amended by LOCKED §9.2 — panel-only, no
|
||||
in-world tint). Three visually distinct states: calibrated / curated /
|
||||
raw-prior. */
|
||||
#cctv-quality-chip[data-cal-badge='calibrated'] {
|
||||
color: #adffbf;
|
||||
border-color: rgba(141, 255, 135, 0.42);
|
||||
background: rgba(141, 255, 135, 0.12);
|
||||
--status-badge-color: #adffbf;
|
||||
--status-badge-border: rgba(141, 255, 135, 0.42);
|
||||
--status-badge-background: rgba(141, 255, 135, 0.12);
|
||||
}
|
||||
|
||||
#cctv-quality-chip[data-cal-badge='curated'] {
|
||||
color: #ffe6a2;
|
||||
border-color: rgba(255, 217, 122, 0.35);
|
||||
background: rgba(255, 217, 122, 0.1);
|
||||
--status-badge-color: #ffe6a2;
|
||||
--status-badge-border: rgba(255, 217, 122, 0.35);
|
||||
--status-badge-background: rgba(255, 217, 122, 0.1);
|
||||
}
|
||||
|
||||
/* Unsaved live edits (save-gated persistence, viewshed design §3e). */
|
||||
#cctv-quality-chip[data-cal-badge='edited'] {
|
||||
color: #ffd08a;
|
||||
border-color: rgba(255, 176, 82, 0.5);
|
||||
background: rgba(255, 176, 82, 0.14);
|
||||
--status-badge-color: #ffd08a;
|
||||
--status-badge-border: rgba(255, 176, 82, 0.5);
|
||||
--status-badge-background: rgba(255, 176, 82, 0.14);
|
||||
}
|
||||
|
||||
.cctv-frame-wrap {
|
||||
@@ -346,26 +304,18 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
||||
|
||||
#cctv-source-badge {
|
||||
align-self: flex-start;
|
||||
border: 1px solid rgba(0, 212, 255, 0.32);
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 212, 255, 0.09);
|
||||
color: #9defff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.3px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
#cctv-source-badge[data-frame-state='loading'] {
|
||||
color: #ffe59b;
|
||||
border-color: rgba(255, 205, 92, 0.45);
|
||||
background: rgba(255, 205, 92, 0.1);
|
||||
--status-badge-color: #ffe59b;
|
||||
--status-badge-border: rgba(255, 205, 92, 0.45);
|
||||
--status-badge-background: rgba(255, 205, 92, 0.1);
|
||||
}
|
||||
|
||||
#cctv-source-badge[data-frame-state='error'] {
|
||||
color: #ffb0b0;
|
||||
border-color: rgba(255, 102, 102, 0.5);
|
||||
background: rgba(255, 72, 72, 0.11);
|
||||
--status-badge-color: #ffb0b0;
|
||||
--status-badge-border: rgba(255, 102, 102, 0.5);
|
||||
--status-badge-background: rgba(255, 72, 72, 0.11);
|
||||
}
|
||||
|
||||
.cctv-summary-label {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user