Merge remote-tracking branch 'upstream/main' into pr1209-merge

This commit is contained in:
Said Turksever committed 2026-10-28 14:05:40 +00:00
commit 11aad05dc9
807 files changed
+83004 -20993

No files matched your search

-6
View File
@@ -3,16 +3,12 @@ import * as Cesium from 'cesium';
import {
LAYER_ID,
REQUEST_DEBOUNCE_MS,
QUERY_LIMIT,
MAX_RENDERED,
QUERY_SNAP_DEGREES,
QUERY_REUSE_MS,
ALPR_COLOR,
} from './policy.js';
import {
snapAlprBox,
boxContains,
destinationPointDeg,
alprRetryDelayMs,
validateAlprSnapshot,
alprCreditMarkup,
@@ -84,9 +80,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
viewportBox,
clearRendered,
hideOnMapCredit,
presentOnMapCredit,
renderRecords,
selectRecord,
focusNearest,
clearSelection,
updateSelectedAnchor,
+2 -2
View File
@@ -13,7 +13,7 @@ import {
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
import { boxContains } from './model.js';
import { t } from '../../i18n/index.js';
import { msg } from '../../i18n/index.js';
import { createAlprFloorResolver } from './floors.js';
import { createAlprOverlay } from './overlay.js';
import {
@@ -452,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) {
layerName: 'ALPR Cameras',
source:
source.attribution?.description || source.label || 'Camera source',
label: t('ALPR camera'),
label: msg('ALPR camera'),
latitude: record.latitude,
longitude: record.longitude,
properties: {
+3 -3
View File
@@ -1,11 +1,11 @@
import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js';
export function textTag(value) {
function textTag(value) {
const t = String(value ?? '').trim();
return t || null;
}
export function numTag(value) {
function numTag(value) {
// `Number('')` and `Number(' ')` are 0, not NaN — a blank or whitespace-only
// tag value must read as missing, never as a due-north bearing.
if (value == null) return null;
@@ -102,7 +102,7 @@ export function normalizeAlprNode(el) {
};
}
export function normalizeDirection(value) {
function normalizeDirection(value) {
const degrees = numTag(value);
return degrees != null && degrees >= 0 && degrees <= 360
? degrees % 360
+1 -1
View File
@@ -3,7 +3,7 @@ import { VectorTile } from '@mapbox/vector-tile';
import { normalizeAlprNode } from './records.js';
/** Map extract attributes without treating an OSM edit date as a field verification. */
export function normalizeAlprTileFeature(feature) {
function normalizeAlprTileFeature(feature) {
const p = feature?.properties || {};
if (feature?.geometry?.type !== 'Point' || p.osmType !== 'node') return null;
const [lon, lat] = feature.geometry.coordinates;
+3 -2
View File
@@ -25,9 +25,10 @@ function presentAwarenessCount(summary = {}) {
}
/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */
function presentAwarenessDistance(meters) {
export function presentAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
if (meters < 1000) return formatQuantity(Math.round(meters), 'm');
const wholeMeters = Math.round(meters);
if (wholeMeters < 1000) return formatQuantity(wholeMeters, 'm');
const digits = meters < 10000 ? 1 : 0;
return formatQuantity(meters / 1000, 'km', {
minimumFractionDigits: digits,
+1 -4
View File
@@ -1,9 +1,6 @@
export const AIRCRAFT_DEPENDENCIES = ['flights', 'military'];
export const DEFERRED_DEPENDENCIES = [
'ais-live-vessels',
'military-installations',
];
const DEFERRED_DEPENDENCIES = ['ais-live-vessels', 'military-installations'];
export const DEPENDENCIES = [
...AIRCRAFT_DEPENDENCIES,
+10
View File
@@ -0,0 +1,10 @@
/**
* Format a compass direction for the awareness overlay.
* @param {number} degrees
* @returns {string}
*/
export function formatAwarenessDirection(degrees) {
if (!Number.isFinite(degrees)) return '—';
const normalized = ((degrees % 360) + 360) % 360;
return String(Math.round(normalized) % 360).padStart(3, '0');
}
@@ -0,0 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatAwarenessDirection } from './presentation.js';
test('awareness bearings that round through north read 000, not 360', () => {
assert.equal(formatAwarenessDirection(359.4), '359');
assert.equal(formatAwarenessDirection(359.6), '000');
assert.equal(formatAwarenessDirection(0), '000');
assert.equal(formatAwarenessDirection(-0.4), '000');
assert.equal(formatAwarenessDirection(Number.NaN), '—');
});
+3 -2
View File
@@ -11,6 +11,7 @@ import {
} from '../../data/militaryAwarenessEngine.js';
import { bearingBetweenCoordinates } from '../../cockpitMath.js';
import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js';
import { formatAwarenessDirection } from './presentation.js';
export function createRendering({
state: layerState,
@@ -225,12 +226,12 @@ export function createRendering({
: null;
const bearingText = Number.isFinite(bearing)
? t('BRG {value}°', {
value: String(Math.round(bearing)).padStart(3, '0'),
value: formatAwarenessDirection(bearing),
})
: t('BRG —');
const courseText = Number.isFinite(item.track)
? ` · ${t('CRS {value}°', {
value: String(Math.round(item.track)).padStart(3, '0'),
value: formatAwarenessDirection(item.track),
})}`
: '';
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
@@ -60,7 +60,7 @@ test('the layer reports failure when a failed city left it with nothing', () =>
});
test('a layer with nothing to draw and nothing failed is not an error', () => {
const { state, methods } = makeLayer();
const { methods } = makeLayer();
// Out of range of every city is empty, not broken.
assert.equal(methods.getStats().error, undefined);
+1 -1
View File
@@ -11,7 +11,7 @@ export const BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
// --- Activation / display thresholds ---
/** Altitude (m) at which bikeshare layer becomes eligible for display. */
export const ACTIVATION_ALTITUDE_M = 50000;
const ACTIVATION_ALTITUDE_M = 50000;
/** Hysteresis enter threshold — layer activates when camera drops below this. */
+1 -1
View File
@@ -1,4 +1,4 @@
import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js';
import { CAMERA_SEEDS } from './policy.js';
import {
lookupModelSpec,
horizontalFovRangeDeg,
+44 -13
View File
@@ -3,15 +3,44 @@ import {
FRAME_SIGNATURE_H,
PROJECTION_CANVAS_WIDTH,
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,
} from './policy.js';
/**
* Contain-fits a source frame into the projection canvas: scaled to the
* largest size that fits, centered, with the margins left for the matte fill.
* Falls back to the full canvas when either dimension is missing or zero.
*
* @param {number} sourceW - Source width in pixels.
* @param {number} sourceH - Source height in pixels.
* @returns {{x:number,y:number,w:number,h:number}} Draw rectangle in canvas pixels.
*/
export function fitToProjection(sourceW, sourceH) {
if (!sourceW || !sourceH)
return {
x: 0,
y: 0,
w: PROJECTION_CANVAS_WIDTH,
h: PROJECTION_CANVAS_HEIGHT,
};
const scale = Math.min(
PROJECTION_CANVAS_WIDTH / sourceW,
PROJECTION_CANVAS_HEIGHT / sourceH,
);
const w = Math.round(sourceW * scale);
const h = Math.round(sourceH * scale);
return {
x: Math.round((PROJECTION_CANVAS_WIDTH - w) / 2),
y: Math.round((PROJECTION_CANVAS_HEIGHT - h) / 2),
w,
h,
};
}
export function createFrames({ state: layerState, services, parts, source }) {
/**
* FNV-1a over the RGB channels of a downsampled frame. Pure (takes the raw
@@ -182,7 +211,7 @@ export function createFrames({ state: layerState, services, parts, source }) {
ctx.clearRect(0, 0, w, h);
const g = ctx.createLinearGradient(0, 0, w, h);
g.addColorStop(0, '#05111a');
g.addColorStop(1, '#01070c');
g.addColorStop(1, PROJECTION_MATTE_COLOR);
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
@@ -320,7 +349,14 @@ export function createFrames({ state: layerState, services, parts, source }) {
// base color (planeMaterial color = WHITE, alpha .95) for the frame or
// two Cesium needs to rebind, which IS the periodic white flash from the
// owner field tests (2026-07-04 and 2026-07-30).
const signature = projectionFrameSignature(runtime);
const fit = fitToProjection(
runtime.image.naturalWidth,
runtime.image.naturalHeight,
);
// Pixels are hashed at a fixed size, so the fit carries the aspect.
const pixels = projectionFrameSignature(runtime);
const signature =
pixels === null ? null : `${pixels}:${fit.w}x${fit.h}`;
runtime.drawnImageStamp = runtime.imageStamp;
if (signature !== null && signature === runtime.lastFrameSignature) {
// Identical pixels — leave the canvas, and therefore the bound
@@ -328,19 +364,14 @@ export function createFrames({ state: layerState, services, parts, source }) {
return;
}
runtime.lastFrameSignature = signature;
runtime.ctx.clearRect(
0,
0,
PROJECTION_CANVAS_WIDTH,
PROJECTION_CANVAS_HEIGHT,
);
runtime.ctx.drawImage(
runtime.image,
runtime.ctx.fillStyle = PROJECTION_MATTE_COLOR;
runtime.ctx.fillRect(
0,
0,
PROJECTION_CANVAS_WIDTH,
PROJECTION_CANVAS_HEIGHT,
);
runtime.ctx.drawImage(runtime.image, fit.x, fit.y, fit.w, fit.h);
runtime.canvasStamp = (runtime.canvasStamp || 0) + 1;
runtime.lastPlaceholderPaintAt = 0;
}
+141
View File
@@ -0,0 +1,141 @@
// src/layers/cctv/frames.test.mjs
// Still-frame fit (#1201): stills draw at their source aspect, centered on the
// 1920x1080 projection canvas, instead of being stretched to fill it.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createFrames, fitToProjection } from './frames.js';
import {
FRAME_SIGNATURE_W,
FRAME_SIGNATURE_H,
PROJECTION_CANVAS_WIDTH,
PROJECTION_CANVAS_HEIGHT,
PROJECTION_MATTE_COLOR,
} from './policy.js';
// The expected rectangles below are written for a 1920x1080 canvas.
assert.equal(PROJECTION_CANVAS_WIDTH, 1920);
assert.equal(PROJECTION_CANVAS_HEIGHT, 1080);
const FULL_CANVAS = {
x: 0,
y: 0,
w: PROJECTION_CANVAS_WIDTH,
h: PROJECTION_CANVAS_HEIGHT,
};
test('a 4:3 still is pillarboxed at full height', () => {
assert.deepEqual(fitToProjection(640, 480), {
x: 240,
y: 0,
w: 1440,
h: 1080,
});
});
test('a source wider than 16:9 is letterboxed at full width', () => {
assert.deepEqual(fitToProjection(3840, 1080), {
x: 0,
y: 270,
w: 1920,
h: 540,
});
});
test('a 16:9 still fills the canvas', () => {
assert.deepEqual(fitToProjection(1920, 1080), FULL_CANVAS);
});
test('a missing or zero dimension falls back to the full canvas', () => {
assert.deepEqual(fitToProjection(0, 480), FULL_CANVAS);
assert.deepEqual(fitToProjection(640, 0), FULL_CANVAS);
assert.deepEqual(fitToProjection(640, undefined), FULL_CANVAS);
assert.deepEqual(fitToProjection(undefined, undefined), FULL_CANVAS);
});
// Draw path: the fit reaches drawImage, and the unchanged-frame skip keys on the
// fitted size as well as the pixels (the 64x36 hash alone drops aspect).
function stillHarness() {
const calls = [];
const ctx = {
fillStyle: null,
fillRect: (...rect) => calls.push(['fillRect', ctx.fillStyle, ...rect]),
clearRect: () => {},
drawImage: (_image, ...rect) => calls.push(['drawImage', ...rect]),
};
// Every frame downsamples to the same pixels, so only the size differs.
const pixels = new Uint8ClampedArray(
FRAME_SIGNATURE_W * FRAME_SIGNATURE_H * 4,
).fill(7);
const runtime = {
mode: 'image',
ctx,
image: { naturalWidth: 0, naturalHeight: 0 },
imageReady: true,
// In flight, so refreshProjectionImage returns before touching image.src.
imageLoading: true,
imageStamp: 0,
drawnImageStamp: -1,
lastFrameSignature: null,
canvasStamp: 0,
signatureCtx: {
clearRect: () => {},
drawImage: () => {},
getImageData: () => ({ data: pixels }),
},
};
const record = { camera: { id: 'still' }, projection: runtime };
const { drawProjectionFrame } = createFrames({
state: { _healthById: new Map(), _activeCameraId: null },
services: {},
parts: {},
source: {},
});
const load = (width, height) => {
runtime.image = { naturalWidth: width, naturalHeight: height };
runtime.imageStamp += 1;
calls.length = 0;
drawProjectionFrame(record);
return calls.slice();
};
return { runtime, load };
}
test('a still is drawn into its fitted rectangle over a full matte', () => {
const { runtime, load } = stillHarness();
assert.deepEqual(load(640, 480), [
[
'fillRect',
PROJECTION_MATTE_COLOR,
0,
0,
PROJECTION_CANVAS_WIDTH,
PROJECTION_CANVAS_HEIGHT,
],
['drawImage', 240, 0, 1440, 1080],
]);
assert.equal(runtime.canvasStamp, 1);
});
test('same pixels at the same size skip the redraw', () => {
const { runtime, load } = stillHarness();
load(640, 480);
assert.deepEqual(load(640, 480), []);
assert.equal(runtime.canvasStamp, 1);
});
test('same pixels at a new aspect redraw at the new fit', () => {
const { runtime, load } = stillHarness();
load(640, 480);
assert.deepEqual(load(1920, 1080), [
[
'fillRect',
PROJECTION_MATTE_COLOR,
0,
0,
PROJECTION_CANVAS_WIDTH,
PROJECTION_CANVAS_HEIGHT,
],
['drawImage', 0, 0, 1920, 1080],
]);
assert.equal(runtime.canvasStamp, 2);
});
-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 }) {
/**
+7 -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';
@@ -35,6 +29,9 @@ export const PROJECTION_CANVAS_WIDTH = 1920;
export const PROJECTION_CANVAS_HEIGHT = 1080;
/** Matte behind fitted stills; the placeholder gradient's dark end. */
export const PROJECTION_MATTE_COLOR = '#01070c';
// Downsample grid for the unchanged-frame signature (drawProjectionFrame).
// 64x36 keeps the 16:9 aspect and reads ~9 KB per check versus the 8.3 MB a
// full-resolution compare would touch.
@@ -87,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.
@@ -410,6 +404,9 @@ export const IDLE_CAMERA_COLOR =
export const ACTIVE_CAMERA_COLOR =
Cesium.Color.fromCssColorString('#ffd97a').withAlpha(0.95);
export const LIVE_CAMERA_COLOR =
Cesium.Color.fromCssColorString('#b48cff').withAlpha(0.88);
export const IDLE_COVERAGE_COLOR =
Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.24);
+12 -7
View File
@@ -4,6 +4,7 @@ import { isHeadingEstimated } from './headingConfidence.js';
import {
ACTIVE_CAMERA_COLOR,
IDLE_CAMERA_COLOR,
LIVE_CAMERA_COLOR,
ACTIVE_COVERAGE_CENTER,
IDLE_COVERAGE_CENTER_MUTED,
ACTIVE_COVERAGE_CENTER_DEPTHFAIL,
@@ -18,6 +19,15 @@ export function createRendering({
parts,
source,
}) {
/** Icon color: active wins, then live-capable video feeds, then idle stills. */
function cameraColorFor(record) {
if (record.camera.id === layerState._activeCameraId)
return ACTIVE_CAMERA_COLOR;
if (parts.model.isVideoFeedType(record.camera.feedType))
return LIVE_CAMERA_COLOR;
return IDLE_CAMERA_COLOR;
}
const {
focusNowMs,
getFocusTarget,
@@ -58,10 +68,7 @@ export function createRendering({
),
cameraDistanceFor: (position) =>
Cesium.Cartesian3.distance(camera.positionWC, position),
baseColorFor: (record) =>
record.camera.id === layerState._activeCameraId
? ACTIVE_CAMERA_COLOR
: IDLE_CAMERA_COLOR,
baseColorFor: cameraColorFor,
});
layerState._activeFocusStyleCount = result.activeCount;
}
@@ -208,9 +215,7 @@ export function createRendering({
for (const record of layerState._records) {
const isActive = record.camera.id === activeId;
if (record.billboard) {
record.billboard.color = isActive
? ACTIVE_CAMERA_COLOR
: IDLE_CAMERA_COLOR;
record.billboard.color = cameraColorFor(record);
record.billboard.scale = isActive ? 1.25 : 1.0;
// disableDepthTestDistance stays POSITIVE_INFINITY for every billboard
// (set at creation) — see the field-test far-zoom submerge fix there.
+73
View File
@@ -0,0 +1,73 @@
// src/layers/cctv/rendering.test.mjs
// Icon colors under focus de-emphasis (#1200): the focus pass fades each icon
// from the same base color the coverage refresh assigns, so a video-feed camera
// stays violet while it dims instead of falling back to the idle cyan.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRendering } from './rendering.js';
import {
ACTIVE_CAMERA_COLOR,
IDLE_CAMERA_COLOR,
LIVE_CAMERA_COLOR,
} from './policy.js';
import { isVideoFeedType } from '../../sources/cctvTypes.js';
const FOCUS_FACTOR = 0.5;
function assertFadedFrom(actual, base, label) {
assert.ok(actual, `${label}: color written`);
assert.deepEqual(
[actual.red, actual.green, actual.blue],
[base.red, base.green, base.blue],
`${label}: hue`,
);
assert.equal(actual.alpha, base.alpha * FOCUS_FACTOR, `${label}: alpha`);
}
test('focus de-emphasis fades each icon from its own color', () => {
const record = (id, feedType) => ({
camera: { id, feedType },
// No position, so the pass skips screen projection; the focus stub below
// decides the factor on its own.
billboard: { show: true },
});
const idleVideo = record('idle-hls', 'hls');
const idleStill = record('idle-image', 'image');
const activeVideo = record('active-hls', 'hls');
const state = {
_enabled: true,
_viewer: { scene: {}, camera: {} },
_records: [idleVideo, idleStill, activeVideo],
_activeCameraId: activeVideo.camera.id,
_activeFocusStyleCount: 0,
_lastFocusStyleAt: -Infinity,
};
const focus = {
focusNowMs: (nowMs) => nowMs,
getFocusTarget: () => ({}),
focusPassIsNeeded: () => true,
advanceSpriteFocus: () => ({
factor: FOCUS_FACTOR,
active: true,
transitioning: false,
}),
focusAlphaNeedsWrite: () => true,
};
const { refreshCctvFocusStyles } = createRendering({
state,
services: { focus },
parts: { model: { isVideoFeedType } },
source: {},
});
refreshCctvFocusStyles(1000);
assertFadedFrom(idleVideo.billboard.color, LIVE_CAMERA_COLOR, 'idle video');
assertFadedFrom(idleStill.billboard.color, IDLE_CAMERA_COLOR, 'idle still');
assertFadedFrom(
activeVideo.billboard.color,
ACTIVE_CAMERA_COLOR,
'active video',
);
assert.equal(state._activeFocusStyleCount, 3);
});
-4
View File
@@ -1,9 +1,5 @@
export const FRAME_ENDPOINT = '/api/cctv/frame';
export const SOURCE_ENDPOINT = '/api/cctv/sources';
export const HEALTH_ENDPOINT = '/api/cctv/health';
export const MEDIA_ENDPOINT = '/api/cctv/media';
export const ACTIVE_FRAME_REFRESH_MS = 10000;
+6 -3
View File
@@ -41,13 +41,16 @@ const ADVISORIES_UNAVAILABLE = msg('Cyclone advisories unavailable');
const SERVER_REASONS = new Set([msg('Cyclone data unavailable')]);
const errorText = (error) =>
error && (SERVER_REASONS.has(error) ? t(error) : translateKnown(error));
// NHC's Tropical Cyclone Status JSON reference: PTC is post-tropical, PC is a
// potential tropical cyclone, and subtropical systems are STS and STD.
const CLASSIFICATION_NAMES = Object.freeze({
PTC: msg('Potential tropical cyclone'),
PC: msg('Potential tropical cyclone'),
PTC: msg('Post-tropical cyclone'),
HU: msg('Hurricane'),
TS: msg('Tropical storm'),
TD: msg('Tropical depression'),
SS: msg('Subtropical storm'),
SD: msg('Subtropical depression'),
STS: msg('Subtropical storm'),
STD: msg('Subtropical depression'),
});
const classificationName = (code) =>
Object.hasOwn(CLASSIFICATION_NAMES, code)
+4 -3
View File
@@ -513,12 +513,13 @@ test('only the latest queued focus runs and an unchanged selection survives refr
});
test('classification display expands known codes without altering the source or guessing unknown meanings', async () => {
for (const [code, label] of [
['PTC', 'Potential tropical cyclone'],
['PC', 'Potential tropical cyclone'],
['PTC', 'Post-tropical cyclone'],
['HU', 'Hurricane'],
['TS', 'Tropical storm'],
['TD', 'Tropical depression'],
['SS', 'Subtropical storm'],
['SD', 'Subtropical depression'],
['STS', 'Subtropical storm'],
['STD', 'Subtropical depression'],
['EX', 'EX'],
['UNKNOWN', 'UNKNOWN'],
]) {
+2 -2
View File
@@ -64,7 +64,7 @@ const SHARED_CARD = Object.freeze({
* @param {boolean} selected
* @returns {object}
*/
export function cycloneStormEntry(storm, selected) {
function cycloneStormEntry(storm, selected) {
return {
...SHARED_CARD,
...markerClearance(
@@ -92,7 +92,7 @@ export function cycloneStormEntry(storm, selected) {
* @param {{tauHours:number,position:object}} point
* @returns {object}
*/
export function cycloneLeadEntry(stormId, point) {
function cycloneLeadEntry(stormId, point) {
return {
...SHARED_CARD,
...markerClearance(CYCLONE_MARKER_PX.forecast),
+23 -7
View File
@@ -100,7 +100,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', {
@@ -118,7 +119,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');
@@ -294,16 +296,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':
@@ -332,9 +342,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);
@@ -342,6 +357,7 @@ export function directionsInstruction(step) {
return road
? t('Continue onto {road}', values)
: t('Continue', { context: 'maneuver' });
}
default:
return road
? t('Continue onto {road}', values)
+6 -3
View File
@@ -4,6 +4,7 @@ import {
satelliteShortName,
fireDetectionKey,
accentForSeverity,
formatFrpMw,
} from '../../data/firmsLabels.js';
import {
VIEW_PADDING,
@@ -480,14 +481,15 @@ export function createModel({
}
function formatFrp(frp) {
return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1);
return formatFrpMw(frp);
}
/** formatFrp for a card, with the locale's decimal separator ("4,9"). */
function formatFrpLabel(frp) {
const digits = frp >= 10 ? 0 : 1;
return formatNumber(frp, {
const text = formatFrpMw(frp);
const digits = text.includes('.') ? 1 : 0;
return formatNumber(Number(text), {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
useGrouping: false,
@@ -591,6 +593,7 @@ export function createModel({
formatLatLon,
formatFrp,
formatAge,
formatAgeLabel,
formatAgoMinutes,
confidenceBucket,
};
+6 -7
View File
@@ -18,9 +18,8 @@ export function createQueries({
if (!fire) return null;
return {
id: fireDetectionKey(fire),
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(fire.frp),
}),
// i18n-ignore: English entity label; the voice card translates it (shownEntityLabel)
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
latitude: fire.lat,
longitude: fire.lon,
frp: fire.frp,
@@ -64,7 +63,8 @@ export function createQueries({
const staleText = layerState._lastUpdate
? t('STALE · cached {age}', {
age:
components.model.formatAge(now - layerState._lastUpdate) || '<1h',
components.model.formatAgeLabel(now - layerState._lastUpdate) ||
t('<1h', { context: 'age' }),
})
: t('STALE');
let loadingLabel = '';
@@ -117,9 +117,8 @@ export function createQueries({
latitude: strongest.lat,
longitude: strongest.lon,
frp: strongest.frp,
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(strongest.frp),
}),
// i18n-ignore: English entity label; the voice card translates it (shownEntityLabel)
label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`,
};
},
+2 -3
View File
@@ -193,9 +193,8 @@ export function createSelection({
layerName: name,
source: 'NASA FIRMS',
dataSource: layerState._dataSource,
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(fire.frp),
}),
// i18n-ignore: English entity label; the voice card translates it (shownEntityLabel)
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
latitude: fire.lat,
longitude: fire.lon,
properties: {
+36 -11
View File
@@ -60,6 +60,10 @@ export function createMotion({
publishFocusTargetFromCachedPosition,
} = services.focus;
// Fixes leave the cache with their history; corrected positions invalidate
// the local frame even when the existing Cartesian is mutated in place.
const fixFrames = new WeakMap();
// Round 5: the old `_warmGroundedAircraftSurfaceCache` (global exact-5-decimal
// warm for every grounded contact on Earth) is GONE. Parked-aircraft GPS
// jitter minted fresh keys every poll → thousands of upstream points per
@@ -124,7 +128,7 @@ export function createMotion({
* @returns {Cesium.Cartesian3|null} Estimated ECEF position, or null if no history exists.
*/
function _deadReckon(icao24, result) {
function _deadReckon(icao24, result, sampledRenderTime) {
const info = flightState.records.data.get(icao24);
if (FOCUS_EVIDENCE_DEV && flightState._focusEvidenceIds.has(icao24)) {
const position = flightState._billboards.get(icao24)?.position;
@@ -149,11 +153,13 @@ export function createMotion({
const out = result || new Cesium.Cartesian3();
// Render one poll interval behind real time so we interpolate between
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
const renderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(),
-RENDER_DELAY_SEC,
flightState._scratchRenderTime,
);
const renderTime =
sampledRenderTime ??
Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(flightState._scratchRenderTime),
-RENDER_DELAY_SEC,
flightState._scratchRenderTime,
);
// Bracketing pair: interpolate — no extrapolation error, no snap-back.
for (let i = history.length - 1; i >= 1; i--) {
@@ -286,12 +292,31 @@ export function createMotion({
0,
flightState._scratchOffset,
);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
fix.position,
Cesium.Ellipsoid.WGS84,
flightState._scratchEnu,
let frame = fixFrames.get(fix);
if (!frame) {
frame = {
position: new Cesium.Cartesian3(),
matrix: new Cesium.Matrix4(),
};
fixFrames.set(fix, frame);
}
if (
!frame.valid ||
!Cesium.Cartesian3.equals(frame.position, fix.position)
) {
Cesium.Transforms.eastNorthUpToFixedFrame(
fix.position,
Cesium.Ellipsoid.WGS84,
frame.matrix,
);
Cesium.Cartesian3.clone(fix.position, frame.position);
frame.valid = true;
}
return Cesium.Matrix4.multiplyByPoint(
frame.matrix,
flightState._scratchOffset,
out,
);
return Cesium.Matrix4.multiplyByPoint(enu, flightState._scratchOffset, out);
}
/**
+184
View File
@@ -0,0 +1,184 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createMotion } from './motion.js';
import { arcOffsetEnu } from '../../data/motionModel.js';
function fixture() {
const flightState = {
records: { data: new Map() },
_positionHistory: new Map(),
_focusEvidenceIds: new Set(),
_scratchArc: {},
_scratchOffset: new Cesium.Cartesian3(),
_scratchRenderTime: new Cesium.JulianDate(),
};
const motion = createMotion({
flightState,
services: { groundFloor: {}, camera: {}, focus: {} },
parts: {},
});
return { flightState, motion };
}
function expectedPosition(fix, dt, turnRate, transform) {
const arc = {};
arcOffsetEnu(fix.velocity, fix.track, turnRate, dt, arc);
return Cesium.Matrix4.multiplyByPoint(
transform(fix.position, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4()),
new Cesium.Cartesian3(arc.east, arc.north, 0),
new Cesium.Cartesian3(),
);
}
function close(actual, expected) {
assert.ok(Cesium.Cartesian3.distance(actual, expected) < 1e-8);
}
test('extrapolation reuses fixed frames and invalidates corrected coordinates', (t) => {
const { motion } = fixture();
const transform = Cesium.Transforms.eastNorthUpToFixedFrame;
const spy = t.mock.method(
Cesium.Transforms,
'eastNorthUpToFixedFrame',
transform,
);
const fix = {
position: Cesium.Cartesian3.fromDegrees(-97, 30, 200),
velocity: 90,
track: 70,
};
const out = new Cesium.Cartesian3();
for (const dt of [-30, 5, 30, 60]) {
motion._extrapolateFix(fix, {}, dt, out, 1.5);
close(out, expectedPosition(fix, dt, 1.5, transform));
}
assert.equal(spy.mock.callCount(), 1);
// Ground reflooring mutates an existing fix's Cartesian in place.
Cesium.Cartesian3.fromDegrees(-97, 30, 350, undefined, fix.position);
motion._extrapolateFix(fix, {}, 30, out, 1.5);
close(out, expectedPosition(fix, 30, 1.5, transform));
assert.equal(spy.mock.callCount(), 2);
fix.position = Cesium.Cartesian3.fromDegrees(140, 80, 1000);
motion._extrapolateFix(fix, {}, 30, out, -2);
close(out, expectedPosition(fix, 30, -2, transform));
assert.equal(spy.mock.callCount(), 3);
fix.velocity = 210;
fix.track = 359;
motion._extrapolateFix(fix, {}, 15, out, -1);
close(out, expectedPosition(fix, 15, -1, transform));
assert.equal(spy.mock.callCount(), 3);
const replacement = { ...fix };
motion._extrapolateFix(replacement, {}, 15, out, -1);
close(out, expectedPosition(replacement, 15, -1, transform));
assert.equal(spy.mock.callCount(), 4);
});
test('stationary fixes do not build a frame until motion needs it', (t) => {
const { motion } = fixture();
const spy = t.mock.method(Cesium.Transforms, 'eastNorthUpToFixedFrame');
const fix = {
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
velocity: 0,
track: 90,
};
const out = new Cesium.Cartesian3();
close(motion._extrapolateFix(fix, {}, 30, out), fix.position);
fix.velocity = 10;
close(motion._extrapolateFix(fix, {}, 0, out), fix.position);
assert.equal(spy.mock.callCount(), 0);
motion._extrapolateFix(fix, {}, 30, out);
assert.equal(spy.mock.callCount(), 1);
});
test('fleet render time is shared without resampling or mutating the supplied instant', (t) => {
const { flightState, motion } = fixture();
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
const end = Cesium.JulianDate.addSeconds(start, 60, new Cesium.JulianDate());
const renderTime = Cesium.JulianDate.addSeconds(
start,
30,
new Cesium.JulianDate(),
);
const savedTime = Cesium.JulianDate.clone(renderTime);
const history = [
{
time: start,
position: Cesium.Cartesian3.fromDegrees(-97, 30, 1000),
track: 90,
},
{
time: end,
position: Cesium.Cartesian3.fromDegrees(-96.99, 30, 1000),
track: 90,
},
];
for (const id of ['one', 'two']) {
flightState.records.data.set(id, { velocity: 100, true_track: 90 });
flightState._positionHistory.set(id, history);
}
t.mock.method(Cesium.JulianDate, 'now', () => {
throw new Error('unexpected per-contact clock read');
});
const expected = Cesium.Cartesian3.lerp(
history[0].position,
history[1].position,
0.5,
new Cesium.Cartesian3(),
);
for (const id of ['one', 'two'])
close(
motion._deadReckon(id, new Cesium.Cartesian3(), renderTime),
expected,
);
assert.ok(Cesium.JulianDate.equals(renderTime, savedTime));
});
test('tracked callers still sample a fresh delayed time without a fleet epoch', (t) => {
const { flightState, motion } = fixture();
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
const history = [
{
time: start,
position: Cesium.Cartesian3.fromDegrees(-97, 30, 1000),
track: 90,
},
{
time: Cesium.JulianDate.addSeconds(start, 60, new Cesium.JulianDate()),
position: Cesium.Cartesian3.fromDegrees(-96.99, 30, 1000),
track: 90,
},
];
flightState.records.data.set('tracked', { velocity: 100, true_track: 90 });
flightState._positionHistory.set('tracked', history);
let elapsed = 60;
const spy = t.mock.method(Cesium.JulianDate, 'now', (result) => {
assert.equal(result, flightState._scratchRenderTime);
return Cesium.JulianDate.addSeconds(start, elapsed, result);
});
const out = new Cesium.Cartesian3();
close(
motion._deadReckon('tracked', out),
Cesium.Cartesian3.lerp(
history[0].position,
history[1].position,
0.5,
new Cesium.Cartesian3(),
),
);
elapsed = 75;
close(
motion._deadReckon('tracked', out),
Cesium.Cartesian3.lerp(
history[0].position,
history[1].position,
0.75,
new Cesium.Cartesian3(),
),
);
assert.equal(spy.mock.callCount(), 2);
});
-8
View File
@@ -1,11 +1,3 @@
export {
GROUND_FLOOR_CLAMP_RADIUS_KM,
GROUND_FLOOR_WARM_MAX_ALT_M,
POSITION_HISTORY_LIMIT,
LANDED_MISSING_POLL_LIMIT,
MISSING_POLL_LIMIT,
ERROR_BACKOFF_INTERVAL,
} from './recordPolicy.js';
import * as Cesium from 'cesium';
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
+15 -5
View File
@@ -452,8 +452,8 @@ export function createQueries({
const isTracked = icao24 === flightState._trackedIcao;
// Keep planes rendered as a 3D model (billboard hidden) so the detection box
// doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden.
const model = flightState._models.get(icao24);
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24);
const model = flightState._models.get(icao24) ?? null;
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24, model);
if (!isTracked && !bb.show && !modelOwnsVisual) continue;
const info = flightState.records.data.get(icao24);
let object = flightState._detectionObjects.get(icao24);
@@ -491,8 +491,19 @@ export function createQueries({
object.position = pos;
object.skipLabel = isTracked;
// Card text only — declutter/cohort identity is `object.sourceId` (icao24).
const id = _contactLabel(icao24, info);
if (object.id !== id) object.id = id;
const callsign = info?.callsign;
const registration = info?.registration;
// Records and enrichment may change in place. Cache by field values,
// while keeping the live visual anchor above independent of text.
if (
object.id === undefined ||
object._labelCallsign !== callsign ||
object._labelRegistration !== registration
) {
object.id = _contactLabel(icao24, info);
object._labelCallsign = callsign;
object._labelRegistration = registration;
}
const altitude = info?.altitude;
if (object._altitude !== altitude) {
object._altitude = altitude;
@@ -580,7 +591,6 @@ export function createQueries({
const maxRange =
Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY;
const now = Cesium.JulianDate.now();
const nearby = [];
for (const [icao24, bb] of flightState._billboards) {
+149
View File
@@ -0,0 +1,149 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createQueries } from './queries.js';
import { createRendering } from './rendering.js';
function fixture() {
const info = {
callsign: ' TEST123 ',
registration: ' N123 ',
altitude: 1000,
};
const bb = { show: true, position: new Cesium.Cartesian3(1, 2, 3) };
const flightState = {
feed: {},
_billboardCollection: { show: true },
_billboards: new Map([['abc123', bb]]),
_models: new Map(),
_detectionObjects: new Map(),
records: { data: new Map([['abc123', info]]) },
};
let formats = 0;
const services = {
aircraftPresentation: {},
militaryRegistry: {},
camera: {},
focus: {},
recession: {},
labels: {
formatFlightLevel: (altitude) => {
formats++;
return String(altitude);
},
},
};
const parts = {
testing: {},
tracking: { _trackedModelRegimeActive: () => true },
motion: { _trackedVisualCached: () => null },
};
parts.rendering = createRendering({ flightState, services, parts });
const queries = createQueries({ flightState, services, parts }).methods;
return { flightState, info, bb, parts, queries, formats: () => formats };
}
test('candidate text follows in-place enrichment without freezing identity or positions', () => {
const f = fixture();
const [first] = f.queries.getDetectableObjects();
assert.equal(first.id, 'TEST123');
assert.equal(first.sourceId, 'abc123');
f.bb.position = new Cesium.Cartesian3(4, 5, 6);
assert.equal(f.queries.getDetectableObjects()[0], first);
assert.equal(first.position, f.bb.position);
assert.equal(f.formats(), 1);
f.info.callsign = ' NEW456 ';
assert.equal(f.queries.getDetectableObjects()[0].id, 'NEW456');
f.info.callsign = ' ';
assert.equal(f.queries.getDetectableObjects()[0].id, 'N123');
f.info.registration = ' N456 ';
f.info.altitude = 2000;
assert.equal(f.queries.getDetectableObjects()[0].id, 'N456');
assert.equal(first.metric, '2000');
assert.equal(f.formats(), 2);
f.flightState.records.data.set('abc123', { ...f.info });
assert.equal(f.queries.getDetectableObjects()[0], first);
f.flightState.records.data.delete('abc123');
assert.equal(f.queries.getDetectableObjects()[0].id, 'abc123');
assert.equal(first.sourceId, 'abc123');
});
test('unchanged candidate text is normalized once across paints', (t) => {
const f = fixture();
const trim = String.prototype.trim;
let calls = 0;
t.mock.method(String.prototype, 'trim', function () {
if (String(this) === ' TEST123 ') calls++;
return trim.call(this);
});
for (let i = 0; i < 20; i++) f.queries.getDetectableObjects();
assert.equal(calls, 1);
});
test('candidate collection resolves models once and keeps handoffs live', (t) => {
const f = fixture();
const get = t.mock.method(f.flightState._models, 'get');
const [candidate] = f.queries.getDetectableObjects();
assert.equal(get.mock.callCount(), 1);
const model = {
ready: false,
show: true,
modelMatrix: Cesium.Matrix4.fromTranslation(
new Cesium.Cartesian3(10, 20, 30),
),
};
f.parts.rendering._modelSpec = () => ({
visualCenterNative: Cesium.Cartesian3.ZERO,
scale: 1,
});
f.flightState._models.set('abc123', model);
f.bb.show = false;
assert.deepEqual(f.queries.getDetectableObjects(), []);
model.ready = true;
assert.equal(f.queries.getDetectableObjects()[0], candidate);
assert.deepEqual(candidate.position, new Cesium.Cartesian3(10, 20, 30));
Cesium.Matrix4.setTranslation(
model.modelMatrix,
new Cesium.Cartesian3(40, 50, 60),
model.modelMatrix,
);
f.queries.getDetectableObjects();
assert.deepEqual(candidate.position, new Cesium.Cartesian3(40, 50, 60));
model.show = false;
assert.deepEqual(f.queries.getDetectableObjects(), []);
f.bb.show = true;
assert.equal(f.queries.getDetectableObjects()[0].position, f.bb.position);
assert.equal(get.mock.callCount(), 6);
model.show = true;
assert.equal(f.parts.rendering._modelOwnsVisual('abc123'), true);
assert.equal(get.mock.callCount(), 7);
});
test('tracked ownership and live anchors remain independent of fleet models', () => {
const f = fixture();
f.flightState._trackedIcao = 'abc123';
f.flightState._trackedModel = { ready: true, show: true };
f.parts.rendering._modelSpec = () => ({});
let anchor = new Cesium.Cartesian3(10, 20, 30);
f.parts.motion._trackedVisualCached = () => anchor;
const [candidate] = f.queries.getDetectableObjects();
assert.equal(candidate.position, anchor);
assert.equal(candidate.skipLabel, true);
assert.equal(f.parts.rendering._modelOwnsVisual('abc123', null), true);
anchor = new Cesium.Cartesian3(40, 50, 60);
assert.equal(f.queries.getDetectableObjects()[0].position, anchor);
for (const tracked of [
null,
{ ready: false, show: true },
{ ready: true, show: false },
]) {
f.flightState._trackedModel = tracked;
assert.equal(
f.parts.rendering._modelOwnsVisual('abc123', { ready: true, show: true }),
false,
);
}
f.flightState._trackedModel = { ready: true, show: true };
f.parts.tracking._trackedModelRegimeActive = () => false;
assert.equal(f.parts.rendering._modelOwnsVisual('abc123', null), false);
});
+20 -3
View File
@@ -32,10 +32,12 @@ import {
TRACKED_MODEL_MIN_PX,
TRACKED_MODEL_MAX_PX,
FLEET_DR_INTERVAL_MS,
RENDER_DELAY_SEC,
COURSE_SLEW_DT_MAX_SEC,
ROTATION_REFRESH_MS,
COURSE_MAX_DPS,
} from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
export function createRendering({
flightState,
@@ -382,17 +384,20 @@ export function createRendering({
* null or still-loading tracked model from claiming a visual it is not
* drawing yet.
* @param {string} icao24
* @param {Cesium.Model|null} [model] Resolved fleet model; null means absent.
* @returns {boolean}
*/
function _modelOwnsVisual(icao24) {
function _modelOwnsVisual(icao24, model) {
if (icao24 === flightState._trackedIcao) {
return (
parts.tracking._trackedModelRegimeActive() &&
_modelIsRendering(flightState._trackedModel)
);
}
return _modelIsRendering(flightState._models.get(icao24));
return _modelIsRendering(
model === undefined ? flightState._models.get(icao24) : model,
);
}
/** A model is the visual only once it actually draws — loaded AND shown.
@@ -503,6 +508,7 @@ export function createRendering({
model = await Cesium.Model.fromGltfAsync({
url: resolveAsset(spec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: MODEL_MIN_PX,
scale: spec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
@@ -709,6 +715,7 @@ export function createRendering({
Cesium.Model.fromGltfAsync({
url: resolveAsset(trackedSpec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: TRACKED_MODEL_MIN_PX,
scale: trackedSpec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN,
@@ -854,6 +861,12 @@ export function createRendering({
)
: 0.08;
flightState._lastFleetTickMs = nowMs;
// Every contact in this pass shares the same delayed display instant.
const renderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(flightState._scratchFleetRenderTime),
-RENDER_DELAY_SEC,
flightState._scratchFleetRenderTime,
);
_drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload
if (flightState._cockpitContactMode)
@@ -957,7 +970,11 @@ export function createRendering({
const info = flightState.records.data.get(icao24);
const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos);
const dr = parts.motion._deadReckon(
icao24,
flightState._scratchFleetPos,
renderTime,
);
// The dead-reckoned point drifts away from the fix whose cell supplied the
// height, so a grounded contact's sprite ends up under the mesh it taxied
// (or coasted) over. Re-floor at the DISPLAYED coordinate — read-only, and
+2 -2
View File
@@ -242,12 +242,12 @@ export function createFlightState({ source, services }) {
flightState._scratchCarto = new Cesium.Cartographic();
flightState._scratchEnu = new Cesium.Matrix4();
flightState._scratchArc = { east: 0, north: 0, endCourseDeg: 0 };
flightState._scratchRenderTime = new Cesium.JulianDate();
flightState._scratchFleetRenderTime = new Cesium.JulianDate();
flightState._scratchFleetPos = new Cesium.Cartesian3();
flightState._scratchSonarScreen = new Cesium.Cartesian2();
+4 -2
View File
@@ -47,7 +47,7 @@ export function createTracking({
const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry;
const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout;
const { applyTrackedCameraFrame } = services.camera;
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
const flightsLayer = layer;
function _emitAwarenessEvent(type, detail) {
@@ -1174,7 +1174,9 @@ export function createTracking({
// first-person reference. A globe click must not fall through to the
// normal empty-space deselection path; cockpit has explicit exit controls.
if (document.body.classList.contains('cockpit-mode')) return;
const picked = viewer.scene.pick(click.position);
const picked = pickContact
? pickContact(viewer.scene, click.position)
: viewer.scene.pick(click.position);
if (picked) {
// Clicking the tracked entity itself — ignore (don't deselect)
+245
View File
@@ -0,0 +1,245 @@
// S2 Underground GhostMaps catalog: which KMZ in the public repository is the
// current edition of each product. Pure and portable (the server builds the
// catalog, tests pin the rules); no i18n, no DOM, no Cesium.
export const GHOSTMAPS_REPO = Object.freeze({
owner: 's2underground',
name: 'GhostMaps',
branch: 'main',
url: 'https://github.com/s2underground/GhostMaps',
});
/** Largest KMZ the proxy downloads; matches the browser's import ceiling. */
export const GHOSTMAPS_MAX_FILE_BYTES = 50 * 1024 * 1024;
const ARCGIS = 'ArcGIS Data for ATAK (KMZs)/';
const INCIDENTS = 'Incident Response Products for ATAK (KMZs and Documents)/';
/**
* Curated products, in panel order. Each names a series of dated uploads; the
* newest edition wins. Patterns match whole repository paths, so a renamed
* folder fails closed (the product drops out) instead of matching elsewhere.
*/
export const GHOSTMAPS_PRODUCTS = Object.freeze([
Object.freeze({
id: 'cip',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Common Intelligence Picture',
defaultOn: true,
pattern: new RegExp(
`^${escape(ARCGIS)}Common Intelligence Picture/Master Database/[^/]+\\.kmz$`,
'i',
),
}),
Object.freeze({
id: 'kinetic-30d',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Kinetic Activity (30 days)',
defaultOn: true,
pattern: new RegExp(
`^${escape(ARCGIS)}Common Intelligence Picture/30-Day Pulls/Kinetic[^/]*\\.kmz$`,
'i',
),
}),
Object.freeze({
id: 'border-crisis',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Border Crisis Map',
defaultOn: false,
pattern: new RegExp(
`^${escape(ARCGIS)}Border Crisis Map/S2Underground_Border_Crisis_Map[^/]*\\.kmz$`,
'i',
),
}),
Object.freeze({
id: 'iran-cip',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Iran Common Intelligence Picture',
defaultOn: false,
pattern: new RegExp(
`^${escape(INCIDENTS)}Iran[^/]*/Iran Common Intelligence Picture/[^/]+\\.kmz$`,
'i',
),
}),
Object.freeze({
id: 'iran-kinetic',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Iran Kinetic Events',
defaultOn: false,
pattern: new RegExp(
`^${escape(INCIDENTS)}Iran[^/]*/Iran Kinetic Events/[^/]+\\.kmz$`,
'i',
),
}),
Object.freeze({
id: 'yemen-flot',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Yemen FLOT',
defaultOn: false,
pattern: new RegExp(
`^${escape(INCIDENTS)}Yemen[^/]*/Yemen FLOT[^/]*\\.kmz$`,
'i',
),
}),
]);
/** The newest incident product outside the curated series and the archive. */
export const GHOSTMAPS_LATEST_DROP = Object.freeze({
id: 'latest-drop',
// i18n-ignore: catalog data; the panel shows its own translated label
title: 'Latest incident drop',
defaultOn: false,
});
const LATEST_DROP_EXCLUDED =
/\/(?:0_Archive|Basedata|Reference Material)(?:\/|$)/i;
function escape(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const MONTHS = [
'jan',
'feb',
'mar',
'apr',
'may',
'jun',
'jul',
'aug',
'sep',
'oct',
'nov',
'dec',
];
const DATE_IN_NAME =
/(january|february|march|april|may|june|july|august|september|sept|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|oct|nov|dec)[ _-]?(\d{1,2})(?:st|nd|rd|th)?[ ,_-]+(20\d\d)/i;
const EDITION_IN_NAME = /(?:[ _-]export)?[ _-]v?(\d{1,3})$/i;
/**
* The date and edition written into an S2 file name, e.g.
* `..._October_16_2026_Export_1.kmz` → 2026-10-16, edition 1. Undated names
* report `dateMs: null` and sort below every dated edition.
* @param {string} fileName
* @returns {{dateMs: ?number, edition: number}}
*/
export function parseGhostMapsEdition(fileName) {
const stem = String(fileName || '').replace(/\.km[lz]$/i, '');
const match = DATE_IN_NAME.exec(stem);
let dateMs = null;
if (match) {
const month = MONTHS.indexOf(match[1].slice(0, 3).toLowerCase());
const day = Number(match[2]);
const year = Number(match[3]);
const at = Date.UTC(year, month, day);
// Date.UTC rolls 31 Sep into 1 Oct; only an exact calendar date counts.
if (month >= 0 && new Date(at).getUTCDate() === day) dateMs = at;
}
const tail = match ? stem.slice(match.index + match[0].length) : stem;
const edition = Number(EDITION_IN_NAME.exec(tail)?.[1] ?? 0);
return { dateMs, edition };
}
function newer(a, b) {
if (!b) return true;
const ad = a.dateMs ?? -Infinity;
const bd = b.dateMs ?? -Infinity;
if (ad !== bd) return ad > bd;
if (a.edition !== b.edition) return a.edition > b.edition;
return a.path > b.path;
}
/** `S2Underground_Border_Crisis_Map_June_02_2026_v1.kmz` → readable words. */
export function ghostMapsFileTitle(fileName) {
return String(fileName || '')
.replace(/\.km[lz]$/i, '')
.replace(/^S2Underground_/i, '')
.replace(/_/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Build the product catalog from a recursive git tree listing.
* @param {Array<{path: string, type: string, sha: string, size?: number}>} tree
* @returns {Array<{id: string, title: string, defaultOn: boolean,
* path: string, fileName: string, fileTitle: string, version: string,
* size: number, dateMs: ?number, edition: number}>}
*/
export function buildGhostMapsCatalog(tree) {
const best = new Map();
let drop = null;
for (const entry of Array.isArray(tree) ? tree : []) {
if (entry?.type !== 'blob' || typeof entry.path !== 'string') continue;
if (!/\.kmz$/i.test(entry.path)) continue;
if (!/^[0-9a-f]{40}$/.test(entry.sha || '')) continue;
const size = Number(entry.size);
if (!Number.isFinite(size) || size <= 0 || size > GHOSTMAPS_MAX_FILE_BYTES)
continue;
const fileName = entry.path.slice(entry.path.lastIndexOf('/') + 1);
const candidate = {
path: entry.path,
fileName,
version: entry.sha,
size,
...parseGhostMapsEdition(fileName),
};
const product = GHOSTMAPS_PRODUCTS.find(({ pattern }) =>
pattern.test(entry.path),
);
if (product) {
if (newer(candidate, best.get(product.id)))
best.set(product.id, candidate);
} else if (
entry.path.startsWith(INCIDENTS) &&
!LATEST_DROP_EXCLUDED.test(`/${entry.path}`) &&
candidate.dateMs != null &&
newer(candidate, drop)
) {
drop = candidate;
}
}
const rows = [];
for (const product of [...GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP]) {
const file =
product === GHOSTMAPS_LATEST_DROP ? drop : best.get(product.id);
if (!file) continue;
rows.push({
id: product.id,
title: product.title,
defaultOn: product.defaultOn,
...file,
fileTitle: ghostMapsFileTitle(file.fileName),
});
}
return rows;
}
/**
* Raw download URL pinned to a commit, so a CDN can never hand back an older
* edition for a newer catalog.
* @param {string} commit 40-hex commit sha.
* @param {string} path Repository path.
*/
export function ghostMapsRawUrl(commit, path) {
const encoded = path.split('/').map(encodeURIComponent).join('/');
return `https://raw.githubusercontent.com/${GHOSTMAPS_REPO.owner}/${GHOSTMAPS_REPO.name}/${commit}/${encoded}`;
}
/**
* The branch head from a git smart-HTTP ref advertisement
* (`/info/refs?service=git-upload-pack`), or null.
* @param {string} text
* @param {string} [branch]
* @returns {?string}
*/
export function parseGitRefAdvertisement(text, branch = GHOSTMAPS_REPO.branch) {
const ref = `refs/heads/${branch}`;
for (const line of String(text || '').split('\n')) {
// pkt-line: 4 hex length digits, then "<sha> <ref>" (the first ref line
// also carries a NUL and the capability list).
const match = /^[0-9a-f]{4}([0-9a-f]{40}) ([^\0\s]+)/.exec(line);
if (match && match[2] === ref) return match[1];
}
return null;
}
+165
View File
@@ -0,0 +1,165 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildGhostMapsCatalog,
ghostMapsFileTitle,
ghostMapsRawUrl,
parseGhostMapsEdition,
parseGitRefAdvertisement,
} from './catalog.js';
const ARCGIS = 'ArcGIS Data for ATAK (KMZs)/';
const INCIDENTS = 'Incident Response Products for ATAK (KMZs and Documents)/';
let n = 0;
const blob = (path, size = 1000) => ({
path,
type: 'blob',
size,
sha: (++n).toString(16).padStart(40, '0'),
});
test('editions read the date and export/version number written in the name', () => {
assert.deepEqual(
parseGhostMapsEdition(
'S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz',
),
{ dateMs: Date.UTC(2026, 9, 16), edition: 1 },
);
assert.deepEqual(
parseGhostMapsEdition(
'S2Underground_Border_Crisis_Map_September_21_2026_Export_v2.kmz',
),
{ dateMs: Date.UTC(2026, 8, 21), edition: 2 },
);
assert.deepEqual(
parseGhostMapsEdition('Reno Wildfires August 24, 2026.kmz'),
{
dateMs: Date.UTC(2026, 7, 24),
edition: 0,
},
);
assert.deepEqual(
parseGhostMapsEdition('Venezuelan_Crisis_Sep_5_2025_v2.kmz'),
{
dateMs: Date.UTC(2025, 8, 5),
edition: 2,
},
);
assert.deepEqual(
parseGhostMapsEdition('Kinetic_Activities_Last_30_Days.kmz'),
{
dateMs: null,
edition: 0,
},
);
// An impossible date is not a date.
assert.equal(parseGhostMapsEdition('X_September_31_2026.kmz').dateMs, null);
});
test('each product keeps its newest edition by date, then edition number', () => {
const master = `${ARCGIS}Common Intelligence Picture/Master Database/`;
const tree = [
blob(
`${master}S2Underground_Common_Intelligence_Picture_October_9_2026_Export_1.kmz`,
),
blob(
`${master}S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz`,
),
blob(
`${master}S2Underground_Common_Intelligence_Picture_October_1_2026_Export_1.kmz`,
),
blob(
`${master}S2Underground_Common_Intelligence_Picture_September_30_2026_Export_2.kmz`,
),
blob(
`${ARCGIS}Common Intelligence Picture/30-Day Pulls/Kinetic_Activities_Last_30_Days.kmz`,
),
blob(
`${ARCGIS}Border Crisis Map/S2Underground_Border_Crisis_Map_September_21_2026_Export_v1.kmz`,
),
blob(
`${ARCGIS}Border Crisis Map/S2Underground_Border_Crisis_Map_September_21_2026_Export_v2.kmz`,
),
// A European-only subset in a subfolder is not the border map.
blob(
`${ARCGIS}Border Crisis Map/European Layers Only/Europe_Migration_Crisis_June_02_2026_v1.kmz`,
),
blob(`${INCIDENTS}Yemen 2026/Yemen FLOT.kmz`),
blob(`${INCIDENTS}Yemen 2026/Yemen FLOT_October_10_2026.kmz`),
{ path: `${INCIDENTS}Yemen 2026`, type: 'tree', sha: 'f'.repeat(40) },
];
const rows = buildGhostMapsCatalog(tree);
const byId = Object.fromEntries(rows.map((row) => [row.id, row]));
assert.deepEqual(
rows.map(({ id }) => id),
['cip', 'kinetic-30d', 'border-crisis', 'yemen-flot'],
);
assert.match(byId.cip.fileName, /October_16_2026/);
assert.equal(byId.cip.defaultOn, true);
assert.equal(byId['kinetic-30d'].defaultOn, true);
assert.match(byId['border-crisis'].fileName, /Export_v2/);
assert.equal(byId['yemen-flot'].fileName, 'Yemen FLOT_October_10_2026.kmz');
assert.equal(byId.cip.version, tree[1].sha);
assert.equal(
byId.cip.fileTitle,
'Common Intelligence Picture October 16 2026 Export 1',
);
});
test('latest drop is the newest dated incident file outside curated, archived and base data', () => {
const rows = buildGhostMapsCatalog([
blob(`${INCIDENTS}Reno Wildfires August 24, 2026.kmz`),
blob(
`${INCIDENTS}Spokane Fire Disaster - August, 2026/SpokaneDisasterAid_August_8_2026.kmz`,
),
blob(
`${INCIDENTS}0_Archive/Texas Floods/TexasFloods_December_1_2026_v1.kmz`,
),
blob(`${INCIDENTS}Iran 2026/Basedata/Pipelines_December_1_2026.kmz`),
blob(
`${INCIDENTS}Iran 2026/Iran Kinetic Events/Iran_Kinetic_Events_December_2_2026_v1.kmz`,
),
blob(`${INCIDENTS}Undated Product.kmz`),
]);
const drop = rows.find(({ id }) => id === 'latest-drop');
assert.equal(drop.fileName, 'Reno Wildfires August 24, 2026.kmz');
assert.ok(rows.some(({ id }) => id === 'iran-kinetic'));
});
test('oversized, empty, non-KMZ and malformed entries are skipped', () => {
const path = `${ARCGIS}Common Intelligence Picture/Master Database/S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz`;
assert.deepEqual(
buildGhostMapsCatalog([
{ ...blob(path), size: 51 * 1024 * 1024 },
{ ...blob(path), size: 0 },
{ ...blob(path), sha: 'not-a-sha' },
blob(path.replace(/\.kmz$/, '.zip')),
null,
]),
[],
);
assert.deepEqual(buildGhostMapsCatalog(undefined), []);
});
test('raw URLs are pinned to the commit and encode every path segment', () => {
assert.equal(
ghostMapsRawUrl('a'.repeat(40), 'Folder (KMZs)/Yemen FLOT.kmz'),
`https://raw.githubusercontent.com/s2underground/GhostMaps/${'a'.repeat(40)}/Folder%20(KMZs)/Yemen%20FLOT.kmz`,
);
assert.equal(ghostMapsFileTitle('S2Underground_A__B.kmz'), 'A B');
});
test('the branch head comes from the smart-HTTP ref advertisement', () => {
const head = '142d7630bc8f1f2c4b96e16ee865ca50d1ab578d';
const other = 'b'.repeat(40);
const text = [
'001e# service=git-upload-pack',
`0159${head} HEAD\0multi_ack symref=HEAD:refs/heads/main`,
`003f${other} refs/heads/main-old`,
`003d${head} refs/heads/main`,
'0000',
].join('\n');
assert.equal(parseGitRefAdvertisement(text), head);
assert.equal(parseGitRefAdvertisement(text, 'dev'), null);
assert.equal(parseGitRefAdvertisement(''), null);
});
+711
View File
@@ -0,0 +1,711 @@
import * as Cesium from 'cesium';
import {
decodeXml,
kmzImageResolver,
kmzMainDocument,
parseXml,
readZipEntries,
resolveKmzPath,
} from '../../data/geoFileImport.js';
import {
formatNumber,
formatUtcDayMonth,
msg,
t,
tn,
} from '../../i18n/index.js';
import { createHorizonCull } from '../streetLevel/view.js';
import { readGhostMapsKml } from './kml.js';
import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js';
export * from './catalog.js';
export * from './records.js';
export { readGhostMapsKml } from './kml.js';
export { createGhostMapsSource } from './source.js';
export const GHOSTMAPS_LAYER_ID = 's2-ghostmaps';
export const GHOSTMAPS_OVERLAY_SOURCE_ID = 's2-ghostmaps';
const PICK_PREFIX = 'ghostmaps:';
const STORAGE_KEY = 'gev.ghostmaps.products';
const ACCENT = '#c8ff00';
const MAX_ICON_BYTES = 512 * 1024;
const CARD_HOST_OPTIONS = Object.freeze({
cohortLimit: 1,
collisionCapacity: 1,
moving: false,
});
const MAX_CARD_LINES = 12;
/** Panel names for the catalog's product ids (the catalog sends English). */
const PRODUCT_LABELS = Object.freeze({
cip: msg('Intel Picture'),
'kinetic-30d': msg('Kinetic 30d'),
'border-crisis': msg('Border Crisis'),
'iran-cip': msg('Iran Picture'),
'iran-kinetic': msg('Iran Kinetic'),
'yemen-flot': msg('Yemen FLOT'),
'latest-drop': msg('Latest Drop'),
});
/** One distinct fallback marker color per product. */
const PRODUCT_COLORS = Object.freeze({
cip: '#c8ff00',
'kinetic-30d': '#ff5a36',
'border-crisis': '#ffb547',
'iran-cip': '#39d0ff',
'iran-kinetic': '#ff3b8d',
'yemen-flot': '#5dff9f',
'latest-drop': '#b48cff',
});
function defaultStorage() {
try {
return globalThis.localStorage ?? null;
} catch {
return null;
}
}
function readStoredSelection(storage) {
try {
const value = JSON.parse(storage?.getItem(STORAGE_KEY) ?? 'null');
return Array.isArray(value) ? new Set(value.map(String)) : null;
} catch {
return null;
}
}
function writeStoredSelection(storage, selected) {
try {
storage?.setItem(STORAGE_KEY, JSON.stringify([...selected]));
} catch {
/* private window or blocked storage: the selection lasts this session */
}
}
/** A round marker for placemarks whose icon is missing or remote. */
const markerCache = new Map();
function markerImage(color) {
if (markerCache.has(color)) return markerCache.get(color);
let url = null;
try {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 20;
const ctx = canvas.getContext('2d');
ctx.beginPath();
ctx.arc(10, 10, 7, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(0,0,0,0.85)';
ctx.stroke();
url = canvas.toDataURL();
} catch {
url = null;
}
markerCache.set(color, url);
return url;
}
const cesiumColor = (rgba, fallback, alpha) =>
rgba
? new Cesium.Color(rgba[0], rgba[1], rgba[2], alpha ?? rgba[3])
: Cesium.Color.fromCssColorString(fallback).withAlpha(alpha ?? 1);
function anchorOf(geometry) {
if (geometry.type === 'point') return geometry.position;
const ring =
geometry.type === 'polygon' ? geometry.outer : geometry.positions;
let lon = 0;
let lat = 0;
for (const [x, y] of ring) {
lon += x;
lat += y;
}
return [lon / ring.length, lat / ring.length];
}
/**
* S2 Underground GhostMaps: the current editions of S2's public KMZ intel
* products, kept in step with the GitHub repository. The proxy watches the
* branch head and pushes each new commit; only changed products reload.
*/
export function createGhostMapsLayer({
source,
overlayHost = null,
screenSpaceEventHandlerFactory = null,
picking = null,
pointer = null,
openExternal = null,
storage = defaultStorage(),
} = {}) {
if (typeof source?.getCatalog !== 'function')
throw new TypeError('GhostMaps layer requires a catalog source');
let _viewer = null;
let _enabled = false;
let _catalog = null;
let _lastError = null;
let _unsubscribe = null;
let _stream = 'closed';
let _queue = Promise.resolve();
let _clickHandler = null;
let _rowListener = null;
let _selectedKey = null;
let _selectedCardId = null;
let _selectedLink = null;
let _lifetime = new AbortController();
let _selection = readStoredSelection(storage);
/** productId → {version, dataSource, urls, count, features} */
const _loaded = new Map();
/** productId → in-flight version */
const _loading = new Map();
/** productId → error text */
const _failed = new Map();
const notifyRow = () => _rowListener?.();
const render = () => _viewer?.scene?.requestRender?.();
// Pins skip the depth test, and with Google 3D tiles the globe is hidden,
// so nothing else stops far-side pins drawing through the planet.
const _horizon = createHorizonCull({
getViewer: () => _viewer,
items: function* () {
for (const item of _loaded.values()) yield* item.pins;
},
onChange: render,
});
const canSelect = () =>
overlayHost && screenSpaceEventHandlerFactory && picking;
function wanted(product) {
return _selection ? _selection.has(product.id) : product.defaultOn;
}
function selectedIds() {
return (_catalog?.products || []).filter(wanted).map(({ id }) => id);
}
function unload(id) {
const item = _loaded.get(id);
if (!item) return;
_loaded.delete(id);
_viewer?.dataSources?.remove(item.dataSource, true);
item.urls.forEach((url) => URL.revokeObjectURL(url));
if (_selectedKey?.startsWith(`${id}:`)) clearSelection();
}
/** Build one product's data source off-scene, then swap it in. */
async function load(product, signal) {
const bytes = await source.getFile(product.id, product.version, { signal });
signal.throwIfAborted();
const entries = await readZipEntries(bytes);
signal.throwIfAborted();
const main = kmzMainDocument(entries);
if (!main) throw new Error(msg('That KMZ holds no KML document.'));
const urls = [];
const resolvePacked = kmzImageResolver(entries, main, (url) =>
urls.push(url),
);
// Icons only: ArcGIS exports also pack multi-megabyte photo attachments,
// which fall back to the product marker instead of being decoded.
const resolveImage = (href) => {
const path = resolveKmzPath(href, main, entries);
return path && entries.get(path).length <= MAX_ICON_BYTES
? resolvePacked(href)
: null;
};
try {
const parsed = readGhostMapsKml(
parseXml(decodeXml(entries.get(main)), 'KML'),
resolveImage,
);
entries.clear();
signal.throwIfAborted();
const color = PRODUCT_COLORS[product.id] || ACCENT;
const dataSource = new Cesium.CustomDataSource(
`${GHOSTMAPS_LAYER_ID}:${product.id}`,
);
const features = [];
const pins = [];
dataSource.entities.suspendEvents();
parsed.features.forEach((feature, index) => {
const key = `${product.id}:${index}`;
const { style } = feature;
feature.geometries.forEach((geometry, part) => {
const id = `${PICK_PREFIX}${key}:${part}`;
if (geometry.type === 'point') {
const position = Cesium.Cartesian3.fromDegrees(
...geometry.position,
);
const entity = dataSource.entities.add({
id,
position,
billboard: {
image: style.icon || markerImage(color),
scale: Math.min(
1.5,
Math.max(0.35, (style.iconScale ?? 1) * 0.7),
),
color:
style.icon && style.iconColor
? cesiumColor(style.iconColor)
: undefined,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
// Ground-clamped icons would sink into terrain and 3D tiles; the
// horizon cull hides the far side instead (see `_horizon`).
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
pins.push({
position,
get show() {
return entity.show;
},
set show(value) {
entity.show = value;
},
});
} else if (geometry.type === 'line') {
dataSource.entities.add({
id,
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray(
geometry.positions.flat(),
),
width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)),
material: cesiumColor(style.lineColor, color),
clampToGround: true,
},
});
} else {
const ring = (points) =>
Cesium.Cartesian3.fromDegreesArray(points.flat());
dataSource.entities.add({
id,
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
ring(geometry.outer),
geometry.holes.map(
(hole) => new Cesium.PolygonHierarchy(ring(hole)),
),
),
material:
style.fill === false
? Cesium.Color.TRANSPARENT
: cesiumColor(
style.polyColor,
color,
style.polyColor ? undefined : 0.25,
),
classificationType: Cesium.ClassificationType.BOTH,
},
});
if (style.outline !== false)
dataSource.entities.add({
id: `${id}:outline`,
polyline: {
positions: ring([...geometry.outer, geometry.outer[0]]),
width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)),
material: cesiumColor(style.lineColor, color),
clampToGround: true,
},
});
}
});
features.push({
name: feature.name,
folder: feature.folder,
description: feature.description,
anchor: anchorOf(feature.geometries[0]),
});
});
dataSource.entities.resumeEvents();
return { version: product.version, dataSource, urls, features, pins };
} catch (error) {
urls.forEach((url) => URL.revokeObjectURL(url));
throw error;
}
}
/** Bring the globe in line with the catalog and the chip selection. */
function sync() {
const run = _queue.then(async () => {
if (!_enabled || !_viewer || !_catalog) return;
const signal = _lifetime.signal;
const products = new Map(_catalog.products.map((p) => [p.id, p]));
for (const id of [..._loaded.keys()]) {
const product = products.get(id);
if (!product || !wanted(product)) unload(id);
}
for (const product of _catalog.products) {
if (!wanted(product) || signal.aborted) continue;
if (_loaded.get(product.id)?.version === product.version) continue;
_loading.set(product.id, product.version);
_failed.delete(product.id);
notifyRow();
try {
const next = await load(product, signal);
if (signal.aborted || !_enabled) {
next.urls.forEach((url) => URL.revokeObjectURL(url));
return;
}
// Cull before the first frame so far-side pins never flash.
_horizon.update(next.pins);
await _viewer.dataSources.add(next.dataSource);
// The new edition is on the globe before the old one leaves.
unload(product.id);
_loaded.set(product.id, next);
} catch (error) {
if (signal.aborted) return;
console.warn(`[Data:GhostMaps] ${product.id}:`, error);
_failed.set(
product.id,
error?.template ||
error?.message ||
msg('GhostMaps file could not be read'),
);
} finally {
_loading.delete(product.id);
notifyRow();
render();
}
}
});
_queue = run.catch(() => {});
return run;
}
function applyCatalog(catalog) {
_catalog = catalog;
_lastError = catalog.error || null;
notifyRow();
return sync();
}
function toggleProduct(id) {
// The first change pins the whole current selection, defaults included.
if (!_selection) _selection = new Set(selectedIds());
if (_selection.has(id)) _selection.delete(id);
else _selection.add(id);
writeStoredSelection(storage, _selection);
notifyRow();
void sync();
}
// ---- selection card ----------------------------------------------------
function featureFor(key) {
const [productId, index] = key.split(':');
return {
product: _catalog?.products.find((p) => p.id === productId) || null,
feature: _loaded.get(productId)?.features[Number(index)] || null,
};
}
function publishCard() {
if (!canSelect()) return;
const { product, feature } = _selectedKey ? featureFor(_selectedKey) : {};
if (!feature) {
_selectedKey = null;
_selectedCardId = null;
_selectedLink = null;
overlayHost.setEntries(
GHOSTMAPS_OVERLAY_SOURCE_ID,
[],
CARD_HOST_OPTIONS,
);
return;
}
const { fields, links } = parseDescriptionFields(feature.description);
_selectedLink = links[0] || null;
const heading = [t(PRODUCT_LABELS[product?.id] || product?.title || '')];
if (feature.folder) heading.push(fieldLabel(feature.folder));
if (product?.dateMs) heading.push(formatUtcDayMonth(product.dateMs));
const details = [heading.filter(Boolean).join(' · ')];
for (const { label, value } of fields) {
if (details.length >= MAX_CARD_LINES) break;
// ArcGIS repeats the feature type as the first row; the title has it.
if (value === feature.name) continue;
const line = label ? `${fieldLabel(label)}: ${value}` : value;
details.push(
...wrapCardText(line, 60, Math.min(4, MAX_CARD_LINES - details.length)),
);
}
if (_selectedLink && openExternal)
details.push(
tn(
links.length,
'{count} source link ↗ · click card to open',
'{count} source links ↗ · click card to open the first',
{ count: formatNumber(links.length) },
),
);
const title = t('S2 · {name}', {
name: fieldLabel(feature.name || feature.folder) || t('Report'),
});
const card = {
id: `ghostmaps-card:${_selectedKey}`,
selected: true,
interactive: Boolean(_selectedLink && openExternal),
title,
details,
accent: PRODUCT_COLORS[product?.id] || ACCENT,
priority: Number.MAX_SAFE_INTEGER,
gapPx: 15,
verticalOnly: true,
placement: 'above',
// A click card comes and goes with the click, as CCTV cards do, rather
// than holding the label arbiter's 2.5 s minimum lifetime; and it hides
// when its marker turns behind the planet.
stateless: true,
horizonCull: true,
position: Cesium.Cartesian3.fromDegrees(...feature.anchor),
};
if (card.interactive) {
const link = _selectedLink;
card.accessibilityLabel = t('Open the source for {title}', { title });
card.activate = () => {
openExternal(link);
return true;
};
}
_selectedCardId = card.id;
overlayHost.setEntries(
GHOSTMAPS_OVERLAY_SOURCE_ID,
[card],
CARD_HOST_OPTIONS,
);
overlayHost.setVisible?.(GHOSTMAPS_OVERLAY_SOURCE_ID, true);
}
function clearSelection() {
_selectedKey = null;
_selectedCardId = null;
_selectedLink = null;
// An empty publish, as the perimeter cards deselect: it releases the
// selected card's paint lane, which clearing the source alone left drawn.
overlayHost?.setEntries(GHOSTMAPS_OVERLAY_SOURCE_ID, [], CARD_HOST_OPTIONS);
}
function pickedKey(picked) {
const pickId = picking.resolvePickId(picked);
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
return null;
const [productId, index] = pickId.slice(PICK_PREFIX.length).split(':');
return _loaded.get(productId)?.features[Number(index)]
? `${productId}:${index}`
: null;
}
function installClickHandler() {
if (!canSelect() || _clickHandler || !_viewer) return;
picking.registerPickOwner?.(
GHOSTMAPS_LAYER_ID,
(pickedId) =>
typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX),
);
_clickHandler = screenSpaceEventHandlerFactory(_viewer);
_clickHandler.setInputAction((click) => {
if (pointer && !pointer.isPointerFree()) return;
const cardHit = overlayHost.hitTest?.(
click.position?.x,
click.position?.y,
{ sourceId: GHOSTMAPS_OVERLAY_SOURCE_ID },
);
if (cardHit && cardHit.entryId === _selectedCardId) {
if (_selectedLink && openExternal) openExternal(_selectedLink);
return;
}
const picked = _viewer.scene.pick(click.position);
const key = picked ? pickedKey(picked) : null;
if (key) {
_selectedKey = key;
publishCard();
render();
return;
}
if (picked) {
const pickId = picking.resolvePickId(picked);
if (pickId && picking.isOwnedByOtherLayer(GHOSTMAPS_LAYER_ID, pickId))
return;
}
if (_selectedKey) {
clearSelection();
render();
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function removeClickHandler() {
_clickHandler?.destroy();
_clickHandler = null;
picking?.unregisterPickOwner?.(GHOSTMAPS_LAYER_ID);
}
function featureCount() {
let count = 0;
for (const item of _loaded.values()) count += item.features.length;
return count;
}
const layer = {
id: GHOSTMAPS_LAYER_ID,
name: 'S2 GhostMaps',
icon: '👻',
source: 'S2 Underground',
// Pushes arrive within seconds over the event stream; this interval is
// the backstop if the stream is down.
updateInterval: 300000,
init(viewer) {
if (_viewer) throw new Error('GhostMaps layer is already initialized');
_viewer = viewer;
},
enable() {
_enabled = true;
_lifetime = new AbortController();
for (const item of _loaded.values()) item.dataSource.show = true;
installClickHandler();
if (!_unsubscribe && typeof source.subscribe === 'function')
_unsubscribe = source.subscribe(
(catalog) => {
if (_enabled) void applyCatalog(catalog);
},
(state) => {
if (_stream === state) return;
_stream = state;
notifyRow();
},
);
},
disable() {
_enabled = false;
_lifetime.abort();
_unsubscribe?.();
_unsubscribe = null;
_stream = 'closed';
removeClickHandler();
clearSelection();
_horizon.stop();
for (const id of [..._loaded.keys()]) unload(id);
render();
},
async update(_viewerArg, { signal } = {}) {
if (!_enabled || signal?.aborted) return false;
try {
const catalog = await source.getCatalog({ signal });
if (!_enabled) return false;
await applyCatalog(catalog);
return true;
} catch (error) {
if (signal?.aborted || !_enabled) return false;
console.warn('[Data:GhostMaps] catalog error:', error);
_lastError = error?.message || msg('GhostMaps catalog unavailable');
notifyRow();
// Keep the layer on; the event stream or the next interval recovers.
return true;
}
},
destroy() {
layer.disable();
_viewer = null;
_catalog = null;
_rowListener = null;
},
setRowControlsListener(listener) {
_rowListener = listener;
},
getRowControls() {
const products = _catalog?.products || [];
const commit = _catalog?.commit;
const info = commit
? _stream === 'open'
? t('LIVE · GitHub {commit} · {message} · checked every {seconds}s', {
commit: commit.slice(0, 7),
message: _catalog.message || '—',
seconds: formatNumber(
Math.round((_catalog.pollMs || 15000) / 1000),
),
})
: t('GitHub {commit} · {message} · reconnecting for live updates', {
commit: commit.slice(0, 7),
message: _catalog.message || '—',
})
: t('Connecting to the S2 GhostMaps repository…');
return {
chips: products.map((product) => {
const loaded = _loaded.get(product.id);
const failed = _failed.get(product.id);
return {
id: product.id,
label: t(PRODUCT_LABELS[product.id] || product.title),
active: wanted(product),
busy: _loading.has(product.id),
state: failed ? 'error' : undefined,
color: PRODUCT_COLORS[product.id],
title: [
product.fileTitle,
loaded
? tn(
loaded.features.length,
'{count} placemark',
'{count} placemarks',
{
count: formatNumber(loaded.features.length),
},
)
: null,
failed ? t(failed) : null,
]
.filter(Boolean)
.join(' · '),
onClick: () => toggleProduct(product.id),
};
}),
legend: [],
info,
infoTitle: t(
'S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.',
),
};
},
getStats() {
return {
count: featureCount(),
products: [..._loaded.keys()],
commit: _catalog?.commit || null,
lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null,
checkedAt: _catalog?.checkedAt || null,
stream: _stream,
loading: _loading.size > 0,
error: _lastError || [..._failed.values()][0] || null,
fallback: false,
};
},
/** Loaded placemarks as plain records, for tools and QA. */
getAnalystRecords(maxCount = Infinity) {
if (!_enabled) return [];
const out = [];
for (const [productId, item] of _loaded) {
for (const feature of item.features) {
if (out.length >= maxCount) return out;
out.push({
product: productId,
name: feature.name,
folder: feature.folder,
lon: feature.anchor[0],
lat: feature.anchor[1],
});
}
}
return out;
},
};
return layer;
}
+198
View File
@@ -0,0 +1,198 @@
// Read the drawable content of a parsed GhostMaps KML document: placemarks
// with their folder, geometry, resolved style and raw description. Network
// links, overlays, models and remote icons are never followed; an icon is
// either an image packed in the same KMZ or the layer's own marker.
import { kmlColor } from './records.js';
export const MAX_GHOSTMAPS_FEATURES = 20_000;
export const MAX_GHOSTMAPS_POSITIONS = 250_000;
const childrenNamed = (el, name) =>
[...(el?.children || [])].filter((child) => child.localName === name);
const firstChild = (el, name) =>
[...(el?.children || [])].find((child) => child.localName === name) || null;
const descendants = (el, name) =>
[...(el?.getElementsByTagName?.('*') || [])].filter(
(child) => child.localName === name,
);
const text = (el) => (el?.textContent ?? '').trim();
function number(el, fallback) {
const value = Number(text(el));
return el && Number.isFinite(value) ? value : fallback;
}
/** `lon,lat[,alt] lon,lat ...` → [[lon, lat], ...]; invalid tuples are dropped. */
export function parseKmlCoordinates(value) {
const out = [];
for (const tuple of String(value ?? '')
.trim()
.split(/\s+/)) {
const [lon, lat] = tuple.split(',').map(Number);
if (
Number.isFinite(lon) &&
Number.isFinite(lat) &&
Math.abs(lat) <= 90 &&
Math.abs(lon) <= 180
)
out.push([lon, lat]);
}
return out;
}
function readStyle(styleEl, resolveImage) {
const style = {};
const icon = firstChild(styleEl, 'IconStyle');
if (icon) {
const href = text(firstChild(firstChild(icon, 'Icon'), 'href'));
style.icon = href ? resolveImage(href) : null;
style.iconScale = number(firstChild(icon, 'scale'), 1);
style.iconColor = kmlColor(text(firstChild(icon, 'color')));
}
const line = firstChild(styleEl, 'LineStyle');
if (line) {
style.lineColor = kmlColor(text(firstChild(line, 'color')));
style.lineWidth = number(firstChild(line, 'width'), 1);
}
const poly = firstChild(styleEl, 'PolyStyle');
if (poly) {
style.polyColor = kmlColor(text(firstChild(poly, 'color')));
style.fill = text(firstChild(poly, 'fill')) !== '0';
style.outline = text(firstChild(poly, 'outline')) !== '0';
}
return style;
}
/** Shared styles by id; a StyleMap resolves to its `normal` pair. */
function collectStyles(doc, resolveImage) {
const styles = new Map();
for (const el of descendants(doc, 'Style')) {
const id = el.getAttribute('id');
if (id) styles.set(id, readStyle(el, resolveImage));
}
const maps = new Map();
for (const el of descendants(doc, 'StyleMap')) {
const id = el.getAttribute('id');
if (!id) continue;
const normal = childrenNamed(el, 'Pair').find(
(pair) => text(firstChild(pair, 'key')) === 'normal',
);
if (!normal) continue;
const inline = firstChild(normal, 'Style');
maps.set(
id,
inline
? readStyle(inline, resolveImage)
: text(firstChild(normal, 'styleUrl')),
);
}
const resolve = (url, depth = 0) => {
const id = String(url || '').replace(/^#/, '');
if (!id || url.includes('/') || depth > 2) return {};
if (styles.has(id)) return styles.get(id);
const mapped = maps.get(id);
if (typeof mapped === 'string') return resolve(mapped, depth + 1);
return mapped || {};
};
return resolve;
}
function readGeometry(el, out, budget) {
switch (el.localName) {
case 'Point': {
const [position] = parseKmlCoordinates(
text(firstChild(el, 'coordinates')),
);
if (position) {
budget.positions += 1;
out.push({ type: 'point', position });
}
break;
}
case 'LineString':
case 'LinearRing': {
const positions = parseKmlCoordinates(
text(firstChild(el, 'coordinates')),
);
if (positions.length >= 2) {
budget.positions += positions.length;
out.push({ type: 'line', positions });
}
break;
}
case 'Polygon': {
const ring = (boundary) =>
parseKmlCoordinates(
text(firstChild(firstChild(boundary, 'LinearRing'), 'coordinates')),
);
const outer = ring(firstChild(el, 'outerBoundaryIs'));
if (outer.length >= 3) {
const holes = childrenNamed(el, 'innerBoundaryIs')
.map(ring)
.filter((hole) => hole.length >= 3);
budget.positions +=
outer.length + holes.reduce((n, hole) => n + hole.length, 0);
out.push({ type: 'polygon', outer, holes });
}
break;
}
case 'MultiGeometry':
for (const child of el.children) readGeometry(child, out, budget);
break;
default:
// Models, tracks and anything newer are not drawn.
break;
}
}
/** Name of the nearest enclosing Folder (or Document), for grouping. */
function folderName(el) {
for (let node = el.parentElement; node; node = node.parentElement) {
if (node.localName === 'Folder' || node.localName === 'Document') {
const name = text(firstChild(node, 'name'));
if (name) return name;
}
}
return '';
}
/**
* @param {Document} doc Parsed KML.
* @param {(href: string) => ?string} resolveImage URL for a KMZ-packed image.
* @returns {{features: Array<{name: string, folder: string,
* description: string, style: object, geometries: Array<object>}>,
* positions: number, over: boolean}}
*/
export function readGhostMapsKml(doc, resolveImage = () => null) {
const styleFor = collectStyles(doc, resolveImage);
const features = [];
const budget = { positions: 0 };
let over = false;
for (const placemark of descendants(doc, 'Placemark')) {
// A hidden placemark (visibility 0) is drawn hidden by Google Earth too.
if (text(firstChild(placemark, 'visibility')) === '0') continue;
const geometries = [];
for (const child of placemark.children)
readGeometry(child, geometries, budget);
if (!geometries.length) continue;
if (
features.length >= MAX_GHOSTMAPS_FEATURES ||
budget.positions > MAX_GHOSTMAPS_POSITIONS
) {
over = true;
break;
}
const inline = firstChild(placemark, 'Style');
features.push({
name: text(firstChild(placemark, 'name')).slice(0, 200),
folder: folderName(placemark).slice(0, 120),
description: text(firstChild(placemark, 'description')),
style: {
...styleFor(text(firstChild(placemark, 'styleUrl'))),
...(inline ? readStyle(inline, resolveImage) : {}),
},
geometries,
});
}
return { features, positions: budget.positions, over };
}
+180
View File
@@ -0,0 +1,180 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createGhostMapsLayer } from './index.js';
function element(localName, children = [], value = '', attributes = {}) {
const node = {
localName,
children,
get textContent() {
return value + children.map((child) => child.textContent).join('');
},
getAttribute: (key) => attributes[key] ?? null,
getElementsByTagName(name) {
return children.flatMap((child) => [
...(name === '*' || name === child.localName ? [child] : []),
...child.getElementsByTagName(name),
]);
},
};
for (const child of children) child.parentElement = node;
return node;
}
function documentFixture() {
return element('document', [
element('kml', [
element('Document', [
element(
'Style',
[
element('IconStyle', [
element('Icon', [element('href', [], 'icon.png')]),
]),
],
'',
{ id: 'pin' },
),
element('Placemark', [
element('name', [], 'Test report'),
element('styleUrl', [], '#pin'),
element('Point', [element('coordinates', [], '12,34')]),
]),
]),
]),
]);
}
// A stored KMZ with a local icon; the XML DOM double supplies its one point.
function fixtureKmz() {
const files = [
['doc.kml', '<kml/>'],
['icon.png', 'packed icon'],
];
const local = [];
const central = [];
let offset = 0;
for (const [name, value] of files) {
const key = Buffer.from(name);
const bytes = Buffer.from(value);
const header = Buffer.alloc(30);
header.writeUInt32LE(0x04034b50);
header.writeUInt32LE(bytes.length, 18);
header.writeUInt32LE(bytes.length, 22);
header.writeUInt16LE(key.length, 26);
const directory = Buffer.alloc(46);
directory.writeUInt32LE(0x02014b50);
directory.writeUInt32LE(bytes.length, 20);
directory.writeUInt32LE(bytes.length, 24);
directory.writeUInt16LE(key.length, 28);
directory.writeUInt32LE(offset, 42);
local.push(header, key, bytes);
central.push(directory, key);
offset += header.length + key.length + bytes.length;
}
const directory = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50);
end.writeUInt16LE(files.length, 8);
end.writeUInt16LE(files.length, 10);
end.writeUInt32LE(directory.length, 12);
end.writeUInt32LE(offset, 16);
return new Uint8Array(Buffer.concat([...local, directory, end]));
}
function fixture(t, { beforeFileReturns, onCreateUrl } = {}) {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser');
globalThis.DOMParser = class {
parseFromString() {
return documentFixture();
}
};
t.after(() => {
if (previous) Object.defineProperty(globalThis, 'DOMParser', previous);
else delete globalThis.DOMParser;
});
const created = [];
const revoked = [];
t.mock.method(URL, 'createObjectURL', () => {
const url = `blob:local-${created.length}`;
created.push(url);
onCreateUrl?.(layer);
return url;
});
t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url));
t.mock.method(console, 'warn', () => {});
const mounted = new Set();
const viewer = {
scene: { requestRender() {} },
dataSources: {
async add(dataSource) {
mounted.add(dataSource);
return dataSource;
},
remove(dataSource) {
return mounted.delete(dataSource);
},
},
};
const layer = createGhostMapsLayer({
storage: null,
source: {
getCatalog: async () => ({
products: [{ id: 'cip', version: 'a'.repeat(40), defaultOn: true }],
}),
getFile: async () => {
beforeFileReturns?.(layer);
return fixtureKmz();
},
},
});
layer.init(viewer);
layer.enable();
t.after(() => layer.destroy());
return { layer, viewer, mounted, created, revoked };
}
test('cancelling a file read does not allocate packed-image URLs', async (t) => {
const { layer, viewer, mounted, created, revoked } = fixture(t, {
beforeFileReturns: (layer) => queueMicrotask(() => layer.disable()),
});
await layer.update(viewer);
assert.equal(created.length, 0);
assert.equal(revoked.length, 0);
assert.equal(mounted.size, 0);
});
test('cancellation during parsing releases every packed-image URL', async (t) => {
const { layer, viewer, mounted, created, revoked } = fixture(t, {
onCreateUrl: (layer) => layer.disable(),
});
await layer.update(viewer);
assert.equal(created.length, 1);
assert.deepEqual(revoked, created);
assert.equal(mounted.size, 0);
});
test('entity construction failure releases parsed image URLs', async (t) => {
const { layer, viewer, mounted, created, revoked } = fixture(t);
t.mock.method(Cesium.EntityCollection.prototype, 'add', () => {
throw new Error('fixture entity failure');
});
await layer.update(viewer);
assert.equal(created.length, 1);
assert.deepEqual(revoked, created);
assert.equal(mounted.size, 0);
assert.equal(layer.getStats().error, 'fixture entity failure');
});
test('successful load keeps icons until the product is disabled', async (t) => {
const { layer, viewer, mounted, created, revoked } = fixture(t);
await layer.update(viewer);
assert.equal(created.length, 1);
assert.equal(revoked.length, 0);
assert.equal(mounted.size, 1);
assert.equal(layer.getStats().count, 1);
layer.disable();
assert.deepEqual(revoked, created);
assert.equal(mounted.size, 0);
});
+165
View File
@@ -0,0 +1,165 @@
// Pure helpers for GhostMaps placemarks: description tables to plain fields,
// KML colors, and card text wrapping. Strings in, data out — description
// markup is only ever read as text, never inserted into the page.
const ENTITIES = Object.freeze({
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
});
/** Decode the HTML character references ArcGIS and Google Earth emit. */
export function decodeHtmlEntities(text) {
return String(text ?? '').replace(
/&(#x[0-9a-f]{1,6}|#\d{1,7}|[a-z]{2,8});/gi,
(whole, ref) => {
if (ref[0] === '#') {
const code =
ref[1] === 'x' || ref[1] === 'X'
? parseInt(ref.slice(2), 16)
: parseInt(ref.slice(1), 10);
return code > 0 &&
code <= 0x10ffff &&
!(code >= 0xd800 && code <= 0xdfff)
? String.fromCodePoint(code)
: '';
}
return ENTITIES[ref.toLowerCase()] ?? whole;
},
);
}
/** Markup to one line of plain text. */
export function htmlToText(html) {
return decodeHtmlEntities(
String(html ?? '')
.replace(/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi, ' ')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<[^>]*>/g, ' '),
)
.replace(/\s+/g, ' ')
.trim();
}
const EMPTY_VALUES = new Set(['', '<null>', 'null', 'n/a', '-', '<blank>']);
// ArcGIS writes geometry type and internal ids into every popup table.
const SKIPPED_LABELS = new Set(['shape', 'objectid', 'fid', 'globalid']);
const MAX_FIELDS = 24;
const MAX_VALUE = 600;
const MAX_LINKS = 6;
/**
* Fields and links from a placemark description. ArcGIS "popup" exports are a
* two-column table (label, value); anything else becomes one unlabelled field.
* @param {string} html Description as written in the KML (CDATA text).
* @returns {{fields: Array<{label: string, value: string}>, links: string[]}}
*/
export function parseDescriptionFields(html) {
const source = String(html ?? '').replace(
/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi,
' ',
);
const fields = [];
const links = [];
const addLinks = (markup) => {
for (const match of markup.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
const url = decodeHtmlEntities(match[1]).trim();
if (/^https?:\/\/[^\s]+$/i.test(url) && !links.includes(url))
links.push(url);
}
};
// Innermost rows only: ArcGIS nests the field table inside an outer row,
// and only leaf rows with exactly two cells are label/value pairs.
for (const row of source.matchAll(
/<tr\b[^>]*>((?:(?!<tr\b)[\s\S])*?)<\/tr\s*>/gi,
)) {
const cells = [
...row[1].matchAll(/<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]\s*>/gi),
].map((cell) => cell[1]);
if (cells.length !== 2) continue;
const label = htmlToText(cells[0]);
const value = htmlToText(cells[1]);
if (!label || SKIPPED_LABELS.has(label.toLowerCase())) continue;
if (EMPTY_VALUES.has(value.toLowerCase())) continue;
addLinks(cells[1]);
// A bare research link is carried by `links`; the card does not need
// the URL twice.
if (/^https?:\/\/\S+$/i.test(value)) continue;
if (fields.length < MAX_FIELDS)
fields.push({ label, value: value.slice(0, MAX_VALUE) });
}
if (!fields.length && !links.length) {
const text = htmlToText(source);
if (text) fields.push({ label: '', value: text.slice(0, MAX_VALUE) });
addLinks(source);
}
return { fields, links: links.slice(0, MAX_LINKS) };
}
/**
* KML `aabbggrr` to `[r, g, b, a]` in 0..1, or null.
* @param {string} text
* @returns {?number[]}
*/
export function kmlColor(text) {
const hex = String(text ?? '')
.trim()
.replace(/^#/, '');
if (!/^[0-9a-f]{8}$/i.test(hex)) return null;
const byte = (at) => parseInt(hex.slice(at, at + 2), 16) / 255;
return [byte(6), byte(4), byte(2), byte(0)];
}
/**
* Greedy word wrap for canvas card lines; overlong words are cut.
* @param {string} text
* @param {number} width Characters per line.
* @param {number} maxLines Lines kept; the last ends with an ellipsis when cut.
* @returns {string[]}
*/
export function wrapCardText(text, width = 56, maxLines = 4) {
const lines = [];
let line = '';
let cut = false;
for (let word of String(text ?? '')
.split(/\s+/)
.filter(Boolean)) {
while (word.length > width) {
if (line) {
lines.push(line);
line = '';
}
lines.push(word.slice(0, width));
word = word.slice(width);
}
if (!line) line = word;
else if (line.length + 1 + word.length <= width) line += ` ${word}`;
else {
lines.push(line);
line = word;
}
if (lines.length >= maxLines) {
cut = true;
break;
}
}
if (line && lines.length < maxLines) lines.push(line);
else if (line) cut = true;
const kept = lines.slice(0, maxLines);
if (cut && kept.length) {
const last = kept[kept.length - 1];
kept[kept.length - 1] = `${last.slice(0, width - 1)}…`;
}
return kept;
}
/** Field labels ArcGIS writes CamelCase; show them as words. */
export function fieldLabel(label) {
return String(label ?? '')
.replace(/([a-z])([A-Z0-9])/g, '$1 $2')
.replace(/_/g, ' ')
.trim();
}
+106
View File
@@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
decodeHtmlEntities,
fieldLabel,
htmlToText,
kmlColor,
parseDescriptionFields,
wrapCardText,
} from './records.js';
// The shape ArcGIS Pro writes into every S2 placemark description.
const ARCGIS = `<html xmlns:fo="http://www.w3.org/1999/XSL/Format"><head>
<META http-equiv="Content-Type" content="text/html"></head>
<body style="margin:0px"><table style="font-family:Arial">
<tr style="text-align:center;font-weight:bold;background:#9CBCE2"><td>HistoricProtestSite</td></tr>
<tr><td><table style="font-family:Arial">
<tr><td>SHAPE</td><td>Point</td></tr>
<tr bgcolor="#D4E4F3"><td>InstallationType</td><td>HistoricProtestSite</td></tr>
<tr><td>OperationalStatus</td><td>ACTIVE</td></tr>
<tr bgcolor="#D4E4F3"><td>Affiliation</td><td>&lt;Null&gt;</td></tr>
<tr><td>Description</td><td>Locals gather here &amp; protest &#8212; weekly.</td></tr>
<tr><td>Research1</td><td><a target="_blank" href="https://x.com/a/status/1">https://x.com/a/status/1</a></td></tr>
<tr><td>Research2</td><td><a href="javascript:alert(1)">bad</a></td></tr>
<tr><td>Research3</td><td>&lt;Null&gt;</td></tr>
</table></td></tr></table></body><script type="text/javascript">
function changeImage(a, b) { document.getElementById('x').innerHTML = b; }
</script></html>`;
test('ArcGIS popup tables become plain label/value fields and http links', () => {
const { fields, links } = parseDescriptionFields(ARCGIS);
assert.deepEqual(fields, [
{ label: 'InstallationType', value: 'HistoricProtestSite' },
{ label: 'OperationalStatus', value: 'ACTIVE' },
{ label: 'Description', value: 'Locals gather here & protest — weekly.' },
{ label: 'Research2', value: 'bad' },
]);
// javascript: and other schemes never become links.
assert.deepEqual(links, ['https://x.com/a/status/1']);
});
test('free-form descriptions become one unlabelled field; scripts are dropped', () => {
assert.deepEqual(
parseDescriptionFields(
'<p>Front line<br>as of 10 Oct</p><script>steal()</script><a href="https://s2.example/x">more</a>',
),
{
fields: [{ label: '', value: 'Front line as of 10 Oct more' }],
links: ['https://s2.example/x'],
},
);
assert.deepEqual(parseDescriptionFields(''), { fields: [], links: [] });
assert.deepEqual(parseDescriptionFields(undefined), {
fields: [],
links: [],
});
});
test('field values and counts are bounded', () => {
const rows = Array.from(
{ length: 40 },
(_, i) => `<tr><td>K${i}</td><td>${'x'.repeat(900)}</td></tr>`,
).join('');
const { fields } = parseDescriptionFields(`<table>${rows}</table>`);
assert.equal(fields.length, 24);
assert.equal(fields[0].value.length, 600);
});
test('entities and tags decode to text', () => {
assert.equal(
decodeHtmlEntities('&lt;b&gt; &#x41;&#66; &nbsp;&bogus;'),
'<b> AB &bogus;',
);
assert.equal(decodeHtmlEntities('&#xD800;&#0;'), '');
assert.equal(htmlToText('<style>a{}</style><b>One</b>\n two'), 'One two');
});
test('KML aabbggrr colors convert to rgba', () => {
assert.deepEqual(kmlColor('ff0000ff'), [1, 0, 0, 1]);
assert.deepEqual(kmlColor('8000ff00'), [0, 1, 0, 128 / 255]);
assert.equal(kmlColor('red'), null);
assert.equal(kmlColor(''), null);
});
test('card text wraps on words, cuts long words and marks truncation', () => {
assert.deepEqual(wrapCardText('one two three four', 9, 4), [
'one two',
'three',
'four',
]);
assert.deepEqual(wrapCardText('abcdefghijkl', 5, 4), [
'abcde',
'fghij',
'kl',
]);
assert.deepEqual(wrapCardText('aa bb cc dd ee', 5, 2), ['aa bb', 'cc d…']);
assert.deepEqual(wrapCardText('', 5, 2), []);
for (const line of wrapCardText('word '.repeat(200), 20, 3))
assert.ok(line.length <= 20);
});
test('CamelCase field labels read as words', () => {
assert.equal(fieldLabel('OperationalStatus'), 'Operational Status');
assert.equal(fieldLabel('Research1'), 'Research 1');
assert.equal(fieldLabel('Kinetic_Activity'), 'Kinetic Activity');
});
+81
View File
@@ -0,0 +1,81 @@
import {
readResponseBytesCapped,
readResponseJsonCapped,
} from '../../sources/httpBody.js';
import { GHOSTMAPS_MAX_FILE_BYTES } from './catalog.js';
// A portable export (package.json): no i18n here. Failures stay English data;
// the Layers panel shows them translated.
const msg = (text) => text;
/**
* Same-origin GhostMaps source: the catalog, one product's KMZ bytes, and a
* push subscription that fires whenever the watcher sees a new commit.
*/
export function createGhostMapsSource({
fetchImpl = (...args) => globalThis.fetch(...args),
EventSourceImpl = globalThis.EventSource,
} = {}) {
return {
async getCatalog({ signal } = {}) {
const response = await fetchImpl('/api/ghostmaps/catalog', { signal });
if (!response.ok)
throw new Error(
`GhostMaps catalog unavailable (HTTP ${response.status})`,
);
const catalog = await readResponseJsonCapped(
response,
1024 * 1024,
signal,
);
if (!Array.isArray(catalog?.products))
throw new Error(msg('Malformed GhostMaps catalog'));
return catalog;
},
async getFile(id, version, { signal } = {}) {
const response = await fetchImpl(
`/api/ghostmaps/file/${encodeURIComponent(id)}?v=${encodeURIComponent(version)}`,
{ signal },
);
if (!response.ok)
throw new Error(`GhostMaps file unavailable (HTTP ${response.status})`);
return readResponseBytesCapped(
response,
GHOSTMAPS_MAX_FILE_BYTES,
signal,
);
},
/**
* Call `onCatalog(catalog)` for every catalog the server pushes, and
* `onState('open' | 'connecting' | 'closed')` as the stream changes. The
* browser's EventSource reconnects by itself. Returns an unsubscribe.
*/
subscribe(onCatalog, onState = () => {}) {
if (typeof EventSourceImpl !== 'function') {
onState('closed');
return () => {};
}
const events = new EventSourceImpl('/api/ghostmaps/events');
const states = ['connecting', 'open', 'closed'];
const report = () => onState(states[events.readyState] ?? 'connecting');
events.onopen = report;
events.onerror = report;
const listener = (event) => {
try {
const catalog = JSON.parse(event.data);
if (Array.isArray(catalog?.products)) onCatalog(catalog);
} catch {
/* ignore a malformed frame; the next one replaces it */
}
};
events.addEventListener('catalog', listener);
report();
return () => {
events.removeEventListener('catalog', listener);
events.close();
};
},
};
}
-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
View File
@@ -57,6 +57,7 @@ export function createControls({ state: layerState, services, parts, source }) {
orbitMatches: layerState._orbitMatches,
lastUpdate: layerState._lastUpdate,
error: layerState._lastError,
stale: layerState._stale,
};
},
+232
View File
@@ -0,0 +1,232 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Event } from 'cesium';
import { createLaunchHover } from './lifecycle.js';
function fakeTimers() {
const timers = new Map();
let nextId = 1;
const api = {
setTimer(fn, ms) {
const id = nextId++;
timers.set(id, { fn, ms });
return id;
},
clearTimer(id) {
timers.delete(id);
},
pending: () => [...timers.values()],
flush() {
const due = [...timers.entries()];
timers.clear();
for (const [, timer] of due) timer.fn();
},
};
return api;
}
function hoverRig({ overMarker }) {
let at = 0,
picks = 0,
enabled = true,
free = true;
const timers = fakeTimers();
const viewer = {
camera: { moveStart: new Event(), moveEnd: new Event() },
scene: {
canvas: { style: { cursor: '' } },
drillPick(position) {
picks++;
return overMarker(position) ? [{ id: 'launch' }] : [];
},
},
};
const hover = createLaunchHover({
viewer,
enabled: () => enabled,
entityLaunchId: (id) => id === 'launch',
now: () => at,
documentRef: { hidden: false },
pointerFree: () => free,
setTimer: timers.setTimer,
clearTimer: timers.clearTimer,
});
return {
hover,
timers,
viewer,
cursor: () => viewer.scene.canvas.style.cursor,
picks: () => picks,
setTime: (value) => (at = value),
setEnabled: (value) => (enabled = value),
setFree: (value) => (free = value),
};
}
const onMarker = (position) => position?.x === 1;
const marker = () => ({ endPosition: { x: 1, y: 1 } });
const empty = () => ({ endPosition: { x: 9, y: 9 } });
test('mission hover runs one trailing check for a pointer that stops on empty space', () => {
const rig = hoverRig({ overMarker: onMarker });
rig.hover.move(marker());
assert.equal(rig.cursor(), 'pointer');
rig.setTime(50);
const movement = empty();
rig.hover.move(movement);
// Cesium reuses its movement object; the trailing check must use a copy.
movement.endPosition.x = 1;
assert.equal(rig.picks(), 1);
assert.deepEqual(
rig.timers.pending().map((timer) => timer.ms),
[70],
);
rig.setTime(80);
rig.hover.move(empty());
assert.equal(rig.timers.pending().length, 1, 'one trailing check at a time');
rig.setTime(120);
rig.timers.flush();
assert.equal(rig.picks(), 2);
assert.equal(rig.cursor(), '');
assert.equal(rig.timers.pending().length, 0);
rig.hover.destroy();
});
test('mission hover trailing check sets the cursor for a pointer that stops on a mission', () => {
const rig = hoverRig({ overMarker: onMarker });
rig.hover.move(empty());
assert.equal(rig.cursor(), '');
rig.setTime(50);
rig.hover.move(marker());
rig.setTime(120);
rig.timers.flush();
assert.equal(rig.picks(), 2);
assert.equal(rig.cursor(), 'pointer');
rig.hover.destroy();
assert.equal(rig.cursor(), '');
});
test('mission hover trailing check is cancelled by reset, destroy, and camera motion', () => {
for (const cancel of [
(rig) => rig.hover.reset(),
(rig) => rig.hover.destroy(),
(rig) => rig.viewer.camera.moveStart.raiseEvent(),
]) {
const rig = hoverRig({ overMarker: onMarker });
rig.hover.move(empty());
rig.setTime(50);
rig.hover.move(marker());
assert.equal(rig.timers.pending().length, 1);
cancel(rig);
assert.equal(rig.timers.pending().length, 0);
assert.equal(rig.picks(), 1);
assert.equal(rig.cursor(), '');
rig.hover.destroy();
}
});
test('mission hover trailing check rechecks eligibility before picking', () => {
for (const block of [
(rig) => rig.setEnabled(false),
(rig) => rig.setFree(false),
]) {
const rig = hoverRig({ overMarker: onMarker });
rig.hover.move(marker());
rig.setTime(50);
rig.hover.move(marker());
block(rig);
rig.setTime(120);
rig.timers.flush();
assert.equal(rig.picks(), 1);
assert.equal(rig.cursor(), '');
rig.hover.destroy();
}
});
test('mission hover throttles, clears on motion/hidden, and releases listeners', () => {
let at = 0,
picks = 0,
enabled = true;
const listeners = new Set();
const doc = {
hidden: false,
addEventListener: (_n, fn) => listeners.add(fn),
removeEventListener: (_n, fn) => listeners.delete(fn),
};
const viewer = {
camera: { moveStart: new Event(), moveEnd: new Event() },
scene: {
canvas: { style: {} },
drillPick() {
picks++;
return [{ id: 'launch' }];
},
},
};
const hover = createLaunchHover({
viewer,
enabled: () => enabled,
entityLaunchId: (id) => id === 'launch',
now: () => at,
documentRef: doc,
pointerFree: () => true,
...fakeTimers(),
});
for (at = 0; at < 120; at++) hover.move({ endPosition: {} });
assert.equal(picks, 1);
assert.equal(viewer.scene.canvas.style.cursor, 'pointer');
hover.move({ endPosition: {} });
assert.equal(picks, 2);
viewer.camera.moveStart.raiseEvent();
at = 500;
hover.move({ endPosition: {} });
assert.equal(picks, 2);
assert.equal(viewer.scene.canvas.style.cursor, '');
viewer.camera.moveEnd.raiseEvent();
hover.move({ endPosition: {} });
assert.equal(picks, 3);
doc.hidden = true;
for (const fn of listeners) fn();
hover.move({ endPosition: {} });
assert.equal(picks, 3);
assert.equal(viewer.scene.canvas.style.cursor, '');
doc.hidden = false;
enabled = false;
hover.move({ endPosition: {} });
assert.equal(picks, 3);
hover.destroy();
assert.equal(listeners.size, 0);
assert.equal(viewer.camera.moveStart.numberOfListeners, 0);
assert.equal(viewer.camera.moveEnd.numberOfListeners, 0);
});
test('mission hover preserves the cursor of a pointer-owning tool', () => {
let free = true,
picks = 0;
const viewer = {
camera: { moveStart: new Event(), moveEnd: new Event() },
scene: {
canvas: { style: { cursor: '' } },
drillPick() {
picks++;
return [{ id: 'launch' }];
},
},
};
const hover = createLaunchHover({
viewer,
enabled: () => true,
entityLaunchId: () => true,
documentRef: null,
pointerFree: () => free,
});
hover.move({ endPosition: {} });
assert.equal(viewer.scene.canvas.style.cursor, 'pointer');
free = false;
viewer.scene.canvas.style.cursor = 'crosshair';
hover.move({ endPosition: {} });
viewer.camera.moveStart.raiseEvent();
hover.destroy();
assert.equal(picks, 1);
assert.equal(viewer.scene.canvas.style.cursor, 'crosshair');
});
+11 -3
View File
@@ -168,9 +168,16 @@ export function createIngestion({
async function performMissionUpdate(token) {
try {
ensureActiveTleLookup(token);
const payload = await source.getLaunches({
signal: layerState._sourceController.signal,
});
const { payload, stale } = source.getLaunchSnapshot
? await source.getLaunchSnapshot({
signal: layerState._sourceController.signal,
})
: {
payload: await source.getLaunches({
signal: layerState._sourceController.signal,
}),
stale: false,
};
const launches = parts.model.normalizeRocketLaunches(payload);
if (
!layerState._enabled ||
@@ -214,6 +221,7 @@ export function createIngestion({
parts.panel.renderMissionPanel();
layerState._lastUpdate = Date.now();
layerState._lastError = null;
layerState._stale = stale === true;
} catch (error) {
if (layerState._enabled && token === layerState._lifecycleToken) {
layerState._lastError = error.message;
+315
View File
@@ -0,0 +1,315 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import * as Cesium from 'cesium';
import { createLaunchSource } from './source.js';
import { createState } from './state.js';
import { createModel } from './model.js';
import { createIngestion } from './ingestion.js';
import { createControls } from './controls.js';
import { createLifecycle } from './lifecycle.js';
import { layerFeedState } from '../../data/feedState.js';
const noop = () => {};
async function fixture({ legacySource = false } = {}) {
const launch = {
id: 'owned-launch',
name: 'Owned mission fixture',
net: new Date(Date.now() - 3600000).toISOString(),
status: { name: 'Launch Successful' },
pad: { name: 'Owned pad', latitude: 28.6, longitude: -80.6 },
rocket: { payloads: [] },
};
const mode = { cache: 'HIT', results: [launch], hold: false };
let arrival;
let release;
const server = http.createServer((req, res) => {
if (req.url === '/api/celestrak/active') {
res.end('');
return;
}
assert.equal(req.url, '/api/launches');
const send = () => {
if (res.destroyed) return;
res.writeHead(200, {
'Content-Type': 'application/json',
'X-GEV-Cache': mode.cache,
});
res.end(JSON.stringify({ results: mode.results }));
};
if (mode.hold) {
release = send;
arrival?.();
} else send();
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
const launchSource = createLaunchSource({
fetchImpl: (url, options) => {
assert.ok(['/api/launches', '/api/celestrak/active'].includes(url));
return fetch(new URL(url, origin), options);
},
});
const source = legacySource
? {
getLaunches: launchSource.getLaunches,
getActiveTle: launchSource.getActiveTle,
}
: launchSource;
const services = {
overlays: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
};
const state = createState({ services });
state._enabled = true;
state._lifecycleToken = 1;
state._dataSource = new Cesium.CustomDataSource('owned-qualification');
const canvas = Object.assign(new EventTarget(), {
disableRootEvents: true,
onwheel: null,
style: {},
});
const viewer = {
dataSources: new Cesium.DataSourceCollection(),
scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] },
};
await viewer.dataSources.add(state._dataSource);
// Only presentation boundaries are inert. Source, normalization, state,
// ingestion, lifecycle, stats and feed-state policy execute unchanged.
const parts = {
panel: {
createMissionPanel: noop,
renderMissionRoster: noop,
renderMissionPanel: noop,
clearMissionRosterHover: noop,
},
replay: { stopMissionReplay: noop },
selection: {
setSelectedMission: noop,
focusMission: noop,
stopMissionZoomAnchor: noop,
},
orbitRendering: {
removeMissionOrbitPrimitives: noop,
syncMissionOrbitPrimitiveVisibility: noop,
},
overlays: {
createReplayVehicleOverlay: noop,
clearMissionOverlaySources: noop,
hideReplayVehicleOverlay: noop,
destroyReplayVehicleOverlay: noop,
},
launchPad: {
initLaunchPadZonePrimitive: noop,
hideLaunchPadZone: noop,
destroyLaunchPadZonePrimitive: noop,
},
rendering: {
updateMissionFrame: noop,
addLaunchEntity(record) {
state._dataSource.entities.add({
id: record.id,
name: record.name,
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
});
},
},
};
const context = { state, services, parts, source };
parts.model = createModel(context);
parts.ingestion = createIngestion(context);
parts.controls = createControls(context);
parts.lifecycle = createLifecycle(context);
const current = () => {
const stats = parts.controls.methods.getStats();
return {
stats,
feed: layerFeedState(stats),
rendered: state._dataSource?.entities.values.map((x) => x.id) || [],
enabled: state._enabled,
};
};
return {
mode,
state,
source,
current,
viewer,
init: () => parts.lifecycle.methods.init(viewer),
update: () => parts.ingestion.methods.update(),
disable: () => parts.lifecycle.methods.disable(),
destroy: () => parts.lifecycle.methods.destroy(viewer),
holdArrival: () =>
new Promise((resolve) => {
arrival = resolve;
}),
release: () => release?.(),
async close() {
await parts.lifecycle.methods.destroy(viewer);
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
},
};
}
test('fresh launches remain nominal through ingestion and the layer stats', async () => {
const f = await fixture();
try {
await f.update();
const value = f.current();
assert.equal(value.feed, 'nominal');
assert.equal(value.stats.count, 1);
assert.deepEqual(value.rendered, ['owned-launch']);
assert.equal(value.stats.error, null);
assert.ok(value.stats.lastUpdate);
} finally {
await f.close();
}
});
test('STALE-ERROR provenance reaches the layer feed state without dropping records', async () => {
const f = await fixture();
try {
f.mode.cache = 'STALE-ERROR';
assert.equal((await f.source.getLaunchSnapshot()).stale, true);
await f.update();
const value = f.current();
assert.equal(value.stats.count, 1);
assert.deepEqual(value.rendered, ['owned-launch']);
assert.equal(value.feed, 'stale');
} finally {
await f.close();
}
});
test('a fresh recovery clears stale launch provenance', async () => {
const f = await fixture();
try {
f.mode.cache = 'STALE-ERROR';
await f.update();
const stale = f.current();
f.mode.cache = 'MISS';
await f.update();
const recovered = f.current();
assert.equal(recovered.feed, 'nominal');
assert.equal(recovered.stats.count, 1);
assert.equal(recovered.stats.error, null);
assert.equal(stale.feed, 'stale');
} finally {
await f.close();
}
});
test('an empty stale snapshot still identifies its fallback provenance', async () => {
const f = await fixture();
try {
f.mode.cache = 'STALE-ERROR';
f.mode.results = [];
await f.update();
assert.equal(f.current().stats.count, 0);
assert.equal(f.current().feed, 'stale');
} finally {
await f.close();
}
});
test('getLaunches-only injected sources remain supported', async () => {
const f = await fixture({ legacySource: true });
try {
await f.update();
assert.equal(f.current().feed, 'nominal');
assert.deepEqual(f.current().rendered, ['owned-launch']);
} finally {
await f.close();
}
});
test('disable cancels a pending stale response without committing it', async () => {
const f = await fixture();
try {
await f.update();
const healthy = f.current();
f.mode.cache = 'STALE-ERROR';
f.mode.hold = true;
const arrived = f.holdArrival();
const pending = f.update();
await arrived;
await f.disable();
f.release();
await pending;
const value = f.current();
assert.equal(value.enabled, false);
assert.equal(value.stats.lastUpdate, healthy.stats.lastUpdate);
assert.equal(value.feed, 'nominal');
assert.equal(f.state._sourceController.signal.aborted, true);
} finally {
await f.close();
}
});
test('hiding retained stale records does not relabel them as fresh', async () => {
const f = await fixture();
try {
f.mode.cache = 'STALE-ERROR';
await f.update();
await f.disable();
assert.equal(f.current().feed, 'stale');
assert.equal(f.current().stats.count, 1);
} finally {
await f.close();
}
});
test('destroy clears the previous feed state and records', async () => {
const f = await fixture();
try {
f.mode.cache = 'STALE-ERROR';
await f.update();
await f.destroy();
const value = f.current();
assert.equal(value.stats.count, 0);
assert.equal(value.stats.lastUpdate, null);
assert.equal(value.feed, 'nominal');
assert.equal(value.enabled, false);
} finally {
await f.close();
}
});
test('destroy followed by actual init starts a clean instance with owned handlers', async () => {
const f = await fixture();
try {
f.mode.cache = 'STALE-ERROR';
await f.update();
await f.destroy();
await f.init();
const value = f.current();
assert.equal(value.feed, 'nominal');
assert.equal(value.stats.count, 0);
assert.equal(value.stats.lastUpdate, null);
assert.equal(value.stats.error, null);
assert.equal(value.enabled, false);
assert.equal(f.viewer.dataSources.length, 1);
assert.equal(f.viewer.scene.preRender.numberOfListeners, 1);
const click = f.state._clickHandler;
const move = f.state._moveHandler;
assert.ok(click instanceof Cesium.ScreenSpaceEventHandler);
assert.ok(move instanceof Cesium.ScreenSpaceEventHandler);
assert.equal(
typeof click.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK),
'function',
);
await f.destroy();
assert.equal(click.isDestroyed(), true);
assert.equal(move.isDestroyed(), true);
assert.equal(f.viewer.dataSources.length, 0);
assert.equal(f.viewer.scene.preRender.numberOfListeners, 0);
} finally {
await f.close();
}
});
+125 -8
View File
@@ -1,6 +1,116 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
const HOVER_PICK_INTERVAL_MS = 120;
/**
* Bound hover picking independently of precise click selection. A move inside
* the throttle window is not dropped: its position is kept and one trailing
* check runs when the window ends, so a pointer that stops is still resolved.
*/
export function createLaunchHover({
viewer,
enabled,
entityLaunchId,
now = () => performance.now(),
documentRef = globalThis.document,
pointerFree = isPointerFree,
setTimer = globalThis.setTimeout,
clearTimer = globalThis.clearTimeout,
}) {
let moving = false;
let lastPick = -Infinity;
let ownsCursor = false;
let pendingPosition = null;
let trailingTimer = null;
const clearCursor = () => {
if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer')
viewer.scene.canvas.style.cursor = '';
ownsCursor = false;
};
const cancelTrailing = () => {
if (trailingTimer !== null) clearTimer(trailingTimer);
trailingTimer = null;
pendingPosition = null;
};
const reset = () => {
cancelTrailing();
clearCursor();
lastPick = -Infinity;
};
const start = () => {
moving = true;
reset();
};
const end = () => {
moving = false;
reset();
};
const visibility = () => {
if (documentRef?.hidden || documentRef?.visibilityState === 'hidden')
reset();
};
const eligible = () =>
enabled() &&
!moving &&
!documentRef?.hidden &&
documentRef?.visibilityState !== 'hidden' &&
pointerFree();
const pick = (position, at) => {
lastPick = at;
const found = viewer.scene
.drillPick(position, 12)
.some((picked) => entityLaunchId(picked?.id));
if (found) {
viewer.scene.canvas.style.cursor = 'pointer';
ownsCursor = true;
} else clearCursor();
};
const trailing = () => {
trailingTimer = null;
const position = pendingPosition;
pendingPosition = null;
if (!position) return;
// Eligibility may have changed while the check waited.
if (!eligible()) {
reset();
return;
}
pick(position, now());
};
const removeStart = viewer.camera?.moveStart?.addEventListener(start);
const removeEnd = viewer.camera?.moveEnd?.addEventListener(end);
documentRef?.addEventListener?.('visibilitychange', visibility);
return {
move(movement) {
if (!eligible()) {
reset();
return;
}
const at = now();
const wait = HOVER_PICK_INTERVAL_MS - (at - lastPick);
if (wait > 0) {
// Cesium reuses the movement object, so keep a copy of the position.
pendingPosition = Cesium.Cartesian2.clone(
movement.endPosition,
pendingPosition ?? undefined,
);
if (trailingTimer === null) trailingTimer = setTimer(trailing, wait);
return;
}
cancelTrailing();
pick(movement.endPosition, at);
},
reset,
destroy() {
reset();
removeStart?.();
removeEnd?.();
documentRef?.removeEventListener?.('visibilitychange', visibility);
},
};
}
export function createLifecycle({
state: layerState,
services,
@@ -8,6 +118,7 @@ export function createLifecycle({
source,
}) {
const { holdContinuousRender, releaseContinuousRender } = services.render;
let hover = null;
const methods = {
init(viewer) {
@@ -20,6 +131,7 @@ export function createLifecycle({
viewer.dataSources.add(layerState._dataSource);
layerState._count = 0;
layerState._lastUpdate = null;
layerState._stale = false;
layerState._lastError = null;
layerState._orbitMatches = 0;
parts.overlays.clearMissionOverlaySources();
@@ -44,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.
@@ -99,6 +212,7 @@ export function createLifecycle({
},
async disable() {
hover?.reset();
layerState._sourceController.abort();
layerState._enabled = false;
releaseContinuousRender('rocket-launches');
@@ -121,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;
@@ -160,6 +276,7 @@ export function createLifecycle({
layerState._count = 0;
layerState._orbitMatches = 0;
layerState._lastUpdate = null;
layerState._stale = false;
layerState._activeTleText = null;
layerState._activeTlePromise = null;
layerState._activeTlePromiseToken = 0;
+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
View File
@@ -19,6 +19,8 @@ export function createState({ services }) {
state._lastError = null;
state._stale = false;
state._orbitMatches = 0;
state._clickHandler = null;
+1 -1
View File
@@ -152,7 +152,7 @@ export function localAdsbCardModel(
t('ALT {altitude} FT · GS {speed} KT · TRK {track}', {
altitude: grouped(altitude),
speed: speed === null ? DASH : speed,
track: track === null ? DASH : `${track}°`,
track: track === null ? DASH : `${track % 360}°`,
}),
vertical === null
? t('V/S {rate}', { rate: DASH })
+19
View File
@@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../../i18n/index.js';
import { localAdsbCardModel } from './card.js';
test('a track of 359.7° reads TRK 0°, not TRK 360°', () => {
setLocale('en');
const { details } = localAdsbCardModel(
{
icao: 'abc123',
altitudeFt: 12500,
groundSpeedKt: 240,
trackDeg: 359.7,
lastPositionAt: 1000,
},
5000,
);
assert.equal(details[1], 'ALT 12,500 FT · GS 240 KT · TRK 0°');
});
+2 -2
View File
@@ -5,12 +5,12 @@ import {
/** Same-origin route served by `server/providers/local-receivers.js`. */
export const LOCAL_RECEIVERS_URL = '/api/local-receivers/aircraft';
export const LOCAL_RECEIVER_POLL_MS = 1_000;
const LOCAL_RECEIVER_POLL_MS = 1_000;
/**
* Browser-side deadline for one route request, body included. The server
* bounds each upstream read at 2 s; this bounds the browser-to-server leg.
*/
export const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000;
const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000;
function abortError() {
const error = new Error('Local receiver request aborted');
+2 -12
View File
@@ -36,18 +36,8 @@ import {
} from './policy.js';
import { localAdsbStatus } from './status.js';
export {
LAYER_ID as LOCAL_ADSB_LAYER_ID,
HEARD_BY_RECEIVER,
} from './policy.js';
export {
localAdsbCardModel,
localAdsbClassLine,
localAdsbIsUat,
localAdsbReceiverLine,
localAdsbTitle,
} from './card.js';
export { localAdsbClass } from './enrichment.js';
export { HEARD_BY_RECEIVER } from './policy.js';
export { localAdsbCardModel, localAdsbReceiverLine } from './card.js';
export { localAdsbStatus } from './status.js';
const FEET_TO_METERS = 0.3048;
+2
View File
@@ -14,6 +14,7 @@ import {
MODEL_PROX_ADD_M,
MODEL_PROX_KEEP_M,
} from '../flights/policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
const specKey = (spec) => `${spec.url}@${spec.scale}`;
@@ -87,6 +88,7 @@ export function createLocalAdsbModels({
model = await loadModel({
url: resolveAsset(spec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: MODEL_MIN_PX,
scale: spec.scale,
color,
+1 -1
View File
@@ -26,7 +26,7 @@ import {
export const LOCAL_ADSB_HISTORY_MS = 10 * 60_000;
export const LOCAL_ADSB_HISTORY_POINTS = 600;
/** A display correction larger than this snaps instead of sliding. */
export const LOCAL_ADSB_SNAP_M = 3_000;
const LOCAL_ADSB_SNAP_M = 3_000;
/** Chord course uses the newest fix at least this much older than the anchor. */
const CHORD_BASELINE_MS = 2_000;
/** Turn rate is estimated over this window of heard fixes. */
+2 -2
View File
@@ -24,7 +24,7 @@ function plural(count, one, many) {
* @param {object[]} feeds Feed statuses from the route.
* @returns {string}
*/
export function describeFeedProblems(feeds) {
function describeFeedProblems(feeds) {
const byStatus = new Map();
for (const feed of feeds) {
if (feed?.status === 'live') continue;
@@ -41,7 +41,7 @@ export function describeFeedProblems(feeds) {
}
/** Whether the server reported at least one configured feed. */
export function feedsConfigured(feedState) {
function feedsConfigured(feedState) {
return Boolean(
feedState?.configured === true &&
Array.isArray(feedState.feeds) &&
-7
View File
@@ -1,10 +1,3 @@
export {
GROUND_FLOOR_WARM_MAX_ALT_M,
POSITION_HISTORY_LIMIT,
LANDED_MISSING_POLL_LIMIT,
MISSING_POLL_LIMIT,
ERROR_BACKOFF_INTERVAL,
} from './recordPolicy.js';
import * as Cesium from 'cesium';
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
-1
View File
@@ -366,7 +366,6 @@ export function createQueries({
const maxRange =
Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY;
const now = Cesium.JulianDate.now();
const nearby = [];
for (const [icao24, bb] of flightState._billboards) {
+3
View File
@@ -53,6 +53,7 @@ import {
ROTATION_REFRESH_MS,
COURSE_MAX_DPS,
} from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
export function createRendering({
flightState,
@@ -473,6 +474,7 @@ export function createRendering({
model = await Cesium.Model.fromGltfAsync({
url: resolveAsset(spec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: MODEL_MIN_PX,
scale: spec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
@@ -676,6 +678,7 @@ export function createRendering({
Cesium.Model.fromGltfAsync({
url: resolveAsset(trackedSpec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: TRACKED_MODEL_MIN_PX,
scale: trackedSpec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR,
-1
View File
@@ -187,7 +187,6 @@ export function createMilitarySnapshotRenderer({
// If this is the tracked aircraft, update label text
// (position updates automatically via dead-reckoning CallbackProperty)
if (isTracked && flightState._trackedEntity) {
const info = flightState.records.data.get(icao24);
tracking._updateTrackedLabelModel(icao24);
}
}
+5 -3
View File
@@ -47,7 +47,7 @@ export function createTracking({
const { ensureGeoidReady, geoidHeight } = services.geoid;
const { clearFocusTarget } = services.focus;
const { applyTrackedCameraFrame } = services.camera;
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
function _emitAwarenessEvent(type, detail) {
if (
@@ -593,7 +593,7 @@ export function createTracking({
}
/**
* Fire-and-forget adsb.lol readsb trace backfill (PRD F2). Trace points are
* Fire-and-forget optional source history backfill (PRD F2). Trace points are
* stride-thinned to the remaining vertex budget, then spliced strictly older
* than the oldest seeded fix AHEAD of the locally accumulated fine segment,
* capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/
@@ -1079,7 +1079,9 @@ export function createTracking({
// first-person reference. Empty globe clicks are inert until the user
// exits with C, Escape, or the dedicated button.
if (document.body.classList.contains('cockpit-mode')) return;
const picked = viewer.scene.pick(click.position);
const picked = pickContact
? pickContact(viewer.scene, click.position)
: viewer.scene.pick(click.position);
if (picked) {
// Clicking the tracked entity itself -- ignore (don't deselect)
+22 -6
View File
@@ -2,6 +2,7 @@ import { msg } from '../../i18n/index.js';
import {
RADIO_DIRECTORY_STALE_MS,
RADIO_DIRECTORY_FUTURE_SKEW_MS,
RADIO_REJECTED_STATION_COUNT_MAX,
} from './policy.js';
// Directory errors stay English in layer state and stats; the layer row and
@@ -64,17 +65,24 @@ export function createIngestion({
typeof body.degraded !== 'boolean'
)
throw new Error('Radio directory freshness metadata was malformed');
// Rows the server skipped as structurally malformed. Older envelopes
// omit the field; a present value must be a bounded count.
const serverRejectedStationCount =
body.rejectedStationCount === undefined
? 0
: body.rejectedStationCount;
if (
!Number.isSafeInteger(serverRejectedStationCount) ||
serverRejectedStationCount < 0 ||
serverRejectedStationCount > RADIO_REJECTED_STATION_COUNT_MAX
)
throw new Error('Radio directory diagnostic metadata was malformed');
const rows = body.stations;
const acceptedRows = rows.filter(
parts.catalogModel.isValidRadioDirectoryStation,
);
if (!acceptedRows.length)
throw new Error('Radio directory returned no usable stations');
if (acceptedRows.length !== rows.length) {
throw new Error(
'Radio directory response contained malformed stations',
);
}
const acceptedGeneration = body.acceptedGeneration;
if (
acceptedGeneration !== null &&
@@ -147,7 +155,15 @@ export function createIngestion({
}
if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt;
}
layerState._degraded = body.degraded;
// Combine server-side structural rejections with rows this client
// refused. The count is diagnostic: it marks coverage degraded but does
// not undo the generation admitted above.
layerState._rejectedStationCount = Math.min(
RADIO_REJECTED_STATION_COUNT_MAX,
serverRejectedStationCount + rows.length - acceptedRows.length,
);
layerState._degraded =
body.degraded || layerState._rejectedStationCount > 0;
layerState._stale = body.stale;
layerState._error = preservingWarmCatalog
? ERROR_REFRESH_DEGRADED
+1
View File
@@ -108,6 +108,7 @@ export function createLifecycle({
layerState._loading = false;
layerState._stale = false;
layerState._degraded = false;
layerState._rejectedStationCount = 0;
layerState._error = null;
layerState._updatedAt = null;
layerState._managerLifecyclePresentation = null;
+5 -1
View File
@@ -10,7 +10,7 @@ export const MARKER_LIFT_M = 2.5;
export const SELECTED_LIFT_M = 5;
export const RADIO_PICK_TOLERANCE_PX = 8;
const RADIO_PICK_TOLERANCE_PX = 8;
export const RADIO_TUNER_DIRECTORY_LIMIT = 750;
@@ -24,6 +24,10 @@ export const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000;
export const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000;
// Bound for the server's `rejectedStationCount` envelope field and the
// combined client counter; matches server/providers/radio/constants.js.
export const RADIO_REJECTED_STATION_COUNT_MAX = 10_000;
export const RADIO_UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+1
View File
@@ -20,6 +20,7 @@ export function createPresentation({
loading: layerState._loading,
stale: layerState._stale,
degraded: layerState._degraded,
rejectedStationCount: layerState._rejectedStationCount,
error: layerState._error,
updatedAt: layerState._updatedAt,
filter: layerState._filter,
+1 -1
View File
@@ -259,7 +259,7 @@ export function createRendering({
layerState._horizonScanCount += 1;
const occluder = horizonOccluder(layerState._viewer.camera);
let visibilityChanged = false;
for (const [id, record] of layerState._renderById) {
for (const record of layerState._renderById.values()) {
const matches = parts.categories.stationMatchesRadioCategory(
record.station,
layerState._filter,
+1
View File
@@ -25,6 +25,7 @@ export function createState({ services }) {
layerState._stale = false;
layerState._degraded = false;
layerState._rejectedStationCount = 0;
layerState._error = null;
+12 -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,12 @@ 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().startsWith('zh-')
? 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
View File
@@ -139,6 +139,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
});
layerState._points.set(noradId, point);
layerState._denseIds.push(noradId);
layerState._denseStale ||= Boolean(res.stale);
added++;
}
// Yield to the event loop between chunks.
@@ -226,6 +227,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
layerState._catalog.delete(noradId);
}
layerState._denseIds = [];
layerState._denseStale = false;
layerState._denseCursor = 0;
layerState._count = layerState._points.size;
layerState._catalogRevision++;
+5 -2
View File
@@ -545,16 +545,19 @@ export function createControls({ state: layerState, services, parts, source }) {
},
getStats() {
const stale = Boolean(layerState._coreStale || layerState._denseStale);
return {
count: layerState._count,
lastUpdate: layerState._lastUpdate,
stale: false,
stale,
status:
layerState._lastError === 'CelesTrak unreachable'
? 'unavailable'
: layerState._lastError
? 'degraded'
: 'nominal',
: stale
? 'stale'
: 'nominal',
error: layerState._lastError,
};
},
+13 -2
View File
@@ -36,7 +36,12 @@ export function createIngestion({
if (!res.ok) return { ...groupDef, entries: [], ok: false };
const entries = parts.orbits.parseTLE(res.text);
updateSignal.throwIfAborted();
return { ...groupDef, entries, ok: entries.length > 0 };
return {
...groupDef,
entries,
ok: entries.length > 0,
stale: Boolean(res.stale),
};
} catch (error) {
if (updateSignal.aborted || error?.name === 'AbortError')
throw error;
@@ -91,6 +96,7 @@ export function createIngestion({
// catalog or those labels stay stale for the life of the session.
layerState._detectionObjects.clear();
layerState._denseIds = [];
layerState._denseStale = false;
layerState._denseCursor = 0;
layerState._denseLoadController?.abort();
layerState._denseLoadController = null;
@@ -102,11 +108,13 @@ export function createIngestion({
// Process all TLE entries in CATALOG_GROUPS order
const allEntries = [];
for (const r of results) {
for (const e of r.entries) allEntries.push({ ...e, group: r.tag });
for (const e of r.entries)
allEntries.push({ ...e, group: r.tag, stale: r.stale });
}
// Deduplicate by NORAD ID — first (most specific) group tag wins
const seen = new Set();
let coreStale = false;
for (const entry of allEntries) {
const satrec = twoline2satrec(entry.line1, entry.line2);
@@ -123,6 +131,8 @@ export function createIngestion({
group: entry.group,
});
coreStale ||= entry.stale;
// Propagate initial position
const pos = parts.orbits.propagatePosition(satrec, now);
if (!pos) continue;
@@ -157,6 +167,7 @@ export function createIngestion({
parts.labels._syncIssOverlay();
}
layerState._coreStale = coreStale;
layerState._count = layerState._points.size;
layerState._catalogRevision++;
layerState._lastUpdate = Date.now();
+4
View File
@@ -24,6 +24,8 @@ export function createLifecycle({
layerState._orbitPaths = new Map();
layerState._count = 0;
layerState._lastUpdate = null;
layerState._coreStale = false;
layerState._denseStale = false;
layerState._trackedNorad = null;
parts.tracking._cancelPendingTrackingRestore();
layerState._trackedEntity = null;
@@ -162,6 +164,8 @@ export function createLifecycle({
layerState._rowControlsListener = null;
layerState._count = 0;
layerState._lastUpdate = null;
layerState._coreStale = false;
layerState._denseStale = false;
layerState._lastError = null;
layerState._lastFocusUpdate = 0;
layerState._activeFocusCount = 0;
+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 = {
+279
View File
@@ -0,0 +1,279 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import { EventEmitter, once } from 'node:events';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createSatelliteSource } from './source.js';
import { createState } from './state.js';
import { createControls } from './controls.js';
import { createOrbits } from './orbits.js';
import { createLabels } from './labels.js';
import { createTracking } from './tracking.js';
import { createRendering } from './rendering.js';
import { createCatalog } from './catalog.js';
import { createLifecycle } from './lifecycle.js';
import { createInteraction } from './interaction.js';
import { createIngestion } from './ingestion.js';
import { layerFeedState } from '../../data/feedState.js';
const GROUPS = [
'stations',
'visual',
'gps-ops',
'glo-ops',
'galileo',
'geo',
'starlink',
];
function tle(id, invalid = false) {
// Owned synthetic catalog IDs over one valid element set; never live requests.
const motion = invalid ? ' 0.00000000' : '15.48899044';
return `OWNED ORBIT FIXTURE ${id}\n1 ${id}U 98067A 26289.78971908 .00007803 00000+0 15012-3 0 9995\n2 ${id} 51.6326 58.3278 0006748 271.9059 88.1156 ${motion}590607`;
}
async function fixture(t) {
const policy = {
stale: new Set(),
invalid: new Set(),
ids: {},
status: 200,
hold: false,
};
const held = new EventEmitter();
let heldCount = 0;
const server = http.createServer((req, res) => {
const group = req.url.split('/').at(-1);
if (policy.hold) {
held.emit('request', ++heldCount);
return;
}
res.writeHead(policy.status, {
'Content-Type': 'text/plain',
'X-TLE-Cache': policy.stale.has(group) ? 'STALE-ERROR' : 'MISS',
});
res.end(
tle(
policy.ids[group] ?? 25545 + GROUPS.indexOf(group),
policy.invalid.has(group),
),
);
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const source = createSatelliteSource({
fetchImpl: (path, options) =>
fetch(`http://127.0.0.1:${server.address().port}${path}`, options),
});
// Application host services and native EventTargets; actual Cesium collections,
// input handler, events, SGP4, ingestion and lifecycle run without a WebGL scene.
const noop = () => {};
const services = {
overlays: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
layerState: { isExplicitLayerStateOrigin: () => false },
focus: {
clearFocusTarget: noop,
publishFocusTargetFromCachedPosition: noop,
getFocusTarget: () => null,
},
context: {
clearTrackedSubjectContext: noop,
getContextStore: () => ({ entities: new Map() }),
refreshTrackedSubjectContext: noop,
selectTrackedSubjectContext: noop,
},
readout: { refreshTrackedReadout: noop },
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
};
const previousDocument = globalThis.document;
globalThis.document = new EventTarget();
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
for (const [name, factory] of Object.entries({
controls: createControls,
orbits: createOrbits,
labels: createLabels,
tracking: createTracking,
rendering: createRendering,
catalog: createCatalog,
interaction: createInteraction,
lifecycle: createLifecycle,
ingestion: createIngestion,
})) {
parts[name] = factory(context);
}
const viewer = {
scene: {
canvas: new EventTarget(),
primitives: new Cesium.PrimitiveCollection(),
preRender: new Cesium.Event(),
},
trackedEntityChanged: new Cesium.Event(),
};
await parts.lifecycle.methods.init(viewer);
t.after(async () => {
if (state._pointCollection) parts.lifecycle.methods.destroy(viewer);
viewer.scene.primitives.destroy();
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
});
return {
state,
parts,
viewer,
policy,
update: (options) => parts.ingestion.methods.update(viewer, options),
stats: () => parts.controls.methods.getStats(),
feed: () => layerFeedState(parts.controls.methods.getStats()),
async waitHeld(count) {
while (heldCount < count)
await once(held, 'request', { signal: AbortSignal.timeout(3000) });
},
};
}
test('stale core records surface through actual HTTP, ingestion and feed state, then recover', async (t) => {
const f = await fixture(t);
await f.update();
assert.equal(f.feed(), 'nominal');
assert.equal(f.stats().count, 6);
f.policy.stale = new Set(GROUPS.slice(0, 6));
await f.update();
assert.equal(f.stats().count, 6);
assert.equal(f.stats().stale, true);
assert.equal(f.feed(), 'stale');
f.policy.stale = new Set(['visual']);
await f.update();
assert.equal(f.feed(), 'stale');
f.policy.stale.clear();
await f.update();
assert.equal(f.stats().stale, false);
assert.equal(f.feed(), 'nominal');
});
test('a stale duplicate cannot taint its earlier fresh winning record', async (t) => {
const f = await fixture(t);
f.policy.stale.add('visual');
f.policy.ids.visual = 25545;
await f.update();
assert.equal(f.stats().count, 5);
assert.equal(f.state._catalog.get(25545).group, 'stations');
assert.equal(f.stats().stale, false);
assert.equal(f.feed(), 'nominal');
f.policy.stale = new Set(['stations']);
await f.update();
assert.equal(f.stats().count, 5);
assert.equal(
f.stats().stale,
true,
'a later fresh duplicate does not replace the stale winner',
);
});
test('invalid stale orbital elements are not admitted as provenance', async (t) => {
const f = await fixture(t);
f.policy.stale.add('visual');
f.policy.invalid.add('visual');
await f.update();
assert.equal(f.stats().count, 5);
assert.equal(f.state._catalog.has(25546), false);
assert.equal(f.stats().stale, false);
});
test('failed and aborted core refreshes keep provenance of retained records', async (t) => {
const f = await fixture(t);
f.policy.stale.add('stations');
await f.update();
const priorTime = f.stats().lastUpdate;
f.policy.status = 503;
await f.update();
assert.equal(f.stats().count, 6);
assert.equal(f.stats().stale, true);
assert.equal(f.stats().status, 'unavailable');
assert.equal(f.stats().lastUpdate, priorTime);
f.policy.hold = true;
const controller = new AbortController();
const work = f.update({ signal: controller.signal });
await f.waitHeld(6);
controller.abort();
await assert.rejects(work, { name: 'AbortError' });
assert.equal(f.stats().count, 6);
assert.equal(f.stats().stale, true);
assert.equal(f.stats().lastUpdate, priorTime);
assert.equal(f.state._activeUpdateControllers.size, 0);
});
test('dense provenance belongs to its admitted records and clears on removal', async (t) => {
const f = await fixture(t);
await f.update();
f.policy.stale.add('starlink');
f.state._params.catalog = 'dense';
assert.equal((await f.parts.catalog._loadDenseCatalog()).status, 'ready');
assert.equal(f.stats().count, 7);
assert.equal(f.feed(), 'stale');
f.state._params.catalog = 'core';
f.parts.catalog._removeDenseCatalog();
assert.equal(f.stats().count, 6);
assert.equal(f.feed(), 'nominal');
f.policy.stale = new Set(['stations']);
await f.update();
f.state._params.catalog = 'dense';
await f.parts.catalog._loadDenseCatalog();
f.state._params.catalog = 'core';
f.parts.catalog._removeDenseCatalog();
assert.equal(
f.feed(),
'stale',
'removing dense data must not clear core provenance',
);
});
test('cancelling dense replacement preserves admitted stale extras until rebuild', async (t) => {
const f = await fixture(t);
await f.update();
f.policy.stale.add('starlink');
f.state._params.catalog = 'dense';
await f.parts.catalog._loadDenseCatalog();
f.policy.hold = true;
const controller = new AbortController();
const work = f.parts.catalog._loadDenseCatalog({ signal: controller.signal });
await f.waitHeld(1);
controller.abort();
assert.equal((await work).status, 'cancelled');
assert.equal(f.stats().count, 7);
assert.equal(f.feed(), 'stale');
f.policy.hold = false;
f.policy.stale.clear();
f.state._params.catalog = 'core';
await f.update();
assert.equal(f.stats().count, 6);
assert.equal(f.feed(), 'nominal');
});
test('destroy aborts an in-flight response and initialization starts with new ownership', async (t) => {
const f = await fixture(t);
f.policy.stale.add('stations');
await f.update();
f.policy.hold = true;
const work = f.update();
await f.waitHeld(6);
const rejected = assert.rejects(work, { name: 'AbortError' });
f.parts.lifecycle.methods.destroy(f.viewer);
assert.equal(f.stats().count, 0);
assert.equal(f.stats().stale, false);
await f.parts.lifecycle.methods.init(f.viewer);
await rejected;
assert.equal(f.stats().stale, false);
f.policy.hold = false;
f.policy.stale.clear();
await f.update();
assert.equal(f.stats().count, 6);
assert.equal(f.feed(), 'nominal');
});
+4
View File
@@ -40,6 +40,10 @@ export function createState({ services }) {
state._lastError = null;
// Cache provenance belongs to admitted core and dense records separately.
state._coreStale = false;
state._denseStale = false;
state._activeUpdateControllers = new Set();
state._denseLoadController = null;
-209
View File
@@ -1,209 +0,0 @@
import * as Cesium from 'cesium';
import { FOLLOW_EYE_HEIGHT_M } from './policy.js';
/** Plausible ellipsoidal land heights: Dead Sea to Everest, with geoid slack. */
const SURFACE_MIN_M = -500;
const SURFACE_MAX_M = 9000;
/** Drive the globe camera from the street-level pose: continuously (follow) or once (framing). */
export function createCameraFollow({ state, parts }) {
const { render } = state.services;
function requestRender() {
render?.governorRequestRender?.('street-level-follow');
}
/**
* The application's camera authority (`attachNavigation`): `run(noun, move)`
* releases tracking and other owners before `move`, or refuses (cockpit);
* `begin(noun)` takes a deferred ticket (a generation, or false) that
* `reassert(generation)` honours only if nothing newer took the camera;
* `subscribeHandoff` reports a newer owner. Without it, moves run directly.
*/
let navigation = null;
let unsubscribeHandoff = null;
/** Set while our own claim stamps a handoff, so it does not end FOLLOW. */
let claiming = false;
/** Take the camera for `move`; false when the application refuses. */
function claim(move) {
if (!navigation?.run) {
move();
return true;
}
claiming = true;
try {
return (
navigation.run('photo', () => {
move();
return true;
}) === true
);
} finally {
claiming = false;
}
}
/**
* Ask for the camera as a photo starts opening. The ticket goes to
* `lookAtPosition`; null when the application refuses (cockpit).
*/
function beginFraming() {
if (!navigation?.begin || !navigation.reassert) return { generation: null };
claiming = true;
try {
const generation = navigation.begin('photo');
return generation === false ? null : { generation };
} finally {
claiming = false;
}
}
/** Another feature took the camera: stop following it. */
function onHandoff() {
if (claiming || !state.street.follow) return;
state.street.follow = false;
state.notify?.();
}
function attachNavigation(next) {
unsubscribeHandoff?.();
navigation = next || null;
unsubscribeHandoff = navigation?.subscribeHandoff?.(onHandoff) || null;
}
/**
* Ground under a photo. `sampleHeight` can return kilometres below the
* ellipsoid before tiles load, so a sample counts only when plausible and
* within the mesh window around bare earth (once that is known).
*/
function groundHeightAt(lon, lat, fallback) {
const scene = state.viewer?.scene;
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
const caster = parts?.groundCaster;
const dem = caster?.groundAt(lon, lat) ?? null;
if (dem === null) caster?.prepare([[lon, lat]]);
// The application's mesh window around bare earth, once that is known.
const withinPrior = state.services.ground?.meshFloorSampleWithinPrior;
const plausible = (height) =>
Number.isFinite(height) &&
height >= SURFACE_MIN_M &&
height <= SURFACE_MAX_M &&
(dem === null || !withinPrior || withinPrior(height, dem));
let height = null;
try {
if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto);
} catch {
/* not sampleable yet */
}
if (!plausible(height)) height = dem;
if (!plausible(height)) height = scene?.globe?.getHeight?.(carto);
if (!plausible(height)) height = fallback;
return plausible(height) ? height : 0;
}
/** Put the Cesium camera where the street-level camera is. */
function followCamera() {
const { position, bearing, tilt, follow, altitude } = state.street;
if (!follow || !position || !state.viewer) return;
const ground = groundHeightAt(position.lon, position.lat, altitude);
state.viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(
position.lon,
position.lat,
ground + FOLLOW_EYE_HEIGHT_M,
),
orientation: {
heading: Cesium.Math.toRadians(bearing || 0),
pitch: Cesium.Math.toRadians(Number.isFinite(tilt) ? tilt : 0),
roll: 0,
},
});
requestRender();
}
/** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */
let framing = null;
/**
* Frame the current image from a short distance behind it, unless another
* action took the camera since `ticket` (from `beginFraming`) was issued.
*/
function lookAtPosition(ticket) {
const { position, bearing, altitude } = state.street;
if (!ticket || !position || !state.viewer) return;
if (
ticket.generation !== null &&
!navigation?.reassert?.(ticket.generation)
)
return;
const ground = groundHeightAt(position.lon, position.lat, altitude);
const fly = () => {
// Owned before the call: starting it cancels the previous flight (ours
// included) synchronously, and a zero-length one completes at once.
const flight = {};
framing = flight;
const release = () => {
if (framing === flight) framing = null;
};
state.viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
4,
),
{
offset: new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(bearing || 0),
Cesium.Math.toRadians(-32),
140,
),
duration: 1.6,
complete: release,
cancel: release,
},
);
};
// A reasserted ticket has already released the other owners.
if (ticket.generation === null) claim(fly);
else fly();
}
/** Stop our framing flight if it is still in the air; another feature's flight is left alone. */
function cancelFraming() {
if (!framing) return;
// Give up ownership first: cancelFlight delivers `cancel` synchronously.
framing = null;
state.viewer?.camera?.cancelFlight?.();
}
function setFollow(enabled) {
const wanted = enabled === true && state.street.followAvailable;
if (wanted && !state.street.follow) {
// Following owns the camera until another feature takes it.
const granted = claim(() => {
state.street.follow = true;
// The framing tween would keep overwriting the follow view until it lands.
cancelFraming();
followCamera();
});
if (!granted) state.street.follow = false;
} else if (!wanted) {
state.street.follow = false;
}
state.notify?.();
}
function destroy() {
attachNavigation(null);
}
return {
followCamera,
beginFraming,
lookAtPosition,
cancelFraming,
setFollow,
attachNavigation,
destroy,
};
}
@@ -1,265 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createCameraFollow } from './cameraFollow.js';
import { FOLLOW_EYE_HEIGHT_M } from './policy.js';
/** Height (m) of a Cartesian above the WGS84 ellipsoid. */
const heightOf = (cartesian) =>
Cesium.Cartographic.fromCartesian(cartesian).height;
/**
* A camera with Cesium's flight bookkeeping: a new flight or `cancelFlight`
* cancels the current one, `land()` completes it, `cancelled` counts stops.
*/
function flightCamera(flights, views) {
let current = null;
function stop() {
const flight = current;
current = null;
flight?.options.cancel?.();
return Boolean(flight);
}
const camera = {
cancelled: 0,
flyToBoundingSphere(sphere, options = {}) {
stop();
current = { sphere, options };
flights.push(current);
},
cancelFlight() {
if (stop()) camera.cancelled++;
},
land() {
const flight = current;
current = null;
flight?.options.complete?.();
},
setView: (view) => views.push(view),
};
return camera;
}
/**
* A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles
* under the photo load) and whose bare earth is `dem`.
*/
function setup({ sampled, dem = null, globe = null, altitude = 149 }) {
const flights = [];
const views = [];
const state = {
services: {},
street: {
position: { lon: -97.7364, lat: 30.2672 },
bearing: 105,
tilt: -10,
altitude,
follow: false,
followAvailable: true,
},
viewer: {
scene: {
sampleHeightSupported: true,
sampleHeight: () => sampled,
globe: { getHeight: () => globe },
},
camera: flightCamera(flights, views),
},
};
const parts = {
groundCaster: dem === null ? null : { groundAt: () => dem, prepare() {} },
};
return {
follow: createCameraFollow({ state, parts }),
state,
flights,
views,
};
}
test('framing a photo ignores a sample kilometres underground and uses bare earth', () => {
const { follow, flights } = setup({ sampled: -14_886, dem: 117 });
follow.lookAtPosition(follow.beginFraming());
assert.equal(flights.length, 1);
const centre = heightOf(flights[0].sphere.center);
assert.ok(
Math.abs(centre - 119) < 0.01,
`centre at ${centre} m, not underground`,
);
});
test('a plausible mesh sample near bare earth wins (a street, or a modest roof)', () => {
const { follow, flights } = setup({ sampled: 121, dem: 117 });
follow.lookAtPosition(follow.beginFraming());
assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 123) < 0.01);
});
test('without bare earth an absurd sample falls back to the image altitude, never below the surface range', () => {
const { follow, flights } = setup({
sampled: -14_886,
dem: null,
globe: undefined,
altitude: 149,
});
follow.lookAtPosition(follow.beginFraming());
assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 151) < 0.01);
});
test('following stands the camera at eye height above the checked ground', () => {
const { follow, state, views } = setup({ sampled: -14_886, dem: 117 });
state.street.follow = true;
follow.followCamera();
assert.equal(views.length, 1);
assert.ok(
Math.abs(heightOf(views[0].destination) - (117 + FOLLOW_EYE_HEIGHT_M)) <
0.01,
);
});
test('cancelFraming stops the framing flight while it is still ours', () => {
const { follow, state } = setup({ sampled: 121, dem: 117 });
const { camera } = state.viewer;
follow.lookAtPosition(follow.beginFraming());
follow.cancelFraming();
assert.equal(camera.cancelled, 1, 'the flight toward the closed photo stops');
follow.cancelFraming();
assert.equal(camera.cancelled, 1, 'and only once');
// Re-framing (the next photo) supersedes the first flight, not the claim.
follow.lookAtPosition(follow.beginFraming());
follow.lookAtPosition(follow.beginFraming());
follow.cancelFraming();
assert.equal(camera.cancelled, 2);
});
test('cancelFraming leaves a landed flight and a newer navigation flight alone', () => {
const { follow, state, flights } = setup({ sampled: 121, dem: 117 });
const { camera } = state.viewer;
follow.lookAtPosition(follow.beginFraming());
camera.land();
follow.cancelFraming();
assert.equal(camera.cancelled, 0, 'landed: nothing to stop');
follow.lookAtPosition(follow.beginFraming());
// A search result flies the globe elsewhere; Cesium cancels ours first.
camera.flyToBoundingSphere(null, {});
assert.equal(flights.length, 3);
follow.cancelFraming();
assert.equal(camera.cancelled, 0, 'the newer flight keeps going');
});
test('turning FOLLOW on stops the framing flight so it cannot fight the follow view', () => {
const { follow, state, views } = setup({ sampled: 121, dem: 117 });
const { camera } = state.viewer;
follow.lookAtPosition(follow.beginFraming());
follow.setFollow(true);
assert.equal(camera.cancelled, 1, 'the framing tween stops');
assert.equal(views.length, 1, 'and the camera stands at the photo');
});
/**
* The application's camera authority: `run` releases tracking (here, a tracked
* entity) and stamps a handoff before the move, or refuses like the cockpit.
*/
function navigationFor(state, { refuse = false } = {}) {
const listeners = new Set();
let generation = 0;
const stamp = () => {
generation++;
for (const listener of listeners) listener({ generation });
return generation;
};
const nav = {
runs: [],
begins: [],
run(noun, move) {
nav.runs.push(noun);
if (refuse) return false;
const current = stamp();
state.viewer.trackedEntity = undefined;
return move(current);
},
/** Deferred: stamp now, release only on a successful reassert. */
begin(noun) {
nav.begins.push(noun);
return refuse ? false : stamp();
},
reassert(ticket) {
if (ticket !== generation) return false;
state.viewer.trackedEntity = undefined;
return true;
},
subscribeHandoff(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
/** Another feature takes the camera. */
handOff: () => stamp(),
listeners,
};
return nav;
}
test('a photo claims the camera when it starts opening and frames once loaded, releasing tracking', () => {
const { follow, state, flights } = setup({ sampled: 160 });
state.viewer.trackedEntity = { id: 'aircraft' };
const nav = navigationFor(state);
follow.attachNavigation(nav);
const ticket = follow.beginFraming();
assert.deepEqual(nav.begins, ['photo']);
assert.ok(state.viewer.trackedEntity, 'nothing is released while it loads');
follow.lookAtPosition(ticket);
assert.equal(state.viewer.trackedEntity, undefined);
assert.equal(flights.length, 1);
});
test('a photo that finishes loading after newer navigation does not frame', () => {
const { follow, state, flights } = setup({ sampled: 160 });
const nav = navigationFor(state);
follow.attachNavigation(nav);
const ticket = follow.beginFraming();
// The user flies to Sacramento, and then tracks an aircraft, while it loads.
nav.handOff();
state.viewer.trackedEntity = { id: 'aircraft' };
follow.lookAtPosition(ticket);
assert.equal(flights.length, 0, 'no flight back to the photo');
assert.deepEqual(state.viewer.trackedEntity, { id: 'aircraft' });
});
test('a refused claim (cockpit) neither frames the photo nor turns FOLLOW on', () => {
const { follow, state, flights, views } = setup({ sampled: 160 });
follow.attachNavigation(navigationFor(state, { refuse: true }));
follow.lookAtPosition(follow.beginFraming());
follow.setFollow(true);
assert.equal(flights.length, 0);
assert.equal(views.length, 0);
assert.equal(state.street.follow, false);
});
test('FOLLOW claims the camera once and ends when another feature takes it', () => {
const { follow, state, views } = setup({ sampled: 160 });
let notified = 0;
state.notify = () => notified++;
const nav = navigationFor(state);
follow.attachNavigation(nav);
follow.setFollow(true);
assert.equal(state.street.follow, true, 'our own handoff keeps FOLLOW on');
assert.deepEqual(nav.runs, ['photo']);
follow.followCamera();
assert.deepEqual(nav.runs, ['photo'], 'pose updates do not claim again');
assert.equal(views.length, 2);
nav.handOff();
assert.equal(state.street.follow, false);
assert.ok(notified >= 2);
follow.followCamera();
assert.equal(views.length, 2, 'the camera is no longer driven');
});
test('destroying the follow part stops listening for handoffs', () => {
const { follow, state } = setup({ sampled: 160 });
const nav = navigationFor(state);
follow.attachNavigation(nav);
assert.equal(nav.listeners.size, 1);
follow.destroy();
assert.equal(nav.listeners.size, 0);
});
+83
View File
@@ -0,0 +1,83 @@
import * as Cesium from 'cesium';
import { plausibleSurfaceHeight, sampledSurfaceHeight } from './view.js';
/** Fly the globe camera once to frame a photo after it opens. */
export function createCameraFraming({ state }) {
/**
* The application's camera authority: `reassert(begin())` succeeds only if
* nothing newer took the camera. Without it, framing flies directly.
*/
let navigation = null;
/** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */
let flight = null;
function attachNavigation(next) {
navigation = next || null;
}
/** Ask for the camera as a photo starts opening: a ticket for `frame`, null when refused (cockpit). */
function begin() {
if (!navigation?.begin || !navigation.reassert) return { generation: null };
const generation = navigation.begin('photo');
return generation === false ? null : { generation };
}
/**
* Ground under a photo. `sampleHeight` can return kilometres below the
* ellipsoid before tiles load, so only a plausible height counts.
*/
function groundHeightAt(lon, lat, fallback) {
const scene = state.viewer?.scene;
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
let height = sampledSurfaceHeight(scene, carto);
if (height === null) height = scene?.globe?.getHeight?.(carto);
if (!plausibleSurfaceHeight(height)) height = fallback;
return plausibleSurfaceHeight(height) ? height : 0;
}
/**
* Frame the open photo from a short distance behind it, unless another
* action took the camera since `ticket` (from `begin`) was issued.
*/
function frame(ticket) {
const { position, bearing, altitude } = state.street;
if (!ticket || !position || !state.viewer) return;
// Detached (shell teardown) since the ticket was issued: nothing to frame for.
if (ticket.generation !== null && !navigation?.reassert(ticket.generation))
return;
const ground = groundHeightAt(position.lon, position.lat, altitude);
// Owned before the call: starting it cancels the previous flight (ours
// included) synchronously, and a zero-length one completes at once.
const current = {};
flight = current;
const release = () => {
if (flight === current) flight = null;
};
state.viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
4,
),
{
offset: new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(bearing || 0),
Cesium.Math.toRadians(-32),
140,
),
duration: 1.6,
complete: release,
cancel: release,
},
);
}
/** Stop our framing flight if it is still in the air; another feature's flight is left alone. */
function cancel() {
if (!flight) return;
// Give up ownership first: cancelFlight delivers `cancel` synchronously.
flight = null;
state.viewer?.camera?.cancelFlight?.();
}
return { attachNavigation, begin, frame, cancel };
}
@@ -0,0 +1,159 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createCameraFraming } from './cameraFraming.js';
import { fakeCesiumViewer } from '../../testSupport/streetLevelFakes.mjs';
/** Height (m) of a Cartesian above the WGS84 ellipsoid. */
const heightOf = (cartesian) =>
Cesium.Cartographic.fromCartesian(cartesian).height;
/**
* A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles
* under the photo load) and whose globe terrain is `globe`.
*/
function setup({ sampled, globe = null, altitude = 149 }) {
const viewer = fakeCesiumViewer();
Object.assign(viewer.scene, {
sampleHeightSupported: true,
sampleHeight: () => sampled,
globe: { getHeight: () => globe },
});
const state = {
services: {},
street: {
position: { lon: -97.7364, lat: 30.2672 },
bearing: 105,
altitude,
},
viewer,
};
return {
framing: createCameraFraming({ state }),
state,
viewer,
flights: viewer.flights.list,
};
}
test('framing a photo stands the sphere on a plausible scene sample', () => {
const { framing, flights } = setup({ sampled: 121 });
framing.frame(framing.begin());
assert.equal(flights.length, 1);
assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 123) < 0.01);
});
test('a sample kilometres underground falls back to the globe, then the image altitude', () => {
const globe = setup({ sampled: -14_886, globe: 117 });
globe.framing.frame(globe.framing.begin());
assert.ok(Math.abs(heightOf(globe.flights[0].sphere.center) - 119) < 0.01);
const altitude = setup({
sampled: -14_886,
globe: undefined,
altitude: 149,
});
altitude.framing.frame(altitude.framing.begin());
assert.ok(Math.abs(heightOf(altitude.flights[0].sphere.center) - 151) < 0.01);
});
test('cancel stops the framing flight while it is still ours', () => {
const { framing, viewer } = setup({ sampled: 121 });
framing.frame(framing.begin());
framing.cancel();
assert.equal(
viewer.flights.cancelled,
1,
'the flight toward the photo stops',
);
framing.cancel();
assert.equal(viewer.flights.cancelled, 1, 'and only once');
// Re-framing (the next photo) supersedes the first flight, not the claim.
framing.frame(framing.begin());
framing.frame(framing.begin());
framing.cancel();
assert.equal(viewer.flights.cancelled, 2);
});
test('cancel leaves a landed flight and a newer navigation flight alone', () => {
const { framing, viewer, flights } = setup({ sampled: 121 });
framing.frame(framing.begin());
viewer.flights.land();
framing.cancel();
assert.equal(viewer.flights.cancelled, 0, 'landed: nothing to stop');
framing.frame(framing.begin());
// A search result flies the globe elsewhere; Cesium cancels ours first.
viewer.camera.flyToBoundingSphere(null, {});
assert.equal(flights.length, 3);
framing.cancel();
assert.equal(viewer.flights.cancelled, 0, 'the newer flight keeps going');
});
/**
* The application's camera authority: `begin` stamps a generation (or refuses
* like the cockpit), `reassert` releases tracking (here, a tracked entity)
* only while that generation is current.
*/
function navigationFor(state, { refuse = false } = {}) {
let generation = 0;
const stamp = () => ++generation;
const nav = {
begins: [],
/** Deferred: stamp now, release only on a successful reassert. */
begin(noun) {
nav.begins.push(noun);
return refuse ? false : stamp();
},
reassert(ticket) {
if (ticket !== generation) return false;
state.viewer.trackedEntity = undefined;
return true;
},
/** Another feature takes the camera. */
handOff: () => stamp(),
};
return nav;
}
test('a photo claims the camera when it starts opening and frames once loaded, releasing tracking', () => {
const { framing, state, flights } = setup({ sampled: 160 });
state.viewer.trackedEntity = { id: 'aircraft' };
const nav = navigationFor(state);
framing.attachNavigation(nav);
const ticket = framing.begin();
assert.deepEqual(nav.begins, ['photo']);
assert.ok(state.viewer.trackedEntity, 'nothing is released while it loads');
framing.frame(ticket);
assert.equal(state.viewer.trackedEntity, undefined);
assert.equal(flights.length, 1);
});
test('a photo that finishes loading after newer navigation does not frame', () => {
const { framing, state, flights } = setup({ sampled: 160 });
const nav = navigationFor(state);
framing.attachNavigation(nav);
const ticket = framing.begin();
// The user flies to Sacramento, and then tracks an aircraft, while it loads.
nav.handOff();
state.viewer.trackedEntity = { id: 'aircraft' };
framing.frame(ticket);
assert.equal(flights.length, 0, 'no flight back to the photo');
assert.deepEqual(state.viewer.trackedEntity, { id: 'aircraft' });
});
test('a refused claim (cockpit) does not frame the photo', () => {
const { framing, state, flights } = setup({ sampled: 160 });
framing.attachNavigation(navigationFor(state, { refuse: true }));
framing.frame(framing.begin());
assert.equal(flights.length, 0);
});
test('a photo that loads after the navigation was detached does not frame', () => {
const { framing, state, flights } = setup({ sampled: 160 });
framing.attachNavigation(navigationFor(state));
const ticket = framing.begin();
framing.attachNavigation(null);
assert.doesNotThrow(() => framing.frame(ticket));
assert.equal(flights.length, 0);
});
+1 -1
View File
@@ -27,7 +27,7 @@ export function sameFilter(a, b) {
/**
* Stored filter (relative days, stable in share links) to the absolute form
* providers compare capture times against.
* coverage, cones and lookups compare capture times against.
* @returns {{pano: string, sinceMs: number|null}}
*/
export function resolveFilter(filter, now = Date.now()) {
+41 -58
View File
@@ -1,48 +1,27 @@
/** Canvas billboard glyphs (image cone, pano ring, position marker); no Cesium needed. */
/** Billboard glyphs as SVG data URLs: the image cone, the 360° ring and the position markers. */
const _cache = new Map();
const px = (n) => Number(n.toFixed(2));
/** A square canvas glyph drawn once per key, centred at (c, c). */
function cachedGlyph(key, size, draw) {
if (_cache.has(key)) return _cache.get(key);
const element = document.createElement('canvas');
element.width = size;
element.height = size;
draw(element.getContext('2d'), size / 2);
_cache.set(key, element);
return element;
function svg(size, body) {
const markup = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">${body}</svg>`;
return `data:image/svg+xml;utf8,${encodeURIComponent(markup)}`;
}
/** A filled wedge from the centre, pointing "up", `halfAngle` radians wide. */
function wedge(ctx, c, radius, halfAngle, color, alpha) {
ctx.beginPath();
ctx.moveTo(c, c);
ctx.arc(c, c, radius, -Math.PI / 2 - halfAngle, -Math.PI / 2 + halfAngle);
ctx.closePath();
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
ctx.fill();
ctx.globalAlpha = 1;
function wedge(size, radius, halfAngle, color, opacity) {
const c = size / 2;
const dx = px(radius * Math.sin(halfAngle));
const dy = px(radius * Math.cos(halfAngle));
const r = px(radius);
return `<path d="M${c} ${c}L${px(c - dx)} ${px(c - dy)}A${r} ${r} 0 0 1 ${px(c + dx)} ${px(c - dy)}Z" fill="${color}" fill-opacity="${opacity}"/>`;
}
function ring(ctx, c, radius, color, width, alpha) {
ctx.beginPath();
ctx.arc(c, c, radius, 0, Math.PI * 2);
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.globalAlpha = alpha;
ctx.stroke();
ctx.globalAlpha = 1;
function ring(size, radius, color, width, opacity) {
return `<circle cx="${size / 2}" cy="${size / 2}" r="${px(radius)}" fill="none" stroke="${color}" stroke-width="${px(width)}" stroke-opacity="${opacity}"/>`;
}
function dot(ctx, c, radius, color, outline, width) {
ctx.beginPath();
ctx.arc(c, c, radius, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = width;
ctx.strokeStyle = outline;
ctx.stroke();
function dot(size, radius, color, outline, width) {
return `<circle cx="${size / 2}" cy="${size / 2}" r="${px(radius)}" fill="${color}" stroke="${outline}" stroke-width="${px(width)}"/>`;
}
/** Cone glyph pointing "up"; rotate the billboard by the compass angle. */
@@ -51,34 +30,38 @@ export function imageConeGlyph({
color = '#e8eaed',
pano = false,
} = {}) {
return cachedGlyph(`cone:${size}:${color}:${pano}`, size, (ctx, c) => {
if (pano) ring(ctx, c, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5);
else wedge(ctx, c, size * 0.46, 0.62, color, 0.42);
dot(
ctx,
c,
size * 0.16,
color,
'rgba(10,10,15,0.85)',
Math.max(1, size * 0.05),
);
});
const shape = pano
? ring(size, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5)
: wedge(size, size * 0.46, 0.62, color, 0.42);
return svg(
size,
shape +
dot(
size,
size * 0.16,
color,
'rgba(10,10,15,0.85)',
Math.max(1, size * 0.05),
),
);
}
/** Street View-style marker: a field-of-view cone from a white-ringed dot. */
export function viewConeMarkerGlyph({ size = 56, color = '#4285f4' } = {}) {
return cachedGlyph(`view-cone:${size}:${color}`, size, (ctx, c) => {
wedge(ctx, c, size * 0.48, 0.62, color, 0.38);
wedge(ctx, c, size * 0.3, 0.62, color, 0.3);
dot(ctx, c, size * 0.13, color, '#ffffff', Math.max(2, size * 0.05));
});
return svg(
size,
wedge(size, size * 0.48, 0.62, color, 0.38) +
wedge(size, size * 0.3, 0.62, color, 0.3) +
dot(size, size * 0.13, color, '#ffffff', Math.max(2, size * 0.05)),
);
}
/** Marker for the image the viewer currently shows. */
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => {
wedge(ctx, c, size * 0.48, 0.55, color, 0.55);
dot(ctx, c, size * 0.2, color, '#0a0a0f', 2);
ring(ctx, c, size * 0.3, color, 1.5, 0.8);
});
return svg(
size,
wedge(size, size * 0.48, 0.55, color, 0.55) +
dot(size, size * 0.2, color, '#0a0a0f', 2) +
ring(size, size * 0.3, color, 1.5, 0.8),
);
}
-256
View File
@@ -1,256 +0,0 @@
/**
* Ground casting for street-level overlays on Google 3D. Draped overlays land
* on the mesh top (trees, overpasses), so casting places them at bare-earth
* (DEM) height plus a lift and lets the mesh hide what is behind buildings.
* Heights come from a coarse grid shared with the ground-floor service; a
* geoid fallback (terrain proxy down) counts as unknown and stays draped.
*/
import { wrapLon } from './tileMath.js';
/** Grid spacing in degrees (~111 m north-south): the ground-floor grid. */
export const GROUND_CAST_STEP_DEG = 0.001;
/** Longest line segment left undivided, in degrees, so lines follow the grid. */
export const GROUND_CAST_DENSIFY_DEG = 0.0005;
/** Metres above the bare earth, so lines sit on the road instead of in it. */
export const GROUND_CAST_LIFT_M = 2;
/** Grid corners one prepare call may request; more stays draped. */
export const GROUND_CAST_MAX_CORNERS = 1024;
/** Cached grid corners before the oldest are dropped to make room. */
const GROUND_CAST_CACHE_MAX = 50_000;
/**
* Mesh refinement, where the Google 3D surface was sampled (the DEM grid
* misses trenches, steep streets and roads under trees):
* - mesh up to MESH_ROAD_ABOVE_M above bare earth is the road: follow it;
* - higher mesh is over the road (canopy, deck): interpolate the road's
* offset from the nearest road samples on both sides;
* - otherwise bare earth plus GROUND_CAST_LIFT_M.
*/
export const MESH_ROAD_ABOVE_M = 3;
/** A sample this far below bare earth is a bad probe, not a road. */
export const MESH_ROAD_BELOW_M = 30;
/** Metres above the sampled road surface. */
export const MESH_LIFT_M = 1;
/** Longest run of covered points bridged from the road on either side. */
export const MESH_BRIDGE_MAX_POINTS = 8;
/** Line spacing, in degrees (~22 m), while the mesh refines heights. */
export const MESH_DENSIFY_DEG = 0.0002;
/**
* Heights for a line's points by the rule above.
* @param {Array<{dem: number, mesh?: number|null}>} points
* @param {{lift?: number}} [options]
* @returns {Array<number>}
*/
export function refineHeights(points, { lift = GROUND_CAST_LIFT_M } = {}) {
const road = points.map(({ dem, mesh }) =>
Number.isFinite(mesh) &&
mesh <= dem + MESH_ROAD_ABOVE_M &&
mesh >= dem - MESH_ROAD_BELOW_M
? mesh - dem
: null,
);
return points.map(({ dem, mesh }, i) => {
if (road[i] !== null) return dem + road[i] + MESH_LIFT_M;
if (Number.isFinite(mesh) && mesh > dem + MESH_ROAD_ABOVE_M) {
let a = i - 1;
while (a >= 0 && i - a <= MESH_BRIDGE_MAX_POINTS && road[a] === null) a--;
let b = i + 1;
while (
b < points.length &&
b - i <= MESH_BRIDGE_MAX_POINTS &&
road[b] === null
)
b++;
const left = a >= 0 && i - a <= MESH_BRIDGE_MAX_POINTS ? road[a] : null;
const right =
b < points.length && b - i <= MESH_BRIDGE_MAX_POINTS ? road[b] : null;
if (left !== null && right !== null) {
const t = (i - a) / (b - a);
return dem + left + (right - left) * t + MESH_LIFT_M;
}
}
return dem + lift;
});
}
/** Camera height (m above ground) below which Google 3D switches to terrain mode. */
export const SURFACE_TERRAIN_ENTER_M = 1400;
/** Camera height above which terrain mode switches back to draped. */
export const SURFACE_TERRAIN_EXIT_M = 1800;
/**
* Next surface mode: terrain only on Google 3D at street zoom, with
* hysteresis between the enter and exit heights.
* @param {'draped'|'terrain'} current
* @param {{photoreal: boolean, heightM: number|null, available: boolean}} view
* @returns {'draped'|'terrain'}
*/
export function nextSurfaceMode(current, { photoreal, heightM, available }) {
if (!available || !photoreal || !Number.isFinite(heightM)) return 'draped';
if (current === 'terrain')
return heightM > SURFACE_TERRAIN_EXIT_M ? 'draped' : 'terrain';
return heightM < SURFACE_TERRAIN_ENTER_M ? 'terrain' : 'draped';
}
/**
* Split long segments so a line follows the terrain. Segments across ±180°
* go the short way round, with added points wrapped into [-180, 180].
* @param {Array<[number, number]>} coords [lon, lat] pairs
* @param {number} [maxStep] longest segment, in degrees
* @returns {Array<[number, number]>}
*/
export function densifyLine(coords, maxStep = GROUND_CAST_DENSIFY_DEG) {
if (!Array.isArray(coords) || coords.length < 2) return coords || [];
const out = [coords[0]];
for (let i = 1; i < coords.length; i++) {
const [lon0, lat0] = coords[i - 1];
const [lon1, lat1] = coords[i];
const dLon = wrapLon(lon1 - lon0);
const span = Math.max(Math.abs(dLon), Math.abs(lat1 - lat0));
const parts = Math.min(64, Math.ceil(span / maxStep - 1e-9));
for (let k = 1; k < parts; k++) {
const t = k / parts;
out.push([wrapLon(lon0 + dLon * t), lat0 + (lat1 - lat0) * t]);
}
out.push(coords[i]);
}
return out;
}
/**
* A bare-earth height cache over the terrain service.
* @param {{terrain: {resolveEllipsoidalGround: Function}, step?: number, lift?: number, maxCorners?: number, cacheMax?: number}} options
*/
export function createGroundCaster({
terrain,
step = GROUND_CAST_STEP_DEG,
lift = GROUND_CAST_LIFT_M,
maxCorners = GROUND_CAST_MAX_CORNERS,
cacheMax = GROUND_CAST_CACHE_MAX,
}) {
if (typeof terrain?.resolveEllipsoidalGround !== 'function')
throw new TypeError('Ground casting requires a terrain service');
/** "i,j" grid corner → ellipsoidal ground height in metres. */
const heights = new Map();
/** Prepares run one at a time, so neighbouring tiles share their corners. */
let queue = Promise.resolve();
const cornerKey = (i, j) => `${i},${j}`;
function missingCorners(points) {
const missing = new Map();
for (const point of points) {
const [lon, lat] = point;
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
const i0 = Math.floor(lon / step);
const j0 = Math.floor(lat / step);
for (let di = 0; di <= 1; di++)
for (let dj = 0; dj <= 1; dj++) {
const key = cornerKey(i0 + di, j0 + dj);
if (!heights.has(key) && !missing.has(key))
missing.set(key, { i: i0 + di, j: j0 + dj });
}
}
return [...missing.entries()];
}
async function resolve(points, signal) {
if (signal?.aborted) return false;
// Evict before counting, or we could drop corners this request counted
// as cached. Oldest first, so the tiles still on screen keep theirs.
for (const key of heights.keys()) {
if (heights.size + maxCorners <= cacheMax) break;
heights.delete(key);
}
const missing = missingCorners(points);
if (!missing.length) return true;
if (missing.length > maxCorners) return false;
// A corner east of 180° is asked for at its wrapped longitude.
const results = await terrain.resolveEllipsoidalGround(
missing.map(([, { i, j }]) => ({
lon: Number(wrapLon(i * step).toFixed(6)),
lat: Number((j * step).toFixed(6)),
})),
{ signal },
);
let complete = true;
missing.forEach(([key], n) => {
const result = results?.[n];
if (result?.source === 'reearth' && Number.isFinite(result.ellipsoid))
heights.set(key, result.ellipsoid);
else complete = false;
});
return complete && !signal?.aborted;
}
/**
* Fetch the grid corners around the points. True when all can be cast;
* false when some are unknown, too many, or aborted. Never rejects.
* @param {Array<[number, number]>} points [lon, lat] pairs
* @param {{signal?: AbortSignal}} [options]
*/
function prepare(points, { signal } = {}) {
const run = queue.then(() => resolve(points, signal)).catch(() => false);
queue = run;
return run;
}
/**
* `prepare` for lines at both densities: the finer mesh points can fall in
* cells the coarse ones skip, and a missing cell leaves the line draped.
*/
function prepareLines(lines, options) {
return prepare(
lines.flatMap((coords) => [
...densifyLine(coords),
...densifyLine(coords, MESH_DENSIFY_DEG),
]),
options,
);
}
/** Bare-earth ellipsoidal height at a point, from cached corners only; null when unknown. */
function groundAt(lon, lat) {
const x = lon / step;
const y = lat / step;
const i0 = Math.floor(x);
const j0 = Math.floor(y);
const h00 = heights.get(cornerKey(i0, j0));
const h10 = heights.get(cornerKey(i0 + 1, j0));
const h01 = heights.get(cornerKey(i0, j0 + 1));
const h11 = heights.get(cornerKey(i0 + 1, j0 + 1));
if (h00 === undefined || h10 === undefined) return null;
if (h01 === undefined || h11 === undefined) return null;
const fx = x - i0;
const fy = y - j0;
const south = h00 + (h10 - h00) * fx;
const north = h01 + (h11 - h01) * fx;
return south + (north - south) * fy;
}
/**
* Densified line as flat [lon, lat, height, ...]; null until every corner is
* cached. With `meshAt` it is densified finer and refined by `refineHeights`.
* @param {Array<[number, number]>} coords
* @param {{meshAt?: (lon: number, lat: number) => number|null|undefined}} [options]
*/
function castLine(coords, { meshAt = null } = {}) {
const rows = [];
const points = meshAt
? densifyLine(coords, MESH_DENSIFY_DEG)
: densifyLine(coords);
for (const [lon, lat] of points) {
const dem = groundAt(lon, lat);
if (dem === null) return null;
rows.push({ lon, lat, dem, mesh: meshAt ? meshAt(lon, lat) : null });
}
const heights = refineHeights(rows, { lift });
const flat = [];
rows.forEach(({ lon, lat }, i) => flat.push(lon, lat, heights[i]));
return flat;
}
return { prepare, prepareLines, groundAt, castLine };
}
-370
View File
@@ -1,370 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
GROUND_CAST_LIFT_M,
MESH_LIFT_M,
refineHeights,
SURFACE_TERRAIN_ENTER_M,
SURFACE_TERRAIN_EXIT_M,
createGroundCaster,
densifyLine,
nextSurfaceMode,
} from './groundCast.js';
/** A terrain service whose ground rises 1 m per 0.001° east and 2 m per 0.001° north. */
function fakeTerrain({ source = 'reearth', fail = false } = {}) {
const calls = [];
return {
calls,
async resolveEllipsoidalGround(coords) {
calls.push(coords);
if (fail) throw new Error('proxy down');
return coords.map(({ lon, lat }) => ({
ellipsoid: 100 + (lon - 10) * 1000 + (lat - 50) * 2000,
source,
}));
},
};
}
test('surface mode: terrain only on Google 3D at street zoom, with hysteresis', () => {
const view = { photoreal: true, available: true };
assert.equal(nextSurfaceMode('draped', { ...view, heightM: 500 }), 'terrain');
assert.equal(
nextSurfaceMode('draped', {
...view,
heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2,
}),
'draped',
);
assert.equal(
nextSurfaceMode('terrain', {
...view,
heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2,
}),
'terrain',
);
assert.equal(
nextSurfaceMode('terrain', {
...view,
heightM: SURFACE_TERRAIN_EXIT_M + 1,
}),
'draped',
);
assert.equal(
nextSurfaceMode('terrain', { ...view, photoreal: false, heightM: 200 }),
'draped',
);
assert.equal(
nextSurfaceMode('terrain', { ...view, available: false, heightM: 200 }),
'draped',
);
assert.equal(
nextSurfaceMode('terrain', { ...view, heightM: null }),
'draped',
);
});
test('densifyLine splits long segments and keeps the vertices', () => {
const line = [
[10, 50],
[10.002, 50],
];
const dense = densifyLine(line, 0.0005);
assert.equal(dense.length, 5);
assert.deepEqual(dense[0], [10, 50]);
assert.deepEqual(dense.at(-1), [10.002, 50]);
assert.ok(Math.abs(dense[2][0] - 10.001) < 1e-9);
const short = [
[10, 50],
[10.0001, 50],
];
assert.deepEqual(densifyLine(short, 0.0005), short);
assert.deepEqual(densifyLine([[10, 50]]), [[10, 50]]);
});
test('densifyLine crosses the date line the short way and wraps what it adds', () => {
// 0.0002° apart: short enough to stay one segment, not 65 points via 0°.
const short = [
[179.9999, 0],
[-179.9999, 0],
];
assert.deepEqual(densifyLine(short), short);
const line = [
[179.999, 50.0005],
[-179.999, 50.0005],
];
const dense = densifyLine(line, 0.0005);
assert.equal(dense.length, 5);
assert.deepEqual(dense[0], line[0]);
assert.deepEqual(dense.at(-1), line[1]);
for (const [lon] of dense)
assert.ok(lon >= -180 && lon <= 180 && Math.abs(lon) >= 179.999, `${lon}`);
assert.ok(Math.abs(dense[1][0] - 179.9995) < 1e-9);
assert.ok(Math.abs(Math.abs(dense[2][0]) - 180) < 1e-9);
assert.ok(Math.abs(dense[3][0] - -179.9995) < 1e-9);
// Westward across the date line too.
const back = densifyLine([...line].reverse(), 0.0005);
assert.equal(back.length, 5);
assert.ok(Math.abs(back[1][0] - -179.9995) < 1e-9);
});
test('the caster interpolates cached grid corners and adds the lift', async () => {
const terrain = fakeTerrain();
const caster = createGroundCaster({ terrain, step: 0.001 });
assert.equal(caster.groundAt(10.0005, 50.0005), null);
assert.equal(await caster.prepare([[10.0005, 50.0005]]), true);
assert.equal(terrain.calls.length, 1);
assert.equal(terrain.calls[0].length, 4);
// Centre of the cell: mean of 100, 101, 102, 103.
assert.ok(Math.abs(caster.groundAt(10.0005, 50.0005) - 101.5) < 1e-6);
// A cast line sits the lift above it.
const point = [10.0005, 50.0005];
const [, , height] = caster.castLine([point, point]);
assert.ok(Math.abs(height - (101.5 + GROUND_CAST_LIFT_M)) < 1e-6);
// Same cell again: nothing to fetch.
assert.equal(await caster.prepare([[10.0002, 50.0008]]), true);
assert.equal(terrain.calls.length, 1);
});
test('castLine returns lon, lat, height triples or null when a corner is missing', async () => {
const caster = createGroundCaster({ terrain: fakeTerrain(), step: 0.001 });
const line = [
[10.0001, 50.0001],
[10.0009, 50.0001],
];
assert.equal(caster.castLine(line), null);
await caster.prepareLines([line]);
const flat = caster.castLine(line);
assert.equal(flat.length % 3, 0);
assert.ok(flat.length >= 6);
assert.equal(flat[0], 10.0001);
assert.equal(flat[1], 50.0001);
assert.ok(flat[2] > 100 && flat[2] < 110);
});
test('a geoid fallback or a failing proxy leaves the heights unknown', async () => {
const fallback = createGroundCaster({
terrain: fakeTerrain({ source: 'geoid-fallback' }),
});
assert.equal(await fallback.prepare([[10.0005, 50.0005]]), false);
assert.equal(fallback.groundAt(10.0005, 50.0005), null);
const down = createGroundCaster({ terrain: fakeTerrain({ fail: true }) });
assert.equal(await down.prepare([[10.0005, 50.0005]]), false);
assert.equal(down.groundAt(10.0005, 50.0005), null);
});
test('too many corners are not requested, and aborted prepares do nothing', async () => {
const terrain = fakeTerrain();
const caster = createGroundCaster({ terrain, step: 0.001, maxCorners: 8 });
const wide = [];
for (let i = 0; i < 10; i++) wide.push([10 + i * 0.001 + 0.0005, 50.0005]);
assert.equal(await caster.prepare(wide), false);
assert.equal(terrain.calls.length, 0);
const controller = new AbortController();
controller.abort();
assert.equal(
await caster.prepare([[10.0005, 50.0005]], { signal: controller.signal }),
false,
);
assert.equal(terrain.calls.length, 0);
});
test('prepares run one at a time so neighbours share corners', async () => {
const terrain = fakeTerrain();
const caster = createGroundCaster({ terrain, step: 0.001 });
const [a, b] = await Promise.all([
caster.prepare([[10.0005, 50.0005]]),
caster.prepare([[10.0015, 50.0005]]),
]);
assert.equal(a, true);
assert.equal(b, true);
// The second cell shares its west corners with the first.
assert.equal(terrain.calls[1].length, 2);
});
test('the caster needs a terrain service', () => {
assert.throws(() => createGroundCaster({ terrain: null }), TypeError);
});
test('refineHeights follows the mesh where it is the road', () => {
// A freeway trench 6 m below the bare-earth grid, and a steep street 2 m above it.
const heights = refineHeights([
{ dem: 100, mesh: 94 },
{ dem: 100, mesh: 102 },
{ dem: 100, mesh: null },
]);
assert.deepEqual(heights, [
94 + MESH_LIFT_M,
102 + MESH_LIFT_M,
100 + GROUND_CAST_LIFT_M,
]);
});
test('refineHeights carries the road under a canopy from both sides', () => {
// Road 1 m and 3 m above bare earth either side of a tree 12 m tall.
const heights = refineHeights([
{ dem: 50, mesh: 51 },
{ dem: 50, mesh: 62 },
{ dem: 50, mesh: 62 },
{ dem: 50, mesh: 53 },
]);
assert.equal(heights[0], 51 + MESH_LIFT_M);
assert.ok(Math.abs(heights[1] - (50 + 1 + 2 / 3 + MESH_LIFT_M)) < 1e-9);
assert.ok(Math.abs(heights[2] - (50 + 1 + 4 / 3 + MESH_LIFT_M)) < 1e-9);
assert.equal(heights[3], 53 + MESH_LIFT_M);
});
test('refineHeights keeps bare earth under a deck with no road beside it, and rejects bad probes', () => {
assert.deepEqual(
refineHeights([
{ dem: 20, mesh: 35 },
{ dem: 20, mesh: 35 },
]),
[20 + GROUND_CAST_LIFT_M, 20 + GROUND_CAST_LIFT_M],
);
// A probe kilometres under the ground is not a road.
assert.deepEqual(refineHeights([{ dem: 20, mesh: -14000 }]), [
20 + GROUND_CAST_LIFT_M,
]);
});
test('castLine refines heights with sampled mesh and densifies finer', async () => {
const caster = createGroundCaster({ terrain: fakeTerrain(), step: 0.001 });
const line = [
[10.0001, 50.0001],
[10.0009, 50.0001],
];
await caster.prepareLines([line]);
const plain = caster.castLine(line);
const meshed = caster.castLine(line, { meshAt: () => 98 });
assert.ok(meshed.length > plain.length, 'finer spacing with the mesh');
for (let i = 2; i < meshed.length; i += 3)
assert.equal(meshed[i], 98 + MESH_LIFT_M);
// Unsampled points keep the bare-earth height.
const unsampled = caster.castLine(line, { meshAt: () => undefined });
assert.ok(unsampled[2] > 100 && unsampled[2] < 110);
});
test('a full cache is cleared before a request counts its corners, so the request still casts', async () => {
const caster = createGroundCaster({
terrain: fakeTerrain(),
step: 0.001,
maxCorners: 8,
cacheMax: 5,
});
// One cell's four corners are cached...
assert.equal(await caster.prepare([[10.0005, 50.0005]]), true);
// ...then a line over that cell and the next needs two more, and the first
// cell must survive the eviction.
const line = [
[10.0002, 50.0005],
[10.0018, 50.0005],
];
assert.equal(await caster.prepareLines([line]), true);
assert.ok(caster.castLine(line), 'the whole line casts');
});
test('a full cache drops its oldest corners, not every corner', async () => {
const caster = createGroundCaster({
terrain: fakeTerrain(),
step: 0.001,
maxCorners: 4,
cacheMax: 12,
});
// Three cells apart from each other, four corners each: the cache is full.
const cells = [10.0005, 10.0105, 10.0205, 10.0305].map((lon) => [
lon,
50.0005,
]);
for (const cell of cells.slice(0, 3))
assert.equal(await caster.prepare([cell]), true);
// A fourth makes room by dropping the first cell only.
assert.equal(await caster.prepare([cells[3]]), true);
assert.equal(caster.groundAt(...cells[0]), null, 'oldest dropped');
for (const cell of cells.slice(1))
assert.notEqual(caster.groundAt(...cell), null, 'newer cells kept');
});
test('cancelling a prepare cancels its terrain request', async () => {
const seen = [];
const caster = createGroundCaster({
terrain: {
async resolveEllipsoidalGround(coords, options) {
seen.push(options?.signal);
return coords.map(() => ({ ellipsoid: 100, source: 'reearth' }));
},
},
});
const controller = new AbortController();
await caster.prepare([[10.0005, 50.0005]], { signal: controller.signal });
assert.equal(seen.length, 1);
assert.equal(seen[0], controller.signal, 'the tile signal reaches the proxy');
});
test('prepared lines always cast with the mesh, however their segments cross the grid', async () => {
// Seeded walks with 50–300 m segments: the finer mesh points can land in
// grid cells the coarse points skip.
let seed = 7;
const random = () => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
let failures = 0;
for (let n = 0; n < 300; n++) {
const caster = createGroundCaster({ terrain: fakeTerrain() });
const line = [[10 + random() * 0.01, 50 + random() * 0.01]];
for (let k = 0; k < 10; k++) {
const [lon, lat] = line[line.length - 1];
const length = (50 + random() * 250) / 111_000;
const angle = random() * 2 * Math.PI;
line.push([
lon + length * Math.cos(angle),
lat + length * Math.sin(angle),
]);
}
assert.equal(await caster.prepareLines([line]), true);
if (caster.castLine(line, { meshAt: () => undefined }) === null) failures++;
}
assert.equal(failures, 0);
});
test('a line across the date line casts along the date line', async () => {
// Ground that rises northward only, so both sides of ±180° agree.
const calls = [];
const terrain = {
async resolveEllipsoidalGround(coords) {
calls.push(...coords);
return coords.map(({ lat }) => ({
ellipsoid: 100 + (lat - 50) * 2000,
source: 'reearth',
}));
},
};
const caster = createGroundCaster({ terrain, step: 0.001 });
const line = [
[179.9995, 50.0005],
[180, 50.0005],
[-179.9995, 50.0005],
];
assert.equal(await caster.prepareLines([line]), true);
for (const meshAt of [null, () => undefined]) {
const flat = caster.castLine(line, { meshAt });
assert.ok(flat, 'every point casts');
assert.ok(
flat.length / 3 <= 8,
`${flat.length / 3} points, not a line round the world`,
);
for (let i = 0; i < flat.length; i += 3) {
assert.ok(Math.abs(flat[i]) >= 179.999 && Math.abs(flat[i]) <= 180);
assert.ok(Math.abs(flat[i + 2] - (101 + GROUND_CAST_LIFT_M)) < 1e-6);
}
}
// Only ground near the date line is asked for, and every longitude is a
// real one (the far corner of a point on 180° is asked for at -179.999°).
assert.ok(calls.length <= 16, `${calls.length} corners`);
for (const { lon } of calls)
assert.ok(Math.abs(lon) >= 179.999 && Math.abs(lon) <= 180, `${lon}`);
});
+5 -15
View File
@@ -3,34 +3,24 @@ import { GROUND_CLICK_MAX_HEIGHT_M } from './policy.js';
import { cameraHeightAboveGround } from './view.js';
/** Whether the camera is low enough for a click on the ground to mean a street. */
export function groundClickHeightOk(viewer, groundAt) {
export function groundClickHeightOk(viewer) {
if (!viewer?.camera?.positionCartographic) return false;
return (
cameraHeightAboveGround(viewer, { groundAt }) <= GROUND_CLICK_MAX_HEIGHT_M
);
return cameraHeightAboveGround(viewer) <= GROUND_CLICK_MAX_HEIGHT_M;
}
/**
* A click on the map itself at street zoom opens the nearest image of the
* active `groundClick` providers, without moving the camera. The height is
* measured at the click, not read from the cached flag.
* @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean, groundAt?: Function}} options
* @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean}} options
* @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise<boolean>|false}
*/
export function createGroundClick({
state,
openNearest,
isAvailable,
groundAt = null,
}) {
export function createGroundClick({ state, openNearest, isAvailable }) {
return function openAtGround(screenPosition, picked) {
const scenePick = state.services.scenePick;
if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition)
return false;
if (
!scenePick.isSurfacePick(picked) ||
!groundClickHeightOk(state.viewer, groundAt)
)
if (!scenePick.isSurfacePick(picked) || !groundClickHeightOk(state.viewer))
return false;
const providerIds = [...state.providers.values()]
.filter(
+108 -192
View File
@@ -1,7 +1,7 @@
import * as Cesium from 'cesium';
import { createState } from './state.js';
import { createMarker } from './marker.js';
import { createCameraFollow } from './cameraFollow.js';
import { createCameraFraming } from './cameraFraming.js';
import { createCredits } from './credits.js';
import { createPickRouter } from './pickRouter.js';
import { createSelection } from './selection.js';
@@ -14,17 +14,12 @@ import {
keyAlternatives,
summarizeCoverage,
} from './uiState.js';
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
import { groundUnderCamera, viewCentre } from './view.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
import { createMeshSampler } from './meshSampler.js';
import { msg, t, translateKnown } from '../../i18n/index.js';
/** Above this camera height the ground under the camera is not worth fetching. */
const SURFACE_WARM_BELOW_M = 6000;
import {
AIM_AT_GROUND_MESSAGE,
FOLLOW_MAP_STACK_ID,
keyStatusLabel,
NEAREST_RADIUS_M,
POSITION_PICK_ID,
STREET_LEVEL_LAYER_ID,
@@ -32,35 +27,31 @@ import {
export { STREET_LEVEL_LAYER_ID } from './policy.js';
/** Longest wait for a provider's key status; past it, the provider decides. */
const KEY_STATUS_TIMEOUT_MS = 10_000;
/** Re-measures of a ground not loaded yet (Google 3D), a second apart. */
const GROUND_RETRY_LIMIT = 5;
const GROUND_RETRY_MS = 1000;
/**
* The Street Level layer over imagery providers (contract in registry.js).
* The core owns what they share; providers own coverage, sequences, viewer.
* The core owns what they share: the filter and switches, the photo viewer
* host, the position marker, the click handler and nearest-image lookups.
* Providers own coverage, sequences and their viewer. One source is lit at
* a time.
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
*/
export function createStreetLevelLayer({
providers: definitions,
services = {},
}) {
const definitionsFrozen = validateProviders(definitions);
const defs = validateProviders(definitions);
const state = createState({ services });
const parts = {};
const context = { state, parts };
parts.groundCaster = services.terrain?.resolveEllipsoidalGround
? createGroundCaster({ terrain: services.terrain })
: null;
// Mesh samples follow the application's rules: a real bare-earth prior,
// its mesh window, and a tileset that has finished streaming.
parts.meshSampler = parts.groundCaster
? createMeshSampler({
getViewer: () => state.viewer,
groundAt: parts.groundCaster.groundAt,
withinPrior: services.ground?.meshFloorSampleWithinPrior ?? null,
tilesReady: services.meshFloor?.visibleTilesetLoaded ?? null,
})
: null;
parts.credits = createCredits();
parts.marker = createMarker(context);
parts.follow = createCameraFollow(context);
parts.framing = createCameraFraming(context);
parts.router = createPickRouter(() => state.providers.values(), {
positionId: POSITION_PICK_ID,
});
@@ -78,7 +69,6 @@ export function createStreetLevelLayer({
state,
openNearest: (point, options) => layer.openNearest(point, options),
isAvailable: (entry) => providerAvailable(entry),
groundAt: parts.groundCaster?.groundAt ?? null,
});
let notifyQueued = false;
@@ -102,11 +92,10 @@ export function createStreetLevelLayer({
function providerContext(entry) {
return Object.freeze({
services: state.services,
/** Resolved against now, so "since N days" keeps up with the clock. */
getFilter: () => resolveFilter(state.filter),
/** The layer and this provider's switch are both on. */
isActive: () => state.enabled && entry.on,
groundCaster: parts.groundCaster,
meshSampler: parts.meshSampler,
getSurface: () => state.surface,
notify,
actions: {
/** `{frame: false}` opens it without moving the camera. */
@@ -138,13 +127,15 @@ export function createStreetLevelLayer({
}
// One source at a time: the first provider that defaults on.
const startsOn = definitionsFrozen.find((def) => def.defaultOn !== false);
for (const def of definitionsFrozen) {
const startsOn = defs.find((def) => def.defaultOn !== false);
for (const def of defs) {
const entry = {
def,
instance: null,
on: def === startsOn,
status: null,
/** The status request in flight; overlapping checks share it. */
statusCheck: null,
/** The error this provider last reported, until it withdraws it. */
reportedError: null,
};
@@ -155,15 +146,6 @@ export function createStreetLevelLayer({
let mapStack = null;
let unsubscribeMapStack = null;
/** Follow is offered only on the Google 3D stack; leaving it stops following. */
function syncFollowAvailability() {
const available = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
if (available === state.street.followAvailable) return;
state.street.followAvailable = available;
if (!available && state.street.follow) parts.follow.setFollow(false);
notify();
}
/** Usable only on its map stack (Google's terms: Street View on Google 3D only). */
function providerAvailable(entry) {
const required = entry.def.requiresMapStack;
@@ -184,109 +166,79 @@ export function createStreetLevelLayer({
parts.viewerHost.unmount(entry.def.id);
}
}
remeasureGround();
notify();
}
/** Whether a ground click can open an image: the camera is at street zoom. */
let groundRetry = null;
let groundRetries = 0;
function cancelGroundRetry() {
clearTimeout(groundRetry);
groundRetry = null;
}
/**
* Whether a ground click can open an image: the camera is at street zoom.
* Under Google 3D the ground is unknown until its tiles load, so an unknown
* ground is measured again a few times instead of waiting for the next move.
*/
function syncGroundClickReady() {
const ready =
cancelGroundRetry();
const ready = state.enabled && groundClickHeightOk(state.viewer);
if (
state.enabled &&
groundClickHeightOk(state.viewer, parts.groundCaster?.groundAt);
!ready &&
groundRetries < GROUND_RETRY_LIMIT &&
groundUnderCamera(state.viewer) === null
) {
groundRetries++;
groundRetry = setTimeout(syncGroundClickReady, GROUND_RETRY_MS);
}
if (ready === state.groundClickReady) return;
state.groundClickReady = ready;
notify();
}
/**
* Camera height above bare earth, for the surface mode. A cold grid cell is
* fetched once and the mode rechecked when it lands.
*/
function cameraHeightForSurface() {
const carto = state.viewer?.camera?.positionCartographic;
if (!carto) return null;
const caster = parts.groundCaster;
const height = cameraHeightAboveGround(state.viewer, {
groundAt: caster.groundAt,
});
const lon = (carto.longitude * 180) / Math.PI;
const lat = (carto.latitude * 180) / Math.PI;
if (caster.groundAt(lon, lat) === null && height < SURFACE_WARM_BELOW_M)
caster.prepare([[lon, lat]]).then((ready) => {
if (ready) scheduleSurfaceSync();
});
return height;
}
/** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */
function syncSurface() {
/** A camera move or a map change measures afresh. */
function remeasureGround() {
groundRetries = 0;
syncGroundClickReady();
const available = Boolean(parts.groundCaster) && state.enabled;
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
const mode = nextSurfaceMode(state.surface, {
available,
photoreal,
heightM: available && photoreal ? cameraHeightForSurface() : null,
});
if (mode === state.surface) return;
state.surface = mode;
parts.meshSampler?.setEnabled(mode === 'terrain');
parts.marker.setSurface(mode);
for (const entry of state.providers.values())
entry.instance.setSurface?.(mode);
notify();
}
let surfaceTimer = null;
function scheduleSurfaceSync() {
clearTimeout(surfaceTimer);
surfaceTimer = setTimeout(syncSurface, 150);
}
let removeSurfaceListeners = null;
function watchSurface(viewer) {
removeSurfaceListeners?.();
const camera = viewer?.camera;
if (!camera?.changed || !camera?.moveEnd) {
removeSurfaceListeners = null;
return;
}
const removeChanged = camera.changed.addEventListener(scheduleSurfaceSync);
const removeEnd = camera.moveEnd.addEventListener(scheduleSurfaceSync);
removeSurfaceListeners = () => {
removeChanged();
removeEnd();
};
}
function unwatchSurface() {
removeSurfaceListeners?.();
removeSurfaceListeners = null;
clearTimeout(surfaceTimer);
surfaceTimer = null;
let removeCameraListener = null;
function watchCamera(viewer) {
removeCameraListener?.();
removeCameraListener =
viewer?.camera?.moveEnd?.addEventListener(remeasureGround) || null;
}
const activeEntries = () =>
[...state.providers.values()].filter((entry) => entry.on);
async function refreshStatus(entry) {
try {
entry.status = await entry.instance.status();
} catch {
entry.status = null;
}
notify();
/** A provider's key status. A hung server must not hold LOADING, or the next check, forever. */
function checkStatus(entry) {
entry.statusCheck ||= (async () => {
try {
entry.status = await entry.instance.status({
signal: AbortSignal.timeout(KEY_STATUS_TIMEOUT_MS),
});
} catch {
entry.status = null;
}
notify();
})().finally(() => {
entry.statusCheck = null;
});
return entry.statusCheck;
}
function activate(entry) {
if (!state.viewer) return;
entry.instance.activate(state.viewer);
if (!entry.status) refreshStatus(entry);
if (!providerAvailable(entry)) return;
parts.credits.show(state.viewer, entry.def);
// The map may have changed by then.
whenIdle(() => {
if (providerAvailable(entry)) parts.viewerHost.prewarm([entry]);
}, 1500);
// Without a known key, a switch-on asks again: the server may be back,
// or have a key now.
if (entry.status?.configured !== true) checkStatus(entry);
if (providerAvailable(entry)) parts.credits.show(state.viewer, entry.def);
}
function deactivate(entry) {
@@ -442,7 +394,6 @@ export function createStreetLevelLayer({
error: stats.error || null,
color: entry.def.colors.coverage,
coverageLabel: entry.def.coverageLabel || null,
highlightsSequence: entry.def.highlightsSequence !== false,
sinceBasis: entry.def.sinceBasis === 'upload' ? 'upload' : 'capture',
groundClick: entry.def.groundClick === true,
filters: [...filtersOf(entry.def)],
@@ -450,23 +401,30 @@ export function createStreetLevelLayer({
});
}
/** The selected sequence; the provider showing the open image answers first. */
function sequenceSnapshot() {
// The provider showing the open image answers first.
const owner = state.providers.get(state.street.providerId);
const candidates = owner
? [owner, ...[...state.providers.values()].filter((e) => e !== owner)]
: [...state.providers.values()];
for (const entry of candidates) {
const stats = entry.instance.sequenceStats?.();
if (stats?.selectedId || stats?.loading)
if (stats?.selectedId || stats?.loading || stats?.error)
return {
providerId: entry.def.id,
selectedId: stats.selectedId || null,
images: stats.images || 0,
loading: stats.loading === true,
error: stats.error || null,
};
}
return { providerId: null, selectedId: null, images: 0, loading: false };
return {
providerId: null,
selectedId: null,
images: 0,
loading: false,
error: null,
};
}
function getUIState() {
@@ -478,7 +436,6 @@ export function createStreetLevelLayer({
providers: providerSnapshots(),
street,
sequence: sequenceSnapshot(),
surface: state.surface,
});
// The lit source cannot run on this map: say so, not a blank panel.
const stranded = [...state.providers.values()].find(
@@ -503,15 +460,13 @@ export function createStreetLevelLayer({
statsRefreshInterval: 1000,
/** The key the switched-on providers share (or null), for the layer row. */
get requiresKeyId() {
const on = [...state.providers.values()].filter((entry) => entry.on);
return requiresKeyIdFor(
on.length ? on.map((entry) => entry.def) : definitionsFrozen,
);
const on = activeEntries();
return requiresKeyIdFor(on.length ? on.map((entry) => entry.def) : defs);
},
/** Registered providers, in chip order. */
providerIds: definitionsFrozen.map((def) => def.id),
providerIds: defs.map((def) => def.id),
getSourceAvailability() {
const available = definitionsFrozen.length > 0;
const available = defs.length > 0;
return {
available,
source: STREET_LEVEL_LAYER_ID,
@@ -522,15 +477,12 @@ export function createStreetLevelLayer({
},
init(viewer) {
if (state.initialized)
throw new Error('Street Level layer is already initialized');
state.viewer = viewer;
state.initialized = true;
parts.marker.ensure(viewer);
parts.marker.setVisible(false);
for (const entry of state.providers.values()) {
entry.instance.init(viewer);
refreshStatus(entry);
checkStatus(entry);
}
console.log('[Data:StreetLevel] Initialized');
},
@@ -541,18 +493,20 @@ export function createStreetLevelLayer({
parts.marker.setVisible(true);
parts.selection.install(viewer);
for (const entry of activeEntries()) activate(entry);
watchSurface(viewer);
syncSurface();
watchCamera(viewer);
remeasureGround();
notify();
},
disable() {
state.enabled = false;
abortNearest();
unwatchSurface();
removeCameraListener?.();
removeCameraListener = null;
cancelGroundRetry();
parts.viewerHost.unmount();
for (const entry of state.providers.values()) entry.instance.deactivate();
syncSurface();
syncGroundClickReady();
parts.credits.hideAll(state.viewer);
parts.selection.uninstall();
parts.marker.setVisible(false);
@@ -568,27 +522,22 @@ export function createStreetLevelLayer({
for (const entry of state.providers.values())
entry.instance.destroy(viewer);
parts.marker.destroy(viewer);
parts.meshSampler?.destroy();
parts.follow.destroy();
parts.framing.attachNavigation(null);
unsubscribeMapStack?.();
unsubscribeMapStack = null;
mapStack = null;
state.listeners.clear();
state.viewer = null;
state.initialized = false;
state.destroyed = true;
},
getStats() {
// The lifecycle polls this every second: summarise, don't snapshot.
// English data: the layer panel translates these through translateKnown.
const snapshots = providerSnapshots();
const coverage = summarizeCoverage(snapshots);
// English data: the layer panel translates these through translateKnown.
const keyLabel = keyStatusLabel(coverage);
let loadingLabel = '';
const keyLabel = coverage.keyRejected
? msg('KEY REJECTED')
: msg('KEY REQUIRED');
if (coverage.keyRequired) loadingLabel = keyLabel;
if (keyLabel) loadingLabel = keyLabel;
else if (coverage.loading) loadingLabel = msg('loading coverage...');
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
return {
@@ -597,7 +546,8 @@ export function createStreetLevelLayer({
loading: coverage.loading,
keyRequired: coverage.keyRequired,
keyAlternatives: keyAlternatives(snapshots),
error: coverage.keyRequired
// A rejected key's message names the fix; a missing one says so.
error: keyLabel
? coverage.keyRejected
? coverage.error || keyLabel
: keyLabel
@@ -606,30 +556,18 @@ export function createStreetLevelLayer({
};
},
/**
* The application's camera authority: `run(noun, move)`, the deferred
* `begin(noun)` / `reassert(generation)` pair and
* `subscribeHandoff(listener)`. FOLLOW claims the camera with `run`; a
* photo claims it when opening starts and frames only if it still owns
* it once loaded. FOLLOW stops when another feature takes the camera.
*/
/** A photo frames itself only if it still owns the camera once loaded. */
attachNavigation(navigation) {
parts.follow.attachNavigation(navigation);
parts.framing.attachNavigation(navigation);
},
/** The application map stack; FOLLOW is available only on Google 3D. */
/** The application map stack; Street View is usable only on Google 3D. */
attachMapStackController(controller) {
unsubscribeMapStack?.();
mapStack = controller || null;
unsubscribeMapStack =
mapStack?.subscribe?.(() => {
syncFollowAvailability();
syncProviderAvailability();
syncSurface();
}) || null;
syncFollowAvailability();
mapStack?.subscribe?.(syncProviderAvailability) || null;
syncProviderAvailability();
syncSurface();
},
/** Share-link and stored state: provider switches plus the filter. */
@@ -640,6 +578,7 @@ export function createStreetLevelLayer({
});
},
/** Unknown providers and malformed values are ignored, so any link applies. */
setParams(params = {}) {
const decoded = decodeParams(params, {
providerIds: state.providers.keys(),
@@ -666,23 +605,14 @@ export function createStreetLevelLayer({
/** The DOM element provider viewers render into. */
attachViewerHost(element) {
parts.viewerHost.attach(element);
if (element && state.enabled)
whenIdle(
() =>
parts.viewerHost.prewarm(
activeEntries().filter((entry) => providerAvailable(entry)),
),
1500,
);
},
setProviderEnabled,
/** Imagery filter for coverage, cones and nearest-image lookups. */
setCoverageFilter,
openImage,
/**
* Open the nearest image of the active providers (or `providerIds`) near
* a point, else the view centre, or with `aim: 'screen-centre'` the ground
* under the screen centre (sky: nothing).
* under the screen centre (sky: nothing). Providers without
* `nearestImage` are skipped.
*/
async openNearest(
point,
@@ -712,6 +642,7 @@ export function createStreetLevelLayer({
let lastError = null;
const asked = (entry) =>
(!providerIds || providerIds.includes(entry.def.id)) &&
typeof entry.instance.nearestImage === 'function' &&
providerAvailable(entry);
const entries = activeEntries().filter(asked);
for (const entry of entries) {
@@ -751,35 +682,20 @@ export function createStreetLevelLayer({
})
: t(
'No street-level imagery within {radius} m of the view centre',
{
radius: NEAREST_RADIUS_M,
},
{ radius: NEAREST_RADIUS_M },
));
state.street.loading = false;
notify();
return false;
},
/** Close the image and deselect it everywhere on the map. */
/** Close the image and deselect its sequence on the map. */
closeViewer() {
abortNearest();
parts.viewerHost.close();
parts.clearSequences();
},
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
setFollow: (enabled) => parts.follow.setFollow(enabled),
resizeViewer: () => parts.viewerHost.resize(),
selectSequence(
sequenceId,
providerId = state.providers.keys().next().value,
) {
return state.providers
.get(providerId)
?.instance.selectSequence?.(sequenceId);
},
clearSequence: () => parts.clearSequences(),
refreshCoverage() {
for (const entry of activeEntries()) entry.instance.refreshCoverage();
},
};
return layer;
}
+371 -206
View File
@@ -5,9 +5,306 @@ import { createStreetLevelLayer } from './index.js';
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
import { createMapillaryProvider } from './providers/mapillary/index.js';
import { createKartaViewProvider } from './providers/kartaview/index.js';
import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs';
import {
fakeCesiumViewer,
fakeMapillarySource,
fakePhotoViewer,
fakeStreetLevelProvider as fakeProvider,
} from '../../testSupport/streetLevelFakes.mjs';
import { setLocale } from '../../i18n/index.js';
/**
* A layer over `source` and `photoViewer`, initialised on a stand-in viewer
* and (unless `enable` is false) enabled.
*/
async function startLayer(
t,
{
source = fakeMapillarySource(),
photoViewer = fakePhotoViewer(),
providers = [createMapillaryProvider({ source, photoViewer })],
view = false,
enable = true,
} = {},
) {
const saved = globalThis.document;
// The click handler listens for Esc on the document.
globalThis.document = new EventTarget();
// Skip Cesium's one-time terrain table download for draped lines.
t.mock.method(
Cesium.GroundPolylinePrimitive,
'initializeTerrainHeights',
async () => {},
);
const viewer = fakeCesiumViewer({ view });
const layer = createStreetLevelLayer({ providers });
layer.init(viewer);
if (enable) layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
await settle();
return { layer, viewer, source, photoViewer };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
function deferred() {
let resolve;
let reject;
const promise = new Promise((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
/** A photo viewer reporting a pose for every image it opens. */
const posingViewer = () =>
fakePhotoViewer({ pose: { position: { lon: -121.49, lat: 38.58 } } });
test('the CC BY-SA credit shows while Mapillary is on and goes with it', async (t) => {
const { layer, viewer } = await startLayer(t);
const shown = () => viewer.credits.map((credit) => credit.html);
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on enable');
assert.match(shown()[0], /CC BY-SA 4\.0/);
layer.setParams({ mapillary: false });
assert.deepEqual(shown(), [], 'hidden with the switch');
layer.setParams({ mapillary: true });
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]);
layer.disable();
assert.deepEqual(shown(), [], 'the credit goes with the layer');
});
test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => {
for (const configured of [false, true]) {
const status = deferred();
const source = fakeMapillarySource({ getStatus: () => status.promise });
const { layer } = await startLayer(t, { source, view: true });
assert.equal(source.calls.tiles.length, 0, 'nothing before the status');
status.resolve({ configured });
await settle();
if (configured) assert.ok(source.calls.tiles.length > 0, 'then coverage');
else {
assert.equal(
source.calls.tiles.length,
0,
'a key-less install never asks',
);
assert.deepEqual(
[layer.getStats().loadingLabel, layer.getStats().error],
['KEY REQUIRED', 'KEY REQUIRED'],
);
assert.equal(layer.getUIState().keyRequired, true);
assert.equal(layer.getUIState().keyRejected, false);
}
layer.destroy();
}
});
test('a key status that failed is asked again on the next switch-on', async (t) => {
let up = false;
let asked = 0;
const source = fakeMapillarySource({
hasToken: () => false,
getStatus: async () => {
asked++;
if (!up) throw new TypeError('fetch failed');
return { configured: true };
},
});
const { layer, viewer } = await startLayer(t, { source, view: true });
assert.equal(asked, 1, 'one check for init and the first switch-on');
assert.equal(layer.getUIState().keyRequired, true);
up = true; // the server is reachable again
layer.disable();
layer.enable(viewer);
await settle();
assert.equal(asked, 2);
assert.equal(layer.getUIState().keyRequired, false);
assert.ok(source.calls.tiles.length > 0, 'coverage loads');
// A known key is not asked again.
layer.disable();
layer.enable(viewer);
await settle();
assert.equal(asked, 2);
});
test('a key status that never answers gives up, so the next switch-on asks again', async (t) => {
// The check's timeout, fired when the test says.
const timeouts = [];
t.mock.method(AbortSignal, 'timeout', (ms) => {
const controller = new AbortController();
timeouts.push({ ms, fire: () => controller.abort(new Error('timed out')) });
return controller.signal;
});
let asked = 0;
const source = fakeMapillarySource({
hasToken: () => false,
getStatus: ({ signal } = {}) =>
new Promise((_, reject) => {
asked++;
signal?.addEventListener('abort', () => reject(signal.reason));
}),
});
const { layer, viewer } = await startLayer(t, { source, view: true });
assert.equal(asked, 1);
assert.equal(timeouts[0].ms, 10_000, 'the check can be cut short');
assert.equal(layer.getUIState().coverage.loading, true, 'LOADING meanwhile');
timeouts[0].fire();
await settle();
assert.equal(layer.getUIState().coverage.loading, false);
assert.equal(layer.getUIState().keyRequired, true, 'no token: KEY REQUIRED');
layer.disable();
layer.enable(viewer);
assert.equal(asked, 2, 'asked again, not stuck on the first');
});
test('a rejected key gates the layer as KEY REJECTED until it goes off', async (t) => {
const rejected = Object.assign(new Error('Mapillary rejected the token'), {
keyRejected: true,
});
const source = fakeMapillarySource({
getTile: async () => {
throw rejected;
},
});
const { layer, viewer } = await startLayer(t, { source, view: true });
await settle();
const stats = layer.getStats();
assert.equal(stats.keyRequired, true);
assert.equal(stats.loadingLabel, 'KEY REJECTED');
assert.match(stats.error, /rejected MAPILLARY_CLIENT_TOKEN/);
const ui = layer.getUIState();
assert.deepEqual([ui.keyRequired, ui.keyRejected], [true, true]);
layer.disable();
layer.enable(viewer);
assert.equal(layer.getUIState().keyRejected, false, 'asked again once on');
});
test('switching Mapillary off clears coverage and closes the image it shows', async (t) => {
const { layer, photoViewer } = await startLayer(t);
layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true);
assert.deepEqual(photoViewer.calls.open, ['img1']);
assert.equal(layer.getUIState().street.open, true);
layer.setParams({ mapillary: false });
assert.equal(photoViewer.calls.unmount, 1, 'its viewer is released');
assert.equal(layer.getUIState().street.open, false);
assert.equal(layer.getUIState().providers[0].on, false);
});
test('a sequence that fails to load reports it apart from the photo, and the error goes with it', async (t) => {
const source = fakeMapillarySource({
getSequenceImages: async () => {
throw new Error('Sequence images unavailable');
},
});
// The photo's pose names its sequence, which opening selects.
const photoViewer = fakePhotoViewer({
pose: { position: { lon: -121.49, lat: 38.58 }, sequenceId: 'seq-1' },
});
const { layer } = await startLayer(t, { source, photoViewer });
layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true);
await settle();
let ui = layer.getUIState();
assert.equal(ui.sequence.error, 'Sequence images unavailable');
assert.equal(ui.street.error, null, 'the photo itself is fine');
layer.closeViewer();
ui = layer.getUIState();
assert.equal(ui.sequence.error, null, 'withdrawn with the sequence');
});
test('closing the photo, switching the layer off or destroying it stops the framing flight', async (t) => {
const { layer, viewer } = await startLayer(t, {
photoViewer: posingViewer(),
});
layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true);
assert.equal(viewer.flights.started, 1, 'the photo is framed');
layer.closeViewer();
assert.equal(viewer.flights.cancelled, 1, 'closed');
await layer.openImage('mapillary', 'img2');
layer.disable();
assert.equal(viewer.flights.cancelled, 2, 'layer off');
layer.enable(viewer);
await layer.openImage('mapillary', 'img3');
layer.destroy();
assert.equal(viewer.flights.cancelled, 3, 'destroyed');
});
test('setParams takes "any date" (0 days) and the switch over the current values (share-link defaults)', async (t) => {
const { layer } = await startLayer(t);
assert.deepEqual(layer.getParams(), {
mapillary: true,
pano: 'all',
sinceDays: 0,
});
layer.setParams({ pano: 'flat', sinceDays: 365 });
assert.equal(layer.getParams().sinceDays, 365);
layer.setParams({ sinceDays: 0 });
assert.deepEqual(layer.getParams(), {
mapillary: true,
pano: 'flat',
sinceDays: 0,
});
layer.setParams({ mapillary: false });
assert.equal(layer.getParams().mapillary, false);
});
test('setParams ignores unknown keys and malformed values', async (t) => {
const { layer } = await startLayer(t);
layer.setParams({ pano: 'pano', sinceDays: 730 });
layer.setParams({ mapillary: '0', kartaview: true, pano: 'weird', x: 1 });
layer.setParams({ sinceDays: -3 });
layer.setParams(null);
assert.deepEqual(layer.getParams(), {
mapillary: true,
pano: 'pano',
sinceDays: 730,
});
});
test('switching Mapillary on while the layer is off draws nothing', async (t) => {
const { layer, viewer, source, photoViewer } = await startLayer(t, {
view: true,
enable: false,
});
layer.attachViewerHost({});
layer.setParams({ mapillary: false });
layer.setParams({ mapillary: true });
await settle();
assert.equal(source.calls.tiles.length, 0, 'no coverage drawn');
assert.deepEqual(viewer.credits, [], 'no credit shown');
assert.equal(photoViewer.calls.mount, 0, 'no viewer stood up');
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
// Enabling the layer is what draws it.
layer.enable(viewer);
await settle();
assert.ok(source.calls.tiles.length > 0);
assert.equal(viewer.credits.length, 1);
});
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
const { layer, viewer } = await startLayer(t, { providers: [] });
assert.deepEqual(layer.providerIds, []);
assert.deepEqual(layer.getUIState().providers, []);
assert.equal(layer.getUIState().keyRequired, false);
assert.match(
layer.getUIState().coverage.hint,
/No street-level imagery providers/,
);
layer.disable();
layer.enable(viewer);
assert.deepEqual(layer.getUIState().providers, []);
});
// ── Several providers: one source at a time, Google 3D, nearest lookups ──
/**
* A stand-in Cesium viewer the layer can be enabled on: a canvas for the
* click handler, camera events, a primitive list and a credit display.
@@ -55,8 +352,11 @@ function fakeViewer() {
},
creditDisplay: {
addStaticCredit: (credit) => credits.push(credit),
removeStaticCredit: (credit) =>
credits.splice(credits.indexOf(credit), 1),
removeStaticCredit: (credit) => {
// Cesium ignores a credit it does not hold.
const at = credits.indexOf(credit);
if (at !== -1) credits.splice(at, 1);
},
},
};
}
@@ -81,19 +381,6 @@ async function enabledLayer(t, providers = [fakeProvider()]) {
return { layer, viewer };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
function deferred() {
let resolve;
let reject;
const promise = new Promise((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
/** A provider whose viewer reports a pose for every image it opens. */
function posingProvider(options) {
const provider = fakeProvider(options);
const create = provider.create;
@@ -148,35 +435,6 @@ function fakeMapStack(initial) {
listenerCount: () => listeners.size,
};
}
test('FOLLOW is available only while the Google 3D map stack is active', () => {
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
assert.equal(
layer.getUIState().street.followAvailable,
false,
'unknown stack: off',
);
const stack = fakeMapStack('esri-imagery');
layer.attachMapStackController(stack);
assert.equal(layer.getUIState().street.followAvailable, false);
layer.setFollow(true);
assert.equal(layer.getUIState().street.follow, false, 'refused on Esri');
stack.switchTo('photoreal');
assert.equal(layer.getUIState().street.followAvailable, true);
layer.setFollow(true);
assert.equal(layer.getUIState().street.follow, true);
stack.switchTo('osm');
assert.equal(layer.getUIState().street.followAvailable, false);
assert.equal(
layer.getUIState().street.follow,
false,
'leaving Google 3D stops following',
);
});
test('the layer lets go of the map stack when destroyed or re-attached', () => {
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
const first = fakeMapStack('photoreal');
@@ -190,60 +448,6 @@ test('the layer lets go of the map stack when destroyed or re-attached', () => {
assert.equal(second.listenerCount(), 0);
});
test('the CC BY-SA credit shows while a provider is on and goes with it', async (t) => {
const { layer, viewer } = await enabledLayer(t);
const shown = () => viewer.credits.map((credit) => credit.html);
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on activate');
assert.match(shown()[0], /CC BY-SA 4\.0/);
layer.setProviderEnabled('mapillary', false);
assert.deepEqual(shown(), [], 'hidden with the provider');
layer.setProviderEnabled('mapillary', true);
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]);
layer.disable();
assert.deepEqual(shown(), [], 'every credit goes with the layer');
});
test('getStats tells a rejected key from a missing one', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
provider.stats.loading = true;
assert.equal(layer.getStats().loadingLabel, 'loading coverage...');
assert.equal(layer.getStats().keyRequired, false);
provider.stats.keyRequired = true;
assert.deepEqual(
[layer.getStats().loadingLabel, layer.getStats().error],
['KEY REQUIRED', 'KEY REQUIRED'],
);
// A rejected key gates the provider exactly like a missing one.
provider.stats.keyRequired = false;
provider.stats.keyRejected = true;
provider.stats.error = 'Mapillary rejected MAPILLARY_CLIENT_TOKEN';
const stats = layer.getStats();
assert.equal(stats.keyRequired, true);
assert.equal(stats.loadingLabel, 'KEY REJECTED');
assert.equal(stats.error, 'Mapillary rejected MAPILLARY_CLIENT_TOKEN');
const ui = layer.getUIState();
assert.equal(ui.keyRequired, true);
assert.equal(ui.keyRejected, true);
assert.equal(ui.providers[0].keyRequired, true);
});
test('switching a provider off deactivates it and closes the image it shows', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true);
assert.deepEqual(provider.calls.open, ['img1']);
assert.equal(layer.getUIState().street.open, true);
layer.setProviderEnabled('mapillary', false);
assert.equal(provider.calls.deactivate, 1);
assert.equal(provider.calls.unmount, 1, 'its viewer is released');
assert.equal(layer.getUIState().street.open, false);
assert.equal(layer.getUIState().street.providerId, null);
});
test('openNearest without the panel reports it instead of loading forever', async (t) => {
const provider = fakeProvider({ nearestImage: async () => 'img1' });
const { layer } = await enabledLayer(t, [provider]);
@@ -299,40 +503,6 @@ test('a provider withdraws only the error it reported', async (t) => {
actions.reportError(null);
assert.match(layer.getUIState().street.error, /Open the Street Level panel/);
});
test('closing the photo stops the globe flying to it', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true);
assert.equal(viewer.flights.started, 1, 'the photo is framed');
layer.closeViewer();
assert.equal(viewer.flights.cancelled, 1);
});
test('switching the layer off or destroying it stops the framing flight', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
layer.attachViewerHost({});
await layer.openImage('mapillary', 'img1');
layer.disable();
assert.equal(viewer.flights.cancelled, 1, 'layer off');
layer.enable(viewer);
await layer.openImage('mapillary', 'img2');
layer.destroy();
assert.equal(viewer.flights.cancelled, 2, 'destroyed');
});
test('closing the photo leaves a newer navigation flight alone', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
layer.attachViewerHost({});
await layer.openImage('mapillary', 'img1');
assert.equal(viewer.flights.started, 1, 'the photo is being framed');
// A search result flies the globe elsewhere before the framing lands.
viewer.camera.flyToBoundingSphere(null, {});
layer.closeViewer();
assert.equal(viewer.flights.cancelled, 0, 'the search flight keeps going');
});
test('an older nearest lookup that answers late cannot replace a newer one', async (t) => {
const { lookups, nearestImage } = slowLookups();
const provider = fakeProvider({ nearestImage });
@@ -395,54 +565,6 @@ test('closing the viewer or switching the layer off retires a nearest lookup', a
assert.equal(lookups[1].signal?.aborted, true, 'layer off aborts it too');
});
test('the Mapillary provider hands the lookup signal to its source', async () => {
const requests = [];
const source = {
hasToken: () => true,
getStatus: async () => ({ configured: true }),
getTile: async () => new Uint8Array(0),
getSequenceImages: async () => [],
nearestImages: async (query, options) => {
requests.push(options);
return [{ id: 7, is_pano: false, captured_at: 10 }];
},
};
const instance = createMapillaryProvider({ source }).create({
services: {},
getFilter: () => ({ pano: 'all', sinceMs: null }),
isActive: () => true,
notify() {},
actions: { openImage() {}, reportError() {} },
});
const { signal } = new AbortController();
assert.equal(
await instance.nearestImage({ lat: 1, lon: 2 }, { signal }),
'7',
);
assert.equal(requests[0]?.signal, signal);
});
test('setParams takes "any date" (0 days) and a provider switch over the current values (share-link defaults)', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
layer.setParams({ pano: 'flat', sinceDays: 365 });
assert.equal(layer.getParams().sinceDays, 365);
layer.setParams({ sinceDays: 0 });
assert.deepEqual(layer.getParams(), {
mapillary: true,
pano: 'flat',
sinceDays: 0,
});
assert.equal(
provider.filters.at(-1).sinceMs,
null,
'providers drop the cut-off',
);
layer.setParams({ mapillary: false });
assert.equal(layer.getParams().mapillary, false);
});
/** A layer initialised on a stand-in viewer but never enabled. */
function initialisedLayer(t, providers) {
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
@@ -635,7 +757,7 @@ test('one imagery source at a time: lighting one darkens the other, either way',
assert.deepEqual(lit(), []);
});
test('Street View appears only on the Google 3D map: chip, legend, credit, source and images', async (t) => {
test('Street View appears only on the Google 3D map: chip, credit, source and images', async (t) => {
const { layer, viewer, stack } = await withStreetView(t, {
mapStack: 'esri-imagery',
});
@@ -646,7 +768,6 @@ test('Street View appears only on the Google 3D map: chip, legend, credit, sourc
const credited = () =>
viewer.credits.some((credit) => /Street View imagery/.test(credit.html));
assert.deepEqual(listed(), ['mapillary'], 'no Street View chip');
assert.ok(!ui().legend.some((item) => item.key === 'google'));
assert.equal(credited(), false, 'no Google credit on another map');
assert.equal(layer.source, 'Mapillary');
assert.equal(layer.getStats().source, 'Mapillary');
@@ -668,6 +789,62 @@ test('Street View appears only on the Google 3D map: chip, legend, credit, sourc
assert.deepEqual(listed(), ['mapillary']);
});
test('under Google 3D the ground is measured again once its tiles load, without a move', async (t) => {
const saved = globalThis.document;
globalThis.document = new EventTarget();
t.after(() => {
globalThis.document = saved;
});
t.mock.timers.enable({ apis: ['setTimeout'] });
const viewer = fakeViewer();
// 4 km up the ellipsoid, 2 km over a city whose tiles are still loading.
let ground;
viewer.camera.positionCartographic = { height: 4000 };
viewer.scene.globe = { show: false };
viewer.scene.sampleHeightSupported = true;
viewer.scene.sampleHeight = () => ground;
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
layer.init(viewer);
layer.enable(viewer);
t.after(() => layer.destroy());
assert.equal(layer.getUIState().groundClickReady, false);
ground = 2000;
t.mock.timers.tick(1000);
assert.equal(layer.getUIState().groundClickReady, true);
});
test('an unknown ground is measured only a few times, and not after the layer is off', (t) => {
const saved = globalThis.document;
globalThis.document = new EventTarget();
t.after(() => {
globalThis.document = saved;
});
t.mock.timers.enable({ apis: ['setTimeout'] });
const viewer = fakeViewer();
let samples = 0;
viewer.camera.positionCartographic = { height: 4000 };
viewer.scene.globe = { show: false };
viewer.scene.sampleHeightSupported = true;
viewer.scene.sampleHeight = () => {
samples++;
return undefined;
};
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
layer.init(viewer);
layer.enable(viewer);
t.after(() => layer.destroy());
t.mock.timers.tick(60_000);
const measured = samples;
assert.ok(measured > 1 && measured <= 12, `measured ${measured} times`);
viewer.camera.moveEnd.raiseEvent();
layer.disable();
const atDisable = samples;
t.mock.timers.tick(60_000);
assert.equal(samples, atDisable, 'no measuring once the layer is off');
});
test('the UI state says when a click (or the button) can open Street View here', async (t) => {
const { layer } = await withStreetView(t);
assert.equal(typeof layer.getUIState().groundClickReady, 'boolean');
@@ -825,20 +1002,6 @@ test('with no Mapillary token, the layer points at Street View, on Google 3D onl
assert.equal(layer.getStats().keyRequired, false);
});
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
const { layer, viewer } = await enabledLayer(t, []);
assert.deepEqual(layer.providerIds, []);
assert.deepEqual(layer.getUIState().providers, []);
assert.equal(layer.getUIState().keyRequired, false);
assert.match(
layer.getUIState().coverage.hint,
/No street-level imagery providers/,
);
layer.disable();
layer.enable(viewer);
assert.deepEqual(layer.getUIState().providers, []);
});
test('a failed lookup’s error goes when another source is lit; a provider can open an image without framing', async (t) => {
const failing = fakeProvider({
nearestImage: async () => {
@@ -890,7 +1053,7 @@ test('a provider’s openNearest (a line click) asks only that provider and leav
assert.equal(viewer.flights.started, 0, 'the user picked the spot');
});
test('KartaView through the core: its own swatch without Selected, its lines counted as streets', async (t) => {
test('KartaView through the core: its lines counted as streets', async (t) => {
const kartaView = createKartaViewProvider({
source: {
getTile: async () => ({ lines: [] }),
@@ -906,15 +1069,7 @@ test('KartaView through the core: its own swatch without Selected, its lines cou
layer.setProviderEnabled('kartaview', true);
await settle();
const ui = layer.getUIState();
assert.deepEqual(
ui.legend.map((entry) => entry.key),
['kartaview'],
);
assert.equal(ui.coverage.label?.noun, 'streets');
assert.equal(
ui.providers.find((p) => p.id === 'kartaview').highlightsSequence,
false,
);
});
test('switching source by params (the chip path) stops a running lookup and clears its spinner and error', async (t) => {
@@ -962,3 +1117,13 @@ test('switching source by params clears a failed lookup’s error; reapplying th
second.setParams({ mapillary: false, other: true });
assert.equal(second.getUIState().street.error, kept);
});
test('the panel says why a lit Street View draws nothing off Google 3D', async (t) => {
const { presentStreetLevelPanel } =
await import('../../ui/streetLevelPresentation.js');
const { layer, stack } = await withStreetView(t);
layer.setProviderEnabled('google', true);
stack.switchTo('esri-imagery');
const view = presentStreetLevelPanel(layer.getUIState());
assert.match(view.meta, /Google Street View needs the Google 3D map/);
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4 -50
View File
@@ -1,7 +1,6 @@
import * as Cesium from 'cesium';
import { positionMarkerGlyph, viewConeMarkerGlyph } from './glyphs.js';
import { COLORS, POSITION_PICK_ID } from './policy.js';
import { refineHeights } from './groundCast.js';
import { createHorizonCull } from './view.js';
const SPRITE_ID = 'street-level:marker';
@@ -22,10 +21,9 @@ function markerLook(style) {
};
}
/** On-globe marker for the open image; in terrain mode it stands on bare earth. */
export function createMarker({ state, parts }) {
/** On-globe marker for the open image, clamped to the ground. */
export function createMarker({ state }) {
const { render, sprites } = state.services;
let last = null;
function requestRender() {
render?.governorRequestRender?.('street-level-marker');
@@ -47,38 +45,10 @@ export function createMarker({ state, parts }) {
sprites?.registerSpriteCollection?.(SPRITE_ID, state.marker.collection);
}
/**
* Terrain-mode height (bare earth refined by mesh samples), or null to
* clamp. Requests a missing terrain cell or mesh sample as a side effect.
*/
function castHeight(position) {
const ground = parts?.groundCaster;
if (state.surface !== 'terrain' || !ground) return null;
const { lon, lat } = position;
const dem = ground.groundAt(lon, lat);
if (dem === null) {
ground.prepare([[lon, lat]]).then((ready) => {
if (ready && last?.position === position)
set(position, last.bearing, last.style);
});
return null;
}
const mesh = parts.meshSampler?.meshAt(lon, lat);
if (mesh === undefined) parts.meshSampler?.request([[lon, lat]]);
return refineHeights([{ dem, mesh }])[0];
}
// A new mesh sample may move the marker onto (or off) the road surface.
parts?.meshSampler?.onSampled(() => {
if (last && state.surface === 'terrain')
set(last.position, last.bearing, last.style);
});
/** Move (or create) the marker; null removes it. `style`: the provider's `marker`. */
function set(position, bearing, style = null) {
const collection = state.marker.collection;
if (!collection) return;
last = position ? { position, bearing, style } : null;
if (!position) {
collection.removeAll();
state.marker.billboard = null;
@@ -86,16 +56,7 @@ export function createMarker({ state, parts }) {
requestRender();
return;
}
const height = castHeight(position);
const cartesian = Cesium.Cartesian3.fromDegrees(
position.lon,
position.lat,
height ?? 0,
);
const heightReference =
height === null
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE;
const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat);
const look = markerLook(style);
if (state.marker.billboard && state.marker.look !== look.key) {
collection.remove?.(state.marker.billboard);
@@ -111,12 +72,11 @@ export function createMarker({ state, parts }) {
width: look.size,
height: look.size,
alignedAxis: Cesium.Cartesian3.UNIT_Z,
heightReference,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
});
} else {
state.marker.billboard.heightReference = heightReference;
state.marker.billboard.position = cartesian;
}
state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0);
@@ -139,17 +99,11 @@ export function createMarker({ state, parts }) {
state.marker.billboard = null;
}
/** Re-place the marker after the surface mode changes. */
function setSurface() {
if (last) set(last.position, last.bearing, last.style);
}
return {
ensure,
set,
clear: () => set(null),
setVisible,
setSurface,
destroy,
};
}
+11 -114
View File
@@ -2,17 +2,9 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createMarker } from './marker.js';
import { GROUND_CAST_LIFT_M, MESH_LIFT_M } from './groundCast.js';
// The marker glyph draws on a canvas; Node has none, so drawing is a no-op.
const noop = () => {};
const context2d = new Proxy({}, { get: () => noop, set: () => true });
globalThis.document ??= {
createElement: () => ({ getContext: () => context2d }),
};
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */
function setup({ parts = {}, surface = 'draped' } = {}) {
function setup() {
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
const preRender = new Set();
const viewer = {
@@ -34,7 +26,6 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
const state = {
services: {},
viewer,
surface,
marker: {
// Stands in for the billboard collection; ensure() keeps it.
collection: {
@@ -46,7 +37,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
billboard: null,
},
};
const marker = createMarker({ state, parts });
const marker = createMarker({ state });
const frame = () => {
for (const listener of [...preRender]) listener();
};
@@ -79,114 +70,23 @@ test('destroying the marker stops the horizon cull', () => {
assert.equal(preRender.size, 0);
});
/**
* Bare earth from a ground caster (null until its cell is fetched) and the
* sampled Google 3D surface from a mesh sampler (undefined until sampled).
*/
function terrainParts({ dem = null, mesh } = {}) {
const ground = { dem, prepares: [] };
const sampler = { mesh, requests: [], listeners: [] };
return {
ground,
sampler,
parts: {
groundCaster: {
groundAt: () => ground.dem,
prepare(points) {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
ground.prepares.push({ points, resolve });
return promise;
},
},
meshSampler: {
meshAt: () => sampler.mesh,
request: (points) => sampler.requests.push(points),
onSampled: (listener) => sampler.listeners.push(listener),
},
},
};
}
const heightOf = (billboard) =>
Cesium.Cartographic.fromCartesian(billboard.position).height;
const SPOT = { lon: -121.49, lat: 38.58 };
test('in terrain mode the marker stands on the sampled road surface (mesh near bare earth)', () => {
const { parts } = terrainParts({ dem: 10, mesh: 11.5 });
const { state, marker } = setup({ parts, surface: 'terrain' });
marker.set(SPOT, 45);
const billboard = state.marker.billboard;
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
assert.ok(
Math.abs(heightOf(billboard) - (11.5 + MESH_LIFT_M)) < 1e-3,
`height ${heightOf(billboard)}`,
);
});
test('in terrain mode without a mesh sample the marker stands on bare earth and asks for one', () => {
const { parts, sampler } = terrainParts({ dem: 10 });
const { state, marker } = setup({ parts, surface: 'terrain' });
marker.set(SPOT, 45);
const billboard = state.marker.billboard;
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
assert.ok(
Math.abs(heightOf(billboard) - (10 + GROUND_CAST_LIFT_M)) < 1e-3,
`height ${heightOf(billboard)}`,
);
assert.deepEqual(sampler.requests, [[[SPOT.lon, SPOT.lat]]]);
// The sample lands: the marker moves onto the road surface.
sampler.mesh = 10.5;
for (const listener of sampler.listeners) listener();
assert.ok(Math.abs(heightOf(billboard) - (10.5 + MESH_LIFT_M)) < 1e-3);
});
test('in terrain mode with no ground yet the marker clamps, then stands up once the ground arrives', async () => {
const { parts, ground } = terrainParts({ mesh: 11 });
const { state, marker } = setup({ parts, surface: 'terrain' });
test('the marker is clamped to the ground and turned to the bearing', () => {
const { state, marker } = setup();
marker.set(SPOT, 45);
const billboard = state.marker.billboard;
assert.equal(
billboard.heightReference,
Cesium.HeightReference.CLAMP_TO_GROUND,
);
assert.deepEqual(ground.prepares[0].points, [[SPOT.lon, SPOT.lat]]);
ground.dem = 10;
ground.prepares[0].resolve(true);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
assert.ok(Math.abs(heightOf(billboard) - (11 + MESH_LIFT_M)) < 1e-3);
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45), 'bearing kept');
});
test('ground that arrives after the marker moved on does not move it back', async () => {
const { parts, ground } = terrainParts();
const { state, marker } = setup({ parts, surface: 'terrain' });
marker.set(SPOT, 0);
const elsewhere = { lon: -121.5, lat: 38.6 };
marker.set(elsewhere, 0);
ground.dem = 10;
ground.prepares[0].resolve(true);
await new Promise((resolve) => setTimeout(resolve, 0));
const placed = Cesium.Cartographic.fromCartesian(
state.marker.billboard.position,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(placed.longitude) - elsewhere.lon) < 1e-9,
);
});
test('draped mode clamps the marker to the globe whatever the ground says', () => {
const { parts, ground } = terrainParts({ dem: 10, mesh: 11 });
const { state, marker } = setup({ parts, surface: 'draped' });
marker.set(SPOT, 0);
assert.equal(
state.marker.billboard.heightReference,
Cesium.HeightReference.CLAMP_TO_GROUND,
);
assert.equal(ground.prepares.length, 0);
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45));
// Moving it keeps the same billboard.
marker.set({ lon: -121.5, lat: 38.6 }, 90);
assert.equal(state.marker.billboard, billboard);
const placed = Cesium.Cartographic.fromCartesian(billboard.position);
assert.ok(Math.abs(Cesium.Math.toDegrees(placed.longitude) + 121.5) < 1e-9);
assert.equal(billboard.rotation, -Cesium.Math.toRadians(90));
});
test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pin', () => {
@@ -205,9 +105,6 @@ test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pi
// Dragging the panorama keeps the look and the billboard.
marker.set({ lon: -121.49, lat: 38.58 }, 90, streetView);
assert.equal(state.marker.billboard, cone);
// Re-placed for a surface change: still the cone.
marker.setSurface();
assert.equal(state.marker.billboard, cone);
// A Mapillary photo next (its provider has no marker): the pin.
marker.set({ lon: -121.49, lat: 38.58 }, 90, undefined);
assert.equal(state.marker.billboard.imageId, 'sl-position:pin');
-371
View File
@@ -1,371 +0,0 @@
import * as Cesium from 'cesium';
import { metresBetween, whenIdle } from './view.js';
/**
* Google 3D surface heights for ground casting (groundCast.js refineHeights):
* one cached `scene.sampleHeight` probe per ~11 m cell. Each probe renders a
* pick pass (1–3 ms), so probes run in budgeted idle slices, nearest the
* camera first and only within MESH_SAMPLE_RADIUS_M. Like the application's
* mesh floor, a probe waits for the visible tileset to finish streaming and a
* sample counts only within the shared window around a real bare-earth height;
* anything else is a miss, retried later. Probes hit 3D tilesets only.
*/
/** Cell size, in degrees (~11 m): samples are shared within a cell. */
export const MESH_CELL_DEG = 0.0001;
/** Cells farther than this from the point under the camera are not probed. */
export const MESH_SAMPLE_RADIUS_M = 900;
/** Main-thread time per idle slice, taking requests in and ranking included. */
export const MESH_SAMPLE_BUDGET_MS = 6;
/** The queue is re-ranked once the camera has moved this far, in metres. */
export const MESH_RERANK_M = 100;
/** A cell whose probe missed is tried again after this long. */
export const MESH_MISS_RETRY_MS = 8000;
/** While the visible tileset is streaming, the next probe waits this long. */
export const MESH_STREAMING_WAIT_MS = 500;
/**
* A sampled cell is probed again once the camera is half as far from it as at
* its last probe (finer tiles have loaded by then), down to this distance:
* at most a handful of probes per cell.
*/
export const MESH_REFRESH_MIN_M = 40;
/** A refreshed sample that moved less than this is not redrawn. */
const MESH_REFRESH_CHANGE_M = 0.5;
/** Cached cells (and remembered misses) before they are dropped and refilled. */
const MESH_CACHE_MAX = 80_000;
/** Listeners hear about new samples at most this often. */
const MESH_NOTIFY_MS = 700;
const cellOf = (value) => Math.round(value / MESH_CELL_DEG);
/** Numeric key of the cell holding a point (exact within 2^53; no per-point string). */
export const meshCellKey = (lon, lat) =>
(cellOf(lon) + 1_800_001) * 2_000_000 + cellOf(lat) + 1_000_000;
/** Add a queued cell to a min-heap on `distance`. */
function heapPush(heap, cell) {
let i = heap.push(cell) - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (heap[parent].distance <= cell.distance) break;
heap[i] = heap[parent];
i = parent;
}
heap[i] = cell;
}
/** Restore the heap below `i` after its cell was replaced. */
function siftDown(heap, i) {
const cell = heap[i];
const n = heap.length;
for (;;) {
let child = 2 * i + 1;
if (child >= n) break;
if (child + 1 < n && heap[child + 1].distance < heap[child].distance)
child++;
if (heap[child].distance >= cell.distance) break;
heap[i] = heap[child];
i = child;
}
heap[i] = cell;
}
function heapPop(heap) {
const top = heap[0];
const last = heap.pop();
if (heap.length) {
heap[0] = last;
siftDown(heap, 0);
}
return top;
}
/** Every top-level primitive that is not a 3D tileset: what a probe skips. */
function overlays(scene) {
const out = [];
const primitives = scene.primitives;
for (let i = 0; i < primitives.length; i++) {
const primitive = primitives.get(i);
if (!(primitive instanceof Cesium.Cesium3DTileset)) out.push(primitive);
}
return out;
}
/**
* `groundAt(lon, lat)` is the bare-earth height or null, `withinPrior(height,
* prior)` the application's mesh window, and `tilesReady(scene)` whether the
* visible tileset has finished streaming.
* @param {{getViewer: () => object|null, groundAt?: Function, withinPrior?: Function, tilesReady?: Function, budgetMs?: number, radiusM?: number, cacheMax?: number, now?: () => number}} options
*/
export function createMeshSampler({
getViewer,
groundAt = null,
withinPrior = null,
tilesReady = null,
budgetMs = MESH_SAMPLE_BUDGET_MS,
radiusM = MESH_SAMPLE_RADIUS_M,
cacheMax = MESH_CACHE_MAX,
now = () => performance.now(),
}) {
/** cell key → sampled mesh height (ellipsoidal metres). */
const heights = new Map();
/** cell key → camera distance (m) at the cell's last probe. */
const probedFrom = new Map();
/** cell key → retry time for probes that hit nothing; bounded like the height cache. */
const misses = new Map();
/** cell key → queued cell, so a cell is queued once. */
const wanted = new Map();
/** Queued {key, lon, lat, distance} cells, a min-heap on distance from `rankedFrom`. */
let queue = [];
/** Requests not yet taken in: {points, next, centre} (centre: the camera then). */
let intake = [];
/** Where the camera was when the queue was ranked. */
let rankedFrom = null;
/** Running estimate of one probe's cost, so a slice stops before overrunning. */
let probeMs = 1;
const listeners = new Set();
let enabled = false;
let running = false;
let fresh = [];
let notifyTimer = null;
/** The sampled mesh height for a point, or undefined when not sampled. */
function meshAt(lon, lat) {
return heights.get(meshCellKey(lon, lat));
}
/** The point under the camera, with the camera's height, or null. */
function cameraCentre() {
const carto = getViewer()?.camera?.positionCartographic;
if (!carto) return null;
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
height: carto.height,
};
}
/** Camera distance (m) to a cell at `height`, from its ground distance. */
function cameraDistance(ground, centre, height) {
const above = (centre.height ?? 0) - (height ?? 0);
return Number.isFinite(above)
? Math.hypot(ground, Math.max(0, above))
: ground;
}
/** A sampled cell is probed again once the camera is half as far as before. */
function dueForRefresh(key, distance) {
const last = probedFrom.get(key);
return last > MESH_REFRESH_MIN_M && distance < last / 2;
}
/** A probe result the application would accept, or undefined. */
function validated(height, lon, lat) {
if (!Number.isFinite(height)) return undefined;
if (!groundAt) return height;
const prior = groundAt(lon, lat);
if (!Number.isFinite(prior)) return undefined;
return !withinPrior || withinPrior(height, prior) ? height : undefined;
}
/**
* Ask for the cells under [lon, lat] points; the list is read later, so do
* not mutate it. Out-of-range cells are dropped; callers ask again on move.
*/
function request(points) {
if (!enabled || !points?.length) return;
const centre = cameraCentre();
if (!centre) return;
intake.push({ points, next: 0, centre });
schedule();
}
/** Queue requested cells, oldest request first, until the slice reaches `until`. */
function takeIn(until) {
const time = Date.now();
while (intake.length) {
const pending = intake[0];
const { points, centre } = pending;
while (pending.next < points.length) {
if ((pending.next & 255) === 255 && now() > until) return;
const [lon, lat] = points[pending.next++];
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
const key = meshCellKey(lon, lat);
if (wanted.has(key)) continue;
const cell = {
key,
lon: cellOf(lon) * MESH_CELL_DEG,
lat: cellOf(lat) * MESH_CELL_DEG,
distance: 0,
};
const ground = metresBetween(cell, centre);
if (ground > radiusM) continue;
if (
heights.has(key) &&
!dueForRefresh(key, cameraDistance(ground, centre, heights.get(key)))
)
continue;
const retryAt = misses.get(key);
if (retryAt !== undefined) {
if (retryAt > time) continue;
misses.delete(key);
}
cell.distance = metresBetween(cell, rankedFrom);
wanted.set(key, cell);
heapPush(queue, cell);
}
intake.shift();
}
}
/**
* Rank the queue from where the camera is now, dropping cells it has left
* out of range: one linear heapify, run only after the camera has moved.
*/
function rerank(centre) {
rankedFrom = centre;
const kept = [];
for (const cell of queue) {
cell.distance = metresBetween(cell, centre);
if (cell.distance > radiusM) wanted.delete(cell.key);
else kept.push(cell);
}
queue = kept;
for (let i = (queue.length >> 1) - 1; i >= 0; i--) siftDown(queue, i);
}
function clearQueue() {
intake = [];
wanted.clear();
queue = [];
rankedFrom = null;
}
function schedule() {
if (running || !enabled || !(queue.length || intake.length)) return;
running = true;
whenIdle(step, 300);
}
function emit() {
notifyTimer = null;
const batch = fresh;
fresh = [];
for (const listener of [...listeners]) {
try {
listener(batch);
} catch (error) {
console.warn('[Data:StreetLevel] mesh listener error:', error);
}
}
}
function step(deadline) {
running = false;
// The slice starts now: taking requests in and ranking count too.
const started = now();
const until =
started +
Math.min(budgetMs, Math.max(1, deadline?.timeRemaining?.() ?? budgetMs));
const scene = getViewer()?.scene;
const centre = cameraCentre();
if (!enabled || !scene?.sampleHeightSupported || !centre) return;
// Mid-stream tiles answer with coarse heights: wait for the stream.
if (tilesReady && !tilesReady(scene)) {
running = true;
setTimeout(() => {
running = false;
schedule();
}, MESH_STREAMING_WAIT_MS);
return;
}
// Nearest first, ranked from where the camera was; out-of-range cells
// are dropped (they stay bare earth).
let worked = false;
if (!rankedFrom || metresBetween(rankedFrom, centre) > MESH_RERANK_M) {
rerank(centre);
worked = true;
}
if (intake.length) {
// Leave room for a probe, so a stream of requests cannot starve them.
takeIn(until - probeMs);
worked = true;
}
const exclude = overlays(scene);
let probed = 0;
while (queue.length) {
// Stop before a probe would overrun the slice. A slice that did
// nothing else always probes once, so the queue drains.
if ((probed || worked) && now() + probeMs > until) break;
const { key, lon, lat } = heapPop(queue);
wanted.delete(key);
// Ranked from up to MESH_RERANK_M away: check the range from here.
const ground = metresBetween({ lon, lat }, centre);
if (ground > radiusM) continue;
probed++;
const probeStarted = now();
let height;
try {
height = scene.sampleHeight(
Cesium.Cartographic.fromDegrees(lon, lat),
exclude,
);
} catch {
height = undefined;
}
probeMs = probeMs * 0.75 + (now() - probeStarted) * 0.25;
height = validated(height, lon, lat);
const previous = heights.get(key);
if (height === undefined) {
// A miss, or a failed refresh: the old sample (if any) and the
// distance it was taken from stay, and the probe is retried after
// the same cooldown.
if (misses.size >= cacheMax) misses.clear();
misses.set(key, Date.now() + MESH_MISS_RETRY_MS);
continue;
}
if (previous === undefined && heights.size >= cacheMax) forget();
misses.delete(key);
heights.set(key, height);
probedFrom.set(key, cameraDistance(ground, centre, height));
if (
previous === undefined ||
Math.abs(height - previous) >= MESH_REFRESH_CHANGE_M
)
fresh.push([lon, lat]);
}
if (fresh.length && !notifyTimer)
notifyTimer = setTimeout(emit, MESH_NOTIFY_MS);
schedule();
}
function forget() {
heights.clear();
probedFrom.clear();
misses.clear();
}
/** Probe only while overlays are cast (Google 3D at street zoom). */
function setEnabled(on) {
enabled = on === true;
if (!enabled) clearQueue();
else schedule();
}
/** Hear about newly sampled cells, as [lon, lat] cell centres. */
function onSampled(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
function destroy() {
enabled = false;
forget();
clearQueue();
listeners.clear();
clearTimeout(notifyTimer);
notifyTimer = null;
}
return { meshAt, request, setEnabled, onSampled, destroy };
}
-405
View File
@@ -1,405 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
MESH_CELL_DEG,
MESH_MISS_RETRY_MS,
MESH_REFRESH_MIN_M,
MESH_SAMPLE_BUDGET_MS,
MESH_STREAMING_WAIT_MS,
createMeshSampler,
} from './meshSampler.js';
import { meshFloorSampleWithinPrior } from '../../data/groundFloor.js';
import { metresBetween } from './view.js';
const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));
const RAD = Math.PI / 180;
/** A scene whose mesh is 100 m everywhere except where `holes` say. */
function fakeViewer({ holes = () => false } = {}) {
const tileset = Object.create(Cesium.Cesium3DTileset.prototype);
const overlay = { name: 'lines' };
const probes = [];
const scene = {
sampleHeightSupported: true,
primitives: {
length: 2,
get: (i) => [tileset, overlay][i],
},
sampleHeight(carto, exclude) {
const lon = carto.longitude / RAD;
const lat = carto.latitude / RAD;
probes.push({ lon, lat, exclude });
return holes(lon, lat) ? undefined : 100;
},
};
return {
probes,
overlay,
viewer: {
scene,
camera: {
positionCartographic: { longitude: 10 * RAD, latitude: 50 * RAD },
},
},
};
}
test('cells are probed once, nearest first, with overlays excluded', async () => {
const { viewer, probes, overlay } = fakeViewer();
const sampler = createMeshSampler({ getViewer: () => viewer });
sampler.setEnabled(true);
const heard = [];
sampler.onSampled((batch) => heard.push(batch.length));
sampler.request([
[10.004, 50],
[10.001, 50],
[10.00101, 50.00001], // same ~11 m cell as the previous point
]);
await settle(1000);
assert.equal(probes.length, 2);
assert.ok(probes[0].lon < probes[1].lon, 'nearest the camera first');
assert.deepEqual(probes[0].exclude, [overlay], 'only tilesets are hit');
assert.equal(sampler.meshAt(10.001, 50), 100);
assert.equal(sampler.meshAt(10.00101, 50.00001), 100);
assert.deepEqual(heard, [2]);
sampler.request([[10.001, 50]]);
await settle();
assert.equal(probes.length, 2, 'a sampled cell is not probed again');
sampler.destroy();
});
test('far cells are skipped, misses are not latched, and nothing runs while disabled', async () => {
const { viewer, probes } = fakeViewer({ holes: (lon) => lon > 10.0049 });
const sampler = createMeshSampler({ getViewer: () => viewer });
sampler.request([[10.001, 50]]);
await settle();
assert.equal(probes.length, 0, 'disabled: nothing queued');
sampler.setEnabled(true);
sampler.request([
[10.05, 50], // ~3.6 km away
[10.005, 50], // a hole: tiles not streamed
]);
await settle();
assert.equal(probes.length, 1);
assert.equal(sampler.meshAt(10.05, 50), undefined);
assert.equal(sampler.meshAt(10.005, 50), undefined);
assert.ok(Math.abs(probes[0].lon - 10.005) < MESH_CELL_DEG);
sampler.destroy();
});
/** Probes run on a 16 ms timer headless; with mocked timers, run them now. */
function probeNow(t) {
t.mock.timers.tick(20);
}
test('a missed cell is probed again after the retry window, not before', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, probes } = fakeViewer({ holes: () => true });
const sampler = createMeshSampler({ getViewer: () => viewer });
sampler.setEnabled(true);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 1, 'probed, and missed');
t.mock.timers.tick(MESH_MISS_RETRY_MS - 100);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 1, 'within the window: not probed');
t.mock.timers.tick(100 + 1);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 2, 'after the window: probed again');
sampler.destroy();
});
test('remembered misses are bounded like the samples', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, probes } = fakeViewer({ holes: () => true });
const sampler = createMeshSampler({ getViewer: () => viewer, cacheMax: 2 });
sampler.setEnabled(true);
for (const lon of [10.001, 10.002, 10.003]) {
sampler.request([[lon, 50]]);
probeNow(t);
}
assert.equal(probes.length, 3);
// The third miss overflowed the list, which was dropped: the first cell
// is asked about again rather than remembered forever.
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 4);
sampler.destroy();
});
test('destroy forgets the samples', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer } = fakeViewer();
const sampler = createMeshSampler({ getViewer: () => viewer });
sampler.setEnabled(true);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(sampler.meshAt(10.001, 50), 100);
sampler.destroy();
assert.equal(sampler.meshAt(10.001, 50), undefined);
});
/**
* A sampler over `fakeViewer` on a fake clock: each probe costs `probeMs`
* and each read of the camera `cameraMs` (work done before any probe).
*/
function clocked(t, { probeMs = 2, cameraMs = 0, holes } = {}) {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const fake = fakeViewer({ holes });
const clock = { now: 0 };
const { scene, camera } = fake.viewer;
const probe = scene.sampleHeight;
scene.sampleHeight = (...args) => {
clock.now += probeMs;
return probe(...args);
};
const position = { ...camera.positionCartographic };
Object.defineProperty(camera, 'positionCartographic', {
get: () => {
clock.now += cameraMs;
return position;
},
});
const sampler = createMeshSampler({
getViewer: () => fake.viewer,
now: () => clock.now,
});
sampler.setEnabled(true);
/** Run one idle slice; its length on the fake clock. */
function slice() {
const started = clock.now;
probeNow(t);
return clock.now - started;
}
/** Put the camera over a point. */
function moveTo(lon, lat) {
position.longitude = lon * RAD;
position.latitude = lat * RAD;
}
return { ...fake, sampler, slice, moveTo };
}
/** Points on a grid `step` degrees apart, `n`×`n`, centred on a point. */
function grid(lon, lat, n, step) {
const points = [];
for (let i = 0; i < n; i++)
for (let j = 0; j < n; j++)
points.push([
lon + (i - (n - 1) / 2) * step,
lat + (j - (n - 1) / 2) * step,
]);
return points;
}
test('cells out of range when asked are not queued, even once the camera reaches them', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t);
// 400 cells ~3.6 km east, past the sampler's range.
sampler.request(grid(10.05, 50, 20, 0.0002));
// The camera gets there before any idle slice runs.
moveTo(10.05, 50);
for (let i = 0; i < 5; i++) slice();
assert.equal(probes.length, 0, 'nothing was queued for later');
// Asked again from there (as a camera move does), they are probed.
sampler.request([[10.05, 50]]);
slice();
assert.equal(probes.length, 1);
sampler.destroy();
});
test('a slice keeps to its budget, counting the work before its first probe', (t) => {
const { sampler, probes, slice } = clocked(t, { probeMs: 2, cameraMs: 3 });
sampler.request(grid(10.001, 50, 5, 0.0002));
const lengths = [];
while (probes.length < 25 && lengths.length < 40) lengths.push(slice());
assert.equal(probes.length, 25, 'every cell is probed in the end');
for (const length of lengths)
assert.ok(length <= MESH_SAMPLE_BUDGET_MS, `a ${length} ms slice`);
sampler.destroy();
});
test('a large queue is ranked once, not sorted again every slice', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t);
// 2,500 cells ~17 m apart around the camera, all within range.
const cells = grid(10, 50, 50, 0.00015);
sampler.request(cells);
const sort = Array.prototype.sort;
let sorted = 0;
t.after(() => (Array.prototype.sort = sort));
Array.prototype.sort = function (...args) {
if (this.length >= 100) sorted += this.length;
return sort.apply(this, args);
};
for (let i = 0; i < 10; i++) slice();
const first = probes.map((probe) =>
metresBetween(probe, { lon: 10, lat: 50 }),
);
assert.ok(first.length >= 10, 'probes ran');
first.forEach((distance, i) =>
assert.ok(
!i || distance >= first[i - 1] - 0.01,
'nearest the camera first, across slices',
),
);
Array.prototype.sort = sort;
assert.equal(sorted, 0, `${sorted} queued cells sorted in 10 slices`);
// The camera moves ~200 m east: the queue is ranked from there.
const done = probes.length;
moveTo(10.003, 50);
slice();
slice();
const after = probes
.slice(done)
.map((probe) => metresBetween(probe, { lon: 10.003, lat: 50 }));
assert.ok(after.length > 0);
assert.ok(after[0] < 20, `nearest the new view first (${after[0]} m)`);
sampler.destroy();
});
test('the sampling range is measured the short way round the date line', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t);
moveTo(179.9999, 0);
sampler.request([
[-179.9999, 0], // ~22 m away, across the date line
[179.99, 0], // ~1.1 km away, past the range
]);
for (let i = 0; i < 5; i++) slice();
assert.equal(probes.length, 1, 'only the neighbour across ±180°');
assert.ok(Math.abs(Math.abs(probes[0].lon) - 179.9999) < MESH_CELL_DEG);
sampler.destroy();
});
/** A scene whose mesh height can change (finer tiles) and a camera that can move. */
function liveViewer({ surface = 80, height = 700, ready = () => true } = {}) {
const tileset = Object.create(Cesium.Cesium3DTileset.prototype);
const probes = [];
const viewer = {
surface,
ready,
scene: {
sampleHeightSupported: true,
primitives: { length: 1, get: () => tileset },
sampleHeight(carto) {
probes.push(carto);
return viewer.surface;
},
},
camera: {
positionCartographic: { longitude: 10 * RAD, latitude: 50 * RAD, height },
},
};
return { viewer, probes };
}
function liveSampler(viewer, options = {}) {
return createMeshSampler({
getViewer: () => viewer,
groundAt: () => 75,
withinPrior: meshFloorSampleWithinPrior,
tilesReady: () => viewer.ready(),
...options,
});
}
test('a sample is probed again once the camera is twice as close, and the change is redrawn', async () => {
const { viewer, probes } = liveViewer({ surface: 80, height: 700 });
const sampler = liveSampler(viewer);
sampler.setEnabled(true);
const heard = [];
sampler.onSampled((batch) => heard.push(batch.length));
sampler.request([[10.001, 50]]);
await settle(900);
assert.equal(sampler.meshAt(10.001, 50), 80);
// Finer tiles load: the surface is really 100 m.
viewer.surface = 100;
sampler.request([[10.001, 50]]);
await settle();
assert.equal(probes.length, 1, 'no new probe from the same distance');
assert.equal(sampler.meshAt(10.001, 50), 80);
viewer.camera.positionCartographic.height = 250;
sampler.request([[10.001, 50]]);
await settle(900);
assert.equal(probes.length, 2);
assert.equal(sampler.meshAt(10.001, 50), 100);
assert.deepEqual(heard, [1, 1], 'the new height is announced for a redraw');
sampler.destroy();
});
test('refreshing stops once a cell was probed from close by', async () => {
const { viewer, probes } = liveViewer({ surface: 80, height: 110 });
viewer.camera.positionCartographic.longitude = 10.001 * RAD;
const sampler = liveSampler(viewer);
sampler.setEnabled(true);
sampler.request([[10.001, 50]]);
await settle();
viewer.camera.positionCartographic.height = 85;
sampler.request([[10.001, 50]]);
await settle();
assert.equal(probes.length, 1, `probed from under ${MESH_REFRESH_MIN_M} m`);
sampler.destroy();
});
test('probes wait while the visible tileset is still streaming', async () => {
let ready = false;
const { viewer, probes } = liveViewer({ ready: () => ready });
const sampler = liveSampler(viewer);
sampler.setEnabled(true);
sampler.request([[10.001, 50]]);
await settle(MESH_STREAMING_WAIT_MS * 2);
assert.equal(probes.length, 0);
ready = true;
await settle(MESH_STREAMING_WAIT_MS * 2);
assert.equal(probes.length, 1);
assert.equal(sampler.meshAt(10.001, 50), 80);
sampler.destroy();
});
test('a sample needs a real bare-earth prior and must sit in the shared mesh window', async () => {
const { viewer } = liveViewer({ surface: 300 });
const unknown = liveSampler(viewer, { groundAt: () => null });
unknown.setEnabled(true);
unknown.request([[10.001, 50]]);
const outside = liveSampler(viewer);
outside.setEnabled(true);
outside.request([[10.001, 50]]);
await settle();
assert.equal(unknown.meshAt(10.001, 50), undefined, 'no prior: not kept');
assert.equal(
outside.meshAt(10.001, 50),
undefined,
'225 m above it: not kept',
);
unknown.destroy();
outside.destroy();
});
test('a failed refresh keeps the last good distance and is retried after the cooldown', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, probes } = liveViewer({ surface: 90, height: 700 });
viewer.camera.positionCartographic.longitude = 10.001 * RAD;
const sampler = liveSampler(viewer);
sampler.setEnabled(true);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(sampler.meshAt(10.001, 50), 90);
// The camera comes down to 20 m and the refresh misses (tiles in flux).
viewer.camera.positionCartographic.height = 110;
viewer.surface = undefined;
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 2);
assert.equal(sampler.meshAt(10.001, 50), 90, 'the old sample stays');
viewer.surface = 100;
t.mock.timers.tick(MESH_MISS_RETRY_MS - 100);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 2, 'not before the cooldown');
t.mock.timers.tick(100);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 3, 'retried after it');
assert.equal(sampler.meshAt(10.001, 50), 100);
sampler.destroy();
});
+25 -9
View File
@@ -6,19 +6,29 @@ import {
import { imageSize } from './imageSize.js';
import { msg, t } from '../../i18n/index.js';
/**
* EXPAND puts the viewer's wrap full screen, or (iPhone) opens it as a
* popover. Each is tried on its own: a browser that cannot parse one
* (Safari before 16.4) throws on the selector.
*/
const EXPANDED_SELECTORS = [
'.sl-viewer-wrap:fullscreen',
'.sl-viewer-wrap:popover-open',
];
/** 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 ||
@@ -27,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]);
/**
@@ -145,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 });
}
@@ -535,14 +545,14 @@ export function createPhotoViewer({
/**
* Docked, Esc anywhere in the viewer's box (the photo, its toolbar, its
* caption) is the provider's before the panel's, which would collapse;
* expanded, the dialog's own Esc shrinks it.
* expanded, the panel's own Esc shrinks it.
*/
function onEscapeKey(event) {
if (
event.key !== 'Escape' ||
event.shiftKey ||
event.defaultPrevented ||
root?.closest?.('.sl-viewer-wrap-expanded') ||
isExpanded() ||
!onEscape()
)
return;
@@ -739,9 +749,15 @@ export function createPhotoViewer({
}
}
/** The viewer is the expanded dialog, not docked in the panel. */
/** The viewer is expanded (full screen, or the iPhone popover), not docked in the panel. */
function isExpanded() {
return Boolean(root?.closest?.('.sl-viewer-wrap-expanded'));
return EXPANDED_SELECTORS.some((selector) => {
try {
return Boolean(root?.closest?.(selector));
} catch {
return false;
}
});
}
/** Another photo, or none: a sharper panorama still on its way is not needed. */
+36 -1
View File
@@ -160,7 +160,7 @@ test('Esc: the docked viewer offers it to the provider first; expanded, it is th
assert.equal(asked, 1);
assert.equal(docked.prevented && docked.stopped, true);
root.closest = (selector) =>
selector.includes('sl-viewer-wrap-expanded') ? {} : null;
selector.includes('sl-viewer-wrap:fullscreen') ? {} : null;
const expanded = press();
assert.equal(asked, 1, 'not asked while expanded');
assert.equal(expanded.prevented, false);
@@ -536,3 +536,38 @@ test('an <img> that turns out too large is refused and let go', async (t) => {
await assert.rejects(aborted);
assert.equal(images.at(-1).src, '', 'its download stops');
});
test('a browser without :fullscreen / :popover-open (Safari < 16.4) still opens a flat photo and hands Esc on', async () => {
let asked = 0;
const viewer = createPhotoViewer({
providerId: 'test',
name: 'Test',
sources: SOURCES,
getPhoto: async (id) => PHOTOS[id],
step: async () => null,
onStep() {},
onEscape: () => ++asked > 0,
load: async (url) => ({ src: url }),
});
const host = fakeHost();
await viewer.mount(host);
const root = host.children[0];
// Safari 16.0–16.3: neither pseudo-class parses, so closest() throws.
root.closest = (selector) => {
if (/:fullscreen|:popover-open/.test(selector))
throw new SyntaxError(`'${selector}' is not a valid selector`);
return null;
};
let failure = null;
try {
await viewer.open('f');
} catch (error) {
failure = error;
}
assert.equal(failure, null, 'opening a flat photo does not throw');
const event = { key: 'Escape' };
event.preventDefault = () => {};
event.stopPropagation = () => {};
assert.doesNotThrow(() => root.fire('keydown', event), 'Esc does not throw');
assert.equal(asked, 1, 'Esc reaches the provider');
});
+16 -10
View File
@@ -5,10 +5,16 @@ const msg = (text) => text;
/** Identity and shared tuning for the provider-neutral Street Level layer. */
export const STREET_LEVEL_LAYER_ID = 'street-level';
/**
* The pixel window a click picks in: coverage lines are 2.5 px wide and there
* is no hover cue, so a click a few pixels off still finds the line.
*/
export const CLICK_PICK_WINDOW_PX = 9;
/** Pick id of the viewer position marker; the core owns it, not a provider. */
export const POSITION_PICK_ID = 'sl:pos';
/** One colour per imagery source: chip, coverage lines, points, cones and legend. */
/** One colour per imagery source: chip, coverage lines and cones. */
export const PROVIDER_COLORS = Object.freeze({
mapillary: '#05cb63',
google: '#4285f4',
@@ -31,17 +37,11 @@ export const MAX_SINCE_DAYS = 36_500;
/** Filter every provider starts with: all imagery, any date. */
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
/** Nearest-image search radius in metres (Mapillary's graph API caps it at 50 m). */
/** Nearest-image search radius in metres. */
export const NEAREST_RADIUS_M = 50;
/**
* The only map stack camera follow is offered on: elsewhere a camera at eye
* height looks at a smeared texture.
*/
export const FOLLOW_MAP_STACK_ID = 'photoreal';
/** Eye height above the sampled ground when the globe camera follows the viewer. */
export const FOLLOW_EYE_HEIGHT_M = 2.4;
/** The Google 3D map stack: Street View may be shown only on it. */
export const GOOGLE_3D_MAP_STACK_ID = 'photoreal';
/** Above this, a ground click is a misclick, not a street. */
export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
@@ -50,3 +50,9 @@ export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
export const AIM_AT_GROUND_MESSAGE = msg(
'Aim the middle of the view at a street to look for imagery there',
);
/** The key gate's label for the pill and the layer list, or null when the key is fine. */
export function keyStatusLabel({ keyRequired, keyRejected }) {
if (keyRejected) return msg('KEY REJECTED');
return keyRequired ? msg('KEY REQUIRED') : null;
}
@@ -1,4 +1,4 @@
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
import { GOOGLE_3D_MAP_STACK_ID } from '../../policy.js';
import { createMapsLoader } from './mapsLoader.js';
import { getLocale } from '../../../../i18n/index.js';
import { createGoogleViewer } from './viewer.js';
@@ -64,7 +64,7 @@ export function createGoogleProvider({
credit: Object.freeze({ html: GOOGLE_CREDIT_HTML }),
groundClick: true,
// Google's terms forbid Street View beside non-Google maps.
requiresMapStack: FOLLOW_MAP_STACK_ID,
requiresMapStack: GOOGLE_3D_MAP_STACK_ID,
// Every panorama is billed to the Maps key.
defaultOn: false,
// All 360°, and no coverage for a date filter to thin.
@@ -114,8 +114,6 @@ export function createGoogleProvider({
stopAuthWatch();
},
refreshCoverage() {},
/** No filter applies to Street View (see `filters`). */
setFilter() {},
@@ -123,8 +121,6 @@ export function createGoogleProvider({
const keyRejected = loader.authFailed();
return {
count: 0,
zoom: null,
kind: null,
loading: false,
hint:
!active || keyRequired || keyRejected ? '' : GROUND_CLICK_HINT,
@@ -40,7 +40,7 @@ export function tileSegments(payload) {
* fourfold.
* @param {Array<Array<[number, number]>>} segments
* @param {{x: number, y: number, z: number}} address
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<never>}}
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>}}
*/
export function chainSegments(segments, { x, y, z }) {
const key = ([lon, lat]) => `${lon},${lat}`;
@@ -84,7 +84,7 @@ export function chainSegments(segments, { x, y, z }) {
isPano: false,
});
});
return { sequences, overview: [] };
return { sequences };
}
/** Decode a KartaView coverage tile into the shared coverage's line records. */
Loaded 100 of 807 files, more files were not shown because too many files have changed in this diff. Show more