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:
Matt Van Horn committed 2026-11-03 11:55:42 -08:00
commit 08b8be26fe
195 files changed
+23414 -1578

No files matched your search

+104
View File
@@ -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;
+87
View File
@@ -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
+594 -22
View File
@@ -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,
};
}
+4
View File
@@ -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,
+73 -20
View File
@@ -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 };
+5 -2
View File
@@ -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';
+24
View File
@@ -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(
+1
View File
@@ -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;
+184
View File
@@ -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)'),
};
},
};
}
+152
View File
@@ -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`,
);
});
+111
View File
@@ -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 };
}
+6 -3
View File
@@ -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 });
+46 -16
View File
@@ -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];
}
+2
View File
@@ -78,6 +78,8 @@ export function createState({ services }) {
layerState._tuningStartStationId = null;
layerState._tuningResumesPlayback = false;
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
layerState._tuningStationById = new Map();
+17 -5
View File
@@ -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,
+22 -4
View File
@@ -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 {
+10
View File
@@ -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
+10
View File
@@ -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');
});
+17 -12
View File
@@ -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)
+73 -8
View File
@@ -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]. */
+130
View File
@@ -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,