Merge remote-tracking branch 'origin/main' into fix/1282

# Conflicts:
#	docs/CURRENT-STATE.md
This commit is contained in:
Said Turksever committed 2026-10-23 12:09:26 +01:00
commit be44f523e0
325 files changed
+20500 -1424

No files matched your search

+1 -1
View File
@@ -123,7 +123,7 @@ export function decodeRing(encoded, decimals) {
}
/** Even-odd point-in-ring test; ring is [[lon, lat], …], open or closed. */
export function pointInRing(ring, lat, lon) {
function pointInRing(ring, lat, lon) {
if (!Array.isArray(ring) || ring.length < 3) return false;
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
+1 -1
View File
@@ -215,7 +215,7 @@ export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze(
* @param {Object} body
* @returns {string|null}
*/
export function aisEnvelopeMmsi(envelope, body = {}) {
function aisEnvelopeMmsi(envelope, body = {}) {
const metadata = envelope?.MetaData || envelope?.Metadata || {};
const raw = metadata.MMSI ?? body.UserID ?? body.UserId ?? body.Mmsi;
if (raw === null || raw === undefined) return null;
+1 -21
View File
@@ -46,28 +46,8 @@ export const AIS_WATCHDOG_DEFAULTS = Object.freeze({
authProbeMs: 3_600_000,
});
/**
* Statuses reported to the browser. 'missing-key' and 'unsupported' keep their
* pre-watchdog meaning so a keyless install still reads as "feature off".
*/
export const AIS_WATCHDOG_STATUSES = Object.freeze([
'idle',
'missing-key',
'unsupported',
'connecting',
'live',
'stale',
'reconnecting',
'down',
'auth-failed',
]);
/** Failure classes. Only 'transport' is allowed to walk the fast ladder. */
export const AIS_FAILURE_KINDS = Object.freeze([
'transport',
'auth',
'rate-limit',
]);
const AIS_FAILURE_KINDS = Object.freeze(['transport', 'auth', 'rate-limit']);
/** Statuses that must not flip back to a hopeful 'connecting' on a retry. */
const QUIET_TERMINAL = new Set(['down', 'auth-failed']);
+3 -63
View File
@@ -62,7 +62,7 @@ const COMMON_FIELDS = Object.freeze(['id', 'lat', 'lon']);
/** Derived per-record field for layers with a time field (hours before now). */
const AGE_FIELD = 'ageHours';
/** Spatial scope kinds `query()` accepts. */
export const ANALYST_SCOPE_KINDS = Object.freeze([
const ANALYST_SCOPE_KINDS = Object.freeze([
'view',
'region',
'radius',
@@ -74,7 +74,7 @@ export const ANALYST_SCOPE_KINDS = Object.freeze([
* @param {string[]} layerKeys Queried layer ids.
* @returns {string[]} Field names, common fields first.
*/
export function analystFieldsFor(layerKeys) {
function analystFieldsFor(layerKeys) {
const fields = new Set(COMMON_FIELDS);
for (const key of layerKeys) {
const layer = ANALYST_LAYERS[key];
@@ -104,44 +104,6 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
}
/**
* One filter: {field, op:'gt'|'lt'|'gte'|'lte'|'eq'|'neq'|'contains', value}.
* With `time: true`, both sides compare as epoch ms (ISO strings accepted).
*/
export function applyFilter(records, filter, { time = false } = {}) {
const { field, op } = filter || {};
if (!field || !op) return records;
const value = time ? toEpochMs(filter.value) : filter.value;
return records.filter((r) => {
const got = time ? toEpochMs(r[field]) : r[field];
if (got === null || got === undefined || (time && !Number.isFinite(got)))
return false;
if (time && (op === 'eq' || op === 'neq'))
return (got === value) === (op === 'eq');
switch (op) {
case 'gt':
return Number(got) > Number(value);
case 'gte':
return Number(got) >= Number(value);
case 'lt':
return Number(got) < Number(value);
case 'lte':
return Number(got) <= Number(value);
case 'eq': {
if (typeof got === 'boolean' || typeof value === 'boolean')
return Boolean(got) === Boolean(value);
return String(got).toLowerCase() === String(value).toLowerCase();
}
case 'neq':
return String(got).toLowerCase() !== String(value).toLowerCase();
case 'contains':
return String(got).toLowerCase().includes(String(value).toLowerCase());
default:
return false;
}
});
}
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere', …}. */
export function applyScope(records, scope, resolved) {
if (!scope || scope.kind === 'anywhere') return records;
@@ -167,28 +129,6 @@ export function applyScope(records, scope, resolved) {
return records;
}
/** Numeric summary for the narration layer. */
function summarize(items, sortField) {
const summary = { count: items.length };
if (sortField && items.length) {
// A loop, not Math.min(...vals): a full FIRMS day is ~160k values, past
// the engine's argument limit.
let min = Infinity;
let max = -Infinity;
for (const item of items) {
const value = Number(item[sortField]);
if (!Number.isFinite(value)) continue;
if (value < min) min = value;
if (value > max) max = value;
}
if (Number.isFinite(min)) {
summary[`${sortField}Min`] = min;
summary[`${sortField}Max`] = max;
}
}
return summary;
}
const OPS_BY_TYPE = Object.freeze({
number: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'],
time: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'],
@@ -213,7 +153,7 @@ const COMMON_UNITS = Object.freeze({
* @param {string} field Field name.
* @returns {'number'|'text'|'flag'|'time'|null} Field type.
*/
export function analystFieldType(layerKeys, field) {
function analystFieldType(layerKeys, field) {
if (!analystFieldsFor(layerKeys).includes(field)) return null;
if (COMMON_TYPES[field]) return COMMON_TYPES[field];
for (const key of layerKeys) {
+7 -3
View File
@@ -412,14 +412,18 @@ const IMAGERY_LABEL_NAMES = Object.freeze([
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
]);
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021, 2021年10月16日. */
function imageryDateText(observedAt) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
if (!match) return '';
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
if (Number.isNaN(date.getTime())) return '';
const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase();
const year = part({ year: 'numeric' });
// Year-first languages (2021年10月16日) keep their own order and units.
const whole = part({ day: 'numeric', month: 'short', year: 'numeric' });
if (whole.startsWith(year)) return whole;
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${year}`.toUpperCase();
}
/**
@@ -1029,7 +1033,7 @@ function createPanel(event, handlers) {
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
</div>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> ${escapeMarkup(t('Clouds are preserved from the source imagery.'))}</span></p>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note">${escapeMarkup(t(' Clouds are preserved from the source imagery.'))}</span></p>
`;
const refs = collectPanelReferences(panel);
+1 -5
View File
@@ -14,7 +14,7 @@ import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
import { t, msg, getLocale } from '../i18n/index.js';
export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
'bhote-koshi-locator-callout';
export const NEPAL_CONTEXT_PRESENTATION = 'nepal-context';
@@ -35,7 +35,6 @@ const BORDER_START_DELAY_MS = 700;
const INCIDENT_CALLOUT_GAP_MS = 200;
const CALLOUT_REVEAL_DURATION_MS = 1200;
const CALLOUT_PULSE_DURATION_MS = 1800;
const CALLOUT_DOT_SIZE_PX = 7;
const CALLOUT_RING_MIN_SIZE_PX = 16;
const CALLOUT_RING_SIZE_RANGE_PX = 14;
const CITY_CALLOUT_START_DELAY_MS = 350;
@@ -1248,6 +1247,3 @@ export function createBhoteKoshiLocatorLayer({
},
};
}
const bhoteKoshiLocatorLayer = createBhoteKoshiLocatorLayer();
export default bhoteKoshiLocatorLayer;
-1
View File
@@ -23,4 +23,3 @@ export {
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
} from '../layers/bikeshare/index.js';
export default layer;
+2 -2
View File
@@ -17,7 +17,7 @@
*/
const EARTH_RADIUS_M = 6371000;
export const PLANE_VERT_ASPECT = 16 / 9;
const PLANE_VERT_ASPECT = 16 / 9;
export const SUPPORT_KEYS = Object.freeze([
'bl',
'bm',
@@ -128,7 +128,7 @@ export function planeSupportPoints(pose) {
* @param {number} capAltM @param {-1|0|1} row @param {{upVert:number}} dims
* @returns {number}
*/
export function supportAltitude(capAltM, row, dims) {
function supportAltitude(capAltM, row, dims) {
return capAltM + row * dims.upVert;
}
+1 -1
View File
@@ -316,7 +316,7 @@ export function createCalibrationGizmo({
}
const mount = positions.mount;
const axes = enuAxes(mount);
const { forwardHoriz, right, view } = viewAxesFor(
const { forwardHoriz } = viewAxesFor(
camera.headingDeg,
camera.pitchDeg,
axes,
+3 -3
View File
@@ -94,7 +94,7 @@ export function incumbentRankKm(
export const CCTV_CARD_CENTER_WEIGHT = 0.5;
/** Robust pool-distance percentile used to scale the screen-space term. */
export const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
/** True only when both viewport dimensions are finite positive pixels. */
export function hasFiniteCctvViewport(viewW, viewH) {
@@ -172,8 +172,8 @@ export function blendCenterRankKm(
// viewport is bucketed into this grid and every occupied cell gets its best
// candidate before global rank fills the rest. Owner-tunable together with
// the budgets above.
export const CCTV_CARD_GRID_COLS = 5;
export const CCTV_CARD_GRID_ROWS = 4;
const CCTV_CARD_GRID_COLS = 5;
const CCTV_CARD_GRID_ROWS = 4;
/**
* Screen-space distribution pass (owner round 2, item C). Buckets the
+263 -3
View File
@@ -33,6 +33,14 @@ const VIDEO = {
feedType: 'video',
url: 'https://cams.upstream.invalid/live.mp4',
};
const WEBM = {
id: 'webm-fixture',
name: 'WebM fixture',
lat: 30.27,
lon: -97.74,
feedType: 'webm',
url: 'https://cams.upstream.invalid/live.webm',
};
const MJPEG = {
id: 'mjpeg-fixture',
name: 'MJPEG fixture',
@@ -69,7 +77,10 @@ function recordingResponse() {
body: '',
writeHead(status, headers) {
res.statusCode = status;
res.headers = headers || {};
res.headers = { ...res.headers, ...headers };
},
setHeader(name, value) {
res.headers[name] = value;
},
write(chunk) {
chunks.push(Buffer.from(chunk));
@@ -105,6 +116,7 @@ function mount(t, respond) {
STILL,
STILL_ALIAS,
VIDEO,
WEBM,
MJPEG,
]);
process.env.CCTV_SOURCES_FILE = 'absent-source-file.json';
@@ -143,9 +155,9 @@ function mount(t, respond) {
});
assert.ok(handler, 'the CCTV provider must mount a middleware');
const call = async (path) => {
const call = async (path, headers = {}) => {
const { res, done } = recordingResponse();
await handler({ url: path, headers: {}, method: 'GET', on() {} }, res);
await handler({ url: path, headers, method: 'GET', on() {} }, res);
await done;
return res;
};
@@ -268,3 +280,251 @@ test('/frame does not follow a still camera off its origin', async (t) => {
[STILL.url],
);
});
/** An upstream Response whose body records whether it was cancelled. */
function trackedUpstream({ status = 200, headers = {}, body = 'payload' }) {
const state = { cancelled: false };
const response = new Response(
new ReadableStream(
{
pull(controller) {
controller.enqueue(new TextEncoder().encode(body));
controller.close();
},
cancel() {
state.cancelled = true;
},
},
{ highWaterMark: 0 },
),
{ status, headers },
);
return { response, state };
}
test('/media refuses an upstream type that does not match the feed kind', async (t) => {
let current;
const app = mount(t, () => current.response);
for (const [camera, contentType, body] of [
[VIDEO, 'text/html', '<!doctype html><p>x</p>'],
[VIDEO, 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
[VIDEO, 'application/vnd.apple.mpegurl', '#EXTM3U'],
[VIDEO, 'video/webm', 'video'],
[VIDEO, undefined, 'video'],
[WEBM, 'video/mp4', 'video'],
[MJPEG, 'multipart/x-mixed-replace', '--frame'],
[MJPEG, 'multipart/x-mixed-replace; boundary=', '--frame'],
[MJPEG, 'image/jpeg', 'jpeg'],
[MJPEG, 'text/html; boundary=frame', '<!doctype html><p>x</p>'],
]) {
current = trackedUpstream({
headers: contentType === undefined ? {} : { 'content-type': contentType },
body,
});
const label = `${camera.id}: ${contentType}`;
const res = await app.call(`/media/${camera.id}`);
assert.equal(res.statusCode, 502, label);
assert.equal(res.headers['Content-Type'], 'application/json', label);
assert.deepEqual(JSON.parse(res.body), { error: 'Media proxy failed' });
assert.equal(current.state.cancelled, true, `${label}: upstream cancelled`);
const health = await app.health(camera.id);
assert.equal(health.status, 'degraded', label);
assert.equal(health.message, 'Unexpected media type', label);
}
});
test('/media relays each feed kind under its canonical type, including a partial response', async (t) => {
let current;
const app = mount(t, () => current);
for (const [camera, upstream, expected] of [
[VIDEO, { 'content-type': 'Video/MP4; codecs="avc1.42E01E"' }, 'video/mp4'],
[
VIDEO,
{
'content-type': 'video/mp4',
'content-range': 'bytes 0-4/100',
'content-length': '5',
'accept-ranges': 'bytes',
},
'video/mp4',
],
[WEBM, { 'content-type': 'video/webm' }, 'video/webm'],
[
MJPEG,
{ 'content-type': 'multipart/x-mixed-replace;boundary="frame"' },
'multipart/x-mixed-replace; boundary=frame',
],
[
MJPEG,
{ 'content-type': 'multipart/x-mixed-replace; boundary="a b"' },
'multipart/x-mixed-replace; boundary="a b"',
],
]) {
const status = upstream['content-range'] ? 206 : 200;
current = upstreamResponse({ status, headers: upstream, body: 'bytes' });
const res = await app.call(`/media/${camera.id}`);
assert.equal(res.statusCode, status, camera.id);
assert.equal(res.headers['Content-Type'], expected);
assert.equal(res.body, 'bytes');
if (status === 206)
assert.equal(res.headers['Content-Range'], 'bytes 0-4/100');
assert.equal((await app.health(camera.id)).status, 'ok');
}
});
test('/media does not follow a live camera off its origin', async (t) => {
const app = mount(t, ({ url }) =>
url === VIDEO.url
? upstreamResponse({
status: 302,
headers: { location: 'https://elsewhere.invalid/live.mp4' },
body: '',
})
: upstreamResponse({
headers: { 'content-type': 'video/mp4' },
body: 'elsewhere',
}),
);
const res = await app.call(`/media/${VIDEO.id}`);
assert.equal(res.statusCode, 502);
assert.doesNotMatch(res.body, /elsewhere/);
assert.deepEqual(app.requests, [{ url: VIDEO.url, redirect: 'manual' }]);
});
test('every CCTV route refuses cross-site requests and admits the app itself', async (t) => {
const app = mount(t, ({ url }) =>
upstreamResponse({
headers: {
'content-type': url === VIDEO.url ? 'video/mp4' : 'image/jpeg',
},
body: 'bytes',
}),
);
const host = 'localhost:5173';
const paths = [
'/sources',
'/health',
`/stream/${STILL.id}`,
`/frame/${STILL.id}`,
`/media/${VIDEO.id}`,
];
for (const headers of [
{ host, 'sec-fetch-site': 'cross-site' },
{ host, 'sec-fetch-site': 'same-site' },
{ host, origin: 'https://elsewhere.example' },
{ host, origin: 'null' },
])
for (const path of paths) {
const res = await app.call(path, headers);
assert.equal(res.statusCode, 403, `${path} ${JSON.stringify(headers)}`);
}
assert.deepEqual(app.requests, [], 'a refused request reached an upstream');
for (const headers of [
{ host, 'sec-fetch-site': 'same-origin' },
{ host, origin: `http://${host}` },
{},
]) {
const frame = await app.call(`/frame/${STILL.id}`, headers);
assert.equal(frame.statusCode, 200);
assert.equal(frame.headers['X-CCTV-Source'], 'upstream-image');
const media = await app.call(`/media/${VIDEO.id}`, headers);
assert.equal(media.statusCode, 200);
assert.equal(media.headers['Content-Type'], 'video/mp4');
}
});
test('/frame falls back past a non-raster upstream and Street View frame', async (t) => {
let streetViewType;
const app = mount(t, ({ url }) =>
url.startsWith('https://maps.googleapis.com/')
? trackedUpstream({
headers:
streetViewType === undefined
? {}
: { 'content-type': streetViewType },
body: 'street-view',
}).response
: upstreamResponse({
headers: { 'content-type': 'image/svg+xml' },
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
}),
);
process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key';
for (const type of [
'image/svg+xml',
'text/html',
'application/xml',
undefined,
]) {
streetViewType = type;
const res = await app.call(`/frame/${STILL.id}`);
assert.equal(res.statusCode, 200);
assert.equal(res.headers['X-CCTV-Source'], 'synthetic', String(type));
assert.doesNotMatch(res.body, /street-view/);
}
streetViewType = 'image/JPEG; charset=x';
const res = await app.call(`/frame/${STILL.id}`);
assert.equal(res.headers['X-CCTV-Source'], 'streetview');
assert.equal(res.headers['Content-Type'], 'image/jpeg');
assert.equal(res.body, 'street-view');
});
test('a forwarded same-origin request reaches every CCTV route but not the Street View fallback', async (t) => {
const app = mount(t, ({ url }) => {
if (url.startsWith('https://maps.googleapis.com/'))
return trackedUpstream({
headers: { 'content-type': 'image/jpeg' },
body: 'street-view',
}).response;
return url === VIDEO.url
? upstreamResponse({ headers: { 'content-type': 'video/mp4' } })
: upstreamResponse({ status: 503, body: '' });
});
process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key';
const host = 'localhost:5173';
const streetViewCalls = () =>
app.requests.filter(({ url }) =>
url.startsWith('https://maps.googleapis.com/'),
).length;
for (const forwarded of [
{ 'x-forwarded-for': '203.0.113.9' },
{ via: '1.1 proxy' },
{ forwarded: 'for=203.0.113.9' },
{ 'x-real-ip': '203.0.113.9' },
{ 'x-forwarded-for': '' },
{ 'x-forwarded-for': ' ' },
{ 'cf-connecting-ip': '' },
]) {
const headers = { host, 'sec-fetch-site': 'same-origin', ...forwarded };
const label = JSON.stringify(forwarded);
assert.equal((await app.call('/sources', headers)).statusCode, 200, label);
const media = await app.call(`/media/${VIDEO.id}`, headers);
assert.equal(media.statusCode, 200, label);
assert.equal(media.headers['Content-Type'], 'video/mp4', label);
const frame = await app.call(`/frame/${STILL.id}`, headers);
assert.equal(frame.statusCode, 200, label);
assert.equal(frame.headers['X-CCTV-Source'], 'synthetic', label);
assert.equal(frame.headers['Content-Type'], 'image/svg+xml', label);
// Cross-site stays refused whatever the forwarding headers say.
for (const path of ['/sources', `/frame/${STILL.id}`, `/media/${VIDEO.id}`])
assert.equal(
(
await app.call(path, {
...forwarded,
host,
'sec-fetch-site': 'cross-site',
})
).statusCode,
403,
`${label} ${path}`,
);
}
assert.equal(streetViewCalls(), 0, 'a forwarded request spent the key');
const direct = await app.call(`/frame/${STILL.id}`, {
host,
'sec-fetch-site': 'same-origin',
});
assert.equal(direct.headers['X-CCTV-Source'], 'streetview');
assert.equal(direct.body, 'street-view');
assert.equal(streetViewCalls(), 1);
});
+54
View File
@@ -259,3 +259,57 @@ test('rejected snapshot responses abort the upstream download', async () => {
assert.equal(signal.aborted, true);
}
});
test('CCTV upstream frame fetch accepts only raster image types and sends their canonical type', async () => {
const frame = (contentType, body = 'frame') => {
const state = { cancelled: false };
const headers = contentType === null ? {} : { 'Content-Type': contentType };
const response = new Response(new ReadableStream({
pull(controller) {
controller.enqueue(new TextEncoder().encode(body));
controller.close();
},
cancel() {
state.cancelled = true;
},
}, { highWaterMark: 0 }), { status: 200, headers });
return { response, state };
};
for (const [contentType, canonical] of [
['image/jpeg', 'image/jpeg'],
['image/JPEG; charset=x', 'image/jpeg'],
[' Image/PNG ', 'image/png'],
['image/gif', 'image/gif'],
['image/webp;q=1', 'image/webp'],
['image/avif', 'image/avif'],
]) {
const { response } = frame(contentType);
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
fetchImpl: async () => response,
});
assert.equal(result?.contentType, canonical, contentType);
assert.equal(result.body.toString(), 'frame');
}
for (const [contentType, body] of [
['image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
['Image/SVG+XML; charset=utf-8', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
['text/html', '<!doctype html><p>x</p>'],
['text/html; charset=utf-8', '<!doctype html><p>x</p>'],
['application/xml', '<x/>'],
['text/xml', '<x/>'],
[null, 'frame'],
['', 'frame'],
['image', 'frame'],
['image/', 'frame'],
['image/jpeg/x', 'frame'],
['image/jpeg, text/html', 'frame'],
['application/octet-stream', 'frame'],
]) {
const { response, state } = frame(contentType, body);
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
fetchImpl: async () => response,
});
assert.equal(result, null, String(contentType));
assert.equal(state.cancelled, true, `${contentType}: body cancelled`);
}
});
+114
View File
@@ -0,0 +1,114 @@
// Camera catalogs are pinned to their agency's image directory. The pin is a
// parsed-URL check, not a string prefix, so a path that only starts with the
// directory before dot segments, an encoded separator, credentials or another
// port are resolved never counts as inside it, and a redirect on a pinned host
// cannot leave the directory either.
//
// Run with: npm test (node --test)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadTflSourcesFromOpenData } from '../../server/providers/cctv/sources.js';
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
import { pinnedCameraUrl } from '../../server/providers/cctv/normalize.js';
const BUCKET = 'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
/** One available JamCam whose frame is at `imageUrl`. */
const jamCam = (n, imageUrl) => ({
id: `JamCams_00001.0000${n}`,
commonName: `JamCam ${n}`,
lat: 51.5072,
lon: -0.1276,
additionalProperties: [
{ key: 'available', value: 'true' },
{ key: 'imageUrl', value: imageUrl },
],
});
test('TfL JamCams keep only frames inside the official bucket, by parsed URL', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const accepted = [
[`${BUCKET}00001.00001.jpg`, `${BUCKET}00001.00001.jpg`],
[`${BUCKET}00001.00002.jpg?t=1`, `${BUCKET}00001.00002.jpg?t=1`],
[
'https://s3-eu-west-1.amazonaws.com:443/jamcams.tfl.gov.uk/00001.00003.jpg',
`${BUCKET}00001.00003.jpg`,
],
];
const rejected = [
`${BUCKET}../other/x.jpg`,
`${BUCKET}%2e%2e/other/x.jpg`,
`${BUCKET}%2E%2E/other/x.jpg`,
`${BUCKET}.%2e/other/x.jpg`,
`${BUCKET}..%2fother/x.jpg`,
`${BUCKET}..%5Cother/x.jpg`,
`${BUCKET}..\\other\\x.jpg`,
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk.other/x.jpg',
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk-other/x.jpg',
'https://s3-eu-west-1.amazonaws.com/other/jamcams.tfl.gov.uk/x.jpg',
'https://user:[email protected]/jamcams.tfl.gov.uk/x.jpg',
'https://s3-eu-west-1.amazonaws.com:8443/jamcams.tfl.gov.uk/x.jpg',
'http://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/x.jpg',
'https://s3-eu-west-1.amazonaws.com.other.example/jamcams.tfl.gov.uk/x.jpg',
'not a url',
];
t.mock.method(globalThis, 'fetch', async () =>
Response.json(
[...accepted.map(([url]) => url), ...rejected].map((url, i) =>
jamCam(i + 1, url),
),
),
);
const cameras = await loadTflSourcesFromOpenData();
assert.deepEqual(
cameras.map((camera) => camera.url),
accepted.map(([, normalized]) => normalized),
);
});
test('a redirect on a pinned camera host stays inside the pinned directory', async () => {
const redirectTo = (location) => async (url) =>
url === `${BUCKET}00001.00001.jpg`
? new Response(null, { status: 302, headers: { location } })
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
for (const location of [
'/jamcams.tfl.gov.uk.other/x.jpg',
'/other/x.jpg',
'/jamcams.tfl.gov.uk/../other/x.jpg',
'/jamcams.tfl.gov.uk/%2e%2e/other/x.jpg',
'/jamcams.tfl.gov.uk/..%2fother/x.jpg',
])
assert.equal(
await fetchWithinHost(
`${BUCKET}00001.00001.jpg`,
{},
redirectTo(location),
),
null,
location,
);
const followed = await fetchWithinHost(
`${BUCKET}00001.00001.jpg`,
{},
redirectTo('/jamcams.tfl.gov.uk/00001.00002.jpg'),
);
assert.equal(await followed.text(), 'frame');
// A host with no pinned directory keeps the same-origin rule alone.
const unpinned = await fetchWithinHost(
'https://cams.example/a/frame.jpg',
{},
async (url) =>
url === 'https://cams.example/a/frame.jpg'
? new Response(null, { status: 302, headers: { location: '/b/x.jpg' } })
: new Response('frame'),
);
assert.equal(await unpinned.text(), 'frame');
});
test('a pin prefix without a trailing slash still names a directory', () => {
assert.equal(
pinnedCameraUrl('https://h.example/camsX/a.jpg', 'https://h.example/cams'),
null,
);
});
@@ -2,7 +2,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { dispatchCockpitModeChanged, enter, exit, _adoptTrackedEntity } from '../ui/cockpitTrackingController.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import * as Cesium from 'cesium';
import flightsLayer, {
_setCockpitDetectionSubjectForTest as setFlightsCockpitSubject,
-1
View File
@@ -1,7 +1,6 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
/**
* Cockpit 3D aircraft policy.
+1 -1
View File
@@ -181,7 +181,7 @@ export function trimTrack(track, nowMs, keep = track.capacity) {
for (const id of track.epochs.keys())
if (!used.has(id)) track.epochs.delete(id);
}
export function validFix(fix) {
function validFix(fix) {
return (
Number.isFinite(fix?.t) &&
fix.t >= 0 &&
+9 -2
View File
@@ -25,11 +25,11 @@ import * as Cesium from 'cesium';
* follows DATA_SOURCES.md (live sources, then bundled snapshots).
* @type {{ key: string, html: string }[]}
*/
export const OSM_CREDIT = {
const OSM_CREDIT = {
key: 'openstreetmap',
html: 'Map and place data <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL</a>)',
};
export const OPENMAPTILES_CREDIT = {
const OPENMAPTILES_CREDIT = {
key: 'openfreemap',
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
};
@@ -429,6 +429,13 @@ export const DATA_CREDITS = [
'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.',
},
// ── Bundled snapshots ───────────────────────────────────────────
{
key: 'sky-background',
html:
'Stars: <a href="https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50" target="_blank" rel="noopener">Yale Bright Star Catalogue</a> ' +
'(Hoffleit &amp; Warren, via CDS). Milky Way: <a href="https://www.eso.org/public/images/eso0932a/" target="_blank" rel="noopener">ESO/S. Brunier</a> ' +
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>; stars removed, blurred and reprojected)',
},
{
key: 'dams',
html: 'Dams: Open Infrastructure Map',
-1
View File
@@ -43,7 +43,6 @@ import {
detectionHorizontalSector,
labelBudgetFor,
normalizeAllocationStrategy,
normalizeProfile,
profileForDensity,
viewScaleForAltitude,
} from './detectionPolicy.js';
+1 -1
View File
@@ -18,7 +18,7 @@ export function stableIdentityHash(layerId, sourceId) {
}
/** Negative means a is a better deterministic contender than b. */
export function compareCohortContenders(a, b) {
function compareCohortContenders(a, b) {
const priorityDelta =
(Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
if (priorityDelta) return priorityDelta;
+1 -2
View File
@@ -5,7 +5,6 @@
* module so density cannot drift into contradictory mode semantics again.
*/
export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]);
export const ALLOCATION_ELASTIC = 'ELASTIC';
export const ALLOCATION_WEIGHTED = 'WEIGHTED';
export const ALLOCATION_STRATEGIES = Object.freeze([
@@ -118,7 +117,7 @@ export function detectionHorizontalSector(screenX, viewportWidth) {
return 'front';
}
export const VIEW_SCALE_BUDGETS = Object.freeze({
const VIEW_SCALE_BUDGETS = Object.freeze({
street: Object.freeze({ 0: 9, 25: 23, 50: 45, 75: 68, 100: 90 }),
city: Object.freeze({ 0: 7, 25: 18, 50: 35, 75: 53, 100: 70 }),
metro: Object.freeze({ 0: 6, 25: 14, 50: 28, 75: 41, 100: 55 }),
+1 -5
View File
@@ -24,7 +24,7 @@ import * as input from './inputOwnership.js';
import { createDirectionsLayer } from '../layers/directions/index.js';
/** The shared scene owners every Directions instance runs on. */
export const directionsServices = Object.freeze({
const directionsServices = Object.freeze({
credits,
render,
sprites,
@@ -49,15 +49,11 @@ export {
DIRECTIONS_MODES,
DIRECTIONS_POINTER_OWNER,
DIRECTIONS_ROUTE_COLOR,
DIRECTIONS_STEP_OVERLAY_SOURCE_ID,
DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS,
FLIGHT_PROGRESS_MS,
POINTER_TOOL_EXITS,
STEP_ANCHOR_DEADLINE_MS,
STEP_ANCHOR_FAST_ATTEMPTS,
STEP_ANCHOR_RETRY_MS,
STEP_ANCHOR_SLOW_RETRY_MS,
createDirectionsLayer,
createDirectionsStepOverlayEntry,
directionsRequestUrl,
directionsRowControls,
-1
View File
@@ -8,4 +8,3 @@ export function createEarthquakesLayer({
} = {}) {
return createApplicationEarthquakes({ source, ...options });
}
export default createEarthquakesLayer();
+58
View File
@@ -304,3 +304,61 @@ test('GBFS rejects redirects and caps bodies through both server hooks', async (
globalThis.fetch = originalFetch;
}
});
test('GBFS relays only JSON, re-serialized under a fixed type, through both server hooks', async () => {
const { gbfsProxy } = await import('../../server/providers/gbfs.js');
const originalFetch = globalThis.fetch;
try {
for (const hook of ['configureServer', 'configurePreviewServer']) {
let handler;
gbfsProxy()[hook]({
middlewares: {
use(_path, callback) {
handler = callback;
},
},
});
const request = async (response) => {
globalThis.fetch = async () => response;
const res = {};
await handler(
{ method: 'GET', url: '/' + encodeURIComponent(STATION_URL) },
{
writeHead(code, headers) {
Object.assign(res, { status: code, headers });
},
end(value) {
res.body = value;
},
},
);
return res;
};
for (const [contentType, body] of [
['text/html', '<!doctype html><p>x</p>'],
['application/json', '<!doctype html><p>x</p>'],
['image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
['application/json', ''],
]) {
const res = await request(
new Response(body, { headers: { 'Content-Type': contentType } }),
);
assert.equal(res.status, 502, `${hook}: ${contentType} ${body}`);
assert.equal(res.headers['Content-Type'], 'application/json');
assert.equal(typeof JSON.parse(res.body).error, 'string');
assert.doesNotMatch(res.body, /doctype|svg/);
}
const res = await request(
new Response(' { "data" : { "stations" : [] } } ', {
headers: { 'Content-Type': 'text/html; charset=iso-8859-1' },
}),
);
assert.equal(res.status, 200);
assert.equal(res.headers['Content-Type'], 'application/json; charset=utf-8');
assert.equal(res.headers['Cache-Control'], 'no-store');
assert.equal(res.body, STATION_BODY);
}
} finally {
globalThis.fetch = originalFetch;
}
});
+4 -4
View File
@@ -5,9 +5,9 @@ import { createImportedPins } from './geoImportPins.js';
import { msg } from '../i18n/marker.js';
/** Largest file accepted, in bytes. */
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
/** Largest total a KMZ may inflate to — a zip bomb stops here. */
export const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024;
const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024;
/** Most files on the globe at once. */
export const MAX_IMPORTED_FILES = 12;
/** Most entities one file may create before it is refused. */
@@ -15,9 +15,9 @@ export const MAX_IMPORT_ENTITIES = 20_000;
/** Most vertices (points of every line, ring and marker) in one file. */
export const MAX_IMPORT_POSITIONS = 250_000;
/** Most normalized style definitions and local image links in one KML/KMZ. */
export const MAX_KML_LINKS = 2_000;
const MAX_KML_LINKS = 2_000;
/** Longest plain-text name kept from a file's features. */
export const MAX_IMPORT_NAME_LENGTH = 120;
const MAX_IMPORT_NAME_LENGTH = 120;
/** What the file picker offers. */
export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx';
+1 -1
View File
@@ -1,5 +1,5 @@
// Inspect encoded headers before allowing the browser to decode local images.
export const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
const MAX_SIDE = 8192;
/** Width and height from a supported raster header; no image is decoded. */
+2 -3
View File
@@ -35,14 +35,14 @@
import { PbfReader } from 'pbf';
/** VehiclePosition.VehicleStopStatus enum → label. */
export const VEHICLE_STOP_STATUS = Object.freeze({
const VEHICLE_STOP_STATUS = Object.freeze({
0: 'INCOMING_AT',
1: 'STOPPED_AT',
2: 'IN_TRANSIT_TO',
});
/** VehiclePosition.OccupancyStatus enum → label. */
export const OCCUPANCY_STATUS = Object.freeze({
const OCCUPANCY_STATUS = Object.freeze({
0: 'EMPTY',
1: 'MANY_SEATS_AVAILABLE',
2: 'FEW_SEATS_AVAILABLE',
@@ -64,7 +64,6 @@ export const GTFS_MAX_ENTITIES = 50_000;
export const GTFS_MAX_STRING_CHARS = 256;
/** FeedHeader.Incrementality: only FULL_DATASET carries a complete snapshot. */
export const GTFS_INCREMENTALITY_FULL_DATASET = 0;
export const GTFS_INCREMENTALITY_DIFFERENTIAL = 1;
/**
* Read a length-delimited string, refusing one that is implausibly long.
-20
View File
@@ -157,26 +157,6 @@ function trackingIdOption(key, token, defaultValue = null) {
});
}
function stringOption(key, token, defaultValue) {
return Object.freeze({
key,
token,
defaultValue,
normalize: (value) => {
if (typeof value === 'number' && Number.isFinite(value))
return String(value).trim().toLowerCase();
if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase();
return normalized ? normalized : null;
},
encode: (value) => String(value),
decode: (value) =>
typeof value === 'string' && value.trim()
? value.trim().toLowerCase()
: null,
});
}
function enumOption(
key,
token,
-1
View File
@@ -1,5 +1,4 @@
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
export { localGeoJsonServices } from '../app/localGeojsonServices.js';
import {
createLocalGeoJsonLayer as createLayer,
createLocalInfrastructureOverlayPublisher as createPublisher,
-18
View File
@@ -1,18 +0,0 @@
import { localGeoJsonServices } from './localGeojson.js';
import { createInfrastructureLayers } from './infrastructure.js';
import { createFirmsHeatmapLayer } from './firmsHeatmap.js';
import submarineCablesLayer from './telegeographySubmarineCables.js';
const [datacenters, dams] = createInfrastructureLayers(localGeoJsonServices);
// Live NASA FIRMS fires (VIIRS ×3 NRT via the /api/firms proxy). The id keeps
// the historical `local-` prefix for persistence + voice-tool-enum compat,
// but the data is NOT bundled anymore — it needs FIRMS_MAP_KEY server-side.
const fires = createFirmsHeatmapLayer({
id: 'local-firms',
name: 'FIRMS Active Fires',
icon: '▲',
source: 'NASA FIRMS · LIVE',
});
export default [datacenters, dams, submarineCablesLayer, fires];
+1 -2
View File
@@ -14,5 +14,4 @@ this derived database.
The public-release snapshot removes contact-oriented fields and note values
that contain email or phone identifiers. The runtime layer does not display or
depend on those fields. Both `dams.geojson` and the runtime JSONL form carry the
same privacy transform.
depend on those fields.
-1
View File
@@ -1 +0,0 @@
{"type":"FeatureCollection","features":[{"id":-19953989,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[14.292076717,46.16897504],[14.292076711,46.168976415],[14.292080818,46.169315044],[14.292082791,46.169327497],[14.292088326,46.169339424],[14.292097199,46.169350341],[14.292109051,46.169359805],[14.2921234,46.169367433],[14.292139665,46.169372916],[14.292454909,46.16945313],[14.292472725,46.169456276],[14.292491094,46.169456843],[14.292509244,46.169454808],[14.293490178,46.169271915],[14.293505685,46.169267932],[14.293519874,46.169262048],[14.293532266,46.169254461],[14.293542439,46.16924543],[14.293550049,46.169235261],[14.293554837,46.169224298],[14.293556643,46.169212912],[14.293555403,46.16920149],[14.293552972,46.169191516],[14.293548058,46.169179216],[14.293539623,46.169167869],[14.293528021,46.169157953],[14.293513742,46.169149885],[14.293497386,46.169144005],[14.293479642,46.16914056],[14.29260928,46.169036741],[14.292221224,46.168859348],[14.292205414,46.168853594],[14.292188274,46.16885012],[14.29217048,46.168849063],[14.292152735,46.168850464],[14.292135742,46.168854269],[14.292120171,46.168860327],[14.292106638,46.168868398],[14.292095679,46.168878164],[14.292087726,46.168889239],[14.292083093,46.168901184],[14.292078818,46.168919205],[14.292077639,46.168928437],[14.292076717,46.16897504]]]},"properties":{"name":"HE Podlubnik - Centrala","tags":{"name":"HE Podlubnik - Centrala","type":"site","power":"plant","alt_name":"HE Škofja Loka","operator":"Elektro Gorenjska","waterway":"dam","start_date":"1969","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"330KW"},"osm_id":-19953989,"output":"330KW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-19754263,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[69.715342348,34.641127032],[69.71550043,34.641135916],[69.715570922,34.64110658],[69.717521056,34.641462978],[69.71812514,34.641577224],[69.718373076,34.641638579],[69.718438371,34.641451327],[69.718438036,34.641377064],[69.718220274,34.641368766],[69.718144586,34.64121412],[69.718014247,34.640948246],[69.717587357,34.640673654],[69.71740899,34.64063493],[69.717223833,34.640595284],[69.716953182,34.640548261],[69.716766517,34.640531246],[69.716362928,34.640542142],[69.716212976,34.640950425],[69.715856829,34.640920669],[69.715476123,34.640943636],[69.715342348,34.641127032]]]},"properties":{"name":"نغلو برېښناکوټ","tags":{"name":"نغلو برېښناکوټ","type":"multipolygon","power":"plant","name:de":"Naghlu-Staudamm","name:en":"Naghlu Dam","name:ja":"ナグルーダム","name:no":"Naghludemningen","name:ru":"ГЭС Наглу","name:uk":"ГЕС Наглу","waterway":"dam","wikidata":"Q4755783","wikipedia":"en:Naghlu Dam","start_date":"1967","plant:source":"hydro","plant:output:electricity":"100 MW"},"osm_id":-19754263,"output":"100 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-19685440,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-54.597119846,-25.41383667],[-54.597234845,-25.413703733],[-54.597107524,-25.413586135],[-54.59730056,-25.412625569],[-54.597341379,-25.412122152],[-54.597361664,-25.411634241],[-54.597330064,-25.410920019],[-54.597191679,-25.410193224],[-54.597057568,-25.409737668],[-54.59698146,-25.409498365],[-54.596806446,-25.409085472],[-54.596524311,-25.408510306],[-54.596161124,-25.407928853],[-54.59586826,-25.40753591],[-54.595651001,-25.407280513],[-54.595510101,-25.407146654],[-54.595350007,-25.407013549],[-54.595191002,-25.406910368],[-54.595019173,-25.406815317],[-54.594639221,-25.40665405],[-54.594135721,-25.406544833],[-54.593702209,-25.406530333],[-54.593262159,-25.406579954],[-54.592841471,-25.406631754],[-54.592785815,-25.406430001],[-54.588964338,-25.407187725],[-54.584364349,-25.408041757],[-54.584397877,-25.408225908],[-54.58083188,-25.40887408],[-54.580662817,-25.408794033],[-54.578582764,-25.409343216],[-54.577176783,-25.409921902],[-54.574835215,-25.411356968],[-54.573420266,-25.412660605],[-54.56995393,-25.41756251],[-54.565822741,-25.423818762],[-54.564863516,-25.426166114],[-54.561372454,-25.436599237],[-54.55977612,-25.441166117],[-54.559956834,-25.441255635],[-54.560411552,-25.441039718],[-54.561530369,-25.438307217],[-54.56325352,-25.43362584],[-54.564224731,-25.430147434],[-54.564559924,-25.42936314],[-54.564880364,-25.429084861],[-54.565817042,-25.428049779],[-54.566482565,-25.427226006],[-54.567271218,-25.426135353],[-54.568109324,-25.425411827],[-54.569107525,-25.423653136],[-54.568448121,-25.422704724],[-54.568378384,-25.422578744],[-54.568555074,-25.42190861],[-54.568801502,-25.421307544],[-54.569317157,-25.42054873],[-54.570266659,-25.419124226],[-54.57099622,-25.417694944],[-54.571731145,-25.416653241],[-54.572525079,-25.415601815],[-54.573302919,-25.414637645],[-54.573759146,-25.414053929],[-54.574652741,-25.413023961],[-54.574928338,-25.412760769],[-54.575345505,-25.412317702],[-54.57570995,-25.4Line truncated
+51
View File
@@ -0,0 +1,51 @@
# Sky background
Catalogue stars over a soft Milky Way, drawn by `src/sky/starrySky.js` in
place of Cesium's blocky Tycho-2 sky box.
| File | Content | Size |
| ---- | ------- | ---- |
| `bright_stars.json` | 9,096 stars: J2000 RA/Dec, V magnitude, B−V | 235 KB |
| `milkyway_{px,nx,py,ny,pz,nz}.webp` | Milky Way glow, six 1024² cube faces, no stars | 62 KB |
## Stars — Yale Bright Star Catalogue
**Source:** 5th Revised Edition (Hoffleit & Warren 1991), CDS catalogue
[V/50](https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50), file
`https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz` (sha256
`3dc44b1e90be8fbe5bcc7656032560f51275f985c7e3f783c9028e1838ec7bed`, fetched
2026-10-19).
**License:** public domain. CDS asks for an acknowledgement; the "Data
attribution" popover carries it.
**Build:** `node scripts/build-bright-stars.mjs [catalog.gz]`. Drops the 14
records without a J2000 position; rounds RA/Dec to 0.001° and magnitudes to
0.01.
## Milky Way — ESO/S. Brunier
**Source:** ESO image [eso0932a](https://www.eso.org/public/images/eso0932a/),
`https://cdn.eso.org/images/original/eso0932a.tif` (6000×3000 sRGB TIFF, sha256
`10f209ab83e1fd89e7fa1ed70277ffc6ed19c43549f04ec637c6806d98aff035`, fetched
2026-10-19).
**License:** [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
**Credit: ESO/S. Brunier.**
**Changes made** (CC BY 4.0 asks for them): resampled to 4096×2048; stars
removed; two images of Jupiter painted over; blurred (0.4°, 2° in the faint
glow); reprojected to ICRF cube faces; black level at the median sky; saved as
WebP. Build: `node scripts/build-milky-way.mjs <eso0932a.tif>`.
The panorama's stars sit up to ~2° off; that is invisible in the blurred glow,
and every sharp star comes from the catalogue.
NASA SVS "Deep Star Maps 2020" was not used: it derives from Gaia DR2, which
ESA licenses CC BY-NC 3.0 IGO.
## Frame
Both layers are in ICRF/J2000. The cube faces use the WebGL face layout, +X
to the March equinox and +Z to the north celestial pole, uploaded without
`flipY`.
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

-1
View File
@@ -1,7 +1,6 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import * as Cesium from 'cesium';
import militaryAwarenessLayer, {
_getAwarenessNavigationStateForTest,
-1
View File
@@ -798,7 +798,6 @@ test('zoom-out aborts an active installation request and returns non-loading gui
// by source probes against the shipped file, the same technique the HUD datum
// tests use where a full boot is impractical.
import { installationRetryDelayMs } from './militaryInstallations.js';
import fs from 'node:fs';
const installationsSource = readLayerSource(
new URL('./militaryInstallations.js', import.meta.url), 'utf8');
+1 -1
View File
@@ -7,7 +7,7 @@ import { createRetryableLoader } from './retryableLoad.js';
export const MILITARY_POINT_CAP = 512;
export const MILITARY_LABEL_CAP = 24;
export const MILITARY_NAMES_TIMEOUT_MS = 10_000;
const MILITARY_NAMES_TIMEOUT_MS = 10_000;
/** Share a bounded acquisition; failed assets become retryable after cooldown. */
export function createMilitaryNamesLoader({
-24
View File
@@ -49,34 +49,12 @@ export const buildRadioTunerBand = layer.buildRadioTunerBand;
export const radioTuningStaticShouldPlay = layer.radioTuningStaticShouldPlay;
export const getRadioAcceptedCatalogSnapshot =
layer.getRadioAcceptedCatalogSnapshot;
export const radioStationResolutionMatches =
layer.radioStationResolutionMatches;
export const getRadioUIState = layer.getRadioUIState;
export const subscribeToRadio = layer.subscribeToRadio;
export const subscribeToRadioPlaybackControls =
layer.subscribeToRadioPlaybackControls;
export const getRadioTunerStations = layer.getRadioTunerStations;
export const beginRadioTuning = layer.beginRadioTuning;
export const setRadioTuningStatic = layer.setRadioTuningStatic;
export const radioCameraNavigationAllowed = layer.radioCameraNavigationAllowed;
export const previewRadioTuningStation = layer.previewRadioTuningStation;
export const endRadioTuning = layer.endRadioTuning;
export const cancelRadioTuning = layer.cancelRadioTuning;
export const commitRadioTuningStation = layer.commitRadioTuningStation;
export const playSelectedRadio = layer.playSelectedRadio;
export const confirmRadioPlayback = layer.confirmRadioPlayback;
export const playPreparedRadioForVoice = layer.playPreparedRadioForVoice;
export const stopRadioPlayback = layer.stopRadioPlayback;
export const toggleRadioPlayback = layer.toggleRadioPlayback;
export const pauseRadioPlayback = layer.pauseRadioPlayback;
export const setRadioVolume = layer.setRadioVolume;
export const setRadioParams = layer.setRadioParams;
export const getRadioParams = layer.getRadioParams;
export const setRadioVoiceDucking = layer.setRadioVoiceDucking;
export const selectRadioStation = layer.selectRadioStation;
export const cycleRadioStation = layer.cycleRadioStation;
export const selectRequestedRadioStation = layer.selectRequestedRadioStation;
export const setRadioFilter = layer.setRadioFilter;
export const retainRadioClusterIdentitiesForStations =
layer.retainRadioClusterIdentitiesForStations;
export {
@@ -86,10 +64,8 @@ export {
RADIO_SINGLETON_MID_LIMIT,
RADIO_SINGLETON_NEAR_LIMIT,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
RADIO_GLOBE_RECENTER_MAX_HEIGHT_M,
RADIO_OVERLAY_SOURCE_OPTIONS,
DEFAULT_RADIO_FILTER,
GLOBAL_RADIO_ALTITUDE_M,
} from '../layers/radio/index.js';
export default layer;
+1 -3
View File
@@ -1779,7 +1779,6 @@ test('voice playback confirmation waits for playing and requires a hard duck', a
});
test('voice playback confirmation accepts fallback buffering but times out safely', async () => {
let listener = () => {};
const state = {
audioState: 'loading',
playingStationId: 'fallback',
@@ -1789,9 +1788,8 @@ test('voice playback confirmation accepts fallback buffering but times out safel
const confirmed = await confirmRadioPlayback({
startPlayback: async () => false,
subscribe: (next) => {
listener = next;
next(state);
return () => { listener = () => {}; };
return () => {};
},
getState: () => state,
timeoutMs: 10,
-5
View File
@@ -117,8 +117,3 @@ export function normalizeRadioCountryInput(value) {
name: canonicalCountryName(code),
});
}
/** Return whether a value is a recognized ISO 3166-1 alpha-2 code. */
export function isRadioCountryCode(value) {
return typeof value === 'string' && ISO_ALPHA_2_SET.has(value.toUpperCase());
}
-1
View File
@@ -28,4 +28,3 @@ export {
createCableReferenceSweepGate,
updateCableReferenceStem,
} from '../layers/submarineCables/index.js';
export default createTeleGeographySubmarineCableLayer();
-2
View File
@@ -930,7 +930,6 @@ for (const fixture of LAYERS) {
}
test('both layers gate the tracked-model load and record its failures', async () => {
const { readFile } = await import('node:fs/promises');
for (const name of ['flights.js', 'militaryFlights.js']) {
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
assert.match(source, /!(?:flightState\.)?_trackedModel\s*&&\s*!(?:flightState\.)?_trackedModelLoading\s*&&\s*(?:parts\.\w+\.)?_trackedModelLoadAllowed\(\s*,?\s*\)/,
@@ -946,7 +945,6 @@ test('both layers gate the tracked-model load and record its failures', async ()
});
test('the tracked regime never consults the fleet models3d toggle', async () => {
const { readFile } = await import('node:fs/promises');
for (const name of ['flights.js', 'militaryFlights.js']) {
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
-1
View File
@@ -1,7 +1,6 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
bindTrackingClickGesture,
isTrackingClickGesture,
-8
View File
@@ -1,5 +1,4 @@
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createWindLayer as createLayer } from '../layers/wind/index.js';
import { createWindSource } from '../layers/wind/source.js';
const sourceSlot = createSourceSlot(
@@ -8,10 +7,3 @@ const sourceSlot = createSourceSlot(
'Wind source',
);
export const configureWindSource = sourceSlot.configure;
/** Create a wind layer using the configured application source by default. */
export function createWindLayer(options = {}) {
return createLayer({ ...options, feed: options.feed ?? sourceSlot.source });
}
export default createWindLayer();