mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge remote-tracking branch 'origin/main' into fix/1279
# Conflicts: # docs/CURRENT-STATE.md
This commit is contained in:
commit
d41c0ccaea
325 files changed
+20500
-1424
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
|
||||
@@ -591,6 +591,7 @@ export function createModel({
|
||||
formatLatLon,
|
||||
formatFrp,
|
||||
formatAge,
|
||||
formatAgeLabel,
|
||||
formatAgoMinutes,
|
||||
confidenceBucket,
|
||||
};
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,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']);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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())], {
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user