mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge current main into S2 GhostMaps and preserve updated import and voice behavior
This commit is contained in:
commit
4da002bd42
515 files changed
+17681
-2202
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js';
|
||||
|
||||
export function textTag(value) {
|
||||
function textTag(value) {
|
||||
const t = String(value ?? '').trim();
|
||||
return t || null;
|
||||
}
|
||||
|
||||
export function numTag(value) {
|
||||
function numTag(value) {
|
||||
// `Number('')` and `Number(' ')` are 0, not NaN — a blank or whitespace-only
|
||||
// tag value must read as missing, never as a due-north bearing.
|
||||
if (value == null) return null;
|
||||
@@ -102,7 +102,7 @@ export function normalizeAlprNode(el) {
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDirection(value) {
|
||||
function normalizeDirection(value) {
|
||||
const degrees = numTag(value);
|
||||
return degrees != null && degrees >= 0 && degrees <= 360
|
||||
? degrees % 360
|
||||
|
||||
@@ -3,7 +3,7 @@ import { VectorTile } from '@mapbox/vector-tile';
|
||||
import { normalizeAlprNode } from './records.js';
|
||||
|
||||
/** Map extract attributes without treating an OSM edit date as a field verification. */
|
||||
export function normalizeAlprTileFeature(feature) {
|
||||
function normalizeAlprTileFeature(feature) {
|
||||
const p = feature?.properties || {};
|
||||
if (feature?.geometry?.type !== 'Point' || p.osmType !== 'node') return null;
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Format a compass direction for the awareness overlay.
|
||||
* @param {number} degrees
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatAwarenessDirection(degrees) {
|
||||
if (!Number.isFinite(degrees)) return '—';
|
||||
const normalized = ((degrees % 360) + 360) % 360;
|
||||
return String(Math.round(normalized) % 360).padStart(3, '0');
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { formatAwarenessDirection } from './presentation.js';
|
||||
|
||||
test('awareness bearings that round through north read 000, not 360', () => {
|
||||
assert.equal(formatAwarenessDirection(359.4), '359');
|
||||
assert.equal(formatAwarenessDirection(359.6), '000');
|
||||
assert.equal(formatAwarenessDirection(0), '000');
|
||||
assert.equal(formatAwarenessDirection(-0.4), '000');
|
||||
assert.equal(formatAwarenessDirection(Number.NaN), '—');
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../../data/militaryAwarenessEngine.js';
|
||||
import { bearingBetweenCoordinates } from '../../cockpitMath.js';
|
||||
import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js';
|
||||
import { formatAwarenessDirection } from './presentation.js';
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
@@ -225,12 +226,12 @@ export function createRendering({
|
||||
: null;
|
||||
const bearingText = Number.isFinite(bearing)
|
||||
? t('BRG {value}°', {
|
||||
value: String(Math.round(bearing)).padStart(3, '0'),
|
||||
value: formatAwarenessDirection(bearing),
|
||||
})
|
||||
: t('BRG —');
|
||||
const courseText = Number.isFinite(item.track)
|
||||
? ` · ${t('CRS {value}°', {
|
||||
value: String(Math.round(item.track)).padStart(3, '0'),
|
||||
value: formatAwarenessDirection(item.track),
|
||||
})}`
|
||||
: '';
|
||||
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
|
||||
|
||||
@@ -60,7 +60,7 @@ test('the layer reports failure when a failed city left it with nothing', () =>
|
||||
});
|
||||
|
||||
test('a layer with nothing to draw and nothing failed is not an error', () => {
|
||||
const { state, methods } = makeLayer();
|
||||
const { methods } = makeLayer();
|
||||
|
||||
// Out of range of every city is empty, not broken.
|
||||
assert.equal(methods.getStats().error, undefined);
|
||||
|
||||
@@ -11,7 +11,7 @@ export const BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
// --- Activation / display thresholds ---
|
||||
/** Altitude (m) at which bikeshare layer becomes eligible for display. */
|
||||
|
||||
export const ACTIVATION_ALTITUDE_M = 50000;
|
||||
const ACTIVATION_ALTITUDE_M = 50000;
|
||||
|
||||
/** Hysteresis enter threshold — layer activates when camera drops below this. */
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js';
|
||||
import { CAMERA_SEEDS } from './policy.js';
|
||||
import {
|
||||
lookupModelSpec,
|
||||
horizontalFovRangeDeg,
|
||||
|
||||
@@ -5,9 +5,6 @@ import {
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
PROJECTION_MATTE_COLOR,
|
||||
PROJECTION_TEXTURE_SWAP_MS,
|
||||
ACTIVE_FRAME_REFRESH_MS,
|
||||
FRAME_ENDPOINT,
|
||||
MEDIA_ENDPOINT,
|
||||
PROJECTION_ACTIVE_REFRESH_MS,
|
||||
PROJECTION_IDLE_REFRESH_MS,
|
||||
PLACEHOLDER_REPAINT_MS,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js';
|
||||
import {
|
||||
PROJECTION_VERT_ASPECT,
|
||||
FRUSTUM_GROUND_CLEARANCE_M,
|
||||
PROBE_MIN_RANGE_M,
|
||||
PROBE_CLEARANCE_M,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HEALTH_SYNC_INTERVAL_MS, HEALTH_ENDPOINT } from './policy.js';
|
||||
import { HEALTH_SYNC_INTERVAL_MS } from './policy.js';
|
||||
|
||||
export function createHealth({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
export {
|
||||
FRAME_ENDPOINT,
|
||||
SOURCE_ENDPOINT,
|
||||
HEALTH_ENDPOINT,
|
||||
MEDIA_ENDPOINT,
|
||||
ACTIVE_FRAME_REFRESH_MS,
|
||||
} from './sourcePolicy.js';
|
||||
export { ACTIVE_FRAME_REFRESH_MS } from './sourcePolicy.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js';
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
@@ -90,9 +84,6 @@ export const CCTV_CALIBRATION_STORAGE_KEY_V2 =
|
||||
|
||||
export const PROJECTION_TEXTURE_SWAP_MS = 1000;
|
||||
|
||||
export const PROJECTION_VERT_ASPECT =
|
||||
PROJECTION_CANVAS_WIDTH / PROJECTION_CANVAS_HEIGHT;
|
||||
|
||||
// V2 frustum geometry (design §2a/§6): the far-cap center + corners never sink
|
||||
// below groundAlt + this clearance, so a fabricated pitch (-24°) cannot bury
|
||||
// the monitor plane in the 3D tiles. Exported for the unit suite.
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
export const FRAME_ENDPOINT = '/api/cctv/frame';
|
||||
|
||||
export const SOURCE_ENDPOINT = '/api/cctv/sources';
|
||||
|
||||
export const HEALTH_ENDPOINT = '/api/cctv/health';
|
||||
|
||||
export const MEDIA_ENDPOINT = '/api/cctv/media';
|
||||
|
||||
export const ACTIVE_FRAME_REFRESH_MS = 10000;
|
||||
@@ -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)
|
||||
|
||||
@@ -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'],
|
||||
]) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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']);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -152,7 +152,7 @@ export function localAdsbCardModel(
|
||||
t('ALT {altitude} FT · GS {speed} KT · TRK {track}', {
|
||||
altitude: grouped(altitude),
|
||||
speed: speed === null ? DASH : speed,
|
||||
track: track === null ? DASH : `${track}°`,
|
||||
track: track === null ? DASH : `${track % 360}°`,
|
||||
}),
|
||||
vertical === null
|
||||
? t('V/S {rate}', { rate: DASH })
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import { localAdsbCardModel } from './card.js';
|
||||
|
||||
test('a track of 359.7° reads TRK 0°, not TRK 360°', () => {
|
||||
setLocale('en');
|
||||
const { details } = localAdsbCardModel(
|
||||
{
|
||||
icao: 'abc123',
|
||||
altitudeFt: 12500,
|
||||
groundSpeedKt: 240,
|
||||
trackDeg: 359.7,
|
||||
lastPositionAt: 1000,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
assert.equal(details[1], 'ALT 12,500 FT · GS 240 KT · TRK 0°');
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,6 +25,7 @@ export function createState({ services }) {
|
||||
layerState._stale = false;
|
||||
|
||||
layerState._degraded = false;
|
||||
layerState._rejectedStationCount = 0;
|
||||
|
||||
layerState._error = null;
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }), '');
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)`,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,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 = {};
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
FEED_STALE_AFTER_MS,
|
||||
MAX_VEHICLES_TOTAL,
|
||||
MISSED_POLLS_TO_DROP,
|
||||
TRANSIT_POLL_MS,
|
||||
isStaleVehicleFix,
|
||||
transitVehicleKey,
|
||||
vehicleReportTimeMs,
|
||||
|
||||
@@ -97,6 +97,7 @@ export function createLifecycle({ state, services, parts }) {
|
||||
else if (hidden) {
|
||||
hidden = false;
|
||||
parts.rendering.resumePlayback();
|
||||
parts.viewport.runProximityCheck();
|
||||
}
|
||||
}
|
||||
function bindVisibility() {
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
Reference in new issue
Block a user