Merge remote-tracking branch 'origin/main' into fix/1273

# Conflicts:
#	docs/CURRENT-STATE.md
#	src/tooling/liveProviders.test.mjs
This commit is contained in:
Said Turksever committed 2026-10-23 12:09:32 +01:00
commit 98af799cb4
325 files changed
+20500 -1424

No files matched your search

-6
View File
@@ -3,16 +3,12 @@ import * as Cesium from 'cesium';
import {
LAYER_ID,
REQUEST_DEBOUNCE_MS,
QUERY_LIMIT,
MAX_RENDERED,
QUERY_SNAP_DEGREES,
QUERY_REUSE_MS,
ALPR_COLOR,
} from './policy.js';
import {
snapAlprBox,
boxContains,
destinationPointDeg,
alprRetryDelayMs,
validateAlprSnapshot,
alprCreditMarkup,
@@ -84,9 +80,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
viewportBox,
clearRendered,
hideOnMapCredit,
presentOnMapCredit,
renderRecords,
selectRecord,
focusNearest,
clearSelection,
updateSelectedAnchor,
+3 -3
View File
@@ -1,11 +1,11 @@
import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js';
export function textTag(value) {
function textTag(value) {
const t = String(value ?? '').trim();
return t || null;
}
export function numTag(value) {
function numTag(value) {
// `Number('')` and `Number(' ')` are 0, not NaN — a blank or whitespace-only
// tag value must read as missing, never as a due-north bearing.
if (value == null) return null;
@@ -102,7 +102,7 @@ export function normalizeAlprNode(el) {
};
}
export function normalizeDirection(value) {
function normalizeDirection(value) {
const degrees = numTag(value);
return degrees != null && degrees >= 0 && degrees <= 360
? degrees % 360
+1 -1
View File
@@ -3,7 +3,7 @@ import { VectorTile } from '@mapbox/vector-tile';
import { normalizeAlprNode } from './records.js';
/** Map extract attributes without treating an OSM edit date as a field verification. */
export function normalizeAlprTileFeature(feature) {
function normalizeAlprTileFeature(feature) {
const p = feature?.properties || {};
if (feature?.geometry?.type !== 'Point' || p.osmType !== 'node') return null;
const [lon, lat] = feature.geometry.coordinates;
+1 -4
View File
@@ -1,9 +1,6 @@
export const AIRCRAFT_DEPENDENCIES = ['flights', 'military'];
export const DEFERRED_DEPENDENCIES = [
'ais-live-vessels',
'military-installations',
];
const DEFERRED_DEPENDENCIES = ['ais-live-vessels', 'military-installations'];
export const DEPENDENCIES = [
...AIRCRAFT_DEPENDENCIES,
@@ -60,7 +60,7 @@ test('the layer reports failure when a failed city left it with nothing', () =>
});
test('a layer with nothing to draw and nothing failed is not an error', () => {
const { state, methods } = makeLayer();
const { methods } = makeLayer();
// Out of range of every city is empty, not broken.
assert.equal(methods.getStats().error, undefined);
+1 -1
View File
@@ -11,7 +11,7 @@ export const BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
// --- Activation / display thresholds ---
/** Altitude (m) at which bikeshare layer becomes eligible for display. */
export const ACTIVATION_ALTITUDE_M = 50000;
const ACTIVATION_ALTITUDE_M = 50000;
/** Hysteresis enter threshold — layer activates when camera drops below this. */
+1 -1
View File
@@ -1,4 +1,4 @@
import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js';
import { CAMERA_SEEDS } from './policy.js';
import {
lookupModelSpec,
horizontalFovRangeDeg,
-3
View File
@@ -5,9 +5,6 @@ import {
PROJECTION_CANVAS_HEIGHT,
PROJECTION_MATTE_COLOR,
PROJECTION_TEXTURE_SWAP_MS,
ACTIVE_FRAME_REFRESH_MS,
FRAME_ENDPOINT,
MEDIA_ENDPOINT,
PROJECTION_ACTIVE_REFRESH_MS,
PROJECTION_IDLE_REFRESH_MS,
PLACEHOLDER_REPAINT_MS,
-1
View File
@@ -1,7 +1,6 @@
import * as Cesium from 'cesium';
import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js';
import {
PROJECTION_VERT_ASPECT,
FRUSTUM_GROUND_CLEARANCE_M,
PROBE_MIN_RANGE_M,
PROBE_CLEARANCE_M,
+1 -1
View File
@@ -1,4 +1,4 @@
import { HEALTH_SYNC_INTERVAL_MS, HEALTH_ENDPOINT } from './policy.js';
import { HEALTH_SYNC_INTERVAL_MS } from './policy.js';
export function createHealth({ state: layerState, services, parts, source }) {
/**
+1 -10
View File
@@ -1,10 +1,4 @@
export {
FRAME_ENDPOINT,
SOURCE_ENDPOINT,
HEALTH_ENDPOINT,
MEDIA_ENDPOINT,
ACTIVE_FRAME_REFRESH_MS,
} from './sourcePolicy.js';
export { ACTIVE_FRAME_REFRESH_MS } from './sourcePolicy.js';
import * as Cesium from 'cesium';
import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js';
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
@@ -90,9 +84,6 @@ export const CCTV_CALIBRATION_STORAGE_KEY_V2 =
export const PROJECTION_TEXTURE_SWAP_MS = 1000;
export const PROJECTION_VERT_ASPECT =
PROJECTION_CANVAS_WIDTH / PROJECTION_CANVAS_HEIGHT;
// V2 frustum geometry (design §2a/§6): the far-cap center + corners never sink
// below groundAlt + this clearance, so a fabricated pitch (-24°) cannot bury
// the monitor plane in the 3D tiles. Exported for the unit suite.
-4
View File
@@ -1,9 +1,5 @@
export const FRAME_ENDPOINT = '/api/cctv/frame';
export const SOURCE_ENDPOINT = '/api/cctv/sources';
export const HEALTH_ENDPOINT = '/api/cctv/health';
export const MEDIA_ENDPOINT = '/api/cctv/media';
export const ACTIVE_FRAME_REFRESH_MS = 10000;
+2 -2
View File
@@ -64,7 +64,7 @@ const SHARED_CARD = Object.freeze({
* @param {boolean} selected
* @returns {object}
*/
export function cycloneStormEntry(storm, selected) {
function cycloneStormEntry(storm, selected) {
return {
...SHARED_CARD,
...markerClearance(
@@ -92,7 +92,7 @@ export function cycloneStormEntry(storm, selected) {
* @param {{tauHours:number,position:object}} point
* @returns {object}
*/
export function cycloneLeadEntry(stormId, point) {
function cycloneLeadEntry(stormId, point) {
return {
...SHARED_CARD,
...markerClearance(CYCLONE_MARKER_PX.forecast),
+1
View File
@@ -591,6 +591,7 @@ export function createModel({
formatLatLon,
formatFrp,
formatAge,
formatAgeLabel,
formatAgoMinutes,
confidenceBucket,
};
+2 -1
View File
@@ -64,7 +64,8 @@ export function createQueries({
const staleText = layerState._lastUpdate
? t('STALE · cached {age}', {
age:
components.model.formatAge(now - layerState._lastUpdate) || '<1h',
components.model.formatAgeLabel(now - layerState._lastUpdate) ||
t('<1h', { context: 'age' }),
})
: t('STALE');
let loadingLabel = '';
-8
View File
@@ -1,11 +1,3 @@
export {
GROUND_FLOOR_CLAMP_RADIUS_KM,
GROUND_FLOOR_WARM_MAX_ALT_M,
POSITION_HISTORY_LIMIT,
LANDED_MISSING_POLL_LIMIT,
MISSING_POLL_LIMIT,
ERROR_BACKOFF_INTERVAL,
} from './recordPolicy.js';
import * as Cesium from 'cesium';
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
-1
View File
@@ -580,7 +580,6 @@ export function createQueries({
const maxRange =
Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY;
const now = Cesium.JulianDate.now();
const nearby = [];
for (const [icao24, bb] of flightState._billboards) {
-2
View File
@@ -2,8 +2,6 @@ export const LAYER_ID = 'military-installations';
export const REQUEST_DEBOUNCE_MS = 180;
export const MAX_VIEWPORT_DEGREES = 10;
export const MAX_RENDERED = 700;
export const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']);
+1 -1
View File
@@ -109,7 +109,7 @@ export function createRendering({
);
});
if (satelliteTrack) layerState._orbitMatches++;
const entity = layerState._dataSource.entities.add({
layerState._dataSource.entities.add({
id: `rocket-launch:${launch.id}`,
position,
point: {
+2 -2
View File
@@ -5,12 +5,12 @@ import {
/** Same-origin route served by `server/providers/local-receivers.js`. */
export const LOCAL_RECEIVERS_URL = '/api/local-receivers/aircraft';
export const LOCAL_RECEIVER_POLL_MS = 1_000;
const LOCAL_RECEIVER_POLL_MS = 1_000;
/**
* Browser-side deadline for one route request, body included. The server
* bounds each upstream read at 2 s; this bounds the browser-to-server leg.
*/
export const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000;
const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000;
function abortError() {
const error = new Error('Local receiver request aborted');
+2 -12
View File
@@ -36,18 +36,8 @@ import {
} from './policy.js';
import { localAdsbStatus } from './status.js';
export {
LAYER_ID as LOCAL_ADSB_LAYER_ID,
HEARD_BY_RECEIVER,
} from './policy.js';
export {
localAdsbCardModel,
localAdsbClassLine,
localAdsbIsUat,
localAdsbReceiverLine,
localAdsbTitle,
} from './card.js';
export { localAdsbClass } from './enrichment.js';
export { HEARD_BY_RECEIVER } from './policy.js';
export { localAdsbCardModel, localAdsbReceiverLine } from './card.js';
export { localAdsbStatus } from './status.js';
const FEET_TO_METERS = 0.3048;
+1 -1
View File
@@ -26,7 +26,7 @@ import {
export const LOCAL_ADSB_HISTORY_MS = 10 * 60_000;
export const LOCAL_ADSB_HISTORY_POINTS = 600;
/** A display correction larger than this snaps instead of sliding. */
export const LOCAL_ADSB_SNAP_M = 3_000;
const LOCAL_ADSB_SNAP_M = 3_000;
/** Chord course uses the newest fix at least this much older than the anchor. */
const CHORD_BASELINE_MS = 2_000;
/** Turn rate is estimated over this window of heard fixes. */
+2 -2
View File
@@ -24,7 +24,7 @@ function plural(count, one, many) {
* @param {object[]} feeds Feed statuses from the route.
* @returns {string}
*/
export function describeFeedProblems(feeds) {
function describeFeedProblems(feeds) {
const byStatus = new Map();
for (const feed of feeds) {
if (feed?.status === 'live') continue;
@@ -41,7 +41,7 @@ export function describeFeedProblems(feeds) {
}
/** Whether the server reported at least one configured feed. */
export function feedsConfigured(feedState) {
function feedsConfigured(feedState) {
return Boolean(
feedState?.configured === true &&
Array.isArray(feedState.feeds) &&
-7
View File
@@ -1,10 +1,3 @@
export {
GROUND_FLOOR_WARM_MAX_ALT_M,
POSITION_HISTORY_LIMIT,
LANDED_MISSING_POLL_LIMIT,
MISSING_POLL_LIMIT,
ERROR_BACKOFF_INTERVAL,
} from './recordPolicy.js';
import * as Cesium from 'cesium';
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
-1
View File
@@ -366,7 +366,6 @@ export function createQueries({
const maxRange =
Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY;
const now = Cesium.JulianDate.now();
const nearby = [];
for (const [icao24, bb] of flightState._billboards) {
-1
View File
@@ -187,7 +187,6 @@ export function createMilitarySnapshotRenderer({
// If this is the tracked aircraft, update label text
// (position updates automatically via dead-reckoning CallbackProperty)
if (isTracked && flightState._trackedEntity) {
const info = flightState.records.data.get(icao24);
tracking._updateTrackedLabelModel(icao24);
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ export const MARKER_LIFT_M = 2.5;
export const SELECTED_LIFT_M = 5;
export const RADIO_PICK_TOLERANCE_PX = 8;
const RADIO_PICK_TOLERANCE_PX = 8;
export const RADIO_TUNER_DIRECTORY_LIMIT = 750;
+1 -1
View File
@@ -259,7 +259,7 @@ export function createRendering({
layerState._horizonScanCount += 1;
const occluder = horizonOccluder(layerState._viewer.camera);
let visibilityChanged = false;
for (const [id, record] of layerState._renderById) {
for (const record of layerState._renderById.values()) {
const matches = parts.categories.stationMatchesRadioCategory(
record.station,
layerState._filter,
+10 -2
View File
@@ -3,7 +3,12 @@
* day's readout. The model (`model.js`) stays English and i18n-free because AI
* tool services import it outside the browser; this module is page-only.
*/
import { formatDate, formatRelativeTime, t } from '../../i18n/index.js';
import {
formatDate,
formatRelativeTime,
getLocale,
t,
} from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { dayTime } from './model.js';
@@ -26,7 +31,10 @@ export const displayMetres = (value) => formatQuantity(value, 'm');
/** The readout's words in the page's language; the model keeps them English. */
export const READOUT_WORDS = Object.freeze({
date: (day) => displayDay(day, LONG_DAY),
daysAgo: (delta) => formatRelativeTime(delta > 0 ? -delta : 0, 'day'),
daysAgo: (delta) => {
const text = formatRelativeTime(delta > 0 ? -delta : 0, 'day');
return getLocale() === 'zh-CN' ? text.replace(/(\d)(?=天)/g, '$1 ') : text;
},
// "Sentinel-2" never breaks at its hyphen: a word joiner follows it
// (JetBrains Mono has no U+2011 non-breaking hyphen).
sensor: (sensor) =>
+1 -1
View File
@@ -300,7 +300,7 @@ export function hhmm(iso) {
}
/** Candidate key: `PRODUCT:YYYY-MM-DD`. */
export function candidateKey(product, day) {
function candidateKey(product, day) {
return `${product}:${day}`;
}
+2 -2
View File
@@ -94,9 +94,9 @@ export const TRACK_VIEW_FROM_HIGH_SCALE = 4;
* attribute, so classification costs nothing per frame.
*/
export const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3);
const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3);
export const _classColor = (group) =>
const _classColor = (group) =>
Cesium.Color.fromCssColorString(satelliteClassColor(group));
export const POINT_STYLES = {
+2 -2
View File
@@ -317,7 +317,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 +593,7 @@ test('a tile that fails before the terrain table loads is reported at once, neve
});
test('the selected sequence is drawn and highlighted even past the per-tile cap', async () => {
const { state, source, coverage, centre, viewer } = setup();
const { state, source, coverage, centre } = setup();
const line = (n) => [
[
[centre.lon - 0.001, centre.lat + n * 1e-6],
+5 -5
View File
@@ -17,18 +17,18 @@ const EXPANDED_SELECTORS = [
];
/** A photo download (tens of kilobytes to a few megabytes). */
export const IMAGE_TIMEOUT_MS = 15_000;
const IMAGE_TIMEOUT_MS = 15_000;
/** A picture file or decoded side past these is refused (a photo is a few MB). */
export const MAX_IMAGE_BYTES = 48 * 1024 * 1024;
export const MAX_IMAGE_SIDE = 16_384;
/** 64 MP: 256 MB once decoded to RGBA. A 16,384 px square is four times that. */
export const MAX_IMAGE_PIXELS = 8_192 * 8_192;
const MAX_IMAGE_PIXELS = 8_192 * 8_192;
/** Where in a file to look for its dimensions (a JPEG's EXIF comes first). */
const HEADER_BYTES = 256 * 1024;
/** Whether a picture this size is past what the viewer decodes. */
export function tooManyPixels(width, height) {
function tooManyPixels(width, height) {
return (
width > MAX_IMAGE_SIDE ||
height > MAX_IMAGE_SIDE ||
@@ -37,7 +37,7 @@ export function tooManyPixels(width, height) {
}
/** Panorama texture widths: enough for a docked viewer, and for an expanded one. */
export const PANO_MID_SIZE = Object.freeze([2048, 1024]);
const PANO_MID_SIZE = Object.freeze([2048, 1024]);
export const PANO_FULL_SIZE = Object.freeze([4096, 2048]);
/**
@@ -155,7 +155,7 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) {
* A picture refused for its size (`tooLarge`): the viewer will not load
* that file another way, an <img> having no size limit at all.
*/
export function oversized(failure) {
function oversized(failure) {
return Object.assign(new Error(failure), { tooLarge: true });
}
@@ -14,7 +14,7 @@ export const KARTAVIEW_LABEL = 'KARTAVIEW';
*/
export const KARTAVIEW_API_HOST = 'https://api.kartaview.org';
/** KartaView's image proxy: resizes a photo given its base64url address. */
export const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org';
const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org';
/** Stable id prefixes for picked primitives; every one starts with `kv:`. */
export const PICK_PREFIX = Object.freeze({
@@ -140,8 +140,6 @@ export const MASK_SLOW_MESSAGE = msg(
export const FILTERED_OUT_MESSAGE = msg(
'None of KartaView’s photos here pass the imagery filter',
);
/** Under the SINCE slider while KartaView is lit. */
export const SINCE_NOTE = '(by upload date)';
/** The UTC calendar day of `ms`, as KartaView's startDate reads it. */
function utcDay(ms) {
@@ -41,8 +41,6 @@ import {
normalizeItem,
} from './policy.js';
export { PANORAMAX_PROVIDER_ID } from './policy.js';
/**
* A tile's bytes decoded by `decode`; a body that is not a vector tile
* (an HTML error page, a cut-off answer) reads as such, not as a parser's
@@ -15,7 +15,7 @@ export const PANORAMAX_LABEL = 'PANORAMAX';
*/
export const PANORAMAX_API = 'https://api.panoramax.xyz/api';
/** The meta-catalogue's web viewer, where the caption link goes. */
export const PANORAMAX_VIEWER = 'https://api.panoramax.xyz/';
const PANORAMAX_VIEWER = 'https://api.panoramax.xyz/';
/** Stable id prefixes for picked primitives; every one starts with `pnx:`. */
export const PICK_PREFIX = Object.freeze({
+1 -1
View File
@@ -1,6 +1,6 @@
import { encodeIdSwitches } from '../../data/idSwitches.js';
export const IMAGERY_FILTERS = Object.freeze(['pano', 'since']);
const IMAGERY_FILTERS = Object.freeze(['pano', 'since']);
export function filtersOf(provider) {
return provider?.filters ?? IMAGERY_FILTERS;
+2 -2
View File
@@ -5,8 +5,8 @@ import { createHorizonCull, metresBetween } from './view.js';
import { msg } from '../../i18n/index.js';
/** Cones closer than this to the previous kept one are dropped. */
export const IMAGE_CONE_MIN_SPACING_M = 3;
export const IMAGE_CONE_SIZE_PX = 26;
const IMAGE_CONE_MIN_SPACING_M = 3;
const IMAGE_CONE_SIZE_PX = 26;
/**
* @typedef {object} ConeImage One image of a sequence, as the cones draw it.
+2 -2
View File
@@ -1,6 +1,6 @@
/** Decoded coverage tiles kept, and for how long. */
export const TILE_CACHE_SIZE = 24;
export const TILE_CACHE_TTL_MS = 10 * 60_000;
const TILE_CACHE_SIZE = 24;
const TILE_CACHE_TTL_MS = 10 * 60_000;
/** Least-recently-used map: reading an entry renews it. */
export function createLru(limit) {
+2 -2
View File
@@ -64,7 +64,7 @@ export function keyAlternatives(providers) {
* The filter controls a switched-on provider honours (both when none is on).
* @param {Array<ProviderSnapshot>} providers
*/
export function filterControls(providers) {
function filterControls(providers) {
const active = providers.filter((p) => p.on);
const honoured = (key) =>
active.length === 0 ||
@@ -78,7 +78,7 @@ export function filterControls(providers) {
* note needs no provider name.
* @param {Array<ProviderSnapshot>} providers
*/
export function sinceNote(providers) {
function sinceNote(providers) {
return providers.some((p) => p.on && p.sinceBasis === 'upload')
? t('(by upload date)')
: null;
+3 -3
View File
@@ -33,7 +33,7 @@ export function featureReference(feature) {
};
}
export function coordsFromProperty(value) {
function coordsFromProperty(value) {
if (!Array.isArray(value) || value.length < 2) return null;
const lon = Number(value[0]);
const lat = Number(value[1]);
@@ -41,7 +41,7 @@ export function coordsFromProperty(value) {
return [lon, lat];
}
export function coordsFromPoint(value) {
function coordsFromPoint(value) {
if (!Array.isArray(value) || value.length < 2) return null;
const lon = Number(value[0]);
const lat = Number(value[1]);
@@ -49,7 +49,7 @@ export function coordsFromPoint(value) {
return [lon, lat];
}
export function collectLonLat(value, out) {
function collectLonLat(value, out) {
if (!Array.isArray(value)) return;
if (typeof value[0] === 'number' && typeof value[1] === 'number') {
const lon = Number(value[0]);
+3 -3
View File
@@ -1,9 +1,9 @@
import * as Cesium from 'cesium';
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
export const RETICLE_RANGE_KM = 10;
export const RETICLE_TILE_CAP = 16;
export const tileKey = (tile) => `${tile.z}/${tile.x}/${tile.y}`;
const RETICLE_RANGE_KM = 10;
const RETICLE_TILE_CAP = 16;
const tileKey = (tile) => `${tile.z}/${tile.x}/${tile.y}`;
const cross = (a, b, c) =>
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
-10
View File
@@ -175,13 +175,3 @@ export const STYLED_MIN_BASE_PX = 5;
/** @const {number} Maximum tile cache entries before LRU eviction */
export const TILE_CACHE_MAX_ENTRIES = 64;
/**
* Milliseconds the first dot paint will wait for flow data. Cached flow
* settles within this window (decode cache, 120 s TTL) and renders fully
* colored; a cold tile fetch loses the race, dots paint immediately in
* white, and recolorDotsInPlace applies the colors when flow arrives —
* field-test round 1's "takes forever to load" was the sequential wait.
*/
export const FLOW_RENDER_RACE_MS = 250;
+1 -1
View File
@@ -1,6 +1,6 @@
import { normalizeRoadMode } from './roadModes.js';
import * as Cesium from 'cesium';
import { FLOW_BUCKET_COLORS, TRAFFIC_TIMING_ENABLED } from './policy.js';
import { FLOW_BUCKET_COLORS } from './policy.js';
export function createState({ services }) {
const layerState = {};
-1
View File
@@ -9,7 +9,6 @@ import {
FEED_STALE_AFTER_MS,
MAX_VEHICLES_TOTAL,
MISSED_POLLS_TO_DROP,
TRANSIT_POLL_MS,
isStaleVehicleFix,
transitVehicleKey,
vehicleReportTimeMs,
-2
View File
@@ -6,7 +6,6 @@ import {
FEED_EVICT_AFTER_MS,
FEED_STALE_AFTER_MS,
MISSED_POLLS_TO_DROP,
SELECTED_CARD_REFRESH_MS,
TRANSIT_POLL_MS,
VEHICLE_MAX_FIX_AGE_MS,
} from './policy.js';
@@ -1407,7 +1406,6 @@ test('the selected card lands where the vehicle stopped, not short of it', async
// segment ends left it a few metres behind, and then the render hold was
// released with no frame scheduled to close the gap.
const app = harness(t);
const start = Date.now();
app.serve('mbta', () => ({
status: 200,
body: snapshot('mbta', 'MBTA', [vehicle('b', 42.36, -71.06, reported())], {
+8 -38
View File
@@ -9,10 +9,7 @@ import {
correctHeight,
setTimeOffsetMs,
} from '../../data/contactPlayback.js';
export const FIX_HISTORY_MAX = 128;
export const DISPLAY_LAG_MIN_MS = 25_000;
export const DISPLAY_LAG_RESET_MS = 90_000;
export const MODE_MAX_SPEED_MPS = Object.freeze({
const MODE_MAX_SPEED_MPS = Object.freeze({
bus: 36, // ~130 km/h — a coach on a motorway
tram: 28, // ~100 km/h — interurban light rail
subway: 42, // ~150 km/h
@@ -32,22 +29,19 @@ export const MODE_MAX_SPEED_MPS = Object.freeze({
* @param {boolean} [inferred] Whether the mode came from a feed default.
* @returns {number} m/s
*/
export function speedCeilingMps(mode, inferred = false) {
function speedCeilingMps(mode, inferred = false) {
if (inferred) return MODE_MAX_SPEED_MPS.unknown;
return MODE_MAX_SPEED_MPS[mode] ?? MODE_MAX_SPEED_MPS.unknown;
}
/** Position deadband is disabled: even a short reported crawl is played. */
export const FIX_NOISE_M = 0;
/**
* Displacement (m) a glide must cover before its direction is worth believing.
* Below this the course derived from it is noise; the last good one is held.
*/
export const COURSE_MIN_TRAVEL_M = 12;
const COURSE_MIN_TRAVEL_M = 12;
/** Maximum display-course change per second (deg). A bus is not a gyroscope. */
export const COURSE_MAX_RATE_DEG_PER_S = 120;
const COURSE_MAX_RATE_DEG_PER_S = 120;
/**
* Longest stretch of elapsed time (s) one evaluation may spend. The limiter
* used to bank the time since the course last CHANGED, so a vehicle that held
@@ -56,15 +50,7 @@ export const COURSE_MAX_RATE_DEG_PER_S = 120;
* EVALUATED, and capped here so that even a first look after a long idle
* sweeps visibly instead of snapping.
*/
export const COURSE_STEP_MAX_S = 0.25;
/**
* Consecutive fixes at the same place before a vehicle is called STOPPED.
* One is not enough: a fifteen-second gap between identical fixes is also what
* a vehicle waiting at a light looks like, and so is a feed that repeated
* itself.
*/
export const STOPPED_FIXES = 2;
const COURSE_STEP_MAX_S = 0.25;
/**
* How long after its newest fix a vehicle stops being described as travelling.
@@ -75,7 +61,7 @@ export const STOPPED_FIXES = 2;
* otherwise is the same overreach as reading STOPPED off a stale status. Ninety
* seconds matches the threshold at which a whole feed reads as gone quiet.
*/
export const MOTION_UNKNOWN_AFTER_MS = 90_000;
const MOTION_UNKNOWN_AFTER_MS = 90_000;
const EARTH_M_PER_DEG = 111_320;
@@ -94,29 +80,13 @@ export function fixDistanceM(a, b) {
return Math.hypot(dLat, dLon);
}
/**
* Course in degrees clockwise from north, from one point to another.
* @param {{lat:number, lon:number}} from
* @param {{lat:number, lon:number}} to
* @returns {number} 0-360
*/
export function courseBetween(from, to) {
const dLat = (to.lat - from.lat) * EARTH_M_PER_DEG;
const dLon =
(to.lon - from.lon) *
EARTH_M_PER_DEG *
Math.cos((from.lat * Math.PI) / 180);
const deg = (Math.atan2(dLon, dLat) * 180) / Math.PI;
return (deg + 360) % 360;
}
/**
* Shortest signed difference between two courses, in degrees (-180, 180].
* @param {number} from
* @param {number} to
* @returns {number}
*/
export function courseDelta(from, to) {
function courseDelta(from, to) {
let delta = ((to - from + 540) % 360) - 180;
if (delta === -180) delta = 180;
return delta;
@@ -259,7 +229,7 @@ export function attachFloorToPlace(entry, fix, heightM) {
correctHeight(entry.track, scratch.seq, heightM);
}
}
export function displayCourse(entry, nowMs) {
function displayCourse(entry, nowMs) {
if (!entry) return null;
const target = Number.isFinite(entry.sample?.segmentCourseDeg)
? entry.sample.segmentCourseDeg
+1 -1
View File
@@ -9,7 +9,7 @@ import {
displacementPlausible,
} from './movement.js';
import { buildTransitSelectionCopy } from './policy.js';
import { seek, sampleAt } from '../../data/contactPlayback.js';
import { sampleAt } from '../../data/contactPlayback.js';
const entry = () => {
const e = { mode: 'bus', modeInferred: false, courseDeg: null };
initializePlayback(e);
+7 -7
View File
@@ -99,7 +99,7 @@ export const VISIBILITY_REFRESH_MS = 250;
* on screen. Generous on purpose: a vehicle that crosses the edge mid-glide
* should already have been moving, not pop into motion at the border.
*/
export const VIEW_MARGIN_RATIO = 0.6;
const VIEW_MARGIN_RATIO = 0.6;
/**
* Whether a vehicle is close enough to the camera's look-at area to be worth
@@ -516,10 +516,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 +529,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 +588,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 +600,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 +659,6 @@ export function transitDetectionMetric(
* @param {number} [nowMs]
* @returns {string}
*/
export function transitVehicleState(entry, nowMs = Date.now()) {
function transitVehicleState(entry, nowMs = Date.now()) {
return displayMotion(entry, nowMs).word;
}
-1
View File
@@ -22,7 +22,6 @@ import {
import {
MARKER_PIXEL_SIZE,
ROTATION_REFRESH_MS,
SELECTED_CARD_REFRESH_MS,
VISIBILITY_REFRESH_MS,
SELECTED_PIXEL_SIZE,
TRANSIT_MODE_COLORS,
-2
View File
@@ -12,8 +12,6 @@ export function createCards({
layer,
options,
}) {
const { state } = vesselState;
function updateSelectedVesselHud(record) {
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
-5
View File
@@ -1,8 +1,3 @@
export {
PARTIAL_RETENTION_MS,
SELECTED_PIN_REFRESHES,
AIS_FIRST_CONNECT_LABEL,
} from './recordPolicy.js';
import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js';
import { msg } from '../../i18n/index.js';
+19 -8
View File
@@ -296,10 +296,6 @@ export function createWeatherLayer({
motion?.addEventListener?.('change', onVisibility);
documentRef?.addEventListener?.('visibilitychange', onVisibility);
registerClock();
removeCamera = viewer.camera.moveEnd?.addEventListener(() => {
checkHost();
if (enabled) notify();
});
},
attachShellServices(services) {
imageryHost =
@@ -315,10 +311,18 @@ export function createWeatherLayer({
},
enable() {
enabled = true;
if (!removeCamera) {
removeCamera = viewer.camera.moveEnd?.addEventListener(() => {
checkHost();
notify();
});
}
registerClock();
},
disable() {
enabled = false;
removeCamera?.();
removeCamera = null;
unregisterClock?.();
unregisterClock = null;
++generation;
@@ -357,7 +361,7 @@ export function createWeatherLayer({
signal: controller.signal,
});
if (!enabled || controller.signal.aborted || request !== controller)
return false;
return enabled && !signal?.aborted && request !== controller;
if (snapshot.unavailable) {
error = msg(
'Weather source unavailable; previous observation retained',
@@ -376,7 +380,9 @@ export function createWeatherLayer({
)
await show(snapshot.latest, controller.signal);
return (
!controller.signal.aborted && request === controller && enabled
enabled &&
!signal?.aborted &&
(!controller.signal.aborted || request !== controller)
);
}
const time =
@@ -388,9 +394,14 @@ export function createWeatherLayer({
(satellite && rendering.getDiagnostics().infrared !== infrared)
)
await show(time, controller.signal);
return !controller.signal.aborted && request === controller && enabled;
return (
enabled &&
!signal?.aborted &&
(!controller.signal.aborted || request !== controller)
);
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
if (controller.signal.aborted || request !== controller)
return enabled && !signal?.aborted && request !== controller;
error = cause?.message || msg('Weather unavailable');
stop();
return true;
+1 -1
View File
@@ -4,7 +4,7 @@ import { readResponseJsonCapped } from '../../sources/httpBody.js';
// the weather layer translates them where it shows them (t / translateKnown).
const msg = (text) => text;
export const WEATHER_PRODUCTS = Object.freeze([
const WEATHER_PRODUCTS = Object.freeze([
'radar',
'clouds',
'clouds-regional',
+46 -1
View File
@@ -694,7 +694,11 @@ test('superseding a pending stage preserves the newer manifest loading state', a
await flush();
const newer = h.layer.update();
await flush();
assert.equal(await old, false);
assert.equal(
await old,
true,
'replacement is accepted by the enable lifecycle',
);
assert.equal(
h.layer.getStats().loading,
true,
@@ -2311,3 +2315,44 @@ for (const product of ['radar', 'clouds-regional', 'lightning']) {
assert.equal(h.viewer.camera.moveEnd.size, 0);
});
}
test('a product replacement accepts supersession but never paints the obsolete first response', async () => {
const pending = [];
const h = layerHarness({
id: 'weather-satellite',
feed: {
getSnapshot: (args) => {
const task = deferred();
pending.push({ ...args, ...task });
return task.promise;
},
},
});
const first = h.layer.update();
h.layer.setParams({ product: 'clouds' });
assert.equal(pending.length, 2);
pending[0].resolve({ ...snapshot, product: 'clouds-regional' });
assert.equal(await first, true);
assert.equal(h.stages.length, 0);
pending[1].resolve({ ...snapshot, product: 'clouds' });
await flush();
h.stages.at(-1).finish();
await flush();
assert.equal(h.layer.getParams().product, 'clouds');
h.layer.destroy();
});
test('disabled weather releases its camera listener and re-enabling binds only once', () => {
const h = layerHarness();
h.layer.disable();
assert.equal(h.moveEnd.size, 0, 'disabled layers own no active camera work');
h.layer.enable();
h.layer.enable();
assert.equal(h.moveEnd.size, 1);
h.layer.disable();
assert.equal(h.moveEnd.size, 0);
h.layer.enable();
assert.equal(h.moveEnd.size, 1);
h.layer.destroy();
assert.equal(h.moveEnd.size, 0);
});
+2 -2
View File
@@ -6,8 +6,8 @@ import {
WIND_NARROW_PATH_LIMIT,
} from './streamlines.js';
export const WIND_FADE_LOW_METERS = 15000;
export const WIND_FADE_HIGH_METERS = 60000;
const WIND_FADE_LOW_METERS = 15000;
const WIND_FADE_HIGH_METERS = 60000;
// Original material: a persistent fine curve plus a moving, tapered highlight.
// The integer part of s identifies a path; its fractional part follows the wind.
+3 -2
View File
@@ -180,7 +180,7 @@ export function createWindLayer({
overlay: requestedScalar(),
});
if (!enabled || controller.signal.aborted || request !== controller)
return false;
return enabled && !signal?.aborted && request !== controller;
manifest = snapshot;
error = null;
if (sampledPosition) sample(sampledPosition);
@@ -194,7 +194,8 @@ export function createWindLayer({
}
return true;
} 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('Wind source unavailable');
return true;
} finally {
+34 -1
View File
@@ -97,7 +97,7 @@ const complete = (model, extra = {}) => ({
});
test('local appearance and units reuse the loaded field; optional scalar requests remain separate', async () => {
const requests = [];
const { layer, calls } = harness({
const { layer } = harness({
getSnapshot: async (args) => {
requests.push(args);
return complete(
@@ -412,3 +412,36 @@ test('wind unit chips appear only alongside a speed legend, including canvas tra
for (const overlay of ['pressure', 'temperature', 'none']) { layer.setParams({ overlay }); assert.deepEqual(unitChips(), []); }
layer.destroy();
});
test('a parameter replacement during manager enable does not roll the layer back', async () => {
const { LayerLifecycle } = await import('../../data/lifecycle.js');
const pending = [];
const rendering = Object.fromEntries(
['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map((name) => [
name,
() => {},
]),
);
const layer = createWindLayer({
feed: {
getSnapshot: (args) =>
new Promise((resolve) => pending.push({ ...args, resolve })),
},
createRendering: () => rendering,
});
const manager = new LayerLifecycle({ container: {} });
manager.register(layer);
const enabled = manager.setEnabled('wind', true);
for (let i = 0; i < 20 && !pending.length; i++) await Promise.resolve();
layer.setParams({ model: 'ifs' });
await Promise.resolve();
pending[0].resolve(snapshot('gfs'));
assert.equal(await enabled, true);
assert.equal(manager.isEnabled('wind'), true);
assert.equal(layer.getStats().loading, true);
pending[1].resolve(snapshot('ifs'));
for (let i = 0; i < 10; i++) await Promise.resolve();
assert.equal(layer.getStats().model, 'IFS');
assert.equal(layer.getStats().count, 4);
await manager.destroyAll();
});