mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into codex/fix-seek-layer-state
This commit is contained in:
102 files changed
+2281
-531
No files matched your search
@@ -1,2 +0,0 @@
|
||||
// Compatibility entry; source contracts are owned by sources/.
|
||||
export { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
@@ -1,24 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createSourceSlot } from './sourceSlot.js';
|
||||
|
||||
test('replacing a source rejects its late result and old cleanup cannot clear the new source', async () => {
|
||||
let finish;
|
||||
const slot = createSourceSlot({ read: async () => 'default' }, ['read']);
|
||||
const old = slot.configure({ read: () => new Promise(resolve => { finish = resolve; }) });
|
||||
const pending = slot.source.read();
|
||||
const stop = slot.configure({ read: async () => 'replacement', attribution: { name: 'Replacement' } });
|
||||
old();
|
||||
finish('stale');
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
assert.equal(await slot.source.read(), 'replacement');
|
||||
assert.equal(slot.source.attribution.name, 'Replacement');
|
||||
stop();
|
||||
assert.throws(() => slot.source.read(), /not configured/);
|
||||
});
|
||||
|
||||
test('an invalid replacement leaves the working source intact', async () => {
|
||||
const slot = createSourceSlot({ read: async () => 42 }, ['read']);
|
||||
assert.throws(() => slot.configure({}), TypeError);
|
||||
assert.equal(await slot.source.read(), 42);
|
||||
});
|
||||
@@ -876,3 +876,46 @@ test('route numbers follow the page language and never break from their unit', (
|
||||
const list = directionsStepList({ route });
|
||||
assert.equal(list.items[1].lead, '240\u00a0м');
|
||||
});
|
||||
|
||||
test('ramp and lane wording stays localized after choosing the maneuver form', (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
const cases = [
|
||||
[
|
||||
'ru',
|
||||
'Въезжайте по съезду плавно налево на I-95',
|
||||
'Сверните на съезд резко направо',
|
||||
'Займите левую полосу',
|
||||
],
|
||||
[
|
||||
'es',
|
||||
'Toma la rampa ligeramente a la izquierda hacia I-95',
|
||||
'Toma la salida bruscamente a la derecha',
|
||||
'Toma el carril izquierdo',
|
||||
],
|
||||
[
|
||||
'zh-CN',
|
||||
'向左前方驶入匝道,进入 I-95',
|
||||
'从出口向右后方驶出',
|
||||
'使用左转车道',
|
||||
],
|
||||
];
|
||||
for (const [locale, ramp, exit, lane] of cases) {
|
||||
setLocale(locale);
|
||||
assert.equal(
|
||||
directionsInstruction({
|
||||
type: 'on ramp',
|
||||
modifier: 'slight left',
|
||||
name: 'I-95',
|
||||
}),
|
||||
ramp,
|
||||
);
|
||||
assert.equal(
|
||||
directionsInstruction({ type: 'off ramp', modifier: 'sharp right' }),
|
||||
exit,
|
||||
);
|
||||
assert.equal(
|
||||
directionsInstruction({ type: 'use lane', modifier: 'sharp left' }),
|
||||
lane,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createFlowTileSource } from '../layers/traffic/flowSource.js';
|
||||
export { tilesForBounds } from './tomtomTiles.js';
|
||||
export { decodeFlowTile } from '../layers/traffic/flowDecode.js';
|
||||
const source = createFlowTileSource();
|
||||
export const { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache } =
|
||||
source;
|
||||
@@ -1,162 +0,0 @@
|
||||
// src/data/flowTiles.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
decodeFlowTile,
|
||||
fetchFlowForBounds,
|
||||
tilesForBounds,
|
||||
getFlowSessionStats,
|
||||
resetFlowTileCache,
|
||||
} from './flowTiles.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// Real TomTom flow tile, downtown Austin z12 x935 y1686 (probed live 2026-07-16).
|
||||
const FIXTURE = path.join(__dirname, 'fixtures', 'tomtom-flow-austin-12-935-1686.pbf');
|
||||
const FIXTURE_TILE = { z: 12, x: 935, y: 1686 };
|
||||
|
||||
function loadFixture() {
|
||||
return fs.readFileSync(FIXTURE);
|
||||
}
|
||||
|
||||
// ── decodeFlowTile against the real fixture ─────────────────
|
||||
|
||||
test('fixture decode: more than 50 flow segments', () => {
|
||||
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
|
||||
assert.ok(segments.length > 50, `got ${segments.length}`);
|
||||
});
|
||||
|
||||
test('fixture decode: every trafficLevel is within [0, 1]', () => {
|
||||
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
|
||||
for (const s of segments) {
|
||||
assert.ok(Number.isFinite(s.trafficLevel), `non-finite level: ${s.trafficLevel}`);
|
||||
assert.ok(s.trafficLevel >= 0 && s.trafficLevel <= 1, `level out of range: ${s.trafficLevel}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('fixture decode: all coordinates land in downtown Austin', () => {
|
||||
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
|
||||
for (const s of segments) {
|
||||
assert.ok(Array.isArray(s.coords) && s.coords.length >= 2, 'polyline too short');
|
||||
for (const [lon, lat] of s.coords) {
|
||||
assert.ok(lon >= -98.0 && lon <= -97.5, `lon out of Austin range: ${lon}`);
|
||||
assert.ok(lat >= 30.0 && lat <= 30.5, `lat out of Austin range: ${lat}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('fixture decode: congestion exists (at least one trafficLevel < 1)', () => {
|
||||
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
|
||||
assert.ok(segments.some((s) => s.trafficLevel < 1), 'no congested segment found');
|
||||
});
|
||||
|
||||
test('fixture decode: segment shape is {coords, trafficLevel, roadType, closure}', () => {
|
||||
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
|
||||
for (const s of segments) {
|
||||
assert.equal(typeof s.roadType, 'string');
|
||||
assert.equal(typeof s.closure, 'boolean');
|
||||
}
|
||||
// The fixture carries real closures — closure decoding is exercised, not vacuous.
|
||||
assert.ok(segments.some((s) => s.closure === true), 'expected at least one closure in fixture');
|
||||
});
|
||||
|
||||
test('decode of a non-MVT buffer returns [] (defensive)', () => {
|
||||
assert.deepEqual(decodeFlowTile(Buffer.from('not a protobuf tile'), 12, 935, 1686), []);
|
||||
});
|
||||
|
||||
// ── tilesForBounds (re-exported slippy math) ────────────────
|
||||
|
||||
test('tilesForBounds: 30.2672,-97.7431 @ z12 -> covers x935 y1686', () => {
|
||||
const tiles = tilesForBounds({
|
||||
south: 30.2672 - 0.001, north: 30.2672 + 0.001,
|
||||
west: -97.7431 - 0.001, east: -97.7431 + 0.001,
|
||||
}, 12);
|
||||
assert.ok(
|
||||
tiles.some((t) => t.z === 12 && t.x === 935 && t.y === 1686),
|
||||
`fixture tile missing: ${JSON.stringify(tiles)}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── fetchFlowForBounds (stubbed fetch: cache + abort) ───────
|
||||
|
||||
/** Bounds fully inside the fixture tile. */
|
||||
const FIXTURE_BOUNDS = { south: 30.24, north: 30.26, west: -97.76, east: -97.74 };
|
||||
|
||||
function stubFetch(impl) {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = impl;
|
||||
return () => { globalThis.fetch = original; };
|
||||
}
|
||||
|
||||
test('fetchFlowForBounds: fetches covering tiles via /api/tomtom and decodes', async () => {
|
||||
resetFlowTileCache();
|
||||
const calls = [];
|
||||
const restore = stubFetch(async (url) => {
|
||||
calls.push(String(url));
|
||||
return new Response(loadFixture(), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/x-protobuf' },
|
||||
});
|
||||
});
|
||||
try {
|
||||
const segments = await fetchFlowForBounds(FIXTURE_BOUNDS);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0], /^\/api\/tomtom\/flow\/12\/935\/1686\.pbf$/);
|
||||
assert.ok(segments.length > 50);
|
||||
assert.ok(getFlowSessionStats().tilesFetched >= 1);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchFlowForBounds: decode cache serves repeat calls within TTL (no refetch)', async () => {
|
||||
resetFlowTileCache();
|
||||
let calls = 0;
|
||||
const restore = stubFetch(async () => {
|
||||
calls += 1;
|
||||
return new Response(loadFixture(), { status: 200 });
|
||||
});
|
||||
try {
|
||||
const first = await fetchFlowForBounds(FIXTURE_BOUNDS);
|
||||
const second = await fetchFlowForBounds(FIXTURE_BOUNDS);
|
||||
assert.equal(calls, 1, 'second call must be served from the decode cache');
|
||||
assert.equal(second.length, first.length);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchFlowForBounds: aborted signal rejects (AbortSignal-aware)', async () => {
|
||||
resetFlowTileCache();
|
||||
const restore = stubFetch(async (url, opts) => {
|
||||
// Mimic real fetch abort semantics.
|
||||
if (opts?.signal?.aborted) {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
throw err;
|
||||
}
|
||||
return new Response(loadFixture(), { status: 200 });
|
||||
});
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(
|
||||
fetchFlowForBounds(FIXTURE_BOUNDS, { signal: controller.signal }),
|
||||
(err) => err.name === 'AbortError'
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchFlowForBounds: non-OK tile responses reject when nothing succeeds', async () => {
|
||||
resetFlowTileCache();
|
||||
const restore = stubFetch(async () => new Response(JSON.stringify({ error: 'no_key' }), { status: 503 }));
|
||||
try {
|
||||
await assert.rejects(fetchFlowForBounds(FIXTURE_BOUNDS));
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
// src/data/issPass.js
|
||||
/**
|
||||
* Next-ISS-pass prediction. Kept as a thin wrapper over satellitePass.js so the
|
||||
* original findNextIssPass signature and defaults still work.
|
||||
*/
|
||||
import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js';
|
||||
|
||||
export { lookAnglesAt };
|
||||
|
||||
/**
|
||||
* Predict the next ISS pass over an observer location.
|
||||
* @param {Object} options
|
||||
* @param {Object} options.satrec SGP4 satellite record
|
||||
* @param {number} options.latDeg Observer latitude in degrees [-90, 90]
|
||||
* @param {number} options.lonDeg Observer longitude in degrees [-180, 180]
|
||||
* @param {number} options.fromMs UTC start time in milliseconds
|
||||
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
|
||||
* @param {number} [options.horizonHours=24] Maximum search window in hours
|
||||
* @param {number} [options.coarseStepSec=30] Coarse search step in seconds
|
||||
* @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking and visibility
|
||||
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
|
||||
* @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number, visible: boolean, sunlit: boolean, observerDark: boolean } | null}
|
||||
*/
|
||||
export function findNextIssPass({
|
||||
satrec,
|
||||
latDeg,
|
||||
lonDeg,
|
||||
fromMs,
|
||||
minElevDeg = 10,
|
||||
horizonHours = 24,
|
||||
coarseStepSec = 30,
|
||||
fineStepSec = 5,
|
||||
requireVisible = false,
|
||||
}) {
|
||||
return findNextSatellitePass({
|
||||
satrec,
|
||||
latDeg,
|
||||
lonDeg,
|
||||
fromMs,
|
||||
minElevDeg,
|
||||
horizonHours,
|
||||
coarseStepSec,
|
||||
fineStepSec,
|
||||
requireVisible,
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// src/data/issPass.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { findNextIssPass, lookAnglesAt } from './issPass.js';
|
||||
import { findNextSatellitePass } from './satellitePass.js';
|
||||
|
||||
// Canonical archived ISS TLE (valid checksums; epoch 2008-09-20 ~12:25 UTC).
|
||||
const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
const L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
||||
const AUSTIN = { latDeg: 30.2672, lonDeg: -97.7431 };
|
||||
const FROM_MS = Date.UTC(2008, 8, 20, 12, 30, 0); // just after the TLE epoch
|
||||
|
||||
test('finds a structurally-consistent ISS pass within 24h of the TLE epoch', () => {
|
||||
const satrec = twoline2satrec(L1, L2);
|
||||
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10 });
|
||||
assert.ok(pass, 'expected a pass within 24h at 30°N for a 51.6° inclination orbit');
|
||||
assert.ok(pass.riseMs > FROM_MS);
|
||||
assert.ok(pass.riseMs < pass.maxElevMs && pass.maxElevMs < pass.setMs);
|
||||
assert.ok(pass.maxElevDeg >= 10);
|
||||
assert.ok(pass.riseAzDeg >= 0 && pass.riseAzDeg < 360);
|
||||
|
||||
// Independent re-check: elevation at the reported peak really is the max-ish
|
||||
// and ≥ threshold; one minute before rise it is below threshold.
|
||||
const peak = lookAnglesAt(satrec, pass.maxElevMs, AUSTIN.latDeg, AUSTIN.lonDeg);
|
||||
assert.ok(peak && Math.abs(peak.elevDeg - pass.maxElevDeg) < 1.5);
|
||||
const before = lookAnglesAt(satrec, pass.riseMs - 60_000, AUSTIN.latDeg, AUSTIN.lonDeg);
|
||||
assert.ok(before && before.elevDeg < 10);
|
||||
});
|
||||
|
||||
test('forwards its original 30 s coarse step and fineStepSec to the pass engine', () => {
|
||||
const satrec = twoline2satrec(L1, L2);
|
||||
const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10, fineStepSec: 30 };
|
||||
assert.deepEqual(findNextIssPass(opts), findNextSatellitePass({ ...opts, coarseStepSec: 30 }));
|
||||
assert.notDeepEqual(findNextIssPass(opts), findNextIssPass({ ...opts, fineStepSec: 5 }));
|
||||
});
|
||||
|
||||
test('returns null when no pass clears an absurd threshold', () => {
|
||||
const satrec = twoline2satrec(L1, L2);
|
||||
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 89.9, horizonHours: 2 });
|
||||
assert.equal(pass, null);
|
||||
});
|
||||
@@ -2,7 +2,18 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createLocalGeoJsonLayer, mapAnalystRecord } from './localGeojson.js';
|
||||
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
|
||||
import {
|
||||
createLocalGeoJsonLayer as createLayer,
|
||||
mapAnalystRecord,
|
||||
} from './localGeojsonCore.js';
|
||||
|
||||
/** The catalog's services, with the overlay host each test supplies. */
|
||||
const createLocalGeoJsonLayer = (options) =>
|
||||
createLayer(options, {
|
||||
...localGeoJsonServices,
|
||||
overlayHost: options.overlayHost,
|
||||
});
|
||||
|
||||
const DC_RAW = {
|
||||
id: '1176042553',
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
|
||||
import {
|
||||
createLocalGeoJsonLayer as createLayer,
|
||||
createLocalInfrastructureOverlayPublisher as createPublisher,
|
||||
} from './localGeojsonCore.js';
|
||||
|
||||
export * from './localGeojsonCore.js';
|
||||
|
||||
/** Create a layer using the standalone app's operations; retain overlay overrides. */
|
||||
export function createLocalGeoJsonLayer(options) {
|
||||
return createLayer(options, {
|
||||
...localGeoJsonServices,
|
||||
overlayHost:
|
||||
options.overlayHost === undefined
|
||||
? localGeoJsonServices.overlayHost
|
||||
: options.overlayHost,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create an overlay publisher using the standalone app's host by default. */
|
||||
export function createLocalInfrastructureOverlayPublisher(options) {
|
||||
return createPublisher({
|
||||
...options,
|
||||
host:
|
||||
options.host === undefined
|
||||
? localGeoJsonServices.overlayHost
|
||||
: options.host,
|
||||
});
|
||||
}
|
||||
@@ -6,13 +6,14 @@ import {
|
||||
GROUND_SAMPLE_MAX_ARMED_RETRIES,
|
||||
LOCAL_OVERLAY_COHORT_LIMIT,
|
||||
LOCAL_STEM_TIP_EPSILON_M,
|
||||
createLocalGeoJsonLayer,
|
||||
createLocalGeoJsonLayer as createLayer,
|
||||
createLocalInfrastructureOverlayEntry,
|
||||
createLocalInfrastructureOverlayPublisher,
|
||||
localDatasetError,
|
||||
localInfrastructureOverlayCopy,
|
||||
selectLocalInfrastructureOverlayCohort,
|
||||
} from './localGeojson.js';
|
||||
} from './localGeojsonCore.js';
|
||||
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
|
||||
import { layerFeedState } from './manager.js';
|
||||
import { INFRA_LOD_ACTIVE_MIN, INFRA_LOD_ACTIVE_MAX } from './localGeojsonLod.js';
|
||||
import {
|
||||
@@ -21,6 +22,13 @@ import {
|
||||
_resetRenderGovernorForTest,
|
||||
} from '../renderGovernor.js';
|
||||
|
||||
/** The catalog's services, with the overlay host each test supplies. */
|
||||
const createLocalGeoJsonLayer = (options) =>
|
||||
createLayer(options, {
|
||||
...localGeoJsonServices,
|
||||
overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost,
|
||||
});
|
||||
|
||||
class MockLayerEvent {
|
||||
constructor() {
|
||||
this.listeners = new Set();
|
||||
@@ -1337,14 +1345,3 @@ test('globe-LOD re-selects during continuous motion, without ever seeing a moveE
|
||||
assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects');
|
||||
});
|
||||
|
||||
|
||||
test('standalone publisher retains its default host for an undefined override', () => {
|
||||
const publisher = createLocalInfrastructureOverlayPublisher({
|
||||
sourceId: 'local-default-host-test', host: undefined,
|
||||
});
|
||||
assert.doesNotThrow(() => {
|
||||
publisher.show();
|
||||
publisher.hide();
|
||||
publisher.destroy();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createLocalGeoJsonLayer } from './localGeojson.js';
|
||||
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
|
||||
import { createLocalGeoJsonLayer as createLayer } from './localGeojsonCore.js';
|
||||
import { getContextStore } from './contextStore.js';
|
||||
|
||||
/** The catalog's services, with the overlay host each test supplies. */
|
||||
const createLocalGeoJsonLayer = (options) =>
|
||||
createLayer(options, {
|
||||
...localGeoJsonServices,
|
||||
overlayHost: options.overlayHost,
|
||||
});
|
||||
|
||||
const dataset = JSON.stringify({
|
||||
type: 'Feature', id: 'dam', properties: { name: 'Test dam' },
|
||||
geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] },
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Pure selection policy for the bundled local-infrastructure layers
|
||||
* (`local-datacenters`, `local-dams`, and any future `createLocalGeoJsonLayer`
|
||||
* dataset). Cesium-specific projection, occlusion, and stem geometry stay in
|
||||
* `localGeojson.js`; this module turns the resulting in-view records into a
|
||||
* `localGeojsonCore.js`; this module turns the resulting in-view records into a
|
||||
* bounded "active" set — the only records that layer should spend per-frame
|
||||
* stem-geometry and ground-sample work on.
|
||||
*
|
||||
@@ -80,7 +80,7 @@ export const INFRA_LOD_INCUMBENT_BONUS = 250;
|
||||
|
||||
/**
|
||||
* Distance at or beyond which the proximity penalty is fully applied (m).
|
||||
* Matches LOCAL_OVERLAY_MAX_DISTANCE_M's order of magnitude in localGeojson.js.
|
||||
* Matches LOCAL_OVERLAY_MAX_DISTANCE_M's order of magnitude in infrastructureOverlayEntry.js.
|
||||
*/
|
||||
export const INFRA_LOD_FAR_M = 12_000_000;
|
||||
|
||||
@@ -97,7 +97,7 @@ export const INFRA_LOD_MAX_DISTANCE_PENALTY = 200;
|
||||
* (callers only compare it), but every input is normalized so the output is
|
||||
* always a finite number.
|
||||
*
|
||||
* @param {number} priority Existing label-priority score (localGeojson.js).
|
||||
* @param {number} priority Existing label-priority score (localGeojsonCore.js).
|
||||
* @param {number} distanceM Camera→feature distance in metres.
|
||||
* @param {boolean} isIncumbent Record currently holds an active stem.
|
||||
* @param {object} [options]
|
||||
@@ -297,7 +297,7 @@ export function applyInfraEvictionGrace({
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Probe window for the motion fallback. `localGeojson.js` marks its selection
|
||||
* Probe window for the motion fallback. `localGeojsonCore.js` marks its selection
|
||||
* dirty on `moveEnd`, which covers drags, one-shot `setView`, and voice
|
||||
* `flyTo` — but a tracked-entity follow camera, a Cockpit view, a route
|
||||
* flight, or a continuous orbit never emits one. Without a fallback the
|
||||
|
||||
+25
-8
@@ -54,6 +54,21 @@ function direction(modifier) {
|
||||
return DIRECTION_WORDS[key] || key;
|
||||
}
|
||||
|
||||
/**
|
||||
* "on the left" only fits a bare side. "slight left" is the adverb
|
||||
* "slightly left", and "on the slightly left" is not an instruction.
|
||||
* @param {string} prefix "Take the ramp" or "Take the exit".
|
||||
* @param {string} modifier OSRM maneuver modifier.
|
||||
* @param {string} onto " onto …", or "".
|
||||
*/
|
||||
function directedWay(prefix, modifier, onto) {
|
||||
const dir = direction(modifier);
|
||||
if (dir === 'left' || dir === 'right')
|
||||
return `${prefix} on the ${dir}${onto}`;
|
||||
if (dir && dir !== 'straight') return `${prefix} ${dir}${onto}`;
|
||||
return `${prefix}${onto}`;
|
||||
}
|
||||
|
||||
function ordinal(n) {
|
||||
if (!Number.isInteger(n) || n < 1) return null;
|
||||
const suffix =
|
||||
@@ -103,13 +118,9 @@ export function instructionFor(step) {
|
||||
case 'merge':
|
||||
return dir ? `Merge ${dir}${onto}` : `Merge${onto}`;
|
||||
case 'on ramp':
|
||||
return dir
|
||||
? `Take the ramp on the ${dir}${onto}`
|
||||
: `Take the ramp${onto}`;
|
||||
return directedWay('Take the ramp', modifier, onto);
|
||||
case 'off ramp':
|
||||
return dir
|
||||
? `Take the exit on the ${dir}${onto}`
|
||||
: `Take the exit${onto}`;
|
||||
return directedWay('Take the exit', modifier, onto);
|
||||
case 'roundabout':
|
||||
case 'rotary': {
|
||||
const exit = ordinal(step?.exit);
|
||||
@@ -124,8 +135,14 @@ export function instructionFor(step) {
|
||||
case 'exit roundabout':
|
||||
case 'exit rotary':
|
||||
return `Exit the roundabout${onto}`;
|
||||
case 'use lane':
|
||||
return dir ? `Use the ${dir} lane${onto}` : `Continue${onto}`;
|
||||
case 'use lane': {
|
||||
const lane = modifier.includes('left')
|
||||
? 'left'
|
||||
: modifier.includes('right')
|
||||
? 'right'
|
||||
: '';
|
||||
return lane ? `Use the ${lane} lane${onto}` : `Continue${onto}`;
|
||||
}
|
||||
case 'notification':
|
||||
default:
|
||||
return `Continue${onto}`;
|
||||
|
||||
@@ -77,6 +77,26 @@ test('instructions read like a navigator, one sentence per OSRM maneuver', () =>
|
||||
instructionFor({ type: 'on ramp', modifier: 'left' }),
|
||||
'Take the ramp on the left',
|
||||
);
|
||||
assert.equal(
|
||||
instructionFor({ type: 'on ramp', modifier: 'slight left', name: 'I-95' }),
|
||||
'Take the ramp slightly left onto I-95',
|
||||
);
|
||||
assert.equal(
|
||||
instructionFor({ type: 'off ramp', modifier: 'sharp right', ref: 'I-280' }),
|
||||
'Take the exit sharply right onto I-280',
|
||||
);
|
||||
assert.equal(
|
||||
instructionFor({ type: 'on ramp', modifier: 'straight', name: 'US 101' }),
|
||||
'Take the ramp onto US 101',
|
||||
);
|
||||
assert.equal(
|
||||
instructionFor({ type: 'use lane', modifier: 'slight left' }),
|
||||
'Use the left lane',
|
||||
);
|
||||
assert.equal(
|
||||
instructionFor({ type: 'use lane', modifier: 'right', name: 'Main St' }),
|
||||
'Use the right lane onto Main St',
|
||||
);
|
||||
assert.equal(
|
||||
instructionFor({
|
||||
type: 'merge',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file Pure tile math + budget accounting for the TomTom traffic-flow proxy.
|
||||
*
|
||||
* Shared by the `/api/tomtom` vite plugin (server-side: coordinate validation,
|
||||
* daily budget governor) and `src/data/flowTiles.js` (client-side: which tiles
|
||||
* daily budget governor) and `src/layers/traffic/flowSource.js` (client-side: which tiles
|
||||
* cover the current traffic fetch bounds). Zero dependencies, Cesium-free, so
|
||||
* both sides can unit-test against it with node:test.
|
||||
*
|
||||
|
||||
@@ -75,4 +75,5 @@ test('the Docker install path keeps its loopback-only, launcher-owned contract',
|
||||
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');
|
||||
assert.match(compose, /^\s+- \.:\/app:z$/m, 'SELinux hosts can read the bind-mounted checkout');
|
||||
});
|
||||
+27
-26
@@ -282,28 +282,28 @@ export class IntelHUD {
|
||||
<div class="hud-sonar" aria-hidden="true"></div>
|
||||
|
||||
<div class="hud-top-bar">
|
||||
<span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span>
|
||||
<span class="hud-top-bar-center">${this._missionId}</span>
|
||||
<span class="hud-top-bar-right">PAGE 1/1</span>
|
||||
<span class="hud-readout"><span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span></span>
|
||||
<span class="hud-readout"><span class="hud-top-bar-center">${this._missionId}</span></span>
|
||||
<span class="hud-readout"><span class="hud-top-bar-right">PAGE 1/1</span></span>
|
||||
</div>
|
||||
|
||||
<div class="hud-corner hud-top-left">
|
||||
<div class="hud-bracket">┌</div>
|
||||
<div class="hud-content">
|
||||
<div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div>
|
||||
<div class="hud-system">${this._missionId} ${this._sensorId}</div>
|
||||
<div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div>
|
||||
<div class="hud-readout"><div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div></div>
|
||||
<div class="hud-readout"><div class="hud-system">${this._missionId} ${this._sensorId}</div></div>
|
||||
<div class="hud-readout"><div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div></div>
|
||||
<div class="hud-summary-wrap">
|
||||
<div class="hud-summary-label">${t('SUMMARY')}</div>
|
||||
<div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div>
|
||||
<div class="hud-readout"><div class="hud-summary-label">${t('SUMMARY')}</div></div>
|
||||
<div class="hud-readout"><div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hud-corner hud-top-right">
|
||||
<div class="hud-content" style="text-align:right">
|
||||
<div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div>
|
||||
<div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div>
|
||||
<div class="hud-readout"><div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div></div>
|
||||
<div class="hud-readout"><div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div></div>
|
||||
</div>
|
||||
<div class="hud-bracket">┐</div>
|
||||
</div>
|
||||
@@ -311,33 +311,33 @@ export class IntelHUD {
|
||||
<div class="hud-corner hud-bottom-left">
|
||||
<div class="hud-bracket">└</div>
|
||||
<div class="hud-content">
|
||||
<div id="hud-mgrs">MGRS: ---</div>
|
||||
<div id="hud-latlon">--°--'--"N ---°--'--"W</div>
|
||||
<div class="hud-readout"><div id="hud-mgrs">MGRS: ---</div></div>
|
||||
<div class="hud-readout"><div id="hud-latlon">--°--'--"N ---°--'--"W</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hud-corner hud-bottom-right">
|
||||
<div class="hud-content" style="text-align:right">
|
||||
<div id="hud-gsd">GSD: --m NIIRS: --</div>
|
||||
<div id="hud-alt">ALT: --m SUN: --° EL</div>
|
||||
<div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div>
|
||||
<div class="hud-readout"><div id="hud-gsd">GSD: --m NIIRS: --</div></div>
|
||||
<div class="hud-readout"><div id="hud-alt">ALT: --m SUN: --° EL</div></div>
|
||||
<div class="hud-readout"><div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div></div>
|
||||
</div>
|
||||
<div class="hud-bracket">┘</div>
|
||||
</div>
|
||||
|
||||
<div class="hud-edge hud-left-edge">
|
||||
<div id="hud-coll">COLL: --:--:--Z</div>
|
||||
<div id="hud-ona">ONA: --°</div>
|
||||
<div class="hud-readout"><div id="hud-coll">COLL: --:--:--Z</div></div>
|
||||
<div class="hud-readout"><div id="hud-ona">ONA: --°</div></div>
|
||||
</div>
|
||||
|
||||
<div class="hud-edge hud-right-edge">
|
||||
<div>BAND: PAN</div>
|
||||
<div>BITS: 11</div>
|
||||
<div>LVL: 1A</div>
|
||||
<div class="hud-readout"><div>BAND: PAN</div></div>
|
||||
<div class="hud-readout"><div>BITS: 11</div></div>
|
||||
<div class="hud-readout"><div>LVL: 1A</div></div>
|
||||
</div>
|
||||
|
||||
<div class="hud-bottom-bar">
|
||||
<span id="hud-bottom-line">LAT: -- LON: -- MGRS: ---</span>
|
||||
<span class="hud-readout"><span id="hud-bottom-line">LAT: -- LON: -- MGRS: ---</span></span>
|
||||
</div>
|
||||
`;
|
||||
this._el.dataset.variant = this._variant;
|
||||
@@ -574,11 +574,12 @@ export class IntelHUD {
|
||||
* @returns {string} Formatted DMS string, e.g. `"38°53'23.10"N"`.
|
||||
*/
|
||||
_toDMS(decimal, type) {
|
||||
const abs = Math.abs(decimal);
|
||||
const deg = Math.floor(abs);
|
||||
const minFloat = (abs - deg) * 60;
|
||||
const min = Math.floor(minFloat);
|
||||
const sec = ((minFloat - min) * 60).toFixed(2);
|
||||
// Round once, in hundredths of a second, so a value that rounds up to
|
||||
// 60.00" carries into the minutes and degrees instead of printing 60.
|
||||
const hundredths = Math.round(Math.abs(decimal) * 360000);
|
||||
const deg = Math.floor(hundredths / 360000);
|
||||
const min = Math.floor(hundredths / 6000) % 60;
|
||||
const sec = ((hundredths % 6000) / 100).toFixed(2);
|
||||
|
||||
let dir;
|
||||
if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S';
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// The HUD's LAT/LON readout in degrees-minutes-seconds. Seconds are printed to
|
||||
// two decimals, so a value within 0.005" of the next whole minute rounds up.
|
||||
// That carry must reach the minutes (and degrees): `122°20'60.00"W` is not a
|
||||
// coordinate. Ordinary decimal inputs land there through binary floating
|
||||
// point: 122.35 - 122 is 0.34999999999999432, so -122.35 read 20'60.00".
|
||||
//
|
||||
// hud.js imports `mgrs`, a CommonJS package whose named exports Node's ESM
|
||||
// loader cannot see, so this installs the same one-specifier stub as
|
||||
// hudAltitudeDatum.test.mjs before importing it.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
|
||||
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
|
||||
registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true };
|
||||
return next(specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (url === MGRS_STUB_URL) {
|
||||
return {
|
||||
format: 'module',
|
||||
shortCircuit: true,
|
||||
source:
|
||||
'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
|
||||
};
|
||||
}
|
||||
return next(url, context);
|
||||
},
|
||||
});
|
||||
|
||||
const { IntelHUD } = await import('./hud.js');
|
||||
|
||||
const toDMS = (decimal, type) => IntelHUD.prototype._toDMS(decimal, type);
|
||||
|
||||
test('a rounded-up second carries into the minutes', () => {
|
||||
assert.equal(toDMS(-122.35, 'lon'), `122°21'00.00"W`);
|
||||
assert.equal(toDMS(139.7, 'lon'), `139°42'00.00"E`);
|
||||
assert.equal(toDMS(37.0166666, 'lat'), `37°01'00.00"N`);
|
||||
});
|
||||
|
||||
test('a carry through 59 minutes reaches the degrees', () => {
|
||||
assert.equal(toDMS(38.999999, 'lat'), `39°00'00.00"N`);
|
||||
assert.equal(toDMS(-0.999999, 'lat'), `01°00'00.00"S`);
|
||||
assert.equal(toDMS(179.999999, 'lon'), `180°00'00.00"E`);
|
||||
});
|
||||
|
||||
test('ordinary values keep their existing format', () => {
|
||||
assert.equal(toDMS(38.889722, 'lat'), `38°53'23.00"N`);
|
||||
assert.equal(toDMS(-77.035278, 'lon'), `077°02'07.00"W`);
|
||||
assert.equal(toDMS(51.5, 'lat'), `51°30'00.00"N`);
|
||||
assert.equal(toDMS(-33.8568, 'lat'), `33°51'24.48"S`);
|
||||
assert.equal(toDMS(0, 'lon'), `000°00'00.00"E`);
|
||||
assert.equal(toDMS(90, 'lat'), `90°00'00.00"N`);
|
||||
});
|
||||
|
||||
test('the telemetry tick paints the carried value into both readouts', () => {
|
||||
const elements = new Map(
|
||||
[
|
||||
'hud-alt',
|
||||
'hud-summary',
|
||||
'hud-mgrs',
|
||||
'hud-latlon',
|
||||
'hud-bottom-line',
|
||||
'hud-gsd',
|
||||
'hud-coll',
|
||||
'hud-ona',
|
||||
'hud-mode',
|
||||
].map((id) => [id, { textContent: '' }]),
|
||||
);
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = {
|
||||
getElementById: (id) => elements.get(id) ?? null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
const viewer = {
|
||||
camera: {
|
||||
pitch: -Math.PI / 2,
|
||||
positionCartographic: {
|
||||
latitude: (37.75 * Math.PI) / 180,
|
||||
longitude: (-122.35 * Math.PI) / 180,
|
||||
height: 1500,
|
||||
},
|
||||
computeViewRectangle: () => undefined,
|
||||
moveStart: { addEventListener() {}, removeEventListener() {} },
|
||||
moveEnd: { addEventListener() {}, removeEventListener() {} },
|
||||
},
|
||||
};
|
||||
let hud;
|
||||
try {
|
||||
hud = new IntelHUD(viewer);
|
||||
hud._updateCameraData();
|
||||
const latlon = elements.get('hud-latlon').textContent;
|
||||
const bottom = elements.get('hud-bottom-line').textContent;
|
||||
assert.equal(latlon, `37°45'00.00"N 122°21'00.00"W`);
|
||||
assert.match(bottom, /LON: 122°21'00\.00"W/);
|
||||
assert.doesNotMatch(`${latlon} ${bottom}`, /60\.00"/);
|
||||
} finally {
|
||||
hud?.destroy();
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
@@ -1867,11 +1867,15 @@
|
||||
"maneuver|Merge": "Incorpórate",
|
||||
"Merge onto {road}": "Incorpórate a {road}",
|
||||
"Take the ramp on the {direction}": "Toma la rampa {direction}",
|
||||
"Take the ramp {direction}": "Toma la rampa {direction}",
|
||||
"Take the ramp on the {direction} onto {road}": "Toma la rampa {direction} hacia {road}",
|
||||
"Take the ramp {direction} onto {road}": "Toma la rampa {direction} hacia {road}",
|
||||
"Take the ramp": "Toma la rampa",
|
||||
"Take the ramp onto {road}": "Toma la rampa hacia {road}",
|
||||
"Take the exit on the {direction}": "Toma la salida {direction}",
|
||||
"Take the exit {direction}": "Toma la salida {direction}",
|
||||
"Take the exit on the {direction} onto {road}": "Toma la salida {direction} hacia {road}",
|
||||
"Take the exit {direction} onto {road}": "Toma la salida {direction} hacia {road}",
|
||||
"Take the exit": "Toma la salida",
|
||||
"Take the exit onto {road}": "Toma la salida hacia {road}",
|
||||
"At the roundabout, take the {exit} exit": "En la rotonda, toma la {exit} salida",
|
||||
|
||||
@@ -1899,11 +1899,15 @@
|
||||
"maneuver|Merge": "Перестройтесь",
|
||||
"Merge onto {road}": "Въезжайте на {road}",
|
||||
"Take the ramp on the {direction}": "Въезжайте на съезд {direction}",
|
||||
"Take the ramp {direction}": "Въезжайте на съезд {direction}",
|
||||
"Take the ramp on the {direction} onto {road}": "Въезжайте по съезду {direction} на {road}",
|
||||
"Take the ramp {direction} onto {road}": "Въезжайте по съезду {direction} на {road}",
|
||||
"Take the ramp": "Въезжайте на съезд",
|
||||
"Take the ramp onto {road}": "Въезжайте по съезду на {road}",
|
||||
"Take the exit on the {direction}": "Сверните на съезд {direction}",
|
||||
"Take the exit {direction}": "Сверните на съезд {direction}",
|
||||
"Take the exit on the {direction} onto {road}": "Сверните на съезд {direction} на {road}",
|
||||
"Take the exit {direction} onto {road}": "Сверните на съезд {direction} на {road}",
|
||||
"Take the exit": "Сверните на съезд",
|
||||
"Take the exit onto {road}": "Сверните на съезд на {road}",
|
||||
"At the roundabout, take the {exit} exit": "На кольце сверните на {exit} съезд",
|
||||
|
||||
@@ -626,11 +626,15 @@
|
||||
"Merge onto {road}": "并入 {road}",
|
||||
"maneuver|Merge": "并道",
|
||||
"Take the ramp on the {direction} onto {road}": "{direction}驶入匝道,进入 {road}",
|
||||
"Take the ramp {direction} onto {road}": "{direction}驶入匝道,进入 {road}",
|
||||
"Take the ramp on the {direction}": "{direction}驶入匝道",
|
||||
"Take the ramp {direction}": "{direction}驶入匝道",
|
||||
"Take the ramp onto {road}": "驶入匝道,进入 {road}",
|
||||
"Take the ramp": "驶入匝道",
|
||||
"Take the exit on the {direction} onto {road}": "从出口{direction}驶出,进入 {road}",
|
||||
"Take the exit {direction} onto {road}": "从出口{direction}驶出,进入 {road}",
|
||||
"Take the exit on the {direction}": "从出口{direction}驶出",
|
||||
"Take the exit {direction}": "从出口{direction}驶出",
|
||||
"Take the exit onto {road}": "从出口驶出,进入 {road}",
|
||||
"Take the exit": "从出口驶出",
|
||||
"At the roundabout, take the {exit} exit onto {road}": "在环岛从{exit}出口驶出,进入 {road}",
|
||||
|
||||
+20
-5
@@ -206,12 +206,27 @@ export function photonSearchUrl(
|
||||
url.searchParams.set('limit', String(limit));
|
||||
|
||||
const corners = String(bias ?? '').split('|');
|
||||
if (corners.length === 2) {
|
||||
const [swLat, swLng] = corners[0].split(',').map(Number);
|
||||
const [neLat, neLng] = corners[1].split(',').map(Number);
|
||||
if ([swLat, swLng, neLat, neLng].every(Number.isFinite)) {
|
||||
const tokens = corners.flatMap((corner) => corner.split(','));
|
||||
if (
|
||||
corners.length === 2 &&
|
||||
corners.every((corner) => corner.split(',').length === 2) &&
|
||||
tokens.every((token) => token.trim() !== '')
|
||||
) {
|
||||
const [swLat, swLng, neLat, neLng] = tokens.map(Number);
|
||||
if (
|
||||
[swLat, swLng, neLat, neLng].every(Number.isFinite) &&
|
||||
Math.abs(swLat) <= 90 &&
|
||||
Math.abs(neLat) <= 90 &&
|
||||
swLat <= neLat &&
|
||||
Math.abs(swLng) <= 180 &&
|
||||
Math.abs(neLng) <= 180
|
||||
) {
|
||||
const midpoint = (swLng + neLng + (swLng > neLng ? 360 : 0)) / 2;
|
||||
url.searchParams.set('lat', String((swLat + neLat) / 2));
|
||||
url.searchParams.set('lon', String((swLng + neLng) / 2));
|
||||
url.searchParams.set(
|
||||
'lon',
|
||||
String(midpoint > 180 ? midpoint - 360 : midpoint),
|
||||
);
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
|
||||
@@ -452,3 +452,14 @@ function readGeocodingConsumer(file) {
|
||||
? [file, 'annotations/resolver.js'] : [file];
|
||||
return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n');
|
||||
}
|
||||
|
||||
|
||||
test('wrapped view bias stays near the antimeridian and malformed coordinates are omitted', () => {
|
||||
for (const [bias, expected] of [['-20,170|-10,-171', 179.5], ['50,175|60,-165', -175], ['0,180|10,-180', 180], ['0,-180|10,180', 0]]) {
|
||||
const params = new URL(photonSearchUrl('island', { bias })).searchParams;
|
||||
assert.equal(Number(params.get('lon')), expected);
|
||||
}
|
||||
for (const bias of ['-91,0|0,1', '0,181|1,0', '10,0|0,1', ',|,', '0,1,2|3,4', '0,1|Infinity,2']) {
|
||||
assert.equal(new URL(photonSearchUrl('island', { bias })).searchParams.has('lon'), false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Format a compass direction for the awareness overlay.
|
||||
* @param {number} degrees
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatAwarenessDirection(degrees) {
|
||||
if (!Number.isFinite(degrees)) return '—';
|
||||
const normalized = ((degrees % 360) + 360) % 360;
|
||||
return String(Math.round(normalized) % 360).padStart(3, '0');
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { formatAwarenessDirection } from './presentation.js';
|
||||
|
||||
test('awareness bearings that round through north read 000, not 360', () => {
|
||||
assert.equal(formatAwarenessDirection(359.4), '359');
|
||||
assert.equal(formatAwarenessDirection(359.6), '000');
|
||||
assert.equal(formatAwarenessDirection(0), '000');
|
||||
assert.equal(formatAwarenessDirection(-0.4), '000');
|
||||
assert.equal(formatAwarenessDirection(Number.NaN), '—');
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../../data/militaryAwarenessEngine.js';
|
||||
import { bearingBetweenCoordinates } from '../../cockpitMath.js';
|
||||
import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js';
|
||||
import { formatAwarenessDirection } from './presentation.js';
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
@@ -225,12 +226,12 @@ export function createRendering({
|
||||
: null;
|
||||
const bearingText = Number.isFinite(bearing)
|
||||
? t('BRG {value}°', {
|
||||
value: String(Math.round(bearing)).padStart(3, '0'),
|
||||
value: formatAwarenessDirection(bearing),
|
||||
})
|
||||
: t('BRG —');
|
||||
const courseText = Number.isFinite(item.track)
|
||||
? ` · ${t('CRS {value}°', {
|
||||
value: String(Math.round(item.track)).padStart(3, '0'),
|
||||
value: formatAwarenessDirection(item.track),
|
||||
})}`
|
||||
: '';
|
||||
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
|
||||
|
||||
@@ -308,16 +308,24 @@ export function directionsInstruction(step) {
|
||||
? t('Merge onto {road}', values)
|
||||
: t('Merge', { context: 'maneuver' });
|
||||
case 'on ramp':
|
||||
if (direction)
|
||||
if (modifier === 'left' || modifier === 'right')
|
||||
return road
|
||||
? t('Take the ramp on the {direction} onto {road}', values)
|
||||
: t('Take the ramp on the {direction}', values);
|
||||
if (direction && modifier !== 'straight')
|
||||
return road
|
||||
? t('Take the ramp {direction} onto {road}', values)
|
||||
: t('Take the ramp {direction}', values);
|
||||
return road ? t('Take the ramp onto {road}', values) : t('Take the ramp');
|
||||
case 'off ramp':
|
||||
if (direction)
|
||||
if (modifier === 'left' || modifier === 'right')
|
||||
return road
|
||||
? t('Take the exit on the {direction} onto {road}', values)
|
||||
: t('Take the exit on the {direction}', values);
|
||||
if (direction && modifier !== 'straight')
|
||||
return road
|
||||
? t('Take the exit {direction} onto {road}', values)
|
||||
: t('Take the exit {direction}', values);
|
||||
return road ? t('Take the exit onto {road}', values) : t('Take the exit');
|
||||
case 'roundabout':
|
||||
case 'rotary':
|
||||
@@ -346,9 +354,14 @@ export function directionsInstruction(step) {
|
||||
return road
|
||||
? t('Exit the roundabout onto {road}', values)
|
||||
: t('Exit the roundabout');
|
||||
case 'use lane':
|
||||
if (direction) {
|
||||
const lane = { road, direction: laneWord(modifier) };
|
||||
case 'use lane': {
|
||||
const side = modifier.includes('left')
|
||||
? 'left'
|
||||
: modifier.includes('right')
|
||||
? 'right'
|
||||
: '';
|
||||
if (side) {
|
||||
const lane = { road, direction: laneWord(side) };
|
||||
return road
|
||||
? t('Use the {direction} lane onto {road}', lane)
|
||||
: t('Use the {direction} lane', lane);
|
||||
@@ -356,6 +369,7 @@ export function directionsInstruction(step) {
|
||||
return road
|
||||
? t('Continue onto {road}', values)
|
||||
: t('Continue', { context: 'maneuver' });
|
||||
}
|
||||
default:
|
||||
return road
|
||||
? t('Continue onto {road}', values)
|
||||
|
||||
@@ -152,7 +152,7 @@ export function localAdsbCardModel(
|
||||
t('ALT {altitude} FT · GS {speed} KT · TRK {track}', {
|
||||
altitude: grouped(altitude),
|
||||
speed: speed === null ? DASH : speed,
|
||||
track: track === null ? DASH : `${track}°`,
|
||||
track: track === null ? DASH : `${track % 360}°`,
|
||||
}),
|
||||
vertical === null
|
||||
? t('V/S {rate}', { rate: DASH })
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import { localAdsbCardModel } from './card.js';
|
||||
|
||||
test('a track of 359.7° reads TRK 0°, not TRK 360°', () => {
|
||||
setLocale('en');
|
||||
const { details } = localAdsbCardModel(
|
||||
{
|
||||
icao: 'abc123',
|
||||
altitudeFt: 12500,
|
||||
groundSpeedKt: 240,
|
||||
trackDeg: 359.7,
|
||||
lastPositionAt: 1000,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
assert.equal(details[1], 'ALT 12,500 FT · GS 240 KT · TRK 0°');
|
||||
});
|
||||
@@ -19,7 +19,7 @@ import { VectorTile } from '@mapbox/vector-tile';
|
||||
* tree-shakeable; decoding happens client-side so the proxy stays a dumb
|
||||
* binary cache.
|
||||
*
|
||||
* @module data/flowTiles
|
||||
* @module layers/traffic/flowDecode
|
||||
*/
|
||||
|
||||
const FLOW_LAYER_NAME = 'Traffic flow';
|
||||
|
||||
@@ -14,6 +14,8 @@ const GLOBAL_TILE_MAXIMUM_LEVEL = 3;
|
||||
// Six decoded 2048×1024 canvases (8 MiB each) bound the cache below 50 MiB.
|
||||
const MAX_MOSAICS = 6;
|
||||
const MAX_PREFETCH_TILES = 8;
|
||||
// How often a staged frame waiting out its quiet interval rechecks readiness.
|
||||
export const READY_WAKE_MS = 150;
|
||||
|
||||
/** Globe imagery: own at most a displayed and a staging frame. Use native Cesium
|
||||
* tile scheduling, projection and texture disposal; the application clock is never touched. */
|
||||
@@ -126,6 +128,7 @@ function createGlobeRendering({
|
||||
frame.offRender?.();
|
||||
frame.offCamera?.();
|
||||
clearTimeout(frame.timeout);
|
||||
clearTimeout(frame.wake);
|
||||
for (const request of frame.requests) request.cancel?.();
|
||||
frame.requests.clear();
|
||||
frame.retries.clear();
|
||||
@@ -277,6 +280,8 @@ function createGlobeRendering({
|
||||
frame.offCamera?.();
|
||||
frame.offCamera = null;
|
||||
clearTimeout(frame.timeout);
|
||||
clearTimeout(frame.wake);
|
||||
frame.wake = null;
|
||||
frame.offAbort?.();
|
||||
if (ok) {
|
||||
lastError = null;
|
||||
@@ -450,8 +455,20 @@ function createGlobeRendering({
|
||||
if (++settled >= 2) finish(true);
|
||||
else viewer.scene.requestRender();
|
||||
} else settled = 0;
|
||||
if (incoming === frame && frame.pending === 0 && frame.loaded > 0)
|
||||
viewer.scene.requestRender();
|
||||
// Waiting out the quiet interval needs another readiness check
|
||||
// later, not a render every frame: wake on a timer so the render
|
||||
// governor's frame cap still holds while a frame is staged.
|
||||
if (
|
||||
incoming === frame &&
|
||||
frame.pending === 0 &&
|
||||
frame.loaded > 0 &&
|
||||
!frame.wake
|
||||
)
|
||||
frame.wake = setTimeout(() => {
|
||||
frame.wake = null;
|
||||
if (!frame.closed && incoming === frame)
|
||||
viewer.scene.requestRender();
|
||||
}, READY_WAKE_MS);
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
onChange();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWeatherRendering } from './rendering.js';
|
||||
import { createWeatherRendering, READY_WAKE_MS } from './rendering.js';
|
||||
import { createWeatherLayer } from './index.js';
|
||||
import { createWeatherClock } from './clock.js';
|
||||
import * as Cesium from 'cesium';
|
||||
@@ -242,6 +242,50 @@ test('a ready weather frame does not wait for unrelated terrain, but requires su
|
||||
h.rendering.clear();
|
||||
});
|
||||
|
||||
test('a staged frame waiting out its quiet interval wakes on a timer, not every render', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let now = 0;
|
||||
const h = renderingHarness({ now: () => now });
|
||||
h.viewer.scene.globe.tilesLoaded = false;
|
||||
const loaded = h.rendering.setFrame(snapshot, times[0]);
|
||||
const tile = deferred();
|
||||
h.providers[0].response = tile;
|
||||
const request = h.providers[0].requestImage(0, 0, 0, {});
|
||||
tile.resolve({});
|
||||
await request;
|
||||
const before = h.renders();
|
||||
for (let i = 0; i < 10; i++) h.postRender.emit();
|
||||
assert.equal(
|
||||
h.renders(),
|
||||
before,
|
||||
'renders while waiting do not request further renders',
|
||||
);
|
||||
t.mock.timers.tick(READY_WAKE_MS - 1);
|
||||
assert.equal(h.renders(), before);
|
||||
t.mock.timers.tick(1);
|
||||
assert.equal(h.renders(), before + 1, 'one wake per interval');
|
||||
now = 200;
|
||||
h.postRender.emit();
|
||||
h.postRender.emit();
|
||||
assert.equal(await loaded, true);
|
||||
const settledRenders = h.renders();
|
||||
t.mock.timers.tick(READY_WAKE_MS * 4);
|
||||
assert.equal(h.renders(), settledRenders, 'commit clears the wake timer');
|
||||
|
||||
const abandoned = h.rendering.setFrame(snapshot, times[1]);
|
||||
const second = deferred();
|
||||
h.providers.at(-1).response = second;
|
||||
const secondRequest = h.providers.at(-1).requestImage(0, 0, 0, {});
|
||||
second.resolve({});
|
||||
await secondRequest;
|
||||
h.postRender.emit();
|
||||
h.rendering.clear();
|
||||
assert.equal(await abandoned, false);
|
||||
const clearedRenders = h.renders();
|
||||
t.mock.timers.tick(READY_WAKE_MS * 4);
|
||||
assert.equal(h.renders(), clearedRenders, 'clear cancels the wake timer');
|
||||
});
|
||||
|
||||
test('camera movement drops abandoned deferred tiles but still waits for admitted requests', async () => {
|
||||
let now = 0;
|
||||
const h = renderingHarness({ now: () => now });
|
||||
|
||||
@@ -38,6 +38,7 @@ export function createGoogleTokenSource({
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (response.status === 204) return null;
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
return null;
|
||||
|
||||
@@ -79,3 +79,12 @@ test('a server that does not offer tokens yields none', async () => {
|
||||
assert.equal(await createGoogleTokenSource({ fetchImpl }).token(), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty 204 response explicitly means no Google token is available', async () => {
|
||||
const { calls, fetchImpl } = server([
|
||||
new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } }),
|
||||
]);
|
||||
const tokens = createGoogleTokenSource({ fetchImpl });
|
||||
assert.equal(await tokens.token(), null);
|
||||
assert.deepEqual(calls, [GOOGLE_TOKEN_PATH]);
|
||||
});
|
||||
@@ -166,6 +166,8 @@ export function nominatimViewboxFromBounds(bounds) {
|
||||
const neLat = nominatimLatitude(match[3]);
|
||||
const neLng = nominatimLongitude(match[4]);
|
||||
if ([swLat, swLng, neLat, neLng].some((value) => value === null)) return null;
|
||||
if (swLat >= neLat) return null;
|
||||
// One Nominatim viewbox cannot express a wrapped interval. Omit this soft
|
||||
// preference rather than biasing the search toward the opposite hemisphere.
|
||||
if (swLat >= neLat || swLng > neLng) return null;
|
||||
return `${swLng},${neLat},${neLng},${swLat}`;
|
||||
}
|
||||
@@ -173,3 +173,12 @@ test('a viewport hint is sent only when every edge is a real coordinate', () =>
|
||||
`${JSON.stringify(bounds)} must not become a viewbox`,
|
||||
);
|
||||
});
|
||||
|
||||
test('wrapped viewports omit the Nominatim bias without rejecting the search', () => {
|
||||
assert.equal(nominatimViewboxFromBounds('-20,170|-10,-171'), null);
|
||||
assert.equal(nominatimViewboxFromBounds('50,175|60,-165'), null);
|
||||
assert.equal(
|
||||
nominatimViewboxFromBounds('-20,170|-10,180'),
|
||||
'170,-10,180,-20',
|
||||
);
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { LOCAL_OVERLAY_COHORT_LIMIT } from '../data/localGeojson.js';
|
||||
import { LOCAL_OVERLAY_COHORT_LIMIT } from '../data/localGeojsonCore.js';
|
||||
import { FIRMS_AMBIENT_COHORT_LIMIT } from '../data/firmsLabels.js';
|
||||
import { vesselOverlayCohortLimit } from '../data/vesselLabels.js';
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../data/cctvLod.js';
|
||||
|
||||
@@ -113,6 +113,9 @@ test('Pinokio menu resolves the nested install marker and exposes each lifecycle
|
||||
runtime.url = url;
|
||||
const items = await menu(kernel, info);
|
||||
assert.equal(items.filter((item) => item.default).length, 1);
|
||||
const ready = installed && running === 'start.js' && Boolean(url);
|
||||
assert.equal(items[0].popout, ready ? true : undefined);
|
||||
assert.ok(items.slice(1).every((item) => item.popout === undefined));
|
||||
return items.map(({ text, href, default: isDefault = false }) => ({ text, href, default: isDefault }));
|
||||
};
|
||||
|
||||
|
||||
@@ -11,9 +11,8 @@ import { expandApplicationHtml } from '../build/application-html.js';
|
||||
// actually decides fresh-boot layer state — including the early return that
|
||||
// makes each layer's own initializer the operative default.
|
||||
// 2. Scope feather. Owner: "I like to hide feather" set it to 0% on
|
||||
// 2026-08-22; the owner revised that to 8% on 2026-08-23 and locked 11%
|
||||
// on 2026-08-24, a soft
|
||||
// edge. The hard crop is still one drag away, and that is pinned too.
|
||||
// 2026-08-22; later revisions were 8%, then 11%, then 100% on
|
||||
// 2026-10-25. Scope stays ON on a fresh boot. The hard crop remains available.
|
||||
// 3. Detection ON (Dense @ 75%) for EVERY style, Normal included.
|
||||
// Owner: "detect should also be on by default."
|
||||
// 4. Detection OUTSIDE opacity 1% (owner final lock, 2026-08-24; 3% on 08-23, 5% before), with
|
||||
@@ -38,6 +37,7 @@ import { AIRCRAFT_BRACKET_FLOOR_ANCHOR } from './data/detectionPolicy.js';
|
||||
import {
|
||||
SCOPE_FEATHER_RATIO_DEFAULT,
|
||||
getScopeMaskFeather,
|
||||
isScopeMaskEnabled,
|
||||
scopeMaskGeometry,
|
||||
setScopeMaskFeather,
|
||||
} from './scopeMask.js';
|
||||
@@ -75,32 +75,40 @@ function managerForHash(hash) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Scope feather — a subtle soft edge on a first run
|
||||
// 2. Scope ON with 100% feather on a first run
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('first run opens with a subtle scope feather, at every surface that decides it', () => {
|
||||
assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 0.11,
|
||||
'owner final lock 2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings');
|
||||
assert.equal(getScopeMaskFeather(), 0.11,
|
||||
test('first run opens with Scope ON and 100% feather at every default surface', () => {
|
||||
assert.equal(isScopeMaskEnabled(), true, 'the live mask starts enabled');
|
||||
assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 1, 'owner preference 2026-10-25');
|
||||
assert.equal(getScopeMaskFeather(), 1,
|
||||
'and the live module starts there, not merely documents it');
|
||||
|
||||
// The slider and its readout are the same default rendered as markup — a
|
||||
// fresh boot applies no restore, so a stale value here would show one number
|
||||
// over a mask drawn at another.
|
||||
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\svalue="11"/,
|
||||
'index.html: the feather slider ships at 11');
|
||||
assert.match(indexHtml, /id="scope-feather-value"[^>]*>11%</,
|
||||
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\svalue="100"/,
|
||||
'index.html: the feather slider ships at 100');
|
||||
assert.match(indexHtml, /id="scope-feather-value"[^>]*>100%</,
|
||||
'index.html: and its readout agrees with the handle');
|
||||
|
||||
assert.match(indexHtml, /<input type="checkbox" id="scope-toggle"[^>]*\schecked\s*\/>/,
|
||||
'the native Scope checkbox starts checked');
|
||||
|
||||
// The link this session generates must describe the mask this session draws,
|
||||
// for the window before the first _syncShareState.
|
||||
assert.match(shareSource, /this\._scopeFeatherPct = 11;/,
|
||||
assert.match(shareSource, /this\._scopeFeatherPct = 100;/,
|
||||
'sharelink.js: the generator starts from the same value the mask starts at');
|
||||
const params = managerForHash('')._buildHashParams();
|
||||
assert.equal(params.get('sc'), '1');
|
||||
assert.equal(params.get('scf'), '100');
|
||||
});
|
||||
|
||||
test('an explicit feather still wins over the subtle default', () => {
|
||||
test('an explicit feather still wins over the 100% default', () => {
|
||||
// A link is authored state. The new default governs a session that said
|
||||
// nothing; it must never overwrite one that said something.
|
||||
assert.equal(managerForHash('#lat=10&lon=20&scf=11').parseInitialHash().scopeFeatherPct, 11,
|
||||
'links carrying the previous default preserve their authored feather');
|
||||
assert.equal(managerForHash('#lat=10&lon=20&scf=35').parseInitialHash().scopeFeatherPct, 35);
|
||||
assert.equal(managerForHash('#lat=10&lon=20&scf=64').parseInitialHash().scopeFeatherPct, 64);
|
||||
assert.equal(managerForHash('#lat=10&lon=20&scf=0').parseInitialHash().scopeFeatherPct, 0,
|
||||
@@ -113,23 +121,28 @@ test('an explicit feather still wins over the subtle default', () => {
|
||||
'a pre-scf link restores the author\'s view, not the new default');
|
||||
});
|
||||
|
||||
test('the subtle default did not weaken the feather control, and 0 is still reachable', () => {
|
||||
test('share links preserve explicit Scope OFF and feather choices over the defaults', () => {
|
||||
const shared = managerForHash('#lat=10&lon=20&sc=0&scf=11').parseInitialHash();
|
||||
assert.equal(shared.scopeEnabled, false);
|
||||
assert.equal(shared.scopeFeatherPct, 11);
|
||||
});
|
||||
|
||||
test('the 100% default retains the expanded feather range and hard edge', () => {
|
||||
// The cheap way to move a default would be to nerf the control. Prove the
|
||||
// slider still spans its full range and the geometry is still DERIVED from
|
||||
// the ratio — a check that would pass vacuously if it only ever saw one value.
|
||||
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\smin="0"[^>]*\smax="100"/,
|
||||
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\smin="0"[^>]*\smax="200"/,
|
||||
'the slider still offers the whole range');
|
||||
const previous = getScopeMaskFeather();
|
||||
try {
|
||||
for (const ratio of [0.35, 0.7, 1]) {
|
||||
for (const ratio of [0.35, 0.7, 1, 1.75, 2]) {
|
||||
setScopeMaskFeather(ratio);
|
||||
const geo = scopeMaskGeometry(1200, 900);
|
||||
const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS;
|
||||
assert.ok(Math.abs((geo.outerR - geo.innerR) - keyholeR * ratio) < 1e-9,
|
||||
`feather ${ratio} must still widen the band to that fraction of the keyhole`);
|
||||
}
|
||||
// The new default is a real, narrow band — not the hard crop, and nowhere
|
||||
// near the retired 35 % halo.
|
||||
// The first-run value draws a band derived from the same keyhole radius.
|
||||
setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT);
|
||||
const soft = scopeMaskGeometry(1200, 900);
|
||||
const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS;
|
||||
|
||||
+25
-21
@@ -1,4 +1,5 @@
|
||||
import { getKeyholeGeometry } from './celestialRing.js';
|
||||
import { SCOPE_FEATHER_RATIO_MAX } from './scopeMaskPolicy.js';
|
||||
|
||||
/**
|
||||
* Scope mask — the app's signature circular viewport treatment, made real.
|
||||
@@ -41,25 +42,10 @@ import { getKeyholeGeometry } from './celestialRing.js';
|
||||
/** Matches the page background the emergent scope faded into. */
|
||||
const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 };
|
||||
/**
|
||||
* Default edge feather as a fraction of the keyhole radius.
|
||||
*
|
||||
* 0.11 since 2026-08-24 (owner final lock; 0.08 on 08-23, hard-crop 0 on
|
||||
* 08-22 — this supersedes both), REVISING the 2026-08-22 ruling that
|
||||
* set it to zero: a subtle soft edge rather than either the hard crop or the
|
||||
* retired 35 % halo. The slider is untouched and still spans 0..100; this is
|
||||
* only where it STARTS. `setScopeMaskFeather` is unchanged, so any feather a
|
||||
* share link carries, or the operator dials in, overrides this immediately, and
|
||||
* the hard-crop path at 0 is still reachable from the handle.
|
||||
*
|
||||
* Keep in lockstep with `#scope-feather-slider`'s markup value AND readout in
|
||||
* index.html and `_scopeFeatherPct` in sharelink.js — a fresh boot applies no
|
||||
* restore, so those literals ARE the first-run state. NOT the `scf` PARSE
|
||||
* fallback, which stays at 35 on purpose: a link predating that field was
|
||||
* authored when 35 was what its author saw, and a link from the feather-0 era
|
||||
* carries `scf=0` explicitly because the generator always writes the field.
|
||||
* Pinned in reasonableDefaults.test.mjs.
|
||||
* First-run feather ratio; the Display slider and share defaults use 100%.
|
||||
* The historical missing-scf parse fallback remains 35%.
|
||||
*/
|
||||
export const SCOPE_FEATHER_RATIO_DEFAULT = 0.11;
|
||||
export const SCOPE_FEATHER_RATIO_DEFAULT = 1;
|
||||
/**
|
||||
* Terminus opacity at/above SCOPE_TERMINUS_FAR_M — slightly translucent so
|
||||
* faint stars survive in the corners at globe scale.
|
||||
@@ -344,7 +330,10 @@ function teardownDevicePixelRatioWatch() {
|
||||
export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) {
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
if (!(keyhole.radius > 0)) return null;
|
||||
const feather = Math.max(0, Math.min(1, Number(featherRatio) || 0));
|
||||
const feather = Math.max(
|
||||
0,
|
||||
Math.min(SCOPE_FEATHER_RATIO_MAX, Number(featherRatio) || 0),
|
||||
);
|
||||
// Feather straddles the keyhole edge so the visible radius stays anchored
|
||||
// to the shared geometry every keyhole consumer (labels, cards) fades on.
|
||||
const half = keyhole.radius * feather * 0.5;
|
||||
@@ -423,6 +412,14 @@ function draw() {
|
||||
_painted = true;
|
||||
}
|
||||
|
||||
/** Expose only the OFF state; an absent attribute preserves the original HUD. */
|
||||
function syncScopeMaskAppearance(enabled = _enabled) {
|
||||
const dataset = globalThis.document?.documentElement?.dataset;
|
||||
if (!dataset) return;
|
||||
if (enabled) delete dataset.scopeMask;
|
||||
else dataset.scopeMask = 'off';
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the scope mask into the viewer container. Idempotent.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
@@ -430,6 +427,7 @@ function draw() {
|
||||
*/
|
||||
export function installScopeMask(viewer) {
|
||||
if (_canvas || !viewer?.container) return;
|
||||
syncScopeMaskAppearance();
|
||||
_container = viewer.container;
|
||||
_viewer = viewer;
|
||||
_canvas = document.createElement('canvas');
|
||||
@@ -505,6 +503,7 @@ export function setScopeMaskEnabled(enabled) {
|
||||
const next = Boolean(enabled);
|
||||
const reEnabled = next && !_enabled;
|
||||
_enabled = next;
|
||||
syncScopeMaskAppearance();
|
||||
if (reEnabled) {
|
||||
// The camera moved freely while the scope was off and nothing sampled it,
|
||||
// so the painted terminus can be a whole altitude band stale. Re-sync once
|
||||
@@ -522,11 +521,14 @@ export function isScopeMaskEnabled() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} ratio - Edge feather as a fraction of keyhole radius [0,1].
|
||||
* @param {number} ratio - Edge feather as a fraction of keyhole radius [0,2].
|
||||
* @returns {void}
|
||||
*/
|
||||
export function setScopeMaskFeather(ratio) {
|
||||
_featherRatio = Math.max(0, Math.min(1, Number(ratio) || 0));
|
||||
_featherRatio = Math.max(
|
||||
0,
|
||||
Math.min(SCOPE_FEATHER_RATIO_MAX, Number(ratio) || 0),
|
||||
);
|
||||
draw();
|
||||
}
|
||||
|
||||
@@ -540,6 +542,7 @@ export function getScopeMaskFeather() {
|
||||
* @returns {void}
|
||||
*/
|
||||
export function destroyScopeMask() {
|
||||
syncScopeMaskAppearance(true);
|
||||
_resizeObserver?.disconnect();
|
||||
_resizeObserver = null;
|
||||
teardownDevicePixelRatioWatch();
|
||||
@@ -553,6 +556,7 @@ export function destroyScopeMask() {
|
||||
|
||||
/** Test seam. */
|
||||
export function _resetScopeMaskForTest() {
|
||||
syncScopeMaskAppearance(true);
|
||||
_resizeObserver?.disconnect();
|
||||
_resizeObserver = null;
|
||||
teardownDevicePixelRatioWatch();
|
||||
|
||||
+50
-5
@@ -18,6 +18,7 @@ import {
|
||||
SCOPE_TERMINUS_MIN_PCT,
|
||||
SCOPE_TERMINUS_MAX_PCT,
|
||||
SCOPE_FEATHER_RATIO_DEFAULT,
|
||||
getScopeMaskFeather,
|
||||
SCOPE_OUTSIDE_ALPHA,
|
||||
SCOPE_TERMINUS_ALPHA_NEAR,
|
||||
SCOPE_TERMINUS_FAR_M,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
_resetScopeMaskForTest,
|
||||
} from './scopeMask.js';
|
||||
import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js';
|
||||
import { SCOPE_FEATHER_RATIO_MAX, clampScopeFeatherPct } from './scopeMaskPolicy.js';
|
||||
|
||||
beforeEach(() => _resetScopeMaskForTest());
|
||||
|
||||
@@ -47,16 +49,34 @@ test('zero feather produces a hard edge exactly at the keyhole radius', () => {
|
||||
assert.equal(geo.outerR, keyholeR);
|
||||
});
|
||||
|
||||
test('feather ratio is clamped to [0,1] and bad input falls back to 0', () => {
|
||||
test('feather ratio reaches the center at 200% and clamps larger input', () => {
|
||||
const wide = scopeMaskGeometry(1000, 800, 7);
|
||||
const one = scopeMaskGeometry(1000, 800, 1);
|
||||
assert.equal(wide.innerR, one.innerR);
|
||||
assert.equal(wide.outerR, one.outerR);
|
||||
const maximum = scopeMaskGeometry(1000, 800, SCOPE_FEATHER_RATIO_MAX);
|
||||
const keyholeR = 800 * 0.5 * KEYHOLE_OUTER_RADIUS;
|
||||
assert.equal(maximum.innerR, 0);
|
||||
assert.equal(maximum.outerR, keyholeR * 2);
|
||||
assert.equal(wide.innerR, maximum.innerR);
|
||||
assert.equal(wide.outerR, maximum.outerR);
|
||||
for (const ratio of [1, 1.5, 2]) {
|
||||
setScopeMaskFeather(ratio);
|
||||
assert.equal(getScopeMaskFeather(), ratio);
|
||||
const geometry = scopeMaskGeometry(1000, 800);
|
||||
assert.equal((geometry.innerR + geometry.outerR) / 2, keyholeR);
|
||||
assert.equal(geometry.outerR - geometry.innerR, keyholeR * ratio);
|
||||
}
|
||||
setScopeMaskFeather(7);
|
||||
assert.equal(getScopeMaskFeather(), SCOPE_FEATHER_RATIO_MAX);
|
||||
const nan = scopeMaskGeometry(1000, 800, Number.NaN);
|
||||
const zero = scopeMaskGeometry(1000, 800, 0);
|
||||
assert.equal(nan.innerR, zero.innerR);
|
||||
});
|
||||
|
||||
test('UI and share feather percentages share one bounded integer range', () => {
|
||||
for (const [value, expected] of [[0, 0], [11, 11], [175.6, 176], [200, 200], [350, 200], [-4, 0], [Number.NaN, 0]]) {
|
||||
assert.equal(clampScopeFeatherPct(value), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('degenerate viewport yields null instead of a broken gradient', () => {
|
||||
assert.equal(scopeMaskGeometry(0, 0), null);
|
||||
assert.equal(scopeMaskGeometry(-5, 100), null);
|
||||
@@ -136,12 +156,14 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) {
|
||||
removeEventListener: (type, fn) => listeners.delete(fn),
|
||||
}),
|
||||
};
|
||||
globalThis.document = { createElement: () => canvas };
|
||||
const documentElement = { dataset: {} };
|
||||
globalThis.document = { documentElement, createElement: () => canvas };
|
||||
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
|
||||
const container = { clientWidth: width, clientHeight: height, appendChild() {} };
|
||||
return {
|
||||
canvas,
|
||||
container,
|
||||
documentElement,
|
||||
/** Canvas work counters: backing-store resizes, clears, fills. */
|
||||
ops,
|
||||
/** Every fillStyle string assigned since install (hard-crop path). */
|
||||
@@ -160,6 +182,29 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
test('scope appearance follows direct state changes and install/destroy lifecycles', () => {
|
||||
const dom = stubScopeMaskDom();
|
||||
try {
|
||||
setScopeMaskEnabled(false);
|
||||
assert.equal(dom.documentElement.dataset.scopeMask, 'off');
|
||||
delete dom.documentElement.dataset.scopeMask;
|
||||
installScopeMask({ container: dom.container });
|
||||
assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'install publishes retained OFF state');
|
||||
destroyScopeMask();
|
||||
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'destroy clears the document marker');
|
||||
installScopeMask({ container: dom.container });
|
||||
assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'reinstall retains the preference');
|
||||
setScopeMaskEnabled(true);
|
||||
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'ON uses the original presentation');
|
||||
setScopeMaskEnabled(false);
|
||||
_resetScopeMaskForTest();
|
||||
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'test reset clears the marker');
|
||||
} finally {
|
||||
destroyScopeMask();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a DPR change with no resize still repaints the backing store', () => {
|
||||
const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 });
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** A 200% feather reaches the center while keeping its midpoint on the keyhole. */
|
||||
export const SCOPE_FEATHER_RATIO_MAX = 2;
|
||||
const SCOPE_FEATHER_MAX_PCT = SCOPE_FEATHER_RATIO_MAX * 100;
|
||||
|
||||
/** Normalize a UI/share-link feather percentage to the supported integer range. */
|
||||
export function clampScopeFeatherPct(value) {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(SCOPE_FEATHER_MAX_PCT, Math.round(Number(value) || 0)),
|
||||
);
|
||||
}
|
||||
@@ -307,10 +307,20 @@ export function createGroundSnap({ groundFloor }) {
|
||||
}
|
||||
const now = Date.now();
|
||||
const entry = entries.get(icao);
|
||||
if (entry && now < entry.nextRetryMs) return heldSnapM(entry, surfacePos); // backoff in force
|
||||
// Backoff in force, unless it ends further off than any backoff can:
|
||||
// a retry stamped before the clock stepped back would otherwise wait for
|
||||
// the clock to catch up.
|
||||
if (
|
||||
entry &&
|
||||
now < entry.nextRetryMs &&
|
||||
entry.nextRetryMs - now <= RETRY_MAX_MS
|
||||
)
|
||||
return heldSnapM(entry, surfacePos);
|
||||
// Per-window budget: deny WITHOUT a retry stamp so the overflow simply
|
||||
// tries again next tick instead of waiting out a backoff it didn't earn.
|
||||
if (now - windowStartMs > SAMPLE_WINDOW_MS) {
|
||||
// A clock that stepped back also opens a new window, or none would open
|
||||
// until it caught up.
|
||||
if (now < windowStartMs || now - windowStartMs > SAMPLE_WINDOW_MS) {
|
||||
windowStartMs = now;
|
||||
windowCount = 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createGroundSnap } from './groundSnap.js';
|
||||
|
||||
const groundFloor = {
|
||||
cachedMeshFloor: () => null,
|
||||
meshFloorPreferred: () => false,
|
||||
reportValidatedMeshFloorCell: () => {},
|
||||
};
|
||||
|
||||
/** A viewer whose ground is a loaded 3D tileset, counting height samples. */
|
||||
function makeViewer() {
|
||||
const calls = { samples: 0 };
|
||||
const tileset = { show: true, tilesLoaded: true };
|
||||
return {
|
||||
calls,
|
||||
scene: {
|
||||
primitives: { length: 1, get: () => tileset },
|
||||
sampleHeight() {
|
||||
calls.samples += 1;
|
||||
return 1650;
|
||||
},
|
||||
globe: { getHeight: () => 1600 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const atDenverAirport = Cesium.Cartesian3.fromDegrees(-104.6737, 39.8561, 0);
|
||||
|
||||
test('a clock that steps back opens a new sample window instead of denying samples until it catches up', (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 });
|
||||
const viewer = makeViewer();
|
||||
const snap = createGroundSnap({ groundFloor });
|
||||
const icaos = ['a00001', 'a00002', 'a00003', 'a00004', 'a00005', 'a00006'];
|
||||
for (const icao of icaos) snap.heightFor(viewer, icao, atDenverAirport);
|
||||
assert.equal(viewer.calls.samples, 4, 'four samples per 250 ms window');
|
||||
t.mock.timers.setTime(1_000_000 - 60_000);
|
||||
for (const icao of icaos) snap.heightFor(viewer, icao, atDenverAirport);
|
||||
assert.equal(
|
||||
viewer.calls.samples,
|
||||
6,
|
||||
'the two denied aircraft sample in the new window',
|
||||
);
|
||||
});
|
||||
|
||||
test('a retry backoff stamped before the clock stepped back does not wait for it to catch up', (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 10_000_000 });
|
||||
const viewer = makeViewer();
|
||||
const tileset = viewer.scene.primitives.get(0);
|
||||
const snap = createGroundSnap({ groundFloor });
|
||||
// Tiles still streaming: a miss, backed off for 2 s.
|
||||
tileset.tilesLoaded = false;
|
||||
assert.equal(snap.heightFor(viewer, 'a00001', atDenverAirport), null);
|
||||
tileset.tilesLoaded = true;
|
||||
assert.equal(snap.heightFor(viewer, 'a00001', atDenverAirport), null);
|
||||
assert.equal(viewer.calls.samples, 0, 'the backoff holds');
|
||||
// The clock steps back an hour: the stamp is an hour off, past any backoff.
|
||||
t.mock.timers.setTime(10_000_000 - 3_600_000);
|
||||
assert.equal(snap.heightFor(viewer, 'a00001', atDenverAirport), 1650);
|
||||
assert.equal(viewer.calls.samples, 1);
|
||||
});
|
||||
@@ -99,6 +99,22 @@ test('Nepal locator token is valid in v2 share links', () => {
|
||||
assert.equal(parsed.layerStateInvalid, false);
|
||||
});
|
||||
|
||||
test('scope feather above 100% survives share parsing and serialization', () => {
|
||||
const manager = makeManager('#v=2&lat=10&lon=20&sc=0&scf=200');
|
||||
const parsed = manager.parseInitialHash();
|
||||
assert.equal(parsed.scopeEnabled, false);
|
||||
assert.equal(parsed.scopeFeatherPct, 200);
|
||||
assert.equal(makeManager('#lat=10&lon=20&scf=175').parseInitialHash().scopeFeatherPct, 175);
|
||||
assert.equal(makeManager('#lat=10&lon=20&scf=999').parseInitialHash().scopeFeatherPct, 200);
|
||||
manager.onToggleChange(false, true, { scopeEnabled: false, scopeFeatherPct: 200 });
|
||||
const params = manager._buildHashParams();
|
||||
assert.equal(params.get('sc'), '0');
|
||||
assert.equal(params.get('scf'), '200');
|
||||
manager.onToggleChange(false, true, { scopeFeatherPct: 999 });
|
||||
assert.equal(manager._buildHashParams().get('scf'), '200');
|
||||
clearTimeout(manager._debounceTimer);
|
||||
});
|
||||
|
||||
test('share-link serialization emits the current celestial state', () => {
|
||||
const manager = makeManager();
|
||||
manager.onToggleChange(false, false, { celestialRingEnabled: false });
|
||||
@@ -498,8 +514,8 @@ test('every explicit visual UI gesture claims restore authority before it mutate
|
||||
['cycleDetection: () => {', 'toggleCctv: () =>', 'cycleDetectionMode()', 'detection hotkey'],
|
||||
['toggleBloom:', 'setBloomIntensity:', 'this._setBloomEnabled(', 'toggleBloom control'],
|
||||
['setBloomIntensity:', 'toggleSharpen:', 'this._setBloomIntensity(', 'setBloomIntensity control'],
|
||||
['toggleSharpen:', 'toggleScope:', 'this._setSharpenEnabled(', 'toggleSharpen control'],
|
||||
['toggleScope:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'toggleScope control'],
|
||||
['toggleSharpen:', 'setScopeEnabled:', 'this._setSharpenEnabled(', 'toggleSharpen control'],
|
||||
['setScopeEnabled:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'setScopeEnabled control'],
|
||||
['setScopeFeather:', 'setSharpenIntensity:', 'setScopeMaskFeather(', 'setScopeFeather control'],
|
||||
['setSharpenIntensity:', 'setHudLayout:', 'this._applySharpenIntensity(', 'setSharpenIntensity control'],
|
||||
['setHudLayout:', 'toggleCleanView:', 'this._setHudVariant(', 'setHudLayout control'],
|
||||
|
||||
+4
-12
@@ -5,6 +5,7 @@ import {
|
||||
normalizeAllocationStrategy,
|
||||
} from './data/detectionPolicy.js';
|
||||
import { clampScopeTerminusPct } from './scopeMask.js';
|
||||
import { clampScopeFeatherPct } from './scopeMaskPolicy.js';
|
||||
import {
|
||||
decodeLayerStateParams,
|
||||
encodeLayerStateParams,
|
||||
@@ -112,10 +113,7 @@ export class ShareLinkManager {
|
||||
this._detectionOutsideOpacityPct = 1;
|
||||
this._celestialRingEnabled = false;
|
||||
this._scopeEnabled = true;
|
||||
// Feather opens on a soft 11% scope-mask edge (owner final lock 2026-08-24,
|
||||
// superseding the 08-22 hard-crop and 08-23 8% rulings) — mirrors
|
||||
// SCOPE_FEATHER_RATIO_DEFAULT in scopeMask.js and the slider's markup value.
|
||||
this._scopeFeatherPct = 11;
|
||||
this._scopeFeatherPct = 100;
|
||||
// null = the altitude-adaptive terminus (the default). A number pins the
|
||||
// outside-fill opacity as a percent, 94..100. (`sce`, 2026-08-17)
|
||||
this._scopeTerminusPct = null;
|
||||
@@ -227,10 +225,7 @@ export class ShareLinkManager {
|
||||
// (`_scopeFeatherPct` in the constructor tracks the default: that one
|
||||
// mirrors live state for the link this session generates, so it must match
|
||||
// the mask, not the archive.)
|
||||
scopeFeatherPct: Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(parseOr(params.get('scf'), 35))),
|
||||
),
|
||||
scopeFeatherPct: clampScopeFeatherPct(parseOr(params.get('scf'), 35)),
|
||||
// Absent (or non-numeric) `sce` = adaptive (null), the default behavior;
|
||||
// a value pins the terminus opacity percent, clamped into the SUPPORTED
|
||||
// 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in
|
||||
@@ -494,10 +489,7 @@ export class ShareLinkManager {
|
||||
if (typeof extras.scopeEnabled === 'boolean')
|
||||
this._scopeEnabled = extras.scopeEnabled;
|
||||
if (typeof extras.scopeFeatherPct === 'number') {
|
||||
this._scopeFeatherPct = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(extras.scopeFeatherPct)),
|
||||
);
|
||||
this._scopeFeatherPct = clampScopeFeatherPct(extras.scopeFeatherPct);
|
||||
}
|
||||
if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null;
|
||||
else if (typeof extras.scopeTerminusPct === 'number') {
|
||||
|
||||
@@ -98,6 +98,13 @@ function dockCapacity(raw) {
|
||||
return capacity - geofenced;
|
||||
}
|
||||
|
||||
/** Read a numeric coordinate without treating blank text or other JSON types as zero. */
|
||||
function coordinateNumber(value) {
|
||||
if (typeof value !== 'number' && (typeof value !== 'string' || !value.trim()))
|
||||
return NaN;
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GBFS station_information payload into a Map of station metadata.
|
||||
* Handles field-name variations across different GBFS providers
|
||||
@@ -113,9 +120,16 @@ export function parseStationInformation(payload) {
|
||||
for (const raw of stations) {
|
||||
// Accept both GBFS (station_id) and alternate (id) field names
|
||||
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
|
||||
const lat = Number(raw?.lat ?? raw?.latitude);
|
||||
const lon = Number(raw?.lon ?? raw?.longitude);
|
||||
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
const lat = coordinateNumber(raw?.lat ?? raw?.latitude);
|
||||
const lon = coordinateNumber(raw?.lon ?? raw?.longitude);
|
||||
if (
|
||||
!stationId ||
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lon) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
Math.abs(lon) > 180
|
||||
)
|
||||
continue;
|
||||
|
||||
stationMap.set(stationId, {
|
||||
stationId,
|
||||
|
||||
@@ -19,6 +19,73 @@ const parseCapacity = (fields) =>
|
||||
parseStationInformation({ data: { stations: [station(fields)] } }).get('s1')
|
||||
.capacity;
|
||||
|
||||
test('station information skips missing, blank and malformed coordinates', () => {
|
||||
for (const field of ['lat', 'lon', 'latitude', 'longitude']) {
|
||||
for (const value of [
|
||||
undefined,
|
||||
null,
|
||||
'',
|
||||
' \t ',
|
||||
false,
|
||||
true,
|
||||
[],
|
||||
[0],
|
||||
{},
|
||||
'bad',
|
||||
NaN,
|
||||
Infinity,
|
||||
]) {
|
||||
const raw = { station_id: 'bad', lat: 0, lon: 0 };
|
||||
if (field === 'latitude') delete raw.lat;
|
||||
if (field === 'longitude') delete raw.lon;
|
||||
raw[field] = value;
|
||||
const parsed = parseStationInformation({
|
||||
data: { stations: [raw, station({ station_id: 'good' })] },
|
||||
});
|
||||
assert.deepEqual(
|
||||
[...parsed.keys()],
|
||||
['good'],
|
||||
`${field} = ${JSON.stringify(value)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('station information skips coordinates outside latitude and longitude bounds', () => {
|
||||
for (const fields of [
|
||||
{ lat: 90.001 },
|
||||
{ lat: -90.001 },
|
||||
{ lon: 180.001 },
|
||||
{ lon: -180.001 },
|
||||
{ lat: undefined, latitude: '91' },
|
||||
{ lon: undefined, longitude: '-181' },
|
||||
]) {
|
||||
const parsed = parseStationInformation({
|
||||
data: { stations: [station(fields)] },
|
||||
});
|
||||
assert.equal(parsed.size, 0, JSON.stringify(fields));
|
||||
}
|
||||
});
|
||||
|
||||
test('station information preserves zero, boundary coordinates and numeric aliases', () => {
|
||||
for (const [fields, expected] of [
|
||||
[{ lat: 0, lon: 0 }, [0, 0]],
|
||||
[{ lat: ' 0 ', lon: '0' }, [0, 0]],
|
||||
[{ lat: 90, lon: 180 }, [90, 180]],
|
||||
[{ lat: '-90', lon: '-180' }, [-90, -180]],
|
||||
[
|
||||
{ lat: null, lon: undefined, latitude: '30.2681', longitude: '-97.7469' },
|
||||
[30.2681, -97.7469],
|
||||
],
|
||||
]) {
|
||||
const parsed = parseStationInformation({
|
||||
data: { stations: [station(fields)] },
|
||||
}).get('s1');
|
||||
assert.ok(parsed, JSON.stringify(fields));
|
||||
assert.deepEqual([parsed.lat, parsed.lon], expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('capacity counts docks, not the geofenced area PBSC adds to it', () => {
|
||||
// Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17.
|
||||
assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import { createGoogleTokenSource } from '../maps/googleTokens.js';
|
||||
import { googleTokenUnavailablePlugin } from '../../server/standalone/google-token-unavailable.js';
|
||||
|
||||
async function withServer({ beforeFallback } = {}, check) {
|
||||
const middlewares = [];
|
||||
const app = {
|
||||
middlewares: {
|
||||
use: (handler) => middlewares.push(handler),
|
||||
},
|
||||
};
|
||||
if (beforeFallback) app.middlewares.use(beforeFallback);
|
||||
googleTokenUnavailablePlugin().configureServer(app);
|
||||
app.middlewares.use((_req, res) => {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Unknown API route' }));
|
||||
});
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
let index = 0;
|
||||
const next = () => middlewares[index++]?.(req, res, next);
|
||||
next();
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const address = server.address();
|
||||
const url = 'http://127.0.0.1:' + address.port;
|
||||
try {
|
||||
await check(url);
|
||||
} finally {
|
||||
server.close();
|
||||
await once(server, 'close');
|
||||
}
|
||||
}
|
||||
|
||||
test('keyless GET and HEAD return uncached 204 instead of 404', async () => {
|
||||
await withServer({}, async (url) => {
|
||||
for (const method of ['GET', 'HEAD']) {
|
||||
const response = await fetch(url + '/api/google/tiles-token?probe=1', {
|
||||
method,
|
||||
});
|
||||
assert.equal(response.status, 204);
|
||||
assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||
assert.equal(await response.text(), '');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('the existing client treats unavailable tokens as null', async () => {
|
||||
await withServer({}, async (url) => {
|
||||
const source = createGoogleTokenSource({
|
||||
fetchImpl: (path, options) => fetch(new URL(path, url), options),
|
||||
});
|
||||
assert.equal(await source.token(), null);
|
||||
});
|
||||
});
|
||||
|
||||
test('unrelated API paths and unsupported methods remain 404', async () => {
|
||||
await withServer({}, async (url) => {
|
||||
for (const [path, method] of [
|
||||
['/api/google/tiles-token/extra', 'GET'],
|
||||
['/api/not-a-token', 'GET'],
|
||||
['/api/google/tiles-token', 'POST'],
|
||||
]) {
|
||||
const response = await fetch(new URL(path, url), { method });
|
||||
assert.equal(response.status, 404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('a real token provider registered first still wins', async () => {
|
||||
await withServer(
|
||||
{
|
||||
beforeFallback: (req, res, next) => {
|
||||
if (req.url !== '/api/google/tiles-token') return next();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
accessToken: 'server-token',
|
||||
expiresAt: 10000000000000,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
async (url) => {
|
||||
const response = await fetch(url + '/api/google/tiles-token');
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal((await response.json()).accessToken, 'server-token');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -5,7 +6,11 @@ import { rm, writeFile } from 'node:fs/promises';
|
||||
import { request } from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { build, createServer, preview } from 'vite';
|
||||
import { BROWSER_CSP, createBrowserViteConfig } from '../../build/vite.js';
|
||||
import {
|
||||
BROWSER_CSP,
|
||||
createBrowserViteConfig,
|
||||
escapeFsDenyPath,
|
||||
} from '../../build/vite.js';
|
||||
import standaloneConfig, * as compatibility from '../../vite.config.js';
|
||||
import * as providers from '../../server/providers/local.js';
|
||||
import { apiNotFoundPlugin } from '../../server/standalone/api-not-found.js';
|
||||
@@ -30,6 +35,20 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
|
||||
assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']);
|
||||
assert.ok(config.server.fs.deny.includes('**/ENVIRONMENT'));
|
||||
assert.ok(config.server.fs.deny.includes('.env.*'));
|
||||
assert.ok(config.server.fs.deny.includes('**/.gev-logs/**'));
|
||||
assert.ok(config.server.fs.deny.includes('**/.gev-cache/**'));
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url))
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\/$/, '');
|
||||
assert.deepEqual(
|
||||
config.server.fs.deny.filter((pattern) => pattern.endsWith('/dist/**')),
|
||||
[`${escapeFsDenyPath(root)}/dist/**`],
|
||||
'only project build output is denied, not dependency dist directories',
|
||||
);
|
||||
assert.equal(
|
||||
escapeFsDenyPath('C:/Users/a/project (1) [test]{copy}'),
|
||||
'C:/Users/a/project \\(1\\) \\[test\\]\\{copy\\}',
|
||||
);
|
||||
assert.equal(config.server.headers['X-Frame-Options'], 'DENY');
|
||||
assert.equal(config.server.headers['X-Content-Type-Options'], 'nosniff');
|
||||
const csp = config.server.headers['Content-Security-Policy'];
|
||||
@@ -98,7 +117,7 @@ test('root config retains existing named exports and standalone provider order',
|
||||
assert.equal(compatibility[name], value, name);
|
||||
const config = standaloneConfig({ mode: 'test' });
|
||||
const standaloneProviders = config.plugins
|
||||
.slice(4, -5)
|
||||
.slice(4, -6)
|
||||
.map((plugin) => plugin.name);
|
||||
assert.equal(
|
||||
standaloneProviders.filter((name) => name === 'gemini-live-proxy').length,
|
||||
@@ -112,9 +131,10 @@ test('root config retains existing named exports and standalone provider order',
|
||||
standaloneProviders.filter((name) => name !== 'gemini-live-proxy'),
|
||||
providers.localProviderPlugins().map((plugin) => plugin.name),
|
||||
);
|
||||
assert.equal(config.plugins.at(-6).name, 'gev-key-setup');
|
||||
// The local MCP route follows every provider and precedes the API fallback.
|
||||
assert.equal(config.plugins.at(-5).name, 'local-mcp');
|
||||
assert.equal(config.plugins.at(-7).name, 'gev-key-setup');
|
||||
// Token fallback follows real providers and local MCP before the API 404.
|
||||
assert.equal(config.plugins.at(-6).name, 'local-mcp');
|
||||
assert.equal(config.plugins.at(-5).name, 'google-token-unavailable');
|
||||
assert.equal(config.plugins.at(-4).name, 'api-not-found');
|
||||
// Config-only, so it sits after the provider slice rather than in it.
|
||||
assert.equal(config.plugins.at(-3).name, 'gev-unwatched-runtime-dirs');
|
||||
|
||||
@@ -5,6 +5,24 @@ import { defineTool, ToolError } from '../catalog.js';
|
||||
import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
|
||||
|
||||
function acquiredMsUtc(acqDate, acqTime) {
|
||||
if (typeof acqDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(acqDate))
|
||||
return NaN;
|
||||
const timeText = String(acqTime ?? '').trim();
|
||||
if (!/^\d{1,4}$/.test(timeText)) return NaN;
|
||||
const hhmm = timeText.padStart(4, '0');
|
||||
const hours = Number(hhmm.slice(0, 2));
|
||||
const minutes = Number(hhmm.slice(2, 4));
|
||||
if (hours > 23 || minutes > 59) return NaN;
|
||||
const timestamp = Date.parse(
|
||||
`${acqDate}T${hhmm.slice(0, 2)}:${hhmm.slice(2, 4)}:00.000Z`,
|
||||
);
|
||||
return Number.isFinite(timestamp) &&
|
||||
new Date(timestamp).toISOString().slice(0, 10) === acqDate
|
||||
? timestamp
|
||||
: NaN;
|
||||
}
|
||||
|
||||
export const getEarthquakes = defineTool({
|
||||
name: 'get_earthquakes',
|
||||
title: 'Recent earthquakes',
|
||||
@@ -114,8 +132,7 @@ export const getActiveFires = defineTool({
|
||||
lon: fire.lon,
|
||||
frp_mw: fire.frp ?? null,
|
||||
confidence: fire.confidence ?? null,
|
||||
acquired:
|
||||
[fire.acqDate, fire.acqTime].filter(Boolean).join(' ') || null,
|
||||
acquired: isoTime(acquiredMsUtc(fire.acqDate, fire.acqTime)),
|
||||
satellite: fire.satellite ?? null,
|
||||
day_night: fire.daynight ?? null,
|
||||
})),
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog } from '../catalog.js';
|
||||
import { getActiveFires } from './hazards.js';
|
||||
|
||||
const catalogWith = (fires) =>
|
||||
composeCatalog({
|
||||
tools: [getActiveFires],
|
||||
services: { fires: { getSnapshot: async () => ({ fires }) } },
|
||||
});
|
||||
|
||||
test('a fire acquired at 00:45 UTC reads as 00:45, not "45"', async () => {
|
||||
const result = await catalogWith([
|
||||
{ lat: 24, lon: 121, frp: 5, acqDate: '2026-01-01', acqTime: '45' },
|
||||
]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } });
|
||||
assert.equal(result.data.rows[0].acquired, '2026-01-01T00:45:00.000Z');
|
||||
});
|
||||
|
||||
test('fire acquisition timestamps reject impossible calendar dates', async () => {
|
||||
for (const acqDate of [
|
||||
'2026-02-29',
|
||||
'2026-02-30',
|
||||
'2026-04-31',
|
||||
'1900-02-29',
|
||||
'2026-13-01',
|
||||
'2026-01-00',
|
||||
]) {
|
||||
const result = await catalogWith([
|
||||
{ lat: 24, lon: 121, acqDate, acqTime: '45' },
|
||||
]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } });
|
||||
assert.equal(result.data.rows[0].acquired, null, acqDate);
|
||||
}
|
||||
});
|
||||
|
||||
test('fire acquisition timestamps preserve valid leap days and UTC times', async () => {
|
||||
for (const [acqDate, acqTime, expected] of [
|
||||
['2024-02-29', '45', '2024-02-29T00:45:00.000Z'],
|
||||
['2000-02-29', 0, '2000-02-29T00:00:00.000Z'],
|
||||
['2026-04-30', '2359', '2026-04-30T23:59:00.000Z'],
|
||||
]) {
|
||||
const result = await catalogWith([
|
||||
{ lat: 24, lon: 121, acqDate, acqTime },
|
||||
]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } });
|
||||
assert.equal(result.data.rows[0].acquired, expected);
|
||||
}
|
||||
});
|
||||
@@ -171,6 +171,46 @@ test('bike share reports uncovered areas and failed systems honestly', async ()
|
||||
);
|
||||
});
|
||||
|
||||
test('bike share excludes blank coordinates without losing stations on the prime meridian', async () => {
|
||||
const stations = [
|
||||
{ station_id: 'blank', lat: 51.5, lon: ' ' },
|
||||
{ station_id: 'zero', lat: 51.5, lon: 0 },
|
||||
];
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
bikeshare: {
|
||||
systems: [system('fixture', 'Fixture Bikes', 51.5, 0)],
|
||||
async getStations(url) {
|
||||
return {
|
||||
data: {
|
||||
stations: url.endsWith('station_information.json')
|
||||
? stations
|
||||
: stations.map(({ station_id }) => ({
|
||||
station_id,
|
||||
num_bikes_available: 4,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('get_bike_share', {
|
||||
area: { lat: 51.5, lon: 0, radius_km: 2 },
|
||||
});
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.station_id),
|
||||
['zero'],
|
||||
);
|
||||
assert.equal(result.data.total, 1);
|
||||
assert.equal(result.data.rows[0].lon, 0);
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'1 bike-share station in 2 km around 51.500, 0.000 with 4 bikes available.',
|
||||
);
|
||||
});
|
||||
|
||||
const feed = {
|
||||
id: 'capmetro',
|
||||
name: 'CapMetro',
|
||||
|
||||
@@ -125,7 +125,7 @@ test('fires need an area and report a missing key as unavailable', async () => {
|
||||
result.data.rows.map((row) => row.frp_mw),
|
||||
[40, 12],
|
||||
);
|
||||
assert.equal(result.data.rows[1].acquired, '2026-01-01 0130');
|
||||
assert.equal(result.data.rows[1].acquired, '2026-01-01T01:30:00.000Z');
|
||||
|
||||
const keyless = composeCatalog({
|
||||
tools: coreTools,
|
||||
|
||||
@@ -70,7 +70,6 @@ export class AircraftDisplay {
|
||||
// a screen reader without this. It matters more now that the button ships
|
||||
// ACTIVE from markup (default-on, 2026-08-22): the very first thing assistive
|
||||
// tech reported was an unpressed-looking control over an armed layer.
|
||||
// Mirrors #scope-toggle, which has always carried aria-pressed.
|
||||
this._models3dBtn?.setAttribute(
|
||||
'aria-pressed',
|
||||
String(this._models3dEnabled),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
@@ -271,6 +272,35 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
|
||||
assert.equal(voice.owner.activeStyle, 'snow');
|
||||
});
|
||||
|
||||
test('scope choice and expanded feather survive every HUD layout and both restore paths', async (t) => {
|
||||
const { owner } = styleOwner(t, 'tactical');
|
||||
const previous = { enabled: isScopeMaskEnabled(), feather: getScopeMaskFeather() };
|
||||
t.after(() => { setScopeMaskEnabled(previous.enabled); setScopeMaskFeather(previous.feather); });
|
||||
Object.assign(owner.services, { setScopeMaskEnabled, setScopeMaskFeather });
|
||||
owner._scopeBtn = { checked: true };
|
||||
owner._scopeFeatherSlider = { value: '11', disabled: false };
|
||||
owner._scopeFeatherValue = {};
|
||||
await owner.restoreShareState({ scopeEnabled: false, scopeFeatherPct: 200 });
|
||||
assert.equal(owner._scopeBtn.checked, false);
|
||||
assert.equal(owner._scopeFeatherSlider.disabled, true);
|
||||
assert.equal(owner._scopeFeatherSlider.value, '200');
|
||||
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) {
|
||||
owner._setHudVariant(layout, { applyVisualDefaults: true });
|
||||
assert.equal(isScopeMaskEnabled(), false, layout);
|
||||
assert.equal(getScopeMaskFeather(), 2, layout);
|
||||
assert.equal(owner._scopeBtn.checked, false, layout);
|
||||
}
|
||||
await owner.applyVisualState({ scope: { enabled: true, featherPct: 175 } });
|
||||
assert.equal(isScopeMaskEnabled(), true);
|
||||
assert.equal(getScopeMaskFeather(), 1.75);
|
||||
assert.equal(owner._scopeBtn.checked, true);
|
||||
assert.equal(owner._scopeFeatherSlider.disabled, false);
|
||||
assert.equal(owner._scopeFeatherValue.textContent, '175%');
|
||||
await owner.applyVisualState({ scope: { featherPct: 500 } });
|
||||
assert.equal(getScopeMaskFeather(), 2);
|
||||
assert.equal(owner._scopeFeatherSlider.value, '200');
|
||||
});
|
||||
|
||||
test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => {
|
||||
const { owner, hud } = styleOwner(t, 'tactical');
|
||||
owner.setStyle('noir');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
|
||||
import { createFrameRateMonitor } from './frameRateMonitor.js';
|
||||
import { bindApplicationShortcuts } from './visualInput.js';
|
||||
import { bindDisplayControls } from './displayControls.js';
|
||||
import { bindDisplayControls, syncScopeControls } from './displayControls.js';
|
||||
import { canonicalizeDensity } from '../data/detectionPolicy.js';
|
||||
|
||||
/** Own keyboard/display event subscriptions; settings remain with their state owners. */
|
||||
@@ -92,6 +93,11 @@ export class DisplayBindings {
|
||||
},
|
||||
});
|
||||
|
||||
syncScopeControls(
|
||||
this._scopeBtn,
|
||||
this._scopeFeatherSlider,
|
||||
isScopeMaskEnabled(),
|
||||
);
|
||||
this._displayControls?.destroy();
|
||||
this._displayControls = bindDisplayControls({
|
||||
elements: {
|
||||
@@ -100,7 +106,7 @@ export class DisplayBindings {
|
||||
bloomSlider: this._bloomSlider,
|
||||
sharpenButton: this._sharpenBtn,
|
||||
sharpenSlider: this._sharpenSlider,
|
||||
scopeButton: this._scopeBtn,
|
||||
scopeToggle: this._scopeBtn,
|
||||
scopeFeatherSlider: this._scopeFeatherSlider,
|
||||
hudLayout: this._hudLayoutSelect,
|
||||
hudButton: this._hudBtn,
|
||||
@@ -134,17 +140,15 @@ export class DisplayBindings {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setSharpenEnabled(!this.sharpenEnabled);
|
||||
},
|
||||
toggleScope: () => {
|
||||
setScopeEnabled: (enabled) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
const next = !isScopeMaskEnabled();
|
||||
setScopeMaskEnabled(next);
|
||||
this._scopeBtn.classList.toggle('active', next);
|
||||
this._scopeBtn.setAttribute('aria-pressed', String(next));
|
||||
setScopeMaskEnabled(enabled);
|
||||
syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, enabled);
|
||||
this._syncShareState();
|
||||
},
|
||||
setScopeFeather: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
const pct = Math.max(0, Math.min(100, value || 0));
|
||||
const pct = clampScopeFeatherPct(value);
|
||||
if (this._scopeFeatherValue)
|
||||
this._scopeFeatherValue.textContent = `${pct}%`;
|
||||
setScopeMaskFeather(pct / 100);
|
||||
|
||||
@@ -18,7 +18,6 @@ export function bindDisplayControls({ elements, actions }) {
|
||||
for (const [name, action] of [
|
||||
['bloomButton', 'toggleBloom'],
|
||||
['sharpenButton', 'toggleSharpen'],
|
||||
['scopeButton', 'toggleScope'],
|
||||
['cleanViewButton', 'toggleCleanView'],
|
||||
['cleanViewExitButton', 'exitCleanView'],
|
||||
['celestialButton', 'toggleCelestial'],
|
||||
@@ -34,6 +33,7 @@ export function bindDisplayControls({ elements, actions }) {
|
||||
['scopeFeatherSlider', 'setScopeFeather'],
|
||||
])
|
||||
listen(elements[name], 'input', action, integer);
|
||||
listen(elements.scopeToggle, 'change', 'setScopeEnabled', (el) => el.checked);
|
||||
listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value);
|
||||
listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value);
|
||||
for (const [name, action] of [
|
||||
@@ -72,3 +72,9 @@ export function bindDisplayControls({ elements, actions }) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep the native Scope checkbox and feather availability in step with the mask. */
|
||||
export function syncScopeControls(toggle, featherSlider, enabled) {
|
||||
if (toggle) toggle.checked = enabled;
|
||||
if (featherSlider) featherSlider.disabled = !enabled;
|
||||
}
|
||||
@@ -158,3 +158,29 @@ test('optional controls are absent safely and subscriptions stay instance-owned'
|
||||
assert.deepEqual(calls, ['b']);
|
||||
two.destroy();
|
||||
});
|
||||
|
||||
test('the native Scope checkbox reads checked state on change and disposes its listener', () => {
|
||||
const scopeToggle = element();
|
||||
scopeToggle.checked = true;
|
||||
const scopeFeatherSlider = element('200');
|
||||
const calls = [];
|
||||
const controls = bindDisplayControls({
|
||||
elements: { scopeToggle, scopeFeatherSlider },
|
||||
actions: {
|
||||
setScopeEnabled: (value) => calls.push(['enabled', value]),
|
||||
setScopeFeather: (value) => calls.push(['feather', value]),
|
||||
},
|
||||
});
|
||||
scopeToggle.checked = false;
|
||||
scopeToggle.dispatchEvent(new Event('change'));
|
||||
scopeToggle.checked = true;
|
||||
scopeToggle.dispatchEvent(new Event('change'));
|
||||
scopeFeatherSlider.dispatchEvent(new Event('input'));
|
||||
controls.destroy();
|
||||
scopeToggle.dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(calls, [
|
||||
['enabled', false],
|
||||
['enabled', true],
|
||||
['feather', 200],
|
||||
]);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
|
||||
if (!host || !frameEvent) return { destroy() {} };
|
||||
|
||||
const readout = documentRef.createElement('div');
|
||||
readout.className = 'frame-rate-readout';
|
||||
readout.className = 'frame-rate-readout scene-text-holdout';
|
||||
readout.hidden = true;
|
||||
// i18n-ignore: FPS is a unit code, the same in every language
|
||||
readout.textContent = 'FPS —';
|
||||
|
||||
@@ -478,6 +478,12 @@ body:not(.ui-clean-view):not(.recording-mode):has(
|
||||
bottom: calc(2vh + 5.4rem);
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud[data-variant='minimal']
|
||||
.hud-bottom-left {
|
||||
bottom: calc(2vh + 6.5rem);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
||||
bottom: calc(2vh + 9rem);
|
||||
}
|
||||
|
||||
@@ -501,6 +501,27 @@ html:not([lang='en']) .panel-collapse-btn {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.pp-scope-toggle input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0 0 0 auto;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pp-scope-toggle:has(input:checked) .pp-label {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.pp-scope-toggle:has(input:focus-visible) {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#scope-slider-row:has(input:disabled) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* ── Bloom Slider (inline with toggle) ───────── */
|
||||
.pp-toggle-group {
|
||||
display: flex;
|
||||
|
||||
@@ -371,3 +371,134 @@
|
||||
#intel-hud[data-variant='minimal'] .hud-summary-wrap {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
/* Scope OFF adds text-sized backing; Scope ON retains the original HUD. */
|
||||
.hud-readout {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
.hud-readout,
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber']) .scene-text-holdout {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
box-shadow: none;
|
||||
border-radius: 2px;
|
||||
padding-block: 0;
|
||||
padding-inline: 6px;
|
||||
margin-block: 0;
|
||||
margin-inline: -6px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
box-sizing: content-box;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
.hud-readout {
|
||||
display: block;
|
||||
width: max-content;
|
||||
min-width: 0;
|
||||
box-sizing: content-box;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
:is(.hud-top-right, .hud-bottom-right)
|
||||
.hud-readout,
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
#style-indicator
|
||||
.scene-text-holdout {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
/* Preserve glyph opacity without dimming the vertical backplates. */
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
|
||||
.hud-edge {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
|
||||
.hud-edge
|
||||
.hud-readout
|
||||
> * {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off'] #intel-hud[data-variant='operator'] .hud-corner,
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar) {
|
||||
width: fit-content;
|
||||
max-width: calc(100vw - 120px);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar)
|
||||
.hud-readout {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
#title-bar
|
||||
h1::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.12em -6px;
|
||||
z-index: -1;
|
||||
border-radius: 2px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
:is(#title-bar .subtitle, #style-indicator .indicator-label) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber']) #title-bar .subtitle {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
#style-indicator
|
||||
.indicator-value {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud[data-variant='minimal']
|
||||
.hud-readout:has(> :is(.hud-classification, .hud-system)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
#title-bar
|
||||
.frame-rate-readout {
|
||||
margin-top: 6px;
|
||||
}
|
||||
@@ -106,19 +106,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true" data-i18n-title>
|
||||
<label class="pp-toggle-btn pp-scope-toggle" title="Scope — the circular viewport mask" data-i18n-title>
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
|
||||
<span class="pp-label" data-i18n>Scope</span>
|
||||
</button>
|
||||
<input type="checkbox" id="scope-toggle" checked />
|
||||
</label>
|
||||
<div class="pp-slider-row visible" id="scope-slider-row">
|
||||
<span class="pp-slider-mini-label" data-i18n>Feather</span>
|
||||
<!-- Feather starts at an 11% soft scope-mask edge (owner final lock
|
||||
2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings);
|
||||
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
|
||||
softens the black mask edge; label fading is the separate Fade
|
||||
slider. -->
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="scope-feather-value">11%</span>
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="200" value="100" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="scope-feather-value">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
<div id="title-bar">
|
||||
<div class="title-glow"></div>
|
||||
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span translate="no">GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
|
||||
<p class="subtitle" data-i18n>NO PLACE LEFT BEHIND</p>
|
||||
<p class="subtitle scene-text-holdout" data-i18n>NO PLACE LEFT BEHIND</p>
|
||||
</div>
|
||||
|
||||
<!-- Style indicator -->
|
||||
<div id="style-indicator">
|
||||
<span class="indicator-label" data-i18n>ACTIVE STYLE</span>
|
||||
<span class="indicator-value" id="active-style-name" data-i18n>NORMAL</span>
|
||||
<span class="indicator-label scene-text-holdout" data-i18n>ACTIVE STYLE</span>
|
||||
<span class="indicator-value scene-text-holdout" id="active-style-name" data-i18n>NORMAL</span>
|
||||
</div>
|
||||
|
||||
<!-- Top-center camera and share actions -->
|
||||
|
||||
+13
-1
@@ -159,18 +159,30 @@ export class VisualEffects {
|
||||
this.requestRender('bloom');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sharpen runs only when it is on and its slider is above 0, as the style
|
||||
* stages do: the pass costs the same at any amount (~1.5 ms GPU per frame
|
||||
* on the Arc, 2026-10-24), and `0.1 + value * 2` still sharpened at 0.
|
||||
*/
|
||||
syncSharpenEnabled() {
|
||||
if (this.stopped || !this.sharpenStage) return;
|
||||
this.sharpenStage.enabled =
|
||||
this.sharpenEnabled && this.sharpenIntensity > 0.001;
|
||||
}
|
||||
|
||||
applySharpenIntensity(value) {
|
||||
if (this.stopped) return;
|
||||
this.sharpenIntensity = value;
|
||||
if (this.sharpenStage)
|
||||
this.sharpenStage.uniforms.amount = 0.1 + value * 2.0;
|
||||
this.syncSharpenEnabled();
|
||||
this.requestRender('sharpen');
|
||||
}
|
||||
|
||||
setSharpenEnabled(enabled) {
|
||||
if (this.stopped) return;
|
||||
this.sharpenEnabled = !!enabled;
|
||||
if (this.sharpenStage) this.sharpenStage.enabled = this.sharpenEnabled;
|
||||
this.syncSharpenEnabled();
|
||||
this.requestRender('sharpen');
|
||||
}
|
||||
|
||||
|
||||
@@ -224,6 +224,24 @@ test('sharpen uses the existing uniform scale and keeps values while disabled',
|
||||
f.effects.destroy();
|
||||
});
|
||||
|
||||
test('sharpen at 0 runs no pass, and comes back with the slider', () => {
|
||||
const f = fixture();
|
||||
f.effects.initPostProcess(0.49);
|
||||
f.effects.setSharpenEnabled(true);
|
||||
assert.equal(f.effects.sharpenStage.enabled, true);
|
||||
f.effects.applySharpenIntensity(0);
|
||||
assert.equal(
|
||||
f.effects.sharpenStage.enabled,
|
||||
false,
|
||||
'no residual sharpen at 0',
|
||||
);
|
||||
f.effects.setSharpenEnabled(true);
|
||||
assert.equal(f.effects.sharpenStage.enabled, false, 'on at 0 is still off');
|
||||
f.effects.applySharpenIntensity(0.2);
|
||||
assert.equal(f.effects.sharpenStage.enabled, true);
|
||||
f.effects.destroy();
|
||||
});
|
||||
|
||||
test('stop revokes pending work immediately but retains stages until final destruction', () => {
|
||||
const f = fixture();
|
||||
f.effects.initStyles();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
|
||||
import { syncScopeControls } from './displayControls.js';
|
||||
import { STYLE_STATUS_LABELS } from './visualPresets.js';
|
||||
import { syncSegmentedControl } from './segmentedControl.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
@@ -136,11 +138,10 @@ export class VisualSettings {
|
||||
}
|
||||
if (typeof scopeEnabled === 'boolean') {
|
||||
setScopeMaskEnabled(scopeEnabled);
|
||||
this._scopeBtn?.classList.toggle('active', scopeEnabled);
|
||||
this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled));
|
||||
syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, scopeEnabled);
|
||||
}
|
||||
if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(scopeFeatherPct)));
|
||||
const pct = clampScopeFeatherPct(scopeFeatherPct);
|
||||
this._scopeFeatherSlider.value = String(pct);
|
||||
if (this._scopeFeatherValue)
|
||||
this._scopeFeatherValue.textContent = `${pct}%`;
|
||||
@@ -1302,11 +1303,14 @@ export class VisualSettings {
|
||||
const scopeState = state.scope || {};
|
||||
if (typeof scopeState.enabled === 'boolean') {
|
||||
setScopeMaskEnabled(scopeState.enabled);
|
||||
this._scopeBtn?.classList.toggle('active', scopeState.enabled);
|
||||
this._scopeBtn?.setAttribute('aria-pressed', String(scopeState.enabled));
|
||||
syncScopeControls(
|
||||
this._scopeBtn,
|
||||
this._scopeFeatherSlider,
|
||||
scopeState.enabled,
|
||||
);
|
||||
}
|
||||
if (typeof scopeState.featherPct === 'number' && this._scopeFeatherSlider) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(scopeState.featherPct)));
|
||||
const pct = clampScopeFeatherPct(scopeState.featherPct);
|
||||
this._scopeFeatherSlider.value = String(pct);
|
||||
if (this._scopeFeatherValue)
|
||||
this._scopeFeatherValue.textContent = `${pct}%`;
|
||||
|
||||
Reference in new issue
Block a user