mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into upstream/wind-stale
This commit is contained in:
305 files changed
+8508
-1252
No files matched your search
@@ -31,10 +31,10 @@ export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
|
||||
*/
|
||||
export const MAX_VERTICES = 512;
|
||||
/** Two clicks closer than this are one vertex: a double-click to finish must not add a stray point. */
|
||||
export const MIN_VERTEX_SEPARATION_M = 0.5;
|
||||
const MIN_VERTEX_SEPARATION_M = 0.5;
|
||||
/** A line shorter than this, or an area thinner than this, is a mis-click rather than a shape. */
|
||||
export const MIN_PATH_LENGTH_M = 1;
|
||||
export const MIN_AREA_M2 = 1;
|
||||
const MIN_PATH_LENGTH_M = 1;
|
||||
const MIN_AREA_M2 = 1;
|
||||
|
||||
/** @param {string} shape @returns {'area'|'line'|'pin'} */
|
||||
export function normalizeShape(shape) {
|
||||
@@ -50,7 +50,7 @@ export function createDrawSession(shape = 'area') {
|
||||
}
|
||||
|
||||
/** Great-circle distance in metres between two {lon, lat} points. */
|
||||
export function greatCircleM(a, b) {
|
||||
function greatCircleM(a, b) {
|
||||
return greatCircleMeters(a, b);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
|
||||
import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js';
|
||||
/** The id this tool claims the pointer under. */
|
||||
export const DRAW_POINTER_OWNER = 'draw';
|
||||
const DRAW_POINTER_OWNER = 'draw';
|
||||
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
|
||||
|
||||
const COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
|
||||
|
||||
@@ -29,7 +29,7 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
|
||||
/** Ms the analyst region lookup waits on geocode + admin boundary. */
|
||||
export const REGION_FALLBACK_BUDGET_MS = 3_000;
|
||||
const REGION_FALLBACK_BUDGET_MS = 3_000;
|
||||
|
||||
/** Entity facts that rule out a state/county reading of the ask. */
|
||||
const NON_ADMIN_ENTITY_KINDS = new Set([
|
||||
@@ -103,18 +103,6 @@ export function createAnnotationResolver({
|
||||
cacheWrite(cache, key, null);
|
||||
}
|
||||
|
||||
/** Wire an external AbortSignal to a local controller; returns a detach fn. */
|
||||
function linkAbort(controller, externalSignal) {
|
||||
if (!externalSignal) return () => {};
|
||||
if (externalSignal.aborted) {
|
||||
controller.abort();
|
||||
return () => {};
|
||||
}
|
||||
const onAbort = () => controller.abort();
|
||||
externalSignal.addEventListener('abort', onAbort, { once: true });
|
||||
return () => externalSignal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single annotation target to a normalized world anchor.
|
||||
*
|
||||
|
||||
+3
-3
@@ -15,9 +15,9 @@ import * as Cesium from 'cesium';
|
||||
import { announceNavigationAuthority } from '../navigationPolicy.js';
|
||||
import { annotationsFromParams, createView } from '../view/index.js';
|
||||
|
||||
export const EMBED_VIEW_MESSAGE = 'gev:view';
|
||||
export const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
|
||||
export const EMBED_READY_MESSAGE = 'gev:ready';
|
||||
const EMBED_VIEW_MESSAGE = 'gev:view';
|
||||
const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
|
||||
const EMBED_READY_MESSAGE = 'gev:ready';
|
||||
|
||||
const FOLLOW_LAYERS = {
|
||||
aircraft: 'flights',
|
||||
|
||||
@@ -72,3 +72,17 @@ test('the defaults are Mapillary (given its source), Street View, KartaView and
|
||||
// Without the application's Street Level source there are none.
|
||||
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
|
||||
});
|
||||
|
||||
test('every built-in provider is exported, so hosts can choose their own list', async () => {
|
||||
const google = await import('gods-eye-view/layers/street-level/google');
|
||||
const kartaview = await import('gods-eye-view/layers/street-level/kartaview');
|
||||
const panoramax = await import('gods-eye-view/layers/street-level/panoramax');
|
||||
const layer = createApplicationStreetLevel({
|
||||
providers: [
|
||||
google.createGoogleProvider({ getApiKey: () => null }),
|
||||
kartaview.createKartaViewProvider(),
|
||||
panoramax.createPanoramaxProvider(),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(layer.providerIds, ['google', 'kartaview', 'panoramax']);
|
||||
});
|
||||
@@ -53,7 +53,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
||||
});
|
||||
|
||||
/** An omitted provider is unavailable, never an authoritative empty dataset. */
|
||||
export class SourceUnavailableError extends Error {
|
||||
class SourceUnavailableError extends Error {
|
||||
constructor(name) {
|
||||
super(`Data source not configured: ${name}`);
|
||||
this.name = 'SourceUnavailableError';
|
||||
|
||||
@@ -1,51 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* Camera presets for notable locations.
|
||||
* Phase 1 default: fly to Austin, TX on load.
|
||||
*/
|
||||
export const CAMERA_PRESETS = {
|
||||
austin: {
|
||||
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 800),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(0),
|
||||
pitch: Cesium.Math.toRadians(-35),
|
||||
roll: 0.0,
|
||||
},
|
||||
},
|
||||
sf: {
|
||||
destination: Cesium.Cartesian3.fromDegrees(-122.4194, 37.7749, 1000),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(30),
|
||||
pitch: Cesium.Math.toRadians(-30),
|
||||
roll: 0.0,
|
||||
},
|
||||
},
|
||||
nyc: {
|
||||
destination: Cesium.Cartesian3.fromDegrees(-73.9857, 40.7484, 1200),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(-20),
|
||||
pitch: Cesium.Math.toRadians(-30),
|
||||
roll: 0.0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Fly the camera to a preset location with a smooth animation.
|
||||
*/
|
||||
export function flyToPreset(viewer, presetName, duration = 3.0) {
|
||||
const preset = CAMERA_PRESETS[presetName];
|
||||
if (!preset) return;
|
||||
|
||||
viewer.camera.flyTo({
|
||||
destination: preset.destination,
|
||||
orientation: preset.orientation,
|
||||
duration,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set camera to Austin on load with a cinematic fly-in.
|
||||
* @returns {Function} Cancels the pending or active startup flight.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -20,8 +20,8 @@ import { ownCameraArrival } from './data/cameraArrival.js';
|
||||
/** Minimum eye height above the rendered surface for a usable view. */
|
||||
export const MIN_EYE_CLEARANCE_M = 120;
|
||||
/** Retries while tiles stream in; sampling fails until the mesh exists. */
|
||||
export const GUARD_ATTEMPTS = 8;
|
||||
export const GUARD_INTERVAL_MS = 600;
|
||||
const GUARD_ATTEMPTS = 8;
|
||||
const GUARD_INTERVAL_MS = 600;
|
||||
/** Ignore sub-metre noise rather than nudging the camera forever. */
|
||||
const GUARD_EPSILON_M = 2;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { LayerBindings } from './ui/layerBindings.js';
|
||||
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
||||
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
||||
import { StyleManager } from './ui/applicationShell.js';
|
||||
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
|
||||
import { CockpitViewController } from './ui/cockpitController.js';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -224,7 +223,7 @@ test('validated voice camera destinations share the UI navigation authority faca
|
||||
'move camera',
|
||||
);
|
||||
ordered(move, [
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))",
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
|
||||
'const start = () => {',
|
||||
/const preserveCameraFlight =\s*motion === 'orbit'/,
|
||||
'runNavigation(start, { preserveCameraFlight })',
|
||||
@@ -384,7 +383,7 @@ test('world-focus listener lifecycle is symmetric and idempotent', () => {
|
||||
});
|
||||
|
||||
test('vessel and fire layers announce valid clicks and never fly cameras', () => {
|
||||
for (const [label, source] of [['vessels', vessels], ['fires', firms]]) {
|
||||
for (const source of [vessels, firms]) {
|
||||
assert.match(source, /requestWorldFocus\(\{/);
|
||||
assert.doesNotMatch(source, /camera\.flyTo/);
|
||||
}
|
||||
|
||||
+300
-12
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* One motion at a time, driven per clock tick. `once` = bounded eased nudge;
|
||||
* `continuous` runs until move_camera{stop}, ANY manual camera input on the
|
||||
* canvas (pointerdown/wheel — the cancelFlight reflex), or a navigation tool
|
||||
* canvas (pointerdown/wheel/touchstart — the cancelFlight reflex), or a navigation tool
|
||||
* starts (the shared UI navigation policy releases this owner before the new
|
||||
* destination mutates the camera). fly_route is a
|
||||
* continuous motion in the same slot: a cinematic dolly along an existing
|
||||
@@ -13,14 +13,24 @@
|
||||
* Orbit circles the current screen-center ground target holding range and
|
||||
* pitch while advancing heading (per-frame camera.lookAt; the lookAt
|
||||
* transform is cleared on every stop so manual control returns cleanly).
|
||||
* Globe rotation turns the world-frame camera about its screen-up axis through
|
||||
* Earth's center, preserving range and framing. Left/right name the visible
|
||||
* globe motion, opposite to the camera's travel around Earth.
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { GLOBE_VIEW } from './locations.js';
|
||||
import {
|
||||
projectEarthDiscToViewport,
|
||||
earthDiscScreenRadius,
|
||||
getKeyholeGeometry,
|
||||
} from './celestialRing.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
} from './renderGovernor.js';
|
||||
|
||||
let _isCockpitActive = null;
|
||||
/** °/s by speed word — orbit; pan uses fractions of view height/s. */
|
||||
const ORBIT_DEG_S = { slow: 2, normal: 6, fast: 15 };
|
||||
const TILT_DEG_S = { slow: 4, normal: 10, fast: 20 };
|
||||
@@ -952,6 +962,7 @@ let _viewer = null;
|
||||
let _getTarget = null;
|
||||
let _active = null; // { kind, motion, direction, speed, mode, motionId, ...state }
|
||||
let _tickRemover = null;
|
||||
let _trackingRemover = null;
|
||||
let _inputRemovers = [];
|
||||
/** Monotonic id for the motion currently running; 0 means "no motion". */
|
||||
let _motionSeq = 0;
|
||||
@@ -1162,7 +1173,11 @@ function onTick() {
|
||||
else if (m.direction === 'right') cam.moveRight(step);
|
||||
else if (m.direction === 'up') cam.moveUp(step);
|
||||
else cam.moveDown(step);
|
||||
} else if (m.kind === 'tilt' || m.kind === 'rotate') {
|
||||
} else if (
|
||||
m.kind === 'tilt' ||
|
||||
m.kind === 'rotate' ||
|
||||
m.kind === 'rotate_globe'
|
||||
) {
|
||||
const degS = TILT_DEG_S[m.speed];
|
||||
const stepRad =
|
||||
m.mode === 'once'
|
||||
@@ -1172,7 +1187,38 @@ function onTick() {
|
||||
(scale - (m.lastScale || 0))
|
||||
: Cesium.Math.toRadians(degS) * dt;
|
||||
m.lastScale = scale;
|
||||
if (m.kind === 'tilt') {
|
||||
if (m.kind === 'rotate_globe') {
|
||||
// Tracking can acquire the camera without going through navigation.
|
||||
// Yield without resetting its pose/frame: the world-space arc proof
|
||||
// only describes Cesium's rotation while the frame is identity.
|
||||
if (
|
||||
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
|
||||
_viewer.trackedEntity ||
|
||||
_isCockpitActive?.() ||
|
||||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
|
||||
) {
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
return;
|
||||
}
|
||||
// Cesium rotates both position and basis about the reference-frame
|
||||
// origin. The captured screen-up axis keeps left/right horizontal even
|
||||
// in a tilted or rolled view; a positive angle moves the globe right.
|
||||
const angle = m.direction === 'left' ? -stepRad : stepRad;
|
||||
if (globeRotationFitsKeyhole() !== true) {
|
||||
interruptCameraMotion('globe-framing-changed');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!globeRotationHasClearance(cam.positionWC) ||
|
||||
Math.abs(
|
||||
Cesium.Cartesian3.magnitude(cam.positionWC) - m.rotationRadius,
|
||||
) > 0.01
|
||||
) {
|
||||
interruptCameraMotion('globe-surface-limit');
|
||||
return;
|
||||
}
|
||||
cam.rotate(m.rotationAxis, angle);
|
||||
} else if (m.kind === 'tilt') {
|
||||
const up = m.direction === 'up';
|
||||
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
|
||||
const next = cam.pitch + (up ? stepRad : -stepRad);
|
||||
@@ -1206,6 +1252,133 @@ function onTick() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */
|
||||
function globeRotationHasClearance(position) {
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
const { minimumRadius, maximumRadius } = ellipsoid;
|
||||
const radius = Cesium.Cartesian3.magnitude(position);
|
||||
return (
|
||||
Number.isFinite(minimumRadius) &&
|
||||
minimumRadius > 0 &&
|
||||
Number.isFinite(maximumRadius) &&
|
||||
maximumRadius >= minimumRadius &&
|
||||
Number.isFinite(radius) &&
|
||||
radius > maximumRadius * (1 + 1e-12)
|
||||
);
|
||||
}
|
||||
|
||||
/** Use the existing WGS84/CSS-pixel projection; null means unsupported, not close-up. */
|
||||
function globeRotationFitsKeyhole(camera = _viewer.camera) {
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
if (!Cesium.Cartesian3.equals(ellipsoid.radii, Cesium.Ellipsoid.WGS84.radii))
|
||||
return null;
|
||||
const canvas = _viewer.scene.canvas;
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
const frustum = camera.frustum;
|
||||
if (
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0 ||
|
||||
!(frustum instanceof Cesium.PerspectiveFrustum) ||
|
||||
!Number.isFinite(frustum.near) ||
|
||||
frustum.near <= 0 ||
|
||||
!Number.isFinite(frustum.far) ||
|
||||
frustum.far <= frustum.near ||
|
||||
frustum.xOffset !== 0 ||
|
||||
frustum.yOffset !== 0
|
||||
)
|
||||
return null;
|
||||
try {
|
||||
const cam = camera;
|
||||
const distance = Cesium.Cartesian3.magnitude(cam.positionWC);
|
||||
const sphereRadius = ellipsoid.maximumRadius;
|
||||
if (!Number.isFinite(distance) || distance <= 0) return null;
|
||||
if (distance <= sphereRadius) return false; // Near-surface polar views are close-up too.
|
||||
const radiusPx = earthDiscScreenRadius(distance, height, frustum.fovy);
|
||||
if (!radiusPx) return null;
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
// The known nadir reset probe uses the same exact cone below; projecting
|
||||
// through SceneTransforms would read the live scene.camera instead.
|
||||
if (
|
||||
camera === _viewer.camera &&
|
||||
!projectEarthDiscToViewport(_viewer, width, height)
|
||||
) {
|
||||
// A near-surface horizon view cannot fit even when pointed at Earth;
|
||||
// an orbital camera looking away is unsupported, not a close-up offer.
|
||||
return radiusPx > Math.min(keyhole.radius, width / 2, height / 2)
|
||||
? false
|
||||
: null;
|
||||
}
|
||||
// The shared disc is an overlay approximation off-axis. Bound the sphere
|
||||
// against the existing keyhole cone. Canvas-edge clipping does not change
|
||||
// framing during rigid globe rotation and is not a clearance condition,
|
||||
// without changing the UI projection or defining another keyhole/mask.
|
||||
const toCenter = Cesium.Cartesian3.negate(
|
||||
cam.positionWC,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const depth = Cesium.Cartesian3.dot(toCenter, cam.directionWC);
|
||||
const right = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.rightWC));
|
||||
const up = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.upWC));
|
||||
const focalY = height / (2 * Math.tan(frustum.fovy / 2));
|
||||
const focalX =
|
||||
width / (2 * Math.tan(frustum.fovy / 2) * frustum.aspectRatio);
|
||||
if (
|
||||
![depth, right, up, focalX, focalY].every(Number.isFinite) ||
|
||||
focalX <= 0 ||
|
||||
focalY <= 0
|
||||
)
|
||||
return null;
|
||||
const halfAngle = Math.asin(sphereRadius / distance);
|
||||
const centerAngle = Math.atan2(Math.hypot(right, up), depth);
|
||||
const keyholeAngle = Math.atan(keyhole.radius / Math.max(focalX, focalY));
|
||||
// No overlay visibility hysteresis: a fully contained smaller globe needs no reset.
|
||||
const framed = centerAngle + halfAngle <= keyholeAngle;
|
||||
if (!framed) return false;
|
||||
// Projection alone does not prove that a tiny globe is within depth planes.
|
||||
if (
|
||||
depth - sphereRadius < frustum.near ||
|
||||
depth + sphereRadius > frustum.far
|
||||
)
|
||||
return null;
|
||||
return true;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check the unchanged reset destination without moving the live camera. */
|
||||
function globeResetFitsKeyhole() {
|
||||
try {
|
||||
const camera = Cesium.Camera.clone(_viewer.camera);
|
||||
const { longitude, latitude } = _viewer.camera.positionCartographic;
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
longitude,
|
||||
latitude,
|
||||
GLOBE_VIEW.heightM,
|
||||
),
|
||||
orientation: {
|
||||
heading: 0,
|
||||
pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg),
|
||||
roll: 0,
|
||||
},
|
||||
endTransform: Cesium.Matrix4.IDENTITY,
|
||||
});
|
||||
return globeRotationFitsKeyhole(camera) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Release only globe rotation when a new camera intent takes ownership. */
|
||||
export function interruptGlobeRotation(reason = 'camera-intent') {
|
||||
if (_active?.kind === 'rotate_globe') return interruptCameraMotion(reason);
|
||||
return { wasActive: false, reason, leveled: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the module to the viewer once (idempotent). The view-target getter is
|
||||
* optional so a non-voice caller (the Directions FLY chip) can arm route
|
||||
@@ -1218,24 +1391,42 @@ function onTick() {
|
||||
* call brings none, so a callback from the old viewer can never answer for the
|
||||
* new one.
|
||||
*/
|
||||
export function initCameraVerbs(viewer, getViewTargetCartesian = null) {
|
||||
export function initCameraVerbs(
|
||||
viewer,
|
||||
getViewTargetCartesian = null,
|
||||
{ isCockpitActive } = {},
|
||||
) {
|
||||
const nextTarget =
|
||||
typeof getViewTargetCartesian === 'function'
|
||||
? getViewTargetCartesian
|
||||
: null;
|
||||
if (_viewer === viewer) {
|
||||
if (nextTarget) _getTarget = nextTarget;
|
||||
if (typeof isCockpitActive === 'function')
|
||||
_isCockpitActive = isCockpitActive;
|
||||
return;
|
||||
}
|
||||
// A globe motion belongs to the viewer whose world pose it captured.
|
||||
if (_active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('viewer-replaced');
|
||||
_getTarget = nextTarget;
|
||||
_isCockpitActive =
|
||||
typeof isCockpitActive === 'function' ? isCockpitActive : null;
|
||||
_viewer = viewer;
|
||||
if (_tickRemover) _tickRemover();
|
||||
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
|
||||
if (_trackingRemover) _trackingRemover();
|
||||
// Cockpit can acquire then clear tracking before the next clock tick.
|
||||
// Yield on acquisition itself without changing legacy camera motions.
|
||||
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
|
||||
if (entity && _active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
});
|
||||
for (const rm of _inputRemovers) rm();
|
||||
_inputRemovers = [];
|
||||
const canvas = viewer.scene.canvas;
|
||||
// ANY manual camera input reclaims control — the cancelFlight reflex.
|
||||
for (const evt of ['pointerdown', 'wheel']) {
|
||||
for (const evt of ['pointerdown', 'touchstart', 'wheel']) {
|
||||
const h = () => interruptCameraMotion('manual-input');
|
||||
canvas.addEventListener(evt, h, { passive: true });
|
||||
_inputRemovers.push(() => canvas.removeEventListener(evt, h));
|
||||
@@ -1256,7 +1447,11 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
const speed = ORBIT_DEG_S[String(args.speed || 'normal').toLowerCase()]
|
||||
? String(args.speed || 'normal').toLowerCase()
|
||||
: 'normal';
|
||||
const mode = args.mode === 'continuous' ? 'continuous' : 'once';
|
||||
const mode =
|
||||
args.mode === 'continuous' ||
|
||||
(motion === 'rotate_globe' && args.mode === undefined)
|
||||
? 'continuous'
|
||||
: 'once';
|
||||
if (motion === 'stop') {
|
||||
const wasActiveBeforeHandoff = Boolean(_active);
|
||||
const stop = () => {
|
||||
@@ -1270,15 +1465,16 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
};
|
||||
return typeof runNavigation === 'function' ? runNavigation(stop) : stop();
|
||||
}
|
||||
if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion)) {
|
||||
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, or stop.`,
|
||||
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion !== 'orbit' &&
|
||||
motion !== 'rotate_globe' &&
|
||||
!['left', 'right', 'up', 'down'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
@@ -1294,6 +1490,17 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
error: 'rotate needs left or right.',
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion === 'rotate_globe' &&
|
||||
direction !== null &&
|
||||
!['left', 'right'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'rotate_globe needs left or right.',
|
||||
};
|
||||
}
|
||||
if (motion === 'tilt') {
|
||||
// Honest limits: at the clamp a tilt is a no-op — say so instead of
|
||||
// reporting success and letting the model gaslight itself (field test).
|
||||
@@ -1318,9 +1525,9 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
}
|
||||
}
|
||||
if (
|
||||
motion === 'orbit' &&
|
||||
_viewer?.trackedEntity &&
|
||||
typeof runNavigation !== 'function'
|
||||
(motion === 'rotate_globe' ||
|
||||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
|
||||
_viewer?.trackedEntity
|
||||
) {
|
||||
// The follow camera owns a tracked view; a lookAt orbit fights it frame
|
||||
// by frame and stop rips the camera out (field finding). Honest refusal
|
||||
@@ -1332,9 +1539,74 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want a free orbit.`,
|
||||
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want ${motion === 'rotate_globe' ? 'to rotate the globe' : 'a free orbit'}.`,
|
||||
};
|
||||
}
|
||||
let globePose;
|
||||
if (motion === 'rotate_globe') {
|
||||
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'Globe rotation requires a 3D view.',
|
||||
};
|
||||
}
|
||||
const cam = _viewer.camera;
|
||||
globePose = {
|
||||
position: Cesium.Cartesian3.clone(cam.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(cam.directionWC),
|
||||
up: Cesium.Cartesian3.normalize(cam.upWC, new Cesium.Cartesian3()),
|
||||
right: Cesium.Cartesian3.clone(cam.rightWC),
|
||||
};
|
||||
if (_isCockpitActive?.())
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'camera-owned',
|
||||
error: 'Exit Cockpit before rotating the globe.',
|
||||
};
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
if (!(
|
||||
Number.isFinite(ellipsoid.minimumRadius) &&
|
||||
ellipsoid.minimumRadius > 0 &&
|
||||
Number.isFinite(ellipsoid.maximumRadius) &&
|
||||
ellipsoid.maximumRadius >= ellipsoid.minimumRadius &&
|
||||
Number.isFinite(Cesium.Cartesian3.magnitude(globePose.position))
|
||||
))
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsafe-ellipsoid',
|
||||
error:
|
||||
'Globe rotation requires a finite supported ellipsoid and a clear globe view.',
|
||||
};
|
||||
const fits = globeRotationFitsKeyhole();
|
||||
if (fits === null)
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsupported-view',
|
||||
error: 'Globe rotation requires a supported perspective view.',
|
||||
};
|
||||
if (!fits) {
|
||||
const canReset = globeResetFitsKeyhole();
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: canReset ? 'globe-view-required' : 'globe-reset-unavailable',
|
||||
error: canReset
|
||||
? 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’'
|
||||
: 'Zoom out further to fit the globe inside the keyhole; the Globe View preset does not fit this viewport.',
|
||||
};
|
||||
}
|
||||
if (!globeRotationHasClearance(globePose.position))
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsafe-ellipsoid',
|
||||
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
|
||||
};
|
||||
}
|
||||
const start = () => {
|
||||
interruptCameraMotion('replaced');
|
||||
const state = {
|
||||
@@ -1343,6 +1615,22 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
speed,
|
||||
mode,
|
||||
};
|
||||
if (motion === 'rotate_globe') {
|
||||
// Convert an inherited ground-target frame without changing the world
|
||||
// pose. Rotation must pivot about Earth, never the previous local target.
|
||||
const cam = _viewer.camera;
|
||||
cam.cancelFlight();
|
||||
cam.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
// Handoff may level a route's bank. Keep the caller's exact world pose.
|
||||
// Copy the basis directly: setView's heading/pitch conversion is singular
|
||||
// at nadir and can change the captured orientation near the poles.
|
||||
Cesium.Cartesian3.clone(globePose.position, cam.position);
|
||||
Cesium.Cartesian3.clone(globePose.direction, cam.direction);
|
||||
Cesium.Cartesian3.clone(globePose.up, cam.up);
|
||||
Cesium.Cartesian3.clone(globePose.right, cam.right);
|
||||
state.rotationAxis = globePose.up;
|
||||
state.rotationRadius = Cesium.Cartesian3.magnitude(globePose.position);
|
||||
}
|
||||
if (motion === 'orbit') {
|
||||
if (_viewer.scene.tweens.length > 0) {
|
||||
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm
|
||||
|
||||
@@ -27,7 +27,7 @@ export function runCctvLayerEnableFocus({
|
||||
* @param {Object} [after={}] Ownership immediately after the await.
|
||||
* @returns {{ trackedEntity: *, cockpitActive: boolean }} Conservative ownership.
|
||||
*/
|
||||
export function mergeCctvEnableOwnership(before = {}, after = {}) {
|
||||
function mergeCctvEnableOwnership(before = {}, after = {}) {
|
||||
return {
|
||||
trackedEntity: before.trackedEntity || after.trackedEntity || null,
|
||||
cockpitActive: !!(before.cockpitActive || after.cockpitActive),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
runCctvLayerEnableFocus,
|
||||
runCctvLayerEnableTransition,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
CCTV_WORLD_CLICK_FOCUS_DURATION_SEC,
|
||||
CCTV_FOCUS_REQUEST_EVENT,
|
||||
|
||||
@@ -174,7 +174,7 @@ export function cockpitWeatherEnabledFromStoredValue(value) {
|
||||
* Weather-backed volumetric cloud pass that exists only while cockpit mode is
|
||||
* active. It owns no Cesium fog/post-process stages and cannot affect map mode.
|
||||
*/
|
||||
export class CockpitCloudEffectsController {
|
||||
class CockpitCloudEffectsController {
|
||||
constructor(viewer, { weatherService = applicationServices.weather } = {}) {
|
||||
this.weatherService = weatherService;
|
||||
this.viewer = viewer;
|
||||
|
||||
@@ -281,7 +281,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc
|
||||
uiSource.indexOf('_syncContactsDetection() {'),
|
||||
uiSource.indexOf('/** Apply a temporary cockpit-only'),
|
||||
);
|
||||
assert.ok(helper.length > 0, 'the Contacts detection helper is present in ui.js');
|
||||
assert.ok(helper.length > 0, 'the Contacts detection helper is present in the UI shell source');
|
||||
assert.match(helper, /if \(this\._contextModeChanging\) return;/, 'fires at settle, not at click');
|
||||
assert.match(helper, /active: this\._contextMode === 'flights'/);
|
||||
assert.match(helper, /styleOwnsDetection:\s*!this\._detectionUserOverridden/);
|
||||
@@ -302,7 +302,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc
|
||||
uiSource.indexOf('_setCockpitVision(mode, active, { revealParameters = false } = {}) {'),
|
||||
uiSource.indexOf('_syncIrBoost() {'),
|
||||
);
|
||||
assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in ui.js');
|
||||
assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in the UI shell source');
|
||||
assert.doesNotMatch(visionHook, /etection/, 'cockpit transitions must not touch detection');
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { setContextMode as contextModeAction } from './ui/contextActions.js';
|
||||
import { ContextControls } from './ui/contextControls.js';
|
||||
import { readFileSync as readRadioSource } from 'node:fs';
|
||||
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
|
||||
// Source-contract pins read the actual Context owners and root disposal wiring. Each pin guards a bug that shipped or nearly shipped:
|
||||
// - session bookkeeping ran AFTER the exit early-return, so the compensating
|
||||
@@ -21,8 +20,6 @@ const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', imp
|
||||
// - the right-rail entry ignored the activation result entirely.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const src = readShellSource();
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||
import { expandApplicationHtml } from '../build/application-html.js';
|
||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import { _selectContextMode } from './ui/contextTransactions.js';
|
||||
@@ -8,13 +7,11 @@ import { clearSelectedLayers } from './ui/contextActions.js';
|
||||
import { readFileSync as readRadioSource } from 'node:fs';
|
||||
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
||||
const ui = readShellSource();
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
|
||||
@@ -32,8 +29,6 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
|
||||
});
|
||||
|
||||
test('Context transition state preserves focus and Tab availability until settle', () => {
|
||||
const syncSource = _syncContextModeButtons.toString();
|
||||
|
||||
const attributes = () => new Map();
|
||||
const makeButton = () => {
|
||||
const attrs = attributes();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
@@ -8,7 +7,6 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const css = readStylesheet(path.join(ROOT, 'style.css'));
|
||||
const ui = readShellSource();
|
||||
|
||||
/*
|
||||
* Required-attribution keep-out pin.
|
||||
@@ -247,6 +245,9 @@ function ownBoxEntries() {
|
||||
const compound = lastCompound(part);
|
||||
if (!ELEMENT_KEYS.some((key) => compound.includes(key))) continue;
|
||||
if (compound.includes('::')) continue; // a pseudo-element is its own box
|
||||
// The phone shell is its own layout; mobileShell.test.mjs pins its
|
||||
// attribution clearance.
|
||||
if (part.startsWith('body.mobile-shell')) continue;
|
||||
entries.push({ rule, part });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ export function decodeRing(encoded, decimals) {
|
||||
}
|
||||
|
||||
/** Even-odd point-in-ring test; ring is [[lon, lat], …], open or closed. */
|
||||
export function pointInRing(ring, lat, lon) {
|
||||
function pointInRing(ring, lat, lon) {
|
||||
if (!Array.isArray(ring) || ring.length < 3) return false;
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
|
||||
@@ -1011,6 +1011,34 @@ test('vessel interaction wire: flights-owned pick preserves vessel selection', (
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel touch interaction yields to the first nearby flight behind map tiles', () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: () => ({ matches: true }),
|
||||
});
|
||||
registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3');
|
||||
const tile = { primitive: { isCesium3DTileset: true } };
|
||||
const harness = installWireHarness(tile);
|
||||
let drills = 0;
|
||||
harness.viewer.scene.drillPick = (_position, limit, width, height) => {
|
||||
drills++;
|
||||
assert.deepEqual([limit, width, height], [8, 14, 14]);
|
||||
return [tile, { id: 'a1b2c3' }];
|
||||
};
|
||||
try {
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(drills, 1);
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||||
assert.equal(harness.trail.clearCalls, 0);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
unregisterPickOwner('flights');
|
||||
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
|
||||
else delete globalThis.matchMedia;
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () => {
|
||||
registerPickOwner('cctv', (pickedId) => pickedId === 'atx-cam-3');
|
||||
const harness = installWireHarness({ id: 'atx-cam-3' });
|
||||
|
||||
@@ -215,7 +215,7 @@ export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze(
|
||||
* @param {Object} body
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function aisEnvelopeMmsi(envelope, body = {}) {
|
||||
function aisEnvelopeMmsi(envelope, body = {}) {
|
||||
const metadata = envelope?.MetaData || envelope?.Metadata || {};
|
||||
const raw = metadata.MMSI ?? body.UserID ?? body.UserId ?? body.Mmsi;
|
||||
if (raw === null || raw === undefined) return null;
|
||||
|
||||
+1
-21
@@ -46,28 +46,8 @@ export const AIS_WATCHDOG_DEFAULTS = Object.freeze({
|
||||
authProbeMs: 3_600_000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Statuses reported to the browser. 'missing-key' and 'unsupported' keep their
|
||||
* pre-watchdog meaning so a keyless install still reads as "feature off".
|
||||
*/
|
||||
export const AIS_WATCHDOG_STATUSES = Object.freeze([
|
||||
'idle',
|
||||
'missing-key',
|
||||
'unsupported',
|
||||
'connecting',
|
||||
'live',
|
||||
'stale',
|
||||
'reconnecting',
|
||||
'down',
|
||||
'auth-failed',
|
||||
]);
|
||||
|
||||
/** Failure classes. Only 'transport' is allowed to walk the fast ladder. */
|
||||
export const AIS_FAILURE_KINDS = Object.freeze([
|
||||
'transport',
|
||||
'auth',
|
||||
'rate-limit',
|
||||
]);
|
||||
const AIS_FAILURE_KINDS = Object.freeze(['transport', 'auth', 'rate-limit']);
|
||||
|
||||
/** Statuses that must not flip back to a hopeful 'connecting' on a retry. */
|
||||
const QUIET_TERMINAL = new Set(['down', 'auth-failed']);
|
||||
|
||||
@@ -62,7 +62,7 @@ const COMMON_FIELDS = Object.freeze(['id', 'lat', 'lon']);
|
||||
/** Derived per-record field for layers with a time field (hours before now). */
|
||||
const AGE_FIELD = 'ageHours';
|
||||
/** Spatial scope kinds `query()` accepts. */
|
||||
export const ANALYST_SCOPE_KINDS = Object.freeze([
|
||||
const ANALYST_SCOPE_KINDS = Object.freeze([
|
||||
'view',
|
||||
'region',
|
||||
'radius',
|
||||
@@ -74,7 +74,7 @@ export const ANALYST_SCOPE_KINDS = Object.freeze([
|
||||
* @param {string[]} layerKeys Queried layer ids.
|
||||
* @returns {string[]} Field names, common fields first.
|
||||
*/
|
||||
export function analystFieldsFor(layerKeys) {
|
||||
function analystFieldsFor(layerKeys) {
|
||||
const fields = new Set(COMMON_FIELDS);
|
||||
for (const key of layerKeys) {
|
||||
const layer = ANALYST_LAYERS[key];
|
||||
@@ -104,44 +104,6 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
|
||||
}
|
||||
|
||||
/**
|
||||
* One filter: {field, op:'gt'|'lt'|'gte'|'lte'|'eq'|'neq'|'contains', value}.
|
||||
* With `time: true`, both sides compare as epoch ms (ISO strings accepted).
|
||||
*/
|
||||
export function applyFilter(records, filter, { time = false } = {}) {
|
||||
const { field, op } = filter || {};
|
||||
if (!field || !op) return records;
|
||||
const value = time ? toEpochMs(filter.value) : filter.value;
|
||||
return records.filter((r) => {
|
||||
const got = time ? toEpochMs(r[field]) : r[field];
|
||||
if (got === null || got === undefined || (time && !Number.isFinite(got)))
|
||||
return false;
|
||||
if (time && (op === 'eq' || op === 'neq'))
|
||||
return (got === value) === (op === 'eq');
|
||||
switch (op) {
|
||||
case 'gt':
|
||||
return Number(got) > Number(value);
|
||||
case 'gte':
|
||||
return Number(got) >= Number(value);
|
||||
case 'lt':
|
||||
return Number(got) < Number(value);
|
||||
case 'lte':
|
||||
return Number(got) <= Number(value);
|
||||
case 'eq': {
|
||||
if (typeof got === 'boolean' || typeof value === 'boolean')
|
||||
return Boolean(got) === Boolean(value);
|
||||
return String(got).toLowerCase() === String(value).toLowerCase();
|
||||
}
|
||||
case 'neq':
|
||||
return String(got).toLowerCase() !== String(value).toLowerCase();
|
||||
case 'contains':
|
||||
return String(got).toLowerCase().includes(String(value).toLowerCase());
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere', …}. */
|
||||
export function applyScope(records, scope, resolved) {
|
||||
if (!scope || scope.kind === 'anywhere') return records;
|
||||
@@ -167,28 +129,6 @@ export function applyScope(records, scope, resolved) {
|
||||
return records;
|
||||
}
|
||||
|
||||
/** Numeric summary for the narration layer. */
|
||||
function summarize(items, sortField) {
|
||||
const summary = { count: items.length };
|
||||
if (sortField && items.length) {
|
||||
// A loop, not Math.min(...vals): a full FIRMS day is ~160k values, past
|
||||
// the engine's argument limit.
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const item of items) {
|
||||
const value = Number(item[sortField]);
|
||||
if (!Number.isFinite(value)) continue;
|
||||
if (value < min) min = value;
|
||||
if (value > max) max = value;
|
||||
}
|
||||
if (Number.isFinite(min)) {
|
||||
summary[`${sortField}Min`] = min;
|
||||
summary[`${sortField}Max`] = max;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
const OPS_BY_TYPE = Object.freeze({
|
||||
number: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'],
|
||||
time: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'],
|
||||
@@ -213,7 +153,7 @@ const COMMON_UNITS = Object.freeze({
|
||||
* @param {string} field Field name.
|
||||
* @returns {'number'|'text'|'flag'|'time'|null} Field type.
|
||||
*/
|
||||
export function analystFieldType(layerKeys, field) {
|
||||
function analystFieldType(layerKeys, field) {
|
||||
if (!analystFieldsFor(layerKeys).includes(field)) return null;
|
||||
if (COMMON_TYPES[field]) return COMMON_TYPES[field];
|
||||
for (const key of layerKeys) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
||||
import { t, msg, getLocale } from '../i18n/index.js';
|
||||
|
||||
export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
|
||||
const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
|
||||
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
|
||||
'bhote-koshi-locator-callout';
|
||||
export const NEPAL_CONTEXT_PRESENTATION = 'nepal-context';
|
||||
@@ -35,7 +35,6 @@ const BORDER_START_DELAY_MS = 700;
|
||||
const INCIDENT_CALLOUT_GAP_MS = 200;
|
||||
const CALLOUT_REVEAL_DURATION_MS = 1200;
|
||||
const CALLOUT_PULSE_DURATION_MS = 1800;
|
||||
const CALLOUT_DOT_SIZE_PX = 7;
|
||||
const CALLOUT_RING_MIN_SIZE_PX = 16;
|
||||
const CALLOUT_RING_SIZE_RANGE_PX = 14;
|
||||
const CITY_CALLOUT_START_DELAY_MS = 350;
|
||||
@@ -1248,6 +1247,3 @@ export function createBhoteKoshiLocatorLayer({
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const bhoteKoshiLocatorLayer = createBhoteKoshiLocatorLayer();
|
||||
export default bhoteKoshiLocatorLayer;
|
||||
@@ -23,4 +23,3 @@ export {
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
} from '../layers/bikeshare/index.js';
|
||||
export default layer;
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
const EARTH_RADIUS_M = 6371000;
|
||||
export const PLANE_VERT_ASPECT = 16 / 9;
|
||||
const PLANE_VERT_ASPECT = 16 / 9;
|
||||
export const SUPPORT_KEYS = Object.freeze([
|
||||
'bl',
|
||||
'bm',
|
||||
@@ -128,7 +128,7 @@ export function planeSupportPoints(pose) {
|
||||
* @param {number} capAltM @param {-1|0|1} row @param {{upVert:number}} dims
|
||||
* @returns {number}
|
||||
*/
|
||||
export function supportAltitude(capAltM, row, dims) {
|
||||
function supportAltitude(capAltM, row, dims) {
|
||||
return capAltM + row * dims.upVert;
|
||||
}
|
||||
|
||||
|
||||
@@ -316,7 +316,7 @@ export function createCalibrationGizmo({
|
||||
}
|
||||
const mount = positions.mount;
|
||||
const axes = enuAxes(mount);
|
||||
const { forwardHoriz, right, view } = viewAxesFor(
|
||||
const { forwardHoriz } = viewAxesFor(
|
||||
camera.headingDeg,
|
||||
camera.pitchDeg,
|
||||
axes,
|
||||
|
||||
+3
-3
@@ -94,7 +94,7 @@ export function incumbentRankKm(
|
||||
export const CCTV_CARD_CENTER_WEIGHT = 0.5;
|
||||
|
||||
/** Robust pool-distance percentile used to scale the screen-space term. */
|
||||
export const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
|
||||
const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
|
||||
|
||||
/** True only when both viewport dimensions are finite positive pixels. */
|
||||
export function hasFiniteCctvViewport(viewW, viewH) {
|
||||
@@ -172,8 +172,8 @@ export function blendCenterRankKm(
|
||||
// viewport is bucketed into this grid and every occupied cell gets its best
|
||||
// candidate before global rank fills the rest. Owner-tunable together with
|
||||
// the budgets above.
|
||||
export const CCTV_CARD_GRID_COLS = 5;
|
||||
export const CCTV_CARD_GRID_ROWS = 4;
|
||||
const CCTV_CARD_GRID_COLS = 5;
|
||||
const CCTV_CARD_GRID_ROWS = 4;
|
||||
|
||||
/**
|
||||
* Screen-space distribution pass (owner round 2, item C). Buckets the
|
||||
|
||||
@@ -2,7 +2,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { dispatchCockpitModeChanged, enter, exit, _adoptTrackedEntity } from '../ui/cockpitTrackingController.js';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import * as Cesium from 'cesium';
|
||||
import flightsLayer, {
|
||||
_setCockpitDetectionSubjectForTest as setFlightsCockpitSubject,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Cockpit 3D aircraft policy.
|
||||
|
||||
@@ -181,7 +181,7 @@ export function trimTrack(track, nowMs, keep = track.capacity) {
|
||||
for (const id of track.epochs.keys())
|
||||
if (!used.has(id)) track.epochs.delete(id);
|
||||
}
|
||||
export function validFix(fix) {
|
||||
function validFix(fix) {
|
||||
return (
|
||||
Number.isFinite(fix?.t) &&
|
||||
fix.t >= 0 &&
|
||||
|
||||
@@ -25,11 +25,11 @@ import * as Cesium from 'cesium';
|
||||
* follows DATA_SOURCES.md (live sources, then bundled snapshots).
|
||||
* @type {{ key: string, html: string }[]}
|
||||
*/
|
||||
export const OSM_CREDIT = {
|
||||
const OSM_CREDIT = {
|
||||
key: 'openstreetmap',
|
||||
html: 'Map and place data <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL</a>)',
|
||||
};
|
||||
export const OPENMAPTILES_CREDIT = {
|
||||
const OPENMAPTILES_CREDIT = {
|
||||
key: 'openfreemap',
|
||||
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
|
||||
};
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
detectionHorizontalSector,
|
||||
labelBudgetFor,
|
||||
normalizeAllocationStrategy,
|
||||
normalizeProfile,
|
||||
profileForDensity,
|
||||
viewScaleForAltitude,
|
||||
} from './detectionPolicy.js';
|
||||
|
||||
@@ -18,7 +18,7 @@ export function stableIdentityHash(layerId, sourceId) {
|
||||
}
|
||||
|
||||
/** Negative means a is a better deterministic contender than b. */
|
||||
export function compareCohortContenders(a, b) {
|
||||
function compareCohortContenders(a, b) {
|
||||
const priorityDelta =
|
||||
(Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
|
||||
if (priorityDelta) return priorityDelta;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* module so density cannot drift into contradictory mode semantics again.
|
||||
*/
|
||||
|
||||
export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]);
|
||||
export const ALLOCATION_ELASTIC = 'ELASTIC';
|
||||
export const ALLOCATION_WEIGHTED = 'WEIGHTED';
|
||||
export const ALLOCATION_STRATEGIES = Object.freeze([
|
||||
@@ -118,7 +117,7 @@ export function detectionHorizontalSector(screenX, viewportWidth) {
|
||||
return 'front';
|
||||
}
|
||||
|
||||
export const VIEW_SCALE_BUDGETS = Object.freeze({
|
||||
const VIEW_SCALE_BUDGETS = Object.freeze({
|
||||
street: Object.freeze({ 0: 9, 25: 23, 50: 45, 75: 68, 100: 90 }),
|
||||
city: Object.freeze({ 0: 7, 25: 18, 50: 35, 75: 53, 100: 70 }),
|
||||
metro: Object.freeze({ 0: 6, 25: 14, 50: 28, 75: 41, 100: 55 }),
|
||||
|
||||
@@ -24,7 +24,7 @@ import * as input from './inputOwnership.js';
|
||||
import { createDirectionsLayer } from '../layers/directions/index.js';
|
||||
|
||||
/** The shared scene owners every Directions instance runs on. */
|
||||
export const directionsServices = Object.freeze({
|
||||
const directionsServices = Object.freeze({
|
||||
credits,
|
||||
render,
|
||||
sprites,
|
||||
@@ -49,15 +49,11 @@ export {
|
||||
DIRECTIONS_MODES,
|
||||
DIRECTIONS_POINTER_OWNER,
|
||||
DIRECTIONS_ROUTE_COLOR,
|
||||
DIRECTIONS_STEP_OVERLAY_SOURCE_ID,
|
||||
DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS,
|
||||
FLIGHT_PROGRESS_MS,
|
||||
POINTER_TOOL_EXITS,
|
||||
STEP_ANCHOR_DEADLINE_MS,
|
||||
STEP_ANCHOR_FAST_ATTEMPTS,
|
||||
STEP_ANCHOR_RETRY_MS,
|
||||
STEP_ANCHOR_SLOW_RETRY_MS,
|
||||
createDirectionsLayer,
|
||||
createDirectionsStepOverlayEntry,
|
||||
directionsRequestUrl,
|
||||
directionsRowControls,
|
||||
|
||||
@@ -8,4 +8,3 @@ export function createEarthquakesLayer({
|
||||
} = {}) {
|
||||
return createApplicationEarthquakes({ source, ...options });
|
||||
}
|
||||
export default createEarthquakesLayer();
|
||||
+33
-10
@@ -81,21 +81,44 @@ export function parseAcquisitionMs(date, time, cache = new Map()) {
|
||||
const cached = cache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
// Same calendar as the CSV gate. Date.UTC would otherwise roll 30 February
|
||||
// into March, and the fire's age would describe a detection that never happened.
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
cache.set(key, 0);
|
||||
return 0;
|
||||
}
|
||||
const timeText = String(time ?? '').trim();
|
||||
if (!/^\d{1,4}$/.test(timeText)) {
|
||||
cache.set(key, 0);
|
||||
return 0;
|
||||
}
|
||||
const hhmm = timeText.padStart(4, '0');
|
||||
const year = Number(date.slice(0, 4));
|
||||
const month = Number(date.slice(5, 7));
|
||||
const day = Number(date.slice(8, 10));
|
||||
const hhmm = String(time ?? '0000').padStart(4, '0');
|
||||
const hours = Number(hhmm.slice(0, 2));
|
||||
const minutes = Number(hhmm.slice(2, 4));
|
||||
const valid =
|
||||
Number.isFinite(year) &&
|
||||
Number.isFinite(month) &&
|
||||
Number.isFinite(day) &&
|
||||
Number.isFinite(hours) &&
|
||||
Number.isFinite(minutes);
|
||||
const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
|
||||
cache.set(key, ms);
|
||||
return ms;
|
||||
if (
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
day > 31 ||
|
||||
hours > 23 ||
|
||||
minutes > 59
|
||||
) {
|
||||
cache.set(key, 0);
|
||||
return 0;
|
||||
}
|
||||
const ms = Date.UTC(year, month - 1, day, hours, minutes);
|
||||
const parsed = new Date(ms);
|
||||
const result =
|
||||
parsed.getUTCFullYear() === year &&
|
||||
parsed.getUTCMonth() === month - 1 &&
|
||||
parsed.getUTCDate() === day
|
||||
? ms
|
||||
: 0;
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,12 +63,39 @@ test('unpadded acq_time parses in the adapter path ("45" = 00:45Z)', () => {
|
||||
|
||||
test('parseAcquisitionMs: memo cache is honored, invalid input → 0', () => {
|
||||
const cache = new Map();
|
||||
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
|
||||
assert.equal(
|
||||
parseAcquisitionMs('2026-07-16', '1006', cache),
|
||||
Date.UTC(2026, 6, 16, 10, 6),
|
||||
);
|
||||
assert.equal(cache.size, 1);
|
||||
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
|
||||
assert.equal(
|
||||
parseAcquisitionMs('2026-07-16', '1006', cache),
|
||||
Date.UTC(2026, 6, 16, 10, 6),
|
||||
);
|
||||
assert.equal(parseAcquisitionMs(undefined, '1006', cache), 0);
|
||||
});
|
||||
|
||||
test('impossible acquisition dates stay untimed instead of rolling forward', () => {
|
||||
for (const [date, time] of [
|
||||
['2026-02-30', '1200'],
|
||||
['2026-04-31', '1200'],
|
||||
['2026-13-01', '1200'],
|
||||
['2026-07-16', '2400'],
|
||||
['2026-07-16', '1260'],
|
||||
]) {
|
||||
assert.equal(parseAcquisitionMs(date, time), 0, `${date}:${time}`);
|
||||
const [fire] = adaptFirmsRecords([
|
||||
proxyRecord({ acqDate: date, acqTime: time }),
|
||||
]);
|
||||
assert.equal(fire.acqMs, 0, `${date}:${time}`);
|
||||
assert.equal(fire.lat, 38.99488);
|
||||
}
|
||||
const [leap] = adaptFirmsRecords([
|
||||
proxyRecord({ acqDate: '2024-02-29', acqTime: '45' }),
|
||||
]);
|
||||
assert.equal(leap.acqMs, Date.UTC(2024, 1, 29, 0, 45));
|
||||
});
|
||||
|
||||
test('daynight "D" → night false', () => {
|
||||
const [fire] = adaptFirmsRecords([proxyRecord({ daynight: 'D' })]);
|
||||
assert.equal(fire.night, false);
|
||||
@@ -89,11 +116,16 @@ test('records with non-finite lat/lon are skipped; index stays sequential', () =
|
||||
proxyRecord({ lat: 40.1 }),
|
||||
]);
|
||||
assert.equal(fires.length, 2);
|
||||
assert.deepEqual(fires.map((f) => f.index), [0, 1]);
|
||||
assert.deepEqual(
|
||||
fires.map((f) => f.index),
|
||||
[0, 1],
|
||||
);
|
||||
});
|
||||
|
||||
test('non-finite frp/brightness → 0; empty input → []', () => {
|
||||
const [fire] = adaptFirmsRecords([proxyRecord({ frp: 'n/a', brightness: undefined })]);
|
||||
const [fire] = adaptFirmsRecords([
|
||||
proxyRecord({ frp: 'n/a', brightness: undefined }),
|
||||
]);
|
||||
assert.equal(fire.frp, 0);
|
||||
assert.equal(fire.brightness, 0);
|
||||
assert.deepEqual(adaptFirmsRecords([]), []);
|
||||
|
||||
+99
-22
@@ -5,9 +5,9 @@ import { createImportedPins } from './geoImportPins.js';
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
/** Largest file accepted, in bytes. */
|
||||
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
||||
const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
||||
/** Largest total a KMZ may inflate to — a zip bomb stops here. */
|
||||
export const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024;
|
||||
const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024;
|
||||
/** Most files on the globe at once. */
|
||||
export const MAX_IMPORTED_FILES = 12;
|
||||
/** Most entities one file may create before it is refused. */
|
||||
@@ -15,9 +15,9 @@ export const MAX_IMPORT_ENTITIES = 20_000;
|
||||
/** Most vertices (points of every line, ring and marker) in one file. */
|
||||
export const MAX_IMPORT_POSITIONS = 250_000;
|
||||
/** Most normalized style definitions and local image links in one KML/KMZ. */
|
||||
export const MAX_KML_LINKS = 2_000;
|
||||
const MAX_KML_LINKS = 2_000;
|
||||
/** Longest plain-text name kept from a file's features. */
|
||||
export const MAX_IMPORT_NAME_LENGTH = 120;
|
||||
const MAX_IMPORT_NAME_LENGTH = 120;
|
||||
/** What the file picker offers. */
|
||||
export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx';
|
||||
|
||||
@@ -1040,26 +1040,103 @@ export function detachImportedTime(dataSource) {
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
/** Repair only the first KML opening tag, scanning its delimiter once. */
|
||||
export function repairKmlRootNamespaces(value, what = 'KML') {
|
||||
const text = String(value);
|
||||
// Skip XML comments and processing instructions without treating their
|
||||
// literal '<kml' text as an opening root tag.
|
||||
let start = 0;
|
||||
for (;;) {
|
||||
start = text.indexOf('<', start);
|
||||
if (start < 0) return text;
|
||||
if (text.slice(start, start + 9).toUpperCase() === '<!DOCTYPE') {
|
||||
let quote = null;
|
||||
let subsetDepth = 0;
|
||||
let end = start + 9;
|
||||
for (; end < text.length; end++) {
|
||||
const char = text[end];
|
||||
if (quote) {
|
||||
if (char === quote) quote = null;
|
||||
} else if (char === '"' || char === "'") quote = char;
|
||||
else if (char === '<' && text.startsWith('<!--', end)) {
|
||||
// Comments and processing instructions inside the internal subset
|
||||
// carry no declaration syntax; jump past them as a whole.
|
||||
const skip = text.indexOf('-->', end + 4);
|
||||
if (skip < 0) return text;
|
||||
end = skip + 2;
|
||||
} else if (char === '<' && text.startsWith('<?', end)) {
|
||||
const skip = text.indexOf('?>', end + 2);
|
||||
if (skip < 0) return text;
|
||||
end = skip + 1;
|
||||
} else if (char === '[') subsetDepth++;
|
||||
else if (char === ']') subsetDepth--;
|
||||
else if (char === '>' && subsetDepth === 0) break;
|
||||
}
|
||||
if (end === text.length) return text;
|
||||
start = end + 1;
|
||||
continue;
|
||||
}
|
||||
const close = text.startsWith('<!--', start)
|
||||
? '-->'
|
||||
: text.startsWith('<?', start)
|
||||
? '?>'
|
||||
: null;
|
||||
if (!close) break;
|
||||
const end = text.indexOf(close, start + 2);
|
||||
if (end < 0) return text; // The XML parser reports an unterminated preamble.
|
||||
start = end + close.length;
|
||||
}
|
||||
const match = /^<kml\b/i.exec(text.slice(start, start + 5));
|
||||
if (!match) return text;
|
||||
let quote = null;
|
||||
let end = -1;
|
||||
for (let index = start + match[0].length; index < text.length; index++) {
|
||||
const char = text[index];
|
||||
if (char === '<') break; // Raw '<' is invalid even inside an XML attribute.
|
||||
if (quote) {
|
||||
if (char === quote) quote = null;
|
||||
} else if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
} else if (char === '>') {
|
||||
end = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (end < 0)
|
||||
throw new GeoImportError(
|
||||
`That ${what} file is not valid XML.`,
|
||||
msg('That {format} file is not valid XML.'),
|
||||
{ format: what },
|
||||
);
|
||||
// Walk the root's attributes in order so quoted values are never read as
|
||||
// declarations. Anything the walk cannot tokenize is kept verbatim for the
|
||||
// XML parser to judge.
|
||||
const seen = new Set();
|
||||
const attribute = /(\s+)([^\s=/>"']+)\s*=\s*("[^"]*"|'[^']*')/y;
|
||||
let position = start + match[0].length;
|
||||
let root = text.slice(start, position);
|
||||
for (;;) {
|
||||
attribute.lastIndex = position;
|
||||
const found = attribute.exec(text);
|
||||
if (!found || attribute.lastIndex > end) break;
|
||||
position = attribute.lastIndex;
|
||||
const prefix = /^xmlns:([\w.-]+)$/.exec(found[2])?.[1];
|
||||
if (prefix && seen.has(prefix)) continue;
|
||||
if (prefix) seen.add(prefix);
|
||||
root += found[0];
|
||||
}
|
||||
root += text.slice(position, end);
|
||||
if (!seen.has('xsi') && /[<\s]xsi:/.test(text))
|
||||
root = root.replace(
|
||||
/(\/?)>$/,
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"$1>',
|
||||
);
|
||||
return text.slice(0, start) + root + text.slice(end);
|
||||
}
|
||||
|
||||
function parseXml(text, what) {
|
||||
// Match the namespace repairs Cesium applies to ordinary exported KML.
|
||||
if (what !== 'GPX')
|
||||
text = String(text).replace(/<kml\b[^>]*>/i, (root) => {
|
||||
const seen = new Set();
|
||||
root = root.replace(
|
||||
/\s+xmlns:([\w.-]+)\s*=\s*(["'])[^"']*\2/g,
|
||||
(declaration, prefix) => {
|
||||
if (seen.has(prefix)) return '';
|
||||
seen.add(prefix);
|
||||
return declaration;
|
||||
},
|
||||
);
|
||||
if (!seen.has('xsi') && /[<\s]xsi:/.test(text))
|
||||
root = root.replace(
|
||||
/>$/,
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
|
||||
);
|
||||
return root;
|
||||
});
|
||||
if (what !== 'GPX') text = repairKmlRootNamespaces(text, what);
|
||||
const doc = new DOMParser().parseFromString(
|
||||
String(text).replace(/^/, ''),
|
||||
'application/xml',
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { deflateRawSync } from 'node:zlib';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
GeoImportError,
|
||||
repairKmlRootNamespaces,
|
||||
loadGeoFile,
|
||||
MAX_IMPORT_POSITIONS,
|
||||
MAX_IMPORT_ENTITIES,
|
||||
@@ -1171,3 +1173,92 @@ test('a dated import exposes no clock and draws for all time', () => {
|
||||
assert.equal(recorded.getValue(Cesium.JulianDate.now()).length, 2);
|
||||
assert.ok(track.polyline.clampToGround.getValue());
|
||||
});
|
||||
|
||||
test('KML root namespace repair respects quoted delimiters and preserves the document tail', () => {
|
||||
const text =
|
||||
'<KML note="a > b" xmlns:x="first" xmlns:x="second"><Placemark xsi:type="test"/></KML>';
|
||||
const result = repairKmlRootNamespaces(text);
|
||||
assert.match(result, /note="a > b"/);
|
||||
assert.match(result, /xmlns:x="first"/);
|
||||
assert.doesNotMatch(result, /second/);
|
||||
assert.match(
|
||||
result,
|
||||
/xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance"/,
|
||||
);
|
||||
assert.ok(result.endsWith('<Placemark xsi:type="test"/></KML>'));
|
||||
assert.match(
|
||||
repairKmlRootNamespaces('<kml xsi:type="test"/>'),
|
||||
/XMLSchema-instance"\/>$/,
|
||||
);
|
||||
for (const malformed of ['<kml ', '<kml name="unterminated>', '<kml <kml >'])
|
||||
assert.throws(() => repairKmlRootNamespaces(malformed), GeoImportError);
|
||||
});
|
||||
|
||||
test('repeated incomplete KML roots finish within an isolated process deadline', () => {
|
||||
const source = `import { repairKmlRootNamespaces } from ${JSON.stringify(new URL('./geoFileImport.js', import.meta.url).href)};
|
||||
try { repairKmlRootNamespaces('<kml '.repeat(1000000)); process.exit(1); }
|
||||
catch (error) { if (error.name !== 'GeoImportError') throw error; }
|
||||
try { repairKmlRootNamespaces('<!DOCTYPE kml [' + ${JSON.stringify("<!-- ' --><?pi ' ?>")}.repeat(100000) + ']><kml '); process.exit(1); }
|
||||
catch (error) { if (error.name !== 'GeoImportError') throw error; }
|
||||
const wide = '<kml' + ${JSON.stringify(` xmlns:gx="a" note='xmlns:gx="b"'`)}.repeat(20000) + '/>';
|
||||
if (repairKmlRootNamespaces(wide).split('xmlns:gx="a"').length !== 2) process.exit(1);`;
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
['--input-type=module', '-e', source],
|
||||
{ timeout: 5000, encoding: 'utf8' },
|
||||
);
|
||||
assert.ifError(child.error);
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
});
|
||||
|
||||
test('KML root repair ignores incomplete tag text inside preamble comments', () => {
|
||||
const input =
|
||||
'<?xml version="1.0"?><!-- example <kml incomplete -->\n<kml xmlns:gx="a" xmlns:gx="b"><Document/></kml>';
|
||||
assert.equal(
|
||||
repairKmlRootNamespaces(input),
|
||||
input.replace(' xmlns:gx="b"', ''),
|
||||
);
|
||||
});
|
||||
|
||||
test('KML namespace repair follows a doctype with a quoted root-like entity', () => {
|
||||
const input =
|
||||
'<!DOCTYPE kml [<!ENTITY example "<kml incomplete >">]><kml xmlns:gx="a" xmlns:gx="b"/>';
|
||||
assert.equal(
|
||||
repairKmlRootNamespaces(input),
|
||||
input.replace(' xmlns:gx="b"', ''),
|
||||
);
|
||||
});
|
||||
|
||||
test('KML namespace repair skips doctype comments and processing instructions whole', () => {
|
||||
for (const prolog of [
|
||||
"<!DOCTYPE kml [<!-- don't count this quote -->]>",
|
||||
'<!DOCTYPE kml [<!-- a ] or > here --><!ENTITY x "y">]>',
|
||||
"<!DOCTYPE kml [<?note it's ] > fine?>]>",
|
||||
'<!DOCTYPE kml [<!ENTITY x "<!-- \' -->">]>',
|
||||
]) {
|
||||
const input = `${prolog}<kml xmlns:gx="a" xmlns:gx="b"><Placemark xsi:type="t"/></kml>`;
|
||||
assert.equal(
|
||||
repairKmlRootNamespaces(input),
|
||||
input.replace(
|
||||
' xmlns:gx="b">',
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
|
||||
),
|
||||
prolog,
|
||||
);
|
||||
}
|
||||
const unterminated = '<!DOCTYPE kml [<!-- open <kml xmlns:gx="a">';
|
||||
assert.equal(repairKmlRootNamespaces(unterminated), unterminated);
|
||||
});
|
||||
|
||||
test('KML namespace repair ignores declaration text inside quoted attribute values', () => {
|
||||
const input =
|
||||
'<kml note=\'a > b xmlns:gx="fake"\' xmlns:gx="http://www.google.com/kml/ext/2.2" xmlns:gx="dup"><gx:Track/></kml>';
|
||||
const result = repairKmlRootNamespaces(input);
|
||||
assert.equal(result, input.replace(' xmlns:gx="dup"', ''));
|
||||
assert.match(result, /\sxmlns:gx="http:\/\/www.google.com\/kml\/ext\/2.2"/);
|
||||
const xsi = '<kml note="xmlns:xsi=\'x\'"><Placemark xsi:type="t"/></kml>';
|
||||
assert.match(
|
||||
repairKmlRootNamespaces(xsi),
|
||||
/note="xmlns:xsi='x'" xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance">/,
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
// Inspect encoded headers before allowing the browser to decode local images.
|
||||
export const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
|
||||
const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
|
||||
const MAX_SIDE = 8192;
|
||||
|
||||
/** Width and height from a supported raster header; no image is decoded. */
|
||||
|
||||
@@ -35,14 +35,14 @@
|
||||
import { PbfReader } from 'pbf';
|
||||
|
||||
/** VehiclePosition.VehicleStopStatus enum → label. */
|
||||
export const VEHICLE_STOP_STATUS = Object.freeze({
|
||||
const VEHICLE_STOP_STATUS = Object.freeze({
|
||||
0: 'INCOMING_AT',
|
||||
1: 'STOPPED_AT',
|
||||
2: 'IN_TRANSIT_TO',
|
||||
});
|
||||
|
||||
/** VehiclePosition.OccupancyStatus enum → label. */
|
||||
export const OCCUPANCY_STATUS = Object.freeze({
|
||||
const OCCUPANCY_STATUS = Object.freeze({
|
||||
0: 'EMPTY',
|
||||
1: 'MANY_SEATS_AVAILABLE',
|
||||
2: 'FEW_SEATS_AVAILABLE',
|
||||
@@ -64,7 +64,6 @@ export const GTFS_MAX_ENTITIES = 50_000;
|
||||
export const GTFS_MAX_STRING_CHARS = 256;
|
||||
/** FeedHeader.Incrementality: only FULL_DATASET carries a complete snapshot. */
|
||||
export const GTFS_INCREMENTALITY_FULL_DATASET = 0;
|
||||
export const GTFS_INCREMENTALITY_DIFFERENTIAL = 1;
|
||||
|
||||
/**
|
||||
* Read a length-delimited string, refusing one that is implausibly long.
|
||||
|
||||
@@ -134,7 +134,10 @@ test('every scene click handler consults ownership before it picks', () => {
|
||||
'src/layers/submarineCables/interaction.js',
|
||||
'viewer.scene.pick(click.position)',
|
||||
],
|
||||
['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'],
|
||||
[
|
||||
'src/layers/vessels/selection.js',
|
||||
'pickContact(viewer.scene, click.position)',
|
||||
],
|
||||
];
|
||||
for (const [file, firstPick] of guarded) {
|
||||
const source = read(file);
|
||||
|
||||
@@ -157,26 +157,6 @@ function trackingIdOption(key, token, defaultValue = null) {
|
||||
});
|
||||
}
|
||||
|
||||
function stringOption(key, token, defaultValue) {
|
||||
return Object.freeze({
|
||||
key,
|
||||
token,
|
||||
defaultValue,
|
||||
normalize: (value) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value))
|
||||
return String(value).trim().toLowerCase();
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized ? normalized : null;
|
||||
},
|
||||
encode: (value) => String(value),
|
||||
decode: (value) =>
|
||||
typeof value === 'string' && value.trim()
|
||||
? value.trim().toLowerCase()
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
function enumOption(
|
||||
key,
|
||||
token,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
|
||||
export { localGeoJsonServices } from '../app/localGeojsonServices.js';
|
||||
import {
|
||||
createLocalGeoJsonLayer as createLayer,
|
||||
createLocalInfrastructureOverlayPublisher as createPublisher,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { localGeoJsonServices } from './localGeojson.js';
|
||||
import { createInfrastructureLayers } from './infrastructure.js';
|
||||
import { createFirmsHeatmapLayer } from './firmsHeatmap.js';
|
||||
import submarineCablesLayer from './telegeographySubmarineCables.js';
|
||||
|
||||
const [datacenters, dams] = createInfrastructureLayers(localGeoJsonServices);
|
||||
|
||||
// Live NASA FIRMS fires (VIIRS ×3 NRT via the /api/firms proxy). The id keeps
|
||||
// the historical `local-` prefix for persistence + voice-tool-enum compat,
|
||||
// but the data is NOT bundled anymore — it needs FIRMS_MAP_KEY server-side.
|
||||
const fires = createFirmsHeatmapLayer({
|
||||
id: 'local-firms',
|
||||
name: 'FIRMS Active Fires',
|
||||
icon: '▲',
|
||||
source: 'NASA FIRMS · LIVE',
|
||||
});
|
||||
|
||||
export default [datacenters, dams, submarineCablesLayer, fires];
|
||||
@@ -14,5 +14,4 @@ this derived database.
|
||||
|
||||
The public-release snapshot removes contact-oriented fields and note values
|
||||
that contain email or phone identifiers. The runtime layer does not display or
|
||||
depend on those fields. Both `dams.geojson` and the runtime JSONL form carry the
|
||||
same privacy transform.
|
||||
depend on those fields.
|
||||
@@ -1 +0,0 @@
|
||||
{"type":"FeatureCollection","features":[{"id":-19953989,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[14.292076717,46.16897504],[14.292076711,46.168976415],[14.292080818,46.169315044],[14.292082791,46.169327497],[14.292088326,46.169339424],[14.292097199,46.169350341],[14.292109051,46.169359805],[14.2921234,46.169367433],[14.292139665,46.169372916],[14.292454909,46.16945313],[14.292472725,46.169456276],[14.292491094,46.169456843],[14.292509244,46.169454808],[14.293490178,46.169271915],[14.293505685,46.169267932],[14.293519874,46.169262048],[14.293532266,46.169254461],[14.293542439,46.16924543],[14.293550049,46.169235261],[14.293554837,46.169224298],[14.293556643,46.169212912],[14.293555403,46.16920149],[14.293552972,46.169191516],[14.293548058,46.169179216],[14.293539623,46.169167869],[14.293528021,46.169157953],[14.293513742,46.169149885],[14.293497386,46.169144005],[14.293479642,46.16914056],[14.29260928,46.169036741],[14.292221224,46.168859348],[14.292205414,46.168853594],[14.292188274,46.16885012],[14.29217048,46.168849063],[14.292152735,46.168850464],[14.292135742,46.168854269],[14.292120171,46.168860327],[14.292106638,46.168868398],[14.292095679,46.168878164],[14.292087726,46.168889239],[14.292083093,46.168901184],[14.292078818,46.168919205],[14.292077639,46.168928437],[14.292076717,46.16897504]]]},"properties":{"name":"HE Podlubnik - Centrala","tags":{"name":"HE Podlubnik - Centrala","type":"site","power":"plant","alt_name":"HE Škofja Loka","operator":"Elektro Gorenjska","waterway":"dam","start_date":"1969","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"330KW"},"osm_id":-19953989,"output":"330KW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-19754263,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[69.715342348,34.641127032],[69.71550043,34.641135916],[69.715570922,34.64110658],[69.717521056,34.641462978],[69.71812514,34.641577224],[69.718373076,34.641638579],[69.718438371,34.641451327],[69.718438036,34.641377064],[69.718220274,34.641368766],[69.718144586,34.64121412],[69.718014247,34.640948246],[69.717587357,34.640673654],[69.71740899,34.64063493],[69.717223833,34.640595284],[69.716953182,34.640548261],[69.716766517,34.640531246],[69.716362928,34.640542142],[69.716212976,34.640950425],[69.715856829,34.640920669],[69.715476123,34.640943636],[69.715342348,34.641127032]]]},"properties":{"name":"نغلو برېښناکوټ","tags":{"name":"نغلو برېښناکوټ","type":"multipolygon","power":"plant","name:de":"Naghlu-Staudamm","name:en":"Naghlu Dam","name:ja":"ナグルーダム","name:no":"Naghludemningen","name:ru":"ГЭС Наглу","name:uk":"ГЕС Наглу","waterway":"dam","wikidata":"Q4755783","wikipedia":"en:Naghlu Dam","start_date":"1967","plant:source":"hydro","plant:output:electricity":"100 MW"},"osm_id":-19754263,"output":"100 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-19685440,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-54.597119846,-25.41383667],[-54.597234845,-25.413703733],[-54.597107524,-25.413586135],[-54.59730056,-25.412625569],[-54.597341379,-25.412122152],[-54.597361664,-25.411634241],[-54.597330064,-25.410920019],[-54.597191679,-25.410193224],[-54.597057568,-25.409737668],[-54.59698146,-25.409498365],[-54.596806446,-25.409085472],[-54.596524311,-25.408510306],[-54.596161124,-25.407928853],[-54.59586826,-25.40753591],[-54.595651001,-25.407280513],[-54.595510101,-25.407146654],[-54.595350007,-25.407013549],[-54.595191002,-25.406910368],[-54.595019173,-25.406815317],[-54.594639221,-25.40665405],[-54.594135721,-25.406544833],[-54.593702209,-25.406530333],[-54.593262159,-25.406579954],[-54.592841471,-25.406631754],[-54.592785815,-25.406430001],[-54.588964338,-25.407187725],[-54.584364349,-25.408041757],[-54.584397877,-25.408225908],[-54.58083188,-25.40887408],[-54.580662817,-25.408794033],[-54.578582764,-25.409343216],[-54.577176783,-25.409921902],[-54.574835215,-25.411356968],[-54.573420266,-25.412660605],[-54.56995393,-25.41756251],[-54.565822741,-25.423818762],[-54.564863516,-25.426166114],[-54.561372454,-25.436599237],[-54.55977612,-25.441166117],[-54.559956834,-25.441255635],[-54.560411552,-25.441039718],[-54.561530369,-25.438307217],[-54.56325352,-25.43362584],[-54.564224731,-25.430147434],[-54.564559924,-25.42936314],[-54.564880364,-25.429084861],[-54.565817042,-25.428049779],[-54.566482565,-25.427226006],[-54.567271218,-25.426135353],[-54.568109324,-25.425411827],[-54.569107525,-25.423653136],[-54.568448121,-25.422704724],[-54.568378384,-25.422578744],[-54.568555074,-25.42190861],[-54.568801502,-25.421307544],[-54.569317157,-25.42054873],[-54.570266659,-25.419124226],[-54.57099622,-25.417694944],[-54.571731145,-25.416653241],[-54.572525079,-25.415601815],[-54.573302919,-25.414637645],[-54.573759146,-25.414053929],[-54.574652741,-25.413023961],[-54.574928338,-25.412760769],[-54.575345505,-25.412317702],[-54.57570995,-25.4Line truncated
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import * as Cesium from 'cesium';
|
||||
import militaryAwarenessLayer, {
|
||||
_getAwarenessNavigationStateForTest,
|
||||
|
||||
@@ -798,7 +798,6 @@ test('zoom-out aborts an active installation request and returns non-loading gui
|
||||
// by source probes against the shipped file, the same technique the HUD datum
|
||||
// tests use where a full boot is impractical.
|
||||
import { installationRetryDelayMs } from './militaryInstallations.js';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const installationsSource = readLayerSource(
|
||||
new URL('./militaryInstallations.js', import.meta.url), 'utf8');
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createRetryableLoader } from './retryableLoad.js';
|
||||
export const MILITARY_POINT_CAP = 512;
|
||||
export const MILITARY_LABEL_CAP = 24;
|
||||
|
||||
export const MILITARY_NAMES_TIMEOUT_MS = 10_000;
|
||||
const MILITARY_NAMES_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Share a bounded acquisition; failed assets become retryable after cooldown. */
|
||||
export function createMilitaryNamesLoader({
|
||||
|
||||
@@ -26,7 +26,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
const memoryHit = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
memoryCache: new Map([[key, fresh]]),
|
||||
inFlight: new Map([[key, Promise.resolve(joined)]]),
|
||||
inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]),
|
||||
readDisk: async () => { diskReads += 1; return disk; },
|
||||
allowUpstream,
|
||||
now: 1000,
|
||||
@@ -40,7 +40,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
const inFlightHit = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
memoryCache: new Map([[key, { id: 'stale', status: 200, cachedAt: 0 }]]),
|
||||
inFlight: new Map([[key, Promise.resolve(joined)]]),
|
||||
inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]),
|
||||
readDisk: async () => { diskReads += 1; return disk; },
|
||||
allowUpstream,
|
||||
now: 1000,
|
||||
@@ -51,9 +51,10 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
assert.equal(diskReads, 0, 'in-flight join must short-circuit before disk');
|
||||
assert.equal(limiterCalls, 0, 'in-flight join must not consume limiter quota');
|
||||
|
||||
const diskMemory = new Map();
|
||||
const diskHit = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
memoryCache: new Map(),
|
||||
memoryCache: diskMemory,
|
||||
inFlight: new Map(),
|
||||
readDisk: async () => { diskReads += 1; return disk; },
|
||||
allowUpstream,
|
||||
@@ -62,6 +63,8 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
assert.equal(diskHit.payload, disk);
|
||||
assert.equal(diskReads, 1);
|
||||
assert.equal(limiterCalls, 0, 'disk hit must not consume limiter quota');
|
||||
const publishedDisk = await resolveOverpassPreflight({ cacheKey: key, memoryCache: diskMemory, inFlight: new Map(), now: 1000, readDisk: async () => { throw new Error('disk result was not published'); }, allowUpstream });
|
||||
assert.equal(publishedDisk.source, 'HIT');
|
||||
|
||||
const upstreamMiss = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
@@ -214,3 +217,66 @@ function pointSegDistDeg(p, a, b) {
|
||||
const t = c1 / c2;
|
||||
return Math.hypot(wx - t * vx, wy - t * vy);
|
||||
}
|
||||
|
||||
|
||||
test('cold disk lookup is single-flight through upstream publication and quota admission', async () => {
|
||||
const inFlight = new Map();
|
||||
let release;
|
||||
const disk = new Promise((resolve) => { release = resolve; });
|
||||
let reads = 0, admissions = 0, upstreams = 0;
|
||||
const payload = { status: 200, body: '{"elements":[]}' };
|
||||
const options = {
|
||||
cacheKey: 'cold', memoryCache: new Map(), inFlight,
|
||||
readDisk: () => { reads++; return disk; },
|
||||
allowUpstream: () => { admissions++; return true; },
|
||||
loadUpstream: async () => { upstreams++; return payload; },
|
||||
};
|
||||
const pending = Array.from({ length: 4 }, () => resolveOverpassPreflight(options));
|
||||
await Promise.resolve();
|
||||
assert.equal(inFlight.size, 1);
|
||||
release(null);
|
||||
const results = await Promise.all(pending);
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(admissions, 1);
|
||||
assert.equal(upstreams, 1);
|
||||
assert.ok(results.every((result) => result.payload === payload));
|
||||
assert.deepEqual(results.map((result) => result.source), ['UPSTREAM', 'INFLIGHT', 'INFLIGHT', 'INFLIGHT']);
|
||||
assert.equal(inFlight.size, 0);
|
||||
const failed = { ...options, readDisk: async () => { throw new Error('disk failed'); } };
|
||||
const rejected = await Promise.allSettled([resolveOverpassPreflight(failed), resolveOverpassPreflight(failed)]);
|
||||
assert.ok(rejected.every((result) => result.status === 'rejected'));
|
||||
assert.equal(inFlight.size, 0);
|
||||
await resolveOverpassPreflight({ ...options, readDisk: async () => null });
|
||||
assert.equal(upstreams, 2, 'a rejected reservation does not poison the next retry');
|
||||
});
|
||||
|
||||
test('a joined client retries admission with its own quota after another client is rate limited', async () => {
|
||||
const inFlight = new Map();
|
||||
let release;
|
||||
const disk = new Promise((resolve) => { release = resolve; });
|
||||
let reads = 0, upstreams = 0;
|
||||
const admissions = { a: 0, b: 0, c: 0 };
|
||||
const quota = { a: false, b: true, c: false };
|
||||
const payload = { status: 200, body: '{"elements":[]}' };
|
||||
const client = (name) => resolveOverpassPreflight({
|
||||
cacheKey: 'quota', memoryCache: new Map(), inFlight,
|
||||
readDisk: () => { reads++; return reads === 1 ? disk : null; },
|
||||
allowUpstream: () => { admissions[name]++; return quota[name]; },
|
||||
loadUpstream: async () => { upstreams++; return payload; },
|
||||
});
|
||||
// A (quota exhausted) owns the cold reservation; B (quota available) and
|
||||
// C (quota exhausted) join it while the disk lookup is pending.
|
||||
const pending = [client('a'), client('b'), client('c')];
|
||||
await Promise.resolve();
|
||||
assert.equal(inFlight.size, 1);
|
||||
release(null);
|
||||
const [a, b, c] = await Promise.all(pending);
|
||||
assert.equal(a.source, 'RATE_LIMITED', "A's own quota still rejects A");
|
||||
assert.equal(b.source, 'UPSTREAM', 'B is admitted on its own quota');
|
||||
assert.equal(b.payload, payload);
|
||||
assert.equal(c.source, 'INFLIGHT', "C coalesces onto B's admitted fetch");
|
||||
assert.equal(c.payload, payload);
|
||||
assert.deepEqual(admissions, { a: 1, b: 1, c: 0 });
|
||||
assert.equal(upstreams, 1);
|
||||
assert.equal(inFlight.size, 0);
|
||||
});
|
||||
@@ -11,6 +11,8 @@
|
||||
* that belong to someone else and leave them alone.
|
||||
*/
|
||||
|
||||
import { isSurfacePick } from './scenePick.js';
|
||||
|
||||
/** @type {Map<string, (pickedId: string) => boolean>} layerId -> ownership predicate */
|
||||
const _owners = new Map();
|
||||
|
||||
@@ -100,3 +102,36 @@ export function isOwnedByOtherLayer(layerId, pickedId) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a moving-contact click without letting map tiles hide a nearby touch
|
||||
* target. Exact picks always win; only a coarse primary pointer may expand a
|
||||
* surface miss. Every consumer uses the same ordered, bounded scene hits, so sibling layers
|
||||
* do not each choose their own contact under one tap.
|
||||
* Unowned non-surface objects remain barriers, not permission to pick through.
|
||||
*
|
||||
* @param {object} scene Cesium scene with pick and optional drillPick.
|
||||
* @param {{x: number, y: number}} position Screen position in CSS pixels.
|
||||
* @returns {object|null|undefined} Original pick or the first registered touch hit.
|
||||
*/
|
||||
export function pickContact(scene, position) {
|
||||
const picked = scene.pick(position);
|
||||
if (
|
||||
!globalThis.matchMedia?.('(pointer: coarse)').matches ||
|
||||
findPickOwner(resolvePickId(picked)) ||
|
||||
!isSurfacePick(picked) ||
|
||||
typeof scene.drillPick !== 'function'
|
||||
)
|
||||
return picked;
|
||||
let hits;
|
||||
try {
|
||||
hits = scene.drillPick(position, 8, 14, 14) || [];
|
||||
} catch {
|
||||
return picked;
|
||||
}
|
||||
for (const hit of hits.slice(0, 8)) {
|
||||
if (findPickOwner(resolvePickId(hit))) return hit;
|
||||
if (!isSurfacePick(hit)) break;
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
unregisterPickOwner,
|
||||
isOwnedByOtherLayer,
|
||||
resolvePickId,
|
||||
pickContact,
|
||||
} from './pickRegistry.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -97,3 +98,122 @@ test('ownership: unregister removes the predicate', () => {
|
||||
unregisterPickOwner('bikeshare');
|
||||
assert.equal(isOwnedByOtherLayer('flights', 'station:1'), false);
|
||||
});
|
||||
|
||||
function primaryPointer(t, coarse) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: (query) => ({ matches: query === '(pointer: coarse)' && coarse }),
|
||||
});
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
|
||||
else delete globalThis.matchMedia;
|
||||
});
|
||||
}
|
||||
|
||||
function contactOwners(t) {
|
||||
registerPickOwner('flights', (id) => id === 'civil');
|
||||
registerPickOwner('military', (id) => id === 'military');
|
||||
registerPickOwner('ais-live-vessels', (id) => id === '123456789');
|
||||
t.after(() => {
|
||||
for (const id of ['flights', 'military', 'ais-live-vessels'])
|
||||
unregisterPickOwner(id);
|
||||
});
|
||||
}
|
||||
|
||||
const mapTile = { primitive: { isCesium3DTileset: true } };
|
||||
|
||||
test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CSS pixels', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
const position = { x: 30, y: 50 };
|
||||
const contact = { primitive: { id: 'civil' } };
|
||||
let drills = 0;
|
||||
const scene = {
|
||||
pick(point) {
|
||||
assert.equal(point, position);
|
||||
return mapTile;
|
||||
},
|
||||
drillPick(...args) {
|
||||
drills++;
|
||||
assert.deepEqual(args, [position, 8, 14, 14]);
|
||||
return [mapTile, contact];
|
||||
},
|
||||
};
|
||||
assert.equal(pickContact(scene, position), contact);
|
||||
assert.equal(drills, 1);
|
||||
});
|
||||
|
||||
test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) {
|
||||
const scene = {
|
||||
pick: () => mapTile,
|
||||
drillPick: () => [mapTile, first, { id: 'civil' }],
|
||||
};
|
||||
assert.equal(pickContact(scene, { x: 1, y: 1 }), first);
|
||||
}
|
||||
});
|
||||
|
||||
test('touch contact pick does not drill through exact registered or unregistered interactive picks', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const exact of [
|
||||
{ id: 'military' },
|
||||
{ id: 'custom-annotation' },
|
||||
{ primitive: { id: 'custom-gizmo' } },
|
||||
{ primitive: { id: 'civil', isCesium3DTileset: true } },
|
||||
]) {
|
||||
const scene = {
|
||||
pick: () => exact,
|
||||
drillPick: () => assert.fail('must keep exact interactive hit'),
|
||||
};
|
||||
assert.equal(pickContact(scene, { x: 1, y: 1 }), exact);
|
||||
}
|
||||
});
|
||||
|
||||
test('touch expansion stops at a non-surface object and respects the drill bound', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const hits of [
|
||||
[mapTile, { id: 'custom-annotation' }, { id: 'civil' }],
|
||||
[...Array(8).fill(mapTile), { id: 'civil' }],
|
||||
[mapTile],
|
||||
]) {
|
||||
assert.equal(
|
||||
pickContact(
|
||||
{ pick: () => mapTile, drillPick: () => hits },
|
||||
{ x: 1, y: 1 },
|
||||
),
|
||||
mapTile,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('fine primary pointers keep the existing exact mouse pick without a drill', (t) => {
|
||||
primaryPointer(t, false);
|
||||
contactOwners(t);
|
||||
const scene = {
|
||||
pick: () => mapTile,
|
||||
drillPick: () => assert.fail('mouse must not expand'),
|
||||
};
|
||||
assert.equal(pickContact(scene, { x: 1, y: 1 }), mapTile);
|
||||
});
|
||||
|
||||
test('touch picking tolerates unavailable or failing drill support and preserves empty sky', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const drillPick of [
|
||||
undefined,
|
||||
() => {
|
||||
throw new Error('context lost');
|
||||
},
|
||||
() => [],
|
||||
]) {
|
||||
assert.equal(
|
||||
pickContact({ pick: () => undefined, drillPick }, { x: 1, y: 1 }),
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -13,8 +13,11 @@ function approximateDistanceM(latA, lonA, latB, lonB) {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
const latitudeScale = 111320;
|
||||
const longitudeScale = latitudeScale * Math.cos((latA * Math.PI) / 180);
|
||||
// The short way round, so places either side of 180° stay close.
|
||||
let dLon = lonB - lonA;
|
||||
dLon -= 360 * Math.round(dLon / 360);
|
||||
return Math.round(
|
||||
Math.hypot((latB - latA) * latitudeScale, (lonB - lonA) * longitudeScale),
|
||||
Math.hypot((latB - latA) * latitudeScale, dLon * longitudeScale),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,34 +49,12 @@ export const buildRadioTunerBand = layer.buildRadioTunerBand;
|
||||
export const radioTuningStaticShouldPlay = layer.radioTuningStaticShouldPlay;
|
||||
export const getRadioAcceptedCatalogSnapshot =
|
||||
layer.getRadioAcceptedCatalogSnapshot;
|
||||
export const radioStationResolutionMatches =
|
||||
layer.radioStationResolutionMatches;
|
||||
export const getRadioUIState = layer.getRadioUIState;
|
||||
export const subscribeToRadio = layer.subscribeToRadio;
|
||||
export const subscribeToRadioPlaybackControls =
|
||||
layer.subscribeToRadioPlaybackControls;
|
||||
export const getRadioTunerStations = layer.getRadioTunerStations;
|
||||
export const beginRadioTuning = layer.beginRadioTuning;
|
||||
export const setRadioTuningStatic = layer.setRadioTuningStatic;
|
||||
export const radioCameraNavigationAllowed = layer.radioCameraNavigationAllowed;
|
||||
export const previewRadioTuningStation = layer.previewRadioTuningStation;
|
||||
export const endRadioTuning = layer.endRadioTuning;
|
||||
export const cancelRadioTuning = layer.cancelRadioTuning;
|
||||
export const commitRadioTuningStation = layer.commitRadioTuningStation;
|
||||
export const playSelectedRadio = layer.playSelectedRadio;
|
||||
export const confirmRadioPlayback = layer.confirmRadioPlayback;
|
||||
export const playPreparedRadioForVoice = layer.playPreparedRadioForVoice;
|
||||
export const stopRadioPlayback = layer.stopRadioPlayback;
|
||||
export const toggleRadioPlayback = layer.toggleRadioPlayback;
|
||||
export const pauseRadioPlayback = layer.pauseRadioPlayback;
|
||||
export const setRadioVolume = layer.setRadioVolume;
|
||||
export const setRadioParams = layer.setRadioParams;
|
||||
export const getRadioParams = layer.getRadioParams;
|
||||
export const setRadioVoiceDucking = layer.setRadioVoiceDucking;
|
||||
export const selectRadioStation = layer.selectRadioStation;
|
||||
export const cycleRadioStation = layer.cycleRadioStation;
|
||||
export const selectRequestedRadioStation = layer.selectRequestedRadioStation;
|
||||
export const setRadioFilter = layer.setRadioFilter;
|
||||
export const retainRadioClusterIdentitiesForStations =
|
||||
layer.retainRadioClusterIdentitiesForStations;
|
||||
export {
|
||||
@@ -86,10 +64,8 @@ export {
|
||||
RADIO_SINGLETON_MID_LIMIT,
|
||||
RADIO_SINGLETON_NEAR_LIMIT,
|
||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
RADIO_GLOBE_RECENTER_MAX_HEIGHT_M,
|
||||
RADIO_OVERLAY_SOURCE_OPTIONS,
|
||||
DEFAULT_RADIO_FILTER,
|
||||
GLOBAL_RADIO_ALTITUDE_M,
|
||||
} from '../layers/radio/index.js';
|
||||
export default layer;
|
||||
|
||||
|
||||
+53
-3
@@ -712,6 +712,58 @@ test('unusable directory responses preserve warm client state atomically', async
|
||||
assert.equal(getRadioUIState().stale, true);
|
||||
assert.equal(getRadioUIState().degraded, true);
|
||||
}
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement, { id: 'malformed' }], stale: false, degraded: false,
|
||||
acceptedGeneration: 2, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioUIState().stationCount, 1);
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 1);
|
||||
assert.equal(getRadioUIState().degraded, true);
|
||||
assert.equal(getRadioUIState().stale, false);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().stations[0].id, replacement.id);
|
||||
// Server-side structural rejections are combined with client rejections
|
||||
// after admission; the healthy generation is still accepted.
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement, { id: 'malformed' }], stale: false, degraded: false,
|
||||
acceptedGeneration: 3, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
rejectedStationCount: 2,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 3);
|
||||
assert.equal(getRadioUIState().degraded, true);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 3);
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement], stale: false, degraded: false,
|
||||
acceptedGeneration: 4, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
rejectedStationCount: 10_000,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 10_000);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
|
||||
for (const rejectedStationCount of [-1, 1.5, '2', null, 10_001]) {
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement], stale: false, degraded: false,
|
||||
acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
rejectedStationCount,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
|
||||
assert.equal(getRadioUIState().stale, true);
|
||||
}
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [{ id: 'malformed' }], stale: false, degraded: false,
|
||||
acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
|
||||
assert.equal(getRadioUIState().stale, true);
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 10_000);
|
||||
radioLayer.destroy();
|
||||
radioLayer.init(viewer);
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 0);
|
||||
assert.equal(getRadioUIState().degraded, false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
radioLayer.destroy();
|
||||
@@ -1779,7 +1831,6 @@ test('voice playback confirmation waits for playing and requires a hard duck', a
|
||||
});
|
||||
|
||||
test('voice playback confirmation accepts fallback buffering but times out safely', async () => {
|
||||
let listener = () => {};
|
||||
const state = {
|
||||
audioState: 'loading',
|
||||
playingStationId: 'fallback',
|
||||
@@ -1789,9 +1840,8 @@ test('voice playback confirmation accepts fallback buffering but times out safel
|
||||
const confirmed = await confirmRadioPlayback({
|
||||
startPlayback: async () => false,
|
||||
subscribe: (next) => {
|
||||
listener = next;
|
||||
next(state);
|
||||
return () => { listener = () => {}; };
|
||||
return () => {};
|
||||
},
|
||||
getState: () => state,
|
||||
timeoutMs: 10,
|
||||
|
||||
@@ -117,8 +117,3 @@ export function normalizeRadioCountryInput(value) {
|
||||
name: canonicalCountryName(code),
|
||||
});
|
||||
}
|
||||
|
||||
/** Return whether a value is a recognized ISO 3166-1 alpha-2 code. */
|
||||
export function isRadioCountryCode(value) {
|
||||
return typeof value === 'string' && ISO_ALPHA_2_SET.has(value.toUpperCase());
|
||||
}
|
||||
@@ -11,7 +11,14 @@ import {
|
||||
publicRadioStation,
|
||||
publicRadioHttpsUrl,
|
||||
} from '../../vite.config.js';
|
||||
import { rankRadioStationsForRequest } from './radio.js';
|
||||
import {
|
||||
getRadioAcceptedCatalogSnapshot,
|
||||
getRadioUIState,
|
||||
radioLayer,
|
||||
rankRadioStationsForRequest,
|
||||
} from './radio.js';
|
||||
import { RADIO_REJECTED_STATION_COUNT_MAX as SERVER_REJECTED_MAX } from '../../server/providers/radio/constants.js';
|
||||
import { RADIO_REJECTED_STATION_COUNT_MAX as CLIENT_REJECTED_MAX } from '../layers/radio/policy.js';
|
||||
|
||||
const UUID = '12345678-1234-4234-8234-123456789abc';
|
||||
const publicLookup = async () => [{ address: '93.184.216.34', family: 4 }];
|
||||
@@ -533,3 +540,62 @@ test('a mirror answering an invalid reason phrase rejects instead of crashing',
|
||||
const crashed = mockMirrorStatus(t, 200, 'OK\x7f');
|
||||
await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError);
|
||||
});
|
||||
|
||||
|
||||
test('case expansion is capped after tag normalization in the portable and server station path', () => {
|
||||
const row = normalizeRadioBrowserStation(station({tags: 'İ'.repeat(80) + ', NEWS__RADIO ,news-radio'}));
|
||||
assert.equal(row.tags[0].length, 80);
|
||||
assert.equal(row.tags[0], 'i\u0307'.repeat(40));
|
||||
assert.deepEqual(row.tags.slice(1), ['news radio']);
|
||||
});
|
||||
|
||||
test('server structural rejections reach the client diagnostic without discarding a healthy generation', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const middleware = createProxy({
|
||||
now: () => Date.now(),
|
||||
fetchImpl: async (url) => {
|
||||
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
|
||||
const rows = healthyRowsForQuery(url);
|
||||
if (queryTag(url)) return responseJson(rows);
|
||||
// 400 valid broad rows, one row missing its required name, and one
|
||||
// schema-valid row that normalization filters by policy (not counted).
|
||||
const { name: _name, ...nameless } = station({ stationuuid: '50000000-0000-4000-8000-000000000001' });
|
||||
return responseJson([...rows, nameless, station({ stationuuid: '50000000-0000-4000-8000-000000000002', lastcheckok: 0 })]);
|
||||
},
|
||||
});
|
||||
const response = await invoke(middleware, '/stations');
|
||||
assert.equal(response.status, 200);
|
||||
const body = JSON.parse(response.body);
|
||||
assert.equal(body.degraded, false);
|
||||
assert.equal(body.degradedReason, null);
|
||||
assert.equal(body.acceptedGeneration, 1);
|
||||
assert.equal(body.rejectedStationCount, 1);
|
||||
assert.equal(SERVER_REJECTED_MAX, CLIENT_REJECTED_MAX);
|
||||
|
||||
const viewer = {
|
||||
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
|
||||
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
|
||||
dataSources: { add() {}, remove() {} },
|
||||
entities: { add(entity) { return entity; }, remove() {} },
|
||||
};
|
||||
radioLayer.destroy();
|
||||
try {
|
||||
globalThis.fetch = async () => new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
radioLayer.init(viewer);
|
||||
radioLayer.enable();
|
||||
await radioLayer.update();
|
||||
const state = getRadioUIState();
|
||||
assert.equal(state.stationCount, body.stations.length);
|
||||
assert.equal(state.rejectedStationCount, 1);
|
||||
assert.equal(state.degraded, true);
|
||||
assert.equal(state.stale, false);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 1);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().instance, body.catalogInstance);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
radioLayer.destroy();
|
||||
}
|
||||
});
|
||||
@@ -28,4 +28,3 @@ export {
|
||||
createCableReferenceSweepGate,
|
||||
updateCableReferenceStem,
|
||||
} from '../layers/submarineCables/index.js';
|
||||
export default createTeleGeographySubmarineCableLayer();
|
||||
@@ -930,7 +930,6 @@ for (const fixture of LAYERS) {
|
||||
}
|
||||
|
||||
test('both layers gate the tracked-model load and record its failures', async () => {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
for (const name of ['flights.js', 'militaryFlights.js']) {
|
||||
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||
assert.match(source, /!(?:flightState\.)?_trackedModel\s*&&\s*!(?:flightState\.)?_trackedModelLoading\s*&&\s*(?:parts\.\w+\.)?_trackedModelLoadAllowed\(\s*,?\s*\)/,
|
||||
@@ -946,7 +945,6 @@ test('both layers gate the tracked-model load and record its failures', async ()
|
||||
});
|
||||
|
||||
test('the tracked regime never consults the fleet models3d toggle', async () => {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
for (const name of ['flights.js', 'militaryFlights.js']) {
|
||||
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||
const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
bindTrackingClickGesture,
|
||||
isTrackingClickGesture,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
import { createWindLayer as createLayer } from '../layers/wind/index.js';
|
||||
import { createWindSource } from '../layers/wind/source.js';
|
||||
|
||||
const sourceSlot = createSourceSlot(
|
||||
@@ -8,10 +7,3 @@ const sourceSlot = createSourceSlot(
|
||||
'Wind source',
|
||||
);
|
||||
export const configureWindSource = sourceSlot.configure;
|
||||
|
||||
/** Create a wind layer using the configured application source by default. */
|
||||
export function createWindLayer(options = {}) {
|
||||
return createLayer({ ...options, feed: options.feed ?? sourceSlot.source });
|
||||
}
|
||||
|
||||
export default createWindLayer();
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Body classes that mean "another surface owns the screen": cockpit, scene
|
||||
* playback, recording and clean view. First run yields to them and the phone
|
||||
* shell closes its sheets for them.
|
||||
*/
|
||||
export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([
|
||||
'cockpit-mode',
|
||||
'scene-playback-mode',
|
||||
'recording-mode',
|
||||
'ui-clean-view',
|
||||
]);
|
||||
@@ -22,6 +22,7 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
||||
// is enthusiasm, not "never show me this again".
|
||||
|
||||
import { t, msg } from './i18n/index.js';
|
||||
import { EXCLUSIVE_SURFACE_CLASSES } from './exclusiveSurfaces.js';
|
||||
|
||||
/** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */
|
||||
export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1';
|
||||
@@ -305,12 +306,7 @@ export async function runFirstRunChoice(
|
||||
* hides the launcher in CSS, and the observer below turns that into a yield —
|
||||
* see the ESC ARBITRATION note on initFirstRunExperience.
|
||||
*/
|
||||
export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([
|
||||
'cockpit-mode',
|
||||
'scene-playback-mode',
|
||||
'recording-mode',
|
||||
'ui-clean-view',
|
||||
]);
|
||||
export { EXCLUSIVE_SURFACE_CLASSES };
|
||||
|
||||
/**
|
||||
* Is some other surface currently claiming the screen?
|
||||
|
||||
@@ -247,6 +247,10 @@ test('no storage is touched from a default parameter position', () => {
|
||||
|
||||
test('the JS and CSS lists of screen-claiming surfaces stay in step', () => {
|
||||
const module = fs.readFileSync(
|
||||
new URL('./exclusiveSurfaces.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const firstRun = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
@@ -279,6 +283,8 @@ test('the JS and CSS lists of screen-claiming surfaces stay in step', () => {
|
||||
module,
|
||||
/export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/,
|
||||
);
|
||||
// First run keeps exporting the one shared list (the phone shell reads it too).
|
||||
assert.match(firstRun, /export \{ EXCLUSIVE_SURFACE_CLASSES \};/);
|
||||
});
|
||||
|
||||
test('exclusiveSurfaceActive reads the live body classes', () => {
|
||||
@@ -373,10 +379,6 @@ test('one ESC does one thing — the radio disclosure stops the launcher outrigh
|
||||
new URL('./ui/radioBindings.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// stopPropagation() does NOT stop later listeners on the SAME document, so the
|
||||
// disclosure's earlier capture handler closed the disclosure and the launcher
|
||||
@@ -986,6 +988,16 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
]);
|
||||
// Cyber deliberately adds one layout; first-run missions still change no tools.
|
||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||
const cameraMotion = legacyTools.find((tool) => tool.name === 'move_camera')
|
||||
.parameters.properties.motion;
|
||||
assert.deepEqual(cameraMotion.enum, [
|
||||
'orbit',
|
||||
'pan',
|
||||
'tilt',
|
||||
'rotate',
|
||||
'rotate_globe',
|
||||
'stop',
|
||||
]);
|
||||
const block = JSON.stringify(legacyTools);
|
||||
// Re-derived for the voice layer manifest (generated layer enums, alias and
|
||||
// field hints), point-and-ask's pointer/referent arguments and the prompt
|
||||
@@ -995,10 +1007,14 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
// list descriptions; the missions still ride existing tools.
|
||||
// Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft.
|
||||
// Air quality (ECCC AQHI) extends the manifest.
|
||||
assert.equal(block.length, 30655, 'serialized tool schema length drifted');
|
||||
// Globe rotation adds one camera motion and its descriptions; Globe View
|
||||
// framing/default guidance updates only move_camera and mode descriptions.
|
||||
// Radio markup and the complete production-request comparison pin locality.
|
||||
// Restore the base camera/Globe View wording with the globe-rotation append.
|
||||
assert.equal(block.length, 30878, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e',
|
||||
'7b95f0f4258896fe5c3316265a19f3a6602809d4e7e681ee7ecd8f2df514ef1f',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(
|
||||
|
||||
+1
-1
@@ -210,7 +210,7 @@ export function formatRelativeTime(value, unit = 'second', options) {
|
||||
}
|
||||
|
||||
/** Collapse markup whitespace so a message reads the same in HTML and JS. */
|
||||
export function normalizeMarkupText(text) {
|
||||
function normalizeMarkupText(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
@@ -2943,5 +2943,9 @@
|
||||
"hemisphere|E": "E",
|
||||
"hemisphere|W": "O",
|
||||
"OK": "OK",
|
||||
"{category} ({count})": "{category} ({count})"
|
||||
"{category} ({count})": "{category} ({count})",
|
||||
"Layers": "Capas",
|
||||
"Search": "Buscar",
|
||||
"More": "Más",
|
||||
"Panels": "Paneles"
|
||||
}
|
||||
@@ -3010,5 +3010,9 @@
|
||||
"hemisphere|E": "В",
|
||||
"hemisphere|W": "З",
|
||||
"OK": "НОРМА",
|
||||
"{category} ({count})": "{category} ({count})"
|
||||
"{category} ({count})": "{category} ({count})",
|
||||
"Layers": "Слои",
|
||||
"Search": "Поиск",
|
||||
"More": "Ещё",
|
||||
"Panels": "Панели"
|
||||
}
|
||||
@@ -2809,5 +2809,9 @@
|
||||
"Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Gemini 语音会话有时长限制。会话到期后,请重新启动语音以开始新对话。",
|
||||
"Show the street-level view full screen (Esc shrinks it)": "全屏显示街景视图(按 Esc 缩小)",
|
||||
"OK": "正常",
|
||||
"{category} ({count})": "{category}({count})"
|
||||
"{category} ({count})": "{category}({count})",
|
||||
"Layers": "图层",
|
||||
"Search": "搜索",
|
||||
"More": "更多",
|
||||
"Panels": "面板"
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
|
||||
@@ -3,16 +3,12 @@ import * as Cesium from 'cesium';
|
||||
import {
|
||||
LAYER_ID,
|
||||
REQUEST_DEBOUNCE_MS,
|
||||
QUERY_LIMIT,
|
||||
MAX_RENDERED,
|
||||
QUERY_SNAP_DEGREES,
|
||||
QUERY_REUSE_MS,
|
||||
ALPR_COLOR,
|
||||
} from './policy.js';
|
||||
import {
|
||||
snapAlprBox,
|
||||
boxContains,
|
||||
destinationPointDeg,
|
||||
alprRetryDelayMs,
|
||||
validateAlprSnapshot,
|
||||
alprCreditMarkup,
|
||||
@@ -84,9 +80,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
viewportBox,
|
||||
clearRendered,
|
||||
hideOnMapCredit,
|
||||
presentOnMapCredit,
|
||||
renderRecords,
|
||||
selectRecord,
|
||||
focusNearest,
|
||||
clearSelection,
|
||||
updateSelectedAnchor,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js';
|
||||
|
||||
export function textTag(value) {
|
||||
function textTag(value) {
|
||||
const t = String(value ?? '').trim();
|
||||
return t || null;
|
||||
}
|
||||
|
||||
export function numTag(value) {
|
||||
function numTag(value) {
|
||||
// `Number('')` and `Number(' ')` are 0, not NaN — a blank or whitespace-only
|
||||
// tag value must read as missing, never as a due-north bearing.
|
||||
if (value == null) return null;
|
||||
@@ -102,7 +102,7 @@ export function normalizeAlprNode(el) {
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDirection(value) {
|
||||
function normalizeDirection(value) {
|
||||
const degrees = numTag(value);
|
||||
return degrees != null && degrees >= 0 && degrees <= 360
|
||||
? degrees % 360
|
||||
|
||||
@@ -3,7 +3,7 @@ import { VectorTile } from '@mapbox/vector-tile';
|
||||
import { normalizeAlprNode } from './records.js';
|
||||
|
||||
/** Map extract attributes without treating an OSM edit date as a field verification. */
|
||||
export function normalizeAlprTileFeature(feature) {
|
||||
function normalizeAlprTileFeature(feature) {
|
||||
const p = feature?.properties || {};
|
||||
if (feature?.geometry?.type !== 'Point' || p.osmType !== 'node') return null;
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
export const AIRCRAFT_DEPENDENCIES = ['flights', 'military'];
|
||||
|
||||
export const DEFERRED_DEPENDENCIES = [
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
];
|
||||
const DEFERRED_DEPENDENCIES = ['ais-live-vessels', 'military-installations'];
|
||||
|
||||
export const DEPENDENCIES = [
|
||||
...AIRCRAFT_DEPENDENCIES,
|
||||
|
||||
@@ -60,7 +60,7 @@ test('the layer reports failure when a failed city left it with nothing', () =>
|
||||
});
|
||||
|
||||
test('a layer with nothing to draw and nothing failed is not an error', () => {
|
||||
const { state, methods } = makeLayer();
|
||||
const { methods } = makeLayer();
|
||||
|
||||
// Out of range of every city is empty, not broken.
|
||||
assert.equal(methods.getStats().error, undefined);
|
||||
|
||||
@@ -11,7 +11,7 @@ export const BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
// --- Activation / display thresholds ---
|
||||
/** Altitude (m) at which bikeshare layer becomes eligible for display. */
|
||||
|
||||
export const ACTIVATION_ALTITUDE_M = 50000;
|
||||
const ACTIVATION_ALTITUDE_M = 50000;
|
||||
|
||||
/** Hysteresis enter threshold — layer activates when camera drops below this. */
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js';
|
||||
import { CAMERA_SEEDS } from './policy.js';
|
||||
import {
|
||||
lookupModelSpec,
|
||||
horizontalFovRangeDeg,
|
||||
|
||||
@@ -5,9 +5,6 @@ import {
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
PROJECTION_MATTE_COLOR,
|
||||
PROJECTION_TEXTURE_SWAP_MS,
|
||||
ACTIVE_FRAME_REFRESH_MS,
|
||||
FRAME_ENDPOINT,
|
||||
MEDIA_ENDPOINT,
|
||||
PROJECTION_ACTIVE_REFRESH_MS,
|
||||
PROJECTION_IDLE_REFRESH_MS,
|
||||
PLACEHOLDER_REPAINT_MS,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js';
|
||||
import {
|
||||
PROJECTION_VERT_ASPECT,
|
||||
FRUSTUM_GROUND_CLEARANCE_M,
|
||||
PROBE_MIN_RANGE_M,
|
||||
PROBE_CLEARANCE_M,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HEALTH_SYNC_INTERVAL_MS, HEALTH_ENDPOINT } from './policy.js';
|
||||
import { HEALTH_SYNC_INTERVAL_MS } from './policy.js';
|
||||
|
||||
export function createHealth({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
export {
|
||||
FRAME_ENDPOINT,
|
||||
SOURCE_ENDPOINT,
|
||||
HEALTH_ENDPOINT,
|
||||
MEDIA_ENDPOINT,
|
||||
ACTIVE_FRAME_REFRESH_MS,
|
||||
} from './sourcePolicy.js';
|
||||
export { ACTIVE_FRAME_REFRESH_MS } from './sourcePolicy.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js';
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
@@ -90,9 +84,6 @@ export const CCTV_CALIBRATION_STORAGE_KEY_V2 =
|
||||
|
||||
export const PROJECTION_TEXTURE_SWAP_MS = 1000;
|
||||
|
||||
export const PROJECTION_VERT_ASPECT =
|
||||
PROJECTION_CANVAS_WIDTH / PROJECTION_CANVAS_HEIGHT;
|
||||
|
||||
// V2 frustum geometry (design §2a/§6): the far-cap center + corners never sink
|
||||
// below groundAlt + this clearance, so a fabricated pitch (-24°) cannot bury
|
||||
// the monitor plane in the 3D tiles. Exported for the unit suite.
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
export const FRAME_ENDPOINT = '/api/cctv/frame';
|
||||
|
||||
export const SOURCE_ENDPOINT = '/api/cctv/sources';
|
||||
|
||||
export const HEALTH_ENDPOINT = '/api/cctv/health';
|
||||
|
||||
export const MEDIA_ENDPOINT = '/api/cctv/media';
|
||||
|
||||
export const ACTIVE_FRAME_REFRESH_MS = 10000;
|
||||
@@ -64,7 +64,7 @@ const SHARED_CARD = Object.freeze({
|
||||
* @param {boolean} selected
|
||||
* @returns {object}
|
||||
*/
|
||||
export function cycloneStormEntry(storm, selected) {
|
||||
function cycloneStormEntry(storm, selected) {
|
||||
return {
|
||||
...SHARED_CARD,
|
||||
...markerClearance(
|
||||
@@ -92,7 +92,7 @@ export function cycloneStormEntry(storm, selected) {
|
||||
* @param {{tauHours:number,position:object}} point
|
||||
* @returns {object}
|
||||
*/
|
||||
export function cycloneLeadEntry(stormId, point) {
|
||||
function cycloneLeadEntry(stormId, point) {
|
||||
return {
|
||||
...SHARED_CARD,
|
||||
...markerClearance(CYCLONE_MARKER_PX.forecast),
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
export {
|
||||
GROUND_FLOOR_CLAMP_RADIUS_KM,
|
||||
GROUND_FLOOR_WARM_MAX_ALT_M,
|
||||
POSITION_HISTORY_LIMIT,
|
||||
LANDED_MISSING_POLL_LIMIT,
|
||||
MISSING_POLL_LIMIT,
|
||||
ERROR_BACKOFF_INTERVAL,
|
||||
} from './recordPolicy.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
|
||||
|
||||
|
||||
@@ -580,7 +580,6 @@ export function createQueries({
|
||||
const maxRange =
|
||||
Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY;
|
||||
|
||||
const now = Cesium.JulianDate.now();
|
||||
const nearby = [];
|
||||
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
|
||||
@@ -47,7 +47,7 @@ export function createTracking({
|
||||
const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry;
|
||||
const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout;
|
||||
const { applyTrackedCameraFrame } = services.camera;
|
||||
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
|
||||
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
|
||||
const flightsLayer = layer;
|
||||
|
||||
function _emitAwarenessEvent(type, detail) {
|
||||
@@ -1174,7 +1174,9 @@ export function createTracking({
|
||||
// first-person reference. A globe click must not fall through to the
|
||||
// normal empty-space deselection path; cockpit has explicit exit controls.
|
||||
if (document.body.classList.contains('cockpit-mode')) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const picked = pickContact
|
||||
? pickContact(viewer.scene, click.position)
|
||||
: viewer.scene.pick(click.position);
|
||||
|
||||
if (picked) {
|
||||
// Clicking the tracked entity itself — ignore (don't deselect)
|
||||
|
||||
@@ -2,8 +2,6 @@ export const LAYER_ID = 'military-installations';
|
||||
|
||||
export const REQUEST_DEBOUNCE_MS = 180;
|
||||
|
||||
export const MAX_VIEWPORT_DEGREES = 10;
|
||||
|
||||
export const MAX_RENDERED = 700;
|
||||
|
||||
export const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']);
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Event } from 'cesium';
|
||||
import { createLaunchHover } from './lifecycle.js';
|
||||
|
||||
function fakeTimers() {
|
||||
const timers = new Map();
|
||||
let nextId = 1;
|
||||
const api = {
|
||||
setTimer(fn, ms) {
|
||||
const id = nextId++;
|
||||
timers.set(id, { fn, ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
pending: () => [...timers.values()],
|
||||
flush() {
|
||||
const due = [...timers.entries()];
|
||||
timers.clear();
|
||||
for (const [, timer] of due) timer.fn();
|
||||
},
|
||||
};
|
||||
return api;
|
||||
}
|
||||
|
||||
function hoverRig({ overMarker }) {
|
||||
let at = 0,
|
||||
picks = 0,
|
||||
enabled = true,
|
||||
free = true;
|
||||
const timers = fakeTimers();
|
||||
const viewer = {
|
||||
camera: { moveStart: new Event(), moveEnd: new Event() },
|
||||
scene: {
|
||||
canvas: { style: { cursor: '' } },
|
||||
drillPick(position) {
|
||||
picks++;
|
||||
return overMarker(position) ? [{ id: 'launch' }] : [];
|
||||
},
|
||||
},
|
||||
};
|
||||
const hover = createLaunchHover({
|
||||
viewer,
|
||||
enabled: () => enabled,
|
||||
entityLaunchId: (id) => id === 'launch',
|
||||
now: () => at,
|
||||
documentRef: { hidden: false },
|
||||
pointerFree: () => free,
|
||||
setTimer: timers.setTimer,
|
||||
clearTimer: timers.clearTimer,
|
||||
});
|
||||
return {
|
||||
hover,
|
||||
timers,
|
||||
viewer,
|
||||
cursor: () => viewer.scene.canvas.style.cursor,
|
||||
picks: () => picks,
|
||||
setTime: (value) => (at = value),
|
||||
setEnabled: (value) => (enabled = value),
|
||||
setFree: (value) => (free = value),
|
||||
};
|
||||
}
|
||||
|
||||
const onMarker = (position) => position?.x === 1;
|
||||
const marker = () => ({ endPosition: { x: 1, y: 1 } });
|
||||
const empty = () => ({ endPosition: { x: 9, y: 9 } });
|
||||
|
||||
test('mission hover runs one trailing check for a pointer that stops on empty space', () => {
|
||||
const rig = hoverRig({ overMarker: onMarker });
|
||||
rig.hover.move(marker());
|
||||
assert.equal(rig.cursor(), 'pointer');
|
||||
rig.setTime(50);
|
||||
const movement = empty();
|
||||
rig.hover.move(movement);
|
||||
// Cesium reuses its movement object; the trailing check must use a copy.
|
||||
movement.endPosition.x = 1;
|
||||
assert.equal(rig.picks(), 1);
|
||||
assert.deepEqual(
|
||||
rig.timers.pending().map((timer) => timer.ms),
|
||||
[70],
|
||||
);
|
||||
rig.setTime(80);
|
||||
rig.hover.move(empty());
|
||||
assert.equal(rig.timers.pending().length, 1, 'one trailing check at a time');
|
||||
rig.setTime(120);
|
||||
rig.timers.flush();
|
||||
assert.equal(rig.picks(), 2);
|
||||
assert.equal(rig.cursor(), '');
|
||||
assert.equal(rig.timers.pending().length, 0);
|
||||
rig.hover.destroy();
|
||||
});
|
||||
|
||||
test('mission hover trailing check sets the cursor for a pointer that stops on a mission', () => {
|
||||
const rig = hoverRig({ overMarker: onMarker });
|
||||
rig.hover.move(empty());
|
||||
assert.equal(rig.cursor(), '');
|
||||
rig.setTime(50);
|
||||
rig.hover.move(marker());
|
||||
rig.setTime(120);
|
||||
rig.timers.flush();
|
||||
assert.equal(rig.picks(), 2);
|
||||
assert.equal(rig.cursor(), 'pointer');
|
||||
rig.hover.destroy();
|
||||
assert.equal(rig.cursor(), '');
|
||||
});
|
||||
|
||||
test('mission hover trailing check is cancelled by reset, destroy, and camera motion', () => {
|
||||
for (const cancel of [
|
||||
(rig) => rig.hover.reset(),
|
||||
(rig) => rig.hover.destroy(),
|
||||
(rig) => rig.viewer.camera.moveStart.raiseEvent(),
|
||||
]) {
|
||||
const rig = hoverRig({ overMarker: onMarker });
|
||||
rig.hover.move(empty());
|
||||
rig.setTime(50);
|
||||
rig.hover.move(marker());
|
||||
assert.equal(rig.timers.pending().length, 1);
|
||||
cancel(rig);
|
||||
assert.equal(rig.timers.pending().length, 0);
|
||||
assert.equal(rig.picks(), 1);
|
||||
assert.equal(rig.cursor(), '');
|
||||
rig.hover.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('mission hover trailing check rechecks eligibility before picking', () => {
|
||||
for (const block of [
|
||||
(rig) => rig.setEnabled(false),
|
||||
(rig) => rig.setFree(false),
|
||||
]) {
|
||||
const rig = hoverRig({ overMarker: onMarker });
|
||||
rig.hover.move(marker());
|
||||
rig.setTime(50);
|
||||
rig.hover.move(marker());
|
||||
block(rig);
|
||||
rig.setTime(120);
|
||||
rig.timers.flush();
|
||||
assert.equal(rig.picks(), 1);
|
||||
assert.equal(rig.cursor(), '');
|
||||
rig.hover.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('mission hover throttles, clears on motion/hidden, and releases listeners', () => {
|
||||
let at = 0,
|
||||
picks = 0,
|
||||
enabled = true;
|
||||
const listeners = new Set();
|
||||
const doc = {
|
||||
hidden: false,
|
||||
addEventListener: (_n, fn) => listeners.add(fn),
|
||||
removeEventListener: (_n, fn) => listeners.delete(fn),
|
||||
};
|
||||
const viewer = {
|
||||
camera: { moveStart: new Event(), moveEnd: new Event() },
|
||||
scene: {
|
||||
canvas: { style: {} },
|
||||
drillPick() {
|
||||
picks++;
|
||||
return [{ id: 'launch' }];
|
||||
},
|
||||
},
|
||||
};
|
||||
const hover = createLaunchHover({
|
||||
viewer,
|
||||
enabled: () => enabled,
|
||||
entityLaunchId: (id) => id === 'launch',
|
||||
now: () => at,
|
||||
documentRef: doc,
|
||||
pointerFree: () => true,
|
||||
...fakeTimers(),
|
||||
});
|
||||
for (at = 0; at < 120; at++) hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 1);
|
||||
assert.equal(viewer.scene.canvas.style.cursor, 'pointer');
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 2);
|
||||
viewer.camera.moveStart.raiseEvent();
|
||||
at = 500;
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 2);
|
||||
assert.equal(viewer.scene.canvas.style.cursor, '');
|
||||
viewer.camera.moveEnd.raiseEvent();
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 3);
|
||||
doc.hidden = true;
|
||||
for (const fn of listeners) fn();
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 3);
|
||||
assert.equal(viewer.scene.canvas.style.cursor, '');
|
||||
doc.hidden = false;
|
||||
enabled = false;
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 3);
|
||||
hover.destroy();
|
||||
assert.equal(listeners.size, 0);
|
||||
assert.equal(viewer.camera.moveStart.numberOfListeners, 0);
|
||||
assert.equal(viewer.camera.moveEnd.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('mission hover preserves the cursor of a pointer-owning tool', () => {
|
||||
let free = true,
|
||||
picks = 0;
|
||||
const viewer = {
|
||||
camera: { moveStart: new Event(), moveEnd: new Event() },
|
||||
scene: {
|
||||
canvas: { style: { cursor: '' } },
|
||||
drillPick() {
|
||||
picks++;
|
||||
return [{ id: 'launch' }];
|
||||
},
|
||||
},
|
||||
};
|
||||
const hover = createLaunchHover({
|
||||
viewer,
|
||||
enabled: () => true,
|
||||
entityLaunchId: () => true,
|
||||
documentRef: null,
|
||||
pointerFree: () => free,
|
||||
});
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(viewer.scene.canvas.style.cursor, 'pointer');
|
||||
free = false;
|
||||
viewer.scene.canvas.style.cursor = 'crosshair';
|
||||
hover.move({ endPosition: {} });
|
||||
viewer.camera.moveStart.raiseEvent();
|
||||
hover.destroy();
|
||||
assert.equal(picks, 1);
|
||||
assert.equal(viewer.scene.canvas.style.cursor, 'crosshair');
|
||||
});
|
||||
@@ -1,6 +1,116 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
const HOVER_PICK_INTERVAL_MS = 120;
|
||||
|
||||
/**
|
||||
* Bound hover picking independently of precise click selection. A move inside
|
||||
* the throttle window is not dropped: its position is kept and one trailing
|
||||
* check runs when the window ends, so a pointer that stops is still resolved.
|
||||
*/
|
||||
export function createLaunchHover({
|
||||
viewer,
|
||||
enabled,
|
||||
entityLaunchId,
|
||||
now = () => performance.now(),
|
||||
documentRef = globalThis.document,
|
||||
pointerFree = isPointerFree,
|
||||
setTimer = globalThis.setTimeout,
|
||||
clearTimer = globalThis.clearTimeout,
|
||||
}) {
|
||||
let moving = false;
|
||||
let lastPick = -Infinity;
|
||||
let ownsCursor = false;
|
||||
let pendingPosition = null;
|
||||
let trailingTimer = null;
|
||||
const clearCursor = () => {
|
||||
if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer')
|
||||
viewer.scene.canvas.style.cursor = '';
|
||||
ownsCursor = false;
|
||||
};
|
||||
const cancelTrailing = () => {
|
||||
if (trailingTimer !== null) clearTimer(trailingTimer);
|
||||
trailingTimer = null;
|
||||
pendingPosition = null;
|
||||
};
|
||||
const reset = () => {
|
||||
cancelTrailing();
|
||||
clearCursor();
|
||||
lastPick = -Infinity;
|
||||
};
|
||||
const start = () => {
|
||||
moving = true;
|
||||
reset();
|
||||
};
|
||||
const end = () => {
|
||||
moving = false;
|
||||
reset();
|
||||
};
|
||||
const visibility = () => {
|
||||
if (documentRef?.hidden || documentRef?.visibilityState === 'hidden')
|
||||
reset();
|
||||
};
|
||||
const eligible = () =>
|
||||
enabled() &&
|
||||
!moving &&
|
||||
!documentRef?.hidden &&
|
||||
documentRef?.visibilityState !== 'hidden' &&
|
||||
pointerFree();
|
||||
const pick = (position, at) => {
|
||||
lastPick = at;
|
||||
const found = viewer.scene
|
||||
.drillPick(position, 12)
|
||||
.some((picked) => entityLaunchId(picked?.id));
|
||||
if (found) {
|
||||
viewer.scene.canvas.style.cursor = 'pointer';
|
||||
ownsCursor = true;
|
||||
} else clearCursor();
|
||||
};
|
||||
const trailing = () => {
|
||||
trailingTimer = null;
|
||||
const position = pendingPosition;
|
||||
pendingPosition = null;
|
||||
if (!position) return;
|
||||
// Eligibility may have changed while the check waited.
|
||||
if (!eligible()) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
pick(position, now());
|
||||
};
|
||||
const removeStart = viewer.camera?.moveStart?.addEventListener(start);
|
||||
const removeEnd = viewer.camera?.moveEnd?.addEventListener(end);
|
||||
documentRef?.addEventListener?.('visibilitychange', visibility);
|
||||
return {
|
||||
move(movement) {
|
||||
if (!eligible()) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const at = now();
|
||||
const wait = HOVER_PICK_INTERVAL_MS - (at - lastPick);
|
||||
if (wait > 0) {
|
||||
// Cesium reuses the movement object, so keep a copy of the position.
|
||||
pendingPosition = Cesium.Cartesian2.clone(
|
||||
movement.endPosition,
|
||||
pendingPosition ?? undefined,
|
||||
);
|
||||
if (trailingTimer === null) trailingTimer = setTimer(trailing, wait);
|
||||
return;
|
||||
}
|
||||
cancelTrailing();
|
||||
pick(movement.endPosition, at);
|
||||
},
|
||||
reset,
|
||||
destroy() {
|
||||
reset();
|
||||
removeStart?.();
|
||||
removeEnd?.();
|
||||
documentRef?.removeEventListener?.('visibilitychange', visibility);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -8,6 +118,7 @@ export function createLifecycle({
|
||||
source,
|
||||
}) {
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
let hover = null;
|
||||
|
||||
const methods = {
|
||||
init(viewer) {
|
||||
@@ -45,14 +156,15 @@ export function createLifecycle({
|
||||
layerState._moveHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
viewer.scene.canvas,
|
||||
);
|
||||
layerState._moveHandler.setInputAction((movement) => {
|
||||
if (!layerState._enabled || !layerState._dataSource?.show) return;
|
||||
const missionEntity = viewer.scene
|
||||
.drillPick(movement.endPosition, 12)
|
||||
.map((picked) => picked?.id)
|
||||
.find((candidate) => parts.selection.entityLaunchId(candidate));
|
||||
viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : '';
|
||||
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
||||
hover = createLaunchHover({
|
||||
viewer,
|
||||
enabled: () => layerState._enabled && layerState._dataSource?.show,
|
||||
entityLaunchId: parts.selection.entityLaunchId,
|
||||
});
|
||||
layerState._moveHandler.setInputAction(
|
||||
hover.move,
|
||||
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
|
||||
);
|
||||
// Apply horizon visibility before Cesium traverses and picks the scene.
|
||||
// A postRender write is one frame late and can remain visibly stale when
|
||||
// request-on-demand rendering stops after a globe camera move.
|
||||
@@ -100,6 +212,7 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
async disable() {
|
||||
hover?.reset();
|
||||
layerState._sourceController.abort();
|
||||
layerState._enabled = false;
|
||||
releaseContinuousRender('rocket-launches');
|
||||
@@ -122,6 +235,8 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
async destroy(viewer) {
|
||||
hover?.destroy();
|
||||
hover = null;
|
||||
layerState._sourceController.abort();
|
||||
releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix)
|
||||
layerState._enabled = false;
|
||||
|
||||
@@ -109,7 +109,7 @@ export function createRendering({
|
||||
);
|
||||
});
|
||||
if (satelliteTrack) layerState._orbitMatches++;
|
||||
const entity = layerState._dataSource.entities.add({
|
||||
layerState._dataSource.entities.add({
|
||||
id: `rocket-launch:${launch.id}`,
|
||||
position,
|
||||
point: {
|
||||
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
|
||||
/** Same-origin route served by `server/providers/local-receivers.js`. */
|
||||
export const LOCAL_RECEIVERS_URL = '/api/local-receivers/aircraft';
|
||||
export const LOCAL_RECEIVER_POLL_MS = 1_000;
|
||||
const LOCAL_RECEIVER_POLL_MS = 1_000;
|
||||
/**
|
||||
* Browser-side deadline for one route request, body included. The server
|
||||
* bounds each upstream read at 2 s; this bounds the browser-to-server leg.
|
||||
*/
|
||||
export const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000;
|
||||
const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000;
|
||||
|
||||
function abortError() {
|
||||
const error = new Error('Local receiver request aborted');
|
||||
|
||||
@@ -36,18 +36,8 @@ import {
|
||||
} from './policy.js';
|
||||
import { localAdsbStatus } from './status.js';
|
||||
|
||||
export {
|
||||
LAYER_ID as LOCAL_ADSB_LAYER_ID,
|
||||
HEARD_BY_RECEIVER,
|
||||
} from './policy.js';
|
||||
export {
|
||||
localAdsbCardModel,
|
||||
localAdsbClassLine,
|
||||
localAdsbIsUat,
|
||||
localAdsbReceiverLine,
|
||||
localAdsbTitle,
|
||||
} from './card.js';
|
||||
export { localAdsbClass } from './enrichment.js';
|
||||
export { HEARD_BY_RECEIVER } from './policy.js';
|
||||
export { localAdsbCardModel, localAdsbReceiverLine } from './card.js';
|
||||
export { localAdsbStatus } from './status.js';
|
||||
|
||||
const FEET_TO_METERS = 0.3048;
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
export const LOCAL_ADSB_HISTORY_MS = 10 * 60_000;
|
||||
export const LOCAL_ADSB_HISTORY_POINTS = 600;
|
||||
/** A display correction larger than this snaps instead of sliding. */
|
||||
export const LOCAL_ADSB_SNAP_M = 3_000;
|
||||
const LOCAL_ADSB_SNAP_M = 3_000;
|
||||
/** Chord course uses the newest fix at least this much older than the anchor. */
|
||||
const CHORD_BASELINE_MS = 2_000;
|
||||
/** Turn rate is estimated over this window of heard fixes. */
|
||||
|
||||
@@ -24,7 +24,7 @@ function plural(count, one, many) {
|
||||
* @param {object[]} feeds Feed statuses from the route.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function describeFeedProblems(feeds) {
|
||||
function describeFeedProblems(feeds) {
|
||||
const byStatus = new Map();
|
||||
for (const feed of feeds) {
|
||||
if (feed?.status === 'live') continue;
|
||||
@@ -41,7 +41,7 @@ export function describeFeedProblems(feeds) {
|
||||
}
|
||||
|
||||
/** Whether the server reported at least one configured feed. */
|
||||
export function feedsConfigured(feedState) {
|
||||
function feedsConfigured(feedState) {
|
||||
return Boolean(
|
||||
feedState?.configured === true &&
|
||||
Array.isArray(feedState.feeds) &&
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
export {
|
||||
GROUND_FLOOR_WARM_MAX_ALT_M,
|
||||
POSITION_HISTORY_LIMIT,
|
||||
LANDED_MISSING_POLL_LIMIT,
|
||||
MISSING_POLL_LIMIT,
|
||||
ERROR_BACKOFF_INTERVAL,
|
||||
} from './recordPolicy.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
|
||||
|
||||
|
||||
@@ -366,7 +366,6 @@ export function createQueries({
|
||||
const maxRange =
|
||||
Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY;
|
||||
|
||||
const now = Cesium.JulianDate.now();
|
||||
const nearby = [];
|
||||
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
|
||||
@@ -187,7 +187,6 @@ export function createMilitarySnapshotRenderer({
|
||||
// If this is the tracked aircraft, update label text
|
||||
// (position updates automatically via dead-reckoning CallbackProperty)
|
||||
if (isTracked && flightState._trackedEntity) {
|
||||
const info = flightState.records.data.get(icao24);
|
||||
tracking._updateTrackedLabelModel(icao24);
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 305 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user