Merge current main into S2 GhostMaps and preserve updated import and voice behavior

This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-10-26 16:25:49 +01:00
commit 4da002bd42
515 files changed
+17681 -2202

No files matched your search

-6
View File
@@ -3,16 +3,12 @@ import * as Cesium from 'cesium';
import {
LAYER_ID,
REQUEST_DEBOUNCE_MS,
QUERY_LIMIT,
MAX_RENDERED,
QUERY_SNAP_DEGREES,
QUERY_REUSE_MS,
ALPR_COLOR,
} from './policy.js';
import {
snapAlprBox,
boxContains,
destinationPointDeg,
alprRetryDelayMs,
validateAlprSnapshot,
alprCreditMarkup,
@@ -84,9 +80,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
viewportBox,
clearRendered,
hideOnMapCredit,
presentOnMapCredit,
renderRecords,
selectRecord,
focusNearest,
clearSelection,
updateSelectedAnchor,
+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 -3
View File
@@ -1,11 +1,11 @@
import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js';
export function textTag(value) {
function textTag(value) {
const t = String(value ?? '').trim();
return t || null;
}
export function numTag(value) {
function numTag(value) {
// `Number('')` and `Number(' ')` are 0, not NaN — a blank or whitespace-only
// tag value must read as missing, never as a due-north bearing.
if (value == null) return null;
@@ -102,7 +102,7 @@ export function normalizeAlprNode(el) {
};
}
export function normalizeDirection(value) {
function normalizeDirection(value) {
const degrees = numTag(value);
return degrees != null && degrees >= 0 && degrees <= 360
? degrees % 360
+1 -1
View File
@@ -3,7 +3,7 @@ import { VectorTile } from '@mapbox/vector-tile';
import { normalizeAlprNode } from './records.js';
/** Map extract attributes without treating an OSM edit date as a field verification. */
export function normalizeAlprTileFeature(feature) {
function normalizeAlprTileFeature(feature) {
const p = feature?.properties || {};
if (feature?.geometry?.type !== 'Point' || p.osmType !== 'node') return null;
const [lon, lat] = feature.geometry.coordinates;
+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,
+1 -4
View File
@@ -1,9 +1,6 @@
export const AIRCRAFT_DEPENDENCIES = ['flights', 'military'];
export const DEFERRED_DEPENDENCIES = [
'ais-live-vessels',
'military-installations',
];
const DEFERRED_DEPENDENCIES = ['ais-live-vessels', 'military-installations'];
export const DEPENDENCIES = [
...AIRCRAFT_DEPENDENCIES,
+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}`;
@@ -60,7 +60,7 @@ test('the layer reports failure when a failed city left it with nothing', () =>
});
test('a layer with nothing to draw and nothing failed is not an error', () => {
const { state, methods } = makeLayer();
const { methods } = makeLayer();
// Out of range of every city is empty, not broken.
assert.equal(methods.getStats().error, undefined);
+1 -1
View File
@@ -11,7 +11,7 @@ export const BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
// --- Activation / display thresholds ---
/** Altitude (m) at which bikeshare layer becomes eligible for display. */
export const ACTIVATION_ALTITUDE_M = 50000;
const ACTIVATION_ALTITUDE_M = 50000;
/** Hysteresis enter threshold — layer activates when camera drops below this. */
+1 -1
View File
@@ -1,4 +1,4 @@
import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js';
import { CAMERA_SEEDS } from './policy.js';
import {
lookupModelSpec,
horizontalFovRangeDeg,
-3
View File
@@ -5,9 +5,6 @@ import {
PROJECTION_CANVAS_HEIGHT,
PROJECTION_MATTE_COLOR,
PROJECTION_TEXTURE_SWAP_MS,
ACTIVE_FRAME_REFRESH_MS,
FRAME_ENDPOINT,
MEDIA_ENDPOINT,
PROJECTION_ACTIVE_REFRESH_MS,
PROJECTION_IDLE_REFRESH_MS,
PLACEHOLDER_REPAINT_MS,
-1
View File
@@ -1,7 +1,6 @@
import * as Cesium from 'cesium';
import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js';
import {
PROJECTION_VERT_ASPECT,
FRUSTUM_GROUND_CLEARANCE_M,
PROBE_MIN_RANGE_M,
PROBE_CLEARANCE_M,
+1 -1
View File
@@ -1,4 +1,4 @@
import { HEALTH_SYNC_INTERVAL_MS, HEALTH_ENDPOINT } from './policy.js';
import { HEALTH_SYNC_INTERVAL_MS } from './policy.js';
export function createHealth({ state: layerState, services, parts, source }) {
/**
+1 -10
View File
@@ -1,10 +1,4 @@
export {
FRAME_ENDPOINT,
SOURCE_ENDPOINT,
HEALTH_ENDPOINT,
MEDIA_ENDPOINT,
ACTIVE_FRAME_REFRESH_MS,
} from './sourcePolicy.js';
export { ACTIVE_FRAME_REFRESH_MS } from './sourcePolicy.js';
import * as Cesium from 'cesium';
import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js';
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
@@ -90,9 +84,6 @@ export const CCTV_CALIBRATION_STORAGE_KEY_V2 =
export const PROJECTION_TEXTURE_SWAP_MS = 1000;
export const PROJECTION_VERT_ASPECT =
PROJECTION_CANVAS_WIDTH / PROJECTION_CANVAS_HEIGHT;
// V2 frustum geometry (design §2a/§6): the far-cap center + corners never sink
// below groundAlt + this clearance, so a fabricated pitch (-24°) cannot bury
// the monitor plane in the 3D tiles. Exported for the unit suite.
-4
View File
@@ -1,9 +1,5 @@
export const FRAME_ENDPOINT = '/api/cctv/frame';
export const SOURCE_ENDPOINT = '/api/cctv/sources';
export const HEALTH_ENDPOINT = '/api/cctv/health';
export const MEDIA_ENDPOINT = '/api/cctv/media';
export const ACTIVE_FRAME_REFRESH_MS = 10000;
+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'],
]) {
+2 -2
View File
@@ -64,7 +64,7 @@ const SHARED_CARD = Object.freeze({
* @param {boolean} selected
* @returns {object}
*/
export function cycloneStormEntry(storm, selected) {
function cycloneStormEntry(storm, selected) {
return {
...SHARED_CARD,
...markerClearance(
@@ -92,7 +92,7 @@ export function cycloneStormEntry(storm, selected) {
* @param {{tauHours:number,position:object}} point
* @returns {object}
*/
export function cycloneLeadEntry(stormId, point) {
function cycloneLeadEntry(stormId, point) {
return {
...SHARED_CARD,
...markerClearance(CYCLONE_MARKER_PX.forecast),
+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)
+6 -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,
@@ -591,6 +593,7 @@ export function createModel({
formatLatLon,
formatFrp,
formatAge,
formatAgeLabel,
formatAgoMinutes,
confidenceBucket,
};
+6 -7
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,
@@ -64,7 +63,8 @@ export function createQueries({
const staleText = layerState._lastUpdate
? t('STALE · cached {age}', {
age:
components.model.formatAge(now - layerState._lastUpdate) || '<1h',
components.model.formatAgeLabel(now - layerState._lastUpdate) ||
t('<1h', { context: 'age' }),
})
: t('STALE');
let loadingLabel = '';
@@ -117,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);
});
-8
View File
@@ -1,11 +1,3 @@
export {
GROUND_FLOOR_CLAMP_RADIUS_KM,
GROUND_FLOOR_WARM_MAX_ALT_M,
POSITION_HISTORY_LIMIT,
LANDED_MISSING_POLL_LIMIT,
MISSING_POLL_LIMIT,
ERROR_BACKOFF_INTERVAL,
} from './recordPolicy.js';
import * as Cesium from 'cesium';
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
+15 -5
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;
@@ -580,7 +591,6 @@ export function createQueries({
const maxRange =
Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY;
const now = Cesium.JulianDate.now();
const nearby = [];
for (const [icao24, bb] of flightState._billboards) {
+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)
-2
View File
@@ -2,8 +2,6 @@ export const LAYER_ID = 'military-installations';
export const REQUEST_DEBOUNCE_MS = 180;
export const MAX_VIEWPORT_DEGREES = 10;
export const MAX_RENDERED = 700;
export const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']);
+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
@@ -109,7 +109,7 @@ export function createRendering({
);
});
if (satelliteTrack) layerState._orbitMatches++;
const entity = layerState._dataSource.entities.add({
layerState._dataSource.entities.add({
id: `rocket-launch:${launch.id}`,
position,
point: {
+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 -2
View File
@@ -5,12 +5,12 @@ import {
/** Same-origin route served by `server/providers/local-receivers.js`. */
export const LOCAL_RECEIVERS_URL = '/api/local-receivers/aircraft';
export const LOCAL_RECEIVER_POLL_MS = 1_000;
const LOCAL_RECEIVER_POLL_MS = 1_000;
/**
* Browser-side deadline for one route request, body included. The server
* bounds each upstream read at 2 s; this bounds the browser-to-server leg.
*/
export const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000;
const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000;
function abortError() {
const error = new Error('Local receiver request aborted');
+2 -12
View File
@@ -36,18 +36,8 @@ import {
} from './policy.js';
import { localAdsbStatus } from './status.js';
export {
LAYER_ID as LOCAL_ADSB_LAYER_ID,
HEARD_BY_RECEIVER,
} from './policy.js';
export {
localAdsbCardModel,
localAdsbClassLine,
localAdsbIsUat,
localAdsbReceiverLine,
localAdsbTitle,
} from './card.js';
export { localAdsbClass } from './enrichment.js';
export { HEARD_BY_RECEIVER } from './policy.js';
export { localAdsbCardModel, localAdsbReceiverLine } from './card.js';
export { localAdsbStatus } from './status.js';
const FEET_TO_METERS = 0.3048;
+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,
+1 -1
View File
@@ -26,7 +26,7 @@ import {
export const LOCAL_ADSB_HISTORY_MS = 10 * 60_000;
export const LOCAL_ADSB_HISTORY_POINTS = 600;
/** A display correction larger than this snaps instead of sliding. */
export const LOCAL_ADSB_SNAP_M = 3_000;
const LOCAL_ADSB_SNAP_M = 3_000;
/** Chord course uses the newest fix at least this much older than the anchor. */
const CHORD_BASELINE_MS = 2_000;
/** Turn rate is estimated over this window of heard fixes. */
+2 -2
View File
@@ -24,7 +24,7 @@ function plural(count, one, many) {
* @param {object[]} feeds Feed statuses from the route.
* @returns {string}
*/
export function describeFeedProblems(feeds) {
function describeFeedProblems(feeds) {
const byStatus = new Map();
for (const feed of feeds) {
if (feed?.status === 'live') continue;
@@ -41,7 +41,7 @@ export function describeFeedProblems(feeds) {
}
/** Whether the server reported at least one configured feed. */
export function feedsConfigured(feedState) {
function feedsConfigured(feedState) {
return Boolean(
feedState?.configured === true &&
Array.isArray(feedState.feeds) &&
-7
View File
@@ -1,10 +1,3 @@
export {
GROUND_FLOOR_WARM_MAX_ALT_M,
POSITION_HISTORY_LIMIT,
LANDED_MISSING_POLL_LIMIT,
MISSING_POLL_LIMIT,
ERROR_BACKOFF_INTERVAL,
} from './recordPolicy.js';
import * as Cesium from 'cesium';
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
-1
View File
@@ -366,7 +366,6 @@ export function createQueries({
const maxRange =
Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY;
const now = Cesium.JulianDate.now();
const nearby = [];
for (const [icao24, bb] of flightState._billboards) {
+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,
-1
View File
@@ -187,7 +187,6 @@ export function createMilitarySnapshotRenderer({
// If this is the tracked aircraft, update label text
// (position updates automatically via dead-reckoning CallbackProperty)
if (isTracked && flightState._trackedEntity) {
const info = flightState.records.data.get(icao24);
tracking._updateTrackedLabelModel(icao24);
}
}
+5 -3
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 (
@@ -593,7 +593,7 @@ export function createTracking({
}
/**
* Fire-and-forget adsb.lol readsb trace backfill (PRD F2). Trace points are
* Fire-and-forget optional source history backfill (PRD F2). Trace points are
* stride-thinned to the remaining vertex budget, then spliced strictly older
* than the oldest seeded fix AHEAD of the locally accumulated fine segment,
* capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/
@@ -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)
+22 -6
View File
@@ -2,6 +2,7 @@ import { msg } from '../../i18n/index.js';
import {
RADIO_DIRECTORY_STALE_MS,
RADIO_DIRECTORY_FUTURE_SKEW_MS,
RADIO_REJECTED_STATION_COUNT_MAX,
} from './policy.js';
// Directory errors stay English in layer state and stats; the layer row and
@@ -64,17 +65,24 @@ export function createIngestion({
typeof body.degraded !== 'boolean'
)
throw new Error('Radio directory freshness metadata was malformed');
// Rows the server skipped as structurally malformed. Older envelopes
// omit the field; a present value must be a bounded count.
const serverRejectedStationCount =
body.rejectedStationCount === undefined
? 0
: body.rejectedStationCount;
if (
!Number.isSafeInteger(serverRejectedStationCount) ||
serverRejectedStationCount < 0 ||
serverRejectedStationCount > RADIO_REJECTED_STATION_COUNT_MAX
)
throw new Error('Radio directory diagnostic metadata was malformed');
const rows = body.stations;
const acceptedRows = rows.filter(
parts.catalogModel.isValidRadioDirectoryStation,
);
if (!acceptedRows.length)
throw new Error('Radio directory returned no usable stations');
if (acceptedRows.length !== rows.length) {
throw new Error(
'Radio directory response contained malformed stations',
);
}
const acceptedGeneration = body.acceptedGeneration;
if (
acceptedGeneration !== null &&
@@ -147,7 +155,15 @@ export function createIngestion({
}
if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt;
}
layerState._degraded = body.degraded;
// Combine server-side structural rejections with rows this client
// refused. The count is diagnostic: it marks coverage degraded but does
// not undo the generation admitted above.
layerState._rejectedStationCount = Math.min(
RADIO_REJECTED_STATION_COUNT_MAX,
serverRejectedStationCount + rows.length - acceptedRows.length,
);
layerState._degraded =
body.degraded || layerState._rejectedStationCount > 0;
layerState._stale = body.stale;
layerState._error = preservingWarmCatalog
? ERROR_REFRESH_DEGRADED
+1
View File
@@ -108,6 +108,7 @@ export function createLifecycle({
layerState._loading = false;
layerState._stale = false;
layerState._degraded = false;
layerState._rejectedStationCount = 0;
layerState._error = null;
layerState._updatedAt = null;
layerState._managerLifecyclePresentation = null;
+5 -1
View File
@@ -10,7 +10,7 @@ export const MARKER_LIFT_M = 2.5;
export const SELECTED_LIFT_M = 5;
export const RADIO_PICK_TOLERANCE_PX = 8;
const RADIO_PICK_TOLERANCE_PX = 8;
export const RADIO_TUNER_DIRECTORY_LIMIT = 750;
@@ -24,6 +24,10 @@ export const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000;
export const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000;
// Bound for the server's `rejectedStationCount` envelope field and the
// combined client counter; matches server/providers/radio/constants.js.
export const RADIO_REJECTED_STATION_COUNT_MAX = 10_000;
export const RADIO_UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+1
View File
@@ -20,6 +20,7 @@ export function createPresentation({
loading: layerState._loading,
stale: layerState._stale,
degraded: layerState._degraded,
rejectedStationCount: layerState._rejectedStationCount,
error: layerState._error,
updatedAt: layerState._updatedAt,
filter: layerState._filter,
+1 -1
View File
@@ -259,7 +259,7 @@ export function createRendering({
layerState._horizonScanCount += 1;
const occluder = horizonOccluder(layerState._viewer.camera);
let visibilityChanged = false;
for (const [id, record] of layerState._renderById) {
for (const record of layerState._renderById.values()) {
const matches = parts.categories.stationMatchesRadioCategory(
record.station,
layerState._filter,
+1
View File
@@ -25,6 +25,7 @@ export function createState({ services }) {
layerState._stale = false;
layerState._degraded = false;
layerState._rejectedStationCount = 0;
layerState._error = null;
+10 -2
View File
@@ -3,7 +3,12 @@
* day's readout. The model (`model.js`) stays English and i18n-free because AI
* tool services import it outside the browser; this module is page-only.
*/
import { formatDate, formatRelativeTime, t } from '../../i18n/index.js';
import {
formatDate,
formatRelativeTime,
getLocale,
t,
} from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { dayTime } from './model.js';
@@ -26,7 +31,10 @@ export const displayMetres = (value) => formatQuantity(value, 'm');
/** The readout's words in the page's language; the model keeps them English. */
export const READOUT_WORDS = Object.freeze({
date: (day) => displayDay(day, LONG_DAY),
daysAgo: (delta) => formatRelativeTime(delta > 0 ? -delta : 0, 'day'),
daysAgo: (delta) => {
const text = formatRelativeTime(delta > 0 ? -delta : 0, 'day');
return getLocale() === 'zh-CN' ? text.replace(/(\d)(?=天)/g, '$1 ') : text;
},
// "Sentinel-2" never breaks at its hyphen: a word joiner follows it
// (JetBrains Mono has no U+2011 non-breaking hyphen).
sensor: (sensor) =>
+1 -1
View File
@@ -300,7 +300,7 @@ export function hhmm(iso) {
}
/** Candidate key: `PRODUCT:YYYY-MM-DD`. */
export function candidateKey(product, day) {
function candidateKey(product, day) {
return `${product}:${day}`;
}
+2 -2
View File
@@ -94,9 +94,9 @@ export const TRACK_VIEW_FROM_HIGH_SCALE = 4;
* attribute, so classification costs nothing per frame.
*/
export const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3);
const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3);
export const _classColor = (group) =>
const _classColor = (group) =>
Cesium.Color.fromCssColorString(satelliteClassColor(group));
export const POINT_STYLES = {
+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.
+38 -6
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();
@@ -317,7 +349,7 @@ test('a rejected key stops coverage requests until the layer goes off', async ()
test('a rate limit keeps the drawn tiles and asks again once the wait is over', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, source, state, coverage } = setup();
const { viewer, source, coverage } = setup();
coverage.refresh();
source.calls[0].reject(
Object.assign(new Error('rate limited'), { retryAfterSec: 30 }),
@@ -593,7 +625,7 @@ test('a tile that fails before the terrain table loads is reported at once, neve
});
test('the selected sequence is drawn and highlighted even past the per-tile cap', async () => {
const { state, source, coverage, centre, viewer } = setup();
const { state, source, coverage, centre } = setup();
const line = (n) => [
[
[centre.lon - 0.001, centre.lat + n * 1e-6],
+5 -5
View File
@@ -17,18 +17,18 @@ const EXPANDED_SELECTORS = [
];
/** A photo download (tens of kilobytes to a few megabytes). */
export const IMAGE_TIMEOUT_MS = 15_000;
const IMAGE_TIMEOUT_MS = 15_000;
/** A picture file or decoded side past these is refused (a photo is a few MB). */
export const MAX_IMAGE_BYTES = 48 * 1024 * 1024;
export const MAX_IMAGE_SIDE = 16_384;
/** 64 MP: 256 MB once decoded to RGBA. A 16,384 px square is four times that. */
export const MAX_IMAGE_PIXELS = 8_192 * 8_192;
const MAX_IMAGE_PIXELS = 8_192 * 8_192;
/** Where in a file to look for its dimensions (a JPEG's EXIF comes first). */
const HEADER_BYTES = 256 * 1024;
/** Whether a picture this size is past what the viewer decodes. */
export function tooManyPixels(width, height) {
function tooManyPixels(width, height) {
return (
width > MAX_IMAGE_SIDE ||
height > MAX_IMAGE_SIDE ||
@@ -37,7 +37,7 @@ export function tooManyPixels(width, height) {
}
/** Panorama texture widths: enough for a docked viewer, and for an expanded one. */
export const PANO_MID_SIZE = Object.freeze([2048, 1024]);
const PANO_MID_SIZE = Object.freeze([2048, 1024]);
export const PANO_FULL_SIZE = Object.freeze([4096, 2048]);
/**
@@ -155,7 +155,7 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) {
* A picture refused for its size (`tooLarge`): the viewer will not load
* that file another way, an <img> having no size limit at all.
*/
export function oversized(failure) {
function oversized(failure) {
return Object.assign(new Error(failure), { tooLarge: true });
}
@@ -14,7 +14,7 @@ export const KARTAVIEW_LABEL = 'KARTAVIEW';
*/
export const KARTAVIEW_API_HOST = 'https://api.kartaview.org';
/** KartaView's image proxy: resizes a photo given its base64url address. */
export const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org';
const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org';
/** Stable id prefixes for picked primitives; every one starts with `kv:`. */
export const PICK_PREFIX = Object.freeze({
@@ -140,8 +140,6 @@ export const MASK_SLOW_MESSAGE = msg(
export const FILTERED_OUT_MESSAGE = msg(
'None of KartaView’s photos here pass the imagery filter',
);
/** Under the SINCE slider while KartaView is lit. */
export const SINCE_NOTE = '(by upload date)';
/** The UTC calendar day of `ms`, as KartaView's startDate reads it. */
function utcDay(ms) {
@@ -224,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);
@@ -36,16 +36,29 @@ async function readJsonOrThrow(response, label) {
return payload;
}
/** `endpoint` with exactly one trailing slash, or '' when unset. */
function endpointBase(endpoint) {
const text = String(endpoint || '').trim();
return text ? text.replace(/\/*$/, '/') : '';
}
/**
* Mapillary network access: tiles via the local proxy, which adds the token
* and caches; Graph API lookups direct with the client token, as MapillaryJS.
* @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options
* With `graphEndpoint` (a Graph API base that adds the token itself, such as
* a same-origin proxy), the source's Graph lookups go there through
* `fetchImpl` instead, and the photo viewer sends its own lookups there too;
* no token is sent or needed.
* @param {{token?: string, fetchImpl?: Function, endpoints?: object,
* graphEndpoint?: string}} options
*/
export function createMapillarySource({
token = '',
fetchImpl = (...args) => globalThis.fetch(...args),
endpoints = {},
graphEndpoint = '',
} = {}) {
const graphBase = endpointBase(graphEndpoint);
const urls = {
status: '/api/mapillary/status',
tiles: '/api/mapillary/tiles',
@@ -54,6 +67,13 @@ export function createMapillarySource({
};
async function graph(path, params = {}, { signal } = {}) {
if (graphBase) {
const search = new URLSearchParams(params);
const response = await fetchImpl(`${graphBase}${path}?${search}`, {
signal,
});
return readJsonOrThrow(response, 'Mapillary graph');
}
if (!token)
throw new MapillarySourceError('Mapillary token not configured', {
status: 503,
@@ -70,6 +90,10 @@ export function createMapillarySource({
get token() {
return token;
},
/** The Graph API base that adds the token itself, or '' (direct). */
get graphEndpoint() {
return graphBase;
},
hasToken: () => Boolean(token),
async getStatus({ signal } = {}) {
@@ -125,3 +125,35 @@ test('a rejected key and a rate limit are told apart from other tile errors', as
'the Retry-After header wins over the body',
);
});
test('with a Graph endpoint, sequence lookups go there without a token', async () => {
const { calls, fetchImpl } = fakeFetch(() =>
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
);
const source = createMapillarySource({
graphEndpoint: '/api/mapillary/graph',
fetchImpl,
});
assert.equal(source.graphEndpoint, '/api/mapillary/graph/');
assert.equal(source.hasToken(), false);
const images = await source.getSequenceImages('seq-1');
assert.equal(images.length, 1);
const url = new URL(calls[0].url, 'http://localhost');
assert.equal(url.pathname, '/api/mapillary/graph/images');
assert.equal(url.searchParams.get('sequence_ids'), 'seq-1');
assert.equal(url.searchParams.has('access_token'), false);
});
test('Graph endpoint errors keep their status', async () => {
const { fetchImpl } = fakeFetch(() =>
jsonResponse({ error: 'no_key', keyRequired: true }, 503),
);
const source = createMapillarySource({
graphEndpoint: '/api/mapillary/graph/',
fetchImpl,
});
await assert.rejects(
() => source.getSequenceImages('1'),
(error) => error.status === 503 && error.keyRequired === true,
);
});
@@ -13,6 +13,20 @@ import { mapillaryImageUrl } from './policy.js';
* @property {string} externalUrl Deep link to the image on mapillary.com.
*/
/**
* The source's Graph endpoint as an absolute URL ending in `/`, as MapillaryJS
* resolves its lookup paths against it; '' when the source has none.
*/
export function graphEndpointUrl(source) {
if (!source?.graphEndpoint) return '';
const url = new URL(
source.graphEndpoint,
globalThis.location?.href || 'http://localhost/',
);
url.pathname = url.pathname.replace(/\/*$/, '/');
return url.href;
}
/**
* The panel's photo viewer: loads MapillaryJS on the first `mount` and emits
* a pose on every image, view or position change.
@@ -115,12 +129,18 @@ export function createMapillaryViewer({ source, render } = {}) {
async function buildViewer(host) {
const built = generation;
destroyViewer();
const { Viewer } = await ensureLibrary();
const { Viewer, GraphDataProvider } = await ensureLibrary();
// Unmounted (layer off) while the library loaded: a viewer built now
// would hold a WebGL context nobody releases.
if (built !== generation) throw new Error('Mapillary viewer was unmounted');
const endpoint = graphEndpointUrl(source);
viewer = new Viewer({
accessToken: source.token,
// A source with its own Graph endpoint (which adds the token itself)
// answers the viewer's lookups, sent without a token; otherwise
// MapillaryJS calls the Graph API with the client token.
...(endpoint
? { dataProvider: new GraphDataProvider({ endpoint }) }
: { accessToken: source.token }),
container: host,
component: {
cover: false,
@@ -41,8 +41,6 @@ import {
normalizeItem,
} from './policy.js';
export { PANORAMAX_PROVIDER_ID } from './policy.js';
/**
* A tile's bytes decoded by `decode`; a body that is not a vector tile
* (an HTML error page, a cut-off answer) reads as such, not as a parser's
@@ -15,7 +15,7 @@ export const PANORAMAX_LABEL = 'PANORAMAX';
*/
export const PANORAMAX_API = 'https://api.panoramax.xyz/api';
/** The meta-catalogue's web viewer, where the caption link goes. */
export const PANORAMAX_VIEWER = 'https://api.panoramax.xyz/';
const PANORAMAX_VIEWER = 'https://api.panoramax.xyz/';
/** Stable id prefixes for picked primitives; every one starts with `pnx:`. */
export const PICK_PREFIX = Object.freeze({
+1 -1
View File
@@ -1,6 +1,6 @@
import { encodeIdSwitches } from '../../data/idSwitches.js';
export const IMAGERY_FILTERS = Object.freeze(['pano', 'since']);
const IMAGERY_FILTERS = Object.freeze(['pano', 'since']);
export function filtersOf(provider) {
return provider?.filters ?? IMAGERY_FILTERS;
+2 -2
View File
@@ -5,8 +5,8 @@ import { createHorizonCull, metresBetween } from './view.js';
import { msg } from '../../i18n/index.js';
/** Cones closer than this to the previous kept one are dropped. */
export const IMAGE_CONE_MIN_SPACING_M = 3;
export const IMAGE_CONE_SIZE_PX = 26;
const IMAGE_CONE_MIN_SPACING_M = 3;
const IMAGE_CONE_SIZE_PX = 26;
/**
* @typedef {object} ConeImage One image of a sequence, as the cones draw it.
+2 -2
View File
@@ -1,6 +1,6 @@
/** Decoded coverage tiles kept, and for how long. */
export const TILE_CACHE_SIZE = 24;
export const TILE_CACHE_TTL_MS = 10 * 60_000;
const TILE_CACHE_SIZE = 24;
const TILE_CACHE_TTL_MS = 10 * 60_000;
/** Least-recently-used map: reading an entry renews it. */
export function createLru(limit) {
+2 -2
View File
@@ -64,7 +64,7 @@ export function keyAlternatives(providers) {
* The filter controls a switched-on provider honours (both when none is on).
* @param {Array<ProviderSnapshot>} providers
*/
export function filterControls(providers) {
function filterControls(providers) {
const active = providers.filter((p) => p.on);
const honoured = (key) =>
active.length === 0 ||
@@ -78,7 +78,7 @@ export function filterControls(providers) {
* note needs no provider name.
* @param {Array<ProviderSnapshot>} providers
*/
export function sinceNote(providers) {
function sinceNote(providers) {
return providers.some((p) => p.on && p.sinceBasis === 'upload')
? t('(by upload date)')
: null;
+55 -3
View File
@@ -22,13 +22,16 @@ registerHooks({
const source = url.includes('/library?')
? 'const lib = await globalThis.__gevMapillaryLibrary();\n' +
'export const Viewer = lib.Viewer;\n' +
'export const RenderMode = lib.RenderMode;\n'
'export const RenderMode = lib.RenderMode;\n' +
'export const GraphDataProvider = lib.GraphDataProvider;\n'
: 'export default {};\n';
return { format: 'module', shortCircuit: true, source };
},
});
const { createMapillaryViewer } =
const { createMapillaryViewer, graphEndpointUrl } =
await import('./providers/mapillary/viewer.js');
const { createMapillarySource } =
await import('./providers/mapillary/source.js');
/**
* A viewer host over `adapter`. `marker` records where the position marker
@@ -345,7 +348,7 @@ function fakeLibrary() {
}
globalThis.__gevMapillaryLibrary = async () => {
await gate.promise;
return { Viewer, RenderMode: {} };
return { Viewer, RenderMode: {}, GraphDataProvider: FakeGraphDataProvider };
};
return { gate, viewers };
}
@@ -565,3 +568,52 @@ test('a pose’s licence reaches the caption state, and a closed viewer forgets
host.close();
assert.equal(state.street.license, null);
});
/** MapillaryJS 4's Graph provider, recording what it was built with. */
class FakeGraphDataProvider {
constructor(options = {}) {
this.options = options;
}
}
test('without a Graph endpoint the viewer gets the client token, as before', async () => {
const library = fakeLibrary();
library.gate.resolve();
const adapter = createMapillaryViewer({ source: { token: 't' } });
await adapter.mount({});
const [viewer] = library.viewers.instances;
assert.equal(viewer.options.accessToken, 't');
assert.equal(viewer.options.dataProvider, undefined);
});
test("with a Graph endpoint the viewer uses MapillaryJS's Graph provider there, without a token", async () => {
const library = fakeLibrary();
library.gate.resolve();
const source = createMapillarySource({
graphEndpoint: 'https://app.example/api/mapillary/graph',
fetchImpl: async () => {
throw new Error('the viewer does not use the source transport');
},
});
const adapter = createMapillaryViewer({ source });
await adapter.mount({});
const [viewer] = library.viewers.instances;
assert.equal(viewer.options.accessToken, undefined);
const provider = viewer.options.dataProvider;
assert.ok(provider instanceof FakeGraphDataProvider);
assert.deepEqual(provider.options, {
endpoint: 'https://app.example/api/mapillary/graph/',
});
});
test('a relative Graph endpoint resolves against the page, with one trailing slash', () => {
assert.equal(
graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph' }),
'http://localhost/api/mapillary/graph/',
);
assert.equal(
graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph//' }),
'http://localhost/api/mapillary/graph/',
);
assert.equal(graphEndpointUrl({ token: 't' }), '');
});
+3 -3
View File
@@ -33,7 +33,7 @@ export function featureReference(feature) {
};
}
export function coordsFromProperty(value) {
function coordsFromProperty(value) {
if (!Array.isArray(value) || value.length < 2) return null;
const lon = Number(value[0]);
const lat = Number(value[1]);
@@ -41,7 +41,7 @@ export function coordsFromProperty(value) {
return [lon, lat];
}
export function coordsFromPoint(value) {
function coordsFromPoint(value) {
if (!Array.isArray(value) || value.length < 2) return null;
const lon = Number(value[0]);
const lat = Number(value[1]);
@@ -49,7 +49,7 @@ export function coordsFromPoint(value) {
return [lon, lat];
}
export function collectLonLat(value, out) {
function collectLonLat(value, out) {
if (!Array.isArray(value)) return;
if (typeof value[0] === 'number' && typeof value[1] === 'number') {
const lon = Number(value[0]);
+28 -1
View File
@@ -20,6 +20,23 @@ export function createAnimation({
parts,
source,
}) {
function syncRenderDemand() {
const needed = Boolean(
layerState._enabled &&
(layerState._dots.length || layerState._heatJamPrim),
);
// Height easing only runs in a held frame. Settle it before releasing, so
// a population emptied mid-correction cannot report loading forever.
if (!needed && layerState._heightPending) parts.retention.finishHeights();
if (needed === Boolean(layerState._renderHeld)) return;
layerState._renderHeld = needed;
if (needed) {
layerState._lastAnimTime = 0;
services.render.holdContinuousRender('traffic');
layerState._viewer?.scene?.requestRender?.();
} else services.render.releaseContinuousRender('traffic');
}
/**
* Spawn animated dot primitives along a single road.
*
@@ -188,6 +205,7 @@ export function createAnimation({
: null,
});
}
syncRenderDemand();
}
/**
@@ -206,6 +224,8 @@ export function createAnimation({
*/
function animate() {
syncRenderDemand();
if (!layerState._renderHeld) return;
const now = Date.now();
// Delta time in seconds, capped to avoid jumps when returning from background tab
const dt = layerState._lastAnimTime
@@ -339,6 +359,13 @@ export function createAnimation({
layerState._count = 0;
layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 };
layerState._closedRoads = 0;
syncRenderDemand();
}
return { spawnDotsForRoad, animate, maybeStopLight, clearDots };
return {
spawnDotsForRoad,
animate,
maybeStopLight,
clearDots,
syncRenderDemand,
};
}
+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();
},
};
}
+3 -3
View File
@@ -1,9 +1,9 @@
import * as Cesium from 'cesium';
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
export const RETICLE_RANGE_KM = 10;
export const RETICLE_TILE_CAP = 16;
export const tileKey = (tile) => `${tile.z}/${tile.x}/${tile.y}`;
const RETICLE_RANGE_KM = 10;
const RETICLE_TILE_CAP = 16;
const tileKey = (tile) => `${tile.z}/${tile.x}/${tile.y}`;
const cross = (a, b, c) =>
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
+2 -3
View File
@@ -12,7 +12,6 @@ export function createLifecycle({
parts,
source,
}) {
const { holdContinuousRender, releaseContinuousRender } = services.render;
const { resetFlowTileCache } = source;
const methods = {
@@ -81,7 +80,7 @@ export function createLifecycle({
layerState._enabled = true;
layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene);
if (layerState._roadMode !== 'tomtom') source.prefetch?.();
holdContinuousRender('traffic'); // per-frame animator (perf wave 2)
parts.animation.syncRenderDemand();
layerState._lastAnimTime = 0;
layerState._pointCollection.show = true;
@@ -142,7 +141,7 @@ export function createLifecycle({
disable(viewer) {
layerState._enabled = false;
services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic');
releaseContinuousRender('traffic');
parts.animation.syncRenderDemand();
clearTimeout(layerState._fetchTimeout);
clearInterval(layerState._enableKickTimer);
layerState._enableKickTimer = null;
+39 -1
View File
@@ -37,7 +37,14 @@ function setup(t) {
visibleRoadsForAltitude: (roads) => roads,
},
};
const ctx = { state, services: {}, parts, source: {} };
const ctx = {
state,
services: {
render: { holdContinuousRender() {}, releaseContinuousRender() {} },
},
parts,
source: {},
};
parts.style = createStyle(ctx);
parts.model = createModel(ctx);
parts.animation = createAnimation(ctx);
@@ -558,6 +565,37 @@ test('provisional opposite-direction heights cannot overwrite a measured sibling
assert.notEqual(forward.waypoints, reverse.waypoints);
});
test('a closure that empties the population mid-correction settles heights and clears loading before release', (t) => {
const h = setup(t);
h.parts.retention.reconcile(h.roads(), 450, true);
h.settle();
assert.equal(h.state._renderHeld, true);
const road = h.state._roads[0];
// Republishing the sole road starts a height correction.
const corrected = h.roads(0, 120);
h.parts.retention.reconcile(corrected, 450, true);
assert.equal(h.state._heightPending, true);
assert.equal(road.heightMoving, true);
// The closure arrives before the next population pass, which then removes
// the final points after their fade, long before the correction finishes.
corrected[0].flow = { level: 0, closure: true };
for (let i = 0; i < 20 && h.state._dots.length; i++) h.frame();
assert.equal(h.state._dots.length, 0);
assert.equal(h.state._heatJamPrim, null);
assert.equal(h.state._renderHeld, false);
assert.equal(h.state._heightPending, false);
assert.equal(h.state._populationPending, false);
assert.equal(road.heightMoving, false);
assert.ok(
Math.abs(C.Cartographic.fromCartesian(road.waypoints[0]).height - 120) <
0.01,
);
// Frames driven by other layers stay idle and cannot revive the work.
h.frame();
assert.equal(h.state._heightPending, false);
assert.equal(h.state._renderHeld, false);
});
test('large height corrections obey a screen-pixel bound and reuse projection scratch objects', (t) => {
const h = setup(t);
h.state._viewer.scene = {
-10
View File
@@ -175,13 +175,3 @@ export const STYLED_MIN_BASE_PX = 5;
/** @const {number} Maximum tile cache entries before LRU eviction */
export const TILE_CACHE_MAX_ENTRIES = 64;
/**
* Milliseconds the first dot paint will wait for flow data. Cached flow
* settles within this window (decode cache, 120 s TTL) and renders fully
* colored; a cold tile fetch loses the race, dots paint immediately in
* white, and recolorDotsInPlace applies the colors when flow arrives —
* field-test round 1's "takes forever to load" was the sequential wait.
*/
export const FLOW_RENDER_RACE_MS = 250;
+45
View File
@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createAnimation } from './animation.js';
test('traffic render demand follows animated population and jam lines across empty, disable and re-enable', () => {
const calls = [];
const state = { _enabled: true, _dots: [], _motion: { rebuilds: 0 } };
const animation = createAnimation({
state,
services: {
render: {
holdContinuousRender: (id) => calls.push(['hold', id]),
releaseContinuousRender: (id) => calls.push(['release', id]),
},
},
parts: {
retention: { clear() {} },
rendering: {
removeHeatLines() {
state._heatJamPrim = null;
},
},
},
});
animation.syncRenderDemand();
assert.deepEqual(calls, []);
state._dots.push({});
animation.syncRenderDemand();
animation.syncRenderDemand();
assert.deepEqual(calls, [['hold', 'traffic']]);
animation.clearDots();
assert.deepEqual(calls.at(-1), ['release', 'traffic']);
state._heatJamPrim = {};
animation.syncRenderDemand();
assert.deepEqual(calls.at(-1), ['hold', 'traffic']);
state._enabled = false;
animation.syncRenderDemand();
assert.deepEqual(calls.at(-1), ['release', 'traffic']);
state._enabled = true;
animation.clearDots();
assert.equal(state._renderHeld, false);
state._dots.push({});
animation.syncRenderDemand();
assert.equal(state._renderHeld, true);
});
+2
View File
@@ -50,6 +50,7 @@ export function createRendering({
layerState._heatSlowPrim = null;
}
layerState._heatLineCount = 0;
parts.animation.syncRenderDemand?.();
}
/**
@@ -152,6 +153,7 @@ export function createRendering({
}
layerState._heatLineCount = kept.length;
parts.animation.syncRenderDemand?.();
if (candidates.length > kept.length) {
console.log(
`[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`,
+25 -1
View File
@@ -125,6 +125,7 @@ export function createRetention({ state, parts }) {
count++;
}
state._count = count;
parts.animation.syncRenderDemand?.();
}
function reconcile(roads, height, replace) {
altitude = height;
@@ -241,6 +242,29 @@ export function createRetention({ state, parts }) {
road.heightMoving = moving;
}
}
/**
* Settle every retained road on its corrected heights at once. Easing only
* runs inside the held animation frame, so once nothing is left to animate
* the remaining correction would otherwise stay pending indefinitely.
* Published roads whose record was just dropped are settled too, so a later
* held frame cannot revive their correction.
*/
function finishHeights() {
const roads = new Set(state._roads);
for (const record of records.values()) roads.add(record.road);
for (const road of roads) {
if (!road.heightMoving) continue;
for (let i = 0; i < road.waypoints.length; i++)
Cesium.Cartesian3.clone(road.targetWaypoints[i], road.waypoints[i]);
for (let i = 0; i < road.segmentDist.length; i++)
road.segmentDist[i] = Cesium.Cartesian3.distance(
road.waypoints[i],
road.waypoints[i + 1],
);
road.heightMoving = false;
}
state._heightPending = false;
}
function clear() {
if (timer !== null) {
if (typeof cancelAnimationFrame === 'function')
@@ -252,5 +276,5 @@ export function createRetention({ state, parts }) {
state._populationPending = false;
state._heightPending = false;
}
return { reconcile, easeHeights, clear, refreshCounts };
return { reconcile, easeHeights, finishHeights, clear, refreshCounts };
}
+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 -1
View File
@@ -1,6 +1,6 @@
import { normalizeRoadMode } from './roadModes.js';
import * as Cesium from 'cesium';
import { FLOW_BUCKET_COLORS, TRAFFIC_TIMING_ENABLED } from './policy.js';
import { FLOW_BUCKET_COLORS } from './policy.js';
export function createState({ services }) {
const layerState = {};
-1
View File
@@ -9,7 +9,6 @@ import {
FEED_STALE_AFTER_MS,
MAX_VEHICLES_TOTAL,
MISSED_POLLS_TO_DROP,
TRANSIT_POLL_MS,
isStaleVehicleFix,
transitVehicleKey,
vehicleReportTimeMs,
+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() {
+1 -3
View File
@@ -6,7 +6,6 @@ import {
FEED_EVICT_AFTER_MS,
FEED_STALE_AFTER_MS,
MISSED_POLLS_TO_DROP,
SELECTED_CARD_REFRESH_MS,
TRANSIT_POLL_MS,
VEHICLE_MAX_FIX_AGE_MS,
} from './policy.js';
@@ -1407,7 +1406,6 @@ test('the selected card lands where the vehicle stopped, not short of it', async
// segment ends left it a few metres behind, and then the render hold was
// released with no frame scheduled to close the gap.
const app = harness(t);
const start = Date.now();
app.serve('mbta', () => ({
status: 200,
body: snapshot('mbta', 'MBTA', [vehicle('b', 42.36, -71.06, reported())], {
@@ -3576,7 +3574,7 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve
source: {},
parts: {
flow: { ensureFlowStatus: noop },
animation: { animate: noop, clearDots: noop },
animation: { animate: noop, clearDots: noop, syncRenderDemand: noop },
viewport: { onCameraChanged: noop },
ingestion: { cancelActiveFetch: noop },
},
+8 -38
View File
@@ -9,10 +9,7 @@ import {
correctHeight,
setTimeOffsetMs,
} from '../../data/contactPlayback.js';
export const FIX_HISTORY_MAX = 128;
export const DISPLAY_LAG_MIN_MS = 25_000;
export const DISPLAY_LAG_RESET_MS = 90_000;
export const MODE_MAX_SPEED_MPS = Object.freeze({
const MODE_MAX_SPEED_MPS = Object.freeze({
bus: 36, // ~130 km/h — a coach on a motorway
tram: 28, // ~100 km/h — interurban light rail
subway: 42, // ~150 km/h
@@ -32,22 +29,19 @@ export const MODE_MAX_SPEED_MPS = Object.freeze({
* @param {boolean} [inferred] Whether the mode came from a feed default.
* @returns {number} m/s
*/
export function speedCeilingMps(mode, inferred = false) {
function speedCeilingMps(mode, inferred = false) {
if (inferred) return MODE_MAX_SPEED_MPS.unknown;
return MODE_MAX_SPEED_MPS[mode] ?? MODE_MAX_SPEED_MPS.unknown;
}
/** Position deadband is disabled: even a short reported crawl is played. */
export const FIX_NOISE_M = 0;
/**
* Displacement (m) a glide must cover before its direction is worth believing.
* Below this the course derived from it is noise; the last good one is held.
*/
export const COURSE_MIN_TRAVEL_M = 12;
const COURSE_MIN_TRAVEL_M = 12;
/** Maximum display-course change per second (deg). A bus is not a gyroscope. */
export const COURSE_MAX_RATE_DEG_PER_S = 120;
const COURSE_MAX_RATE_DEG_PER_S = 120;
/**
* Longest stretch of elapsed time (s) one evaluation may spend. The limiter
* used to bank the time since the course last CHANGED, so a vehicle that held
@@ -56,15 +50,7 @@ export const COURSE_MAX_RATE_DEG_PER_S = 120;
* EVALUATED, and capped here so that even a first look after a long idle
* sweeps visibly instead of snapping.
*/
export const COURSE_STEP_MAX_S = 0.25;
/**
* Consecutive fixes at the same place before a vehicle is called STOPPED.
* One is not enough: a fifteen-second gap between identical fixes is also what
* a vehicle waiting at a light looks like, and so is a feed that repeated
* itself.
*/
export const STOPPED_FIXES = 2;
const COURSE_STEP_MAX_S = 0.25;
/**
* How long after its newest fix a vehicle stops being described as travelling.
@@ -75,7 +61,7 @@ export const STOPPED_FIXES = 2;
* otherwise is the same overreach as reading STOPPED off a stale status. Ninety
* seconds matches the threshold at which a whole feed reads as gone quiet.
*/
export const MOTION_UNKNOWN_AFTER_MS = 90_000;
const MOTION_UNKNOWN_AFTER_MS = 90_000;
const EARTH_M_PER_DEG = 111_320;
@@ -94,29 +80,13 @@ export function fixDistanceM(a, b) {
return Math.hypot(dLat, dLon);
}
/**
* Course in degrees clockwise from north, from one point to another.
* @param {{lat:number, lon:number}} from
* @param {{lat:number, lon:number}} to
* @returns {number} 0-360
*/
export function courseBetween(from, to) {
const dLat = (to.lat - from.lat) * EARTH_M_PER_DEG;
const dLon =
(to.lon - from.lon) *
EARTH_M_PER_DEG *
Math.cos((from.lat * Math.PI) / 180);
const deg = (Math.atan2(dLon, dLat) * 180) / Math.PI;
return (deg + 360) % 360;
}
/**
* Shortest signed difference between two courses, in degrees (-180, 180].
* @param {number} from
* @param {number} to
* @returns {number}
*/
export function courseDelta(from, to) {
function courseDelta(from, to) {
let delta = ((to - from + 540) % 360) - 180;
if (delta === -180) delta = 180;
return delta;
@@ -259,7 +229,7 @@ export function attachFloorToPlace(entry, fix, heightM) {
correctHeight(entry.track, scratch.seq, heightM);
}
}
export function displayCourse(entry, nowMs) {
function displayCourse(entry, nowMs) {
if (!entry) return null;
const target = Number.isFinite(entry.sample?.segmentCourseDeg)
? entry.sample.segmentCourseDeg
+1 -1
View File
@@ -9,7 +9,7 @@ import {
displacementPlausible,
} from './movement.js';
import { buildTransitSelectionCopy } from './policy.js';
import { seek, sampleAt } from '../../data/contactPlayback.js';
import { sampleAt } from '../../data/contactPlayback.js';
const entry = () => {
const e = { mode: 'bus', modeInferred: false, courseDeg: null };
initializePlayback(e);
+10 -8
View File
@@ -99,7 +99,7 @@ export const VISIBILITY_REFRESH_MS = 250;
* on screen. Generous on purpose: a vehicle that crosses the edge mid-glide
* should already have been moving, not pop into motion at the border.
*/
export const VIEW_MARGIN_RATIO = 0.6;
const VIEW_MARGIN_RATIO = 0.6;
/**
* Whether a vehicle is close enough to the camera's look-at area to be worth
@@ -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 =
@@ -516,10 +518,10 @@ export function createTransitSelectedOverlayEntry(key, position, copy, mode) {
}
/** Modes that ordinarily run below the street, drawn on the surface above themselves. */
export const UNDERGROUND_MODES = new Set(['subway']);
const UNDERGROUND_MODES = new Set(['subway']);
/** The mode in plain words, for a card a person reads rather than scans. */
export const TRANSIT_MODE_WORD = Object.freeze({
const TRANSIT_MODE_WORD = Object.freeze({
bus: msg('Bus'),
tram: msg('Tram'),
subway: msg('Subway'),
@@ -529,7 +531,7 @@ export const TRANSIT_MODE_WORD = Object.freeze({
});
/** Short, fixed-width mode word for the detection card's class field. */
export const TRANSIT_MODE_ABBR = Object.freeze({
const TRANSIT_MODE_ABBR = Object.freeze({
bus: 'BUS',
tram: 'TRAM',
subway: 'METRO',
@@ -588,7 +590,7 @@ export function transitDetectionId(record, feed = null) {
}
/** Longest route text the single-line detection label can carry. */
export const DETECTION_ID_MAX_CHARS = 14;
const DETECTION_ID_MAX_CHARS = 14;
/**
* Cut long route text with an ellipsis, so a reader can see it was cut.
@@ -600,7 +602,7 @@ export const DETECTION_ID_MAX_CHARS = 14;
* @param {string} text
* @returns {string} Uppercase, at most DETECTION_ID_MAX_CHARS characters.
*/
export function elideRouteText(text) {
function elideRouteText(text) {
const value = String(text || '').toUpperCase();
if (value.length <= DETECTION_ID_MAX_CHARS) return value;
return `${value.slice(0, DETECTION_ID_MAX_CHARS - 1)}\u2026`;
@@ -659,6 +661,6 @@ export function transitDetectionMetric(
* @param {number} [nowMs]
* @returns {string}
*/
export function transitVehicleState(entry, nowMs = Date.now()) {
function transitVehicleState(entry, nowMs = Date.now()) {
return displayMotion(entry, nowMs).word;
}
-1
View File
@@ -22,7 +22,6 @@ import {
import {
MARKER_PIXEL_SIZE,
ROTATION_REFRESH_MS,
SELECTED_CARD_REFRESH_MS,
VISIBILITY_REFRESH_MS,
SELECTED_PIXEL_SIZE,
TRANSIT_MODE_COLORS,
@@ -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();
-2
View File
@@ -12,8 +12,6 @@ export function createCards({
layer,
options,
}) {
const { state } = vesselState;
function updateSelectedVesselHud(record) {
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
-5
View File
@@ -1,8 +1,3 @@
export {
PARTIAL_RETENTION_MS,
SELECTED_PIN_REFRESHES,
AIS_FIRST_CONNECT_LABEL,
} from './recordPolicy.js';
import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js';
import { msg } from '../../i18n/index.js';
+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 -8
View File
@@ -296,10 +296,6 @@ export function createWeatherLayer({
motion?.addEventListener?.('change', onVisibility);
documentRef?.addEventListener?.('visibilitychange', onVisibility);
registerClock();
removeCamera = viewer.camera.moveEnd?.addEventListener(() => {
checkHost();
if (enabled) notify();
});
},
attachShellServices(services) {
imageryHost =
@@ -315,10 +311,18 @@ export function createWeatherLayer({
},
enable() {
enabled = true;
if (!removeCamera) {
removeCamera = viewer.camera.moveEnd?.addEventListener(() => {
checkHost();
notify();
});
}
registerClock();
},
disable() {
enabled = false;
removeCamera?.();
removeCamera = null;
unregisterClock?.();
unregisterClock = null;
++generation;
@@ -357,7 +361,7 @@ export function createWeatherLayer({
signal: controller.signal,
});
if (!enabled || controller.signal.aborted || request !== controller)
return false;
return enabled && !signal?.aborted && request !== controller;
if (snapshot.unavailable) {
error = msg(
'Weather source unavailable; previous observation retained',
@@ -376,7 +380,9 @@ export function createWeatherLayer({
)
await show(snapshot.latest, controller.signal);
return (
!controller.signal.aborted && request === controller && enabled
enabled &&
!signal?.aborted &&
(!controller.signal.aborted || request !== controller)
);
}
const time =
@@ -388,9 +394,14 @@ export function createWeatherLayer({
(satellite && rendering.getDiagnostics().infrared !== infrared)
)
await show(time, controller.signal);
return !controller.signal.aborted && request === controller && enabled;
return (
enabled &&
!signal?.aborted &&
(!controller.signal.aborted || request !== controller)
);
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
if (controller.signal.aborted || request !== controller)
return enabled && !signal?.aborted && request !== controller;
error = cause?.message || msg('Weather unavailable');
stop();
return true;
+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();
+1 -1
View File
@@ -4,7 +4,7 @@ import { readResponseJsonCapped } from '../../sources/httpBody.js';
// the weather layer translates them where it shows them (t / translateKnown).
const msg = (text) => text;
export const WEATHER_PRODUCTS = Object.freeze([
const WEATHER_PRODUCTS = Object.freeze([
'radar',
'clouds',
'clouds-regional',
Loaded 100 of 515 files, more files were not shown because too many files have changed in this diff. Show more