mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge remote-tracking branch 'origin/main' into fix/1273
# Conflicts: # docs/CURRENT-STATE.md # src/tooling/liveProviders.test.mjs
This commit is contained in:
commit
98af799cb4
325 files changed
+20500
-1424
No files matched your search
@@ -31,10 +31,10 @@ export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
|
||||
*/
|
||||
export const MAX_VERTICES = 512;
|
||||
/** Two clicks closer than this are one vertex: a double-click to finish must not add a stray point. */
|
||||
export const MIN_VERTEX_SEPARATION_M = 0.5;
|
||||
const MIN_VERTEX_SEPARATION_M = 0.5;
|
||||
/** A line shorter than this, or an area thinner than this, is a mis-click rather than a shape. */
|
||||
export const MIN_PATH_LENGTH_M = 1;
|
||||
export const MIN_AREA_M2 = 1;
|
||||
const MIN_PATH_LENGTH_M = 1;
|
||||
const MIN_AREA_M2 = 1;
|
||||
|
||||
/** @param {string} shape @returns {'area'|'line'|'pin'} */
|
||||
export function normalizeShape(shape) {
|
||||
@@ -50,7 +50,7 @@ export function createDrawSession(shape = 'area') {
|
||||
}
|
||||
|
||||
/** Great-circle distance in metres between two {lon, lat} points. */
|
||||
export function greatCircleM(a, b) {
|
||||
function greatCircleM(a, b) {
|
||||
return greatCircleMeters(a, b);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
|
||||
import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js';
|
||||
/** The id this tool claims the pointer under. */
|
||||
export const DRAW_POINTER_OWNER = 'draw';
|
||||
const DRAW_POINTER_OWNER = 'draw';
|
||||
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
|
||||
|
||||
const COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
|
||||
|
||||
@@ -29,7 +29,7 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
|
||||
/** Ms the analyst region lookup waits on geocode + admin boundary. */
|
||||
export const REGION_FALLBACK_BUDGET_MS = 3_000;
|
||||
const REGION_FALLBACK_BUDGET_MS = 3_000;
|
||||
|
||||
/** Entity facts that rule out a state/county reading of the ask. */
|
||||
const NON_ADMIN_ENTITY_KINDS = new Set([
|
||||
@@ -103,18 +103,6 @@ export function createAnnotationResolver({
|
||||
cacheWrite(cache, key, null);
|
||||
}
|
||||
|
||||
/** Wire an external AbortSignal to a local controller; returns a detach fn. */
|
||||
function linkAbort(controller, externalSignal) {
|
||||
if (!externalSignal) return () => {};
|
||||
if (externalSignal.aborted) {
|
||||
controller.abort();
|
||||
return () => {};
|
||||
}
|
||||
const onAbort = () => controller.abort();
|
||||
externalSignal.addEventListener('abort', onAbort, { once: true });
|
||||
return () => externalSignal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single annotation target to a normalized world anchor.
|
||||
*
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
+3
-3
@@ -15,9 +15,9 @@ import * as Cesium from 'cesium';
|
||||
import { announceNavigationAuthority } from '../navigationPolicy.js';
|
||||
import { annotationsFromParams, createView } from '../view/index.js';
|
||||
|
||||
export const EMBED_VIEW_MESSAGE = 'gev:view';
|
||||
export const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
|
||||
export const EMBED_READY_MESSAGE = 'gev:ready';
|
||||
const EMBED_VIEW_MESSAGE = 'gev:view';
|
||||
const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
|
||||
const EMBED_READY_MESSAGE = 'gev:ready';
|
||||
|
||||
const FOLLOW_LAYERS = {
|
||||
aircraft: 'flights',
|
||||
|
||||
@@ -53,7 +53,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
||||
});
|
||||
|
||||
/** An omitted provider is unavailable, never an authoritative empty dataset. */
|
||||
export class SourceUnavailableError extends Error {
|
||||
class SourceUnavailableError extends Error {
|
||||
constructor(name) {
|
||||
super(`Data source not configured: ${name}`);
|
||||
this.name = 'SourceUnavailableError';
|
||||
|
||||
+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
|
||||
|
||||
@@ -1,51 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* Camera presets for notable locations.
|
||||
* Phase 1 default: fly to Austin, TX on load.
|
||||
*/
|
||||
export const CAMERA_PRESETS = {
|
||||
austin: {
|
||||
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 800),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(0),
|
||||
pitch: Cesium.Math.toRadians(-35),
|
||||
roll: 0.0,
|
||||
},
|
||||
},
|
||||
sf: {
|
||||
destination: Cesium.Cartesian3.fromDegrees(-122.4194, 37.7749, 1000),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(30),
|
||||
pitch: Cesium.Math.toRadians(-30),
|
||||
roll: 0.0,
|
||||
},
|
||||
},
|
||||
nyc: {
|
||||
destination: Cesium.Cartesian3.fromDegrees(-73.9857, 40.7484, 1200),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(-20),
|
||||
pitch: Cesium.Math.toRadians(-30),
|
||||
roll: 0.0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Fly the camera to a preset location with a smooth animation.
|
||||
*/
|
||||
export function flyToPreset(viewer, presetName, duration = 3.0) {
|
||||
const preset = CAMERA_PRESETS[presetName];
|
||||
if (!preset) return;
|
||||
|
||||
viewer.camera.flyTo({
|
||||
destination: preset.destination,
|
||||
orientation: preset.orientation,
|
||||
duration,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set camera to Austin on load with a cinematic fly-in.
|
||||
* @returns {Function} Cancels the pending or active startup flight.
|
||||
|
||||
@@ -20,8 +20,8 @@ import { ownCameraArrival } from './data/cameraArrival.js';
|
||||
/** Minimum eye height above the rendered surface for a usable view. */
|
||||
export const MIN_EYE_CLEARANCE_M = 120;
|
||||
/** Retries while tiles stream in; sampling fails until the mesh exists. */
|
||||
export const GUARD_ATTEMPTS = 8;
|
||||
export const GUARD_INTERVAL_MS = 600;
|
||||
const GUARD_ATTEMPTS = 8;
|
||||
const GUARD_INTERVAL_MS = 600;
|
||||
/** Ignore sub-metre noise rather than nudging the camera forever. */
|
||||
const GUARD_EPSILON_M = 2;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { LayerBindings } from './ui/layerBindings.js';
|
||||
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
||||
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
||||
import { StyleManager } from './ui/applicationShell.js';
|
||||
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
|
||||
import { CockpitViewController } from './ui/cockpitController.js';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -384,7 +383,7 @@ test('world-focus listener lifecycle is symmetric and idempotent', () => {
|
||||
});
|
||||
|
||||
test('vessel and fire layers announce valid clicks and never fly cameras', () => {
|
||||
for (const [label, source] of [['vessels', vessels], ['fires', firms]]) {
|
||||
for (const source of [vessels, firms]) {
|
||||
assert.match(source, /requestWorldFocus\(\{/);
|
||||
assert.doesNotMatch(source, /camera\.flyTo/);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function runCctvLayerEnableFocus({
|
||||
* @param {Object} [after={}] Ownership immediately after the await.
|
||||
* @returns {{ trackedEntity: *, cockpitActive: boolean }} Conservative ownership.
|
||||
*/
|
||||
export function mergeCctvEnableOwnership(before = {}, after = {}) {
|
||||
function mergeCctvEnableOwnership(before = {}, after = {}) {
|
||||
return {
|
||||
trackedEntity: before.trackedEntity || after.trackedEntity || null,
|
||||
cockpitActive: !!(before.cockpitActive || after.cockpitActive),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
runCctvLayerEnableFocus,
|
||||
runCctvLayerEnableTransition,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
CCTV_WORLD_CLICK_FOCUS_DURATION_SEC,
|
||||
CCTV_FOCUS_REQUEST_EVENT,
|
||||
|
||||
@@ -174,7 +174,7 @@ export function cockpitWeatherEnabledFromStoredValue(value) {
|
||||
* Weather-backed volumetric cloud pass that exists only while cockpit mode is
|
||||
* active. It owns no Cesium fog/post-process stages and cannot affect map mode.
|
||||
*/
|
||||
export class CockpitCloudEffectsController {
|
||||
class CockpitCloudEffectsController {
|
||||
constructor(viewer, { weatherService = applicationServices.weather } = {}) {
|
||||
this.weatherService = weatherService;
|
||||
this.viewer = viewer;
|
||||
|
||||
@@ -281,7 +281,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc
|
||||
uiSource.indexOf('_syncContactsDetection() {'),
|
||||
uiSource.indexOf('/** Apply a temporary cockpit-only'),
|
||||
);
|
||||
assert.ok(helper.length > 0, 'the Contacts detection helper is present in ui.js');
|
||||
assert.ok(helper.length > 0, 'the Contacts detection helper is present in the UI shell source');
|
||||
assert.match(helper, /if \(this\._contextModeChanging\) return;/, 'fires at settle, not at click');
|
||||
assert.match(helper, /active: this\._contextMode === 'flights'/);
|
||||
assert.match(helper, /styleOwnsDetection:\s*!this\._detectionUserOverridden/);
|
||||
@@ -302,7 +302,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc
|
||||
uiSource.indexOf('_setCockpitVision(mode, active, { revealParameters = false } = {}) {'),
|
||||
uiSource.indexOf('_syncIrBoost() {'),
|
||||
);
|
||||
assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in ui.js');
|
||||
assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in the UI shell source');
|
||||
assert.doesNotMatch(visionHook, /etection/, 'cockpit transitions must not touch detection');
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { setContextMode as contextModeAction } from './ui/contextActions.js';
|
||||
import { ContextControls } from './ui/contextControls.js';
|
||||
import { readFileSync as readRadioSource } from 'node:fs';
|
||||
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
|
||||
// Source-contract pins read the actual Context owners and root disposal wiring. Each pin guards a bug that shipped or nearly shipped:
|
||||
// - session bookkeeping ran AFTER the exit early-return, so the compensating
|
||||
@@ -21,8 +20,6 @@ const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', imp
|
||||
// - the right-rail entry ignored the activation result entirely.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const src = readShellSource();
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||
import { expandApplicationHtml } from '../build/application-html.js';
|
||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import { _selectContextMode } from './ui/contextTransactions.js';
|
||||
@@ -8,13 +7,11 @@ import { clearSelectedLayers } from './ui/contextActions.js';
|
||||
import { readFileSync as readRadioSource } from 'node:fs';
|
||||
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
||||
const ui = readShellSource();
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
|
||||
@@ -32,8 +29,6 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
|
||||
});
|
||||
|
||||
test('Context transition state preserves focus and Tab availability until settle', () => {
|
||||
const syncSource = _syncContextModeButtons.toString();
|
||||
|
||||
const attributes = () => new Map();
|
||||
const makeButton = () => {
|
||||
const attrs = attributes();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
@@ -8,7 +7,6 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const css = readStylesheet(path.join(ROOT, 'style.css'));
|
||||
const ui = readShellSource();
|
||||
|
||||
/*
|
||||
* Required-attribution keep-out pin.
|
||||
@@ -247,6 +245,9 @@ function ownBoxEntries() {
|
||||
const compound = lastCompound(part);
|
||||
if (!ELEMENT_KEYS.some((key) => compound.includes(key))) continue;
|
||||
if (compound.includes('::')) continue; // a pseudo-element is its own box
|
||||
// The phone shell is its own layout; mobileShell.test.mjs pins its
|
||||
// attribution clearance.
|
||||
if (part.startsWith('body.mobile-shell')) continue;
|
||||
entries.push({ rule, part });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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']);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -23,4 +23,3 @@ export {
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
} from '../layers/bikeshare/index.js';
|
||||
export default layer;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 & 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',
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
detectionHorizontalSector,
|
||||
labelBudgetFor,
|
||||
normalizeAllocationStrategy,
|
||||
normalizeProfile,
|
||||
profileForDensity,
|
||||
viewScaleForAltitude,
|
||||
} from './detectionPolicy.js';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,4 +8,3 @@ export function createEarthquakesLayer({
|
||||
} = {}) {
|
||||
return createApplicationEarthquakes({ source, ...options });
|
||||
}
|
||||
export default createEarthquakesLayer();
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
|
||||
export { localGeoJsonServices } from '../app/localGeojsonServices.js';
|
||||
import {
|
||||
createLocalGeoJsonLayer as createLayer,
|
||||
createLocalInfrastructureOverlayPublisher as createPublisher,
|
||||
|
||||
@@ -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];
|
||||
@@ -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 +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
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -28,4 +28,3 @@ export {
|
||||
createCableReferenceSweepGate,
|
||||
updateCableReferenceStem,
|
||||
} from '../layers/submarineCables/index.js';
|
||||
export default createTeleGeographySubmarineCableLayer();
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
'',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Body classes that mean "another surface owns the screen": cockpit, scene
|
||||
* playback, recording and clean view. First run yields to them and the phone
|
||||
* shell closes its sheets for them.
|
||||
*/
|
||||
export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([
|
||||
'cockpit-mode',
|
||||
'scene-playback-mode',
|
||||
'recording-mode',
|
||||
'ui-clean-view',
|
||||
]);
|
||||
@@ -22,6 +22,7 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
||||
// is enthusiasm, not "never show me this again".
|
||||
|
||||
import { t, msg } from './i18n/index.js';
|
||||
import { EXCLUSIVE_SURFACE_CLASSES } from './exclusiveSurfaces.js';
|
||||
|
||||
/** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */
|
||||
export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1';
|
||||
@@ -305,12 +306,7 @@ export async function runFirstRunChoice(
|
||||
* hides the launcher in CSS, and the observer below turns that into a yield —
|
||||
* see the ESC ARBITRATION note on initFirstRunExperience.
|
||||
*/
|
||||
export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([
|
||||
'cockpit-mode',
|
||||
'scene-playback-mode',
|
||||
'recording-mode',
|
||||
'ui-clean-view',
|
||||
]);
|
||||
export { EXCLUSIVE_SURFACE_CLASSES };
|
||||
|
||||
/**
|
||||
* Is some other surface currently claiming the screen?
|
||||
|
||||
@@ -247,6 +247,10 @@ test('no storage is touched from a default parameter position', () => {
|
||||
|
||||
test('the JS and CSS lists of screen-claiming surfaces stay in step', () => {
|
||||
const module = fs.readFileSync(
|
||||
new URL('./exclusiveSurfaces.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const firstRun = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
@@ -279,6 +283,8 @@ test('the JS and CSS lists of screen-claiming surfaces stay in step', () => {
|
||||
module,
|
||||
/export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/,
|
||||
);
|
||||
// First run keeps exporting the one shared list (the phone shell reads it too).
|
||||
assert.match(firstRun, /export \{ EXCLUSIVE_SURFACE_CLASSES \};/);
|
||||
});
|
||||
|
||||
test('exclusiveSurfaceActive reads the live body classes', () => {
|
||||
@@ -373,10 +379,6 @@ test('one ESC does one thing — the radio disclosure stops the launcher outrigh
|
||||
new URL('./ui/radioBindings.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// stopPropagation() does NOT stop later listeners on the SAME document, so the
|
||||
// disclosure's earlier capture handler closed the disclosure and the launcher
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
+3
-2
@@ -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;
|
||||
|
||||
@@ -209,7 +210,7 @@ export function formatRelativeTime(value, unit = 'second', options) {
|
||||
}
|
||||
|
||||
/** Collapse markup whitespace so a message reads the same in HTML and JS. */
|
||||
export function normalizeMarkupText(text) {
|
||||
function normalizeMarkupText(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
+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,16 @@
|
||||
"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})",
|
||||
"Layers": "Capas",
|
||||
"Search": "Buscar",
|
||||
"More": "Más",
|
||||
"Panels": "Paneles"
|
||||
}
|
||||
@@ -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,16 @@
|
||||
"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})",
|
||||
"Layers": "Слои",
|
||||
"Search": "Поиск",
|
||||
"More": "Ещё",
|
||||
"Panels": "Панели"
|
||||
}
|
||||
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');
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
@@ -69,7 +68,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),
|
||||
|
||||
@@ -3,16 +3,12 @@ import * as Cesium from 'cesium';
|
||||
import {
|
||||
LAYER_ID,
|
||||
REQUEST_DEBOUNCE_MS,
|
||||
QUERY_LIMIT,
|
||||
MAX_RENDERED,
|
||||
QUERY_SNAP_DEGREES,
|
||||
QUERY_REUSE_MS,
|
||||
ALPR_COLOR,
|
||||
} from './policy.js';
|
||||
import {
|
||||
snapAlprBox,
|
||||
boxContains,
|
||||
destinationPointDeg,
|
||||
alprRetryDelayMs,
|
||||
validateAlprSnapshot,
|
||||
alprCreditMarkup,
|
||||
@@ -84,9 +80,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
viewportBox,
|
||||
clearRendered,
|
||||
hideOnMapCredit,
|
||||
presentOnMapCredit,
|
||||
renderRecords,
|
||||
selectRecord,
|
||||
focusNearest,
|
||||
clearSelection,
|
||||
updateSelectedAnchor,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js';
|
||||
|
||||
export function textTag(value) {
|
||||
function textTag(value) {
|
||||
const t = String(value ?? '').trim();
|
||||
return t || null;
|
||||
}
|
||||
|
||||
export function numTag(value) {
|
||||
function numTag(value) {
|
||||
// `Number('')` and `Number(' ')` are 0, not NaN — a blank or whitespace-only
|
||||
// tag value must read as missing, never as a due-north bearing.
|
||||
if (value == null) return null;
|
||||
@@ -102,7 +102,7 @@ export function normalizeAlprNode(el) {
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDirection(value) {
|
||||
function normalizeDirection(value) {
|
||||
const degrees = numTag(value);
|
||||
return degrees != null && degrees >= 0 && degrees <= 360
|
||||
? degrees % 360
|
||||
|
||||
@@ -3,7 +3,7 @@ import { VectorTile } from '@mapbox/vector-tile';
|
||||
import { normalizeAlprNode } from './records.js';
|
||||
|
||||
/** Map extract attributes without treating an OSM edit date as a field verification. */
|
||||
export function normalizeAlprTileFeature(feature) {
|
||||
function normalizeAlprTileFeature(feature) {
|
||||
const p = feature?.properties || {};
|
||||
if (feature?.geometry?.type !== 'Point' || p.osmType !== 'node') return null;
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
export const AIRCRAFT_DEPENDENCIES = ['flights', 'military'];
|
||||
|
||||
export const DEFERRED_DEPENDENCIES = [
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
];
|
||||
const DEFERRED_DEPENDENCIES = ['ais-live-vessels', 'military-installations'];
|
||||
|
||||
export const DEPENDENCIES = [
|
||||
...AIRCRAFT_DEPENDENCIES,
|
||||
|
||||
@@ -60,7 +60,7 @@ test('the layer reports failure when a failed city left it with nothing', () =>
|
||||
});
|
||||
|
||||
test('a layer with nothing to draw and nothing failed is not an error', () => {
|
||||
const { state, methods } = makeLayer();
|
||||
const { methods } = makeLayer();
|
||||
|
||||
// Out of range of every city is empty, not broken.
|
||||
assert.equal(methods.getStats().error, undefined);
|
||||
|
||||
@@ -11,7 +11,7 @@ export const BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
// --- Activation / display thresholds ---
|
||||
/** Altitude (m) at which bikeshare layer becomes eligible for display. */
|
||||
|
||||
export const ACTIVATION_ALTITUDE_M = 50000;
|
||||
const ACTIVATION_ALTITUDE_M = 50000;
|
||||
|
||||
/** Hysteresis enter threshold — layer activates when camera drops below this. */
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js';
|
||||
import { CAMERA_SEEDS } from './policy.js';
|
||||
import {
|
||||
lookupModelSpec,
|
||||
horizontalFovRangeDeg,
|
||||
|
||||
@@ -5,9 +5,6 @@ import {
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
PROJECTION_MATTE_COLOR,
|
||||
PROJECTION_TEXTURE_SWAP_MS,
|
||||
ACTIVE_FRAME_REFRESH_MS,
|
||||
FRAME_ENDPOINT,
|
||||
MEDIA_ENDPOINT,
|
||||
PROJECTION_ACTIVE_REFRESH_MS,
|
||||
PROJECTION_IDLE_REFRESH_MS,
|
||||
PLACEHOLDER_REPAINT_MS,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js';
|
||||
import {
|
||||
PROJECTION_VERT_ASPECT,
|
||||
FRUSTUM_GROUND_CLEARANCE_M,
|
||||
PROBE_MIN_RANGE_M,
|
||||
PROBE_CLEARANCE_M,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HEALTH_SYNC_INTERVAL_MS, HEALTH_ENDPOINT } from './policy.js';
|
||||
import { HEALTH_SYNC_INTERVAL_MS } from './policy.js';
|
||||
|
||||
export function createHealth({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
export {
|
||||
FRAME_ENDPOINT,
|
||||
SOURCE_ENDPOINT,
|
||||
HEALTH_ENDPOINT,
|
||||
MEDIA_ENDPOINT,
|
||||
ACTIVE_FRAME_REFRESH_MS,
|
||||
} from './sourcePolicy.js';
|
||||
export { ACTIVE_FRAME_REFRESH_MS } from './sourcePolicy.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js';
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
@@ -90,9 +84,6 @@ export const CCTV_CALIBRATION_STORAGE_KEY_V2 =
|
||||
|
||||
export const PROJECTION_TEXTURE_SWAP_MS = 1000;
|
||||
|
||||
export const PROJECTION_VERT_ASPECT =
|
||||
PROJECTION_CANVAS_WIDTH / PROJECTION_CANVAS_HEIGHT;
|
||||
|
||||
// V2 frustum geometry (design §2a/§6): the far-cap center + corners never sink
|
||||
// below groundAlt + this clearance, so a fabricated pitch (-24°) cannot bury
|
||||
// the monitor plane in the 3D tiles. Exported for the unit suite.
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
export const FRAME_ENDPOINT = '/api/cctv/frame';
|
||||
|
||||
export const SOURCE_ENDPOINT = '/api/cctv/sources';
|
||||
|
||||
export const HEALTH_ENDPOINT = '/api/cctv/health';
|
||||
|
||||
export const MEDIA_ENDPOINT = '/api/cctv/media';
|
||||
|
||||
export const ACTIVE_FRAME_REFRESH_MS = 10000;
|
||||
@@ -64,7 +64,7 @@ const SHARED_CARD = Object.freeze({
|
||||
* @param {boolean} selected
|
||||
* @returns {object}
|
||||
*/
|
||||
export function cycloneStormEntry(storm, selected) {
|
||||
function cycloneStormEntry(storm, selected) {
|
||||
return {
|
||||
...SHARED_CARD,
|
||||
...markerClearance(
|
||||
@@ -92,7 +92,7 @@ export function cycloneStormEntry(storm, selected) {
|
||||
* @param {{tauHours:number,position:object}} point
|
||||
* @returns {object}
|
||||
*/
|
||||
export function cycloneLeadEntry(stormId, point) {
|
||||
function cycloneLeadEntry(stormId, point) {
|
||||
return {
|
||||
...SHARED_CARD,
|
||||
...markerClearance(CYCLONE_MARKER_PX.forecast),
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
export {
|
||||
GROUND_FLOOR_CLAMP_RADIUS_KM,
|
||||
GROUND_FLOOR_WARM_MAX_ALT_M,
|
||||
POSITION_HISTORY_LIMIT,
|
||||
LANDED_MISSING_POLL_LIMIT,
|
||||
MISSING_POLL_LIMIT,
|
||||
ERROR_BACKOFF_INTERVAL,
|
||||
} from './recordPolicy.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
|
||||
|
||||
|
||||
@@ -580,7 +580,6 @@ export function createQueries({
|
||||
const maxRange =
|
||||
Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY;
|
||||
|
||||
const now = Cesium.JulianDate.now();
|
||||
const nearby = [];
|
||||
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
|
||||
Loaded 100 of 325 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user