Merge origin/main and sync Traditional Chinese catalog

This commit is contained in:
German Khokhlov committed 2026-10-27 10:25:07 +04:00
commit ba9db7fc41
366 files changed
+26422 -1292

No files matched your search

+2 -2
View File
@@ -13,7 +13,7 @@ import {
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
import { boxContains } from './model.js';
import { t } from '../../i18n/index.js';
import { msg } from '../../i18n/index.js';
import { createAlprFloorResolver } from './floors.js';
import { createAlprOverlay } from './overlay.js';
import {
@@ -452,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) {
layerName: 'ALPR Cameras',
source:
source.attribution?.description || source.label || 'Camera source',
label: t('ALPR camera'),
label: msg('ALPR camera'),
latitude: record.latitude,
longitude: record.longitude,
properties: {
+3 -2
View File
@@ -25,9 +25,10 @@ function presentAwarenessCount(summary = {}) {
}
/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */
function presentAwarenessDistance(meters) {
export function presentAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
if (meters < 1000) return formatQuantity(Math.round(meters), 'm');
const wholeMeters = Math.round(meters);
if (wholeMeters < 1000) return formatQuantity(wholeMeters, 'm');
const digits = meters < 10000 ? 1 : 0;
return formatQuantity(meters / 1000, 'km', {
minimumFractionDigits: digits,
+10
View File
@@ -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), '—');
});
+3 -2
View File
@@ -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}`;
+6 -3
View File
@@ -41,13 +41,16 @@ const ADVISORIES_UNAVAILABLE = msg('Cyclone advisories unavailable');
const SERVER_REASONS = new Set([msg('Cyclone data unavailable')]);
const errorText = (error) =>
error && (SERVER_REASONS.has(error) ? t(error) : translateKnown(error));
// NHC's Tropical Cyclone Status JSON reference: PTC is post-tropical, PC is a
// potential tropical cyclone, and subtropical systems are STS and STD.
const CLASSIFICATION_NAMES = Object.freeze({
PTC: msg('Potential tropical cyclone'),
PC: msg('Potential tropical cyclone'),
PTC: msg('Post-tropical cyclone'),
HU: msg('Hurricane'),
TS: msg('Tropical storm'),
TD: msg('Tropical depression'),
SS: msg('Subtropical storm'),
SD: msg('Subtropical depression'),
STS: msg('Subtropical storm'),
STD: msg('Subtropical depression'),
});
const classificationName = (code) =>
Object.hasOwn(CLASSIFICATION_NAMES, code)
+4 -3
View File
@@ -513,12 +513,13 @@ test('only the latest queued focus runs and an unchanged selection survives refr
});
test('classification display expands known codes without altering the source or guessing unknown meanings', async () => {
for (const [code, label] of [
['PTC', 'Potential tropical cyclone'],
['PC', 'Potential tropical cyclone'],
['PTC', 'Post-tropical cyclone'],
['HU', 'Hurricane'],
['TS', 'Tropical storm'],
['TD', 'Tropical depression'],
['SS', 'Subtropical storm'],
['SD', 'Subtropical depression'],
['STS', 'Subtropical storm'],
['STD', 'Subtropical depression'],
['EX', 'EX'],
['UNKNOWN', 'UNKNOWN'],
]) {
+23 -7
View File
@@ -112,7 +112,8 @@ export const FLIGHT_PROGRESS_MS = 250;
export function routeDistanceText(meters) {
if (!Number.isFinite(meters) || meters < 0) return '';
if (meters < 100) return formatQuantity(Math.round(meters), 'm');
if (meters < 1000) return formatQuantity(Math.round(meters / 10) * 10, 'm');
const tens = Math.round(meters / 10) * 10;
if (tens < 1000) return formatQuantity(tens, 'm');
const km = meters / 1000;
return km < 10
? formatQuantity(km, 'km', {
@@ -130,7 +131,8 @@ export function routeDistanceText(meters) {
*/
export function routeDurationText(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '';
if (seconds < 60) return formatQuantity(Math.round(seconds), 's');
const wholeSeconds = Math.round(seconds);
if (wholeSeconds < 60) return formatQuantity(wholeSeconds, 's');
const minutes = Math.round(seconds / 60);
if (minutes < 60) return formatQuantity(minutes, 'min');
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
@@ -306,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':
@@ -344,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);
@@ -354,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)
+5 -3
View File
@@ -4,6 +4,7 @@ import {
satelliteShortName,
fireDetectionKey,
accentForSeverity,
formatFrpMw,
} from '../../data/firmsLabels.js';
import {
VIEW_PADDING,
@@ -480,14 +481,15 @@ export function createModel({
}
function formatFrp(frp) {
return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1);
return formatFrpMw(frp);
}
/** formatFrp for a card, with the locale's decimal separator ("4,9"). */
function formatFrpLabel(frp) {
const digits = frp >= 10 ? 0 : 1;
return formatNumber(frp, {
const text = formatFrpMw(frp);
const digits = text.includes('.') ? 1 : 0;
return formatNumber(Number(text), {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
useGrouping: false,
+4 -6
View File
@@ -18,9 +18,8 @@ export function createQueries({
if (!fire) return null;
return {
id: fireDetectionKey(fire),
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(fire.frp),
}),
// i18n-ignore: English entity label; the voice card translates it (shownEntityLabel)
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
latitude: fire.lat,
longitude: fire.lon,
frp: fire.frp,
@@ -118,9 +117,8 @@ export function createQueries({
latitude: strongest.lat,
longitude: strongest.lon,
frp: strongest.frp,
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(strongest.frp),
}),
// i18n-ignore: English entity label; the voice card translates it (shownEntityLabel)
label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`,
};
},
+2 -3
View File
@@ -193,9 +193,8 @@ export function createSelection({
layerName: name,
source: 'NASA FIRMS',
dataSource: layerState._dataSource,
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(fire.frp),
}),
// i18n-ignore: English entity label; the voice card translates it (shownEntityLabel)
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
latitude: fire.lat,
longitude: fire.lon,
properties: {
+36 -11
View File
@@ -60,6 +60,10 @@ export function createMotion({
publishFocusTargetFromCachedPosition,
} = services.focus;
// Fixes leave the cache with their history; corrected positions invalidate
// the local frame even when the existing Cartesian is mutated in place.
const fixFrames = new WeakMap();
// Round 5: the old `_warmGroundedAircraftSurfaceCache` (global exact-5-decimal
// warm for every grounded contact on Earth) is GONE. Parked-aircraft GPS
// jitter minted fresh keys every poll → thousands of upstream points per
@@ -124,7 +128,7 @@ export function createMotion({
* @returns {Cesium.Cartesian3|null} Estimated ECEF position, or null if no history exists.
*/
function _deadReckon(icao24, result) {
function _deadReckon(icao24, result, sampledRenderTime) {
const info = flightState.records.data.get(icao24);
if (FOCUS_EVIDENCE_DEV && flightState._focusEvidenceIds.has(icao24)) {
const position = flightState._billboards.get(icao24)?.position;
@@ -149,11 +153,13 @@ export function createMotion({
const out = result || new Cesium.Cartesian3();
// Render one poll interval behind real time so we interpolate between
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
const renderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(),
-RENDER_DELAY_SEC,
flightState._scratchRenderTime,
);
const renderTime =
sampledRenderTime ??
Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(flightState._scratchRenderTime),
-RENDER_DELAY_SEC,
flightState._scratchRenderTime,
);
// Bracketing pair: interpolate — no extrapolation error, no snap-back.
for (let i = history.length - 1; i >= 1; i--) {
@@ -286,12 +292,31 @@ export function createMotion({
0,
flightState._scratchOffset,
);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
fix.position,
Cesium.Ellipsoid.WGS84,
flightState._scratchEnu,
let frame = fixFrames.get(fix);
if (!frame) {
frame = {
position: new Cesium.Cartesian3(),
matrix: new Cesium.Matrix4(),
};
fixFrames.set(fix, frame);
}
if (
!frame.valid ||
!Cesium.Cartesian3.equals(frame.position, fix.position)
) {
Cesium.Transforms.eastNorthUpToFixedFrame(
fix.position,
Cesium.Ellipsoid.WGS84,
frame.matrix,
);
Cesium.Cartesian3.clone(fix.position, frame.position);
frame.valid = true;
}
return Cesium.Matrix4.multiplyByPoint(
frame.matrix,
flightState._scratchOffset,
out,
);
return Cesium.Matrix4.multiplyByPoint(enu, flightState._scratchOffset, out);
}
/**
+184
View File
@@ -0,0 +1,184 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createMotion } from './motion.js';
import { arcOffsetEnu } from '../../data/motionModel.js';
function fixture() {
const flightState = {
records: { data: new Map() },
_positionHistory: new Map(),
_focusEvidenceIds: new Set(),
_scratchArc: {},
_scratchOffset: new Cesium.Cartesian3(),
_scratchRenderTime: new Cesium.JulianDate(),
};
const motion = createMotion({
flightState,
services: { groundFloor: {}, camera: {}, focus: {} },
parts: {},
});
return { flightState, motion };
}
function expectedPosition(fix, dt, turnRate, transform) {
const arc = {};
arcOffsetEnu(fix.velocity, fix.track, turnRate, dt, arc);
return Cesium.Matrix4.multiplyByPoint(
transform(fix.position, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4()),
new Cesium.Cartesian3(arc.east, arc.north, 0),
new Cesium.Cartesian3(),
);
}
function close(actual, expected) {
assert.ok(Cesium.Cartesian3.distance(actual, expected) < 1e-8);
}
test('extrapolation reuses fixed frames and invalidates corrected coordinates', (t) => {
const { motion } = fixture();
const transform = Cesium.Transforms.eastNorthUpToFixedFrame;
const spy = t.mock.method(
Cesium.Transforms,
'eastNorthUpToFixedFrame',
transform,
);
const fix = {
position: Cesium.Cartesian3.fromDegrees(-97, 30, 200),
velocity: 90,
track: 70,
};
const out = new Cesium.Cartesian3();
for (const dt of [-30, 5, 30, 60]) {
motion._extrapolateFix(fix, {}, dt, out, 1.5);
close(out, expectedPosition(fix, dt, 1.5, transform));
}
assert.equal(spy.mock.callCount(), 1);
// Ground reflooring mutates an existing fix's Cartesian in place.
Cesium.Cartesian3.fromDegrees(-97, 30, 350, undefined, fix.position);
motion._extrapolateFix(fix, {}, 30, out, 1.5);
close(out, expectedPosition(fix, 30, 1.5, transform));
assert.equal(spy.mock.callCount(), 2);
fix.position = Cesium.Cartesian3.fromDegrees(140, 80, 1000);
motion._extrapolateFix(fix, {}, 30, out, -2);
close(out, expectedPosition(fix, 30, -2, transform));
assert.equal(spy.mock.callCount(), 3);
fix.velocity = 210;
fix.track = 359;
motion._extrapolateFix(fix, {}, 15, out, -1);
close(out, expectedPosition(fix, 15, -1, transform));
assert.equal(spy.mock.callCount(), 3);
const replacement = { ...fix };
motion._extrapolateFix(replacement, {}, 15, out, -1);
close(out, expectedPosition(replacement, 15, -1, transform));
assert.equal(spy.mock.callCount(), 4);
});
test('stationary fixes do not build a frame until motion needs it', (t) => {
const { motion } = fixture();
const spy = t.mock.method(Cesium.Transforms, 'eastNorthUpToFixedFrame');
const fix = {
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
velocity: 0,
track: 90,
};
const out = new Cesium.Cartesian3();
close(motion._extrapolateFix(fix, {}, 30, out), fix.position);
fix.velocity = 10;
close(motion._extrapolateFix(fix, {}, 0, out), fix.position);
assert.equal(spy.mock.callCount(), 0);
motion._extrapolateFix(fix, {}, 30, out);
assert.equal(spy.mock.callCount(), 1);
});
test('fleet render time is shared without resampling or mutating the supplied instant', (t) => {
const { flightState, motion } = fixture();
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
const end = Cesium.JulianDate.addSeconds(start, 60, new Cesium.JulianDate());
const renderTime = Cesium.JulianDate.addSeconds(
start,
30,
new Cesium.JulianDate(),
);
const savedTime = Cesium.JulianDate.clone(renderTime);
const history = [
{
time: start,
position: Cesium.Cartesian3.fromDegrees(-97, 30, 1000),
track: 90,
},
{
time: end,
position: Cesium.Cartesian3.fromDegrees(-96.99, 30, 1000),
track: 90,
},
];
for (const id of ['one', 'two']) {
flightState.records.data.set(id, { velocity: 100, true_track: 90 });
flightState._positionHistory.set(id, history);
}
t.mock.method(Cesium.JulianDate, 'now', () => {
throw new Error('unexpected per-contact clock read');
});
const expected = Cesium.Cartesian3.lerp(
history[0].position,
history[1].position,
0.5,
new Cesium.Cartesian3(),
);
for (const id of ['one', 'two'])
close(
motion._deadReckon(id, new Cesium.Cartesian3(), renderTime),
expected,
);
assert.ok(Cesium.JulianDate.equals(renderTime, savedTime));
});
test('tracked callers still sample a fresh delayed time without a fleet epoch', (t) => {
const { flightState, motion } = fixture();
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
const history = [
{
time: start,
position: Cesium.Cartesian3.fromDegrees(-97, 30, 1000),
track: 90,
},
{
time: Cesium.JulianDate.addSeconds(start, 60, new Cesium.JulianDate()),
position: Cesium.Cartesian3.fromDegrees(-96.99, 30, 1000),
track: 90,
},
];
flightState.records.data.set('tracked', { velocity: 100, true_track: 90 });
flightState._positionHistory.set('tracked', history);
let elapsed = 60;
const spy = t.mock.method(Cesium.JulianDate, 'now', (result) => {
assert.equal(result, flightState._scratchRenderTime);
return Cesium.JulianDate.addSeconds(start, elapsed, result);
});
const out = new Cesium.Cartesian3();
close(
motion._deadReckon('tracked', out),
Cesium.Cartesian3.lerp(
history[0].position,
history[1].position,
0.5,
new Cesium.Cartesian3(),
),
);
elapsed = 75;
close(
motion._deadReckon('tracked', out),
Cesium.Cartesian3.lerp(
history[0].position,
history[1].position,
0.75,
new Cesium.Cartesian3(),
),
);
assert.equal(spy.mock.callCount(), 2);
});
+15 -4
View File
@@ -452,8 +452,8 @@ export function createQueries({
const isTracked = icao24 === flightState._trackedIcao;
// Keep planes rendered as a 3D model (billboard hidden) so the detection box
// doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden.
const model = flightState._models.get(icao24);
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24);
const model = flightState._models.get(icao24) ?? null;
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24, model);
if (!isTracked && !bb.show && !modelOwnsVisual) continue;
const info = flightState.records.data.get(icao24);
let object = flightState._detectionObjects.get(icao24);
@@ -491,8 +491,19 @@ export function createQueries({
object.position = pos;
object.skipLabel = isTracked;
// Card text only — declutter/cohort identity is `object.sourceId` (icao24).
const id = _contactLabel(icao24, info);
if (object.id !== id) object.id = id;
const callsign = info?.callsign;
const registration = info?.registration;
// Records and enrichment may change in place. Cache by field values,
// while keeping the live visual anchor above independent of text.
if (
object.id === undefined ||
object._labelCallsign !== callsign ||
object._labelRegistration !== registration
) {
object.id = _contactLabel(icao24, info);
object._labelCallsign = callsign;
object._labelRegistration = registration;
}
const altitude = info?.altitude;
if (object._altitude !== altitude) {
object._altitude = altitude;
+149
View File
@@ -0,0 +1,149 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createQueries } from './queries.js';
import { createRendering } from './rendering.js';
function fixture() {
const info = {
callsign: ' TEST123 ',
registration: ' N123 ',
altitude: 1000,
};
const bb = { show: true, position: new Cesium.Cartesian3(1, 2, 3) };
const flightState = {
feed: {},
_billboardCollection: { show: true },
_billboards: new Map([['abc123', bb]]),
_models: new Map(),
_detectionObjects: new Map(),
records: { data: new Map([['abc123', info]]) },
};
let formats = 0;
const services = {
aircraftPresentation: {},
militaryRegistry: {},
camera: {},
focus: {},
recession: {},
labels: {
formatFlightLevel: (altitude) => {
formats++;
return String(altitude);
},
},
};
const parts = {
testing: {},
tracking: { _trackedModelRegimeActive: () => true },
motion: { _trackedVisualCached: () => null },
};
parts.rendering = createRendering({ flightState, services, parts });
const queries = createQueries({ flightState, services, parts }).methods;
return { flightState, info, bb, parts, queries, formats: () => formats };
}
test('candidate text follows in-place enrichment without freezing identity or positions', () => {
const f = fixture();
const [first] = f.queries.getDetectableObjects();
assert.equal(first.id, 'TEST123');
assert.equal(first.sourceId, 'abc123');
f.bb.position = new Cesium.Cartesian3(4, 5, 6);
assert.equal(f.queries.getDetectableObjects()[0], first);
assert.equal(first.position, f.bb.position);
assert.equal(f.formats(), 1);
f.info.callsign = ' NEW456 ';
assert.equal(f.queries.getDetectableObjects()[0].id, 'NEW456');
f.info.callsign = ' ';
assert.equal(f.queries.getDetectableObjects()[0].id, 'N123');
f.info.registration = ' N456 ';
f.info.altitude = 2000;
assert.equal(f.queries.getDetectableObjects()[0].id, 'N456');
assert.equal(first.metric, '2000');
assert.equal(f.formats(), 2);
f.flightState.records.data.set('abc123', { ...f.info });
assert.equal(f.queries.getDetectableObjects()[0], first);
f.flightState.records.data.delete('abc123');
assert.equal(f.queries.getDetectableObjects()[0].id, 'abc123');
assert.equal(first.sourceId, 'abc123');
});
test('unchanged candidate text is normalized once across paints', (t) => {
const f = fixture();
const trim = String.prototype.trim;
let calls = 0;
t.mock.method(String.prototype, 'trim', function () {
if (String(this) === ' TEST123 ') calls++;
return trim.call(this);
});
for (let i = 0; i < 20; i++) f.queries.getDetectableObjects();
assert.equal(calls, 1);
});
test('candidate collection resolves models once and keeps handoffs live', (t) => {
const f = fixture();
const get = t.mock.method(f.flightState._models, 'get');
const [candidate] = f.queries.getDetectableObjects();
assert.equal(get.mock.callCount(), 1);
const model = {
ready: false,
show: true,
modelMatrix: Cesium.Matrix4.fromTranslation(
new Cesium.Cartesian3(10, 20, 30),
),
};
f.parts.rendering._modelSpec = () => ({
visualCenterNative: Cesium.Cartesian3.ZERO,
scale: 1,
});
f.flightState._models.set('abc123', model);
f.bb.show = false;
assert.deepEqual(f.queries.getDetectableObjects(), []);
model.ready = true;
assert.equal(f.queries.getDetectableObjects()[0], candidate);
assert.deepEqual(candidate.position, new Cesium.Cartesian3(10, 20, 30));
Cesium.Matrix4.setTranslation(
model.modelMatrix,
new Cesium.Cartesian3(40, 50, 60),
model.modelMatrix,
);
f.queries.getDetectableObjects();
assert.deepEqual(candidate.position, new Cesium.Cartesian3(40, 50, 60));
model.show = false;
assert.deepEqual(f.queries.getDetectableObjects(), []);
f.bb.show = true;
assert.equal(f.queries.getDetectableObjects()[0].position, f.bb.position);
assert.equal(get.mock.callCount(), 6);
model.show = true;
assert.equal(f.parts.rendering._modelOwnsVisual('abc123'), true);
assert.equal(get.mock.callCount(), 7);
});
test('tracked ownership and live anchors remain independent of fleet models', () => {
const f = fixture();
f.flightState._trackedIcao = 'abc123';
f.flightState._trackedModel = { ready: true, show: true };
f.parts.rendering._modelSpec = () => ({});
let anchor = new Cesium.Cartesian3(10, 20, 30);
f.parts.motion._trackedVisualCached = () => anchor;
const [candidate] = f.queries.getDetectableObjects();
assert.equal(candidate.position, anchor);
assert.equal(candidate.skipLabel, true);
assert.equal(f.parts.rendering._modelOwnsVisual('abc123', null), true);
anchor = new Cesium.Cartesian3(40, 50, 60);
assert.equal(f.queries.getDetectableObjects()[0].position, anchor);
for (const tracked of [
null,
{ ready: false, show: true },
{ ready: true, show: false },
]) {
f.flightState._trackedModel = tracked;
assert.equal(
f.parts.rendering._modelOwnsVisual('abc123', { ready: true, show: true }),
false,
);
}
f.flightState._trackedModel = { ready: true, show: true };
f.parts.tracking._trackedModelRegimeActive = () => false;
assert.equal(f.parts.rendering._modelOwnsVisual('abc123', null), false);
});
+20 -3
View File
@@ -32,10 +32,12 @@ import {
TRACKED_MODEL_MIN_PX,
TRACKED_MODEL_MAX_PX,
FLEET_DR_INTERVAL_MS,
RENDER_DELAY_SEC,
COURSE_SLEW_DT_MAX_SEC,
ROTATION_REFRESH_MS,
COURSE_MAX_DPS,
} from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
export function createRendering({
flightState,
@@ -382,17 +384,20 @@ export function createRendering({
* null or still-loading tracked model from claiming a visual it is not
* drawing yet.
* @param {string} icao24
* @param {Cesium.Model|null} [model] Resolved fleet model; null means absent.
* @returns {boolean}
*/
function _modelOwnsVisual(icao24) {
function _modelOwnsVisual(icao24, model) {
if (icao24 === flightState._trackedIcao) {
return (
parts.tracking._trackedModelRegimeActive() &&
_modelIsRendering(flightState._trackedModel)
);
}
return _modelIsRendering(flightState._models.get(icao24));
return _modelIsRendering(
model === undefined ? flightState._models.get(icao24) : model,
);
}
/** A model is the visual only once it actually draws — loaded AND shown.
@@ -503,6 +508,7 @@ export function createRendering({
model = await Cesium.Model.fromGltfAsync({
url: resolveAsset(spec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: MODEL_MIN_PX,
scale: spec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
@@ -709,6 +715,7 @@ export function createRendering({
Cesium.Model.fromGltfAsync({
url: resolveAsset(trackedSpec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: TRACKED_MODEL_MIN_PX,
scale: trackedSpec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN,
@@ -854,6 +861,12 @@ export function createRendering({
)
: 0.08;
flightState._lastFleetTickMs = nowMs;
// Every contact in this pass shares the same delayed display instant.
const renderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(flightState._scratchFleetRenderTime),
-RENDER_DELAY_SEC,
flightState._scratchFleetRenderTime,
);
_drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload
if (flightState._cockpitContactMode)
@@ -957,7 +970,11 @@ export function createRendering({
const info = flightState.records.data.get(icao24);
const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos);
const dr = parts.motion._deadReckon(
icao24,
flightState._scratchFleetPos,
renderTime,
);
// The dead-reckoned point drifts away from the fix whose cell supplied the
// height, so a grounded contact's sprite ends up under the mesh it taxied
// (or coasted) over. Re-floor at the DISPLAYED coordinate — read-only, and
+2 -2
View File
@@ -242,12 +242,12 @@ export function createFlightState({ source, services }) {
flightState._scratchCarto = new Cesium.Cartographic();
flightState._scratchEnu = new Cesium.Matrix4();
flightState._scratchArc = { east: 0, north: 0, endCourseDeg: 0 };
flightState._scratchRenderTime = new Cesium.JulianDate();
flightState._scratchFleetRenderTime = new Cesium.JulianDate();
flightState._scratchFleetPos = new Cesium.Cartesian3();
flightState._scratchSonarScreen = new Cesium.Cartesian2();
+4 -2
View File
@@ -47,7 +47,7 @@ export function createTracking({
const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry;
const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout;
const { applyTrackedCameraFrame } = services.camera;
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
const flightsLayer = layer;
function _emitAwarenessEvent(type, detail) {
@@ -1174,7 +1174,9 @@ export function createTracking({
// first-person reference. A globe click must not fall through to the
// normal empty-space deselection path; cockpit has explicit exit controls.
if (document.body.classList.contains('cockpit-mode')) return;
const picked = viewer.scene.pick(click.position);
const picked = pickContact
? pickContact(viewer.scene, click.position)
: viewer.scene.pick(click.position);
if (picked) {
// Clicking the tracked entity itself — ignore (don't deselect)
+245
View File
@@ -0,0 +1,245 @@
// S2 Underground GhostMaps catalog: which KMZ in the public repository is the
// current edition of each product. Pure and portable (the server builds the
// catalog, tests pin the rules); no i18n, no DOM, no Cesium.
export const GHOSTMAPS_REPO = Object.freeze({
owner: 's2underground',
name: 'GhostMaps',
branch: 'main',
url: 'https://github.com/s2underground/GhostMaps',
});
/** Largest KMZ the proxy downloads; matches the browser's import ceiling. */
export const GHOSTMAPS_MAX_FILE_BYTES = 50 * 1024 * 1024;
const ARCGIS = 'ArcGIS Data for ATAK (KMZs)/';
const INCIDENTS = 'Incident Response Products for ATAK (KMZs and Documents)/';
/**
* Curated products, in panel order. Each names a series of dated uploads; the
* newest edition wins. Patterns match whole repository paths, so a renamed
* folder fails closed (the product drops out) instead of matching elsewhere.
*/
export const GHOSTMAPS_PRODUCTS = Object.freeze([
Object.freeze({
id: 'cip',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Common Intelligence Picture',
defaultOn: true,
pattern: new RegExp(
`^${escape(ARCGIS)}Common Intelligence Picture/Master Database/[^/]+\\.kmz$`,
'i',
),
}),
Object.freeze({
id: 'kinetic-30d',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Kinetic Activity (30 days)',
defaultOn: true,
pattern: new RegExp(
`^${escape(ARCGIS)}Common Intelligence Picture/30-Day Pulls/Kinetic[^/]*\\.kmz$`,
'i',
),
}),
Object.freeze({
id: 'border-crisis',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Border Crisis Map',
defaultOn: false,
pattern: new RegExp(
`^${escape(ARCGIS)}Border Crisis Map/S2Underground_Border_Crisis_Map[^/]*\\.kmz$`,
'i',
),
}),
Object.freeze({
id: 'iran-cip',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Iran Common Intelligence Picture',
defaultOn: false,
pattern: new RegExp(
`^${escape(INCIDENTS)}Iran[^/]*/Iran Common Intelligence Picture/[^/]+\\.kmz$`,
'i',
),
}),
Object.freeze({
id: 'iran-kinetic',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Iran Kinetic Events',
defaultOn: false,
pattern: new RegExp(
`^${escape(INCIDENTS)}Iran[^/]*/Iran Kinetic Events/[^/]+\\.kmz$`,
'i',
),
}),
Object.freeze({
id: 'yemen-flot',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Yemen FLOT',
defaultOn: false,
pattern: new RegExp(
`^${escape(INCIDENTS)}Yemen[^/]*/Yemen FLOT[^/]*\\.kmz$`,
'i',
),
}),
]);
/** The newest incident product outside the curated series and the archive. */
export const GHOSTMAPS_LATEST_DROP = Object.freeze({
id: 'latest-drop',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Latest incident drop',
defaultOn: false,
});
const LATEST_DROP_EXCLUDED =
/\/(?:0_Archive|Basedata|Reference Material)(?:\/|$)/i;
function escape(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const MONTHS = [
'jan',
'feb',
'mar',
'apr',
'may',
'jun',
'jul',
'aug',
'sep',
'oct',
'nov',
'dec',
];
const DATE_IN_NAME =
/(january|february|march|april|may|june|july|august|september|sept|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|oct|nov|dec)[ _-]?(\d{1,2})(?:st|nd|rd|th)?[ ,_-]+(20\d\d)/i;
const EDITION_IN_NAME = /(?:[ _-]export)?[ _-]v?(\d{1,3})$/i;
/**
* The date and edition written into an S2 file name, e.g.
* `..._October_16_2026_Export_1.kmz` → 2026-10-16, edition 1. Undated names
* report `dateMs: null` and sort below every dated edition.
* @param {string} fileName
* @returns {{dateMs: ?number, edition: number}}
*/
export function parseGhostMapsEdition(fileName) {
const stem = String(fileName || '').replace(/\.km[lz]$/i, '');
const match = DATE_IN_NAME.exec(stem);
let dateMs = null;
if (match) {
const month = MONTHS.indexOf(match[1].slice(0, 3).toLowerCase());
const day = Number(match[2]);
const year = Number(match[3]);
const at = Date.UTC(year, month, day);
// Date.UTC rolls 31 Sep into 1 Oct; only an exact calendar date counts.
if (month >= 0 && new Date(at).getUTCDate() === day) dateMs = at;
}
const tail = match ? stem.slice(match.index + match[0].length) : stem;
const edition = Number(EDITION_IN_NAME.exec(tail)?.[1] ?? 0);
return { dateMs, edition };
}
function newer(a, b) {
if (!b) return true;
const ad = a.dateMs ?? -Infinity;
const bd = b.dateMs ?? -Infinity;
if (ad !== bd) return ad > bd;
if (a.edition !== b.edition) return a.edition > b.edition;
return a.path > b.path;
}
/** `S2Underground_Border_Crisis_Map_June_02_2026_v1.kmz` → readable words. */
export function ghostMapsFileTitle(fileName) {
return String(fileName || '')
.replace(/\.km[lz]$/i, '')
.replace(/^S2Underground_/i, '')
.replace(/_/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Build the product catalog from a recursive git tree listing.
* @param {Array<{path: string, type: string, sha: string, size?: number}>} tree
* @returns {Array<{id: string, title: string, defaultOn: boolean,
* path: string, fileName: string, fileTitle: string, version: string,
* size: number, dateMs: ?number, edition: number}>}
*/
export function buildGhostMapsCatalog(tree) {
const best = new Map();
let drop = null;
for (const entry of Array.isArray(tree) ? tree : []) {
if (entry?.type !== 'blob' || typeof entry.path !== 'string') continue;
if (!/\.kmz$/i.test(entry.path)) continue;
if (!/^[0-9a-f]{40}$/.test(entry.sha || '')) continue;
const size = Number(entry.size);
if (!Number.isFinite(size) || size <= 0 || size > GHOSTMAPS_MAX_FILE_BYTES)
continue;
const fileName = entry.path.slice(entry.path.lastIndexOf('/') + 1);
const candidate = {
path: entry.path,
fileName,
version: entry.sha,
size,
...parseGhostMapsEdition(fileName),
};
const product = GHOSTMAPS_PRODUCTS.find(({ pattern }) =>
pattern.test(entry.path),
);
if (product) {
if (newer(candidate, best.get(product.id)))
best.set(product.id, candidate);
} else if (
entry.path.startsWith(INCIDENTS) &&
!LATEST_DROP_EXCLUDED.test(`/${entry.path}`) &&
candidate.dateMs != null &&
newer(candidate, drop)
) {
drop = candidate;
}
}
const rows = [];
for (const product of [...GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP]) {
const file =
product === GHOSTMAPS_LATEST_DROP ? drop : best.get(product.id);
if (!file) continue;
rows.push({
id: product.id,
title: product.title,
defaultOn: product.defaultOn,
...file,
fileTitle: ghostMapsFileTitle(file.fileName),
});
}
return rows;
}
/**
* Raw download URL pinned to a commit, so a CDN can never hand back an older
* edition for a newer catalog.
* @param {string} commit 40-hex commit sha.
* @param {string} path Repository path.
*/
export function ghostMapsRawUrl(commit, path) {
const encoded = path.split('/').map(encodeURIComponent).join('/');
return `https://raw.githubusercontent.com/${GHOSTMAPS_REPO.owner}/${GHOSTMAPS_REPO.name}/${commit}/${encoded}`;
}
/**
* The branch head from a git smart-HTTP ref advertisement
* (`/info/refs?service=git-upload-pack`), or null.
* @param {string} text
* @param {string} [branch]
* @returns {?string}
*/
export function parseGitRefAdvertisement(text, branch = GHOSTMAPS_REPO.branch) {
const ref = `refs/heads/${branch}`;
for (const line of String(text || '').split('\n')) {
// pkt-line: 4 hex length digits, then "<sha> <ref>" (the first ref line
// also carries a NUL and the capability list).
const match = /^[0-9a-f]{4}([0-9a-f]{40}) ([^\0\s]+)/.exec(line);
if (match && match[2] === ref) return match[1];
}
return null;
}
+165
View File
@@ -0,0 +1,165 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildGhostMapsCatalog,
ghostMapsFileTitle,
ghostMapsRawUrl,
parseGhostMapsEdition,
parseGitRefAdvertisement,
} from './catalog.js';
const ARCGIS = 'ArcGIS Data for ATAK (KMZs)/';
const INCIDENTS = 'Incident Response Products for ATAK (KMZs and Documents)/';
let n = 0;
const blob = (path, size = 1000) => ({
path,
type: 'blob',
size,
sha: (++n).toString(16).padStart(40, '0'),
});
test('editions read the date and export/version number written in the name', () => {
assert.deepEqual(
parseGhostMapsEdition(
'S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz',
),
{ dateMs: Date.UTC(2026, 9, 16), edition: 1 },
);
assert.deepEqual(
parseGhostMapsEdition(
'S2Underground_Border_Crisis_Map_September_21_2026_Export_v2.kmz',
),
{ dateMs: Date.UTC(2026, 8, 21), edition: 2 },
);
assert.deepEqual(
parseGhostMapsEdition('Reno Wildfires August 24, 2026.kmz'),
{
dateMs: Date.UTC(2026, 7, 24),
edition: 0,
},
);
assert.deepEqual(
parseGhostMapsEdition('Venezuelan_Crisis_Sep_5_2025_v2.kmz'),
{
dateMs: Date.UTC(2025, 8, 5),
edition: 2,
},
);
assert.deepEqual(
parseGhostMapsEdition('Kinetic_Activities_Last_30_Days.kmz'),
{
dateMs: null,
edition: 0,
},
);
// An impossible date is not a date.
assert.equal(parseGhostMapsEdition('X_September_31_2026.kmz').dateMs, null);
});
test('each product keeps its newest edition by date, then edition number', () => {
const master = `${ARCGIS}Common Intelligence Picture/Master Database/`;
const tree = [
blob(
`${master}S2Underground_Common_Intelligence_Picture_October_9_2026_Export_1.kmz`,
),
blob(
`${master}S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz`,
),
blob(
`${master}S2Underground_Common_Intelligence_Picture_October_1_2026_Export_1.kmz`,
),
blob(
`${master}S2Underground_Common_Intelligence_Picture_September_30_2026_Export_2.kmz`,
),
blob(
`${ARCGIS}Common Intelligence Picture/30-Day Pulls/Kinetic_Activities_Last_30_Days.kmz`,
),
blob(
`${ARCGIS}Border Crisis Map/S2Underground_Border_Crisis_Map_September_21_2026_Export_v1.kmz`,
),
blob(
`${ARCGIS}Border Crisis Map/S2Underground_Border_Crisis_Map_September_21_2026_Export_v2.kmz`,
),
// A European-only subset in a subfolder is not the border map.
blob(
`${ARCGIS}Border Crisis Map/European Layers Only/Europe_Migration_Crisis_June_02_2026_v1.kmz`,
),
blob(`${INCIDENTS}Yemen 2026/Yemen FLOT.kmz`),
blob(`${INCIDENTS}Yemen 2026/Yemen FLOT_October_10_2026.kmz`),
{ path: `${INCIDENTS}Yemen 2026`, type: 'tree', sha: 'f'.repeat(40) },
];
const rows = buildGhostMapsCatalog(tree);
const byId = Object.fromEntries(rows.map((row) => [row.id, row]));
assert.deepEqual(
rows.map(({ id }) => id),
['cip', 'kinetic-30d', 'border-crisis', 'yemen-flot'],
);
assert.match(byId.cip.fileName, /October_16_2026/);
assert.equal(byId.cip.defaultOn, true);
assert.equal(byId['kinetic-30d'].defaultOn, true);
assert.match(byId['border-crisis'].fileName, /Export_v2/);
assert.equal(byId['yemen-flot'].fileName, 'Yemen FLOT_October_10_2026.kmz');
assert.equal(byId.cip.version, tree[1].sha);
assert.equal(
byId.cip.fileTitle,
'Common Intelligence Picture October 16 2026 Export 1',
);
});
test('latest drop is the newest dated incident file outside curated, archived and base data', () => {
const rows = buildGhostMapsCatalog([
blob(`${INCIDENTS}Reno Wildfires August 24, 2026.kmz`),
blob(
`${INCIDENTS}Spokane Fire Disaster - August, 2026/SpokaneDisasterAid_August_8_2026.kmz`,
),
blob(
`${INCIDENTS}0_Archive/Texas Floods/TexasFloods_December_1_2026_v1.kmz`,
),
blob(`${INCIDENTS}Iran 2026/Basedata/Pipelines_December_1_2026.kmz`),
blob(
`${INCIDENTS}Iran 2026/Iran Kinetic Events/Iran_Kinetic_Events_December_2_2026_v1.kmz`,
),
blob(`${INCIDENTS}Undated Product.kmz`),
]);
const drop = rows.find(({ id }) => id === 'latest-drop');
assert.equal(drop.fileName, 'Reno Wildfires August 24, 2026.kmz');
assert.ok(rows.some(({ id }) => id === 'iran-kinetic'));
});
test('oversized, empty, non-KMZ and malformed entries are skipped', () => {
const path = `${ARCGIS}Common Intelligence Picture/Master Database/S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz`;
assert.deepEqual(
buildGhostMapsCatalog([
{ ...blob(path), size: 51 * 1024 * 1024 },
{ ...blob(path), size: 0 },
{ ...blob(path), sha: 'not-a-sha' },
blob(path.replace(/\.kmz$/, '.zip')),
null,
]),
[],
);
assert.deepEqual(buildGhostMapsCatalog(undefined), []);
});
test('raw URLs are pinned to the commit and encode every path segment', () => {
assert.equal(
ghostMapsRawUrl('a'.repeat(40), 'Folder (KMZs)/Yemen FLOT.kmz'),
`https://raw.githubusercontent.com/s2underground/GhostMaps/${'a'.repeat(40)}/Folder%20(KMZs)/Yemen%20FLOT.kmz`,
);
assert.equal(ghostMapsFileTitle('S2Underground_A__B.kmz'), 'A B');
});
test('the branch head comes from the smart-HTTP ref advertisement', () => {
const head = '142d7630bc8f1f2c4b96e16ee865ca50d1ab578d';
const other = 'b'.repeat(40);
const text = [
'001e# service=git-upload-pack',
`0159${head} HEAD\0multi_ack symref=HEAD:refs/heads/main`,
`003f${other} refs/heads/main-old`,
`003d${head} refs/heads/main`,
'0000',
].join('\n');
assert.equal(parseGitRefAdvertisement(text), head);
assert.equal(parseGitRefAdvertisement(text, 'dev'), null);
assert.equal(parseGitRefAdvertisement(''), null);
});
+711
View File
@@ -0,0 +1,711 @@
import * as Cesium from 'cesium';
import {
decodeXml,
kmzImageResolver,
kmzMainDocument,
parseXml,
readZipEntries,
resolveKmzPath,
} from '../../data/geoFileImport.js';
import {
formatNumber,
formatUtcDayMonth,
msg,
t,
tn,
} from '../../i18n/index.js';
import { createHorizonCull } from '../streetLevel/view.js';
import { readGhostMapsKml } from './kml.js';
import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js';
export * from './catalog.js';
export * from './records.js';
export { readGhostMapsKml } from './kml.js';
export { createGhostMapsSource } from './source.js';
export const GHOSTMAPS_LAYER_ID = 's2-ghostmaps';
export const GHOSTMAPS_OVERLAY_SOURCE_ID = 's2-ghostmaps';
const PICK_PREFIX = 'ghostmaps:';
const STORAGE_KEY = 'gev.ghostmaps.products';
const ACCENT = '#c8ff00';
const MAX_ICON_BYTES = 512 * 1024;
const CARD_HOST_OPTIONS = Object.freeze({
cohortLimit: 1,
collisionCapacity: 1,
moving: false,
});
const MAX_CARD_LINES = 12;
/** Panel names for the catalog's product ids (the catalog sends English). */
const PRODUCT_LABELS = Object.freeze({
cip: msg('Intel Picture'),
'kinetic-30d': msg('Kinetic 30d'),
'border-crisis': msg('Border Crisis'),
'iran-cip': msg('Iran Picture'),
'iran-kinetic': msg('Iran Kinetic'),
'yemen-flot': msg('Yemen FLOT'),
'latest-drop': msg('Latest Drop'),
});
/** One distinct fallback marker color per product. */
const PRODUCT_COLORS = Object.freeze({
cip: '#c8ff00',
'kinetic-30d': '#ff5a36',
'border-crisis': '#ffb547',
'iran-cip': '#39d0ff',
'iran-kinetic': '#ff3b8d',
'yemen-flot': '#5dff9f',
'latest-drop': '#b48cff',
});
function defaultStorage() {
try {
return globalThis.localStorage ?? null;
} catch {
return null;
}
}
function readStoredSelection(storage) {
try {
const value = JSON.parse(storage?.getItem(STORAGE_KEY) ?? 'null');
return Array.isArray(value) ? new Set(value.map(String)) : null;
} catch {
return null;
}
}
function writeStoredSelection(storage, selected) {
try {
storage?.setItem(STORAGE_KEY, JSON.stringify([...selected]));
} catch {
/* private window or blocked storage: the selection lasts this session */
}
}
/** A round marker for placemarks whose icon is missing or remote. */
const markerCache = new Map();
function markerImage(color) {
if (markerCache.has(color)) return markerCache.get(color);
let url = null;
try {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 20;
const ctx = canvas.getContext('2d');
ctx.beginPath();
ctx.arc(10, 10, 7, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(0,0,0,0.85)';
ctx.stroke();
url = canvas.toDataURL();
} catch {
url = null;
}
markerCache.set(color, url);
return url;
}
const cesiumColor = (rgba, fallback, alpha) =>
rgba
? new Cesium.Color(rgba[0], rgba[1], rgba[2], alpha ?? rgba[3])
: Cesium.Color.fromCssColorString(fallback).withAlpha(alpha ?? 1);
function anchorOf(geometry) {
if (geometry.type === 'point') return geometry.position;
const ring =
geometry.type === 'polygon' ? geometry.outer : geometry.positions;
let lon = 0;
let lat = 0;
for (const [x, y] of ring) {
lon += x;
lat += y;
}
return [lon / ring.length, lat / ring.length];
}
/**
* S2 Underground GhostMaps: the current editions of S2's public KMZ intel
* products, kept in step with the GitHub repository. The proxy watches the
* branch head and pushes each new commit; only changed products reload.
*/
export function createGhostMapsLayer({
source,
overlayHost = null,
screenSpaceEventHandlerFactory = null,
picking = null,
pointer = null,
openExternal = null,
storage = defaultStorage(),
} = {}) {
if (typeof source?.getCatalog !== 'function')
throw new TypeError('GhostMaps layer requires a catalog source');
let _viewer = null;
let _enabled = false;
let _catalog = null;
let _lastError = null;
let _unsubscribe = null;
let _stream = 'closed';
let _queue = Promise.resolve();
let _clickHandler = null;
let _rowListener = null;
let _selectedKey = null;
let _selectedCardId = null;
let _selectedLink = null;
let _lifetime = new AbortController();
let _selection = readStoredSelection(storage);
/** productId → {version, dataSource, urls, count, features} */
const _loaded = new Map();
/** productId → in-flight version */
const _loading = new Map();
/** productId → error text */
const _failed = new Map();
const notifyRow = () => _rowListener?.();
const render = () => _viewer?.scene?.requestRender?.();
// Pins skip the depth test, and with Google 3D tiles the globe is hidden,
// so nothing else stops far-side pins drawing through the planet.
const _horizon = createHorizonCull({
getViewer: () => _viewer,
items: function* () {
for (const item of _loaded.values()) yield* item.pins;
},
onChange: render,
});
const canSelect = () =>
overlayHost && screenSpaceEventHandlerFactory && picking;
function wanted(product) {
return _selection ? _selection.has(product.id) : product.defaultOn;
}
function selectedIds() {
return (_catalog?.products || []).filter(wanted).map(({ id }) => id);
}
function unload(id) {
const item = _loaded.get(id);
if (!item) return;
_loaded.delete(id);
_viewer?.dataSources?.remove(item.dataSource, true);
item.urls.forEach((url) => URL.revokeObjectURL(url));
if (_selectedKey?.startsWith(`${id}:`)) clearSelection();
}
/** Build one product's data source off-scene, then swap it in. */
async function load(product, signal) {
const bytes = await source.getFile(product.id, product.version, { signal });
signal.throwIfAborted();
const entries = await readZipEntries(bytes);
signal.throwIfAborted();
const main = kmzMainDocument(entries);
if (!main) throw new Error(msg('That KMZ holds no KML document.'));
const urls = [];
const resolvePacked = kmzImageResolver(entries, main, (url) =>
urls.push(url),
);
// Icons only: ArcGIS exports also pack multi-megabyte photo attachments,
// which fall back to the product marker instead of being decoded.
const resolveImage = (href) => {
const path = resolveKmzPath(href, main, entries);
return path && entries.get(path).length <= MAX_ICON_BYTES
? resolvePacked(href)
: null;
};
try {
const parsed = readGhostMapsKml(
parseXml(decodeXml(entries.get(main)), 'KML'),
resolveImage,
);
entries.clear();
signal.throwIfAborted();
const color = PRODUCT_COLORS[product.id] || ACCENT;
const dataSource = new Cesium.CustomDataSource(
`${GHOSTMAPS_LAYER_ID}:${product.id}`,
);
const features = [];
const pins = [];
dataSource.entities.suspendEvents();
parsed.features.forEach((feature, index) => {
const key = `${product.id}:${index}`;
const { style } = feature;
feature.geometries.forEach((geometry, part) => {
const id = `${PICK_PREFIX}${key}:${part}`;
if (geometry.type === 'point') {
const position = Cesium.Cartesian3.fromDegrees(
...geometry.position,
);
const entity = dataSource.entities.add({
id,
position,
billboard: {
image: style.icon || markerImage(color),
scale: Math.min(
1.5,
Math.max(0.35, (style.iconScale ?? 1) * 0.7),
),
color:
style.icon && style.iconColor
? cesiumColor(style.iconColor)
: undefined,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
// Ground-clamped icons would sink into terrain and 3D tiles; the
// horizon cull hides the far side instead (see `_horizon`).
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
pins.push({
position,
get show() {
return entity.show;
},
set show(value) {
entity.show = value;
},
});
} else if (geometry.type === 'line') {
dataSource.entities.add({
id,
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray(
geometry.positions.flat(),
),
width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)),
material: cesiumColor(style.lineColor, color),
clampToGround: true,
},
});
} else {
const ring = (points) =>
Cesium.Cartesian3.fromDegreesArray(points.flat());
dataSource.entities.add({
id,
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
ring(geometry.outer),
geometry.holes.map(
(hole) => new Cesium.PolygonHierarchy(ring(hole)),
),
),
material:
style.fill === false
? Cesium.Color.TRANSPARENT
: cesiumColor(
style.polyColor,
color,
style.polyColor ? undefined : 0.25,
),
classificationType: Cesium.ClassificationType.BOTH,
},
});
if (style.outline !== false)
dataSource.entities.add({
id: `${id}:outline`,
polyline: {
positions: ring([...geometry.outer, geometry.outer[0]]),
width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)),
material: cesiumColor(style.lineColor, color),
clampToGround: true,
},
});
}
});
features.push({
name: feature.name,
folder: feature.folder,
description: feature.description,
anchor: anchorOf(feature.geometries[0]),
});
});
dataSource.entities.resumeEvents();
return { version: product.version, dataSource, urls, features, pins };
} catch (error) {
urls.forEach((url) => URL.revokeObjectURL(url));
throw error;
}
}
/** Bring the globe in line with the catalog and the chip selection. */
function sync() {
const run = _queue.then(async () => {
if (!_enabled || !_viewer || !_catalog) return;
const signal = _lifetime.signal;
const products = new Map(_catalog.products.map((p) => [p.id, p]));
for (const id of [..._loaded.keys()]) {
const product = products.get(id);
if (!product || !wanted(product)) unload(id);
}
for (const product of _catalog.products) {
if (!wanted(product) || signal.aborted) continue;
if (_loaded.get(product.id)?.version === product.version) continue;
_loading.set(product.id, product.version);
_failed.delete(product.id);
notifyRow();
try {
const next = await load(product, signal);
if (signal.aborted || !_enabled) {
next.urls.forEach((url) => URL.revokeObjectURL(url));
return;
}
// Cull before the first frame so far-side pins never flash.
_horizon.update(next.pins);
await _viewer.dataSources.add(next.dataSource);
// The new edition is on the globe before the old one leaves.
unload(product.id);
_loaded.set(product.id, next);
} catch (error) {
if (signal.aborted) return;
console.warn(`[Data:GhostMaps] ${product.id}:`, error);
_failed.set(
product.id,
error?.template ||
error?.message ||
msg('GhostMaps file could not be read'),
);
} finally {
_loading.delete(product.id);
notifyRow();
render();
}
}
});
_queue = run.catch(() => {});
return run;
}
function applyCatalog(catalog) {
_catalog = catalog;
_lastError = catalog.error || null;
notifyRow();
return sync();
}
function toggleProduct(id) {
// The first change pins the whole current selection, defaults included.
if (!_selection) _selection = new Set(selectedIds());
if (_selection.has(id)) _selection.delete(id);
else _selection.add(id);
writeStoredSelection(storage, _selection);
notifyRow();
void sync();
}
// ---- selection card ----------------------------------------------------
function featureFor(key) {
const [productId, index] = key.split(':');
return {
product: _catalog?.products.find((p) => p.id === productId) || null,
feature: _loaded.get(productId)?.features[Number(index)] || null,
};
}
function publishCard() {
if (!canSelect()) return;
const { product, feature } = _selectedKey ? featureFor(_selectedKey) : {};
if (!feature) {
_selectedKey = null;
_selectedCardId = null;
_selectedLink = null;
overlayHost.setEntries(
GHOSTMAPS_OVERLAY_SOURCE_ID,
[],
CARD_HOST_OPTIONS,
);
return;
}
const { fields, links } = parseDescriptionFields(feature.description);
_selectedLink = links[0] || null;
const heading = [t(PRODUCT_LABELS[product?.id] || product?.title || '')];
if (feature.folder) heading.push(fieldLabel(feature.folder));
if (product?.dateMs) heading.push(formatUtcDayMonth(product.dateMs));
const details = [heading.filter(Boolean).join(' · ')];
for (const { label, value } of fields) {
if (details.length >= MAX_CARD_LINES) break;
// ArcGIS repeats the feature type as the first row; the title has it.
if (value === feature.name) continue;
const line = label ? `${fieldLabel(label)}: ${value}` : value;
details.push(
...wrapCardText(line, 60, Math.min(4, MAX_CARD_LINES - details.length)),
);
}
if (_selectedLink && openExternal)
details.push(
tn(
links.length,
'{count} source link ↗ · click card to open',
'{count} source links ↗ · click card to open the first',
{ count: formatNumber(links.length) },
),
);
const title = t('S2 · {name}', {
name: fieldLabel(feature.name || feature.folder) || t('Report'),
});
const card = {
id: `ghostmaps-card:${_selectedKey}`,
selected: true,
interactive: Boolean(_selectedLink && openExternal),
title,
details,
accent: PRODUCT_COLORS[product?.id] || ACCENT,
priority: Number.MAX_SAFE_INTEGER,
gapPx: 15,
verticalOnly: true,
placement: 'above',
// A click card comes and goes with the click, as CCTV cards do, rather
// than holding the label arbiter's 2.5 s minimum lifetime; and it hides
// when its marker turns behind the planet.
stateless: true,
horizonCull: true,
position: Cesium.Cartesian3.fromDegrees(...feature.anchor),
};
if (card.interactive) {
const link = _selectedLink;
card.accessibilityLabel = t('Open the source for {title}', { title });
card.activate = () => {
openExternal(link);
return true;
};
}
_selectedCardId = card.id;
overlayHost.setEntries(
GHOSTMAPS_OVERLAY_SOURCE_ID,
[card],
CARD_HOST_OPTIONS,
);
overlayHost.setVisible?.(GHOSTMAPS_OVERLAY_SOURCE_ID, true);
}
function clearSelection() {
_selectedKey = null;
_selectedCardId = null;
_selectedLink = null;
// An empty publish, as the perimeter cards deselect: it releases the
// selected card's paint lane, which clearing the source alone left drawn.
overlayHost?.setEntries(GHOSTMAPS_OVERLAY_SOURCE_ID, [], CARD_HOST_OPTIONS);
}
function pickedKey(picked) {
const pickId = picking.resolvePickId(picked);
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
return null;
const [productId, index] = pickId.slice(PICK_PREFIX.length).split(':');
return _loaded.get(productId)?.features[Number(index)]
? `${productId}:${index}`
: null;
}
function installClickHandler() {
if (!canSelect() || _clickHandler || !_viewer) return;
picking.registerPickOwner?.(
GHOSTMAPS_LAYER_ID,
(pickedId) =>
typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX),
);
_clickHandler = screenSpaceEventHandlerFactory(_viewer);
_clickHandler.setInputAction((click) => {
if (pointer && !pointer.isPointerFree()) return;
const cardHit = overlayHost.hitTest?.(
click.position?.x,
click.position?.y,
{ sourceId: GHOSTMAPS_OVERLAY_SOURCE_ID },
);
if (cardHit && cardHit.entryId === _selectedCardId) {
if (_selectedLink && openExternal) openExternal(_selectedLink);
return;
}
const picked = _viewer.scene.pick(click.position);
const key = picked ? pickedKey(picked) : null;
if (key) {
_selectedKey = key;
publishCard();
render();
return;
}
if (picked) {
const pickId = picking.resolvePickId(picked);
if (pickId && picking.isOwnedByOtherLayer(GHOSTMAPS_LAYER_ID, pickId))
return;
}
if (_selectedKey) {
clearSelection();
render();
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function removeClickHandler() {
_clickHandler?.destroy();
_clickHandler = null;
picking?.unregisterPickOwner?.(GHOSTMAPS_LAYER_ID);
}
function featureCount() {
let count = 0;
for (const item of _loaded.values()) count += item.features.length;
return count;
}
const layer = {
id: GHOSTMAPS_LAYER_ID,
name: 'S2 GhostMaps',
icon: '👻',
source: 'S2 Underground',
// Pushes arrive within seconds over the event stream; this interval is
// the backstop if the stream is down.
updateInterval: 300000,
init(viewer) {
if (_viewer) throw new Error('GhostMaps layer is already initialized');
_viewer = viewer;
},
enable() {
_enabled = true;
_lifetime = new AbortController();
for (const item of _loaded.values()) item.dataSource.show = true;
installClickHandler();
if (!_unsubscribe && typeof source.subscribe === 'function')
_unsubscribe = source.subscribe(
(catalog) => {
if (_enabled) void applyCatalog(catalog);
},
(state) => {
if (_stream === state) return;
_stream = state;
notifyRow();
},
);
},
disable() {
_enabled = false;
_lifetime.abort();
_unsubscribe?.();
_unsubscribe = null;
_stream = 'closed';
removeClickHandler();
clearSelection();
_horizon.stop();
for (const id of [..._loaded.keys()]) unload(id);
render();
},
async update(_viewerArg, { signal } = {}) {
if (!_enabled || signal?.aborted) return false;
try {
const catalog = await source.getCatalog({ signal });
if (!_enabled) return false;
await applyCatalog(catalog);
return true;
} catch (error) {
if (signal?.aborted || !_enabled) return false;
console.warn('[Data:GhostMaps] catalog error:', error);
_lastError = error?.message || msg('GhostMaps catalog unavailable');
notifyRow();
// Keep the layer on; the event stream or the next interval recovers.
return true;
}
},
destroy() {
layer.disable();
_viewer = null;
_catalog = null;
_rowListener = null;
},
setRowControlsListener(listener) {
_rowListener = listener;
},
getRowControls() {
const products = _catalog?.products || [];
const commit = _catalog?.commit;
const info = commit
? _stream === 'open'
? t('LIVE · GitHub {commit} · {message} · checked every {seconds}s', {
commit: commit.slice(0, 7),
message: _catalog.message || '—',
seconds: formatNumber(
Math.round((_catalog.pollMs || 15000) / 1000),
),
})
: t('GitHub {commit} · {message} · reconnecting for live updates', {
commit: commit.slice(0, 7),
message: _catalog.message || '—',
})
: t('Connecting to the S2 GhostMaps repository…');
return {
chips: products.map((product) => {
const loaded = _loaded.get(product.id);
const failed = _failed.get(product.id);
return {
id: product.id,
label: t(PRODUCT_LABELS[product.id] || product.title),
active: wanted(product),
busy: _loading.has(product.id),
state: failed ? 'error' : undefined,
color: PRODUCT_COLORS[product.id],
title: [
product.fileTitle,
loaded
? tn(
loaded.features.length,
'{count} placemark',
'{count} placemarks',
{
count: formatNumber(loaded.features.length),
},
)
: null,
failed ? t(failed) : null,
]
.filter(Boolean)
.join(' · '),
onClick: () => toggleProduct(product.id),
};
}),
legend: [],
info,
infoTitle: t(
'S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.',
),
};
},
getStats() {
return {
count: featureCount(),
products: [..._loaded.keys()],
commit: _catalog?.commit || null,
lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null,
checkedAt: _catalog?.checkedAt || null,
stream: _stream,
loading: _loading.size > 0,
error: _lastError || [..._failed.values()][0] || null,
fallback: false,
};
},
/** Loaded placemarks as plain records, for tools and QA. */
getAnalystRecords(maxCount = Infinity) {
if (!_enabled) return [];
const out = [];
for (const [productId, item] of _loaded) {
for (const feature of item.features) {
if (out.length >= maxCount) return out;
out.push({
product: productId,
name: feature.name,
folder: feature.folder,
lon: feature.anchor[0],
lat: feature.anchor[1],
});
}
}
return out;
},
};
return layer;
}
+198
View File
@@ -0,0 +1,198 @@
// Read the drawable content of a parsed GhostMaps KML document: placemarks
// with their folder, geometry, resolved style and raw description. Network
// links, overlays, models and remote icons are never followed; an icon is
// either an image packed in the same KMZ or the layer's own marker.
import { kmlColor } from './records.js';
export const MAX_GHOSTMAPS_FEATURES = 20_000;
export const MAX_GHOSTMAPS_POSITIONS = 250_000;
const childrenNamed = (el, name) =>
[...(el?.children || [])].filter((child) => child.localName === name);
const firstChild = (el, name) =>
[...(el?.children || [])].find((child) => child.localName === name) || null;
const descendants = (el, name) =>
[...(el?.getElementsByTagName?.('*') || [])].filter(
(child) => child.localName === name,
);
const text = (el) => (el?.textContent ?? '').trim();
function number(el, fallback) {
const value = Number(text(el));
return el && Number.isFinite(value) ? value : fallback;
}
/** `lon,lat[,alt] lon,lat ...` → [[lon, lat], ...]; invalid tuples are dropped. */
export function parseKmlCoordinates(value) {
const out = [];
for (const tuple of String(value ?? '')
.trim()
.split(/\s+/)) {
const [lon, lat] = tuple.split(',').map(Number);
if (
Number.isFinite(lon) &&
Number.isFinite(lat) &&
Math.abs(lat) <= 90 &&
Math.abs(lon) <= 180
)
out.push([lon, lat]);
}
return out;
}
function readStyle(styleEl, resolveImage) {
const style = {};
const icon = firstChild(styleEl, 'IconStyle');
if (icon) {
const href = text(firstChild(firstChild(icon, 'Icon'), 'href'));
style.icon = href ? resolveImage(href) : null;
style.iconScale = number(firstChild(icon, 'scale'), 1);
style.iconColor = kmlColor(text(firstChild(icon, 'color')));
}
const line = firstChild(styleEl, 'LineStyle');
if (line) {
style.lineColor = kmlColor(text(firstChild(line, 'color')));
style.lineWidth = number(firstChild(line, 'width'), 1);
}
const poly = firstChild(styleEl, 'PolyStyle');
if (poly) {
style.polyColor = kmlColor(text(firstChild(poly, 'color')));
style.fill = text(firstChild(poly, 'fill')) !== '0';
style.outline = text(firstChild(poly, 'outline')) !== '0';
}
return style;
}
/** Shared styles by id; a StyleMap resolves to its `normal` pair. */
function collectStyles(doc, resolveImage) {
const styles = new Map();
for (const el of descendants(doc, 'Style')) {
const id = el.getAttribute('id');
if (id) styles.set(id, readStyle(el, resolveImage));
}
const maps = new Map();
for (const el of descendants(doc, 'StyleMap')) {
const id = el.getAttribute('id');
if (!id) continue;
const normal = childrenNamed(el, 'Pair').find(
(pair) => text(firstChild(pair, 'key')) === 'normal',
);
if (!normal) continue;
const inline = firstChild(normal, 'Style');
maps.set(
id,
inline
? readStyle(inline, resolveImage)
: text(firstChild(normal, 'styleUrl')),
);
}
const resolve = (url, depth = 0) => {
const id = String(url || '').replace(/^#/, '');
if (!id || url.includes('/') || depth > 2) return {};
if (styles.has(id)) return styles.get(id);
const mapped = maps.get(id);
if (typeof mapped === 'string') return resolve(mapped, depth + 1);
return mapped || {};
};
return resolve;
}
function readGeometry(el, out, budget) {
switch (el.localName) {
case 'Point': {
const [position] = parseKmlCoordinates(
text(firstChild(el, 'coordinates')),
);
if (position) {
budget.positions += 1;
out.push({ type: 'point', position });
}
break;
}
case 'LineString':
case 'LinearRing': {
const positions = parseKmlCoordinates(
text(firstChild(el, 'coordinates')),
);
if (positions.length >= 2) {
budget.positions += positions.length;
out.push({ type: 'line', positions });
}
break;
}
case 'Polygon': {
const ring = (boundary) =>
parseKmlCoordinates(
text(firstChild(firstChild(boundary, 'LinearRing'), 'coordinates')),
);
const outer = ring(firstChild(el, 'outerBoundaryIs'));
if (outer.length >= 3) {
const holes = childrenNamed(el, 'innerBoundaryIs')
.map(ring)
.filter((hole) => hole.length >= 3);
budget.positions +=
outer.length + holes.reduce((n, hole) => n + hole.length, 0);
out.push({ type: 'polygon', outer, holes });
}
break;
}
case 'MultiGeometry':
for (const child of el.children) readGeometry(child, out, budget);
break;
default:
// Models, tracks and anything newer are not drawn.
break;
}
}
/** Name of the nearest enclosing Folder (or Document), for grouping. */
function folderName(el) {
for (let node = el.parentElement; node; node = node.parentElement) {
if (node.localName === 'Folder' || node.localName === 'Document') {
const name = text(firstChild(node, 'name'));
if (name) return name;
}
}
return '';
}
/**
* @param {Document} doc Parsed KML.
* @param {(href: string) => ?string} resolveImage URL for a KMZ-packed image.
* @returns {{features: Array<{name: string, folder: string,
* description: string, style: object, geometries: Array<object>}>,
* positions: number, over: boolean}}
*/
export function readGhostMapsKml(doc, resolveImage = () => null) {
const styleFor = collectStyles(doc, resolveImage);
const features = [];
const budget = { positions: 0 };
let over = false;
for (const placemark of descendants(doc, 'Placemark')) {
// A hidden placemark (visibility 0) is drawn hidden by Google Earth too.
if (text(firstChild(placemark, 'visibility')) === '0') continue;
const geometries = [];
for (const child of placemark.children)
readGeometry(child, geometries, budget);
if (!geometries.length) continue;
if (
features.length >= MAX_GHOSTMAPS_FEATURES ||
budget.positions > MAX_GHOSTMAPS_POSITIONS
) {
over = true;
break;
}
const inline = firstChild(placemark, 'Style');
features.push({
name: text(firstChild(placemark, 'name')).slice(0, 200),
folder: folderName(placemark).slice(0, 120),
description: text(firstChild(placemark, 'description')),
style: {
...styleFor(text(firstChild(placemark, 'styleUrl'))),
...(inline ? readStyle(inline, resolveImage) : {}),
},
geometries,
});
}
return { features, positions: budget.positions, over };
}
+180
View File
@@ -0,0 +1,180 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createGhostMapsLayer } from './index.js';
function element(localName, children = [], value = '', attributes = {}) {
const node = {
localName,
children,
get textContent() {
return value + children.map((child) => child.textContent).join('');
},
getAttribute: (key) => attributes[key] ?? null,
getElementsByTagName(name) {
return children.flatMap((child) => [
...(name === '*' || name === child.localName ? [child] : []),
...child.getElementsByTagName(name),
]);
},
};
for (const child of children) child.parentElement = node;
return node;
}
function documentFixture() {
return element('document', [
element('kml', [
element('Document', [
element(
'Style',
[
element('IconStyle', [
element('Icon', [element('href', [], 'icon.png')]),
]),
],
'',
{ id: 'pin' },
),
element('Placemark', [
element('name', [], 'Test report'),
element('styleUrl', [], '#pin'),
element('Point', [element('coordinates', [], '12,34')]),
]),
]),
]),
]);
}
// A stored KMZ with a local icon; the XML DOM double supplies its one point.
function fixtureKmz() {
const files = [
['doc.kml', '<kml/>'],
['icon.png', 'packed icon'],
];
const local = [];
const central = [];
let offset = 0;
for (const [name, value] of files) {
const key = Buffer.from(name);
const bytes = Buffer.from(value);
const header = Buffer.alloc(30);
header.writeUInt32LE(0x04034b50);
header.writeUInt32LE(bytes.length, 18);
header.writeUInt32LE(bytes.length, 22);
header.writeUInt16LE(key.length, 26);
const directory = Buffer.alloc(46);
directory.writeUInt32LE(0x02014b50);
directory.writeUInt32LE(bytes.length, 20);
directory.writeUInt32LE(bytes.length, 24);
directory.writeUInt16LE(key.length, 28);
directory.writeUInt32LE(offset, 42);
local.push(header, key, bytes);
central.push(directory, key);
offset += header.length + key.length + bytes.length;
}
const directory = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50);
end.writeUInt16LE(files.length, 8);
end.writeUInt16LE(files.length, 10);
end.writeUInt32LE(directory.length, 12);
end.writeUInt32LE(offset, 16);
return new Uint8Array(Buffer.concat([...local, directory, end]));
}
function fixture(t, { beforeFileReturns, onCreateUrl } = {}) {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser');
globalThis.DOMParser = class {
parseFromString() {
return documentFixture();
}
};
t.after(() => {
if (previous) Object.defineProperty(globalThis, 'DOMParser', previous);
else delete globalThis.DOMParser;
});
const created = [];
const revoked = [];
t.mock.method(URL, 'createObjectURL', () => {
const url = `blob:local-${created.length}`;
created.push(url);
onCreateUrl?.(layer);
return url;
});
t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url));
t.mock.method(console, 'warn', () => {});
const mounted = new Set();
const viewer = {
scene: { requestRender() {} },
dataSources: {
async add(dataSource) {
mounted.add(dataSource);
return dataSource;
},
remove(dataSource) {
return mounted.delete(dataSource);
},
},
};
const layer = createGhostMapsLayer({
storage: null,
source: {
getCatalog: async () => ({
products: [{ id: 'cip', version: 'a'.repeat(40), defaultOn: true }],
}),
getFile: async () => {
beforeFileReturns?.(layer);
return fixtureKmz();
},
},
});
layer.init(viewer);
layer.enable();
t.after(() => layer.destroy());
return { layer, viewer, mounted, created, revoked };
}
test('cancelling a file read does not allocate packed-image URLs', async (t) => {
const { layer, viewer, mounted, created, revoked } = fixture(t, {
beforeFileReturns: (layer) => queueMicrotask(() => layer.disable()),
});
await layer.update(viewer);
assert.equal(created.length, 0);
assert.equal(revoked.length, 0);
assert.equal(mounted.size, 0);
});
test('cancellation during parsing releases every packed-image URL', async (t) => {
const { layer, viewer, mounted, created, revoked } = fixture(t, {
onCreateUrl: (layer) => layer.disable(),
});
await layer.update(viewer);
assert.equal(created.length, 1);
assert.deepEqual(revoked, created);
assert.equal(mounted.size, 0);
});
test('entity construction failure releases parsed image URLs', async (t) => {
const { layer, viewer, mounted, created, revoked } = fixture(t);
t.mock.method(Cesium.EntityCollection.prototype, 'add', () => {
throw new Error('fixture entity failure');
});
await layer.update(viewer);
assert.equal(created.length, 1);
assert.deepEqual(revoked, created);
assert.equal(mounted.size, 0);
assert.equal(layer.getStats().error, 'fixture entity failure');
});
test('successful load keeps icons until the product is disabled', async (t) => {
const { layer, viewer, mounted, created, revoked } = fixture(t);
await layer.update(viewer);
assert.equal(created.length, 1);
assert.equal(revoked.length, 0);
assert.equal(mounted.size, 1);
assert.equal(layer.getStats().count, 1);
layer.disable();
assert.deepEqual(revoked, created);
assert.equal(mounted.size, 0);
});
+165
View File
@@ -0,0 +1,165 @@
// Pure helpers for GhostMaps placemarks: description tables to plain fields,
// KML colors, and card text wrapping. Strings in, data out — description
// markup is only ever read as text, never inserted into the page.
const ENTITIES = Object.freeze({
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
});
/** Decode the HTML character references ArcGIS and Google Earth emit. */
export function decodeHtmlEntities(text) {
return String(text ?? '').replace(
/&(#x[0-9a-f]{1,6}|#\d{1,7}|[a-z]{2,8});/gi,
(whole, ref) => {
if (ref[0] === '#') {
const code =
ref[1] === 'x' || ref[1] === 'X'
? parseInt(ref.slice(2), 16)
: parseInt(ref.slice(1), 10);
return code > 0 &&
code <= 0x10ffff &&
!(code >= 0xd800 && code <= 0xdfff)
? String.fromCodePoint(code)
: '';
}
return ENTITIES[ref.toLowerCase()] ?? whole;
},
);
}
/** Markup to one line of plain text. */
export function htmlToText(html) {
return decodeHtmlEntities(
String(html ?? '')
.replace(/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi, ' ')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<[^>]*>/g, ' '),
)
.replace(/\s+/g, ' ')
.trim();
}
const EMPTY_VALUES = new Set(['', '<null>', 'null', 'n/a', '-', '<blank>']);
// ArcGIS writes geometry type and internal ids into every popup table.
const SKIPPED_LABELS = new Set(['shape', 'objectid', 'fid', 'globalid']);
const MAX_FIELDS = 24;
const MAX_VALUE = 600;
const MAX_LINKS = 6;
/**
* Fields and links from a placemark description. ArcGIS "popup" exports are a
* two-column table (label, value); anything else becomes one unlabelled field.
* @param {string} html Description as written in the KML (CDATA text).
* @returns {{fields: Array<{label: string, value: string}>, links: string[]}}
*/
export function parseDescriptionFields(html) {
const source = String(html ?? '').replace(
/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi,
' ',
);
const fields = [];
const links = [];
const addLinks = (markup) => {
for (const match of markup.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
const url = decodeHtmlEntities(match[1]).trim();
if (/^https?:\/\/[^\s]+$/i.test(url) && !links.includes(url))
links.push(url);
}
};
// Innermost rows only: ArcGIS nests the field table inside an outer row,
// and only leaf rows with exactly two cells are label/value pairs.
for (const row of source.matchAll(
/<tr\b[^>]*>((?:(?!<tr\b)[\s\S])*?)<\/tr\s*>/gi,
)) {
const cells = [
...row[1].matchAll(/<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]\s*>/gi),
].map((cell) => cell[1]);
if (cells.length !== 2) continue;
const label = htmlToText(cells[0]);
const value = htmlToText(cells[1]);
if (!label || SKIPPED_LABELS.has(label.toLowerCase())) continue;
if (EMPTY_VALUES.has(value.toLowerCase())) continue;
addLinks(cells[1]);
// A bare research link is carried by `links`; the card does not need
// the URL twice.
if (/^https?:\/\/\S+$/i.test(value)) continue;
if (fields.length < MAX_FIELDS)
fields.push({ label, value: value.slice(0, MAX_VALUE) });
}
if (!fields.length && !links.length) {
const text = htmlToText(source);
if (text) fields.push({ label: '', value: text.slice(0, MAX_VALUE) });
addLinks(source);
}
return { fields, links: links.slice(0, MAX_LINKS) };
}
/**
* KML `aabbggrr` to `[r, g, b, a]` in 0..1, or null.
* @param {string} text
* @returns {?number[]}
*/
export function kmlColor(text) {
const hex = String(text ?? '')
.trim()
.replace(/^#/, '');
if (!/^[0-9a-f]{8}$/i.test(hex)) return null;
const byte = (at) => parseInt(hex.slice(at, at + 2), 16) / 255;
return [byte(6), byte(4), byte(2), byte(0)];
}
/**
* Greedy word wrap for canvas card lines; overlong words are cut.
* @param {string} text
* @param {number} width Characters per line.
* @param {number} maxLines Lines kept; the last ends with an ellipsis when cut.
* @returns {string[]}
*/
export function wrapCardText(text, width = 56, maxLines = 4) {
const lines = [];
let line = '';
let cut = false;
for (let word of String(text ?? '')
.split(/\s+/)
.filter(Boolean)) {
while (word.length > width) {
if (line) {
lines.push(line);
line = '';
}
lines.push(word.slice(0, width));
word = word.slice(width);
}
if (!line) line = word;
else if (line.length + 1 + word.length <= width) line += ` ${word}`;
else {
lines.push(line);
line = word;
}
if (lines.length >= maxLines) {
cut = true;
break;
}
}
if (line && lines.length < maxLines) lines.push(line);
else if (line) cut = true;
const kept = lines.slice(0, maxLines);
if (cut && kept.length) {
const last = kept[kept.length - 1];
kept[kept.length - 1] = `${last.slice(0, width - 1)}…`;
}
return kept;
}
/** Field labels ArcGIS writes CamelCase; show them as words. */
export function fieldLabel(label) {
return String(label ?? '')
.replace(/([a-z])([A-Z0-9])/g, '$1 $2')
.replace(/_/g, ' ')
.trim();
}
+106
View File
@@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
decodeHtmlEntities,
fieldLabel,
htmlToText,
kmlColor,
parseDescriptionFields,
wrapCardText,
} from './records.js';
// The shape ArcGIS Pro writes into every S2 placemark description.
const ARCGIS = `<html xmlns:fo="http://www.w3.org/1999/XSL/Format"><head>
<META http-equiv="Content-Type" content="text/html"></head>
<body style="margin:0px"><table style="font-family:Arial">
<tr style="text-align:center;font-weight:bold;background:#9CBCE2"><td>HistoricProtestSite</td></tr>
<tr><td><table style="font-family:Arial">
<tr><td>SHAPE</td><td>Point</td></tr>
<tr bgcolor="#D4E4F3"><td>InstallationType</td><td>HistoricProtestSite</td></tr>
<tr><td>OperationalStatus</td><td>ACTIVE</td></tr>
<tr bgcolor="#D4E4F3"><td>Affiliation</td><td>&lt;Null&gt;</td></tr>
<tr><td>Description</td><td>Locals gather here &amp; protest &#8212; weekly.</td></tr>
<tr><td>Research1</td><td><a target="_blank" href="https://x.com/a/status/1">https://x.com/a/status/1</a></td></tr>
<tr><td>Research2</td><td><a href="javascript:alert(1)">bad</a></td></tr>
<tr><td>Research3</td><td>&lt;Null&gt;</td></tr>
</table></td></tr></table></body><script type="text/javascript">
function changeImage(a, b) { document.getElementById('x').innerHTML = b; }
</script></html>`;
test('ArcGIS popup tables become plain label/value fields and http links', () => {
const { fields, links } = parseDescriptionFields(ARCGIS);
assert.deepEqual(fields, [
{ label: 'InstallationType', value: 'HistoricProtestSite' },
{ label: 'OperationalStatus', value: 'ACTIVE' },
{ label: 'Description', value: 'Locals gather here & protest — weekly.' },
{ label: 'Research2', value: 'bad' },
]);
// javascript: and other schemes never become links.
assert.deepEqual(links, ['https://x.com/a/status/1']);
});
test('free-form descriptions become one unlabelled field; scripts are dropped', () => {
assert.deepEqual(
parseDescriptionFields(
'<p>Front line<br>as of 10 Oct</p><script>steal()</script><a href="https://s2.example/x">more</a>',
),
{
fields: [{ label: '', value: 'Front line as of 10 Oct more' }],
links: ['https://s2.example/x'],
},
);
assert.deepEqual(parseDescriptionFields(''), { fields: [], links: [] });
assert.deepEqual(parseDescriptionFields(undefined), {
fields: [],
links: [],
});
});
test('field values and counts are bounded', () => {
const rows = Array.from(
{ length: 40 },
(_, i) => `<tr><td>K${i}</td><td>${'x'.repeat(900)}</td></tr>`,
).join('');
const { fields } = parseDescriptionFields(`<table>${rows}</table>`);
assert.equal(fields.length, 24);
assert.equal(fields[0].value.length, 600);
});
test('entities and tags decode to text', () => {
assert.equal(
decodeHtmlEntities('&lt;b&gt; &#x41;&#66; &nbsp;&bogus;'),
'<b> AB &bogus;',
);
assert.equal(decodeHtmlEntities('&#xD800;&#0;'), '');
assert.equal(htmlToText('<style>a{}</style><b>One</b>\n two'), 'One two');
});
test('KML aabbggrr colors convert to rgba', () => {
assert.deepEqual(kmlColor('ff0000ff'), [1, 0, 0, 1]);
assert.deepEqual(kmlColor('8000ff00'), [0, 1, 0, 128 / 255]);
assert.equal(kmlColor('red'), null);
assert.equal(kmlColor(''), null);
});
test('card text wraps on words, cuts long words and marks truncation', () => {
assert.deepEqual(wrapCardText('one two three four', 9, 4), [
'one two',
'three',
'four',
]);
assert.deepEqual(wrapCardText('abcdefghijkl', 5, 4), [
'abcde',
'fghij',
'kl',
]);
assert.deepEqual(wrapCardText('aa bb cc dd ee', 5, 2), ['aa bb', 'cc d…']);
assert.deepEqual(wrapCardText('', 5, 2), []);
for (const line of wrapCardText('word '.repeat(200), 20, 3))
assert.ok(line.length <= 20);
});
test('CamelCase field labels read as words', () => {
assert.equal(fieldLabel('OperationalStatus'), 'Operational Status');
assert.equal(fieldLabel('Research1'), 'Research 1');
assert.equal(fieldLabel('Kinetic_Activity'), 'Kinetic Activity');
});
+81
View File
@@ -0,0 +1,81 @@
import {
readResponseBytesCapped,
readResponseJsonCapped,
} from '../../sources/httpBody.js';
import { GHOSTMAPS_MAX_FILE_BYTES } from './catalog.js';
// A portable export (package.json): no i18n here. Failures stay English data;
// the Layers panel shows them translated.
const msg = (text) => text;
/**
* Same-origin GhostMaps source: the catalog, one product's KMZ bytes, and a
* push subscription that fires whenever the watcher sees a new commit.
*/
export function createGhostMapsSource({
fetchImpl = (...args) => globalThis.fetch(...args),
EventSourceImpl = globalThis.EventSource,
} = {}) {
return {
async getCatalog({ signal } = {}) {
const response = await fetchImpl('/api/ghostmaps/catalog', { signal });
if (!response.ok)
throw new Error(
`GhostMaps catalog unavailable (HTTP ${response.status})`,
);
const catalog = await readResponseJsonCapped(
response,
1024 * 1024,
signal,
);
if (!Array.isArray(catalog?.products))
throw new Error(msg('Malformed GhostMaps catalog'));
return catalog;
},
async getFile(id, version, { signal } = {}) {
const response = await fetchImpl(
`/api/ghostmaps/file/${encodeURIComponent(id)}?v=${encodeURIComponent(version)}`,
{ signal },
);
if (!response.ok)
throw new Error(`GhostMaps file unavailable (HTTP ${response.status})`);
return readResponseBytesCapped(
response,
GHOSTMAPS_MAX_FILE_BYTES,
signal,
);
},
/**
* Call `onCatalog(catalog)` for every catalog the server pushes, and
* `onState('open' | 'connecting' | 'closed')` as the stream changes. The
* browser's EventSource reconnects by itself. Returns an unsubscribe.
*/
subscribe(onCatalog, onState = () => {}) {
if (typeof EventSourceImpl !== 'function') {
onState('closed');
return () => {};
}
const events = new EventSourceImpl('/api/ghostmaps/events');
const states = ['connecting', 'open', 'closed'];
const report = () => onState(states[events.readyState] ?? 'connecting');
events.onopen = report;
events.onerror = report;
const listener = (event) => {
try {
const catalog = JSON.parse(event.data);
if (Array.isArray(catalog?.products)) onCatalog(catalog);
} catch {
/* ignore a malformed frame; the next one replaces it */
}
};
events.addEventListener('catalog', listener);
report();
return () => {
events.removeEventListener('catalog', listener);
events.close();
};
},
};
}
+232
View File
@@ -0,0 +1,232 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Event } from 'cesium';
import { createLaunchHover } from './lifecycle.js';
function fakeTimers() {
const timers = new Map();
let nextId = 1;
const api = {
setTimer(fn, ms) {
const id = nextId++;
timers.set(id, { fn, ms });
return id;
},
clearTimer(id) {
timers.delete(id);
},
pending: () => [...timers.values()],
flush() {
const due = [...timers.entries()];
timers.clear();
for (const [, timer] of due) timer.fn();
},
};
return api;
}
function hoverRig({ overMarker }) {
let at = 0,
picks = 0,
enabled = true,
free = true;
const timers = fakeTimers();
const viewer = {
camera: { moveStart: new Event(), moveEnd: new Event() },
scene: {
canvas: { style: { cursor: '' } },
drillPick(position) {
picks++;
return overMarker(position) ? [{ id: 'launch' }] : [];
},
},
};
const hover = createLaunchHover({
viewer,
enabled: () => enabled,
entityLaunchId: (id) => id === 'launch',
now: () => at,
documentRef: { hidden: false },
pointerFree: () => free,
setTimer: timers.setTimer,
clearTimer: timers.clearTimer,
});
return {
hover,
timers,
viewer,
cursor: () => viewer.scene.canvas.style.cursor,
picks: () => picks,
setTime: (value) => (at = value),
setEnabled: (value) => (enabled = value),
setFree: (value) => (free = value),
};
}
const onMarker = (position) => position?.x === 1;
const marker = () => ({ endPosition: { x: 1, y: 1 } });
const empty = () => ({ endPosition: { x: 9, y: 9 } });
test('mission hover runs one trailing check for a pointer that stops on empty space', () => {
const rig = hoverRig({ overMarker: onMarker });
rig.hover.move(marker());
assert.equal(rig.cursor(), 'pointer');
rig.setTime(50);
const movement = empty();
rig.hover.move(movement);
// Cesium reuses its movement object; the trailing check must use a copy.
movement.endPosition.x = 1;
assert.equal(rig.picks(), 1);
assert.deepEqual(
rig.timers.pending().map((timer) => timer.ms),
[70],
);
rig.setTime(80);
rig.hover.move(empty());
assert.equal(rig.timers.pending().length, 1, 'one trailing check at a time');
rig.setTime(120);
rig.timers.flush();
assert.equal(rig.picks(), 2);
assert.equal(rig.cursor(), '');
assert.equal(rig.timers.pending().length, 0);
rig.hover.destroy();
});
test('mission hover trailing check sets the cursor for a pointer that stops on a mission', () => {
const rig = hoverRig({ overMarker: onMarker });
rig.hover.move(empty());
assert.equal(rig.cursor(), '');
rig.setTime(50);
rig.hover.move(marker());
rig.setTime(120);
rig.timers.flush();
assert.equal(rig.picks(), 2);
assert.equal(rig.cursor(), 'pointer');
rig.hover.destroy();
assert.equal(rig.cursor(), '');
});
test('mission hover trailing check is cancelled by reset, destroy, and camera motion', () => {
for (const cancel of [
(rig) => rig.hover.reset(),
(rig) => rig.hover.destroy(),
(rig) => rig.viewer.camera.moveStart.raiseEvent(),
]) {
const rig = hoverRig({ overMarker: onMarker });
rig.hover.move(empty());
rig.setTime(50);
rig.hover.move(marker());
assert.equal(rig.timers.pending().length, 1);
cancel(rig);
assert.equal(rig.timers.pending().length, 0);
assert.equal(rig.picks(), 1);
assert.equal(rig.cursor(), '');
rig.hover.destroy();
}
});
test('mission hover trailing check rechecks eligibility before picking', () => {
for (const block of [
(rig) => rig.setEnabled(false),
(rig) => rig.setFree(false),
]) {
const rig = hoverRig({ overMarker: onMarker });
rig.hover.move(marker());
rig.setTime(50);
rig.hover.move(marker());
block(rig);
rig.setTime(120);
rig.timers.flush();
assert.equal(rig.picks(), 1);
assert.equal(rig.cursor(), '');
rig.hover.destroy();
}
});
test('mission hover throttles, clears on motion/hidden, and releases listeners', () => {
let at = 0,
picks = 0,
enabled = true;
const listeners = new Set();
const doc = {
hidden: false,
addEventListener: (_n, fn) => listeners.add(fn),
removeEventListener: (_n, fn) => listeners.delete(fn),
};
const viewer = {
camera: { moveStart: new Event(), moveEnd: new Event() },
scene: {
canvas: { style: {} },
drillPick() {
picks++;
return [{ id: 'launch' }];
},
},
};
const hover = createLaunchHover({
viewer,
enabled: () => enabled,
entityLaunchId: (id) => id === 'launch',
now: () => at,
documentRef: doc,
pointerFree: () => true,
...fakeTimers(),
});
for (at = 0; at < 120; at++) hover.move({ endPosition: {} });
assert.equal(picks, 1);
assert.equal(viewer.scene.canvas.style.cursor, 'pointer');
hover.move({ endPosition: {} });
assert.equal(picks, 2);
viewer.camera.moveStart.raiseEvent();
at = 500;
hover.move({ endPosition: {} });
assert.equal(picks, 2);
assert.equal(viewer.scene.canvas.style.cursor, '');
viewer.camera.moveEnd.raiseEvent();
hover.move({ endPosition: {} });
assert.equal(picks, 3);
doc.hidden = true;
for (const fn of listeners) fn();
hover.move({ endPosition: {} });
assert.equal(picks, 3);
assert.equal(viewer.scene.canvas.style.cursor, '');
doc.hidden = false;
enabled = false;
hover.move({ endPosition: {} });
assert.equal(picks, 3);
hover.destroy();
assert.equal(listeners.size, 0);
assert.equal(viewer.camera.moveStart.numberOfListeners, 0);
assert.equal(viewer.camera.moveEnd.numberOfListeners, 0);
});
test('mission hover preserves the cursor of a pointer-owning tool', () => {
let free = true,
picks = 0;
const viewer = {
camera: { moveStart: new Event(), moveEnd: new Event() },
scene: {
canvas: { style: { cursor: '' } },
drillPick() {
picks++;
return [{ id: 'launch' }];
},
},
};
const hover = createLaunchHover({
viewer,
enabled: () => true,
entityLaunchId: () => true,
documentRef: null,
pointerFree: () => free,
});
hover.move({ endPosition: {} });
assert.equal(viewer.scene.canvas.style.cursor, 'pointer');
free = false;
viewer.scene.canvas.style.cursor = 'crosshair';
hover.move({ endPosition: {} });
viewer.camera.moveStart.raiseEvent();
hover.destroy();
assert.equal(picks, 1);
assert.equal(viewer.scene.canvas.style.cursor, 'crosshair');
});
+123 -8
View File
@@ -1,6 +1,116 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
const HOVER_PICK_INTERVAL_MS = 120;
/**
* Bound hover picking independently of precise click selection. A move inside
* the throttle window is not dropped: its position is kept and one trailing
* check runs when the window ends, so a pointer that stops is still resolved.
*/
export function createLaunchHover({
viewer,
enabled,
entityLaunchId,
now = () => performance.now(),
documentRef = globalThis.document,
pointerFree = isPointerFree,
setTimer = globalThis.setTimeout,
clearTimer = globalThis.clearTimeout,
}) {
let moving = false;
let lastPick = -Infinity;
let ownsCursor = false;
let pendingPosition = null;
let trailingTimer = null;
const clearCursor = () => {
if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer')
viewer.scene.canvas.style.cursor = '';
ownsCursor = false;
};
const cancelTrailing = () => {
if (trailingTimer !== null) clearTimer(trailingTimer);
trailingTimer = null;
pendingPosition = null;
};
const reset = () => {
cancelTrailing();
clearCursor();
lastPick = -Infinity;
};
const start = () => {
moving = true;
reset();
};
const end = () => {
moving = false;
reset();
};
const visibility = () => {
if (documentRef?.hidden || documentRef?.visibilityState === 'hidden')
reset();
};
const eligible = () =>
enabled() &&
!moving &&
!documentRef?.hidden &&
documentRef?.visibilityState !== 'hidden' &&
pointerFree();
const pick = (position, at) => {
lastPick = at;
const found = viewer.scene
.drillPick(position, 12)
.some((picked) => entityLaunchId(picked?.id));
if (found) {
viewer.scene.canvas.style.cursor = 'pointer';
ownsCursor = true;
} else clearCursor();
};
const trailing = () => {
trailingTimer = null;
const position = pendingPosition;
pendingPosition = null;
if (!position) return;
// Eligibility may have changed while the check waited.
if (!eligible()) {
reset();
return;
}
pick(position, now());
};
const removeStart = viewer.camera?.moveStart?.addEventListener(start);
const removeEnd = viewer.camera?.moveEnd?.addEventListener(end);
documentRef?.addEventListener?.('visibilitychange', visibility);
return {
move(movement) {
if (!eligible()) {
reset();
return;
}
const at = now();
const wait = HOVER_PICK_INTERVAL_MS - (at - lastPick);
if (wait > 0) {
// Cesium reuses the movement object, so keep a copy of the position.
pendingPosition = Cesium.Cartesian2.clone(
movement.endPosition,
pendingPosition ?? undefined,
);
if (trailingTimer === null) trailingTimer = setTimer(trailing, wait);
return;
}
cancelTrailing();
pick(movement.endPosition, at);
},
reset,
destroy() {
reset();
removeStart?.();
removeEnd?.();
documentRef?.removeEventListener?.('visibilitychange', visibility);
},
};
}
export function createLifecycle({
state: layerState,
services,
@@ -8,6 +118,7 @@ export function createLifecycle({
source,
}) {
const { holdContinuousRender, releaseContinuousRender } = services.render;
let hover = null;
const methods = {
init(viewer) {
@@ -45,14 +156,15 @@ export function createLifecycle({
layerState._moveHandler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas,
);
layerState._moveHandler.setInputAction((movement) => {
if (!layerState._enabled || !layerState._dataSource?.show) return;
const missionEntity = viewer.scene
.drillPick(movement.endPosition, 12)
.map((picked) => picked?.id)
.find((candidate) => parts.selection.entityLaunchId(candidate));
viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : '';
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
hover = createLaunchHover({
viewer,
enabled: () => layerState._enabled && layerState._dataSource?.show,
entityLaunchId: parts.selection.entityLaunchId,
});
layerState._moveHandler.setInputAction(
hover.move,
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
);
// Apply horizon visibility before Cesium traverses and picks the scene.
// A postRender write is one frame late and can remain visibly stale when
// request-on-demand rendering stops after a globe camera move.
@@ -100,6 +212,7 @@ export function createLifecycle({
},
async disable() {
hover?.reset();
layerState._sourceController.abort();
layerState._enabled = false;
releaseContinuousRender('rocket-launches');
@@ -122,6 +235,8 @@ export function createLifecycle({
},
async destroy(viewer) {
hover?.destroy();
hover = null;
layerState._sourceController.abort();
releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix)
layerState._enabled = false;
+1 -1
View File
@@ -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 })
+19
View File
@@ -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°');
});
+2
View File
@@ -14,6 +14,7 @@ import {
MODEL_PROX_ADD_M,
MODEL_PROX_KEEP_M,
} from '../flights/policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
const specKey = (spec) => `${spec.url}@${spec.scale}`;
@@ -87,6 +88,7 @@ export function createLocalAdsbModels({
model = await loadModel({
url: resolveAsset(spec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: MODEL_MIN_PX,
scale: spec.scale,
color,
+3
View File
@@ -53,6 +53,7 @@ import {
ROTATION_REFRESH_MS,
COURSE_MAX_DPS,
} from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
export function createRendering({
flightState,
@@ -473,6 +474,7 @@ export function createRendering({
model = await Cesium.Model.fromGltfAsync({
url: resolveAsset(spec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: MODEL_MIN_PX,
scale: spec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
@@ -676,6 +678,7 @@ export function createRendering({
Cesium.Model.fromGltfAsync({
url: resolveAsset(trackedSpec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: TRACKED_MODEL_MIN_PX,
scale: trackedSpec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR,
+4 -2
View File
@@ -47,7 +47,7 @@ export function createTracking({
const { ensureGeoidReady, geoidHeight } = services.geoid;
const { clearFocusTarget } = services.focus;
const { applyTrackedCameraFrame } = services.camera;
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
function _emitAwarenessEvent(type, detail) {
if (
@@ -1079,7 +1079,9 @@ export function createTracking({
// first-person reference. Empty globe clicks are inert until the user
// exits with C, Escape, or the dedicated button.
if (document.body.classList.contains('cockpit-mode')) return;
const picked = viewer.scene.pick(click.position);
const picked = pickContact
? pickContact(viewer.scene, click.position)
: viewer.scene.pick(click.position);
if (picked) {
// Clicking the tracked entity itself -- ignore (don't deselect)
+23 -3
View File
@@ -105,6 +105,25 @@ function pointsOf(sequence) {
* @property {boolean} [keepOrder] Cap a tile's lines in the order decoded (default: newest first).
*/
/**
* The tile zoom a provider's `zoomFor` answered, or null. Providers written
* before the core went slim return `{zoom, kind}`: its zoom is read, and an
* `'overview'` level draws nothing, since nothing is drawn from orbit. Any
* other answer that is not a whole zoom draws nothing too, rather than
* asking for no tiles without a hint.
* @param {unknown} level
* @returns {number|null}
*/
export function coverageZoom(level) {
const zoom =
level && typeof level === 'object'
? level.kind === 'overview'
? null
: level.zoom
: level;
return Number.isInteger(zoom) && zoom >= 0 ? zoom : null;
}
/**
* Camera-driven sequence lines from any XYZ line-tile source, near the
* ground only, draped on terrain and 3D tiles alike. Nothing is drawn from
@@ -115,8 +134,9 @@ function pointsOf(sequence) {
* @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array<object>}>} options.fetchTile
* A decoded tile; sequences are `{id, parts, capturedAt, isPano}`. A
* rejection may carry `keyRequired`, `keyRejected` or `retryAfterSec`.
* @param {(heightM: number|null) => number|null} options.zoomFor
* Tile zoom for a camera height above ground; null draws nothing.
* @param {(heightM: number|null) => number|{zoom: number, kind?: string}|null} options.zoomFor
* Tile zoom for a camera height above ground; null draws nothing. The
* older `{zoom, kind}` shape is still read (see `coverageZoom`).
* @param {{coverage: string, selected: string}} options.colors
* @param {string} options.pickPrefix Pick-id prefix of the lines.
* @param {string} options.renderKey Render-governor reason.
@@ -380,7 +400,7 @@ export function createLineCoverage({
const height = Number.isFinite(cameraHeight)
? cameraHeight - (ground ?? 0)
: null;
const zoom = zoomFor(height);
const zoom = coverageZoom(zoomFor(height));
if (zoom == null) return { hint: messages.tooHigh || ZOOM_IN_HINT };
// Rays meet the ground where it really is (1,600 m up in Denver) and stop
// short of the horizon, which would stretch the box to the world.
+36 -4
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
coverageZoom,
createLineCoverage,
freshCoverage,
ZOOM_IN_HINT,
@@ -28,7 +29,7 @@ const { maxSequences: COVERAGE_MAX_SEQUENCES, maxTiles: COVERAGE_MAX_TILES } =
COVERAGE_TUNING;
/** Line coverage as the Mapillary provider builds it, over `source`. */
function createCoverage({ state, source }) {
function createCoverage({ state, source, zoomFor = coverageZoomForHeight }) {
return createLineCoverage({
state,
fetchTile: async (tile, { signal }) =>
@@ -36,7 +37,7 @@ function createCoverage({ state, source }) {
await source.getTile('coverage', tile.z, tile.x, tile.y, { signal }),
tile,
),
zoomFor: coverageZoomForHeight,
zoomFor,
colors: COLORS,
pickPrefix: PICK_PREFIX.sequence,
renderKey: 'mapillary-coverage',
@@ -123,7 +124,7 @@ function deferredSource() {
};
}
function setup() {
function setup({ zoomFor } = {}) {
const centre = tileCentre(-121.4944, 38.5816);
const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 });
const source = deferredSource();
@@ -146,7 +147,7 @@ function setup() {
},
sequence: { selectedId: null },
};
const coverage = createCoverage({ state, source });
const coverage = createCoverage({ state, source, zoomFor });
const bytes = encodeCoverageTile(centre.tile, {
sequences: [
{
@@ -239,6 +240,37 @@ test('panning off a tile that is still loading stops counting it as loading', as
coverage.clear();
});
test('a zoomFor in the older {zoom, kind} shape still draws its lines', async () => {
// Providers written before the core went slim (#1139's Vegbilder) answer
// an object: it must not ask for no tiles and say nothing.
const { viewer, source, coverage, bytes } = setup({
zoomFor: (height) => {
const zoom = coverageZoomForHeight(height);
return zoom == null ? null : { zoom, kind: 'sequence' };
},
});
coverage.refresh();
assert.equal(source.calls.length, 1, 'the tile is asked for');
assert.equal(source.calls[0].z, 14);
source.calls[0].resolve(bytes);
await settle();
assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'its line drawn');
assert.equal(coverage.stats().hint, '');
coverage.clear();
});
test('a zoom level is read from either shape; an overview or a bad answer draws nothing', () => {
assert.equal(coverageZoom(14), 14);
assert.equal(coverageZoom({ zoom: 15, kind: 'sequence' }), 15);
assert.equal(coverageZoom({ zoom: 12 }), 12);
assert.equal(coverageZoom({ zoom: 5, kind: 'overview' }), null, 'orbit');
assert.equal(coverageZoom(null), null);
assert.equal(coverageZoom(undefined), null);
assert.equal(coverageZoom({}), null);
assert.equal(coverageZoom(14.5), null);
assert.equal(coverageZoom('14'), null);
});
test('from orbit nothing is requested or drawn, and the hint says to zoom in', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
@@ -222,9 +222,32 @@ export function kartaViewTimeMs(value) {
String(value ?? ''),
);
if (!match) return null;
const [, y, mo, d, h = '0', mi = '0', s = '0'] = match;
if (Number(y) < 1990) return null;
const ms = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s);
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const hours = Number(match[4] ?? 0);
const minutes = Number(match[5] ?? 0);
const seconds = Number(match[6] ?? 0);
if (year < 1990) return null;
if (
month < 1 ||
month > 12 ||
day < 1 ||
hours > 23 ||
minutes > 59 ||
seconds > 59
)
return null;
// Date.UTC rolls 31 February into March. A photo would then pass or fail
// the day filter on the wrong calendar day, so an impossible date is unknown.
const ms = Date.UTC(year, month - 1, day, hours, minutes, seconds);
const parsed = new Date(ms);
if (
parsed.getUTCFullYear() !== year ||
parsed.getUTCMonth() !== month - 1 ||
parsed.getUTCDate() !== day
)
return null;
return Number.isFinite(ms) ? ms : null;
}
@@ -158,6 +158,10 @@ test('timestamps parse as UTC; years before 1990 and garbage are unknown', () =>
kartaViewTimeMs('2022-03-21T06:42:59'),
Date.UTC(2022, 2, 21, 6, 42, 59),
);
assert.equal(kartaViewTimeMs('2022-02-31 12:00:00'), null);
assert.equal(kartaViewTimeMs('2022-13-01 00:00:00'), null);
assert.equal(kartaViewTimeMs('2022-03-21 25:00:00'), null);
assert.equal(kartaViewTimeMs('2022-04-31'), null);
assert.equal(kartaViewTimeMs('0000-00-00 00:00:00'), null);
assert.equal(kartaViewTimeMs(''), null);
assert.equal(kartaViewTimeMs(null), null);
+1 -1
View File
@@ -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';
+69 -8
View File
@@ -4,30 +4,79 @@ import { clipTileLine } from '../../sources/openFreeMap.js';
import { tileToBBox } from '../../data/tomtomTiles.js';
import { decodeFlowTile } from './flowDecode.js';
const FLOW_TILE_PATH = (z, x, y) => `/api/tomtom/flow/${z}/${x}/${y}.pbf`;
/**
* Whether the proxy answered a tile from its cache past the refresh window
* (STALE-BUDGET once the daily budget is spent, STALE-ERROR when TomTom
* fails), and when the proxy fetched it.
*/
function tileFreshness(response) {
const status = response.headers?.get?.('x-tomtom-cache') || '';
const fetchedAt = Number(response.headers?.get?.('x-tomtom-fetched-at'));
return {
stale: status.startsWith('STALE'),
fetchedAt: fetchedAt > 0 ? fetchedAt : null,
};
}
/** Own one bounded decoded flow cache; every request still passes the server tile budget. */
export function createFlowTileSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
// Freshness travels in response headers, but the tile cache keeps only
// decoded segments: hold each response's freshness until its tile decodes,
// then key it by the decoded tile, which the cache hands back on a hit.
const pending = new Map();
const freshness = new WeakMap();
const tiles = createVectorTileSource({
template: '/api/tomtom/flow/{z}/{x}/{y}.pbf',
template: FLOW_TILE_PATH('{z}', '{x}', '{y}'),
allowedOrigin: 'http://localhost',
decode: (bytes, z, x, y) =>
decodeFlowTile(bytes, z, x, y, { strict: true })
decode: (bytes, z, x, y) => {
// Take the mark before decoding, so a tile that fails to decode
// leaves nothing behind.
const path = FLOW_TILE_PATH(z, x, y);
const entry = pending.get(path);
const mark = entry?.mark;
entry?.release();
const segments = decodeFlowTile(bytes, z, x, y, { strict: true })
.filter(isDrivableFlowSegment)
.flatMap((segment) =>
clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({
...segment,
coords,
})),
),
fetchImpl,
);
if (mark) freshness.set(segments, mark);
return segments;
},
fetchImpl: async (url, init) => {
const response = await fetchImpl(url, init);
if (response.ok && !init.signal.aborted) {
const path = String(url);
const entry = {
mark: tileFreshness(response),
release() {
if (pending.get(path) === entry) pending.delete(path);
init.signal.removeEventListener('abort', entry.release);
},
};
pending.get(path)?.release();
pending.set(path, entry);
// The vector source aborts this signal for failed/cancelled body reads,
// including size-limit failures that never reach decode.
init.signal.addEventListener('abort', entry.release, { once: true });
}
return response;
},
ttlMs: 120_000,
maxTiles: 16,
});
let partial = false;
/**
* Flow segments for bounds, with whether any covering tile failed to load
* for this request.
* for this request, whether any came from the proxy's stale cache, and when
* the oldest tile was fetched.
*/
async function fetchFlowDetail(bounds, { signal, zoom = 12 } = {}) {
const result = await tiles.fetchBounds(bounds, {
@@ -44,7 +93,16 @@ export function createFlowTileSource({
coords,
})),
);
return { segments, partial: result.partial === true };
const marks = result.tiles.map((tile) => freshness.get(tile) || {});
const times = marks
.map((mark) => mark.fetchedAt)
.filter((at) => Number.isFinite(at));
return {
segments,
partial: result.partial === true,
stale: marks.some((mark) => mark.stale === true),
oldestTileAt: times.length ? Math.min(...times) : null,
};
}
return {
fetchFlowDetail,
@@ -52,6 +110,9 @@ export function createFlowTileSource({
return (await fetchFlowDetail(bounds, options)).segments;
},
getFlowSessionStats: () => ({ ...tiles.getStats(), partial }),
resetFlowTileCache: () => tiles.clear(),
resetFlowTileCache: () => {
for (const entry of pending.values()) entry.release();
tiles.clear();
},
};
}
+153
View File
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { getEventListeners } from 'node:events';
import { createFlowTileSource } from './flowSource.js';
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
import { decodeFlowTile } from './flowDecode.js';
import { decodeOpenFreeMapTile } from '../../sources/openFreeMap.js';
@@ -867,3 +869,154 @@ test('traffic separates API and public tile transports, including the default ti
assert.ok(publicUrls.length >= 4);
assert.ok(apiUrls.length >= 4);
});
test('flow detail reports stale proxy tiles and the oldest tile time, also from its cache', async () => {
const fetchedAt = Date.UTC(2026, 9, 16, 8);
const reply = (status, at) => async () =>
new Response(fixture, {
headers: { 'x-tomtom-cache': status, 'x-tomtom-fetched-at': String(at) },
});
const stale = createTrafficSource({
fetchImpl: reply('STALE-BUDGET', fetchedAt),
});
const first = await stale.fetchFlowDetail(bounds);
assert.ok(first.segments.length > 0);
assert.equal(first.stale, true);
assert.equal(first.oldestTileAt, fetchedAt);
// The decoded tile is served from cache now, and keeps its mark.
const again = await stale.fetchFlowDetail(bounds);
assert.equal(stale.getFlowSessionStats().tilesFetched, 1);
assert.equal(again.stale, true);
assert.equal(again.oldestTileAt, fetchedAt);
const fresh = createTrafficSource({ fetchImpl: reply('MISS', fetchedAt) });
const live = await fresh.fetchFlowDetail(bounds);
assert.equal(live.stale, false);
assert.equal(live.oldestTileAt, fetchedAt);
const unmarked = createTrafficSource({
fetchImpl: async () => new Response(fixture),
});
const plain = await unmarked.fetchFlowDetail(bounds);
assert.equal(plain.stale, false);
assert.equal(plain.oldestTileAt, null);
});
// Capture constructor-owned Maps only while constructing the source, so tests
// can assert retained metadata without adding a diagnostic API to production.
function flowSourceWithMaps(fetchImpl) {
const maps = [];
const NativeMap = globalThis.Map;
let source;
try {
globalThis.Map = class extends NativeMap {
constructor(...args) {
super(...args);
maps.push(this);
}
};
source = createFlowTileSource({ fetchImpl });
} finally {
globalThis.Map = NativeMap;
}
return { source, pending: maps[0] };
}
test('flow freshness is released after body failures, size rejection, decode and reset', async () => {
for (const failure of ['body', 'size', 'decode']) {
let requestSignal;
let fail = true;
const { source, pending } = flowSourceWithMaps(async (url, { signal }) => {
requestSignal = signal;
return {
ok: true,
headers: new Headers({
'x-tomtom-cache': fail ? 'STALE-ERROR' : 'MISS',
'x-tomtom-fetched-at': fail ? '1000' : '2000',
...(fail && failure === 'size'
? { 'content-length': '99999999' }
: {}),
}),
async arrayBuffer() {
if (fail && failure === 'body') throw new Error('body disconnected');
return fail && failure === 'decode' ? new Uint8Array([255]) : fixture;
},
};
});
await assert.rejects(source.fetchFlowDetail(bounds));
assert.equal(pending.size, 0, `${failure} leaves no pending metadata`);
assert.equal(getEventListeners(requestSignal, 'abort').length, 0);
assert.equal(source.getFlowSessionStats().cacheEntries, 0);
fail = false;
const fresh = await source.fetchFlowDetail(bounds);
assert.equal(fresh.stale, false);
assert.equal(fresh.oldestTileAt, 2000);
assert.equal(pending.size, 0, 'successful decode releases metadata');
assert.equal(getEventListeners(requestSignal, 'abort').length, 0);
source.resetFlowTileCache();
assert.equal(pending.size, 0);
}
});
test('flow reset and late cancelled headers cannot retain or replace newer freshness', async () => {
let resolveOld;
let oldSignal;
let calls = 0;
const { source, pending } = flowSourceWithMaps((url, { signal }) => {
if (++calls === 1) {
oldSignal = signal;
return new Promise((resolve) => {
resolveOld = resolve;
});
}
return Promise.resolve(
new Response(fixture, {
headers: { 'x-tomtom-cache': 'MISS', 'x-tomtom-fetched-at': '2000' },
}),
);
});
const old = source.fetchFlowDetail(bounds);
const rejected = assert.rejects(old, { name: 'AbortError' });
while (!resolveOld) await new Promise((resolve) => setImmediate(resolve));
source.resetFlowTileCache();
const fresh = await source.fetchFlowDetail(bounds);
resolveOld(
new Response(fixture, {
headers: {
'x-tomtom-cache': 'STALE-ERROR',
'x-tomtom-fetched-at': '1000',
},
}),
);
await rejected;
await new Promise((resolve) => setImmediate(resolve));
assert.equal(pending.size, 0);
assert.equal(getEventListeners(oldSignal, 'abort').length, 0);
assert.equal(fresh.stale, false);
assert.equal(fresh.oldestTileAt, 2000);
assert.equal((await source.fetchFlowDetail(bounds)).oldestTileAt, 2000);
});
test('cancelling a flow body releases its freshness before a new request', async () => {
const controller = new AbortController();
let requestSignal;
const { source, pending } = flowSourceWithMaps(async (url, { signal }) => {
requestSignal = signal;
return {
ok: true,
headers: new Headers({ 'x-tomtom-cache': 'STALE-ERROR' }),
async arrayBuffer() {
assert.equal(pending.size, 1);
controller.abort();
return fixture;
},
};
});
await assert.rejects(
source.fetchFlowDetail(bounds, { signal: controller.signal }),
{ name: 'AbortError' },
);
assert.equal(pending.size, 0);
assert.equal(getEventListeners(requestSignal, 'abort').length, 0);
assert.equal(source.getFlowSessionStats().cacheEntries, 0);
});
+1
View File
@@ -97,6 +97,7 @@ export function createLifecycle({ state, services, parts }) {
else if (hidden) {
hidden = false;
parts.rendering.resumePlayback();
parts.viewport.runProximityCheck();
}
}
function bindVisibility() {
+3 -1
View File
@@ -442,10 +442,12 @@ export function buildTransitSelectionCopy(
? entry.sample.latestReportAgeMs
: vehicleFixAgeMs(record, nowMs, fetchedAtMs)) / 1000,
);
// Seconds through 89s, then whole minutes. Rounding 90s up would skip
// "1 min" and call a minute-and-a-half-old report two minutes old.
const reported =
ageS < 90
? t('Reported {seconds} s ago', { seconds: ageS })
: t('Reported {minutes} min ago', { minutes: Math.round(ageS / 60) });
: t('Reported {minutes} min ago', { minutes: Math.floor(ageS / 60) });
// A time the feed did not give for THIS vehicle is labelled, so the card
// never presents a borrowed or inferred time as the vehicle's own report.
const ownTime =
@@ -39,6 +39,26 @@ test('en: the copy is unchanged with and without the i18n t()', () => {
]);
});
test('a report 90 seconds old reads as 1 minute, not 2', () => {
setLocale('en');
const line = (ageMs) =>
buildTransitSelectionCopy(
feed,
{
id: 'age',
lat: 1,
lon: 1,
timestamp: Math.floor((now - ageMs) / 1000),
timestampSource: 'vehicle',
},
'bus',
now,
).details[1];
assert.equal(line(89_000), 'Reported 89 s ago');
assert.equal(line(90_000), 'Reported 1 min ago');
assert.equal(line(120_000), 'Reported 2 min ago');
});
for (const locale of ['es', 'ru']) {
test(`${locale}: the Transit card is in the UI language`, () => {
setLocale(locale);
+5
View File
@@ -75,6 +75,11 @@ export function createViewport({ state, services, parts }) {
}
function runProximityCheck() {
if (
globalThis.document?.hidden ||
globalThis.document?.visibilityState === 'hidden'
)
return;
if (!state._enabled || !state._viewer) return;
refreshViewBounds();
const altitude = getCameraAltitude();
+4 -2
View File
@@ -10,7 +10,7 @@ export function createSelection({
options,
}) {
const { state } = vesselState;
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
const { requestWorldFocus } = services.worldFocus;
const {
selectEntityContext,
@@ -37,7 +37,9 @@ export function createSelection({
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!state.feed.enabled) return;
const picked = viewer.scene.pick(click.position);
const picked = pickContact
? pickContact(viewer.scene, click.position)
: viewer.scene.pick(click.position);
const pickedId = resolvePickId(picked);
let record = pickedId ? state.records.byMmsi.get(pickedId) : null;
const rawId = picked?.id ?? picked?.primitive?.id;
+19 -2
View File
@@ -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();
+45 -1
View File
@@ -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 });