mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge remote-tracking branch 'upstream/main' into fix/drone-airspace
# Conflicts: # docs/CURRENT-STATE.md # src/app/constructCatalog.test.mjs # src/app/sourceComposition.test.mjs # src/data/layerState.test.mjs # src/data/layerStateTokenLedger.test.mjs # src/i18n/locales/ar.json
This commit is contained in:
195 files changed
+23414
-1578
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { cameraPoseSignature } from '../../data/iconOrientation.js';
|
||||
import { AWARENESS_RANGE_PULSE } from './policy.js';
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
@@ -8,6 +9,86 @@ export function createLifecycle({
|
||||
}) {
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
|
||||
/**
|
||||
* Note the user handling the map's camera (pointer, wheel or touch on the
|
||||
* Cesium canvas, where its camera controller listens), so a camera move the
|
||||
* user made can wake the range pulses and one the app made (tracking,
|
||||
* cockpit, orbit, a fly-to) does not. Keys are left out: Cesium's camera
|
||||
* controller reads none, and typing elsewhere must not count. Nor does
|
||||
* input while the camera controller's inputs are off: the cockpit (or a
|
||||
* gizmo drag, or a drawing tool) owns the camera then, and the user's
|
||||
* gesture moves nothing.
|
||||
*/
|
||||
function noteCameraInput(event) {
|
||||
if (event?.type === 'pointermove' && !event.buttons) return;
|
||||
if (!cameraInputsEnabled()) return;
|
||||
layerState.lastCameraInputMs = Date.now();
|
||||
if (event?.type === 'pointerdown') activePointers.add(event.pointerId);
|
||||
if (event?.type === 'pointerdown' || event?.type === 'touchstart')
|
||||
followGesture(true);
|
||||
}
|
||||
|
||||
// A drag can leave the canvas, and Cesium follows it at document level
|
||||
// (in Firefox, and wherever there are no pointer events). So does this,
|
||||
// from a gesture's start on the canvas to its end, so a long gesture keeps
|
||||
// the input window open the whole way.
|
||||
const GESTURE_MOVE_EVENTS = ['pointermove', 'touchmove'];
|
||||
const GESTURE_END_EVENTS = [
|
||||
'pointerup',
|
||||
'pointercancel',
|
||||
'touchend',
|
||||
'touchcancel',
|
||||
];
|
||||
let followingGesture = false;
|
||||
const activePointers = new Set();
|
||||
|
||||
function followGesture(on) {
|
||||
if (followingGesture === on) return;
|
||||
followingGesture = on;
|
||||
if (!on) activePointers.clear();
|
||||
const listen = on ? 'addEventListener' : 'removeEventListener';
|
||||
for (const type of GESTURE_MOVE_EVENTS)
|
||||
globalThis.document?.[listen]?.(type, noteCameraInput, { passive: true });
|
||||
for (const type of GESTURE_END_EVENTS)
|
||||
globalThis.document?.[listen]?.(type, endGesture, { passive: true });
|
||||
globalThis[listen]?.('blur', dropGesture);
|
||||
globalThis.document?.[listen]?.('visibilitychange', dropGesture);
|
||||
}
|
||||
|
||||
// A gesture ends with its last contact: lifting one finger of a pinch
|
||||
// leaves Cesium a one-finger drag.
|
||||
function endGesture(event) {
|
||||
noteCameraInput(event);
|
||||
activePointers.delete(event?.pointerId);
|
||||
if (activePointers.size || event?.touches?.length) return;
|
||||
followGesture(false);
|
||||
}
|
||||
|
||||
// Losing the window or the page mid-gesture means its end may never
|
||||
// arrive here, so stop following it and drop its pending input.
|
||||
function dropGesture(event) {
|
||||
if (
|
||||
event?.type === 'visibilitychange' &&
|
||||
document.visibilityState !== 'hidden'
|
||||
)
|
||||
return;
|
||||
followGesture(false);
|
||||
layerState.lastCameraInputMs = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
function cameraInputsEnabled() {
|
||||
const controller = layerState.viewer?.scene?.screenSpaceCameraController;
|
||||
return controller?.enableInputs !== false;
|
||||
}
|
||||
|
||||
const CAMERA_INPUT_EVENTS = [
|
||||
'pointerdown',
|
||||
'pointermove',
|
||||
'pointerup',
|
||||
'wheel',
|
||||
'touchstart',
|
||||
];
|
||||
|
||||
/**
|
||||
* Quantized camera-pose signature, or '' when the camera cannot report a full
|
||||
* pose yet. An unknown pose reads as PARKED, so a camera that is still coming
|
||||
@@ -50,10 +131,18 @@ export function createLifecycle({
|
||||
'gev:awareness-subject-cleared',
|
||||
layerState.subjectClearListener,
|
||||
);
|
||||
const input = layerState.viewer.canvas ?? layerState.viewer.scene?.canvas;
|
||||
for (const type of CAMERA_INPUT_EVENTS)
|
||||
input?.addEventListener?.(type, noteCameraInput, { passive: true });
|
||||
layerState.cameraInputTarget = input ?? null;
|
||||
layerState.preRenderRemover =
|
||||
layerState.viewer.scene.preRender.addEventListener(() => {
|
||||
if (!layerState.enabled) return;
|
||||
const now = Date.now();
|
||||
// Another owner took the camera: a gesture begun before it did must
|
||||
// not wake the pulses on that owner's first move.
|
||||
if (!cameraInputsEnabled())
|
||||
layerState.lastCameraInputMs = Number.NEGATIVE_INFINITY;
|
||||
// "Did the camera move" uses the SAME quantized pose signature the fleet
|
||||
// rotation pass gates on (iconOrientation.cameraPoseSignature) rather than
|
||||
// camera.changed, whose granularity is globally degraded by other layers
|
||||
@@ -62,6 +151,14 @@ export function createLifecycle({
|
||||
if (poseSig !== layerState.lastCameraPoseSig) {
|
||||
layerState.lastCameraPoseSig = poseSig;
|
||||
layerState.lastCameraPoseChangeMs = now;
|
||||
// A camera move the user made runs the range pulses again; parked,
|
||||
// or moved by the app (tracking, cockpit, orbit, a fly-to), they
|
||||
// rest.
|
||||
if (
|
||||
now - layerState.lastCameraInputMs <
|
||||
AWARENESS_RANGE_PULSE.inputWakeMs
|
||||
)
|
||||
parts.rendering.wakePulses();
|
||||
}
|
||||
const decision = parts.model.awarenessRefreshDecision({
|
||||
nowMs: now,
|
||||
@@ -108,6 +205,13 @@ export function createLifecycle({
|
||||
'gev:awareness-subject-cleared',
|
||||
layerState.subjectClearListener,
|
||||
);
|
||||
for (const type of CAMERA_INPUT_EVENTS)
|
||||
layerState.cameraInputTarget?.removeEventListener?.(
|
||||
type,
|
||||
noteCameraInput,
|
||||
);
|
||||
layerState.cameraInputTarget = null;
|
||||
followGesture(false);
|
||||
}
|
||||
layerState.preRenderRemover?.();
|
||||
layerState.preRenderRemover = null;
|
||||
|
||||
@@ -44,6 +44,93 @@ export const AWARENESS_MAX_NAVIGATION_EXAMPLES = 10000;
|
||||
|
||||
export const CONTEXT_RIM_HEIGHT_M = 2500;
|
||||
|
||||
/** @constant The 250 km range ring around the Global Context subject.
|
||||
* The old 1 px ellipse outline vanished over desert, snow and under
|
||||
* the FLIR, NVG and CRT styles. A bright core inside a dark halo keeps
|
||||
* luminance contrast on light and dark ground alike, and luminance is what
|
||||
* the monochrome styles keep. The core is the Context panel's blue; the halo
|
||||
* is the map-card ink. Widths are screen pixels; `outlineWidth` is per side.
|
||||
* It is drawn as an unpickable primitive, so clicks on its edge reach the
|
||||
* contacts underneath.
|
||||
* While the sonar pulses run, the ring dims to `restOpacity` and flashes
|
||||
* to full as each pulse reaches it, fading over `flashMs`. `restOpacity`
|
||||
* keeps the 250 km edge readable between flashes, in screenshots and for
|
||||
* low-vision viewers. Once the pulses rest, and without pulses (reduced
|
||||
* motion, or a scene that cannot animate), it stays on screen at full. */
|
||||
export const AWARENESS_RANGE_RING_STYLE = Object.freeze({
|
||||
color: 'rgba(98, 181, 255, 0.95)',
|
||||
outlineColor: 'rgba(4, 12, 16, 0.8)',
|
||||
width: 4,
|
||||
outlineWidth: 1,
|
||||
segments: 128,
|
||||
flashMs: 1800,
|
||||
restOpacity: 0.4,
|
||||
});
|
||||
|
||||
/** @constant Sonar-style pulses that grow slowly from the subject to the
|
||||
* range ring and are almost gone when they reach it. `count` pulses share
|
||||
* one `periodMs`, evenly staggered, so a new one starts every
|
||||
* `periodMs / count`. Each starts at `startFraction` of the radius, fades in
|
||||
* over `fadeInFraction` of its travel, then fades as (1 - travel) to the
|
||||
* `fadeOutPower` down to `endOpacityFraction` of `peakOpacity`, so a pulse
|
||||
* is dim but still visible when it reaches the ring and the ring flashes.
|
||||
* Each line is `width` CSS pixels wide, its outer `outlineWidth` a dark
|
||||
* halo, in the ring's colours, as Cesium's polyline outline material draws.
|
||||
* Behind each line a radial gradient in the same blue fades inward over
|
||||
* `gradientDepthFraction` of the radius, at most
|
||||
* `gradientOpacity` strong. One unpickable disc draws the lines and the
|
||||
* gradient, so pulses never catch a click meant for a contact; its shader
|
||||
* takes at most four pulses. The disc follows the subject by a model-matrix
|
||||
* rotation, so it stays concentric with the ring, and is rebuilt only after
|
||||
* the subject moves `gradientRebuildM` from where it was built; while the
|
||||
* pulses rest the hidden disc is left alone and catches up when they wake. The
|
||||
* disc is hidden while the camera is below its height, where it would sweep
|
||||
* the sky.
|
||||
* The pulses run for `activeMs` (three ring flashes) after a selection or
|
||||
* a camera move, fading in and out over `settleMs`, then rest: the ring
|
||||
* stays on screen, static, and nothing asks for frames, so a parked view
|
||||
* goes quiet again. Only a camera move within `inputWakeMs` of the user
|
||||
* handling the map (pointer, wheel or touch on the canvas, which covers a
|
||||
* drag's inertia, while the camera controls take input) wakes them; a moving subject and the app's own camera moves
|
||||
* (tracking, cockpit, orbit, a fly-to) do not. Below the disc's height they
|
||||
* rest too. While they run they never hold
|
||||
* continuous rendering; each drawn frame arms one more request after the
|
||||
* Cyber sonar's 80 ms cadence, so the two never beat. When something else
|
||||
* already renders continuously they update every frame. Skipped under
|
||||
* prefers-reduced-motion. */
|
||||
export const AWARENESS_RANGE_PULSE = Object.freeze({
|
||||
count: 3,
|
||||
periodMs: 9000,
|
||||
startFraction: 0.02,
|
||||
fadeInFraction: 0.08,
|
||||
fadeOutPower: 1.5,
|
||||
endOpacityFraction: 0.35,
|
||||
peakOpacity: 0.6,
|
||||
width: 3,
|
||||
outlineWidth: 1,
|
||||
gradientDepthFraction: 0.35,
|
||||
gradientOpacity: 0.22,
|
||||
gradientGranularityDeg: 0.25,
|
||||
gradientRebuildM: 20000,
|
||||
activeMs: 9000,
|
||||
settleMs: 1200,
|
||||
inputWakeMs: 1500,
|
||||
});
|
||||
|
||||
/** @constant Sonar echoes on contact brackets. When a pulse front reaches a
|
||||
* flight, military aircraft or AIS vessel in the window, the detection
|
||||
* overlay draws a second bracket in `color` around that contact's own
|
||||
* bracket. It starts on the bracket and spreads outward by up to `spreadPx`
|
||||
* as it fades over `fadeMs`, so the frame pulses. Only `layerIds` echo;
|
||||
* mapped installations have no detection bracket. */
|
||||
export const AWARENESS_RANGE_ECHO = Object.freeze({
|
||||
layerIds: ['flights', 'military', 'ais-live-vessels'],
|
||||
fadeMs: 1400,
|
||||
color: 'rgb(98, 181, 255)',
|
||||
spreadPx: 8,
|
||||
lineWidth: 1.75,
|
||||
});
|
||||
|
||||
export const VESSEL_FOCUS_RADIUS_M = 3000;
|
||||
|
||||
export const SOURCE_LABEL = {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
CYBER_SONAR_RENDER_INTERVAL_MS,
|
||||
createCyberSonarSampler,
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
} from '../../cyberSonar.js';
|
||||
@@ -10,8 +11,339 @@ import {
|
||||
AWARENESS_RADIUS_M,
|
||||
} from '../../data/militaryAwarenessEngine.js';
|
||||
import { bearingBetweenCoordinates } from '../../cockpitMath.js';
|
||||
import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js';
|
||||
import {
|
||||
AWARENESS_PAGE_SIZE,
|
||||
AWARENESS_RANGE_ECHO,
|
||||
AWARENESS_RANGE_PULSE,
|
||||
AWARENESS_RANGE_RING_STYLE,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
} from './policy.js';
|
||||
import { formatAwarenessDirection } from './presentation.js';
|
||||
import {
|
||||
awarenessEchoStrength,
|
||||
clearAwarenessEchoField,
|
||||
setAwarenessEchoField,
|
||||
} from '../../awarenessEcho.js';
|
||||
|
||||
/**
|
||||
* A closed ring of positions `radiusM` along the surface from `groundCenter`,
|
||||
* raised to `heightM`. Each bearing steps over a sphere with the ellipsoid's
|
||||
* radius of curvature in that direction (Euler's formula), which keeps the
|
||||
* ring within ~50 m of the true geodesic radius at 250 km.
|
||||
* @param {Cesium.Cartesian3} groundCenter Ring centre on the ellipsoid.
|
||||
* @param {number} radiusM Surface radius in metres.
|
||||
* @param {number} heightM Height of the ring above the ellipsoid.
|
||||
* @param {number} [segments] Chords around the ring.
|
||||
* @returns {Cesium.Cartesian3[]} `segments + 1` positions; the last repeats the first.
|
||||
*/
|
||||
export function awarenessRangeRingPositions(
|
||||
groundCenter,
|
||||
radiusM,
|
||||
heightM,
|
||||
segments = AWARENESS_RANGE_RING_STYLE.segments,
|
||||
) {
|
||||
const { latitude, longitude } =
|
||||
Cesium.Cartographic.fromCartesian(groundCenter);
|
||||
const { x: a, z: b } = Cesium.Ellipsoid.WGS84.radii;
|
||||
const e2 = 1 - (b * b) / (a * a);
|
||||
const sinLat = Math.sin(latitude);
|
||||
const cosLat = Math.cos(latitude);
|
||||
const sinLon = Math.sin(longitude);
|
||||
const cosLon = Math.cos(longitude);
|
||||
const w = 1 - e2 * sinLat * sinLat;
|
||||
const meridional = (a * (1 - e2)) / (w * Math.sqrt(w));
|
||||
const primeVertical = a / Math.sqrt(w);
|
||||
// Step along the great circle in 3D from the centre's up, north and east
|
||||
// unit vectors. Unlike the latitude/longitude destination formula, this
|
||||
// stays well conditioned at and next to the poles.
|
||||
const up = [cosLat * cosLon, cosLat * sinLon, sinLat];
|
||||
const north = [-sinLat * cosLon, -sinLat * sinLon, cosLat];
|
||||
const east = [-sinLon, cosLon, 0];
|
||||
const positions = [];
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const bearing = ((i % segments) / segments) * Cesium.Math.TWO_PI;
|
||||
const cosB = Math.cos(bearing);
|
||||
const sinB = Math.sin(bearing);
|
||||
const angular =
|
||||
radiusM * ((cosB * cosB) / meridional + (sinB * sinB) / primeVertical);
|
||||
const cosA = Math.cos(angular);
|
||||
const sinA = Math.sin(angular);
|
||||
const [x, y, z] = up.map(
|
||||
(u, k) => u * cosA + (north[k] * cosB + east[k] * sinB) * sinA,
|
||||
);
|
||||
const lat = Math.atan2(z, Math.hypot(x, y));
|
||||
const lon = Math.atan2(y, x);
|
||||
positions.push(Cesium.Cartesian3.fromRadians(lon, lat, heightM));
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where pulse `index` is at `nowMs`: its radius and opacity. Pulses travel at
|
||||
* constant speed from `startFraction` of the range to the full range, fade in
|
||||
* over `fadeInFraction` of the way, then fade as (1 - travel) to the
|
||||
* `fadeOutPower` down to `endOpacityFraction` of the peak, so each is dim
|
||||
* but still visible when it reaches the range ring. The opacity already
|
||||
* includes `peakOpacity`.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @param {number} index Pulse number, 0 to `count - 1`.
|
||||
* @param {number} [radiusM] Range the pulses grow to.
|
||||
* @param {object} [pulse] Timing and shape, see `AWARENESS_RANGE_PULSE`.
|
||||
* @returns {{radiusM: number, alpha: number}}
|
||||
*/
|
||||
export function awarenessPulseState(
|
||||
nowMs,
|
||||
index,
|
||||
radiusM = AWARENESS_RADIUS_M,
|
||||
pulse = AWARENESS_RANGE_PULSE,
|
||||
) {
|
||||
const cycle = nowMs / pulse.periodMs + index / pulse.count;
|
||||
const phase = cycle - Math.floor(cycle);
|
||||
return {
|
||||
radiusM:
|
||||
radiusM * (pulse.startFraction + (1 - pulse.startFraction) * phase),
|
||||
alpha:
|
||||
pulse.peakOpacity *
|
||||
Math.min(1, phase / pulse.fadeInFraction) *
|
||||
(pulse.endOpacityFraction +
|
||||
(1 - pulse.endOpacityFraction) * (1 - phase) ** pulse.fadeOutPower),
|
||||
};
|
||||
}
|
||||
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
/** Whether the viewer asked the system for less motion. */
|
||||
function prefersReducedMotion() {
|
||||
return globalThis.matchMedia?.(REDUCED_MOTION_QUERY)?.matches ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the pulses are switched on at `nowMs`: 0 at rest, rising to 1 over
|
||||
* `settleMs` from `wake.startMs` and falling back to 0 over `settleMs` up to
|
||||
* `wake.untilMs`.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @param {{startMs: number, untilMs: number}} wake Window from `wakeAwarenessPulses`.
|
||||
* @param {object} [pulse] Timing, see `AWARENESS_RANGE_PULSE`.
|
||||
* @returns {number} 0 to 1.
|
||||
*/
|
||||
export function awarenessPulseLevel(
|
||||
nowMs,
|
||||
wake,
|
||||
pulse = AWARENESS_RANGE_PULSE,
|
||||
) {
|
||||
const level = Math.min(
|
||||
(nowMs - wake.startMs) / pulse.settleMs,
|
||||
(wake.untilMs - nowMs) / pulse.settleMs,
|
||||
);
|
||||
return Math.max(0, Math.min(1, level));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pulses for `activeMs` from `nowMs`. A wake while they already run
|
||||
* keeps their current level, so they never jump back to full mid-fade.
|
||||
* @param {{startMs: number, untilMs: number}} wake Updated in place.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @param {object} [pulse] Timing, see `AWARENESS_RANGE_PULSE`.
|
||||
* @returns {{startMs: number, untilMs: number}} `wake`.
|
||||
*/
|
||||
export function wakeAwarenessPulses(
|
||||
wake,
|
||||
nowMs,
|
||||
pulse = AWARENESS_RANGE_PULSE,
|
||||
) {
|
||||
wake.startMs =
|
||||
nowMs - awarenessPulseLevel(nowMs, wake, pulse) * pulse.settleMs;
|
||||
wake.untilMs = nowMs + pulse.activeMs;
|
||||
return wake;
|
||||
}
|
||||
|
||||
/**
|
||||
* How strongly the 250 km ring shows at `nowMs` while pulses run: 1 as a
|
||||
* pulse front reaches it, fading over `flashMs`, so the ring appears once
|
||||
* per arriving pulse instead of standing on screen.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @returns {number} 0 to 1.
|
||||
*/
|
||||
export function awarenessRingFlash(nowMs) {
|
||||
return awarenessEchoStrength(
|
||||
nowMs,
|
||||
AWARENESS_RADIUS_M,
|
||||
AWARENESS_RADIUS_M,
|
||||
AWARENESS_RANGE_PULSE,
|
||||
{ fadeMs: AWARENESS_RANGE_RING_STYLE.flashMs },
|
||||
);
|
||||
}
|
||||
|
||||
/** Most pulses the pulse shader draws; its uniforms are vec4s. */
|
||||
export const AWARENESS_PULSE_GRADIENT_MAX = 4;
|
||||
|
||||
/** The pulse disc's Cesium material type, one cached template per page. */
|
||||
const AWARENESS_PULSE_MATERIAL_TYPE = 'GevAwarenessPulseGradient';
|
||||
|
||||
// Ellipse texture coordinates put the centre at (0.5, 0.5) and the rim at
|
||||
// distance 0.5, so `r` is the fraction of the range. Each pulse is a line
|
||||
// `lineWidth` CSS pixels wide whose outer `haloWidth` is a dark halo, half
|
||||
// on each side, as Cesium's polyline outline material draws it, with a band
|
||||
// behind it that is strongest at the line and fades to nothing `depth`
|
||||
// inward. Widths come from the screen-space gradient of `r`, as Cesium's
|
||||
// contour material measures its lines; without derivatives the lines are
|
||||
// left out and only the band shows.
|
||||
const PULSE_GRADIENT_SOURCE = `
|
||||
uniform vec4 color;
|
||||
uniform vec4 lineColor;
|
||||
uniform vec4 haloColor;
|
||||
uniform vec4 radii;
|
||||
uniform vec4 alphas;
|
||||
uniform float depth;
|
||||
uniform float lineWidth;
|
||||
uniform float haloWidth;
|
||||
vec4 over(vec4 top, vec4 under) {
|
||||
float a = top.a + under.a * (1.0 - top.a);
|
||||
if (a <= 0.0) return vec4(0.0);
|
||||
return vec4((top.rgb * top.a + under.rgb * under.a * (1.0 - top.a)) / a, a);
|
||||
}
|
||||
czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
float r = length(materialInput.st - vec2(0.5)) * 2.0;
|
||||
float band = 0.0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
float inside = radii[i] - r;
|
||||
if (inside >= 0.0 && inside <= depth) {
|
||||
float t = 1.0 - inside / depth;
|
||||
band += alphas[i] * t * t;
|
||||
}
|
||||
}
|
||||
vec4 result = vec4(color.rgb, clamp(band * color.a, 0.0, 1.0));
|
||||
#if (__VERSION__ == 300 || defined(GL_OES_standard_derivatives))
|
||||
// Change in r per CSS pixel: the gradient's length per device pixel,
|
||||
// times the device pixels in one CSS pixel.
|
||||
float cssPx = max(length(vec2(dFdx(r), dFdy(r))), 1e-7) * czm_pixelRatio;
|
||||
float outerHalf = lineWidth * 0.5;
|
||||
float coreHalf = max(lineWidth - haloWidth, 0.0) * 0.5;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
float d = abs(radii[i] - r) / cssPx;
|
||||
float cover = clamp(outerHalf + 0.5 - d, 0.0, 1.0);
|
||||
if (cover <= 0.0) continue;
|
||||
float core = clamp(coreHalf + 0.5 - d, 0.0, 1.0);
|
||||
vec4 pulse = mix(haloColor, lineColor, core);
|
||||
result = over(vec4(pulse.rgb, pulse.a * cover * alphas[i]), result);
|
||||
}
|
||||
#endif
|
||||
material.diffuse = result.rgb;
|
||||
material.alpha = result.a;
|
||||
return material;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* The pulses: one flat, translucent, unpickable disc the size of the range
|
||||
* ring whose shader draws each pulse line and the band behind it. Drawing
|
||||
* them here rather than as entities keeps them out of picking, so clicks and
|
||||
* taps near a pulse still reach the contact underneath. Moving pulses only
|
||||
* change its uniforms; the disc is rebuilt only when the subject moves.
|
||||
* @param {Cesium.Cartesian3} groundCenter Disc centre on the ellipsoid.
|
||||
* @param {Cesium.Material} material Shared pulse-gradient material.
|
||||
* @returns {Cesium.Primitive}
|
||||
*/
|
||||
function createPulseGradient(groundCenter, material) {
|
||||
return new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.EllipseGeometry({
|
||||
center: groundCenter,
|
||||
semiMajorAxis: AWARENESS_RADIUS_M,
|
||||
semiMinorAxis: AWARENESS_RADIUS_M,
|
||||
height: CONTEXT_RIM_HEIGHT_M,
|
||||
granularity: Cesium.Math.toRadians(
|
||||
AWARENESS_RANGE_PULSE.gradientGranularityDeg,
|
||||
),
|
||||
vertexFormat:
|
||||
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat,
|
||||
}),
|
||||
}),
|
||||
appearance: new Cesium.MaterialAppearance({
|
||||
material,
|
||||
flat: true,
|
||||
translucent: true,
|
||||
faceForward: true,
|
||||
}),
|
||||
asynchronous: false,
|
||||
allowPicking: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The ring's polyline geometry: `AWARENESS_RANGE_RING_STYLE.width` screen
|
||||
* pixels, straight chords between the given positions (they already follow
|
||||
* the surface), in the material appearance's vertex format.
|
||||
* @param {Cesium.Cartesian3[]} positions Ring positions.
|
||||
* @returns {object} PolylineGeometry options.
|
||||
*/
|
||||
export function awarenessRingGeometryOptions(positions) {
|
||||
return {
|
||||
positions,
|
||||
width: AWARENESS_RANGE_RING_STYLE.width,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The 250 km ring as an unpickable polyline primitive in the ring's outline
|
||||
* material, so a click or tap on its edge reaches the contact underneath.
|
||||
* Its positions are geometry, so a moved subject rebuilds it; the material
|
||||
* is shared between builds so a flash keeps going.
|
||||
* @param {Cesium.Cartesian3[]} positions Ring positions.
|
||||
* @param {Cesium.Material} material Shared PolylineOutline material.
|
||||
* @returns {Cesium.Primitive}
|
||||
*/
|
||||
function createRingPrimitive(positions, material) {
|
||||
return new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.PolylineGeometry(
|
||||
awarenessRingGeometryOptions(positions),
|
||||
),
|
||||
}),
|
||||
appearance: new Cesium.PolylineMaterialAppearance({
|
||||
material,
|
||||
translucent: true,
|
||||
}),
|
||||
asynchronous: false,
|
||||
allowPicking: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The rotation about the Earth's centre that carries `from` onto `to`, as a
|
||||
* model matrix, so a disc built around `from` sits around `to`.
|
||||
* @param {Cesium.Cartesian3} from Where the disc was built.
|
||||
* @param {Cesium.Cartesian3} to Where it should be.
|
||||
* @param {Cesium.Matrix4} [result]
|
||||
* @returns {Cesium.Matrix4}
|
||||
*/
|
||||
export function discFollowMatrix(from, to, result = new Cesium.Matrix4()) {
|
||||
const a = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3());
|
||||
const b = Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3());
|
||||
const axis = Cesium.Cartesian3.cross(a, b, new Cesium.Cartesian3());
|
||||
const sin = Cesium.Cartesian3.magnitude(axis);
|
||||
if (sin < 1e-12) return Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY, result);
|
||||
const angle = Math.atan2(sin, Cesium.Cartesian3.dot(a, b));
|
||||
const rotation = Cesium.Matrix3.fromQuaternion(
|
||||
Cesium.Quaternion.fromAxisAngle(
|
||||
Cesium.Cartesian3.divideByScalar(axis, sin, axis),
|
||||
angle,
|
||||
),
|
||||
);
|
||||
return Cesium.Matrix4.fromRotationTranslation(
|
||||
rotation,
|
||||
Cesium.Cartesian3.ZERO,
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
/** Camera height above the ellipsoid, or NaN when the scene cannot say. */
|
||||
function cameraHeightM(scene) {
|
||||
return scene.camera?.positionCartographic?.height ?? Number.NaN;
|
||||
}
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
@@ -271,7 +603,62 @@ export function createRendering({
|
||||
presentDirection({ hidden: false, ring, heading, labels, markers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the range pulses again for `activeMs`, after a camera move. Call it
|
||||
* during a frame (lifecycle's preRender): that frame's postRender arms the
|
||||
* next request, so waking asks for no extra frame of its own.
|
||||
*/
|
||||
function wakePulses() {
|
||||
const wake = layerState.visual?.wake;
|
||||
if (!wake) return;
|
||||
wakeAwarenessPulses(wake, performance.now());
|
||||
followPulseDisc();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the pulse disc centred on the subject. Within `gradientRebuildM` of
|
||||
* where it was built, the disc is rotated about the Earth's centre onto
|
||||
* the subject by its model matrix, which keeps it concentric with the ring
|
||||
* at no build cost; the ellipsoid curves a little differently from that
|
||||
* sphere, a few tens of metres in height at 20 km. Further away it is
|
||||
* rebuilt, which costs a few milliseconds.
|
||||
*/
|
||||
function followPulseDisc() {
|
||||
const { gradient, gradientCenter, center } = layerState.visual;
|
||||
if (!gradient) return;
|
||||
if (
|
||||
Cesium.Cartesian3.distance(gradientCenter, center) <
|
||||
AWARENESS_RANGE_PULSE.gradientRebuildM
|
||||
) {
|
||||
gradient.modelMatrix = discFollowMatrix(
|
||||
gradientCenter,
|
||||
center,
|
||||
gradient.modelMatrix,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const primitives = layerState.viewer.scene.primitives;
|
||||
layerState.visual.gradient = primitives.add(
|
||||
createPulseGradient(center, gradient.appearance.material),
|
||||
);
|
||||
// Primitive.destroy leaves the material alone, so the new disc keeps
|
||||
// sharing it.
|
||||
primitives.remove(gradient);
|
||||
Cesium.Cartesian3.clone(center, gradientCenter);
|
||||
}
|
||||
|
||||
function clearVisual() {
|
||||
layerState.visual?.stopPulses?.();
|
||||
layerState.visual?.removeMotionListener?.();
|
||||
if (layerState.visual?.gradient && layerState.viewer)
|
||||
layerState.viewer.scene.primitives.remove(layerState.visual.gradient);
|
||||
if (layerState.visual?.ringPrimitive && layerState.viewer) {
|
||||
layerState.viewer.scene.primitives.remove?.(
|
||||
layerState.visual.ringPrimitive,
|
||||
);
|
||||
governorRequestRender('awareness-visual');
|
||||
}
|
||||
clearAwarenessEchoField();
|
||||
if (layerState.visual?.entities && layerState.viewer) {
|
||||
for (const entity of layerState.visual.entities)
|
||||
layerState.viewer.entities.remove(entity);
|
||||
@@ -291,17 +678,39 @@ export function createRendering({
|
||||
0,
|
||||
);
|
||||
const key = `${subject.layerId}:${subject.id}`;
|
||||
if (layerState.visual?.key === key) {
|
||||
// Large ellipse geometry is more reliable as a ConstantPositionProperty.
|
||||
// Move it only after a meaningful displacement so live tracking does not
|
||||
// churn ten large geometries for sub-pixel motion every 750 ms.
|
||||
const reducedMotion = prefersReducedMotion();
|
||||
// A reduced-motion change rebuilds the visual, with or without pulses.
|
||||
if (
|
||||
layerState.visual?.key === key &&
|
||||
layerState.visual.reducedMotion === reducedMotion
|
||||
) {
|
||||
// Move the ring only after a meaningful displacement so live tracking
|
||||
// does not rebuild its geometry for sub-pixel motion every 750 ms.
|
||||
if (
|
||||
Cesium.Cartesian3.distance(layerState.visual.center, groundCenter) >=
|
||||
250
|
||||
) {
|
||||
for (const entity of layerState.visual.entities)
|
||||
entity.position.setValue(groundCenter);
|
||||
const positions = awarenessRangeRingPositions(
|
||||
groundCenter,
|
||||
AWARENESS_RADIUS_M,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
);
|
||||
// The echo field reads `visual.center` in place and follows on its
|
||||
// own. The pulse disc follows by its model matrix and is rebuilt only
|
||||
// every `gradientRebuildM`: a build costs a few milliseconds.
|
||||
layerState.visual.ringPositions = positions;
|
||||
const { ringPrimitive } = layerState.visual;
|
||||
if (ringPrimitive) {
|
||||
const primitives = layerState.viewer.scene.primitives;
|
||||
layerState.visual.ringPrimitive = primitives.add(
|
||||
createRingPrimitive(positions, ringPrimitive.appearance.material),
|
||||
);
|
||||
primitives.remove(ringPrimitive);
|
||||
} else layerState.visual.ring.polyline.positions.setValue(positions);
|
||||
Cesium.Cartesian3.clone(groundCenter, layerState.visual.center);
|
||||
// A resting disc is hidden, so it catches up when the pulses wake.
|
||||
if (performance.now() < layerState.visual.wake?.untilMs)
|
||||
followPulseDisc();
|
||||
// Content actually changed — buy exactly one frame instead of holding.
|
||||
governorRequestRender('awareness-visual');
|
||||
}
|
||||
@@ -310,26 +719,188 @@ export function createRendering({
|
||||
clearVisual();
|
||||
|
||||
const entities = [];
|
||||
|
||||
entities.push(
|
||||
layerState.viewer.entities.add({
|
||||
position: groundCenter,
|
||||
ellipse: {
|
||||
semiMajorAxis: AWARENESS_RADIUS_M,
|
||||
semiMinorAxis: AWARENESS_RADIUS_M,
|
||||
fill: false,
|
||||
outline: true,
|
||||
outlineColor:
|
||||
Cesium.Color.fromCssColorString('#62b5ff').withAlpha(0.72),
|
||||
height: CONTEXT_RIM_HEIGHT_M,
|
||||
},
|
||||
}),
|
||||
const scene = layerState.viewer.scene;
|
||||
// A viewer without a primitive collection or a preRender hook (headless
|
||||
// tools, partial stubs) gets the static ring only, as the Cyber sonar
|
||||
// scene does. So does a viewer who prefers reduced motion.
|
||||
const animate = Boolean(
|
||||
!reducedMotion &&
|
||||
scene?.primitives?.add &&
|
||||
scene?.preRender?.addEventListener &&
|
||||
scene?.postRender?.addEventListener,
|
||||
);
|
||||
// Pulses run from now until `wake.untilMs`, then rest.
|
||||
const wake = animate
|
||||
? wakeAwarenessPulses(
|
||||
{ startMs: -Infinity, untilMs: -Infinity },
|
||||
performance.now(),
|
||||
)
|
||||
: null;
|
||||
// From a camera below the rim height the disc would sweep across the sky,
|
||||
// so it is hidden there, and the pulses rest with it: no flash, no
|
||||
// echoes, no frames.
|
||||
const belowRim = () => cameraHeightM(scene) < CONTEXT_RIM_HEIGHT_M;
|
||||
const levelAt = (nowMs) =>
|
||||
wake && !belowRim() ? awarenessPulseLevel(nowMs, wake) : 0;
|
||||
|
||||
// A polyline, not an ellipse outline: WebGL draws ellipse outlines at
|
||||
// 1 px whatever `outlineWidth` says, and the ring needs a halo.
|
||||
const ring = AWARENESS_RANGE_RING_STYLE;
|
||||
const core = Cesium.Color.fromCssColorString(ring.color);
|
||||
const halo = Cesium.Color.fromCssColorString(ring.outlineColor);
|
||||
const ringPositions = awarenessRangeRingPositions(
|
||||
groundCenter,
|
||||
AWARENESS_RADIUS_M,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
);
|
||||
// While pulses run, the ring flashes as each one arrives and otherwise
|
||||
// dims to `restOpacity`; as they settle it returns to full and stays.
|
||||
const ringAlpha = (nowMs) => {
|
||||
const pulsed = Math.max(ring.restOpacity, awarenessRingFlash(nowMs));
|
||||
return 1 - levelAt(nowMs) * (1 - pulsed);
|
||||
};
|
||||
let ringEntity = null;
|
||||
let ringMaterial = null;
|
||||
let ringPrimitive = null;
|
||||
if (scene?.primitives?.add) {
|
||||
ringMaterial = Cesium.Material.fromType('PolylineOutline', {
|
||||
color: core.clone(),
|
||||
outlineColor: halo.clone(),
|
||||
outlineWidth: ring.outlineWidth,
|
||||
});
|
||||
ringPrimitive =
|
||||
scene.primitives.add(
|
||||
createRingPrimitive(ringPositions, ringMaterial),
|
||||
) ?? null;
|
||||
}
|
||||
if (!ringPrimitive) {
|
||||
// Headless tools and partial stubs have no primitive collection; they
|
||||
// get the static ring as an entity.
|
||||
ringEntity = layerState.viewer.entities.add({
|
||||
polyline: {
|
||||
positions: new Cesium.ConstantProperty(ringPositions),
|
||||
width: ring.width,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
material: new Cesium.PolylineOutlineMaterialProperty({
|
||||
color: core,
|
||||
outlineColor: halo,
|
||||
outlineWidth: ring.outlineWidth,
|
||||
}),
|
||||
},
|
||||
});
|
||||
entities.push(ringEntity);
|
||||
}
|
||||
|
||||
const center = Cesium.Cartesian3.clone(groundCenter);
|
||||
let gradient = null;
|
||||
let stopPulses = null;
|
||||
if (animate) {
|
||||
const pulse = AWARENESS_RANGE_PULSE;
|
||||
const count = Math.min(pulse.count, AWARENESS_PULSE_GRADIENT_MAX);
|
||||
const uniforms = {
|
||||
color: core.clone(),
|
||||
lineColor: core.clone(),
|
||||
haloColor: halo.clone(),
|
||||
radii: new Cesium.Cartesian4(),
|
||||
alphas: new Cesium.Cartesian4(),
|
||||
depth: pulse.gradientDepthFraction,
|
||||
lineWidth: pulse.width,
|
||||
haloWidth: pulse.outlineWidth,
|
||||
};
|
||||
uniforms.color.alpha = pulse.gradientOpacity;
|
||||
// A named type: Cesium caches every material type for the page's
|
||||
// lifetime, so an unnamed fabric (a fresh GUID each time) would leave
|
||||
// one entry per new visual. Built from the cached template, the
|
||||
// uniforms come back as plain copies; restore this visual's own.
|
||||
const material = new Cesium.Material({
|
||||
fabric: {
|
||||
type: AWARENESS_PULSE_MATERIAL_TYPE,
|
||||
uniforms,
|
||||
source: PULSE_GRADIENT_SOURCE,
|
||||
},
|
||||
translucent: true,
|
||||
});
|
||||
Object.assign(material.uniforms, uniforms);
|
||||
gradient = scene.primitives.add(
|
||||
createPulseGradient(groundCenter, material),
|
||||
);
|
||||
const radii = [0, 0, 0, 0];
|
||||
const alphas = [0, 0, 0, 0];
|
||||
const removePreRender = scene.preRender.addEventListener(() => {
|
||||
const nowMs = performance.now();
|
||||
const level = levelAt(nowMs);
|
||||
const alpha = ringAlpha(nowMs);
|
||||
ringMaterial.uniforms.color.alpha = core.alpha * alpha;
|
||||
ringMaterial.uniforms.outlineColor.alpha = halo.alpha * alpha;
|
||||
// The disc is looked up each frame: a subject move swaps it.
|
||||
const disc = layerState.visual?.gradient;
|
||||
if (disc) disc.show = level > 0;
|
||||
for (let index = 0; index < count; index++) {
|
||||
const state = awarenessPulseState(nowMs, index);
|
||||
radii[index] = state.radiusM / AWARENESS_RADIUS_M;
|
||||
alphas[index] = state.alpha * level;
|
||||
}
|
||||
Cesium.Cartesian4.fromArray(radii, 0, material.uniforms.radii);
|
||||
Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas);
|
||||
});
|
||||
// Each drawn frame, whoever asked for it, arms one request a Cyber
|
||||
// sonar interval later, as the Cyber sonar scene does. Requests never
|
||||
// land within 80 ms of a frame, so they cannot beat the detection
|
||||
// lane's 80 ms gate, and they stop once the pulses rest.
|
||||
let timer = null;
|
||||
const removePostRender = scene.postRender.addEventListener(() => {
|
||||
globalThis.clearTimeout(timer);
|
||||
timer = null;
|
||||
if (performance.now() < wake.untilMs && !belowRim())
|
||||
timer = globalThis.setTimeout(() => {
|
||||
timer = null;
|
||||
governorRequestRender('awareness-pulse');
|
||||
}, CYBER_SONAR_RENDER_INTERVAL_MS);
|
||||
});
|
||||
stopPulses = () => {
|
||||
removePreRender();
|
||||
removePostRender();
|
||||
globalThis.clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
// The detection overlay pulses the brackets of flights and vessels a
|
||||
// front crosses; it reads the centre in place, so it follows too.
|
||||
setAwarenessEchoField({
|
||||
center,
|
||||
radiusM: AWARENESS_RADIUS_M,
|
||||
pulse,
|
||||
echo: AWARENESS_RANGE_ECHO,
|
||||
level: levelAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Turning reduced motion on or off mid-session rebuilds the visual.
|
||||
const motionQuery = globalThis.matchMedia?.(REDUCED_MOTION_QUERY);
|
||||
const onMotionChange = () => {
|
||||
if (layerState.subject) renderVisual(layerState.subject);
|
||||
};
|
||||
// Safari before 14 has only the older addListener / removeListener.
|
||||
if (motionQuery?.addEventListener)
|
||||
motionQuery.addEventListener('change', onMotionChange);
|
||||
else motionQuery?.addListener?.(onMotionChange);
|
||||
|
||||
layerState.visual = {
|
||||
key,
|
||||
entities,
|
||||
center: Cesium.Cartesian3.clone(groundCenter),
|
||||
ring: ringEntity,
|
||||
ringPrimitive,
|
||||
ringPositions,
|
||||
center,
|
||||
gradient,
|
||||
gradientCenter: Cesium.Cartesian3.clone(groundCenter),
|
||||
reducedMotion,
|
||||
wake,
|
||||
stopPulses,
|
||||
removeMotionListener: () => {
|
||||
if (motionQuery?.removeEventListener)
|
||||
motionQuery.removeEventListener('change', onMotionChange);
|
||||
else motionQuery?.removeListener?.(onMotionChange);
|
||||
},
|
||||
};
|
||||
governorRequestRender('awareness-visual');
|
||||
}
|
||||
@@ -343,5 +914,6 @@ export function createRendering({
|
||||
updateDirectionOverlay,
|
||||
clearVisual,
|
||||
renderVisual,
|
||||
wakePulses,
|
||||
};
|
||||
}
|
||||
@@ -59,6 +59,10 @@ export function createState({ services }) {
|
||||
lastCameraPoseSig: '',
|
||||
/** When the pose signature last changed bins (hysteresis anchor). */
|
||||
lastCameraPoseChangeMs: 0,
|
||||
/** When the viewer last handled the camera (pointer, wheel, touch, key). */
|
||||
lastCameraInputMs: Number.NEGATIVE_INFINITY,
|
||||
/** Element the camera-input listeners are attached to. */
|
||||
cameraInputTarget: null,
|
||||
/** Whether the view currently counts as moving (hysteretic, not per-frame). */
|
||||
cameraMoving: false,
|
||||
lastEvaluatedPosition: null,
|
||||
|
||||
@@ -28,6 +28,29 @@ function services() {
|
||||
};
|
||||
}
|
||||
|
||||
// The application's own scene services, as src/app/layers/militaryFlights.js
|
||||
// composes them; only the surface samplers and registry are stubbed.
|
||||
async function sceneServices() {
|
||||
return {
|
||||
picking: await import('../../data/pickRegistry.js'),
|
||||
sprites: await import('../../data/spriteOrder.js'),
|
||||
trails: await import('../../data/trailRenderer.js'),
|
||||
aircraftPresentation: await import('../../data/tr3bRegistry.js'),
|
||||
camera: await import('../../data/trackedCamera.js'),
|
||||
labels: await import('../../data/detectionDraw.js'),
|
||||
geoid: await import('../../data/geoid.js'),
|
||||
focus: await import('../../data/focusDeemphasis.js'),
|
||||
readout: await import('../../data/trackedReadout.js'),
|
||||
context: await import('../../data/contextStore.js'),
|
||||
render: await import('../../renderGovernor.js'),
|
||||
recession: await import('../../data/aircraftRecession.js'),
|
||||
groundFloor: await import('../../data/groundFloor.js'),
|
||||
groundSnap: await import('../../data/groundSnap.js'),
|
||||
meshFloor: { sampleMeshFloorCells: () => {} },
|
||||
militaryRegistry: { registerMilitaryIcaos: () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
test('military layer instances isolate policy and restoration state without requesting a source', async () => {
|
||||
let requests = 0;
|
||||
const source = {
|
||||
@@ -122,26 +145,7 @@ test('a newly seen military contact appears at its delayed position, not ahead o
|
||||
// the next fleet tick jumped it back.
|
||||
const Cesium = await import('cesium');
|
||||
const { RENDER_DELAY_SEC } = await import('./policy.js');
|
||||
// The application's own scene services, as src/app/layers/militaryFlights.js
|
||||
// composes them; only the surface samplers and registry are stubbed.
|
||||
const supplied = {
|
||||
picking: await import('../../data/pickRegistry.js'),
|
||||
sprites: await import('../../data/spriteOrder.js'),
|
||||
trails: await import('../../data/trailRenderer.js'),
|
||||
aircraftPresentation: await import('../../data/tr3bRegistry.js'),
|
||||
camera: await import('../../data/trackedCamera.js'),
|
||||
labels: await import('../../data/detectionDraw.js'),
|
||||
geoid: await import('../../data/geoid.js'),
|
||||
focus: await import('../../data/focusDeemphasis.js'),
|
||||
readout: await import('../../data/trackedReadout.js'),
|
||||
context: await import('../../data/contextStore.js'),
|
||||
render: await import('../../renderGovernor.js'),
|
||||
recession: await import('../../data/aircraftRecession.js'),
|
||||
groundFloor: await import('../../data/groundFloor.js'),
|
||||
groundSnap: await import('../../data/groundSnap.js'),
|
||||
meshFloor: { sampleMeshFloorCells: () => {} },
|
||||
militaryRegistry: { registerMilitaryIcaos: () => {} },
|
||||
};
|
||||
const supplied = await sceneServices();
|
||||
const fixLon = -97.6;
|
||||
const fixLat = 30.3;
|
||||
const speedMps = 200;
|
||||
@@ -213,6 +217,55 @@ test('a newly seen military contact appears at its delayed position, not ahead o
|
||||
);
|
||||
});
|
||||
|
||||
test('a vanished military contact fades while position-less rows keep theirs', async () => {
|
||||
// About a third of a live adsb.lol /v2/mil fetch is contacts without a
|
||||
// position. Those rows marked every snapshot partial, so an aircraft that
|
||||
// left the feed stayed at full opacity for five minutes.
|
||||
const Cesium = await import('cesium');
|
||||
const { readsbSnapshot } = await import('../../sources/live/aircraft.js');
|
||||
const nowMs = Date.now();
|
||||
const row = (hex, lon) => ({
|
||||
hex,
|
||||
lat: 30.3,
|
||||
lon,
|
||||
alt_baro: 20_000,
|
||||
gs: 300,
|
||||
track: 90,
|
||||
seen_pos: 1,
|
||||
});
|
||||
const polls = [
|
||||
[row('ae0001', -97.6), row('ae0002', -96.6), row('ae0003', -95.6)],
|
||||
[row('ae0001', -97.5), { hex: 'ae0003' }],
|
||||
];
|
||||
// Every poll also lists a contact that never has a position.
|
||||
for (const poll of polls) poll.push({ hex: 'ae0009', type: 'mode_s' });
|
||||
const layer = createMilitaryFlightLayer({
|
||||
services: await sceneServices(),
|
||||
source: {
|
||||
label: 'Fixture aircraft',
|
||||
getSnapshot: async () =>
|
||||
readsbSnapshot(
|
||||
{ ac: polls.shift() },
|
||||
{ observedAtMs: nowMs, now: nowMs },
|
||||
),
|
||||
},
|
||||
});
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
layer.testing._setTrackedMilitaryRefreshStateForTest({
|
||||
icao24: 'seed00',
|
||||
entity: null,
|
||||
meta: { rawLat: 0, rawLon: 0, onGround: false },
|
||||
billboard: { show: false, position: Cesium.Cartesian3.fromDegrees(0, 0) },
|
||||
billboardCollection: { show: true, add: (options) => ({ ...options }) },
|
||||
viewer,
|
||||
tracked: false,
|
||||
});
|
||||
await layer.update(viewer);
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.findByQuery('ae0002')?.stale, true, 'vanished: fading');
|
||||
assert.equal(layer.findByQuery('ae0003')?.stale, false, 'still listed');
|
||||
});
|
||||
|
||||
test('voice context reads a track that rounds to north as 0°, not 360°', () => {
|
||||
const stubs = services();
|
||||
stubs.aircraftPresentation = { tr3bTypeLabel: (id, type) => type };
|
||||
|
||||
@@ -187,9 +187,12 @@ export class MilitaryFlightRecords {
|
||||
fixEpochMs: aircraft.positionTimeMs ?? observedAtMs,
|
||||
};
|
||||
}
|
||||
absence(id, { complete, likelyLanded }) {
|
||||
absence(id, { complete, retainIds, likelyLanded }) {
|
||||
// Partial admissions and position-less rows do not prove absence, but
|
||||
// retention remains bounded; a likely-landed position-less contact
|
||||
// still takes the landed limit.
|
||||
if (
|
||||
!complete &&
|
||||
(!complete || (retainIds?.has(id) && !likelyLanded)) &&
|
||||
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
|
||||
)
|
||||
return 'retain';
|
||||
|
||||
@@ -99,6 +99,30 @@ test('military records keep bounded partial retention and release metadata only
|
||||
);
|
||||
});
|
||||
|
||||
test('complete military snapshots age out vanished aircraft and retain position-less ones unless landed', () => {
|
||||
const records = store();
|
||||
for (const id of ['aaa001', 'aaa002', 'aaa003'])
|
||||
records.receive(observation({ id, reference: id }), context());
|
||||
// aaa001 and aaa003 are still listed without a position; aaa002 vanished.
|
||||
const absent = (id, likelyLanded = false) =>
|
||||
records.absence(id, {
|
||||
complete: true,
|
||||
retainIds: new Set(['aaa001', 'aaa003']),
|
||||
likelyLanded,
|
||||
});
|
||||
assert.deepEqual(
|
||||
[absent('aaa002'), absent('aaa002'), absent('aaa002')],
|
||||
['stale', 'stale', 'remove'],
|
||||
);
|
||||
assert.equal(absent('aaa001'), 'retain');
|
||||
assert.equal(records.missingPolls.has('aaa001'), false);
|
||||
// A landed contact that lost its position takes the landed fast-cull.
|
||||
assert.equal(absent('aaa003', true), 'remove');
|
||||
// Position-less retention stays bounded like partial admission.
|
||||
records.data.get('aaa001').observedReceiptMs = Date.now() - 300001;
|
||||
assert.equal(absent('aaa001'), 'stale');
|
||||
});
|
||||
|
||||
test('military emergency status follows the latest poll instead of sticking', () => {
|
||||
const records = store();
|
||||
const first = records.receive(
|
||||
|
||||
@@ -259,6 +259,7 @@ export function createMilitarySnapshotRenderer({
|
||||
if (currentIcaos.has(icao24)) continue;
|
||||
const absence = records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
});
|
||||
if (absence === 'retain') continue;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { msg } from '../../i18n/marker.js';
|
||||
import { OSM_PLACES_LAYER_ID } from './model.js';
|
||||
import { createNominatimStatus } from '../../sources/nominatimStatus.js';
|
||||
export * from './model.js';
|
||||
|
||||
const OVERLAY_SOURCE_ID = OSM_PLACES_LAYER_ID;
|
||||
/** Named results that get a map label; the rest are points. */
|
||||
const LABEL_LIMIT = 48;
|
||||
const COLOR = '#7fe3ff';
|
||||
|
||||
/**
|
||||
* OSM Places: the results of the last voice OpenStreetMap search ("hospitals
|
||||
* in Kathmandu"), drawn as points with name labels and queryable by
|
||||
* analyst_query. It fetches nothing itself; `setResults` replaces the set.
|
||||
* The search runs only on an operator-configured Overpass, so without one
|
||||
* the layer stays empty. Session-only: never in share links or stored layer
|
||||
* state.
|
||||
* @param {{overlayHost: object, overpassConfigured?: () => Promise<boolean|null>}} options
|
||||
*/
|
||||
export function createOsmPlacesLayer({
|
||||
overlayHost,
|
||||
// The same once-per-page capability probe as Nominatim's, on Overpass.
|
||||
overpassConfigured = createNominatimStatus({ url: '/api/overpass/status' }),
|
||||
} = {}) {
|
||||
if (!overlayHost) throw new TypeError('OSM Places requires an overlay host');
|
||||
let viewer = null;
|
||||
let dataSource = null;
|
||||
let enabled = false;
|
||||
let records = [];
|
||||
let meta = null;
|
||||
let lastUpdate = null;
|
||||
/** true / false from the server's probe, null while unknown. */
|
||||
let configured = null;
|
||||
|
||||
function render() {
|
||||
if (!dataSource) return;
|
||||
dataSource.entities.removeAll();
|
||||
const color = Cesium.Color.fromCssColorString(COLOR);
|
||||
const overlay = [];
|
||||
for (const record of records) {
|
||||
const position = Cesium.Cartesian3.fromDegrees(record.lon, record.lat);
|
||||
dataSource.entities.add({
|
||||
id: `osm-place:${record.id}`,
|
||||
position,
|
||||
point: {
|
||||
pixelSize: 9,
|
||||
color,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.8),
|
||||
outlineWidth: 2,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
// On top only up close (as bikeshare stations): beyond that the
|
||||
// depth test keeps far-side points from showing through the globe.
|
||||
disableDepthTestDistance: 2500,
|
||||
},
|
||||
});
|
||||
if (record.name && overlay.length < LABEL_LIMIT)
|
||||
overlay.push({
|
||||
id: record.id,
|
||||
position,
|
||||
variant: 'label',
|
||||
title: record.name.slice(0, 40),
|
||||
accent: COLOR,
|
||||
priority: LABEL_LIMIT - overlay.length,
|
||||
collisionGroup: 'ambient-label',
|
||||
paintLane: 'ambient-label',
|
||||
interactive: false,
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
gapPx: 12,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
});
|
||||
}
|
||||
if (enabled)
|
||||
overlayHost.setEntries(OVERLAY_SOURCE_ID, overlay, {
|
||||
cohortLimit: LABEL_LIMIT,
|
||||
collisionCapacity: LABEL_LIMIT,
|
||||
moving: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: OSM_PLACES_LAYER_ID,
|
||||
name: 'OSM Places',
|
||||
icon: '⌖',
|
||||
source: 'OpenStreetMap',
|
||||
updateInterval: 0,
|
||||
|
||||
/** Unavailable only once the server says no Overpass is configured. */
|
||||
getSourceAvailability() {
|
||||
return configured === false
|
||||
? {
|
||||
available: false,
|
||||
source: OSM_PLACES_LAYER_ID,
|
||||
reason: 'Needs an operator-configured Overpass server',
|
||||
}
|
||||
: { available: true, source: OSM_PLACES_LAYER_ID, reason: null };
|
||||
},
|
||||
|
||||
init(nextViewer) {
|
||||
viewer = nextViewer;
|
||||
Promise.resolve(overpassConfigured?.())
|
||||
.then((answer) => {
|
||||
if (typeof answer === 'boolean') configured = answer;
|
||||
})
|
||||
.catch(() => {});
|
||||
dataSource = new Cesium.CustomDataSource(OSM_PLACES_LAYER_ID);
|
||||
dataSource.show = false;
|
||||
viewer.dataSources.add(dataSource);
|
||||
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
|
||||
},
|
||||
|
||||
enable() {
|
||||
enabled = true;
|
||||
if (dataSource) dataSource.show = true;
|
||||
overlayHost.setVisible(OVERLAY_SOURCE_ID, true);
|
||||
render();
|
||||
return true;
|
||||
},
|
||||
|
||||
disable() {
|
||||
enabled = false;
|
||||
if (dataSource) dataSource.show = false;
|
||||
overlayHost.clearSource(OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
|
||||
return true;
|
||||
},
|
||||
|
||||
/** Nothing to poll: results change only through setResults. */
|
||||
async update() {
|
||||
return true;
|
||||
},
|
||||
|
||||
destroy() {
|
||||
enabled = false;
|
||||
overlayHost.clearSource(OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
|
||||
if (dataSource && viewer) viewer.dataSources.remove(dataSource, true);
|
||||
dataSource = null;
|
||||
viewer = null;
|
||||
records = [];
|
||||
meta = null;
|
||||
lastUpdate = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace the result set.
|
||||
* @param {object[]} next Records from `mapOsmFeature`.
|
||||
* @param {{kind: string, label: string, area: string}} nextMeta
|
||||
*/
|
||||
setResults(next, nextMeta) {
|
||||
records = Array.isArray(next) ? next.slice() : [];
|
||||
meta = nextMeta ? { ...nextMeta } : null;
|
||||
lastUpdate = Date.now();
|
||||
render();
|
||||
},
|
||||
|
||||
/** The current search, for panels and voice. */
|
||||
getResultsMeta() {
|
||||
return meta ? { ...meta, count: records.length } : null;
|
||||
},
|
||||
|
||||
getAnalystRecords(maxCount = 2000) {
|
||||
if (!enabled) return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
: 2000;
|
||||
return records.slice(0, limit).map((record) => ({ ...record }));
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
count: records.length,
|
||||
lastUpdate,
|
||||
error: null,
|
||||
statusMessage: meta
|
||||
? `${meta.label} · ${meta.area}`
|
||||
: msg('Voice place search (needs an Overpass server)'),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// OSM Places holds the last voice search's results: queryable while shown,
|
||||
// labelled through the overlay host, and nothing when switched off.
|
||||
//
|
||||
// Run with: npm test
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createOsmPlacesLayer } from './index.js';
|
||||
|
||||
function fakeOverlayHost() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
setEntries: (...args) => calls.push(['setEntries', ...args]),
|
||||
clearSource: (...args) => calls.push(['clearSource', ...args]),
|
||||
setVisible: (...args) => calls.push(['setVisible', ...args]),
|
||||
};
|
||||
}
|
||||
|
||||
const RECORDS = [
|
||||
{
|
||||
id: 'node/1',
|
||||
lat: 27.7,
|
||||
lon: 85.3,
|
||||
name: 'Bir Hospital',
|
||||
kind: 'hospital',
|
||||
},
|
||||
{ id: 'node/2', lat: 27.71, lon: 85.31, name: null, kind: 'hospital' },
|
||||
];
|
||||
|
||||
test('results are queryable only while the layer is on, and labels follow names', () => {
|
||||
const host = fakeOverlayHost();
|
||||
const layer = createOsmPlacesLayer({ overlayHost: host });
|
||||
layer.setResults(RECORDS, {
|
||||
kind: 'hospital',
|
||||
label: 'hospitals',
|
||||
area: 'Kathmandu',
|
||||
});
|
||||
assert.deepEqual(layer.getAnalystRecords(), [], 'off: nothing to count');
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
assert.equal(layer.getStats().statusMessage, 'hospitals · Kathmandu');
|
||||
layer.enable();
|
||||
const records = layer.getAnalystRecords();
|
||||
assert.equal(records.length, 2);
|
||||
records[0].name = 'changed';
|
||||
assert.equal(
|
||||
layer.getAnalystRecords()[0].name,
|
||||
'Bir Hospital',
|
||||
'callers get copies',
|
||||
);
|
||||
assert.deepEqual(layer.getResultsMeta(), {
|
||||
kind: 'hospital',
|
||||
label: 'hospitals',
|
||||
area: 'Kathmandu',
|
||||
count: 2,
|
||||
});
|
||||
layer.disable();
|
||||
assert.ok(
|
||||
host.calls.some(
|
||||
([name, id]) => name === 'clearSource' && id === 'osm-places',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty layer tells the operator how to fill it', () => {
|
||||
const layer = createOsmPlacesLayer({ overlayHost: fakeOverlayHost() });
|
||||
assert.match(layer.getStats().statusMessage, /needs an Overpass server/);
|
||||
assert.throws(() => createOsmPlacesLayer(), TypeError);
|
||||
});
|
||||
|
||||
test('destroy and re-init expose a fresh empty session', () => {
|
||||
const host = fakeOverlayHost();
|
||||
const layer = createOsmPlacesLayer({ overlayHost: host });
|
||||
layer.setResults(RECORDS, {
|
||||
kind: 'hospital',
|
||||
label: 'hospitals',
|
||||
area: 'Kathmandu',
|
||||
});
|
||||
assert.ok(layer.getStats().lastUpdate);
|
||||
layer.destroy();
|
||||
const dataSources = {
|
||||
added: [],
|
||||
removed: [],
|
||||
add(source) {
|
||||
this.added.push(source);
|
||||
return source;
|
||||
},
|
||||
remove(source, destroy) {
|
||||
this.removed.push([source, destroy]);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
layer.init({ dataSources });
|
||||
layer.enable();
|
||||
assert.equal(layer.getResultsMeta(), null);
|
||||
assert.deepEqual(layer.getAnalystRecords(), []);
|
||||
assert.deepEqual(layer.getStats(), {
|
||||
count: 0,
|
||||
lastUpdate: null,
|
||||
error: null,
|
||||
statusMessage: 'Voice place search (needs an Overpass server)',
|
||||
});
|
||||
});
|
||||
|
||||
test('without an operator Overpass the layer shows as unavailable', async () => {
|
||||
const added = [];
|
||||
const viewer = { dataSources: { add: (ds) => added.push(ds), remove() {} } };
|
||||
const off = createOsmPlacesLayer({
|
||||
overlayHost: fakeOverlayHost(),
|
||||
overpassConfigured: async () => false,
|
||||
});
|
||||
assert.equal(
|
||||
off.getSourceAvailability().available,
|
||||
true,
|
||||
'unknown until asked',
|
||||
);
|
||||
off.init(viewer);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(off.getSourceAvailability().available, false);
|
||||
const unknown = createOsmPlacesLayer({
|
||||
overlayHost: fakeOverlayHost(),
|
||||
overpassConfigured: async () => null,
|
||||
});
|
||||
unknown.init(viewer);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(
|
||||
unknown.getSourceAvailability().available,
|
||||
true,
|
||||
'an unknown answer keeps it listed',
|
||||
);
|
||||
});
|
||||
|
||||
test('places are depth-tested past close range, so they never show through the globe', () => {
|
||||
const added = [];
|
||||
const viewer = { dataSources: { add: (ds) => added.push(ds), remove() {} } };
|
||||
const layer = createOsmPlacesLayer({
|
||||
overlayHost: fakeOverlayHost(),
|
||||
overpassConfigured: async () => true,
|
||||
});
|
||||
layer.init(viewer);
|
||||
layer.enable();
|
||||
layer.setResults(RECORDS, {
|
||||
kind: 'hospital',
|
||||
label: 'hospitals',
|
||||
area: 'Kathmandu',
|
||||
});
|
||||
const point = added[0].entities.values[0].point;
|
||||
const distance = point.disableDepthTestDistance.getValue();
|
||||
assert.ok(
|
||||
Number.isFinite(distance) && distance <= 5000,
|
||||
`depth test off only within ${distance} m`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* OSM Places records: OpenStreetMap features found by a voice search,
|
||||
* mapped to plain analyst records. Pure — no Cesium.
|
||||
* @module layers/osmPlaces/model
|
||||
*/
|
||||
|
||||
export const OSM_PLACES_LAYER_ID = 'osm-places';
|
||||
/** A node and a way/relation of the same name this close are one site mapped twice. */
|
||||
const SAME_SITE_M = 75;
|
||||
|
||||
/** Longest tag value kept; OSM names are open text that reaches the model. */
|
||||
const MAX_TEXT = 120;
|
||||
|
||||
/**
|
||||
* A tag value as plain bounded text: control characters (newlines, tabs)
|
||||
* become spaces; format characters (bidi overrides, zero-width spaces) are
|
||||
* dropped, except the zero-width (non-)joiners Persian and Indic names spell
|
||||
* with; runs of whitespace collapse, and long values are cut with an
|
||||
* ellipsis.
|
||||
*/
|
||||
function text(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const clean = value
|
||||
.replace(/\p{Cc}+/gu, ' ')
|
||||
.replace(/(?![\u200c\u200d])\p{Cf}/gu, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!clean) return null;
|
||||
// Cut on code points so an emoji's surrogate pair is never split.
|
||||
const chars = Array.from(clean);
|
||||
return chars.length <= MAX_TEXT
|
||||
? clean
|
||||
: `${chars
|
||||
.slice(0, MAX_TEXT - 1)
|
||||
.join('')
|
||||
.trimEnd()}…`;
|
||||
}
|
||||
|
||||
function distanceM(a, b) {
|
||||
const toRad = Math.PI / 180;
|
||||
const x = (b.lon - a.lon) * toRad * Math.cos(((a.lat + b.lat) / 2) * toRad);
|
||||
const y = (b.lat - a.lat) * toRad;
|
||||
return Math.hypot(x, y) * 6_371_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* One feature from `/api/osm/features` → an analyst record.
|
||||
* @param {{id: string, lat: number, lon: number, tags?: object}} feature
|
||||
* @param {{id: string, label: string}} preset
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function mapOsmFeature(feature, preset) {
|
||||
if (!Number.isFinite(feature?.lat) || !Number.isFinite(feature?.lon))
|
||||
return null;
|
||||
const tags = feature.tags || {};
|
||||
const [osmType] = String(feature.id || '').split('/');
|
||||
return {
|
||||
id: String(feature.id),
|
||||
lat: feature.lat,
|
||||
lon: feature.lon,
|
||||
name: text(tags['name:en']) || text(tags.name),
|
||||
kind: preset.id,
|
||||
operator: text(tags.operator) || text(tags.brand),
|
||||
emergency: text(tags.emergency),
|
||||
wheelchair: text(tags.wheelchair),
|
||||
openingHours: text(tags.opening_hours),
|
||||
city: text(tags['addr:city']),
|
||||
osmType: ['node', 'way', 'relation'].includes(osmType) ? osmType : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove double counting without merging distinct places:
|
||||
*
|
||||
* - the same OSM element (identical `type/id`) is counted once;
|
||||
* - a node and a way/relation with the same name within 75 m are one site
|
||||
* mapped twice (a point on the building and the building outline), and
|
||||
* merge;
|
||||
* - two nodes, or two ways, are always distinct places — two neighbouring
|
||||
* cafés of one chain are two cafés — and never merge, whatever their names.
|
||||
*
|
||||
* @param {object[]} records
|
||||
* @returns {{records: object[], merged: number}}
|
||||
*/
|
||||
export function dedupeOsmRecords(records) {
|
||||
const kept = [];
|
||||
const seen = new Set();
|
||||
let merged = 0;
|
||||
for (const record of records) {
|
||||
if (seen.has(record.id)) {
|
||||
merged += 1;
|
||||
continue;
|
||||
}
|
||||
seen.add(record.id);
|
||||
const name = record.name?.toLowerCase();
|
||||
const isNode = record.osmType === 'node';
|
||||
const sameSite =
|
||||
name &&
|
||||
record.osmType &&
|
||||
kept.some(
|
||||
(other) =>
|
||||
other.osmType &&
|
||||
(other.osmType === 'node') !== isNode &&
|
||||
other.name?.toLowerCase() === name &&
|
||||
distanceM(other, record) <= SAME_SITE_M,
|
||||
);
|
||||
if (sameSite) merged += 1;
|
||||
else kept.push(record);
|
||||
}
|
||||
return { records: kept, merged };
|
||||
}
|
||||
@@ -344,14 +344,17 @@ export function createPlayback({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
if (!parts.interaction.radioPresentationAllowed())
|
||||
return Promise.resolve(false);
|
||||
if (!layerState._selectedId) {
|
||||
if (layerState._selectedId) {
|
||||
// Drop a fallback left by a voice step whose playback never started.
|
||||
layerState._playFallbackId = null;
|
||||
layerState._playFallbackFocus = null;
|
||||
} else {
|
||||
const ranked = parts.queries.rankedVisibleStations();
|
||||
if (!ranked.length) return Promise.resolve(false);
|
||||
layerState._playFallbackId = ranked[1]?.id || null;
|
||||
layerState._playFallbackFocus = false;
|
||||
parts.selection.selectRadioStation(ranked[0].id, {
|
||||
autoplay: false,
|
||||
focus: false,
|
||||
playFallback: { id: ranked[1]?.id || null, focus: false },
|
||||
});
|
||||
}
|
||||
return playSelectedRadio({ origin });
|
||||
|
||||
@@ -73,6 +73,16 @@ export function createSelection({
|
||||
return { filter: layerState._filter, volume: layerState._userVolume };
|
||||
}
|
||||
|
||||
// A paused or failed stream of another station would outlive its selection.
|
||||
function releaseStaleRadioStream(stationId) {
|
||||
if (
|
||||
layerState._audioStationId &&
|
||||
layerState._audioStationId !== stationId &&
|
||||
['paused', 'error'].includes(layerState._audioState)
|
||||
)
|
||||
parts.playback.stopRadioPlayback();
|
||||
}
|
||||
|
||||
/** Select a station. Playback occurs only when autoplay is explicitly true. */
|
||||
|
||||
function selectRadioStation(
|
||||
@@ -83,11 +93,18 @@ export function createSelection({
|
||||
origin = 'programmatic',
|
||||
attemptId = null,
|
||||
cameraNavigation = null,
|
||||
playFallback = null,
|
||||
} = {},
|
||||
) {
|
||||
if (!parts.interaction.radioPresentationAllowed()) return false;
|
||||
const station = layerState._stationById.get(String(id));
|
||||
if (!station) return false;
|
||||
if (!autoplay) releaseStaleRadioStream(station.id);
|
||||
// Set after the release, which clears fallbacks.
|
||||
if (playFallback) {
|
||||
layerState._playFallbackId = playFallback.id;
|
||||
layerState._playFallbackFocus = playFallback.focus;
|
||||
}
|
||||
layerState._tuningUnavailableStationId = null;
|
||||
if (
|
||||
!parts.navigation.radioCameraNavigationOwnsSelection(
|
||||
@@ -104,6 +121,7 @@ export function createSelection({
|
||||
)
|
||||
parts.tuning.endRadioTuning();
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
const changed = layerState._selectedId !== station.id;
|
||||
layerState._selectedId = station.id;
|
||||
const generation = ++layerState._selectionGeneration;
|
||||
const sessionGeneration = layerState._sessionGeneration;
|
||||
@@ -122,6 +140,9 @@ export function createSelection({
|
||||
if (focus) parts.navigation.focusStation(station);
|
||||
parts.presentation.emitState();
|
||||
if (autoplay) void parts.playback.playSelectedRadio({ origin, attemptId });
|
||||
// A user step to another station cancels a playback voice prepared.
|
||||
else if (origin === 'user' && changed)
|
||||
parts.presentation.emitPlaybackControl('select', origin);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -133,6 +154,7 @@ export function createSelection({
|
||||
rotate = false,
|
||||
stationIds = null,
|
||||
autoplay = true,
|
||||
fallback = autoplay,
|
||||
origin = 'programmatic',
|
||||
} = {},
|
||||
) {
|
||||
@@ -159,25 +181,29 @@ export function createSelection({
|
||||
current < 0
|
||||
? 0
|
||||
: (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length;
|
||||
layerState._playFallbackId =
|
||||
ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id : null;
|
||||
// Voice defers playback, so its silent step keeps a fallback; silent UI steps do not.
|
||||
const fallbackId =
|
||||
fallback && ranked.length > 1
|
||||
? ranked[(nextIndex + 1) % ranked.length].id
|
||||
: null;
|
||||
const rotationCameraState = rotate
|
||||
? parts.navigation.radioCameraState()
|
||||
: null;
|
||||
const rotationNavigation = rotate
|
||||
? parts.navigation.beginRadioCameraNavigation(rotationCameraState)
|
||||
: null;
|
||||
layerState._playFallbackFocus = rotate
|
||||
? (fallbackStation) => {
|
||||
parts.navigation.rotateRadioStationIntoView(
|
||||
fallbackStation,
|
||||
0.65,
|
||||
rotationCameraState,
|
||||
rotationNavigation,
|
||||
);
|
||||
return rotationNavigation;
|
||||
}
|
||||
: false;
|
||||
const fallbackFocus =
|
||||
fallback && rotate
|
||||
? (fallbackStation) => {
|
||||
parts.navigation.rotateRadioStationIntoView(
|
||||
fallbackStation,
|
||||
0.65,
|
||||
rotationCameraState,
|
||||
rotationNavigation,
|
||||
);
|
||||
return rotationNavigation;
|
||||
}
|
||||
: false;
|
||||
const station = ranked[nextIndex];
|
||||
if (rotate)
|
||||
parts.navigation.rotateRadioStationIntoView(
|
||||
@@ -191,6 +217,7 @@ export function createSelection({
|
||||
focus: false,
|
||||
origin,
|
||||
cameraNavigation: rotationNavigation,
|
||||
playFallback: { id: fallbackId, focus: fallbackFocus },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -217,9 +244,12 @@ export function createSelection({
|
||||
},
|
||||
);
|
||||
if (!ranked.length) return null;
|
||||
layerState._playFallbackId = ranked[1]?.id || null;
|
||||
layerState._playFallbackFocus = false;
|
||||
selectRadioStation(ranked[0].id, { autoplay, focus: false, origin });
|
||||
selectRadioStation(ranked[0].id, {
|
||||
autoplay,
|
||||
focus: false,
|
||||
origin,
|
||||
playFallback: { id: ranked[1]?.id || null, focus: false },
|
||||
});
|
||||
return ranked[0];
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,8 @@ export function createState({ services }) {
|
||||
|
||||
layerState._tuningStartStationId = null;
|
||||
|
||||
layerState._tuningResumesPlayback = false;
|
||||
|
||||
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
||||
|
||||
layerState._tuningStationById = new Map();
|
||||
|
||||
@@ -66,6 +66,12 @@ export function createTuning({ state: layerState, services, parts, source }) {
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
layerState._tuningStartStationId = layerState._selectedId;
|
||||
layerState._tuningPreviewId = layerState._selectedId;
|
||||
// Like Previous/Next, the release plays only if a stream was active.
|
||||
layerState._tuningResumesPlayback = [
|
||||
'loading',
|
||||
'buffering',
|
||||
'playing',
|
||||
].includes(layerState._audioState);
|
||||
parts.tuningNoise.installTuningNoise();
|
||||
parts.playback.pauseRadioPlayback({ origin: 'user' });
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
@@ -179,7 +185,9 @@ export function createTuning({ state: layerState, services, parts, source }) {
|
||||
layerState._selectionGeneration += 1;
|
||||
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
|
||||
layerState._tuningUnavailableStationId = stationId;
|
||||
parts.presentation.emitState();
|
||||
// The drag paused the stream; with no selection, Resume would start the
|
||||
// nearest station. Stop also publishes the state and cancels a voice handoff.
|
||||
parts.playback.stopRadioPlayback({ origin });
|
||||
return Object.freeze({
|
||||
ok: false,
|
||||
reason: 'station-unavailable',
|
||||
@@ -187,9 +195,11 @@ export function createTuning({ state: layerState, services, parts, source }) {
|
||||
generation,
|
||||
});
|
||||
}
|
||||
const autoplay = layerState._tuningResumesPlayback;
|
||||
layerState._tuningResumesPlayback = false;
|
||||
layerState._tuningActive = false;
|
||||
layerState._tuningStatic = true;
|
||||
layerState._tuningAwaitingStationId = frozenStation.id;
|
||||
layerState._tuningStatic = autoplay;
|
||||
layerState._tuningAwaitingStationId = autoplay ? frozenStation.id : null;
|
||||
layerState._tuningPreviewId = null;
|
||||
layerState._tuningStartStationId = null;
|
||||
layerState._tuningUnavailableStationId = null;
|
||||
@@ -204,10 +214,12 @@ export function createTuning({ state: layerState, services, parts, source }) {
|
||||
layerState._playFallbackFocus = null;
|
||||
layerState._playFallbackOrigin = 'programmatic';
|
||||
layerState._playFallbackAttemptId = null;
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
// Muting alone would leave the noise graph running after a silent release.
|
||||
if (autoplay) parts.tuningNoise.syncTuningNoiseGain();
|
||||
else parts.tuningNoise.clearRadioTuningNoise({ emit: false });
|
||||
parts.presentation.emitState();
|
||||
const selected = parts.selection.selectRadioStation(frozenStation.id, {
|
||||
autoplay: true,
|
||||
autoplay,
|
||||
focus: false,
|
||||
origin,
|
||||
cameraNavigation,
|
||||
|
||||
@@ -50,6 +50,11 @@ export function createTuningNoise({
|
||||
layerState._tuningNoiseGain = null;
|
||||
}
|
||||
|
||||
function suspendTuningNoiseContext() {
|
||||
const suspended = layerState._tuningNoiseContext?.suspend?.();
|
||||
if (suspended?.catch) void suspended.catch(() => {});
|
||||
}
|
||||
|
||||
function syncTuningNoiseGain() {
|
||||
if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return;
|
||||
const audible = radioTuningStaticShouldPlay({
|
||||
@@ -79,8 +84,21 @@ export function createTuningNoise({
|
||||
if (!AudioContextClass) return false;
|
||||
if (!layerState._tuningNoiseContext)
|
||||
layerState._tuningNoiseContext = new AudioContextClass();
|
||||
const resumed = layerState._tuningNoiseContext.resume?.();
|
||||
if (resumed?.catch) void resumed.catch(() => {});
|
||||
const context = layerState._tuningNoiseContext;
|
||||
const resumed = context.resume?.();
|
||||
// A release before the device starts finds the context still suspended,
|
||||
// so its suspend() is a no-op; suspend again once resume() settles.
|
||||
if (resumed?.then)
|
||||
void resumed.then(
|
||||
() => {
|
||||
if (
|
||||
layerState._tuningNoiseContext === context &&
|
||||
!layerState._tuningNoiseSource
|
||||
)
|
||||
suspendTuningNoiseContext();
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
if (layerState._tuningNoiseSource) return true;
|
||||
|
||||
const frameCount = Math.max(
|
||||
@@ -120,6 +138,7 @@ export function createTuningNoise({
|
||||
if (layerState._tuningCameraNavigation)
|
||||
parts.navigation.invalidateRadioCameraNavigation();
|
||||
layerState._tuningActive = false;
|
||||
layerState._tuningResumesPlayback = false;
|
||||
layerState._tuningStatic = false;
|
||||
layerState._tuningAwaitingStationId = null;
|
||||
layerState._tuningPreviewId = null;
|
||||
@@ -131,8 +150,7 @@ export function createTuningNoise({
|
||||
parts.rendering.updateSelectionEntity();
|
||||
syncTuningNoiseGain();
|
||||
stopTuningNoiseSource();
|
||||
const suspended = layerState._tuningNoiseContext?.suspend?.();
|
||||
if (suspended?.catch) void suspended.catch(() => {});
|
||||
suspendTuningNoiseContext();
|
||||
if (emit) parts.presentation.emitState();
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -1209,6 +1209,16 @@ export function createRecentImageryLayer({
|
||||
notify('alpha');
|
||||
},
|
||||
|
||||
/** Stop catalog/preview work owned by a cancelled voice action. */
|
||||
cancelPendingSearch() {
|
||||
// Without a note the emptied strip would read as "no imagery".
|
||||
if (_searching) _notice = msg('Search cancelled');
|
||||
cancelSearch();
|
||||
clearPending();
|
||||
syncRender();
|
||||
notify();
|
||||
},
|
||||
|
||||
/**
|
||||
* Forget the box, the candidates, both pins, the preview and any pending
|
||||
* pick; reset opacity and split and cancel the box tool. Mode and
|
||||
|
||||
@@ -1385,3 +1385,13 @@ test('with SELECT BOX armed, Escape clears the preview first and the next Escape
|
||||
assert.equal(f.snap().toolActive, false);
|
||||
await tool.destroy();
|
||||
});
|
||||
|
||||
test('a voice search cancelled mid-flight says so instead of looking empty', async () => {
|
||||
const f = fixture();
|
||||
f.layer.enable();
|
||||
f.layer.setBox(BOX);
|
||||
assert.equal(f.snap().searching, true);
|
||||
f.layer.cancelPendingSearch();
|
||||
assert.equal(f.snap().searching, false);
|
||||
assert.equal(f.snap().notice, 'Search cancelled');
|
||||
});
|
||||
@@ -20,6 +20,19 @@ function tileFreshness(response) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip a flow line to a box. Every piece keeps its uncut line as `line`, so
|
||||
* pieces of one line that doubles back are never paired with each other.
|
||||
*/
|
||||
function clipFlowSegment(segment, bbox) {
|
||||
const line = segment.line || segment;
|
||||
return clipTileLine(segment.coords, bbox).map((coords) => ({
|
||||
...segment,
|
||||
coords,
|
||||
line,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Own one bounded decoded flow cache; every request still passes the server tile budget. */
|
||||
export function createFlowTileSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
@@ -41,12 +54,7 @@ export function createFlowTileSource({
|
||||
entry?.release();
|
||||
const segments = decodeFlowTile(bytes, z, x, y, { strict: true })
|
||||
.filter(isDrivableFlowSegment)
|
||||
.flatMap((segment) =>
|
||||
clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({
|
||||
...segment,
|
||||
coords,
|
||||
})),
|
||||
);
|
||||
.flatMap((segment) => clipFlowSegment(segment, tileToBBox(z, x, y)));
|
||||
if (mark) freshness.set(segments, mark);
|
||||
return segments;
|
||||
},
|
||||
@@ -87,12 +95,9 @@ export function createFlowTileSource({
|
||||
partial = result.partial;
|
||||
const segments = bounds.coverage
|
||||
? result.tiles.flat()
|
||||
: result.tiles.flat().flatMap((segment) =>
|
||||
clipTileLine(segment.coords, bounds).map((coords) => ({
|
||||
...segment,
|
||||
coords,
|
||||
})),
|
||||
);
|
||||
: result.tiles
|
||||
.flat()
|
||||
.flatMap((segment) => clipFlowSegment(segment, bounds));
|
||||
const marks = result.tiles.map((tile) => freshness.get(tile) || {});
|
||||
const times = marks
|
||||
.map((mark) => mark.fetchedAt)
|
||||
|
||||
@@ -35,6 +35,14 @@ const MIN_FILL_M = 40;
|
||||
* Same-class overlap keeps the full 35 m matcher radius.
|
||||
*/
|
||||
const CLASS_MISMATCH_RADIUS_M = 15;
|
||||
/**
|
||||
* Opposite-direction TomTom lines this close share one street: TomTom draws
|
||||
* both directions of a two-way street a metre or two apart, and a divided
|
||||
* carriageway's sides further apart.
|
||||
*/
|
||||
const PAIRED_SIDE_RADIUS_M = 5;
|
||||
/** Arc-length positions sampled along a `one_side` line for its partner. */
|
||||
const PAIRED_SIDE_SAMPLES = [0.25, 0.5, 0.75];
|
||||
const CONTROLLED_ACCESS_FLOW = new Set(['Motorway', 'International road']);
|
||||
const CONTROLLED_ACCESS_OSM = new Set(['motorway', 'trunk']);
|
||||
|
||||
@@ -85,20 +93,69 @@ export function isDrivableFlowSegment(segment) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Share of a `one_side` line's sample points (PAIRED_SIDE_SAMPLES) with an
|
||||
* opposite-direction TomTom line beside them: TomTom draws a two-way street
|
||||
* whose directions differ as two `one_side` lines on one centreline, while a
|
||||
* divided carriageway's other side is further away than PAIRED_SIDE_RADIUS_M.
|
||||
* @returns {number} Paired share in [0, 1].
|
||||
*/
|
||||
function pairedSampleShare(segment, index) {
|
||||
const xs = [];
|
||||
const ys = [];
|
||||
const cum = [0];
|
||||
for (const [lon, lat] of segment.coords) {
|
||||
const [x, y] = index.project(lon, lat);
|
||||
const i = xs.push(x) - 1;
|
||||
ys.push(y);
|
||||
if (i) cum.push(cum[i - 1] + Math.hypot(x - xs[i - 1], y - ys[i - 1]));
|
||||
}
|
||||
const length = cum[cum.length - 1];
|
||||
if (!(length > 0)) return 0;
|
||||
const line = segment.line || segment;
|
||||
let paired = 0;
|
||||
let i = 1;
|
||||
for (const share of PAIRED_SIDE_SAMPLES) {
|
||||
const target = share * length;
|
||||
while (i < cum.length - 1 && cum[i] < target) i++;
|
||||
const dx = xs[i] - xs[i - 1];
|
||||
const dy = ys[i] - ys[i - 1];
|
||||
const t = (target - cum[i - 1]) / (cum[i] - cum[i - 1] || 1);
|
||||
const reverse = (Math.atan2(-dx, -dy) * 180) / Math.PI;
|
||||
if (
|
||||
index.nearest(
|
||||
xs[i - 1] + dx * t,
|
||||
ys[i - 1] + dy * t,
|
||||
reverse,
|
||||
// A line that doubles back beside itself, even clipped into pieces,
|
||||
// is not its own pair.
|
||||
(piece) =>
|
||||
(piece.flow.line || piece.flow) === line ? 0 : PAIRED_SIDE_RADIUS_M,
|
||||
).best
|
||||
)
|
||||
paired++;
|
||||
}
|
||||
return paired / PAIRED_SIDE_SAMPLES.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert decoded TomTom flow lines into road records that carry their own
|
||||
* flow. A line with `coverage: 'full'` describes both travel directions;
|
||||
* `one_side` lines describe the direction they are drawn in.
|
||||
* `one_side` lines describe the direction they are drawn in. A `one_side`
|
||||
* line paired with the opposite direction on the same street gets that
|
||||
* direction's half share of dots, as a two-way road's direction does.
|
||||
* @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean, coverage?:string}>} segments
|
||||
* @returns {Array<object>} Road records (`directFlow: true`).
|
||||
*/
|
||||
export function flowSegmentsToRoads(segments) {
|
||||
return (segments || [])
|
||||
.filter(
|
||||
(segment) =>
|
||||
segment?.coords?.length >= 2 && isDrivableFlowSegment(segment),
|
||||
)
|
||||
.map((segment) => ({
|
||||
const drivable = (segments || []).filter(
|
||||
(segment) => segment?.coords?.length >= 2 && isDrivableFlowSegment(segment),
|
||||
);
|
||||
const sides = createFlowSegmentIndex(
|
||||
drivable.filter((segment) => segment.coverage !== 'full'),
|
||||
);
|
||||
return drivable.map((segment) => {
|
||||
const road = {
|
||||
coordinates: segment.coords,
|
||||
type: FLOW_ROAD_TYPES[segment.roadType],
|
||||
drivable: true,
|
||||
@@ -110,7 +167,15 @@ export function flowSegmentsToRoads(segments) {
|
||||
oneway: segment.coverage === 'full' ? 0 : 1,
|
||||
flow: { level: segment.trafficLevel, closure: segment.closure === true },
|
||||
directFlow: true,
|
||||
}));
|
||||
};
|
||||
if (
|
||||
segment.coverage !== 'full' &&
|
||||
sides &&
|
||||
pairedSampleShare(segment, sides) >= 0.5
|
||||
)
|
||||
road.densityWeight = 0.5;
|
||||
return road;
|
||||
});
|
||||
}
|
||||
|
||||
/** Cut a projected polyline to the arc-length interval [from, to]. */
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PbfWriter } from 'pbf';
|
||||
import {
|
||||
flowSegmentsToRoads,
|
||||
resolveRoadMode,
|
||||
selectTrafficRoads,
|
||||
} from './roadModes.js';
|
||||
import { createFlowTileSource } from './flowSource.js';
|
||||
import { createTrafficSource } from './source.js';
|
||||
import { createTrafficLayer } from './index.js';
|
||||
import {
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
serializeStoredLayerState,
|
||||
parseStoredLayerState,
|
||||
} from '../../data/layerState.js';
|
||||
import { tileToBBox } from '../../data/tomtomTiles.js';
|
||||
|
||||
const coordinates = [
|
||||
[-97.744, 30.267],
|
||||
@@ -116,6 +119,133 @@ test('a full-coverage TomTom line is two-way and covers both OSM directions', ()
|
||||
assert.equal(roads.length, 1);
|
||||
assert.equal(roads[0].directFlow, true);
|
||||
});
|
||||
test("opposite TomTom sides of one street share a two-way street's dots", () => {
|
||||
// About 1 m and 20 m north of `coordinates`, drawn westbound.
|
||||
const back = (dLat) => ({
|
||||
...flow,
|
||||
coords: coordinates.map(([lon, lat]) => [lon, lat + dLat]).reverse(),
|
||||
});
|
||||
const weights = (segments) =>
|
||||
flowSegmentsToRoads(segments).map((road) => road.densityWeight ?? 1);
|
||||
assert.deepEqual(weights([flow, back(0.00001)]), [0.5, 0.5]);
|
||||
assert.deepEqual(weights([flow]), [1], 'a lone side keeps the one-way share');
|
||||
assert.deepEqual(
|
||||
weights([flow, back(0.00018)]),
|
||||
[1, 1],
|
||||
'a divided carriageway keeps one-way shares',
|
||||
);
|
||||
assert.deepEqual(
|
||||
weights([
|
||||
flow,
|
||||
{
|
||||
...flow,
|
||||
coords: coordinates.map(([lon, lat]) => [lon, lat + 0.00001]),
|
||||
},
|
||||
]),
|
||||
[1, 1],
|
||||
'same-direction lines are not a pair',
|
||||
);
|
||||
assert.deepEqual(
|
||||
weights([{ ...flow, coverage: 'full' }, back(0.00001)]),
|
||||
[1, 1],
|
||||
'a full-coverage line is not a side, and pairs with nothing',
|
||||
);
|
||||
});
|
||||
test('a TomTom side paired along only part of its length keeps its share', () => {
|
||||
// Westbound partners about 1 m north, from the start of `flow` to 30% and
|
||||
// to 60% of its length: one or two of its three sample points.
|
||||
const partner = (toLon) => ({
|
||||
...flow,
|
||||
coords: [
|
||||
[toLon, 30.26701],
|
||||
[-97.744, 30.26701],
|
||||
],
|
||||
});
|
||||
const weightOfFlow = (other) =>
|
||||
flowSegmentsToRoads([flow, other])[0].densityWeight ?? 1;
|
||||
assert.equal(weightOfFlow(partner(-97.7434)), 1);
|
||||
assert.equal(weightOfFlow(partner(-97.7428)), 0.5);
|
||||
});
|
||||
test('a TomTom side that doubles back beside itself is not its own pair', () => {
|
||||
// East along `coordinates`, then back west about 1 m north.
|
||||
const hairpin = {
|
||||
...flow,
|
||||
coords: [...coordinates, [-97.742, 30.26701], [-97.744, 30.26701]],
|
||||
};
|
||||
assert.equal(flowSegmentsToRoads([hairpin])[0].densityWeight ?? 1, 1);
|
||||
});
|
||||
/** A flow tile of one-way Major roads; each line is [x, y, x, y, ...]. */
|
||||
function flowTile(lines) {
|
||||
const writer = new PbfWriter();
|
||||
writer.writeMessage(3, writeFlowLayer, lines);
|
||||
return writer.finish();
|
||||
}
|
||||
function writeFlowLayer(lines, layer) {
|
||||
layer.writeVarintField(15, 2);
|
||||
layer.writeStringField(1, 'Traffic flow');
|
||||
layer.writeVarintField(5, 4096);
|
||||
layer.writeStringField(3, 'traffic_level');
|
||||
layer.writeMessage(4, (_, value) => value.writeDoubleField(3, 0.6), null);
|
||||
layer.writeStringField(3, 'road_type');
|
||||
layer.writeMessage(
|
||||
4,
|
||||
(_, value) => value.writeStringField(1, 'Major road'),
|
||||
null,
|
||||
);
|
||||
layer.writeStringField(3, 'traffic_road_coverage');
|
||||
layer.writeMessage(
|
||||
4,
|
||||
(_, value) => value.writeStringField(1, 'one_side'),
|
||||
null,
|
||||
);
|
||||
for (const xy of lines) {
|
||||
// MoveTo one point, LineTo the rest, as zigzag deltas.
|
||||
const geometry = [9];
|
||||
for (let i = 0; i < xy.length; i++) {
|
||||
if (i === 2) geometry.push(2 | ((xy.length / 2 - 1) << 3));
|
||||
const delta = xy[i] - (i < 2 ? 0 : xy[i - 2]);
|
||||
geometry.push((delta << 1) ^ (delta >> 31));
|
||||
}
|
||||
layer.writeMessage(
|
||||
2,
|
||||
(_, feature) => {
|
||||
feature.writeVarintField(3, 2);
|
||||
feature.writePackedVarint(2, [0, 0, 1, 1, 2, 2]);
|
||||
feature.writePackedVarint(4, geometry);
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
test('TomTom sides clipped by their tile and the view pair only with other lines', async () => {
|
||||
// A one-way hairpin turning in the tile buffer, its arms one tile unit
|
||||
// (about 2 m) apart, and a separate opposite pair. The tile clip cuts the
|
||||
// hairpin into two arms; the view clip then cuts every line again.
|
||||
const lines = [
|
||||
[500, 1000, 4300, 1000, 4300, 1001, 500, 1001],
|
||||
[-200, 3000, 4300, 3000],
|
||||
[4300, 3001, -200, 3001],
|
||||
];
|
||||
const source = createFlowTileSource({
|
||||
fetchImpl: async () => new Response(flowTile(lines)),
|
||||
});
|
||||
const tile = tileToBBox(12, 935, 1686);
|
||||
const west = tile.west + (tile.east - tile.west) * 0.2;
|
||||
const { segments } = await source.fetchFlowDetail({
|
||||
...tile,
|
||||
west,
|
||||
east: tile.east - 1e-9,
|
||||
south: tile.south + 1e-9,
|
||||
});
|
||||
assert.equal(segments.length, 4);
|
||||
assert.ok(
|
||||
segments.every((s) => s.coords.every(([lon]) => lon >= west - 1e-10)),
|
||||
);
|
||||
assert.deepEqual(
|
||||
flowSegmentsToRoads(segments).map((road) => road.densityWeight ?? 1),
|
||||
[1, 1, 0.5, 0.5],
|
||||
);
|
||||
});
|
||||
test('bearing and distance reject nearby crossing and separate OSM roads', () => {
|
||||
const parallel = {
|
||||
...main,
|
||||
|
||||
Reference in new issue
Block a user