Merge remote-tracking branch 'origin/main' into fix/realtime-debug-log-hardening

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Pedro Lobato committed 2026-09-15 11:49:03 -05:00
commit 4fd168ff3c
332 files changed
+56273 -6359

No files matched your search

+455 -105
View File
@@ -1,12 +1,20 @@
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js';
import { isRateLimitedOutcome, resolveAnnotationTarget, sampleGroundHeight } from './annotationResolver.js';
import {
holdContinuousRender,
releaseContinuousRender,
} from '../renderGovernor.js';
import {
isRateLimitedOutcome,
resolveAnnotationTarget,
sampleGroundHeight,
} from './annotationResolver.js';
import { ringCentroid } from './drawMode.js';
// Dev convenience: expose the app's Cesium instance for console/preview probing
// (single shared module instance — avoids dual-Cesium state bugs when testing).
if (typeof window !== 'undefined' && !window.__CESIUM__) window.__CESIUM__ = Cesium;
if (typeof window !== 'undefined' && !window.__CESIUM__)
window.__CESIUM__ = Cesium;
/**
* Annotation engine — the voice agent's "whiteboard" over the 3D world.
@@ -25,7 +33,13 @@ const COLORS = new Set(['primary', 'amber', 'cyan', 'green', 'red']);
// Entity FACTS the voice model may attach to an annotation (annotate_map.entityKind) —
// what kind of thing the target IS, routing the resolver (e.g. point_feature keeps a
// monument point-first). Unknown values are dropped, never guessed.
const ENTITY_KINDS = new Set(['building', 'compound', 'district', 'street', 'point_feature']);
const ENTITY_KINDS = new Set([
'building',
'compound',
'district',
'street',
'point_feature',
]);
const DEFAULT_TTL_MS = 22_000;
const FADE_MS = 1200;
// Hard ceiling on simultaneously-live marks. Protects against a runaway voice
@@ -51,11 +65,14 @@ const OUTLINE_UPGRADE_CONCURRENCY = 2;
* retrying immediately. A thrown resolver counts as definitive — the pre-retry
* catch→point behavior. Exported for tests.
*/
export async function resolveOutlineWithRetry(resolveOutline, {
delaysMs = OUTLINE_RETRY_DELAYS_MS,
isStale = () => false,
waitFn = wait,
} = {}) {
export async function resolveOutlineWithRetry(
resolveOutline,
{
delaysMs = OUTLINE_RETRY_DELAYS_MS,
isStale = () => false,
waitFn = wait,
} = {},
) {
let retriedRateLimit = false;
for (let attempt = 0; ; attempt += 1) {
let fp;
@@ -68,7 +85,9 @@ export async function resolveOutlineWithRetry(resolveOutline, {
// A throttle gets one deliberately spaced retry. If that retry is throttled
// too, stop this mark's outline task instead of replaying the batch storm.
if (retriedRateLimit || attempt >= delaysMs.length) return undefined;
const retryAfterMs = Number.isFinite(fp.retryAfterMs) ? fp.retryAfterMs : 0;
const retryAfterMs = Number.isFinite(fp.retryAfterMs)
? fp.retryAfterMs
: 0;
await waitFn(Math.max(retryAfterMs, delaysMs[attempt]));
if (isStale()) return undefined;
retriedRateLimit = true;
@@ -89,7 +108,9 @@ export async function resolveOutlineWithRetry(resolveOutline, {
* geometry is unknown. Exported for tests.
*/
export function normalizeTargetKey(target) {
const raw = String(target ?? '').trim().toLowerCase();
const raw = String(target ?? '')
.trim()
.toLowerCase();
if (!raw) return null;
const head = raw.split(',')[0].trim();
return head || raw;
@@ -123,12 +144,22 @@ export function createAnnotationEngine({
function emitOutlineEvent(evt) {
for (const listener of outlineListeners) {
try { listener(evt); } catch { /* a listener error must never break the upgrade */ }
try {
listener(evt);
} catch {
/* a listener error must never break the upgrade */
}
}
}
function abortPending() {
for (const c of activeControllers) { try { c.abort(); } catch { /* no-op */ } }
for (const c of activeControllers) {
try {
c.abort();
} catch {
/* no-op */
}
}
activeControllers.clear();
}
@@ -174,7 +205,9 @@ export function createAnnotationEngine({
function rollbackRendererState(anno) {
try {
renderer.remove(anno);
} catch { /* the renderer is already in a bad way; the original error wins */ }
} catch {
/* the renderer is already in a bad way; the original error wins */
}
}
function ensureTicking() {
@@ -226,7 +259,15 @@ export function createAnnotationEngine({
* @returns {Promise<{ok, drawn, failed, ids, results}>}
*/
async function annotate(requests, opts = {}) {
if (destroyed) return { ok: false, drawn: 0, failed: 0, ids: [], results: [], error: 'destroyed' };
if (destroyed)
return {
ok: false,
drawn: 0,
failed: 0,
ids: [],
results: [],
error: 'destroyed',
};
const list = Array.isArray(requests) ? requests : [requests];
if (opts.clearPrevious) clear(); // bumps generation + aborts older pending work
@@ -247,7 +288,11 @@ export function createAnnotationEngine({
// allSettled gives per-item error isolation (one failed item never aborts the batch); the
// mutation pass below then runs in ORDER, so de-dup, the synchronous live-cap check, and output
// order are all preserved exactly as the old serial loop had them.
const settled = await Promise.allSettled(list.map((spec) => resolveSpec(spec, controller.signal, opts.flyTo === true)));
const settled = await Promise.allSettled(
list.map((spec) =>
resolveSpec(spec, controller.signal, opts.flyTo === true),
),
);
try {
for (let i = 0; i < list.length; i += 1) {
@@ -270,8 +315,15 @@ export function createAnnotationEngine({
// stacked. De-dup runs BEFORE the cap so a re-narration still refreshes when full.
const dup = findDuplicate(anno);
if (dup) {
const labelChanged = String(dup.label || '').trim().toLowerCase() !== String(anno.label || '').trim().toLowerCase();
const colorChanged = (dup.color || 'primary') !== (anno.color || 'primary');
const labelChanged =
String(dup.label || '')
.trim()
.toLowerCase() !==
String(anno.label || '')
.trim()
.toLowerCase();
const colorChanged =
(dup.color || 'primary') !== (anno.color || 'primary');
if (labelChanged || colorChanged) {
// Same place + shape, but a NEW label and/or color ("Marina" → "Marina District",
// or a recolor) → replace in place so the latest caption/color wins (count
@@ -305,13 +357,26 @@ export function createAnnotationEngine({
ids.push(anno.id);
if (!firstAnchor) firstAnchor = anno;
if (typeof resolved.resolveOutline === 'function') {
startOutlineUpgrade(anno, resolved.resolveOutline, myGen, controller);
startOutlineUpgrade(
anno,
resolved.resolveOutline,
myGen,
controller,
);
}
results.push({ ...okResult(anno, resolved, anno.id), duplicate: true });
results.push({
...okResult(anno, resolved, anno.id),
duplicate: true,
});
} else {
// Identical re-narration → refresh the existing mark's lifecycle in place (keep
// it visible whether persistent or TTL-based, and revive a fading-out one).
if (persist) { dup.ttlMs = null; } else { dup.createdAt = performance.now(); dup.ttlMs = anno.ttlMs; }
if (persist) {
dup.ttlMs = null;
} else {
dup.createdAt = performance.now();
dup.ttlMs = anno.ttlMs;
}
dup.expiring = false;
dup.fadeStart = null;
ids.push(dup.id);
@@ -319,11 +384,19 @@ export function createAnnotationEngine({
// A re-narration is also a RETRY: if the existing mark never got its outline
// (transient Overpass miss), attach this call's deferred resolver to it.
if (!dup.ring && typeof resolved.resolveOutline === 'function') {
startOutlineUpgrade(dup, resolved.resolveOutline, myGen, controller);
startOutlineUpgrade(
dup,
resolved.resolveOutline,
myGen,
controller,
);
}
// Same result builder as the fresh path (full metadata) but the existing id —
// route fallback / approximate honesty fields survive a re-narration.
results.push({ ...okResult(anno, resolved, dup.id), duplicate: true });
results.push({
...okResult(anno, resolved, dup.id),
duplicate: true,
});
}
continue;
}
@@ -350,12 +423,23 @@ export function createAnnotationEngine({
if (!firstAnchor) firstAnchor = anno;
// Progressive: the point is on the board; trace the outline behind the narration.
if (typeof resolved.resolveOutline === 'function') {
startOutlineUpgrade(anno, resolved.resolveOutline, myGen, controller);
startOutlineUpgrade(
anno,
resolved.resolveOutline,
myGen,
controller,
);
}
results.push(okResult(anno, resolved, anno.id));
} catch (error) {
if (superseded()) break;
results.push(failResult(spec, error?.message || 'annotation failed', Array.isArray(error?.failedTargets) ? error.failedTargets : null));
results.push(
failResult(
spec,
error?.message || 'annotation failed',
Array.isArray(error?.failedTargets) ? error.failedTargets : null,
),
);
}
}
} finally {
@@ -365,7 +449,14 @@ export function createAnnotationEngine({
// A clear/new-topic landed mid-flight: drop everything this call produced and
// do not touch the renderer or camera — the board now belongs to a newer call.
if (superseded()) {
return { ok: false, drawn: 0, failed: results.length, ids: [], results, aborted: true };
return {
ok: false,
drawn: 0,
failed: results.length,
ids: [],
results,
aborted: true,
};
}
if (ids.length) {
@@ -393,7 +484,8 @@ export function createAnnotationEngine({
if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);
if (type === 'route') {
const points = Array.isArray(spec.points) ? spec.points : [];
if (points.length < 2) throw new Error('a route needs at least 2 waypoints');
if (points.length < 2)
throw new Error('a route needs at least 2 waypoints');
const resolvedPts = [];
const failed = [];
for (const pt of points) {
@@ -425,7 +517,12 @@ export function createAnnotationEngine({
}
// Real street-following route (OSM/OSRM), mode-aware.
const mode = normalizeMode(spec.mode);
const routed = await fetchRoute(resolvedPts.map((p) => [p.lon, p.lat]), mode, signal, placeSearch);
const routed = await fetchRoute(
resolvedPts.map((p) => [p.lon, p.lat]),
mode,
signal,
placeSearch,
);
if (routed) {
return {
path: routed.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
@@ -439,8 +536,16 @@ export function createAnnotationEngine({
// Routing unavailable: do NOT pass off straight segments as a real route. Draw
// a clearly-labeled direct line with great-circle distance and NO travel time.
let straight = 0;
for (let i = 1; i < resolvedPts.length; i += 1) straight += greatCircleM(resolvedPts[i - 1], resolvedPts[i]);
return { path: resolvedPts, distanceM: straight, durationS: null, mode, source: resolvedPts[0].source, fallback: true };
for (let i = 1; i < resolvedPts.length; i += 1)
straight += greatCircleM(resolvedPts[i - 1], resolvedPts[i]);
return {
path: resolvedPts,
distanceM: straight,
durationS: null,
mode,
source: resolvedPts[0].source,
fallback: true,
};
}
if (type === 'arrow') {
const from = await resolveTarget({
@@ -479,9 +584,15 @@ export function createAnnotationEngine({
err.failedTargets = failed;
throw err;
}
return { from, to, distanceM: greatCircleM(from, to), source: from.source };
return {
from,
to,
distanceM: greatCircleM(from, to),
source: from.source,
};
}
const wantFootprint = type === 'area' ? spec.footprint !== false : Boolean(spec.footprint);
const wantFootprint =
type === 'area' ? spec.footprint !== false : Boolean(spec.footprint);
return resolveTarget({
placeSearch,
viewer,
@@ -491,7 +602,8 @@ export function createAnnotationEngine({
screenX: spec.screenX,
screenY: spec.screenY,
footprint: wantFootprint,
intent: spec.intent === 'around_the_thing' ? 'around_the_thing' : 'the_thing',
intent:
spec.intent === 'around_the_thing' ? 'around_the_thing' : 'the_thing',
entityKind: ENTITY_KINDS.has(spec?.entityKind) ? spec.entityKind : null,
// The label often carries the ask's true shape when the target omits it
// ("target: Texas State Capitol" + "label: Capitol grounds") — a resolver HINT only.
@@ -525,11 +637,15 @@ export function createAnnotationEngine({
}
function drainOutlineQueue() {
while (activeOutlineUpgrades < OUTLINE_UPGRADE_CONCURRENCY && outlineQueue.length) {
while (
activeOutlineUpgrades < OUTLINE_UPGRADE_CONCURRENCY &&
outlineQueue.length
) {
const task = outlineQueue.shift();
const stale = task.myGen !== generation
|| task.controller.signal.aborted
|| !annotations.has(task.anno.id);
const stale =
task.myGen !== generation ||
task.controller.signal.aborted ||
!annotations.has(task.anno.id);
if (stale) {
if (task.anno._outlineTask === task) task.anno._outlineTask = null;
releaseController(task.controller);
@@ -549,7 +665,10 @@ export function createAnnotationEngine({
// proxy caches the late completion, so the retry is usually a warm cache hit.
fp = await resolveOutlineWithRetry(resolveOutline, {
delaysMs: outlineRetryDelaysMs,
isStale: () => myGen !== generation || controller.signal.aborted || !annotations.has(anno.id),
isStale: () =>
myGen !== generation ||
controller.signal.aborted ||
!annotations.has(anno.id),
});
if (myGen !== generation || controller.signal.aborted) return; // board superseded
if (!annotations.has(anno.id)) return; // mark replaced/removed while resolving
@@ -577,11 +696,22 @@ export function createAnnotationEngine({
// wins, exactly like the add-time replace path). Not an auto-clear — it removes
// a second representation of the same mark, never a distinct one.
for (const other of annotations.values()) {
if (other.id === anno.id || other.type !== 'area' || other.pendingOutline) continue;
if (!other.anchor || Math.abs(other.anchor.lon - anno.anchor.lon) >= 5e-4
|| Math.abs(other.anchor.lat - anno.anchor.lat) >= 5e-4) continue;
if (Boolean(other.synthesized) !== Boolean(anno.synthesized)) continue;
if ((other.footprintKind || null) !== (anno.footprintKind || null)) continue;
if (
other.id === anno.id ||
other.type !== 'area' ||
other.pendingOutline
)
continue;
if (
!other.anchor ||
Math.abs(other.anchor.lon - anno.anchor.lon) >= 5e-4 ||
Math.abs(other.anchor.lat - anno.anchor.lat) >= 5e-4
)
continue;
if (Boolean(other.synthesized) !== Boolean(anno.synthesized))
continue;
if ((other.footprintKind || null) !== (anno.footprintKind || null))
continue;
if (!ringsEqual(other.ring, anno.ring)) continue;
annotations.delete(other.id);
renderer.remove(other);
@@ -629,7 +759,12 @@ export function createAnnotationEngine({
// Route-specific signal so the voice layer can be honest about an OSRM outage:
// fallback=true means a straight direct line, not a real route.
...(anno.type === 'route'
? { fallback: Boolean(anno.fallback), mode: anno.mode, distanceM: anno.distanceM, durationS: anno.durationS }
? {
fallback: Boolean(anno.fallback),
mode: anno.mode,
distanceM: anno.distanceM,
durationS: anno.durationS,
}
: {}),
};
}
@@ -643,13 +778,18 @@ export function createAnnotationEngine({
* degenerate geocode stacks on one point (several Capitol monuments) would erase each other. */
function findDuplicate(anno) {
if (!anno.anchor) return null;
const near = (a, b) => a && b && Math.abs(a.lon - b.lon) < 5e-4 && Math.abs(a.lat - b.lat) < 5e-4;
const near = (a, b) =>
a &&
b &&
Math.abs(a.lon - b.lon) < 5e-4 &&
Math.abs(a.lat - b.lat) < 5e-4;
// FULL per-vertex geometry comparison (the live cap is only 120 and this runs solely for
// candidates that already matched type+label+anchor, so it's cheap) — so two DIFFERENT
// same-length rings/paths at the same anchor are never falsely merged.
const ringSame = ringsEqual;
const pathSame = (a, b) => {
const la = a ? a.length : 0; const lb = b ? b.length : 0;
const la = a ? a.length : 0;
const lb = b ? b.length : 0;
if (la !== lb) return false;
for (let i = 0; i < la; i++) if (!near(a[i], b[i])) return false;
return true;
@@ -674,10 +814,12 @@ export function createAnnotationEngine({
// the identity; the target is only the stand-in while geometry is unknown.
if (!anno.targetKey || !ex.targetKey) continue;
if (anno.targetKey !== ex.targetKey) continue;
if ((ex.intentKey || 'thing') !== (anno.intentKey || 'thing')) continue;
if ((ex.intentKey || 'thing') !== (anno.intentKey || 'thing'))
continue;
} else {
if (Boolean(ex.synthesized) !== Boolean(anno.synthesized)) continue;
if ((ex.footprintKind || null) !== (anno.footprintKind || null)) continue;
if ((ex.footprintKind || null) !== (anno.footprintKind || null))
continue;
if (!ringSame(ex.ring, anno.ring)) continue;
}
} else if (anno.type === 'arrow') {
@@ -691,7 +833,13 @@ export function createAnnotationEngine({
// point (e.g. several Texas Capitol monuments all geocoding to the dome centroid) — NOT a
// duplicate. Only collapse a genuine re-narration of the SAME point (same label); distinct
// labels stay as separate marks (their callouts de-collide in the renderer).
const sameLabel = String(ex.label || '').trim().toLowerCase() === String(anno.label || '').trim().toLowerCase();
const sameLabel =
String(ex.label || '')
.trim()
.toLowerCase() ===
String(anno.label || '')
.trim()
.toLowerCase();
if (!sameLabel) continue;
}
return ex;
@@ -703,7 +851,11 @@ export function createAnnotationEngine({
const type = normalizeType(spec?.type);
const id = `anno-${++_seq}`;
const color = COLORS.has(spec?.color) ? spec.color : 'primary';
const label = cleanLabel(spec?.label) || resolved?.label || resolved?.from?.label || null;
const label =
cleanLabel(spec?.label) ||
resolved?.label ||
resolved?.from?.label ||
null;
const now = performance.now();
const base = {
@@ -712,17 +864,27 @@ export function createAnnotationEngine({
color,
label,
createdAt: now,
ttlMs: persist ? null : (Number(spec?.ttlMs) || DEFAULT_TTL_MS),
ttlMs: persist ? null : Number(spec?.ttlMs) || DEFAULT_TTL_MS,
alpha: 0, // animate in
bornAt: now,
expiring: false,
};
if (type === 'route') {
const path = resolved.path.map((p) => ({ lon: p.lon, lat: p.lat, height: p.height }));
const path = resolved.path.map((p) => ({
lon: p.lon,
lat: p.lat,
height: p.height,
}));
return {
...base,
label: composeRouteLabel(base.label, resolved.distanceM, resolved.durationS, resolved.mode, resolved.fallback),
label: composeRouteLabel(
base.label,
resolved.distanceM,
resolved.durationS,
resolved.mode,
resolved.fallback,
),
anchor: path[0],
to: null,
path,
@@ -738,8 +900,16 @@ export function createAnnotationEngine({
return {
...base,
label: appendDistance(base.label, resolved.distanceM),
anchor: { lon: resolved.from.lon, lat: resolved.from.lat, height: resolved.from.height },
to: { lon: resolved.to.lon, lat: resolved.to.lat, height: resolved.to.height },
anchor: {
lon: resolved.from.lon,
lat: resolved.from.lat,
height: resolved.from.height,
},
to: {
lon: resolved.to.lon,
lat: resolved.to.lat,
height: resolved.to.height,
},
ring: null,
};
}
@@ -814,13 +984,17 @@ export function createAnnotationEngine({
if (m.to) points.push(m.to);
}
if (!points.length || points.some((p) => isPointOnScreen(p))) return;
console.log(`[Annotations] auto-framing ${marks.length} off-screen mark(s)`);
console.log(
`[Annotations] auto-framing ${marks.length} off-screen mark(s)`,
);
assistFlightUntil = performance.now() + 2600; // ~flight duration + settle
if (marks.length === 1) {
frameAnnotation(marks[0]);
return;
}
const cart = points.map((p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0));
const cart = points.map((p) =>
Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0),
);
const sphere = Cesium.BoundingSphere.fromPoints(cart);
viewer.camera.flyToBoundingSphere(sphere, {
offset: new Cesium.HeadingPitchRange(
@@ -841,9 +1015,20 @@ export function createAnnotationEngine({
try {
const camera = viewer.camera;
const pos = Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0);
const cv = camera.frustum.computeCullingVolume(camera.position, camera.direction, camera.up);
if (cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) === Cesium.Intersect.OUTSIDE) return false;
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, camera.position);
const cv = camera.frustum.computeCullingVolume(
camera.position,
camera.direction,
camera.up,
);
if (
cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) ===
Cesium.Intersect.OUTSIDE
)
return false;
const occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
camera.position,
);
return occluder.isPointVisible(pos);
} catch {
return true;
@@ -853,15 +1038,24 @@ export function createAnnotationEngine({
function frameAnnotation(anno) {
try {
const target = anno.to
? { lon: (anno.anchor.lon + anno.to.lon) / 2, lat: (anno.anchor.lat + anno.to.lat) / 2 }
? {
lon: (anno.anchor.lon + anno.to.lon) / 2,
lat: (anno.anchor.lat + anno.to.lat) / 2,
}
: anno.anchor;
// No ring yet (progressive outline still resolving) → size the flight from the
// Places viewport box when we have one, so a big compound isn't framed at
// building scale while its outline is traced. Never re-fly when the ring lands.
const range = anno.ring ? ringRange(anno.ring) : (viewportRange(anno.viewport) || 600);
const range = anno.ring
? ringRange(anno.ring)
: viewportRange(anno.viewport) || 600;
viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
Cesium.Cartesian3.fromDegrees(target.lon, target.lat, anno.anchor.height || 0),
Cesium.Cartesian3.fromDegrees(
target.lon,
target.lat,
anno.anchor.height || 0,
),
range,
),
{
@@ -884,7 +1078,9 @@ export function createAnnotationEngine({
destroy() {
if (destroyed) return;
destroyed = true;
try { clear(); } finally {
try {
clear();
} finally {
if (tickHandle != null) cancelAnimationFrame(tickHandle);
tickHandle = null;
outlineListeners.clear();
@@ -912,12 +1108,36 @@ export function createAnnotationEngine({
* Lays down a small San Francisco "tour" the way the voice agent would.
*/
async demo() {
return annotate([
{ type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' },
{ type: 'area', target: 'Presidio of San Francisco', label: 'The Presidio (former Army base)', color: 'green', footprint: true },
{ type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'cyan' },
{ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina District, San Francisco', label: 'next to the Marina' },
], { flyTo: true, clearPrevious: true, persist: true });
return annotate(
[
{
type: 'highlight',
target: 'Palace of Fine Arts, San Francisco',
label: 'Palace of Fine Arts',
color: 'amber',
},
{
type: 'area',
target: 'Presidio of San Francisco',
label: 'The Presidio (former Army base)',
color: 'green',
footprint: true,
},
{
type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco',
label: 'ILM / Lucasfilm',
color: 'cyan',
},
{
type: 'arrow',
target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina District, San Francisco',
label: 'next to the Marina',
},
],
{ flyTo: true, clearPrevious: true, persist: true },
);
},
/**
@@ -927,34 +1147,107 @@ export function createAnnotationEngine({
*/
async tour() {
clear();
flyTo({ lon: -122.4486, lat: 37.7960, height: 520, heading: 0, pitch: -26, duration: 3 });
flyTo({
lon: -122.4486,
lat: 37.796,
height: 520,
heading: 0,
pitch: -26,
duration: 3,
});
if (destroyed) return { ok: false };
await wait(3200);
await annotate([{ type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' }], { persist: true });
await annotate(
[
{
type: 'highlight',
target: 'Palace of Fine Arts, San Francisco',
label: 'Palace of Fine Arts',
color: 'amber',
},
],
{ persist: true },
);
if (destroyed) return { ok: false };
await wait(2600);
await annotate([{ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina Green, San Francisco', label: 'next to the Marina', color: 'cyan' }], { persist: true });
await annotate(
[
{
type: 'arrow',
target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina Green, San Francisco',
label: 'next to the Marina',
color: 'cyan',
},
],
{ persist: true },
);
if (destroyed) return { ok: false };
await wait(2600);
flyTo({ lon: -122.4545, lat: 37.7880, height: 1500, heading: 18, pitch: -32, duration: 3 });
flyTo({
lon: -122.4545,
lat: 37.788,
height: 1500,
heading: 18,
pitch: -32,
duration: 3,
});
if (destroyed) return { ok: false };
await wait(3200);
await annotate([{ type: 'area', target: 'Presidio of San Francisco', label: 'The Presidio — a former Army base', color: 'green', footprint: true }], { persist: true });
await annotate(
[
{
type: 'area',
target: 'Presidio of San Francisco',
label: 'The Presidio — a former Army base',
color: 'green',
footprint: true,
},
],
{ persist: true },
);
if (destroyed) return { ok: false };
await wait(2800);
await annotate([{ type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'red' }], { persist: true });
await annotate(
[
{
type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco',
label: 'ILM / Lucasfilm',
color: 'red',
},
],
{ persist: true },
);
if (destroyed) return { ok: false };
await wait(2600);
await annotate([{ type: 'route', color: 'amber', label: 'Crissy Field shoreline', points: [
{ target: 'Palace of Fine Arts, San Francisco' },
{ target: 'Crissy Field, San Francisco' },
{ target: 'Fort Point, San Francisco' },
] }], { persist: true });
await annotate(
[
{
type: 'route',
color: 'amber',
label: 'Crissy Field shoreline',
points: [
{ target: 'Palace of Fine Arts, San Francisco' },
{ target: 'Crissy Field, San Francisco' },
{ target: 'Fort Point, San Francisco' },
],
},
],
{ persist: true },
);
return { ok: true, steps: 6 };
},
};
function flyTo({ lon, lat, height, heading = 0, pitch = -30, duration = 2.5 }) {
function flyTo({
lon,
lat,
height,
heading = 0,
pitch = -30,
duration = 2.5,
}) {
if (destroyed) return;
try {
viewer.camera.flyTo({
@@ -984,7 +1277,11 @@ function ringsEqual(a, b) {
const lb = b ? b.length : 0;
if (la !== lb) return false;
for (let i = 0; i < la; i++) {
if (Math.abs(a[i][0] - b[i][0]) >= 1e-5 || Math.abs(a[i][1] - b[i][1]) >= 1e-5) return false;
if (
Math.abs(a[i][0] - b[i][0]) >= 1e-5 ||
Math.abs(a[i][1] - b[i][1]) >= 1e-5
)
return false;
}
return true;
}
@@ -1010,7 +1307,8 @@ function specTargets(spec) {
* arrow endpoint) when known; otherwise it derives them from the spec.
*/
function failResult(spec, error, failedTargets) {
const targets = (failedTargets && failedTargets.length) ? failedTargets : specTargets(spec);
const targets =
failedTargets && failedTargets.length ? failedTargets : specTargets(spec);
return {
ok: false,
label: spec?.label || spec?.target || targets[0] || null,
@@ -1022,7 +1320,8 @@ function failResult(spec, error, failedTargets) {
function normalizeType(type) {
const t = String(type || '').toLowerCase();
if (t === 'area' || t === 'polygon' || t === 'outline' || t === 'compound') return 'area';
if (t === 'area' || t === 'polygon' || t === 'outline' || t === 'compound')
return 'area';
if (t === 'route' || t === 'path' || t === 'line') return 'route';
if (t === 'arrow' || t === 'vector' || t === 'connector') return 'arrow';
if (t === 'label' || t === 'callout' || t === 'text') return 'label';
@@ -1036,9 +1335,10 @@ function computeAlpha(anno, now) {
if (anno.ttlMs == null && !anno.expiring) return fadeIn;
const fadeStart = anno.fadeStart != null
? anno.fadeStart
: anno.createdAt + (anno.ttlMs || 0);
const fadeStart =
anno.fadeStart != null
? anno.fadeStart
: anno.createdAt + (anno.ttlMs || 0);
if (now < fadeStart) return fadeIn;
const out = 1 - (now - fadeStart) / FADE_MS;
if (out <= 0) anno.expiring = true;
@@ -1052,7 +1352,10 @@ function pendingAnimation(anno, now) {
if (now - anno.bornAt < 260) return true; // fading in
if (anno.expiring) return true; // fading out
if (anno.ttlMs != null) {
const fadeStart = anno.fadeStart != null ? anno.fadeStart : anno.createdAt + (anno.ttlMs || 0);
const fadeStart =
anno.fadeStart != null
? anno.fadeStart
: anno.createdAt + (anno.ttlMs || 0);
return now < fadeStart + FADE_MS; // TTL fade still ahead or in progress
}
return false; // persistent + faded in → stable
@@ -1110,16 +1413,30 @@ function round5(n) {
/** A spec whose geometry was supplied by hand (drawTool.js) rather than by a name. */
function isManualSpec(spec, type) {
if (!spec || spec.manual !== true) return false;
if (type === 'route') return Array.isArray(spec.path) && spec.path.length >= 2;
if (type === 'route')
return Array.isArray(spec.path) && spec.path.length >= 2;
if (type === 'area') return Array.isArray(spec.ring) && spec.ring.length >= 3;
return Number.isFinite(Number(spec.latitude)) && Number.isFinite(Number(spec.longitude));
return (
Number.isFinite(Number(spec.latitude)) &&
Number.isFinite(Number(spec.longitude))
);
}
/** [lon, lat] pairs or {lon, lat} objects → [lon, lat] pairs, invalid entries dropped. */
function manualPairs(list) {
return (Array.isArray(list) ? list : [])
.map((p) => (Array.isArray(p) ? [Number(p[0]), Number(p[1])] : [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)]))
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180);
.map((p) =>
Array.isArray(p)
? [Number(p[0]), Number(p[1])]
: [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)],
)
.filter(
([lon, lat]) =>
Number.isFinite(lon) &&
Number.isFinite(lat) &&
Math.abs(lat) <= 90 &&
Math.abs(lon) <= 180,
);
}
/**
@@ -1132,15 +1449,28 @@ function resolveManualSpec(spec, type, viewer) {
// height 0 is a placeholder, not a placement: the world renderer draws a
// route with clampToGround + CESIUM_3D_TILE classification, so the line is
// draped onto the photoreal surface whatever this number says.
const pts = manualPairs(spec.path).map(([lon, lat]) => ({ lon, lat, height: 0 }));
const pts = manualPairs(spec.path).map(([lon, lat]) => ({
lon,
lat,
height: 0,
}));
if (pts.length < 2) throw new Error('a drawn line needs at least 2 points');
let distanceM = 0;
for (let i = 1; i < pts.length; i += 1) distanceM += greatCircleM(pts[i - 1], pts[i]);
return { path: pts, distanceM, durationS: null, mode: 'manual', source: 'manual', fallback: false };
for (let i = 1; i < pts.length; i += 1)
distanceM += greatCircleM(pts[i - 1], pts[i]);
return {
path: pts,
distanceM,
durationS: null,
mode: 'manual',
source: 'manual',
fallback: false,
};
}
if (type === 'area') {
const ring = manualPairs(spec.ring);
if (ring.length < 3) throw new Error('a drawn area needs at least 3 points');
if (ring.length < 3)
throw new Error('a drawn area needs at least 3 points');
// Averaged through ringCentroid, which unwraps longitudes first: a ring
// straddling the antimeridian has coordinates like [179.999, -179.999], and
// a raw mean of those puts the anchor on the Greenwich meridian, half a
@@ -1151,13 +1481,25 @@ function resolveManualSpec(spec, type, viewer) {
const lon = centre.lon;
const lat = centre.lat;
return {
lon, lat, height: sampleGroundHeight(viewer, lon, lat),
ring, footprintKind: 'area', buildingHeight: null, synthesized: false, source: 'manual',
lon,
lat,
height: sampleGroundHeight(viewer, lon, lat),
ring,
footprintKind: 'area',
buildingHeight: null,
synthesized: false,
source: 'manual',
};
}
const lon = Number(spec.longitude);
const lat = Number(spec.latitude);
return { lon, lat, height: sampleGroundHeight(viewer, lon, lat), ring: null, source: 'manual' };
return {
lon,
lat,
height: sampleGroundHeight(viewer, lon, lat),
ring: null,
source: 'manual',
};
}
/** The ring's distinct positions: a closing vertex that repeats the first is dropped. */
@@ -1165,20 +1507,26 @@ function closedRingWithoutRepeat(ring) {
if (ring.length < 2) return ring;
const [firstLon, firstLat] = ring[0];
const [lastLon, lastLat] = ring[ring.length - 1];
return firstLon === lastLon && firstLat === lastLat ? ring.slice(0, -1) : ring;
return firstLon === lastLon && firstLat === lastLat
? ring.slice(0, -1)
: ring;
}
function normalizeMode(m) {
const t = String(m || '').toLowerCase();
if (t === 'car' || t === 'drive' || t === 'driving') return 'car';
if (t === 'bike' || t === 'cycle' || t === 'cycling' || t === 'bicycle') return 'bike';
if (t === 'bike' || t === 'cycle' || t === 'cycling' || t === 'bicycle')
return 'bike';
return 'foot';
}
/** Route failure remains an explicitly labelled direct line in the caller. */
async function fetchRoute(coordPairs, mode, signal, service) {
try { return await service.route?.(coordPairs, mode, { signal }) || null; }
catch { return null; }
try {
return (await service.route?.(coordPairs, mode, { signal })) || null;
} catch {
return null;
}
}
function greatCircleM(a, b) {
@@ -1188,7 +1536,9 @@ function greatCircleM(a, b) {
const dLon = toRad(b.lon - a.lon);
const la1 = toRad(a.lat);
const la2 = toRad(b.lat);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
}
+19 -2
View File
@@ -1,4 +1,21 @@
import { normalizeOverpassFeatures } from '../sources/overpassFeaturesRecords.js';
import { createAnnotationResolver } from './resolver.js';
import { applicationServices } from '../services/application.js';
const resolver = createAnnotationResolver({ boundarySource: applicationServices.boundaries });
export const { resolveAnnotationTarget, refineScope, isRateLimitedOutcome, isGroundsLikeAsk, selectFootprint, viewportBias, placesNearViewRecovery, resolveRegionRingForQuery, pickWorldFromScreen, sampleGroundHeight } = resolver;
const resolver = createAnnotationResolver({
featureSource: applicationServices.features,
});
export const {
resolveAnnotationTarget,
refineScope,
isRateLimitedOutcome,
isGroundsLikeAsk,
viewportBias,
placesNearViewRecovery,
resolveRegionRingForQuery,
pickWorldFromScreen,
sampleGroundHeight,
} = resolver;
/** Compatibility adapter for callers supplying raw OSM fixtures. */
export const selectFootprint = (elements, ...args) =>
resolver.selectFootprint(normalizeOverpassFeatures(elements), ...args);
+10 -2
View File
@@ -42,7 +42,11 @@ export function createHybridAnnotationRenderer(viewer) {
entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
screen.add(entry.screenProxy);
}
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
} else if (
anno.type === 'route' &&
Array.isArray(anno.path) &&
anno.path.length >= 2
) {
// Drape the path on the tiles (world), caption it with a screen callout
// at the path midpoint.
entry.worldProxy = liveProxy(anno, { label: null });
@@ -86,7 +90,11 @@ export function createHybridAnnotationRenderer(viewer) {
} else if (anno.label) {
screen.add(screenProxy);
}
entry.screenProxy = entry.screenProxy ? screenProxy : (anno.label ? screenProxy : null);
entry.screenProxy = entry.screenProxy
? screenProxy
: anno.label
? screenProxy
: null;
}
/**
+17 -3
View File
@@ -10,13 +10,27 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
* footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver,
* and voice tool wiring are identical to the other two branches.
*/
export function initAnnotations({ viewer, tileset = null, placeSearch, resolver }) {
export function initAnnotations({
viewer,
tileset = null,
placeSearch,
resolver,
}) {
// World-space footprint draping; clamped marks can use the photoreal tiles.
if (tileset) {
try { tileset.enableCollision = true; } catch { /* older tileset */ }
try {
tileset.enableCollision = true;
} catch {
/* older tileset */
}
}
const renderer = createHybridAnnotationRenderer(viewer);
const engine = createAnnotationEngine({ viewer, renderer, placeSearch, resolveTarget: resolver?.resolveAnnotationTarget });
const engine = createAnnotationEngine({
viewer,
renderer,
placeSearch,
resolveTarget: resolver?.resolveAnnotationTarget,
});
window.__gevAnnotations = engine;
return engine;
}
+83 -485
View File
@@ -1,3 +1,16 @@
import { requireFeatureSource } from '../sources/featureSource.js';
import { createOverpassFeatureSource } from '../sources/overpassFeatures.js';
import {
ringAreaM2,
stitchLine,
bufferCorridor,
simplifyRing,
closeRing,
ringCentroid,
approximateAreaM2,
approximateDistanceM,
pointInPolygon,
} from '../sources/featureGeometry.js';
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js';
@@ -12,13 +25,16 @@ import {
import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
/** Own annotation lookup caches and the supplied boundary query service. */
/** Own annotation lookup caches and ranking of supplied feature candidates. */
export function createAnnotationResolver({
boundarySource,
signal: lifetime,
featureSource = createOverpassFeatureSource({
boundarySource,
signal: lifetime,
}),
} = {}) {
if (typeof boundarySource?.query !== 'function')
throw new TypeError('Annotation boundaries require a query service');
requireFeatureSource(featureSource);
lifetime?.throwIfAborted();
/**
@@ -764,20 +780,6 @@ export function createAnnotationResolver({
}
/** Shoelace area (m²) of a [[lon,lat], ...] ring in a local equirectangular projection. */
function ringAreaM2(ring) {
if (!Array.isArray(ring) || ring.length < 3) return 0;
const mLat = 111_320;
const mLon = mLat * Math.cos(Cesium.Math.toRadians(ring[0][1]));
let area = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0] * mLon;
const yi = ring[i][1] * mLat;
const xj = ring[j][0] * mLon;
const yj = ring[j][1] * mLat;
area += xj * yi - xi * yj;
}
return Math.abs(area) / 2;
}
/** Resolve a name through the supplied service; geometry selection stays here. */
async function geocodePlace(query, biasRect, signal, placeSearch) {
@@ -979,25 +981,6 @@ export function createAnnotationResolver({
return value?.rateLimited === true;
}
/** POST an Overpass QL query and return elements, a transient null, or a throttle object. */
async function overpassJson(query, timeoutMs = 14000, signal) {
const controller = new AbortController();
const detach = linkAbort(controller, signal);
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
try {
return await boundarySource.query(query, {
signal: lifetime
? AbortSignal.any([lifetime, controller.signal])
: controller.signal,
});
} catch {
return null;
} finally {
window.clearTimeout(timer);
detach();
}
}
/**
* Resolve an administrative boundary (country/state/county/city/neighborhood).
* `is_in(point)` returns every admin area containing the point at all levels;
@@ -1014,10 +997,9 @@ export function createAnnotationResolver({
const cachedFp = cacheRead(footprintCache, cacheKey);
if (cachedFp !== undefined) return cachedFp;
const candidates = await overpassJson(
`[out:json][timeout:25];is_in(${lat},${lon})->.a;area.a["boundary"="administrative"]["admin_level"];out tags;`,
14000,
signal,
const candidates = await featureSource.getAdministrativeAreas(
{ lat, lon },
{ signal: signal },
);
if (isRateLimitedOutcome(candidates)) return candidates;
if (!candidates) return undefined; // transient upstream failure (vs null = definitive miss)
@@ -1025,17 +1007,17 @@ export function createAnnotationResolver({
const queryWords = normalizedWords(query);
const scored = [];
for (const el of candidates) {
if (el.type !== 'area' || !el.tags) continue;
if (el.category !== 'administrative') continue;
// Match against the FULL name set (incl. official_name) so the query can still hit a
// verbose official name — but score COMPLETENESS against the CORE name only. A long
// official_name ("City and County of San Francisco") otherwise DILUTES the real
// boundary's completeness and lets a less-specific duplicate ("San Francisco County")
// win — which then has no backing relation and collapses the whole resolution to a dot.
const coreWords = normalizedWords(
[el.tags.name, el.tags['name:en']].filter(Boolean).join(' '),
[el.names.primary, el.names.english].filter(Boolean).join(' '),
);
const fullWords = normalizedWords(
[el.tags.name, el.tags['name:en'], el.tags.official_name]
[el.names.primary, el.names.english, el.names.official]
.filter(Boolean)
.join(' '),
);
@@ -1053,7 +1035,7 @@ export function createAnnotationResolver({
// one (decisively outranks a "<name> County" style duplicate).
const exactName =
coreWords.size === queryWords.size && coreOverlap === queryWords.size;
const level = Number(el.tags.admin_level) || 99;
const level = Number(el.level) || 99;
// Bias toward the scope's specificity: city/neighborhood prefer the MORE specific
// (higher admin_level) match; state/country prefer the broader one.
let levelBias;
@@ -1109,19 +1091,17 @@ export function createAnnotationResolver({
// 16 s turned finishable region pivots (Sicilia's dense coastline) into permanent
// "transients" — every retry died the same death (field test 2026-07-23). The
// outline is progressive, so a long budget blocks nothing; repeats are disk-cached.
const relEls = await overpassJson(
`[out:json][timeout:25];area(${cand.el.id})->.x;rel(pivot.x);out geom;`,
28000,
signal,
);
const relEls = await featureSource.getAreaGeometry(cand.el.id, {
signal: signal,
});
if (isRateLimitedOutcome(relEls)) return relEls;
if (relEls === null) {
transient = true;
break;
} // network blip — don't definitively fail
const relEl = relEls.find((e) => e.type === 'relation');
const relEl = relEls[0];
if (!relEl) continue; // no relation backing this area — try the next candidate
const coords = elementCoordinates(relEl);
const coords = relEl.coordinates;
if (coords.length < 3) continue; // incomplete geometry — try the next
let ring = closeRing(coords.map((p) => [p.lon, p.lat]));
@@ -1184,25 +1164,20 @@ export function createAnnotationResolver({
*/
async function fetchPlaceArea(lat, lon, query, signal) {
const queryWords = normalizedWords(query);
const els = await overpassJson(
`[out:json][timeout:20];(` +
`way(around:1500,${lat},${lon})["place"~"neighbourhood|suburb|quarter|borough"]["name"];` +
`relation(around:1500,${lat},${lon})["place"~"neighbourhood|suburb|quarter|borough"]["name"];` +
`relation(around:1500,${lat},${lon})["boundary"="place"]["name"];` +
`);out tags geom;`,
14000,
signal,
const els = await featureSource.getNeighborhoodAreas(
{ lat, lon },
{ signal: signal },
);
if (isRateLimitedOutcome(els)) return els;
if (els === null) return undefined; // transient upstream failure (vs [] = no match)
let bestRing = null;
let bestScore = 0;
for (const el of els) {
const coords = elementCoordinates(el);
const coords = el.coordinates;
if (coords.length < 3) continue;
const tags = el.tags || {};
const names = el.names;
const nameWords = normalizedWords(
[tags.name, tags['name:en']].filter(Boolean).join(' '),
[names.primary, names.english].filter(Boolean).join(' '),
);
const overlap = wordOverlap(queryWords, nameWords);
if (!overlap) continue;
@@ -1238,26 +1213,20 @@ export function createAnnotationResolver({
const queryWords = normalizedWords(query);
// Tier C — a same-named district / quarter / named commercial area.
const areaEls = await overpassJson(
`[out:json][timeout:20];(` +
`way(around:450,${lat},${lon})["place"~"quarter|neighbourhood|suburb|city_block"];` +
`relation(around:450,${lat},${lon})["place"~"quarter|neighbourhood|suburb"];` +
`way(around:450,${lat},${lon})["landuse"~"commercial|retail"]["name"];` +
`relation(around:450,${lat},${lon})["landuse"~"commercial|retail"]["name"]["type"="multipolygon"];` +
`);out tags geom;`,
14000,
signal,
const areaEls = await featureSource.getStreetAreas(
{ lat, lon },
{ signal: signal },
);
if (isRateLimitedOutcome(areaEls)) return areaEls;
if (areaEls) {
let bestRing = null;
let bestScore = 0;
for (const el of areaEls) {
const coords = elementCoordinates(el);
const coords = el.coordinates;
if (coords.length < 3) continue;
const tags = el.tags || {};
const names = el.names;
const nameWords = normalizedWords(
[tags.name, tags['name:en']].filter(Boolean).join(' '),
[names.primary, names.english].filter(Boolean).join(' '),
);
const overlap = wordOverlap(queryWords, nameWords);
if (!overlap) continue;
@@ -1277,23 +1246,20 @@ export function createAnnotationResolver({
}
// Tier F — buffer the matching centerline into a corridor ribbon (workhorse).
const wayEls = await overpassJson(
`[out:json][timeout:20];way(around:320,${lat},${lon})["highway"]["name"];out geom;`,
14000,
signal,
const wayEls = await featureSource.getStreetLines(
{ lat, lon },
{ signal: signal },
);
if (isRateLimitedOutcome(wayEls)) return wayEls;
if (wayEls) {
const segments = [];
for (const el of wayEls) {
const tags = el.tags || {};
const names = el.names;
const nameWords = normalizedWords(
[tags.name, tags['name:en']].filter(Boolean).join(' '),
[names.primary, names.english].filter(Boolean).join(' '),
);
if (!wordOverlap(queryWords, nameWords)) continue;
const geom = (el.geometry || []).filter(
(p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon),
);
const geom = el.coordinates;
if (geom.length >= 2) segments.push(geom.map((p) => [p.lon, p.lat]));
}
if (segments.length) {
@@ -1317,119 +1283,6 @@ export function createAnnotationResolver({
return definitive ? null : undefined;
}
/** Chain street way-segments into one contiguous polyline by endpoint matching. */
function stitchLine(segments) {
const same = (a, b) => approximateDistanceM(a[1], a[0], b[1], b[0]) < 2;
const remaining = segments.map((s) => s.slice());
let line = remaining.shift();
let advanced = true;
while (advanced && remaining.length) {
advanced = false;
for (let i = 0; i < remaining.length; i++) {
const s = remaining[i];
if (same(line[line.length - 1], s[0])) line = line.concat(s.slice(1));
else if (same(line[line.length - 1], s[s.length - 1]))
line = line.concat(s.slice(0, -1).reverse());
else if (same(line[0], s[s.length - 1]))
line = s.slice(0, -1).concat(line);
else if (same(line[0], s[0])) line = s.slice(1).reverse().concat(line);
else continue;
remaining.splice(i, 1);
advanced = true;
break;
}
}
return line;
}
/** Offset a centerline into a closed corridor ring (~2*halfWidthM wide). */
function bufferCorridor(line, halfWidthM) {
const lat0 = line[0][1];
const mLon = 111320 * Math.cos((lat0 * Math.PI) / 180);
const mLat = 111320;
const P = line.map(([lon, lat]) => [lon * mLon, lat * mLat]);
const left = [];
const right = [];
for (let i = 0; i < P.length; i++) {
const a = P[Math.max(0, i - 1)];
const b = P[Math.min(P.length - 1, i + 1)];
let dx = b[0] - a[0];
let dy = b[1] - a[1];
const len = Math.hypot(dx, dy) || 1;
dx /= len;
dy /= len;
const nx = -dy;
const ny = dx;
left.push([P[i][0] + nx * halfWidthM, P[i][1] + ny * halfWidthM]);
right.push([P[i][0] - nx * halfWidthM, P[i][1] - ny * halfWidthM]);
}
const ring = [...left, ...right.reverse(), left[0]];
return ring.map(([x, y]) => [x / mLon, y / mLat]);
}
/** Douglas–Peucker ring simplification with a metres tolerance. Pre-decimates
* very large rings (state/country) to keep the recursion shallow. */
function simplifyRing(ring, tolM) {
if (ring.length <= 24) return ring;
let pts = ring;
// Hard-cap the input to Douglas-Peucker so a pathological 50k-point country
// boundary can't dominate a frame, even with the iterative implementation.
if (pts.length > 4000) {
const step = Math.ceil(pts.length / 4000);
pts = pts.filter((_, i) => i % step === 0 || i === ring.length - 1);
}
const lat0 = pts[0][1];
const tol = tolM / (111320 * Math.cos((lat0 * Math.PI) / 180));
const out = douglasPeucker(pts, tol);
return out.length >= 4 ? out : ring;
}
function douglasPeucker(points, tol) {
const n = points.length;
if (n < 3) return points.slice();
// Iterative, index-based (no per-call array slicing / recursion), so a large
// state/country boundary can't blow the call stack or thrash GC and freeze the
// main thread during a voice turn.
const keep = new Uint8Array(n);
keep[0] = 1;
keep[n - 1] = 1;
const stack = [[0, n - 1]];
while (stack.length) {
const seg = stack.pop();
const first = seg[0];
const last = seg[1];
let index = -1;
let maxD = 0;
const a = points[first];
const b = points[last];
for (let i = first + 1; i < last; i += 1) {
const dist = perpDistance(points[i], a, b);
if (dist > maxD) {
maxD = dist;
index = i;
}
}
if (maxD > tol && index > first) {
keep[index] = 1;
stack.push([first, index]);
stack.push([index, last]);
}
}
const out = [];
for (let i = 0; i < n; i += 1) if (keep[i]) out.push(points[i]);
return out;
}
function perpDistance(p, a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
if (len2 === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]);
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}
/**
* Fetch the best-matching OSM polygon near a point and classify it as a single
* `building` (small footprint, gets extruded into a volume) or an `area` (a
@@ -1466,30 +1319,10 @@ export function createAnnotationResolver({
const cachedFp = cacheRead(footprintCache, cacheKey);
if (cachedFp !== undefined) return cachedFp;
// `out geom` (not `out tags geom`) so relation members come back WITH their
// way geometry — needed to trace multipolygon compounds like the Presidio.
const overpassQuery = `
[out:json][timeout:25];
(
way(around:320,${lat},${lon})["building"];
relation(around:320,${lat},${lon})["building"];
way(around:1800,${lat},${lon})["landuse"];
relation(around:1800,${lat},${lon})["landuse"];
way(around:1800,${lat},${lon})["leisure"];
relation(around:1800,${lat},${lon})["leisure"];
way(around:1800,${lat},${lon})["aeroway"="aerodrome"];
relation(around:1800,${lat},${lon})["aeroway"="aerodrome"];
way(around:1800,${lat},${lon})["natural"="water"]["name"];
relation(around:1800,${lat},${lon})["natural"="water"]["name"]["type"="multipolygon"];
way(around:1200,${lat},${lon})["shop"="mall"];
relation(around:1200,${lat},${lon})["shop"="mall"];
way(around:800,${lat},${lon})["amenity"];
way(around:800,${lat},${lon})["tourism"];
const elements = await featureSource.getFootprints(
{ lat, lon },
{ signal: signal },
);
out geom;
`;
const elements = await overpassJson(overpassQuery, 12000, signal);
if (isRateLimitedOutcome(elements)) return elements;
if (elements === null || signal?.aborted) return undefined;
const fp = selectFootprint(elements, lat, lon, query, mode);
@@ -1501,8 +1334,6 @@ export function createAnnotationResolver({
return null;
}
const ENCLOSING_RADIUS_M = 600; // sweep this far for an enclosing named non-building polygon
/**
* Find the REAL enclosing "grounds / compound / campus" polygon containing a point
* — the smallest NAMED, NON-building `leisure` / `landuse` / `boundary` / `amenity`
@@ -1532,30 +1363,14 @@ export function createAnnotationResolver({
const cached = cacheRead(enclosingAreaCache, cacheKey);
if (cached !== undefined) return cached;
// Named non-building polygons near the point. `out geom` returns inline geometry so it
// stitches with elementCoordinates/stitchRing (multipolygon outers). Buildings are EXCLUDED
// in JS below (a tag filter can't drop a relation whose members include a building).
const q = `
[out:json][timeout:25];
(
way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["leisure"]["name"];
relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["leisure"]["name"]["type"="multipolygon"];
way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["landuse"]["name"];
relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["landuse"]["name"]["type"="multipolygon"];
way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["boundary"]["name"];
relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["boundary"]["name"];
way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["amenity"]["name"];
relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["amenity"]["name"]["type"="multipolygon"];
way(around:${ENCLOSING_RADIUS_M},${lat},${lon})["natural"="water"]["name"];
relation(around:${ENCLOSING_RADIUS_M},${lat},${lon})["natural"="water"]["name"]["type"="multipolygon"];
);
out geom;
`;
// Fail-fast budget: an opportunistic enrichment, not worth blocking on a hung Overpass —
// but generous enough (12 s ≈ the footprint fetch budget) that an ordinary slow mirror
// doesn't strand a grounds mark as a point. overpassJson returns null on a transient
// doesn't strand a grounds mark as a point. The feature source returns null on a transient
// failure (timeout/502).
const elements = await overpassJson(q, 12000, signal);
const elements = await featureSource.getEnclosingAreas(
{ lat, lon },
{ signal: signal },
);
if (isRateLimitedOutcome(elements)) return elements;
if (elements === null) return undefined; // transient upstream failure (vs [] = definitive no-match)
@@ -1564,8 +1379,8 @@ export function createAnnotationResolver({
let bestArea = Infinity;
let bestNameMatch = false;
for (const el of elements) {
if (el.tags?.building) continue; // exclude buildings (the exact feature, never the grounds)
const coords = elementCoordinates(el);
if (el.building) continue; // exclude buildings (the exact feature, never the grounds)
const coords = el.coordinates;
if (coords.length < 3) continue;
if (!pointInPolygon(lon, lat, coords)) continue; // require genuine containment
const ringLonLat = closeRing(coords.map((p) => [p.lon, p.lat]));
@@ -1577,10 +1392,10 @@ export function createAnnotationResolver({
// makes the Capitol land on "Capitol Square" even though the user said "grounds".
const nameWords = normalizedWords(
[
el.tags?.name,
el.tags?.['name:en'],
el.tags?.official_name,
el.tags?.alt_name,
el.names.primary,
el.names.english,
el.names.official,
el.names.alternate,
]
.filter(Boolean)
.join(' '),
@@ -1606,7 +1421,6 @@ export function createAnnotationResolver({
return null;
}
const MONUMENT_RADIUS_M = 2500; // search this far from the view centre for a named monument
/** A target that reads like a fine-grained monument/marker (vs a building/district). */
function isMonumentLikeQuery(query) {
return /\b(monument|memorial|statue|sculpture|fountain|cenotaph|obelisk|plaque|bust)\b/i.test(
@@ -1675,18 +1489,10 @@ export function createAnnotationResolver({
// timeout, and one caller's clear() must not kill the others' snap).
let pending = monumentInflight.get(centerKey);
if (!pending) {
const q = `
[out:json][timeout:20];
(
nwr(around:${MONUMENT_RADIUS_M},${lat},${lon})["historic"~"memorial|monument|statue|tomb"];
nwr(around:${MONUMENT_RADIUS_M},${lat},${lon})["tourism"="artwork"]["name"];
nwr(around:${MONUMENT_RADIUS_M},${lat},${lon})["memorial"]["name"];
);
out center tags;
`;
// Short timeout + fail-fast: this is an opportunistic snap, not worth blocking narration
// on a slow/overloaded Overpass. overpassJson returns null on a transient failure.
pending = overpassJson(q, 6000)
// on a slow/overloaded Overpass. The feature source returns null on a transient failure.
pending = featureSource
.getMonuments({ lat, lon }, { signal: undefined })
.then((elements) => {
if (elements === null || isRateLimitedOutcome(elements))
return null; // transient — NEVER cached (a poisoned bucket
@@ -1694,16 +1500,12 @@ export function createAnnotationResolver({
const feats = [];
for (const el of elements) {
const name =
el.tags?.name ||
el.tags?.['name:en'] ||
el.tags?.official_name ||
el.tags?.alt_name;
el.names.primary ||
el.names.english ||
el.names.official ||
el.names.alternate;
if (!name) continue;
const p = Number.isFinite(el.lat)
? { lat: el.lat, lon: el.lon }
: el.center
? { lat: el.center.lat, lon: el.center.lon }
: null;
const p = el.point;
if (!p) continue;
feats.push({
name,
@@ -1767,20 +1569,20 @@ export function createAnnotationResolver({
let bestScore = -Infinity;
for (const element of elements) {
const coords = elementCoordinates(element);
const coords = element.coordinates;
if (coords.length < 3) continue;
const tags = element.tags || {};
const isBuilding = Boolean(tags.building);
const names = element.names;
const isBuilding = element.building;
const areaM2 = approximateAreaM2(coords);
const nameWords = normalizedWords(
[
tags.name,
tags['name:en'],
tags.official_name,
tags.alt_name,
tags.short_name,
names.primary,
names.english,
names.official,
names.alternate,
names.short,
]
.filter(Boolean)
.join(' '),
@@ -1848,7 +1650,10 @@ export function createAnnotationResolver({
best = {
ring: closeRing(coords.map((p) => [p.lon, p.lat])),
kind: isBuilding ? 'building' : 'area',
heightM: isBuilding ? buildingHeightFromTags(tags, areaM2) : null,
heightM: isBuilding
? (element.heightM ??
Math.max(10, Math.min(70, Math.sqrt(Math.max(1, areaM2)) * 0.6)))
: null,
};
}
}
@@ -1856,213 +1661,6 @@ export function createAnnotationResolver({
return best;
}
/** Estimate a building's height (m) from OSM tags, falling back to footprint size. */
function buildingHeightFromTags(tags, areaM2) {
const explicit = parseMeters(tags.height || tags['building:height']);
if (explicit) return explicit;
const levels = Number.parseFloat(tags['building:levels']);
const roof = parseMeters(tags['roof:height']) || 0;
if (Number.isFinite(levels) && levels > 0) return levels * 3.3 + roof;
const side = Math.sqrt(Math.max(1, areaM2));
return Math.max(10, Math.min(70, side * 0.6));
}
function parseMeters(value) {
if (value == null) return 0;
const n = Number.parseFloat(String(value).replace(',', '.'));
if (!Number.isFinite(n) || n <= 0) return 0;
return /\b(ft|feet|foot)\b/i.test(String(value)) ? n * 0.3048 : n;
}
// --- geometry helpers -------------------------------------------------------
function elementCoordinates(element) {
if (Array.isArray(element.geometry)) {
return element.geometry.filter(
(p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon),
);
}
if (!Array.isArray(element.members)) return [];
// For a multipolygon relation the boundary is split across several outer
// ways (the Presidio has 8), so chain them into one ring by matching
// endpoints rather than taking a single segment.
const outerWays = element.members
.filter(
(m) => (m.role === 'outer' || !m.role) && Array.isArray(m.geometry),
)
.map((m) =>
m.geometry.filter(
(p) => Number.isFinite(p?.lat) && Number.isFinite(p?.lon),
),
)
.filter((w) => w.length >= 2);
return stitchRing(outerWays);
}
/**
* Stitch relation outer ways into a footprint ring. Builds ALL connected
* components (a multipolygon outer boundary is split across several ways), then
* returns the largest component whose endpoints actually meet — a true closed
* ring. If no component closes cleanly, it accepts the largest only when the
* endpoint gap is small relative to its own span (a minor seam); otherwise the
* relation geometry is incomplete and it returns [] rather than fabricate a
* straight chord across the gap (the reported bay-spanning blob).
*/
function stitchRing(ways) {
if (!ways.length) return [];
const components = buildRingComponents(ways);
if (!components.length) return [];
const CLOSE_TOL_M = 30; // endpoints within 30 m → a genuinely closed ring
const closed = components.filter((c) => endpointGapM(c) <= CLOSE_TOL_M);
if (closed.length) {
// Pick by projected AREA, not vertex count — a small, highly-detailed island
// must not beat the large simple mainland. (For a disjoint multipolygon this
// returns only the largest closed outer ring; full multi-ring rendering is a
// renderer change tracked separately.)
return largestByArea(closed);
}
// Nothing closes exactly. Bridge a seam ONLY when the gap is BOTH a small
// fraction of the feature's own span (so half a compound's boundary missing is
// rejected) AND under a hard absolute ceiling (so a state/country can never get a
// kilometres-long chord — the bay blob). A complete relation closes at ~0; a
// genuine seam (e.g. the Presidio's ~few-hundred-m coastline gap) bridges; a
// truly incomplete relation is rejected → point fallback.
const largest = largestByArea(components);
const allow = Math.min(ringSpanM(largest) * 0.15, 1200);
return endpointGapM(largest) <= allow ? largest : [];
}
/** Largest component by projected area (each component is an array of {lat,lon}). */
function largestByArea(components) {
let best = components[0];
let bestArea = approximateAreaM2(best);
for (let i = 1; i < components.length; i += 1) {
const area = approximateAreaM2(components[i]);
if (area > bestArea) {
bestArea = area;
best = components[i];
}
}
return best;
}
/** Rough diameter (m) of a chain's bounding box. */
function ringSpanM(chain) {
let minLat = Infinity;
let maxLat = -Infinity;
let minLon = Infinity;
let maxLon = -Infinity;
for (const p of chain) {
if (p.lat < minLat) minLat = p.lat;
if (p.lat > maxLat) maxLat = p.lat;
if (p.lon < minLon) minLon = p.lon;
if (p.lon > maxLon) maxLon = p.lon;
}
return approximateDistanceM(minLat, minLon, maxLat, maxLon);
}
/** Chain ways into maximal connected components by endpoint matching. */
function buildRingComponents(ways) {
const same = (a, b) =>
Math.abs(a.lon - b.lon) < 1e-7 && Math.abs(a.lat - b.lat) < 1e-7;
const remaining = ways.map((w) => w.slice());
const components = [];
while (remaining.length) {
let chain = remaining.shift();
let grew = true;
while (grew) {
grew = false;
const head = chain[0];
const tail = chain[chain.length - 1];
for (let i = 0; i < remaining.length; i += 1) {
const w = remaining[i];
const ws = w[0];
const we = w[w.length - 1];
if (same(tail, ws)) chain = chain.concat(w.slice(1));
else if (same(tail, we))
chain = chain.concat(w.slice(0, -1).reverse());
else if (same(head, we)) chain = w.slice(0, -1).concat(chain);
else if (same(head, ws)) chain = w.slice(1).reverse().concat(chain);
else continue;
remaining.splice(i, 1);
grew = true;
break;
}
}
components.push(chain);
}
return components;
}
/** Great-circle distance (m) between a chain's first and last vertex. */
function endpointGapM(chain) {
if (!chain || chain.length < 2) return Infinity;
const a = chain[0];
const b = chain[chain.length - 1];
return approximateDistanceM(a.lat, a.lon, b.lat, b.lon);
}
function closeRing(ring) {
if (ring.length < 3) return ring;
const [fx, fy] = ring[0];
const [lx, ly] = ring[ring.length - 1];
if (fx !== lx || fy !== ly) ring.push([fx, fy]);
return ring;
}
function ringCentroid(ring) {
if (!ring || ring.length < 3) return null;
let sumLat = 0;
let sumLon = 0;
for (const [lon, lat] of ring) {
sumLat += lat;
sumLon += lon;
}
return { lat: sumLat / ring.length, lon: sumLon / ring.length };
}
function approximateAreaM2(coords) {
// Shoelace in a local equirectangular projection (good enough for buildings).
if (coords.length < 3) return 0;
const lat0 = coords[0].lat;
const mPerDegLat = 111_320;
const mPerDegLon = mPerDegLat * Math.cos(Cesium.Math.toRadians(lat0));
let area = 0;
for (let i = 0, j = coords.length - 1; i < coords.length; j = i++) {
const xi = coords[i].lon * mPerDegLon;
const yi = coords[i].lat * mPerDegLat;
const xj = coords[j].lon * mPerDegLon;
const yj = coords[j].lat * mPerDegLat;
area += xj * yi - xi * yj;
}
return Math.abs(area) / 2;
}
function approximateDistanceM(latA, lonA, latB, lonB) {
const latScale = 111_320;
const lonScale =
latScale * Math.cos(Cesium.Math.toRadians((latA + latB) / 2));
return Math.hypot((latB - latA) * latScale, (lonB - lonA) * lonScale);
}
function pointInPolygon(lon, lat, coords) {
let inside = false;
for (let i = 0, j = coords.length - 1; i < coords.length; j = i++) {
const a = coords[i];
const b = coords[j];
const intersects =
a.lat > lat !== b.lat > lat &&
lon <
((b.lon - a.lon) * (lat - a.lat)) /
(b.lat - a.lat || Number.EPSILON) +
a.lon;
if (intersects) inside = !inside;
}
return inside;
}
function normalizedWords(value) {
return new Set(
String(value || '')
+254 -71
View File
@@ -37,9 +37,9 @@ const PALETTE = {
// Altitude-scaled marker geometry. Zoomed in (≤ NEAR_H) reticles are full-size; pulling
// back shrinks them toward small recon dots (MIN floor) so many world-anchored marks no
// longer collapse into an overlapping blob when their anchors project close together.
const MARK_SCALE_NEAR_H = 800; // m: at/below this, full-size reticles
const MARK_SCALE_FAR_H = 6000; // m: at/above this, minimum size
const MARK_SCALE_MIN = 0.32; // floor so dots never vanish
const MARK_SCALE_NEAR_H = 800; // m: at/below this, full-size reticles
const MARK_SCALE_FAR_H = 6000; // m: at/above this, minimum size
const MARK_SCALE_MIN = 0.32; // floor so dots never vanish
const RING_OUTER_R = 34;
const RING_INNER_R = 18;
const DOT_R = 5;
@@ -51,16 +51,22 @@ function markScale(h) {
return 1 - t * (1 - MARK_SCALE_MIN);
}
export function createScreenAnnotationRenderer(viewer, {
overlayPaintRect = getOverlayPaintRect,
activeTrackedReadoutId = getActiveTrackedReadoutId,
} = {}) {
export function createScreenAnnotationRenderer(
viewer,
{
overlayPaintRect = getOverlayPaintRect,
activeTrackedReadoutId = getActiveTrackedReadoutId,
} = {},
) {
injectStyles();
const { layer, svg, defs } = buildOverlay();
document.body.appendChild(layer);
const scene = viewer.scene;
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, scene.camera.positionWC);
const occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
scene.camera.positionWC,
);
const records = new Map(); // anno.id -> { anno, group, parts }
const scratch = new Cesium.Cartesian2();
const scratchDir = new Cesium.Cartesian3();
@@ -112,8 +118,13 @@ export function createScreenAnnotationRenderer(viewer, {
return cached.h;
}
try {
if (scene.clampToHeightSupported && typeof scene.clampToHeight === 'function') {
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
if (
scene.clampToHeightSupported &&
typeof scene.clampToHeight === 'function'
) {
const c = scene.clampToHeight(
Cesium.Cartesian3.fromDegrees(lon, lat, 0),
);
if (c) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) {
@@ -123,7 +134,9 @@ export function createScreenAnnotationRenderer(viewer, {
}
}
}
} catch { /* tiles not ready */ }
} catch {
/* tiles not ready */
}
const fallback = cached ? cached.h : 0;
heightCache.set(key, { h: fallback, settled: false, gen: projGen });
trimHeightCache();
@@ -140,7 +153,8 @@ export function createScreenAnnotationRenderer(viewer, {
// "draw-on" gesture, so they never masquerade as an authoritative boundary.
parts.poly = svgEl('polygon', {
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw',
fill: c, stroke: c,
fill: c,
stroke: c,
'fill-opacity': anno.synthesized ? '0.08' : '0.14',
'stroke-width': '2.5',
...(anno.synthesized ? { 'stroke-dasharray': '9 7' } : {}),
@@ -151,25 +165,41 @@ export function createScreenAnnotationRenderer(viewer, {
if (parts.label) group.appendChild(parts.label.node);
} else if (anno.type === 'arrow' && anno.to) {
parts.path = svgEl('path', {
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c,
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
class: 'gev-anno-arrow gev-draw',
fill: 'none',
stroke: c,
'stroke-width': '3',
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
filter: 'url(#gev-sketch)',
});
ensureArrowMarker(defs, anno.color || 'primary', c);
group.appendChild(parts.path);
parts.label = makeCallout(anno.label, c);
if (parts.label) group.appendChild(parts.label.node);
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
} else if (
anno.type === 'route' &&
Array.isArray(anno.path) &&
anno.path.length >= 2
) {
// Multi-waypoint path: a drawn-on polyline with a dot at each waypoint.
parts.poly = svgEl('polyline', {
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c,
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
class: 'gev-anno-arrow gev-draw',
fill: 'none',
stroke: c,
'stroke-width': '3',
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
filter: 'url(#gev-sketch)',
});
ensureArrowMarker(defs, anno.color || 'primary', c);
group.appendChild(parts.poly);
parts.dots = anno.path.map(() => {
const dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: '4' });
const dot = svgEl('circle', {
class: 'gev-anno-dot',
fill: c,
stroke: '#06121c',
'stroke-width': '2',
r: '4',
});
group.appendChild(dot);
return dot;
});
@@ -178,14 +208,39 @@ export function createScreenAnnotationRenderer(viewer, {
} else {
// pin / highlight / label — pulsing target rings + a marker dot + callout
if (anno.type !== 'label') {
parts.ringOuter = svgEl('circle', { class: 'gev-anno-ring gev-pulse', fill: 'none', stroke: c, 'stroke-width': '2', r: '34' });
parts.ringInner = svgEl('circle', { class: 'gev-anno-ring', fill: c, 'fill-opacity': '0.12', stroke: c, 'stroke-width': '2.5', r: '18', filter: 'url(#gev-sketch)' });
parts.ringOuter = svgEl('circle', {
class: 'gev-anno-ring gev-pulse',
fill: 'none',
stroke: c,
'stroke-width': '2',
r: '34',
});
parts.ringInner = svgEl('circle', {
class: 'gev-anno-ring',
fill: c,
'fill-opacity': '0.12',
stroke: c,
'stroke-width': '2.5',
r: '18',
filter: 'url(#gev-sketch)',
});
group.appendChild(parts.ringOuter);
group.appendChild(parts.ringInner);
}
parts.dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: anno.type === 'label' ? '4' : '5' });
parts.dot = svgEl('circle', {
class: 'gev-anno-dot',
fill: c,
stroke: '#06121c',
'stroke-width': '2',
r: anno.type === 'label' ? '4' : '5',
});
group.appendChild(parts.dot);
parts.leader = svgEl('line', { class: 'gev-anno-leader', stroke: c, 'stroke-width': '1.5', 'stroke-opacity': '0.7' });
parts.leader = svgEl('line', {
class: 'gev-anno-leader',
stroke: c,
'stroke-width': '1.5',
'stroke-opacity': '0.7',
});
group.appendChild(parts.leader);
parts.label = makeCallout(anno.label, c);
if (parts.label) group.appendChild(parts.label.node);
@@ -209,18 +264,26 @@ export function createScreenAnnotationRenderer(viewer, {
// any length. Guarded on the specific property so an unrelated transition
// (none here, but defensive) doesn't clear it early.
for (const drawEl of group.querySelectorAll('.gev-draw')) {
drawEl.addEventListener('transitionend', (e) => {
if (e.propertyName === 'stroke-dashoffset') {
drawEl.style.strokeDasharray = 'none';
}
}, { once: true });
drawEl.addEventListener(
'transitionend',
(e) => {
if (e.propertyName === 'stroke-dashoffset') {
drawEl.style.strokeDasharray = 'none';
}
},
{ once: true },
);
}
projectAll();
} catch (error) {
// Hard, immediate unwind — a mark that never finished has nothing to
// fade out, and the caller (engine rollback) must find a clean board.
records.delete(anno.id);
try { group.remove(); } catch { /* never inserted */ }
try {
group.remove();
} catch {
/* never inserted */
}
if (records.size === 0) heightCache.clear();
throw error;
}
@@ -269,13 +332,22 @@ export function createScreenAnnotationRenderer(viewer, {
// Anchor at the real surface height (cached + validated), so marks sit on
// the ground/building instead of at sea level. The settle-once cache keeps
// it stable (no per-frame jitter that earlier broke nearby projections).
const world = Cesium.Cartesian3.fromDegrees(lon, lat, groundHeight(lon, lat));
const world = Cesium.Cartesian3.fromDegrees(
lon,
lat,
groundHeight(lon, lat),
);
// Reject points behind the camera (they produce wrapped/extreme coords).
Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir);
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0) return null;
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0)
return null;
// Reject points beyond the globe horizon.
if (!occluder.isPointVisible(world)) return null;
const win = Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, scratch);
const win = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
world,
scratch,
);
if (!win || !Number.isFinite(win.x) || !Number.isFinite(win.y)) return null;
// Reject absurd off-screen projections (anchor not in view).
const w = scene.canvas.clientWidth || scene.canvas.width;
@@ -291,9 +363,9 @@ export function createScreenAnnotationRenderer(viewer, {
// tracked subject's screen FOOTPRINT is faded. The complete card footprint comes
// from the host's ACTUAL painted rectangle after final layout; the billboard extent
// is unioned for aircraft/satellites that also own a native tracked graphic.
const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint
const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1)
const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop
const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint
const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1)
const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop
const _scratchTrackedWin = new Cesium.Cartesian2();
// Evaluate a NearFarScalar (billboard scaleByDistance) at a camera distance.
@@ -317,11 +389,16 @@ export function createScreenAnnotationRenderer(viewer, {
const ent = viewer.trackedEntity;
const now = Cesium.JulianDate.now();
const world = typeof ent?.gevDisplayPosition === 'function'
? ent.gevDisplayPosition()
: null;
const world =
typeof ent?.gevDisplayPosition === 'function'
? ent.gevDisplayPosition()
: null;
const win = world
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, _scratchTrackedWin)
? Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
world,
_scratchTrackedWin,
)
: null;
// Billboard box — centered on the anchor, magnified by scaleByDistance.
@@ -331,8 +408,13 @@ export function createScreenAnnotationRenderer(viewer, {
const baseH = bb.height?.getValue?.(now) ?? 28;
let scale = 1;
const sbd = bb.scaleByDistance?.getValue?.(now);
if (sbd) scale = nearFarValue(sbd, Cesium.Cartesian3.distance(scene.camera.positionWC, world));
const hw = (baseW * scale) / 2; const hh = (baseH * scale) / 2;
if (sbd)
scale = nearFarValue(
sbd,
Cesium.Cartesian3.distance(scene.camera.positionWC, world),
);
const hw = (baseW * scale) / 2;
const hh = (baseH * scale) / 2;
const bbLeft = win.x - hw;
const bbRight = win.x + hw;
const bbTop = win.y - hh;
@@ -345,7 +427,12 @@ export function createScreenAnnotationRenderer(viewer, {
if (![left, right, top, bottom].every(Number.isFinite)) return null;
const m = TRACKED_BBOX_MARGIN;
return { left: left - m, right: right + m, top: top - m, bottom: bottom + m };
return {
left: left - m,
right: right + m,
top: top - m,
bottom: bottom + m,
};
}
function projectAll() {
@@ -357,7 +444,9 @@ export function createScreenAnnotationRenderer(viewer, {
const w = scene.canvas.clientWidth || scene.canvas.width;
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
// Shrink point-marker geometry as the camera pulls back (declutter the blob).
const mScale = markScale(viewer.camera.positionCartographic?.height ?? 1000);
const mScale = markScale(
viewer.camera.positionCartographic?.height ?? 1000,
);
for (const rec of records.values()) {
const { anno, group, parts } = rec;
@@ -369,17 +458,22 @@ export function createScreenAnnotationRenderer(viewer, {
if (anno.type === 'area' && parts.poly) {
const pts = [];
let cx = 0; let cy = 0; let n = 0;
let cx = 0;
let cy = 0;
let n = 0;
for (const [lon, lat] of anno.ring) {
const p = project(lon, lat);
if (!p) continue;
pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
cx += p.x; cy += p.y; n++;
cx += p.x;
cy += p.y;
n++;
}
if (pts.length >= 3) {
parts.poly.setAttribute('points', pts.join(' '));
group.style.display = '';
if (parts.label) positionCallout(parts.label, cx / n, cy / n - 6, true);
if (parts.label)
positionCallout(parts.label, cx / n, cy / n - 6, true);
} else {
group.style.display = 'none';
}
@@ -388,16 +482,27 @@ export function createScreenAnnotationRenderer(viewer, {
const b = project(anno.to.lon, anno.to.lat);
if (a && b) {
// gentle arc so the connector reads as a drawn gesture, not a ruler line
const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2;
const dx = b.x - a.x; const dy = b.y - a.y;
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
const bow = Math.min(60, len * 0.18);
const ctrlX = mx - (dy / len) * bow;
const ctrlY = my + (dx / len) * bow;
parts.path.setAttribute('d', `M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`);
parts.path.setAttribute(
'd',
`M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`,
);
group.style.display = '';
// Anchor the label to the (stable) endpoint midpoint, lifted toward the bow.
if (parts.label) positionCallout(parts.label, mx + (ctrlX - mx) * 0.5, my + (ctrlY - my) * 0.5 - 8, true);
if (parts.label)
positionCallout(
parts.label,
mx + (ctrlX - mx) * 0.5,
my + (ctrlY - my) * 0.5 - 8,
true,
);
} else {
group.style.display = 'none';
}
@@ -412,13 +517,22 @@ export function createScreenAnnotationRenderer(viewer, {
// dots at each resolvable waypoint; hide the rest
parts.dots.forEach((dot, i) => {
const p = projected[i];
if (p) { dot.setAttribute('cx', p.x.toFixed(1)); dot.setAttribute('cy', p.y.toFixed(1)); dot.style.display = ''; }
else { dot.style.display = 'none'; }
if (p) {
dot.setAttribute('cx', p.x.toFixed(1));
dot.setAttribute('cy', p.y.toFixed(1));
dot.style.display = '';
} else {
dot.style.display = 'none';
}
});
group.style.display = '';
// label at the middle waypoint
const mid = projected.filter(Boolean)[Math.floor(projected.filter(Boolean).length / 2)];
if (parts.label && mid) positionCallout(parts.label, mid.x, mid.y - 12, true);
const mid =
projected.filter(Boolean)[
Math.floor(projected.filter(Boolean).length / 2)
];
if (parts.label && mid)
positionCallout(parts.label, mid.x, mid.y - 12, true);
} else {
group.style.display = 'none';
}
@@ -427,16 +541,40 @@ export function createScreenAnnotationRenderer(viewer, {
if (p) {
group.style.display = '';
const baseDotR = anno.type === 'label' ? LABEL_DOT_R : DOT_R;
if (parts.dot) { parts.dot.setAttribute('cx', p.x.toFixed(1)); parts.dot.setAttribute('cy', p.y.toFixed(1)); parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1)); }
if (parts.ringOuter) { parts.ringOuter.setAttribute('cx', p.x.toFixed(1)); parts.ringOuter.setAttribute('cy', p.y.toFixed(1)); parts.ringOuter.setAttribute('r', (RING_OUTER_R * mScale).toFixed(1)); }
if (parts.ringInner) { parts.ringInner.setAttribute('cx', p.x.toFixed(1)); parts.ringInner.setAttribute('cy', p.y.toFixed(1)); parts.ringInner.setAttribute('r', (RING_INNER_R * mScale).toFixed(1)); }
if (parts.dot) {
parts.dot.setAttribute('cx', p.x.toFixed(1));
parts.dot.setAttribute('cy', p.y.toFixed(1));
parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1));
}
if (parts.ringOuter) {
parts.ringOuter.setAttribute('cx', p.x.toFixed(1));
parts.ringOuter.setAttribute('cy', p.y.toFixed(1));
parts.ringOuter.setAttribute(
'r',
(RING_OUTER_R * mScale).toFixed(1),
);
}
if (parts.ringInner) {
parts.ringInner.setAttribute('cx', p.x.toFixed(1));
parts.ringInner.setAttribute('cy', p.y.toFixed(1));
parts.ringInner.setAttribute(
'r',
(RING_INNER_R * mScale).toFixed(1),
);
}
if (parts.label) {
// Keep the callout tucked near the (now smaller) reticle by scaling its offset.
const lx = p.x + 18 * mScale; const ly = p.y - 34 * mScale;
const lx = p.x + 18 * mScale;
const ly = p.y - 34 * mScale;
positionCallout(parts.label, lx, ly, false);
if (parts.leader) {
parts.leader.setAttribute('x1', p.x.toFixed(1)); parts.leader.setAttribute('y1', p.y.toFixed(1));
parts.leader.setAttribute('x2', lx.toFixed(1)); parts.leader.setAttribute('y2', (ly + parts.label.height).toFixed(1));
parts.leader.setAttribute('x1', p.x.toFixed(1));
parts.leader.setAttribute('y1', p.y.toFixed(1));
parts.leader.setAttribute('x2', lx.toFixed(1));
parts.leader.setAttribute(
'y2',
(ly + parts.label.height).toFixed(1),
);
parts.label._leader = parts.leader; // so de-collision can re-point it
}
}
@@ -450,7 +588,12 @@ export function createScreenAnnotationRenderer(viewer, {
const placed = [];
for (const rec of records.values()) {
const cal = rec.parts.label;
if (cal && cal.sized && rec.group.style.display !== 'none' && Number.isFinite(cal._x)) {
if (
cal &&
cal.sized &&
rec.group.style.display !== 'none' &&
Number.isFinite(cal._x)
) {
placed.push(cal);
}
}
@@ -471,7 +614,11 @@ export function createScreenAnnotationRenderer(viewer, {
let overlapping = false;
if (trackedRect) {
let bb = null;
try { bb = group.getBBox(); } catch { bb = null; }
try {
bb = group.getBBox();
} catch {
bb = null;
}
if (bb && (bb.width !== 0 || bb.height !== 0)) {
// HYSTERESIS: a mark that's already hidden must clear the rect by
// TRACKED_HYSTERESIS_PX before it starts showing again; one that's visible fades
@@ -479,8 +626,11 @@ export function createScreenAnnotationRenderer(viewer, {
// frame-to-frame (the pulsing reticle stroke + de-collided cards keep the bbox in
// motion), which was the source of the flicker.
const m = cur < 0.5 ? TRACKED_HYSTERESIS_PX : 0;
overlapping = bb.x <= trackedRect.right + m && bb.x + bb.width >= trackedRect.left - m
&& bb.y <= trackedRect.bottom + m && bb.y + bb.height >= trackedRect.top - m;
overlapping =
bb.x <= trackedRect.right + m &&
bb.x + bb.width >= trackedRect.left - m &&
bb.y <= trackedRect.bottom + m &&
bb.y + bb.height >= trackedRect.top - m;
}
}
const target = overlapping ? 0 : 1;
@@ -490,7 +640,10 @@ export function createScreenAnnotationRenderer(viewer, {
// (The CSS `transition: opacity` was removed from .gev-anno so this per-frame JS ease
// is the ONLY easing — previously the two fought and produced an opacity oscillation.)
if (rec._trackedFade !== 1) {
group.setAttribute('opacity', String((anno.alpha ?? 1) * rec._trackedFade));
group.setAttribute(
'opacity',
String((anno.alpha ?? 1) * rec._trackedFade),
);
}
}
}
@@ -501,7 +654,13 @@ export function createScreenAnnotationRenderer(viewer, {
rec.group.classList.remove('gev-in');
rec.group.classList.add('gev-out');
const node = rec.group;
window.setTimeout(() => { try { node.remove(); } catch { /* gone */ } }, 360);
window.setTimeout(() => {
try {
node.remove();
} catch {
/* gone */
}
}, 360);
records.delete(anno.id);
// Board emptied → drop accumulated height samples (natural reset point).
if (records.size === 0) heightCache.clear();
@@ -513,8 +672,16 @@ export function createScreenAnnotationRenderer(viewer, {
}
function destroy() {
try { scene.postRender.removeEventListener(onPostRender); } catch { /* torn down */ }
try { layer.remove(); } catch { /* gone */ }
try {
scene.postRender.removeEventListener(onPostRender);
} catch {
/* torn down */
}
try {
layer.remove();
} catch {
/* gone */
}
records.clear();
heightCache.clear();
}
@@ -562,7 +729,15 @@ function makeCallout(text, c) {
node.appendChild(rect);
node.appendChild(accent);
node.appendChild(t);
const callout = { node, rect, text: t, accent, width: 0, height: 26, sized: false };
const callout = {
node,
rect,
text: t,
accent,
width: 0,
height: 26,
sized: false,
};
// size the card to the text once it is in the DOM
requestAnimationFrame(() => sizeCallout(callout));
return callout;
@@ -581,7 +756,9 @@ function sizeCallout(callout) {
callout.width = w;
callout.height = h;
callout.sized = true;
} catch { /* not laid out yet */ }
} catch {
/* not laid out yet */
}
}
function positionCallout(callout, x, y, center) {
@@ -589,7 +766,10 @@ function positionCallout(callout, x, y, center) {
const tx = center ? x - callout.width / 2 : x;
callout._x = tx;
callout._y = y;
callout.node.setAttribute('transform', `translate(${tx.toFixed(1)}, ${y.toFixed(1)})`);
callout.node.setAttribute(
'transform',
`translate(${tx.toFixed(1)}, ${y.toFixed(1)})`,
);
}
/**
@@ -607,7 +787,10 @@ function decollideCallouts(callouts) {
const overlapY = a._y < b._y + b.height && a._y + a.height > b._y;
if (overlapX && overlapY) {
a._y = b._y + b.height + 5;
a.node.setAttribute('transform', `translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`);
a.node.setAttribute(
'transform',
`translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`,
);
if (a._leader) {
a._leader.setAttribute('x2', a._x.toFixed(1));
a._leader.setAttribute('y2', a._y.toFixed(1));
+200 -106
View File
@@ -58,7 +58,9 @@ export function createWorldAnnotationRenderer(viewer) {
ensureFlowFabricRegistered();
function colorFor(anno) {
return Cesium.Color.fromCssColorString(PALETTE[anno.color] || PALETTE.primary);
return Cesium.Color.fromCssColorString(
PALETTE[anno.color] || PALETTE.primary,
);
}
// Target-ring radius (meters) scaled to camera height so it reads at any
@@ -86,7 +88,11 @@ export function createWorldAnnotationRenderer(viewer) {
const entities = [];
anno._entities = entities;
if (anno.ring && anno.ring.length >= 3 && anno.footprintKind === 'building') {
if (
anno.ring &&
anno.ring.length >= 3 &&
anno.footprintKind === 'building'
) {
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION
// volume that tints the real photogrammetry mesh (dome, walls, roof —
// everything inside the column) amber and pulses. A flat footprint
@@ -94,93 +100,134 @@ export function createWorldAnnotationRenderer(viewer) {
// actual tiles. A faint wireframe cage rides on top as a secondary cue.
const buffered = bufferRing(anno.ring, 3);
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
const groundH = sampleGroundOutside(viewer.scene, anno.ring)
?? (Number.isFinite(anno.anchor.height) ? anno.anchor.height - (anno.buildingHeight || 25) : 0);
const groundH =
sampleGroundOutside(viewer.scene, anno.ring) ??
(Number.isFinite(anno.anchor.height)
? anno.anchor.height - (anno.buildingHeight || 25)
: 0);
const baseH = groundH - 3;
// Tall headroom is free: classification only colors where tiles exist, so
// empty air above the roof isn't tinted — this just guarantees we cover
// under-tagged building heights.
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
// 1) Classification volume — tints the mesh amber, pulsing.
entities.push(dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
classificationType: CLASSIFY,
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.45, pulse: true })),
},
}));
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
classificationType: CLASSIFY,
material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.45, pulse: true }),
),
},
}),
);
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
// the footprint so the extruded outline draws ~14 clean verticals, not one
// per (often 100+) ring vertex.
const cagePositions = Cesium.Cartesian3.fromDegreesArray(decimateRing(buffered, 14).flat());
entities.push(dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
fill: false,
outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}));
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
decimateRing(buffered, 14).flat(),
);
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
fill: false,
outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}),
);
// 3) Crisp draped base outline so the footprint reads on the ground.
entities.push(dataSource.entities.add({
polyline: {
positions,
width: 3,
material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.25, color: liveColor(anno, base, { alpha: 0.7 }) }),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
entities.push(
dataSource.entities.add({
polyline: {
positions,
width: 3,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.25,
color: liveColor(anno, base, { alpha: 0.7 }),
}),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
} else if (anno.ring && anno.ring.length >= 3) {
// Larger area (district / compound / park) → flat fill draped on the tiles
// + a glowing outline. Draping is right here: you can't extrude a whole
// neighbourhood, and the GIS overlay shows the boundary clearly.
const fillPositions = Cesium.Cartesian3.fromDegreesArray(anno.ring.flat());
entities.push(dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: anno.synthesized ? 0.10 : 0.20, pulse: true })),
classificationType: CLASSIFY,
},
}));
entities.push(dataSource.entities.add({
polyline: {
positions: fillPositions,
width: 6,
// Synthesized → DASHED outline (signals "approximate, not an authoritative
// boundary", research §8.4/§8.6); real footprints → solid glow.
material: anno.synthesized
? new Cesium.PolylineDashMaterialProperty({ color: liveColor(anno, base, { alpha: 0.95 }), dashLength: 24 })
: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.35, color: liveColor(anno, base, { alpha: 1 }) }),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
const fillPositions = Cesium.Cartesian3.fromDegreesArray(
anno.ring.flat(),
);
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, {
alpha: anno.synthesized ? 0.1 : 0.2,
pulse: true,
}),
),
classificationType: CLASSIFY,
},
}),
);
entities.push(
dataSource.entities.add({
polyline: {
positions: fillPositions,
width: 6,
// Synthesized → DASHED outline (signals "approximate, not an authoritative
// boundary", research §8.4/§8.6); real footprints → solid glow.
material: anno.synthesized
? new Cesium.PolylineDashMaterialProperty({
color: liveColor(anno, base, { alpha: 0.95 }),
dashLength: 24,
})
: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.35,
color: liveColor(anno, base, { alpha: 1 }),
}),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
} else if (
anno.type === 'route' &&
Array.isArray(anno.path) &&
anno.path.length >= 2
) {
// A real path (street-following) draped on the 3D tiles, with dashes that
// FLOW toward the destination (custom st.s + time material; works on the
// clamped/classified ground polyline).
const positions = Cesium.Cartesian3.fromDegreesArray(
anno.path.flatMap((p) => [p.lon, p.lat]),
);
entities.push(dataSource.entities.add({
polyline: {
positions,
width: 9,
material: new FlowMaterialProperty(PALETTE[anno.color] || PALETTE.primary),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
entities.push(
dataSource.entities.add({
polyline: {
positions,
width: 9,
material: new FlowMaterialProperty(
PALETTE[anno.color] || PALETTE.primary,
),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
} else if (anno.type === 'arrow' && anno.to) {
// Connector draped across the ground from origin to destination.
@@ -188,24 +235,30 @@ export function createWorldAnnotationRenderer(viewer) {
Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
Cesium.Cartesian3.fromDegrees(anno.to.lon, anno.to.lat),
];
entities.push(dataSource.entities.add({
polyline: {
positions,
width: 16,
material: new Cesium.PolylineArrowMaterialProperty(liveColor(anno, base, { alpha: 0.95 })),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
entities.push(
dataSource.entities.add({
polyline: {
positions,
width: 16,
material: new Cesium.PolylineArrowMaterialProperty(
liveColor(anno, base, { alpha: 0.95 }),
),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
if (anno.label) {
const mid = {
lon: (anno.anchor.lon + anno.to.lon) / 2,
lat: (anno.anchor.lat + anno.to.lat) / 2,
};
entities.push(dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
label: labelGraphic(anno, base),
}));
entities.push(
dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
label: labelGraphic(anno, base),
}),
);
}
} else {
// pin / highlight / label — a camera-proportional target ring + a marker.
@@ -213,16 +266,23 @@ export function createWorldAnnotationRenderer(viewer) {
// Radius scales with camera height so the ring reads at any altitude.
// semiMajor === semiMinor is required AND must hold every frame, so both
// axes read the SAME ringRadius() (camera height is constant per frame).
entities.push(dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
ellipse: {
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.38, pulse: true })),
outline: false,
classificationType: CLASSIFY,
},
}));
entities.push(
dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(
anno.anchor.lon,
anno.anchor.lat,
),
ellipse: {
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.38, pulse: true }),
),
outline: false,
classificationType: CLASSIFY,
},
}),
);
}
entities.push(labelMarker(anno, base, { point: true }));
}
@@ -232,14 +292,16 @@ export function createWorldAnnotationRenderer(viewer) {
function labelMarker(anno, base, { point }) {
return dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
point: point ? {
pixelSize: anno.type === 'label' ? 8 : 14,
color: liveColor(anno, base, { alpha: 1 }),
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
outlineWidth: 3,
heightReference: CLAMP,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
} : undefined,
point: point
? {
pixelSize: anno.type === 'label' ? 8 : 14,
color: liveColor(anno, base, { alpha: 1 }),
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
outlineWidth: 3,
heightReference: CLAMP,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
}
: undefined,
label: anno.label ? labelGraphic(anno, base) : undefined,
});
}
@@ -256,7 +318,10 @@ export function createWorldAnnotationRenderer(viewer) {
pixelOffset: new Cesium.Cartesian2(0, -16),
showBackground: true,
backgroundColor: new Cesium.CallbackProperty(
() => Cesium.Color.fromCssColorString('#0b1622').withAlpha(0.72 * (anno.alpha ?? 1)),
() =>
Cesium.Color.fromCssColorString('#0b1622').withAlpha(
0.72 * (anno.alpha ?? 1),
),
false,
),
backgroundPadding: new Cesium.Cartesian2(8, 5),
@@ -357,8 +422,16 @@ export function FlowMaterialProperty(colorCss) {
this._definitionChanged = new Cesium.Event();
}
Object.defineProperties(FlowMaterialProperty.prototype, {
isConstant: { get() { return false; } }, // re-evaluated each frame → it animates
definitionChanged: { get() { return this._definitionChanged; } },
isConstant: {
get() {
return false;
},
}, // re-evaluated each frame → it animates
definitionChanged: {
get() {
return this._definitionChanged;
},
},
});
FlowMaterialProperty.prototype.getType = function getType() {
return 'GevRouteFlow';
@@ -394,10 +467,17 @@ function decimateRing(ring, n) {
* tiles under those points aren't loaded yet.
*/
function sampleGroundOutside(scene, ring) {
if (!scene?.clampToHeightSupported || typeof scene.clampToHeight !== 'function') return null;
if (
!scene?.clampToHeightSupported ||
typeof scene.clampToHeight !== 'function'
)
return null;
let clon = 0;
let clat = 0;
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
for (const [lon, lat] of ring) {
clon += lon;
clat += lat;
}
clon /= ring.length;
clat /= ring.length;
const latS = 111320;
@@ -407,7 +487,16 @@ function sampleGroundOutside(scene, ring) {
maxR = Math.max(maxR, Math.hypot((lon - clon) * lonS, (lat - clat) * latS));
}
const out = maxR * 1.5 + 12;
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7, 0.7], [-0.7, -0.7], [0.7, -0.7], [-0.7, 0.7]];
const dirs = [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
[0.7, 0.7],
[-0.7, -0.7],
[0.7, -0.7],
[-0.7, 0.7],
];
const samples = [];
for (const [dx, dy] of dirs) {
const lon = clon + (dx * out) / lonS;
@@ -418,7 +507,9 @@ function sampleGroundOutside(scene, ring) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
}
} catch { /* tile not ready */ }
} catch {
/* tile not ready */
}
}
if (!samples.length) return null;
samples.sort((a, b) => a - b);
@@ -433,7 +524,10 @@ function sampleGroundOutside(scene, ring) {
function bufferRing(ring, meters) {
let clon = 0;
let clat = 0;
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
for (const [lon, lat] of ring) {
clon += lon;
clat += lat;
}
clon /= ring.length;
clat /= ring.length;
const latScale = 111320;
+1
View File
@@ -6,6 +6,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
cctvLayer: 'cctv',
radioLayer: 'radio',
bikeshareLayer: 'bikeshare',
transitLayer: 'transit',
aisLiveVesselsLayer: 'ais-live-vessels',
militaryAwarenessLayer: 'military-awareness',
militaryInstallationsLayer: 'military-installations',
+3 -1
View File
@@ -88,6 +88,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
'cctv',
'radio',
'bikeshare',
'transit',
'ais-live-vessels',
'military-awareness',
'military-installations',
@@ -97,7 +98,8 @@ test('controls bind catalog instances rather than similarly named defaults', ()
const catalog = createLayerCatalog(layers, metadata(layers));
const services = catalogControlServices(catalog);
assert.equal(services.flightsLayer, layers[1]);
assert.equal(services.aisLiveVesselsLayer, layers[7]);
assert.equal(services.transitLayer, layers[7]);
assert.equal(services.aisLiveVesselsLayer, layers[8]);
assert.equal(new Set(Object.values(services)).size, layers.length);
assert.throws(
() => catalogControlServices(createLayerCatalog([], [])),
+10 -1
View File
@@ -9,6 +9,7 @@ import { createApplicationRadio } from './layers/radio.js';
import { createApplicationTraffic } from './layers/traffic.js';
import { createApplicationBikeshare } from './layers/bikeshare.js';
import { createApplicationDirections } from './layers/directions.js';
import { createApplicationTransit } from './layers/transit.js';
import { createApplicationInstallations } from './layers/militaryInstallations.js';
import { createApplicationSatellites } from './layers/satellites.js';
import { createApplicationLaunches } from './layers/rocketLaunches.js';
@@ -18,7 +19,9 @@ import { createApplicationFirms } from './layers/firms.js';
import { createApplicationEarthquakes } from './layers/earthquakes.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
import { localGeoJsonServices } from '../data/localGeojson.js';
import { localGeoJsonServices } from './localGeojsonServices.js';
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
const SOURCE_METHODS = Object.freeze({
flights: ['getSnapshot'],
@@ -54,6 +57,7 @@ export function createApplicationCatalog({
metadata = LAYER_STATE_REGISTRY,
vesselOptions,
resolveAsset,
nepalBoundaryResolver,
}) {
if (!signal?.addEventListener)
throw new TypeError('An application lifetime signal is required');
@@ -100,6 +104,10 @@ export function createApplicationCatalog({
});
const catalog = createLayerCatalog(
[
createBhoteKoshiEventLayer(),
createBhoteKoshiLocatorLayer({
boundaryResolver: nepalBoundaryResolver,
}),
flights,
military,
createApplicationEarthquakes({ source: sources.earthquakes }),
@@ -111,6 +119,7 @@ export function createApplicationCatalog({
createApplicationRadio({ surface, source: sources.radio }),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections(),
createApplicationTransit({ surface, source: sources.transit }),
vessels,
installations,
createApplicationAwareness({
+22 -1
View File
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
import { createApplicationCatalog } from './constructCatalog.js';
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
import { catalogControlServices } from './catalog.js';
import { LayerLifecycle } from '../data/lifecycle.js';
function fixtureSources(ids, calls) {
const sources = createStandaloneLayerSources();
@@ -38,7 +39,27 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 18);
assert.equal(first.layers.length, 21);
assert.ok(first.get('transit'));
assert.ok(first.get('bhote-koshi-2026'));
assert.ok(first.get('bhote-koshi-locator'));
const lifecycle = new LayerLifecycle({});
for (const layer of first.layers) lifecycle.register(layer);
const rows = lifecycle.getAll();
for (const id of ['bhote-koshi-2026', 'bhote-koshi-locator']) {
assert.equal(
rows.find((row) => row.id === id)?.showInTogglePanel,
false,
`${id} remains registered for Scenes but is absent from Data Layers`,
);
assert.equal(typeof first.get(id).enable, 'function');
assert.equal(typeof first.get(id).setParams, 'function');
}
assert.equal(
rows.find((row) => row.id === 'flights')?.showInTogglePanel,
true,
'ordinary data layer entries remain visible',
);
assert.deepEqual(
first.layers.map(({ id }) => id),
second.layers.map(({ id }) => id),
+3 -1
View File
@@ -2,7 +2,7 @@ import { LayerLifecycle } from '../data/lifecycle.js';
import { LayerPresentation } from './layerPresentation.js';
/** Register the application layer catalog before allowing state restoration. */
export function createApplicationData({
scene: { viewer },
scene: { viewer, mapStackController },
controls: { styleManager },
catalog,
allowQaRegistration,
@@ -27,6 +27,8 @@ export function createApplicationData({
throw new TypeError('An application layer catalog is required');
for (const layer of catalog.layers) dataManager.register(layer);
for (const layer of catalog.layers) layer.attachDataManager?.(dataManager);
for (const layer of catalog.layers)
layer.attachMapStackController?.(mapStackController);
// Restoration starts only after the caller's complete registry is sealed.
dataManager.finalizeRegistrations(catalog.metadata);
if (allowQaRegistration) {
+49
View File
@@ -0,0 +1,49 @@
import { createTransitLayer } from '../../layers/transit/index.js';
import * as render from '../../renderGovernor.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import {
registerDynamicCredit,
transitFeedCredit,
} from '../../data/dataCredits.js';
/**
* Construct one layer using the application scene owners and the
* application's surface services.
*
* The floor comes from `surface.groundFloor` — the ONE instance aircraft,
* trails, fires and camera geometry stand on — and the rendered-mesh sampler
* from `surface.meshFloor`, so a mesh floor the application resolves for a
* cell is the floor transit draws on, and a transit-only session warms its
* own mesh floors. An earlier cut imported the legacy module-level singleton
* instead and could not see either.
*
* @param {{surface: object}} options
* @returns {object}
*/
export function createApplicationTransit({ surface, source }) {
if (!surface?.groundFloor || !surface?.meshFloor)
throw new TypeError('Transit needs the application surface services');
const { groundFloor, meshFloor } = surface;
return createTransitLayer({
source,
services: {
render,
sprites,
picking,
overlays,
credits: { registerDynamicCredit, transitFeedCredit },
ground: {
GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
cachedGroundFloor: groundFloor.cachedGroundFloor,
coarseFloorCoord: groundFloor.coarseFloorCoord,
neighborFloorM: groundFloor.neighborFloorM,
warmGroundFloor: groundFloor.warmGroundFloor,
corridorFloorCells: groundFloor.corridorFloorCells,
allocateCorridorCells: groundFloor.allocateCorridorCells,
},
mesh: { sampleMeshFloorCells: meshFloor.sampleMeshFloorCells },
},
});
}
+93
View File
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSurfaceServices } from '../surfaceServices.js';
import { createApplicationTransit } from './transit.js';
import { createApplicationCatalog } from '../constructCatalog.js';
import { createStandaloneLayerSources } from '../../standalone/layerSources.js';
/** A terrain source that answers every point with one ellipsoidal height. */
function fixtureSurface(signal, demM) {
return createSurfaceServices({
terrainSource: {
getHeights: async (chunk) => chunk.map(() => ({ ellipsoid: demM })),
},
signal,
eventTarget: null,
});
}
test('transit reads the mesh floor the application resolved, through the application surface', async (t) => {
// The floor transit draws on is the ONE the application constructs — the
// instance aircraft and camera geometry stand on — not a module-level copy.
// An earlier cut imported the legacy singleton and could not see a mesh
// floor the app had already resolved for a cell; it drew on bare earth.
const lifetime = new AbortController();
t.after(() => lifetime.abort());
const surface = fixtureSurface(lifetime.signal, 20);
const layer = createApplicationTransit({ surface });
const height = layer._transitPartsForTest().height;
const lat = 42.3601;
const lon = -71.0589;
// Nothing warm yet: the layer has no floor and no prior to borrow.
assert.equal(height.requestHeight(lat, lon).height, null);
// The application resolves the DEM for that cell...
await surface.groundFloor.resolveGroundFloorCells([{ lat, lon }]);
const lift = surface.groundFloor.GROUND_FLOOR_LIFT_M;
assert.equal(
height.requestHeight(lat, lon).height,
20 + lift,
'the DEM floor, lifted',
);
// ...and then a rendered-mesh sample lands on the same cell, seventeen
// metres above bare earth, the way a photogrammetric street sits.
assert.equal(
surface.groundFloor.reportValidatedMeshFloorCell(lat, lon, 37),
true,
);
assert.equal(
surface.groundFloor.cachedGroundFloor(lat, lon),
37,
'the app now floors on the mesh',
);
assert.equal(
height.requestHeight(lat, lon).height,
37 + lift,
'and so does transit — the same number',
);
// Leaving the photoreal regime drops back to the DEM for everyone at once.
surface.groundFloor.setMeshFloorPreferred(false);
assert.equal(height.requestHeight(lat, lon).height, 20 + lift);
});
test('the catalog hands transit the same surface it hands everything else', async (t) => {
const lifetime = new AbortController();
t.after(() => lifetime.abort());
const surface = fixtureSurface(lifetime.signal, 5);
const catalog = createApplicationCatalog({
sources: createStandaloneLayerSources(),
signal: lifetime.signal,
surface,
});
const transit = catalog.get('transit');
assert.ok(transit, 'transit is in the catalog');
const lat = 30.2672;
const lon = -97.7431;
await surface.groundFloor.resolveGroundFloorCells([{ lat, lon }]);
surface.groundFloor.reportValidatedMeshFloorCell(lat, lon, 22);
assert.equal(
transit._transitPartsForTest().height.requestHeight(lat, lon).height,
22 + surface.groundFloor.GROUND_FLOOR_LIFT_M,
);
});
test('transit refuses to build without the application surface', () => {
assert.throws(() => createApplicationTransit({}), /surface/);
assert.throws(
() => createApplicationTransit({ surface: { groundFloor: {} } }),
/surface/,
);
});
+25
View File
@@ -0,0 +1,25 @@
import { governorRequestRender } from '../renderGovernor.js';
import {
clearSelectedEntityContextForLayer,
registerEntityContext,
removeEntityContextsForLayer,
selectEntityContext,
} from '../data/contextStore.js';
import {
clearOverlaySource,
setOverlayEntries,
setOverlaySourceVisible,
} from '../overlays/worldOverlay.js';
/** Existing standalone application operations, shared by its local layers. */
export const localGeoJsonServices = Object.freeze({
overlayHost: Object.freeze({
clearSource: clearOverlaySource,
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
}),
clearSelectedEntityContextForLayer,
registerEntityContext,
removeEntityContextsForLayer,
selectEntityContext,
governorRequestRender,
});
+15 -3
View File
@@ -1,3 +1,5 @@
import { requireFeatureSource } from '../sources/featureSource.js';
import { createOverpassFeatureSource } from '../sources/overpassFeatures.js';
import { createSurfaceServices } from './surfaceServices.js';
import { createAnnotationResolver } from '../annotations/resolver.js';
import { searchAndFlyTo } from '../locations.js';
@@ -5,7 +7,6 @@ import { searchAndFlyTo } from '../locations.js';
/** Assemble application operations from the caller's request services. */
export function createApplicationOperations({ requests, signal, eventTarget }) {
for (const [name, method] of Object.entries({
boundaries: 'query',
terrain: 'getHeights',
regional: 'getBrief',
weather: 'getConditions',
@@ -14,13 +15,20 @@ export function createApplicationOperations({ requests, signal, eventTarget }) {
if (typeof requests?.[name]?.[method] !== 'function')
throw new TypeError(`Missing application request service: ${name}`);
}
const features = requireFeatureSource(
requests.features ??
createOverpassFeatureSource({
boundarySource: requests.boundaries,
signal,
}),
);
const surface = createSurfaceServices({
terrainSource: requests.terrain,
signal,
eventTarget,
});
const annotationResolver = createAnnotationResolver({
boundarySource: requests.boundaries,
featureSource: features,
signal,
});
return Object.freeze({
@@ -30,7 +38,11 @@ export function createApplicationOperations({ requests, signal, eventTarget }) {
searchAndFlyTo: (viewer, query, options = {}) =>
searchAndFlyTo(viewer, query, {
...options,
boundaries: requests.boundaries,
features,
signal:
signal && options.signal
? AbortSignal.any([signal, options.signal])
: signal || options.signal,
recoverNearView: annotationResolver.placesNearViewRecovery,
}),
});
+19
View File
@@ -163,3 +163,22 @@ test('annotation lookup and cached outlines use the supplied source independentl
{ name: 'AbortError' },
);
});
test('invalid feature adapters fail before surface listeners are installed', () => {
let installed = 0;
assert.throws(
() =>
createApplicationOperations({
signal: new AbortController().signal,
requests: { ...requests({ getHeights: async () => [] }), features: {} },
eventTarget: {
addEventListener() {
installed++;
},
removeEventListener() {},
},
}),
/Missing feature operation/,
);
assert.equal(installed, 0);
});
+2 -52
View File
@@ -1,52 +1,2 @@
/** Bind a source contract once for the application without replacing its layer instance. */
export function createSourceSlot(
initial,
methods,
label = 'Source',
optional = {},
) {
let active = initial;
let binding = null;
const source = Object.fromEntries(
[...methods, ...Object.keys(optional)].map((method) => [
method,
(...args) => {
if (typeof active?.[method] !== 'function' && optional[method])
return optional[method](...args);
if (typeof active?.[method] !== 'function')
throw new Error(`${label} is not configured`);
const owner = binding;
const provider = active;
const value = provider[method](...args);
if (value && typeof value.then === 'function')
return value.then((result) => {
if (binding !== owner || active !== provider)
throw new DOMException(`${label} was replaced`, 'AbortError');
return result;
});
return value;
},
]),
);
for (const property of ['label', 'attribution']) {
Object.defineProperty(source, property, {
enumerable: true,
get: () => active?.[property],
});
}
return {
source,
configure(next) {
if (methods.some((method) => typeof next?.[method] !== 'function'))
throw new TypeError(`Invalid ${label.toLowerCase()}`);
const owner = Symbol(label);
active = next;
binding = owner;
return () => {
if (binding !== owner) return;
active = null;
binding = null;
};
},
};
}
// Compatibility entry; source contracts are owned by sources/.
export { createSourceSlot } from '../sources/sourceSlot.js';
+7 -1
View File
@@ -27,7 +27,13 @@ export function createApplicationTools({
const { viewer, tileset, mapStackController, operations } = scene;
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
const { dataManager } = data;
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager);
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
isMapStackAvailable: (id) =>
mapStackController?.isStackAvailable(id) === true,
});
dataManager.layers
.get('bhote-koshi-2026')
?.module.attachSceneController(sceneDirector);
defer(() => sceneDirector.destroy());
onSceneDirector?.(sceneDirector);
const annotations = initAnnotations({
+11 -10
View File
@@ -31,7 +31,8 @@ function body(source, pattern, label) {
function ordered(source, needles, label) {
let previous = -1;
for (const needle of needles) {
const index = source.indexOf(needle, previous + 1);
const match = needle instanceof RegExp ? needle.exec(source.slice(previous + 1)) : null;
const index = needle instanceof RegExp ? (match ? previous + 1 + match.index : -1) : source.indexOf(needle, previous + 1);
assert.ok(index >= 0, `${label}: missing ${needle}`);
assert.ok(index > previous, `${label}: ${needle} is out of order`);
previous = index;
@@ -104,7 +105,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
'Cockpit entry transaction',
);
ordered(transaction, [
'if (!selectedLayer.trackById?.(selectedTarget.id, { origin: selectionOrigin })) {',
/if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/,
'if (!entryError) entered = Boolean(cockpitView.enter());',
], 'Cockpit entry transaction');
// No direct camera control: every mutation goes through a layer tracker or
@@ -200,11 +201,11 @@ test('accepted navigation releases through PR15-aware ownership before flight',
test('validated voice camera destinations share the UI navigation authority facade', () => {
assert.match(ui, /runImmediateNavigation\(noun, navigate, releaseOptions = undefined\) \{\s*return this\._runExplicitNavigation\(noun, navigate, releaseOptions\);/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager, 'camera', 'move_camera', navigate, releaseOptions/);
assert.match(voice, /runManagedVoiceNavigation\(styleManager, 'route', 'fly_route', navigate/);
assert.match(voice, /runManagedVoiceNavigation\(styleManager, 'fire', 'track_entity'/);
assert.match(voice, /runManagedVoiceNavigation\(styleManager, family\.kind, 'track_entity'/);
assert.match(voice, /runManagedVoiceNavigation\(styleManager, 'frame', 'frame_overhead'/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'camera',\s*'move_camera',\s*navigate,\s*releaseOptions/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'route',\s*'fly_route',\s*navigate/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'fire',\s*'track_entity'/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*family\.kind,\s*'track_entity'/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'frame',\s*'frame_overhead'/);
const trackedVoice = voice.slice(
voice.indexOf('async function trackEntity'),
voice.indexOf('async function frameOverhead'),
@@ -224,20 +225,20 @@ test('validated voice camera destinations share the UI navigation authority faca
ordered(move, [
"if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))",
'const start = () => {',
"const preserveCameraFlight = motion === 'orbit'",
/const preserveCameraFlight =\s*motion === 'orbit'/,
'runNavigation(start, { preserveCameraFlight })',
], 'move validation before handoff');
const route = body(
cameraVerbs,
/export function flyRoute\(annoList, args = \{\}, floorFn = null, runNavigation = null, warmFn = null\) \{([\s\S]*?)\n\}/,
/export function flyRoute\(\s*annoList,\s*args = \{\},\s*floorFn = null,\s*runNavigation = null,\s*warmFn = null,?\s*\) \{([\s\S]*?)\n\}/,
'fly route',
);
ordered(route, [
'if (!routes.length)',
'const pts = route.path.map',
'const start = () => {',
"return typeof runNavigation === 'function' ? runNavigation(start) : start();",
/return typeof runNavigation === 'function'\s*\? runNavigation\(start\)\s*: start\(\);/,
], 'route validation before handoff');
// The corridor warm is injected the same way the floor READ is — the dolly
// never reaches into the data layer itself, and the voice dispatch is the one
+473 -141
View File
File diff suppressed because it is too large. Load diff
+18 -7
View File
@@ -22,9 +22,11 @@ export function activateCctvCameraFromWorldClick(
) {
if (!cameraId || typeof activate !== 'function') return false;
if (activate(cameraId) !== CCTV_ACTIVATION_RESULT.ACTIVATED) return false;
eventTarget.dispatchEvent(new CustomEvent(CCTV_FOCUS_REQUEST_EVENT, {
detail: { cameraId },
}));
eventTarget.dispatchEvent(
new CustomEvent(CCTV_FOCUS_REQUEST_EVENT, {
detail: { cameraId },
}),
);
return true;
}
@@ -36,8 +38,12 @@ export function activateCctvCameraFromWorldClick(
* @returns {() => void} Listener disposer.
*/
export function registerCctvFocusRequestListener(eventTarget, listener) {
if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener
|| typeof listener !== 'function') return () => {};
if (
!eventTarget?.addEventListener ||
!eventTarget?.removeEventListener ||
typeof listener !== 'function'
)
return () => {};
eventTarget.addEventListener(CCTV_FOCUS_REQUEST_EVENT, listener);
let disposed = false;
return () => {
@@ -57,11 +63,16 @@ export function registerCctvFocusRequestListener(eventTarget, listener) {
*/
export function routeCctvFocusRequest(event, runExplicitFocus, focusCamera) {
const cameraId = event?.detail?.cameraId;
if (typeof cameraId !== 'string' || !cameraId || typeof runExplicitFocus !== 'function') {
if (
typeof cameraId !== 'string' ||
!cameraId ||
typeof runExplicitFocus !== 'function'
) {
return false;
}
return runExplicitFocus(
() => cameraId,
(selectedId) => focusCamera(selectedId, CCTV_WORLD_CLICK_FOCUS_DURATION_SEC),
(selectedId) =>
focusCamera(selectedId, CCTV_WORLD_CLICK_FOCUS_DURATION_SEC),
);
}
+186 -56
View File
@@ -90,16 +90,30 @@ export function getKeyholeGeometry(width, height) {
* floor through a band derived from the live keyhole radius.
*/
export function keyholeLabelAlpha(labelX, labelY, width, height) {
return keyholeLabelAlphaFromGeometry(labelX, labelY, getKeyholeGeometry(width, height));
return keyholeLabelAlphaFromGeometry(
labelX,
labelY,
getKeyholeGeometry(width, height),
);
}
/** Compute keyhole opacity from geometry already cached by a hot render loop. */
export function keyholeLabelAlphaFromGeometry(labelX, labelY, geometry) {
if (!geometry || !(geometry.radius > 0) || !Number.isFinite(labelX) || !Number.isFinite(labelY)) return 0;
if (
!geometry ||
!(geometry.radius > 0) ||
!Number.isFinite(labelX) ||
!Number.isFinite(labelY)
)
return 0;
const feather = geometry.featherPx;
const distance = Math.hypot(labelX - geometry.centerX, labelY - geometry.centerY);
const distance = Math.hypot(
labelX - geometry.centerX,
labelY - geometry.centerY,
);
if (distance <= geometry.radius) return 1;
if (!(feather > 0) || distance >= geometry.radius + feather) return keyholeOutsideOpacity;
if (!(feather > 0) || distance >= geometry.radius + feather)
return keyholeOutsideOpacity;
const progress = clamp((distance - geometry.radius) / feather, 0, 1);
return 1 - (1 - keyholeOutsideOpacity) * progress;
}
@@ -131,7 +145,11 @@ export function isCelestialRingStyleSupported(styleName) {
* @param {number} lastAngle - Previous stable angle in radians.
* @returns {{angle:number, opacity:number, stable:boolean}}
*/
export function celestialScreenAngle(rightComponent, upComponent, lastAngle = 0) {
export function celestialScreenAngle(
rightComponent,
upComponent,
lastAngle = 0,
) {
const planeLength = Math.hypot(rightComponent, upComponent);
if (!Number.isFinite(planeLength) || planeLength < CELESTIAL_PLANE_EPSILON) {
return {
@@ -170,26 +188,42 @@ export function isFullGlobeInsideKeyhole(geometry, wasVisible = false) {
keyholeCenterY,
keyholeRadius,
} = geometry || {};
const values = [earthCenterX, earthCenterY, earthRadius, keyholeCenterX, keyholeCenterY, keyholeRadius];
if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0) return false;
const offset = Math.hypot(earthCenterX - keyholeCenterX, earthCenterY - keyholeCenterY);
const values = [
earthCenterX,
earthCenterY,
earthRadius,
keyholeCenterX,
keyholeCenterY,
keyholeRadius,
];
if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0)
return false;
const offset = Math.hypot(
earthCenterX - keyholeCenterX,
earthCenterY - keyholeCenterY,
);
const clearance = keyholeRadius - (offset + earthRadius);
return clearance >= (wasVisible ? GLOBE_EXIT_CLEARANCE_PX : GLOBE_ENTER_CLEARANCE_PX);
return (
clearance >=
(wasVisible ? GLOBE_EXIT_CLEARANCE_PX : GLOBE_ENTER_CLEARANCE_PX)
);
}
/** Return the Earth-disc radius in CSS pixels for a perspective camera. */
export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) {
const earthRadiusM = Cesium.Ellipsoid.WGS84.maximumRadius;
if (
!Number.isFinite(cameraDistance)
|| cameraDistance <= earthRadiusM
|| !(viewportHeight > 0)
|| !Number.isFinite(fovy)
|| fovy <= 0
|| fovy >= Math.PI
) return null;
!Number.isFinite(cameraDistance) ||
cameraDistance <= earthRadiusM ||
!(viewportHeight > 0) ||
!Number.isFinite(fovy) ||
fovy <= 0 ||
fovy >= Math.PI
)
return null;
const angularRadius = Math.asin(clamp(earthRadiusM / cameraDistance, 0, 1));
const radius = (viewportHeight * 0.5) * Math.tan(angularRadius) / Math.tan(fovy * 0.5);
const radius =
(viewportHeight * 0.5 * Math.tan(angularRadius)) / Math.tan(fovy * 0.5);
return Number.isFinite(radius) && radius > 0 ? radius : null;
}
@@ -216,7 +250,11 @@ export function projectEarthDiscToViewport(
if (!camera || !scene || !(width > 0) || !(height > 0)) return null;
const distance = Cesium.Cartesian3.magnitude(camera.positionWC);
if (!Number.isFinite(distance) || distance <= Cesium.Ellipsoid.WGS84.maximumRadius) return null;
if (
!Number.isFinite(distance) ||
distance <= Cesium.Ellipsoid.WGS84.maximumRadius
)
return null;
const toCenter = Cesium.Cartesian3.negate(
camera.positionWC,
@@ -229,9 +267,14 @@ export function projectEarthDiscToViewport(
Cesium.Cartesian3.ZERO,
scratchCenter,
);
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) return null;
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y))
return null;
const earthRadius = earthDiscScreenRadius(distance, height, camera.frustum?.fovy);
const earthRadius = earthDiscScreenRadius(
distance,
height,
camera.frustum?.fovy,
);
if (!earthRadius) return null;
const keyhole = getKeyholeGeometry(width, height);
@@ -286,7 +329,12 @@ function drawSunRays(ctx, cx, cy, radius, innerRadius, angle) {
glow.addColorStop(0.58, 'rgba(255, 235, 188, 0.027)');
glow.addColorStop(1, 'rgba(255, 242, 214, 0)');
ctx.fillStyle = glow;
ctx.fillRect(cx - radius - 4, cy - radius - 4, (radius + 4) * 2, (radius + 4) * 2);
ctx.fillRect(
cx - radius - 4,
cy - radius - 4,
(radius + 4) * 2,
(radius + 4) * 2,
);
ctx.lineCap = 'round';
ctx.shadowColor = 'rgba(255, 232, 171, 0.11)';
@@ -299,7 +347,10 @@ function drawSunRays(ctx, cx, cy, radius, innerRadius, angle) {
const ex = sx + Math.cos(rayAngle) * rayLength;
const ey = sy + Math.sin(rayAngle) * rayLength;
const lineGradient = ctx.createLinearGradient(sx, sy, ex, ey);
lineGradient.addColorStop(0, `rgba(255, 230, 166, ${0.075 - Math.abs(i) * 0.011})`);
lineGradient.addColorStop(
0,
`rgba(255, 230, 166, ${0.075 - Math.abs(i) * 0.011})`,
);
lineGradient.addColorStop(1, 'rgba(255, 226, 156, 0)');
ctx.strokeStyle = lineGradient;
ctx.lineWidth = i === 0 ? 1.8 : 0.9;
@@ -340,7 +391,8 @@ export class CelestialRing {
this.viewer = viewer;
this.enabled = !!enabled;
this.visible = false;
this._onAutoDisable = typeof onAutoDisable === 'function' ? onAutoDisable : null;
this._onAutoDisable =
typeof onAutoDisable === 'function' ? onAutoDisable : null;
this._focusInProgress = false;
this._ephemerisDirty = true;
this._ephemerisUpdateCount = 0;
@@ -364,7 +416,9 @@ export class CelestialRing {
this._fixedMatrix = new Cesium.Matrix3();
this._buildDOM();
this._removePostRender = viewer.scene.postRender.addEventListener(() => this._draw());
this._removePostRender = viewer.scene.postRender.addEventListener(() =>
this._draw(),
);
// Pre-existing staleness fix (perf wave 2 review): the ephemeris was
// sampled once per visible-enable from the FROZEN app clock, so the
// sun/moon markers aged with the app. Resample real wall time each
@@ -394,18 +448,26 @@ export class CelestialRing {
this._sunCanvas = document.createElement('canvas');
this._sunCanvas.className = 'celestial-ring-canvas celestial-sun-canvas';
this._sunCtx = this._sunCanvas.getContext('2d', { alpha: true, desynchronized: true });
this._sunCtx = this._sunCanvas.getContext('2d', {
alpha: true,
desynchronized: true,
});
this._moonCanvas = document.createElement('canvas');
this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-canvas';
this._moonCtx = this._moonCanvas.getContext('2d', { alpha: true, desynchronized: true });
this._moonCtx = this._moonCanvas.getContext('2d', {
alpha: true,
desynchronized: true,
});
this._sunMarker = document.createElement('span');
this._sunMarker.className = 'celestial-marker celestial-sun material-symbols-outlined';
this._sunMarker.className =
'celestial-marker celestial-sun material-symbols-outlined';
this._sunMarker.textContent = 'light_mode';
this._moonMarker = document.createElement('span');
this._moonMarker.className = 'celestial-marker celestial-moon material-symbols-outlined';
this._moonMarker.className =
'celestial-marker celestial-moon material-symbols-outlined';
this._moonMarker.textContent = 'dark_mode';
this._root.append(
@@ -413,7 +475,7 @@ export class CelestialRing {
this._sunCanvas,
this._moonCanvas,
this._sunMarker,
this._moonMarker
this._moonMarker,
);
this.viewer.container.appendChild(this._root);
}
@@ -458,15 +520,24 @@ export class CelestialRing {
const height = canvas.clientHeight || canvas.height;
const cartographic = this.viewer.camera.positionCartographic;
const fovy = this.viewer.camera.frustum?.fovy;
if (!(height > 0) || !cartographic || !Number.isFinite(fovy) || fovy <= 0 || fovy >= Math.PI) {
if (
!(height > 0) ||
!cartographic ||
!Number.isFinite(fovy) ||
fovy <= 0 ||
fovy >= Math.PI
) {
return false;
}
const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius;
const keyholeRadius = getKeyholeGeometry(canvas.clientWidth || canvas.width, height).radius;
const keyholeRadius = getKeyholeGeometry(
canvas.clientWidth || canvas.width,
height,
).radius;
const targetScreenRadius = keyholeRadius * FULL_GLOBE_RADIUS_RATIO;
const angularRadius = Math.atan(
(targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5)
(targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5),
);
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
@@ -480,7 +551,7 @@ export class CelestialRing {
destination: Cesium.Cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
altitude
altitude,
),
orientation: {
heading: this.viewer.camera.heading,
@@ -500,7 +571,10 @@ export class CelestialRing {
/** Clear the backing canvas. */
_clear() {
for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) {
for (const [canvas, ctx] of [
[this._sunCanvas, this._sunCtx],
[this._moonCanvas, this._moonCtx],
]) {
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
}
this._ringOutline?.style.setProperty('display', 'none');
@@ -512,12 +586,23 @@ export class CelestialRing {
/** Resize the canvas backing store while drawing in CSS pixels. */
_resize(width, height) {
const nativeDpr = Math.max(1, window.devicePixelRatio || 1);
const pixelsScale = Math.sqrt(CELESTIAL_MAX_BACKING_PIXELS / (width * height));
const dimensionScale = CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height);
const dpr = Math.min(nativeDpr, CELESTIAL_MAX_DEVICE_PIXEL_RATIO, pixelsScale, dimensionScale);
const pixelsScale = Math.sqrt(
CELESTIAL_MAX_BACKING_PIXELS / (width * height),
);
const dimensionScale =
CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height);
const dpr = Math.min(
nativeDpr,
CELESTIAL_MAX_DEVICE_PIXEL_RATIO,
pixelsScale,
dimensionScale,
);
const bw = Math.max(1, Math.round(width * dpr));
const bh = Math.max(1, Math.round(height * dpr));
for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) {
for (const [canvas, ctx] of [
[this._sunCanvas, this._sunCtx],
[this._moonCanvas, this._moonCtx],
]) {
if (canvas.width !== bw || canvas.height !== bh) {
canvas.width = bw;
canvas.height = bh;
@@ -536,14 +621,25 @@ export class CelestialRing {
_updateEphemeris(time) {
if (!this._ephemerisDirty) return true;
Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(time, this._sunInertial);
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(time, this._moonInertial);
const matrix = Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix)
|| Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix);
Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(
time,
this._sunInertial,
);
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(
time,
this._moonInertial,
);
const matrix =
Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix) ||
Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix);
if (!matrix) return false;
Cesium.Matrix3.multiplyByVector(matrix, this._sunInertial, this._sunFixed);
Cesium.Matrix3.multiplyByVector(matrix, this._moonInertial, this._moonFixed);
Cesium.Matrix3.multiplyByVector(
matrix,
this._moonInertial,
this._moonFixed,
);
Cesium.Cartesian3.normalize(this._sunFixed, this._sunFixed);
Cesium.Cartesian3.normalize(this._moonFixed, this._moonFixed);
this._ephemerisDirty = false;
@@ -574,8 +670,14 @@ export class CelestialRing {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const margin = 14;
const maxRadiusX = Math.abs(cos) > 1e-4 ? (cx - margin) / Math.abs(cos) : Number.POSITIVE_INFINITY;
const maxRadiusY = Math.abs(sin) > 1e-4 ? (cy - margin) / Math.abs(sin) : Number.POSITIVE_INFINITY;
const maxRadiusX =
Math.abs(cos) > 1e-4
? (cx - margin) / Math.abs(cos)
: Number.POSITIVE_INFINITY;
const maxRadiusY =
Math.abs(sin) > 1e-4
? (cy - margin) / Math.abs(sin)
: Number.POSITIVE_INFINITY;
const markerRadius = Math.min(radius, maxRadiusX, maxRadiusY);
marker.style.left = `${cx + cos * markerRadius}px`;
marker.style.top = `${cy + sin * markerRadius}px`;
@@ -596,14 +698,24 @@ export class CelestialRing {
const sunKey = `${outlineKey}:${Math.round(rayInnerRadius)}`;
if (sunKey !== this._sunRenderKey) {
this._sunCtx.clearRect(0, 0, this._sunCanvas.width, this._sunCanvas.height);
this._sunCtx.clearRect(
0,
0,
this._sunCanvas.width,
this._sunCanvas.height,
);
drawSunRays(this._sunCtx, cx, cy, radius, rayInnerRadius, 0);
drawTaperedArc(this._sunCtx, cx, cy, radius, 0, '222, 190, 89', 0.56);
this._sunRenderKey = sunKey;
}
if (outlineKey !== this._moonRenderKey) {
this._moonCtx.clearRect(0, 0, this._moonCanvas.width, this._moonCanvas.height);
this._moonCtx.clearRect(
0,
0,
this._moonCanvas.width,
this._moonCanvas.height,
);
drawMoonHaze(this._moonCtx, cx, cy, radius, 0);
drawTaperedArc(this._moonCtx, cx, cy, radius, 0, '48, 201, 229', 0.42);
this._moonRenderKey = outlineKey;
@@ -624,7 +736,9 @@ export class CelestialRing {
const disc = this._projectedEarthDisc(width, height);
const wasVisible = this.visible;
const nextVisible = disc ? isFullGlobeInsideKeyhole(disc, wasVisible) : false;
const nextVisible = disc
? isFullGlobeInsideKeyhole(disc, wasVisible)
: false;
this.visible = nextVisible;
this._root.classList.toggle('visible', nextVisible);
this._root.dataset.globeVisible = String(nextVisible);
@@ -645,12 +759,12 @@ export class CelestialRing {
const sunProjection = celestialScreenAngle(
Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC),
Cesium.Cartesian3.dot(this._sunFixed, camera.upWC),
this._sunAngle
this._sunAngle,
);
const moonProjection = celestialScreenAngle(
Cesium.Cartesian3.dot(this._moonFixed, camera.rightWC),
Cesium.Cartesian3.dot(this._moonFixed, camera.upWC),
this._moonAngle
this._moonAngle,
);
if (sunProjection.stable) this._sunAngle = sunProjection.angle;
if (moonProjection.stable) this._moonAngle = moonProjection.angle;
@@ -660,7 +774,10 @@ export class CelestialRing {
const cx = width * 0.5;
const cy = height * 0.5;
const radius = disc.keyholeRadius - RING_INSET_PX;
const rayInnerRadius = Math.min(radius - 12, disc.earthRadius + Math.max(38, radius * 0.065));
const rayInnerRadius = Math.min(
radius - 12,
disc.earthRadius + Math.max(38, radius * 0.065),
);
this._renderEffectLayers(cx, cy, radius, rayInnerRadius);
this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`;
this._sunCanvas.style.opacity = String(this._sunOpacity);
@@ -671,16 +788,24 @@ export class CelestialRing {
// stroke. Near conjunction (such as a new moon), keep the true bearings but
// move the moon into a second radial lane so both bodies remain legible.
const markerRadius = radius - MARKER_INSET_PX;
const markersCollide = circularAngleDistance(this._sunAngle, this._moonAngle)
< MARKER_COLLISION_ANGLE;
this._positionMarker(this._sunMarker, cx, cy, markerRadius, this._sunAngle, this._sunOpacity);
const markersCollide =
circularAngleDistance(this._sunAngle, this._moonAngle) <
MARKER_COLLISION_ANGLE;
this._positionMarker(
this._sunMarker,
cx,
cy,
markerRadius,
this._sunAngle,
this._sunOpacity,
);
this._positionMarker(
this._moonMarker,
cx,
cy,
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
this._moonAngle,
this._moonOpacity
this._moonOpacity,
);
this._debug = {
enabled: true,
@@ -698,7 +823,12 @@ export class CelestialRing {
/** Read-only geometry snapshot for browser QA. */
getDebugState() {
return this._debug ? { ...this._debug, disc: this._debug.disc ? { ...this._debug.disc } : null } : null;
return this._debug
? {
...this._debug,
disc: this._debug.disc ? { ...this._debug.disc } : null,
}
: null;
}
/** Detach the render hook and remove all overlay DOM. */
+133 -47
View File
@@ -1,6 +1,9 @@
import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium';
import { deriveWeatherEffectProfile, weatherAltitudeFactors } from './weatherEffectsMath.js';
import {
deriveWeatherEffectProfile,
weatherAltitudeFactors,
} from './weatherEffectsMath.js';
const WEATHER_REFRESH_MS = 5 * 60_000;
const CLOUD_FRAME_MS = 1000 / 12;
@@ -117,28 +120,47 @@ function compileShader(gl, type, source) {
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
const error = gl.getShaderInfoLog(shader) || 'Unknown shader compilation error';
const error =
gl.getShaderInfoLog(shader) || 'Unknown shader compilation error';
gl.deleteShader(shader);
throw new Error(error);
}
function greatCircleM(a, b) {
if (![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(Number.isFinite)) return Infinity;
if (
![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(
Number.isFinite,
)
)
return Infinity;
const latitudeA = Cesium.Math.toRadians(a.latitude);
const latitudeB = Cesium.Math.toRadians(b.latitude);
const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude);
const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude);
const haversine = Math.sin(latitudeDelta / 2) ** 2
+ Math.cos(latitudeA) * Math.cos(latitudeB) * Math.sin(longitudeDelta / 2) ** 2;
return 6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine));
const haversine =
Math.sin(latitudeDelta / 2) ** 2 +
Math.cos(latitudeA) *
Math.cos(latitudeB) *
Math.sin(longitudeDelta / 2) ** 2;
return (
6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine))
);
}
/** Return whether cockpit weather must refresh for elapsed time or movement. */
export function cockpitWeatherRefreshDue({ nowMs, fetchedAt, anchor, point, hasWeather }) {
export function cockpitWeatherRefreshDue({
nowMs,
fetchedAt,
anchor,
point,
hasWeather,
}) {
if (!hasWeather) return true;
if (!Number.isFinite(nowMs) || !Number.isFinite(fetchedAt)) return true;
return nowMs - fetchedAt >= WEATHER_REFRESH_MS
|| greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M;
return (
nowMs - fetchedAt >= WEATHER_REFRESH_MS ||
greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M
);
}
/** Returns the capped framebuffer size used by the cockpit cloud pass. */
@@ -192,18 +214,24 @@ export class CockpitCloudEffectsController {
this.pending = null;
this.abort = null;
this.destroyed = false;
this.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this.reducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
this.enabled = this.readEnabledPreference();
this.onResize = () => this.resize();
this.onCockpitMode = (event) => {
const active = event?.detail?.active ?? document.body.classList.contains('cockpit-mode');
const active =
event?.detail?.active ??
document.body.classList.contains('cockpit-mode');
if (active && this.enabled) this.start();
else this.stop();
};
this.onEnabledChange = (event) => {
const requested = event?.detail?.enabled;
this.setEnabled(typeof requested === 'boolean' ? requested : !this.enabled);
this.setEnabled(
typeof requested === 'boolean' ? requested : !this.enabled,
);
};
window.addEventListener('resize', this.onResize);
window.addEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
@@ -222,9 +250,11 @@ export class CockpitCloudEffectsController {
}
emitEnabledState() {
window.dispatchEvent(new CustomEvent('gev:cockpit-weather-state', {
detail: { enabled: this.enabled },
}));
window.dispatchEvent(
new CustomEvent('gev:cockpit-weather-state', {
detail: { enabled: this.enabled },
}),
);
}
setEnabled(enabled) {
@@ -232,7 +262,9 @@ export class CockpitCloudEffectsController {
this.enabled = next;
try {
localStorage.setItem(WEATHER_ENABLED_STORAGE_KEY, next ? '1' : '0');
} catch { /* best effort */ }
} catch {
/* best effort */
}
if (!next) {
this.stop();
} else if (document.body.classList.contains('cockpit-mode')) {
@@ -255,7 +287,11 @@ export class CockpitCloudEffectsController {
if (!gl) throw new Error('WebGL unavailable');
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
const fragmentShader = compileShader(
gl,
gl.FRAGMENT_SHADER,
FRAGMENT_SHADER,
);
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
@@ -263,15 +299,18 @@ export class CockpitCloudEffectsController {
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(program) || 'Cloud shader link failed');
throw new Error(
gl.getProgramInfoLog(program) || 'Cloud shader link failed',
);
}
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, -1, 1,
-1, 1, 1, -1, 1, 1,
]), gl.STATIC_DRAW);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW,
);
const position = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
@@ -293,7 +332,8 @@ export class CockpitCloudEffectsController {
resize() {
if (!this.gl) return;
const size = cockpitCloudRenderSize(window.innerWidth, window.innerHeight);
if (this.canvas.width === size.width && this.canvas.height === size.height) return;
if (this.canvas.width === size.width && this.canvas.height === size.height)
return;
this.canvas.width = size.width;
this.canvas.height = size.height;
this.gl.viewport(0, 0, size.width, size.height);
@@ -313,7 +353,13 @@ export class CockpitCloudEffectsController {
}
start() {
if (this.destroyed || !this.enabled || this.frame !== null || this.suspended) return;
if (
this.destroyed ||
!this.enabled ||
this.frame !== null ||
this.suspended
)
return;
if (!this.gl) {
this.initializeRenderer();
this.resize();
@@ -353,16 +399,21 @@ export class CockpitCloudEffectsController {
}
async refresh() {
if (this.destroyed || !this.enabled || this.pending || this.suspended
|| !document.body.classList.contains('cockpit-mode')) {
if (
this.destroyed ||
!this.enabled ||
this.pending ||
this.suspended ||
!document.body.classList.contains('cockpit-mode')
) {
return this.pending;
}
const point = this.cameraPoint();
if (!point) return null;
if (
this.weather
&& Date.now() - this.fetchedAt < WEATHER_REFRESH_MS
&& greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M
this.weather &&
Date.now() - this.fetchedAt < WEATHER_REFRESH_MS &&
greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M
) {
this.applyWeather(this.weather, point.altitudeM);
return this.weather;
@@ -370,11 +421,17 @@ export class CockpitCloudEffectsController {
this.abort?.abort();
this.abort = new AbortController();
const pending = this.weatherService.getConditions(point.latitude, point.longitude, { signal: this.abort.signal });
const pending = this.weatherService.getConditions(
point.latitude,
point.longitude,
{ signal: this.abort.signal },
);
const request = this.abort;
this.pending = pending.then(async (payload) => {
this.pending = pending
.then(async (payload) => {
if (this.abort !== request || request.signal.aborted) return null;
if (!payload?.weather) throw new Error('Cloud weather observation unavailable');
if (!payload?.weather)
throw new Error('Cloud weather observation unavailable');
this.weather = payload.weather;
this.fetchedAt = Date.now();
this.anchor = point;
@@ -391,7 +448,10 @@ export class CockpitCloudEffectsController {
return null;
})
.finally(() => {
if (this.abort === request) { this.pending = null; this.abort = null; }
if (this.abort === request) {
this.pending = null;
this.abort = null;
}
});
return this.pending;
}
@@ -399,12 +459,17 @@ export class CockpitCloudEffectsController {
applyWeather(weather, altitudeM) {
const profile = deriveWeatherEffectProfile(weather);
const altitude = weatherAltitudeFactors(altitudeM);
this.targetStrength = profile.available ? profile.cloud * altitude.cloud : 0;
this.targetStrength = profile.available
? profile.cloud * altitude.cloud
: 0;
this.windDirectionDeg = profile.windDirectionDeg || 0;
this.windStrength = profile.wind || 0;
this.canvas.dataset.cloudStrength = this.targetStrength.toFixed(3);
this.canvas.dataset.weatherCode = String(weather?.weatherCode ?? 'unknown');
if (this.reducedMotion && document.body.classList.contains('cockpit-mode')) {
if (
this.reducedMotion &&
document.body.classList.contains('cockpit-mode')
) {
this.strength = this.targetStrength;
const visible = this.strength > 0.035;
this.canvas.classList.toggle('active', visible);
@@ -414,7 +479,11 @@ export class CockpitCloudEffectsController {
}
tick(timeMs) {
if (this.destroyed || !this.enabled || !document.body.classList.contains('cockpit-mode')) {
if (
this.destroyed ||
!this.enabled ||
!document.body.classList.contains('cockpit-mode')
) {
this.stop();
return;
}
@@ -431,20 +500,27 @@ export class CockpitCloudEffectsController {
if (timeMs - this.lastRefreshCheckMs >= 1000) {
this.lastRefreshCheckMs = timeMs;
const point = this.cameraPoint();
if (point && cockpitWeatherRefreshDue({
nowMs: Date.now(),
fetchedAt: this.fetchedAt,
anchor: this.anchor,
point,
hasWeather: Boolean(this.weather),
})) void this.refresh();
if (
point &&
cockpitWeatherRefreshDue({
nowMs: Date.now(),
fetchedAt: this.fetchedAt,
anchor: this.anchor,
point,
hasWeather: Boolean(this.weather),
})
)
void this.refresh();
}
const blend = this.reducedMotion ? 1 : 0.08;
this.strength += (this.targetStrength - this.strength) * blend;
const visible = this.strength > 0.035;
this.canvas.classList.toggle('active', visible);
if (visible && (this.reducedMotion || timeMs - this.lastFrameMs >= CLOUD_FRAME_MS)) {
if (
visible &&
(this.reducedMotion || timeMs - this.lastFrameMs >= CLOUD_FRAME_MS)
) {
this.lastFrameMs = timeMs;
this.render(timeMs / 1000);
} else if (!visible) {
@@ -468,9 +544,16 @@ export class CockpitCloudEffectsController {
const windRadians = Cesium.Math.toRadians(this.windDirectionDeg);
const windScale = 0.008 + this.windStrength * 0.022;
gl.useProgram(this.program);
gl.uniform2f(this.locations.resolution, this.canvas.width, this.canvas.height);
gl.uniform2f(
this.locations.resolution,
this.canvas.width,
this.canvas.height,
);
gl.uniform1f(this.locations.time, timeSec);
gl.uniform1f(this.locations.strength, Math.min(1, Math.max(0, this.strength)));
gl.uniform1f(
this.locations.strength,
Math.min(1, Math.max(0, this.strength)),
);
gl.uniform2f(
this.locations.wind,
Math.sin(windRadians) * windScale,
@@ -517,7 +600,10 @@ export class CockpitCloudEffectsController {
this.stop();
window.removeEventListener('resize', this.onResize);
window.removeEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
window.removeEventListener('gev:cockpit-weather-toggle', this.onEnabledChange);
window.removeEventListener(
'gev:cockpit-weather-toggle',
this.onEnabledChange,
);
if (this.program && this.gl) this.gl.deleteProgram(this.program);
this.canvas.remove();
}
+15 -6
View File
@@ -45,7 +45,7 @@ test('Cockpit has one reset action beside its bottom exit path', () => {
);
const topCenterActions = html.match(/<nav id="top-center-actions"[\s\S]*?<\/nav>/);
assert.ok(topCenterActions, 'Top-center globe actions are missing');
assert.match(topCenterActions[0], /id="clear-selected-layers"[\s\S]*?id="share-btn"[\s\S]*?id="reset-globe-view"/);
assert.match(topCenterActions[0], /id="clear-selected-layers"[\s\S]*?id="share-btn"[\s\S]*?id="tilt-map-view"[\s\S]*?id="north-up-view"[\s\S]*?id="reset-globe-view"/);
assert.equal((html.match(/id="clear-selected-layers"/g) || []).length, 1, 'Clear Layers must have one DOM owner');
assert.equal((html.match(/id="reset-globe-view"/g) || []).length, 1, 'Reset Globe must have one DOM owner');
assert.equal((html.match(/id="cockpit-reset-globe"/g) || []).length, 1, 'Cockpit Reset must have one DOM owner');
@@ -227,7 +227,7 @@ test('share startup isolates panel defaults from recipient-local collapse prefer
});
test('Cockpit owns a focused shared Display portal and compact Radio controls', () => {
const hiddenRule = css.match(/body\.cockpit-mode :is\(([^)]*)\)\s*\{\s*display:\s*none\s*!important;/);
const hiddenRule = css.match(/body\.cockpit-mode\s*:is\(([^)]*)\)\s*\{\s*display:\s*none\s*!important;/);
assert.ok(hiddenRule, 'Cockpit hidden-chrome rule is missing');
assert.match(css, /body\.cockpit-mode #right-context-rail\s*\{\s*display:\s*none\s*!important;/);
assert.match(css, /body\.cockpit-mode #left-panel-stack > #scene-panel\s*\{\s*display:\s*none\s*!important;/);
@@ -240,13 +240,22 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
assert.match(html, /id="clear-selected-layers"[^>]*aria-label="Clear selected data layers"/);
assert.match(html, /id="reset-globe-view"[^>]*aria-label="Reset to full globe view"/);
assert.match(css, /#top-center-actions\s*\{[\s\S]*?left:\s*50%;[\s\S]*?display:\s*flex;[\s\S]*?transform:\s*translateX\(-50%\)/);
assert.match(
css,
/@media \(max-width: 720px\)\s*\{[\s\S]*?#top-center-actions\s*\{[\s\S]*?right:\s*16px;[\s\S]*?left:\s*auto;[\s\S]*?transform:\s*none;/,
);
assert.match(css, /@media \(max-width: 720px\)\s*\{[\s\S]*?#style-indicator\s*\{\s*display:\s*none;/);
assert.match(
css,
/@media \(max-width: 520px\)\s*\{[\s\S]*?#title-bar h1 > span:last-child,[\s\S]*?#title-bar \.subtitle\s*\{\s*display:\s*none;/,
);
assert.match(css, /body\.ui-clean-view #top-center-actions/);
assert.match(css, /body\.recording-mode #top-center-actions/);
assert.match(
css,
/body\.scene-playback-mode :is\(#clear-selected-layers, #reset-globe-view\)\s*\{\s*display:\s*none !important;/,
/body\.scene-playback-mode\s*:is\([\s\S]*?#clear-selected-layers,[\s\S]*?#tilt-map-view,[\s\S]*?#north-up-view,[\s\S]*?#reset-globe-view[\s\S]*?\)\s*\{\s*display:\s*none !important;/,
);
assert.match(sceneDirector, /this\._running = true;\s*this\._setPlaybackActive\(true\);/);
assert.match(sceneDirector, /this\._running = true;\s*this\._previewRun = preview;\s*if \(preview\) this\._setPlaybackActive\(true\);/);
assert.match(sceneDirector, /styleManager\.setRecordingMode\(false\);\s*this\._setPlaybackActive\(false\);/);
assert.match(SceneControls.prototype.setPlaybackActive.toString(), /document\.body\.classList\.toggle\('scene-playback-mode', active\)/);
assert.equal((html.match(/id="hud-toggle"/g) || []).length, 1, 'HUD control must have one stateful DOM owner');
@@ -468,11 +477,11 @@ test('real disclosure changes reconsider only their own temporary panel lane', (
test('Cockpit hides the complete top-center globe action group', () => {
assert.match(
css,
/body\.cockpit-mode :is\([\s\S]*?#top-center-actions[\s\S]*?\)\s*\{\s*display: none !important;\s*\}/,
/body\.cockpit-mode\s*:is\([\s\S]*?#top-center-actions[\s\S]*?\)\s*\{\s*display: none !important;\s*\}/,
);
assert.match(
css,
/body\.cockpit-mode :is\(#clear-selected-layers, #share-btn, #reset-globe-view\)\s*\{\s*display:\s*none !important;/,
/body\.cockpit-mode\s*:is\([\s\S]*?#clear-selected-layers,[\s\S]*?#share-btn,[\s\S]*?#tilt-map-view,[\s\S]*?#north-up-view,[\s\S]*?#reset-globe-view[\s\S]*?\)\s*\{\s*display:\s*none !important;/,
'Cockpit must hide each map-only globe action even if its group layout is disturbed',
);
});
+32 -15
View File
@@ -8,7 +8,11 @@
*/
/** Force the prior aircraft layer to reacquire Cesium and durable tracking ownership. */
export function restoreAircraftTrackingOwner(layer, id, { origin = 'programmatic' } = {}) {
export function restoreAircraftTrackingOwner(
layer,
id,
{ origin = 'programmatic' } = {},
) {
if (!layer?.trackById || !id) return false;
layer.stopTracking?.({ origin });
return Boolean(layer.trackById(id, { origin }));
@@ -33,11 +37,13 @@ export function enterCockpitWithTracking({
rollbackTarget = undefined,
selectionOrigin = 'programmatic',
}) {
const currentTarget = aircraftTrackingTarget(cockpitView?.readAircraftInfo?.());
const restoreTarget = rollbackTarget === undefined ? currentTarget : rollbackTarget;
const key = (target) => target?.layerId && target?.id
? `${target.layerId}:${target.id}`
: null;
const currentTarget = aircraftTrackingTarget(
cockpitView?.readAircraftInfo?.(),
);
const restoreTarget =
rollbackTarget === undefined ? currentTarget : rollbackTarget;
const key = (target) =>
target?.layerId && target?.id ? `${target.layerId}:${target.id}` : null;
let activeLayer = currentLayer;
let activeTarget = currentTarget;
let entryError = null;
@@ -49,8 +55,14 @@ export function enterCockpitWithTracking({
if (selectingTarget) {
activeLayer = selectedLayer;
activeTarget = selectedTarget;
if (!selectedLayer.trackById?.(selectedTarget.id, { origin: selectionOrigin })) {
entryError = new Error('Selected aircraft could not be tracked for Cockpit entry');
if (
!selectedLayer.trackById?.(selectedTarget.id, {
origin: selectionOrigin,
})
) {
entryError = new Error(
'Selected aircraft could not be tracked for Cockpit entry',
);
}
}
if (!entryError) entered = Boolean(cockpitView.enter());
@@ -66,12 +78,15 @@ export function enterCockpitWithTracking({
// programmatic rollback would fix the camera while leaving the failed
// target durable in local/share state.
activeLayer?.stopTracking?.({ origin: selectionOrigin });
if (restoreTarget && !restoreAircraftTrackingOwner(
rollbackLayer,
restoreTarget.id,
{ origin: selectionOrigin },
)) {
entryError ||= new Error('Prior aircraft tracking could not be restored');
if (
restoreTarget &&
!restoreAircraftTrackingOwner(rollbackLayer, restoreTarget.id, {
origin: selectionOrigin,
})
) {
entryError ||= new Error(
'Prior aircraft tracking could not be restored',
);
}
} catch (error) {
entryError ||= error instanceof Error ? error : new Error(String(error));
@@ -87,6 +102,8 @@ export function enterCockpitWithTracking({
return {
entered,
error: entered ? null : entryError?.message || 'Cockpit entry was unavailable',
error: entered
? null
: entryError?.message || 'Cockpit entry was unavailable',
};
}
+15 -4
View File
@@ -75,11 +75,16 @@ export function contactsDetectionEnterPlan(current, restore) {
* @returns {?{mode: string, densityPct: ?number}} State to replay, or null when
* there is nothing to do.
*/
export function contactsDetectionExitPlan(restore, current, styleOwnsDetection = false) {
export function contactsDetectionExitPlan(
restore,
current,
styleOwnsDetection = false,
) {
if (!restore || styleOwnsDetection) return null;
const saved = normalizeDetectionState(restore);
const now = normalizeDetectionState(current);
if (saved.mode === now.mode && saved.densityPct === now.densityPct) return null;
if (saved.mode === now.mode && saved.densityPct === now.densityPct)
return null;
return saved;
}
@@ -119,7 +124,11 @@ export function applyContactsDetection({
if (plan.turnOn) applyPreset();
return { restore: plan.restore, changed: plan.turnOn };
}
const plan = contactsDetectionExitPlan(restore, getState(), styleOwnsDetection);
const plan = contactsDetectionExitPlan(
restore,
getState(),
styleOwnsDetection,
);
if (plan) restoreState(plan);
return { restore: null, changed: Boolean(plan) };
}
@@ -145,7 +154,9 @@ export function shareableDetectionState({ owned, liveMode, liveDensityPct }) {
if (!owned) return { mode: liveMode, densityPct: liveDensityPct };
return {
mode: owned.mode ?? liveMode,
densityPct: Number.isFinite(owned.densityPct) ? owned.densityPct : liveDensityPct,
densityPct: Number.isFinite(owned.densityPct)
? owned.densityPct
: liveDensityPct,
};
}
+125 -72
View File
@@ -1,5 +1,11 @@
const CONTEXT_DEPENDENCIES = Object.freeze({
flights: new Set(['military-awareness', 'flights', 'military', 'ais-live-vessels', 'military-installations']),
flights: new Set([
'military-awareness',
'flights',
'military',
'ais-live-vessels',
'military-installations',
]),
'space-missions': new Set(['rocket-launches', 'satellites']),
});
const CONTEXT_COMPANIONS = new Set(['radio']);
@@ -11,11 +17,13 @@ export function isExplicitUserIntentOrigin(origin, layerId = null) {
/** Preserve explicit layer intent that completes while a stale restore is queued. */
export function recordContextRestoreExplicitChange({ restoreState, change }) {
if (
!restoreState?.enabledLayerIds
|| change?.type !== 'visibility'
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
) return false;
if (!restoreState.explicitLayerStates) restoreState.explicitLayerStates = new Map();
!restoreState?.enabledLayerIds ||
change?.type !== 'visibility' ||
!isExplicitUserIntentOrigin(change.origin, change.layerId)
)
return false;
if (!restoreState.explicitLayerStates)
restoreState.explicitLayerStates = new Map();
restoreState.explicitLayerStates.set(change.layerId, Boolean(change.enabled));
if (change.enabled) restoreState.enabledLayerIds.add(change.layerId);
else restoreState.enabledLayerIds.delete(change.layerId);
@@ -23,30 +31,41 @@ export function recordContextRestoreExplicitChange({ restoreState, change }) {
}
/** Settle every explicit Context-restore replay without losing semantic failures. */
export async function settleContextIntentReplay({ restoreState, setEnabled, notificationToken = null }) {
export async function settleContextIntentReplay({
restoreState,
setEnabled,
notificationToken = null,
}) {
if (restoreState?.cancelled) return null;
const entries = [...(restoreState?.explicitLayerStates || [])];
const results = await Promise.allSettled(entries.map(([layerId, enabled]) => (
setEnabled(layerId, enabled, {
origin: 'context-intent-replay',
...(notificationToken ? { notificationToken } : {}),
})
)));
const failedIndexes = results.flatMap((result, index) => (
result.status === 'rejected' || result.value === false ? [index] : []
));
const results = await Promise.allSettled(
entries.map(([layerId, enabled]) =>
setEnabled(layerId, enabled, {
origin: 'context-intent-replay',
...(notificationToken ? { notificationToken } : {}),
}),
),
);
const failedIndexes = results.flatMap((result, index) =>
result.status === 'rejected' || result.value === false ? [index] : [],
);
if (failedIndexes.length === 0) return null;
const failedLayerIds = failedIndexes.map((index) => entries[index][0]);
const rejected = failedIndexes
.map((index) => results[index])
.find((result) => result.status === 'rejected');
const error = rejected?.reason instanceof Error
? rejected.reason
: new Error(`Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`);
error.failedLayerIds = [...new Set([
...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []),
...failedLayerIds,
])];
const error =
rejected?.reason instanceof Error
? rejected.reason
: new Error(
`Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`,
);
error.failedLayerIds = [
...new Set([
...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []),
...failedLayerIds,
]),
];
return error;
}
@@ -54,18 +73,29 @@ export async function settleContextIntentReplay({ restoreState, setEnabled, noti
export function mergeContextTransitionErrors(primaryError, secondaryError) {
if (!primaryError) return secondaryError || null;
if (!secondaryError) return primaryError;
primaryError.failedLayerIds = [...new Set([
...(Array.isArray(primaryError.failedLayerIds) ? primaryError.failedLayerIds : []),
...(Array.isArray(secondaryError.failedLayerIds) ? secondaryError.failedLayerIds : []),
])];
primaryError.failedLayerIds = [
...new Set([
...(Array.isArray(primaryError.failedLayerIds)
? primaryError.failedLayerIds
: []),
...(Array.isArray(secondaryError.failedLayerIds)
? secondaryError.failedLayerIds
: []),
]),
];
return primaryError;
}
/** Settle a user-facing Context action and convert every failure form to false. */
export async function settleUserFacingContextAction({ operation, onFailure, falseIsFailure = true }) {
export async function settleUserFacingContextAction({
operation,
onFailure,
falseIsFailure = true,
}) {
try {
const result = await operation();
if (falseIsFailure && result === false) throw new Error('Context transition did not complete');
if (falseIsFailure && result === false)
throw new Error('Context transition did not complete');
return result;
} catch (error) {
try {
@@ -83,7 +113,10 @@ export async function settleUserFacingContextAction({ operation, onFailure, fals
* one into `userAdded` makes exit restoration re-enable the mode layer the
* user just turned off.
*/
export const CONTEXT_ENTRY_LAYER_IDS = Object.freeze(['military-awareness', 'rocket-launches']);
export const CONTEXT_ENTRY_LAYER_IDS = Object.freeze([
'military-awareness',
'rocket-launches',
]);
/**
* Leave a Context transaction: publish the settled coordination flag, then
@@ -108,7 +141,8 @@ export function settleContextModeChange(owner, changing = false) {
if (!owner) return;
const wasChanging = Boolean(owner._contextModeChanging);
owner._contextModeChanging = Boolean(changing);
if (wasChanging && !owner._contextModeChanging) owner._syncContextModeButtons?.();
if (wasChanging && !owner._contextModeChanging)
owner._syncContextModeButtons?.();
}
/**
@@ -142,19 +176,24 @@ export async function runWithContextModeChanging(owner, operation) {
* @param {object|null} input.change DataLayerManager visibility notification.
* @returns {boolean} Whether the active Context bundle should be disabled.
*/
export function shouldExitContextForLayerChange({ contextMode, globalContextEnabled, change }) {
export function shouldExitContextForLayerChange({
contextMode,
globalContextEnabled,
change,
}) {
if (
change?.type !== 'visibility'
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
|| change.layerId === 'military-awareness'
|| CONTEXT_COMPANIONS.has(change.layerId)
change?.type !== 'visibility' ||
!isExplicitUserIntentOrigin(change.origin, change.layerId) ||
change.layerId === 'military-awareness' ||
CONTEXT_COMPANIONS.has(change.layerId)
) {
return false;
}
const active = Boolean(globalContextEnabled || contextMode);
if (!active) return false;
const dependencies = CONTEXT_DEPENDENCIES[contextMode] || new Set(['military-awareness']);
const dependencies =
CONTEXT_DEPENDENCIES[contextMode] || new Set(['military-awareness']);
if (change.enabled) return false;
return Boolean(contextMode && dependencies.has(change.layerId));
}
@@ -181,10 +220,10 @@ export function contextAllowedLayerIds(contextMode) {
*/
export function shouldCaptureContextSession(change) {
return Boolean(
['visibility-requested', 'visibility-will-change'].includes(change?.type)
&& isExplicitUserIntentOrigin(change.origin, change.layerId)
&& change.enabled
&& CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId)
['visibility-requested', 'visibility-will-change'].includes(change?.type) &&
isExplicitUserIntentOrigin(change.origin, change.layerId) &&
change.enabled &&
CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId),
);
}
@@ -194,12 +233,12 @@ export function shouldCaptureContextSession(change) {
*/
export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
return Boolean(
clearInFlight
&& change?.type === 'visibility-requested'
&& change.layerId === 'rocket-launches'
&& change.enabled === true
&& isExplicitUserIntentOrigin(change.origin, change.layerId)
&& Number.isInteger(change.intentEpoch)
clearInFlight &&
change?.type === 'visibility-requested' &&
change.layerId === 'rocket-launches' &&
change.enabled === true &&
isExplicitUserIntentOrigin(change.origin, change.layerId) &&
Number.isInteger(change.intentEpoch),
);
}
@@ -218,11 +257,15 @@ export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
* @param {string|null} [input.effectiveContextMode] Committed or entering mode.
* @returns {boolean} Whether the snapshot's userAdded set was modified.
*/
export function recordContextSessionUserChange({ snapshot, change, effectiveContextMode = null }) {
export function recordContextSessionUserChange({
snapshot,
change,
effectiveContextMode = null,
}) {
if (
!snapshot?.userAdded
|| change?.type !== 'visibility'
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
!snapshot?.userAdded ||
change?.type !== 'visibility' ||
!isExplicitUserIntentOrigin(change.origin, change.layerId)
) {
return false;
}
@@ -233,7 +276,11 @@ export function recordContextSessionUserChange({ snapshot, change, effectiveCont
snapshot.userAdded.add(change.layerId);
return true;
}
if (contextAllowedLayerIds(effectiveContextMode).has(change.layerId) && !isCompanion) return false;
if (
contextAllowedLayerIds(effectiveContextMode).has(change.layerId) &&
!isCompanion
)
return false;
snapshot.userAdded.add(change.layerId);
if (isCompanion) snapshot.userRemoved?.delete(change.layerId);
return true;
@@ -276,11 +323,15 @@ export function contextSnapshotLayerIds(
* @param {string|null} [input.layerName] User-facing layer name.
* @returns {string|null} Honest user-facing refusal reason, or null when allowed.
*/
export function contextLayerEnableBlockReason({ contextMode, change, layerName = null }) {
export function contextLayerEnableBlockReason({
contextMode,
change,
layerName = null,
}) {
if (
contextMode !== 'space-missions'
|| change?.enabled !== true
|| contextAllowedLayerIds('space-missions').has(change.layerId)
contextMode !== 'space-missions' ||
change?.enabled !== true ||
contextAllowedLayerIds('space-missions').has(change.layerId)
) {
return null;
}
@@ -297,20 +348,20 @@ export function contextLayerEnableBlockReason({ contextMode, change, layerName =
* @param {object|null} input.change Manager visibility notification.
* @returns {'ignore'|'replacement'|'restore'} Cancellation disposition.
*/
export function spaceMissionEntryCancellationDisposition({
change,
}) {
export function spaceMissionEntryCancellationDisposition({ change }) {
if (
change?.type !== 'visibility-cancelled'
|| change.layerId !== 'rocket-launches'
|| change.enabled !== true
) return 'ignore';
change?.type !== 'visibility-cancelled' ||
change.layerId !== 'rocket-launches' ||
change.enabled !== true
)
return 'ignore';
if (
change.cancellationReason === 'superseded'
&& Number.isInteger(change.successorIntentEpoch)
&& change.successorIntentEpoch > change.intentEpoch
&& change.successorEnabled === true
) return 'replacement';
change.cancellationReason === 'superseded' &&
Number.isInteger(change.successorIntentEpoch) &&
change.successorIntentEpoch > change.intentEpoch &&
change.successorEnabled === true
)
return 'replacement';
return 'restore';
}
@@ -348,10 +399,12 @@ export function cockpitEntryAllowed({
flightsEnabled,
militaryEnabled,
}) {
return contextMode === 'flights'
&& !contextModeChanging
&& Boolean(flightsEnabled)
&& Boolean(militaryEnabled);
return (
contextMode === 'flights' &&
!contextModeChanging &&
Boolean(flightsEnabled) &&
Boolean(militaryEnabled)
);
}
/**
+6 -2
View File
@@ -3,7 +3,9 @@
* Cesium retains ownership of showing and hiding its required credit lightbox.
*/
export function configureCreditKeyboardAccess(root = document) {
const expand = root?.querySelector?.('#cesium-credits .cesium-credit-expand-link');
const expand = root?.querySelector?.(
'#cesium-credits .cesium-credit-expand-link',
);
const lightbox = root?.querySelector?.('.cesium-credit-lightbox');
const close = lightbox?.querySelector?.('.cesium-credit-lightbox-close');
if (!expand || !lightbox || !close) return false;
@@ -44,7 +46,9 @@ export function configureCreditKeyboardAccess(root = document) {
// A long Space hold may hand control to push-to-talk and move focus away.
// Cancelling the armed release prevents that eventual keyup from also
// activating the attribution control.
control.addEventListener('blur', () => { spacePressed = false; });
control.addEventListener('blur', () => {
spacePressed = false;
});
};
const closeAndRestoreFocus = () => {
expand.setAttribute('aria-expanded', 'false');
+414 -67
View File
@@ -8,90 +8,415 @@
*/
const HELI = new Set([
'EC20', 'EC25', 'EC30', 'EC35', 'EC45', 'EC55', 'AS50', 'AS55', 'AS65', 'AS32',
'A109', 'A119', 'A139', 'A169', 'A189', 'B06', 'B06T', 'B407', 'B412', 'B427',
'B429', 'B430', 'B505', 'S76', 'S92', 'S61', 'S64', 'H60', 'H500', 'MD52',
'MD60', 'R22', 'R44', 'R66', 'EXEC', 'EXPL', 'GAZL', 'LYNX', 'NH90', 'PUMA',
'SCAV', 'UH1', 'B105', 'B212', 'B214', 'B222', 'AC', 'H47', 'H64',
'EC20',
'EC25',
'EC30',
'EC35',
'EC45',
'EC55',
'AS50',
'AS55',
'AS65',
'AS32',
'A109',
'A119',
'A139',
'A169',
'A189',
'B06',
'B06T',
'B407',
'B412',
'B427',
'B429',
'B430',
'B505',
'S76',
'S92',
'S61',
'S64',
'H60',
'H500',
'MD52',
'MD60',
'R22',
'R44',
'R66',
'EXEC',
'EXPL',
'GAZL',
'LYNX',
'NH90',
'PUMA',
'SCAV',
'UH1',
'B105',
'B212',
'B214',
'B222',
'AC',
'H47',
'H64',
]);
const QUAD = new Set([
'B741', 'B742', 'B743', 'B744', 'B748', 'B74S', 'B74R', 'B74D', 'A388', 'A342',
'A343', 'A345', 'A346', 'A124', 'C5M', 'A225', 'IL96', 'B52', 'A140',
'B741',
'B742',
'B743',
'B744',
'B748',
'B74S',
'B74R',
'B74D',
'A388',
'A342',
'A343',
'A345',
'A346',
'A124',
'C5M',
'A225',
'IL96',
'B52',
'A140',
]);
const WIDE = new Set([
'A306', 'A30B', 'A310', 'A332', 'A333', 'A338', 'A339', 'A359', 'A35K', 'B762',
'B763', 'B764', 'B772', 'B77L', 'B773', 'B77W', 'B778', 'B779', 'B788', 'B789',
'B78X', 'MD11', 'IL86', 'DC10', 'L101', 'A337', 'B767', 'B777', 'B787',
'C17', 'K35R', // military heavies that read as widebodies
'A306',
'A30B',
'A310',
'A332',
'A333',
'A338',
'A339',
'A359',
'A35K',
'B762',
'B763',
'B764',
'B772',
'B77L',
'B773',
'B77W',
'B778',
'B779',
'B788',
'B789',
'B78X',
'MD11',
'IL86',
'DC10',
'L101',
'A337',
'B767',
'B777',
'B787',
'C17',
'K35R', // military heavies that read as widebodies
]);
const TPROP = new Set([
'DH8A', 'DH8B', 'DH8C', 'DH8D', 'AT43', 'AT44', 'AT45', 'AT46', 'AT72', 'AT73',
'AT75', 'AT76', 'SF34', 'SB20', 'SW3', 'SW4', 'E110', 'E120', 'C208', 'C212',
'C408', 'PC12', 'B190', 'BE20', 'B350', 'B300', 'JS31', 'JS32', 'JS41', 'D228',
'D328', 'F50', 'F27', 'ATP', 'TBM7', 'TBM8', 'TBM9', 'TBM0', 'PC6', 'C441',
'C425', 'DHC6', 'DHC7', 'C130', 'AN12', 'AN26', 'AN32', 'SH36', 'CVLT', 'SAAB',
'DH8A',
'DH8B',
'DH8C',
'DH8D',
'AT43',
'AT44',
'AT45',
'AT46',
'AT72',
'AT73',
'AT75',
'AT76',
'SF34',
'SB20',
'SW3',
'SW4',
'E110',
'E120',
'C208',
'C212',
'C408',
'PC12',
'B190',
'BE20',
'B350',
'B300',
'JS31',
'JS32',
'JS41',
'D228',
'D328',
'F50',
'F27',
'ATP',
'TBM7',
'TBM8',
'TBM9',
'TBM0',
'PC6',
'C441',
'C425',
'DHC6',
'DHC7',
'C130',
'AN12',
'AN26',
'AN32',
'SH36',
'CVLT',
'SAAB',
'A400',
]);
const GLIDER = new Set([
'DISC', 'DUOD', 'VENT', 'NIMB', 'NIM3', 'NIM4', 'JANS', 'ARCE', 'DG40', 'DG80',
'DG1T', 'DG30', 'DG50', 'LS3', 'LS4', 'LS6', 'LS7', 'LS8', 'STD3', 'G103',
'G102', 'G104', 'PW5', 'PW6', 'L13', 'L23', 'L33', 'PIK', 'PEGA', 'KEST',
'TWIN', 'AS33', 'ASW', 'ASG', 'ASK', 'VENS', 'GLID', 'MOSQ', 'DIMO',
'DISC',
'DUOD',
'VENT',
'NIMB',
'NIM3',
'NIM4',
'JANS',
'ARCE',
'DG40',
'DG80',
'DG1T',
'DG30',
'DG50',
'LS3',
'LS4',
'LS6',
'LS7',
'LS8',
'STD3',
'G103',
'G102',
'G104',
'PW5',
'PW6',
'L13',
'L23',
'L33',
'PIK',
'PEGA',
'KEST',
'TWIN',
'AS33',
'ASW',
'ASG',
'ASK',
'VENS',
'GLID',
'MOSQ',
'DIMO',
]);
const LIGHT = new Set([
'C150', 'C152', 'C162', 'C172', 'C72R', 'C175', 'C177', 'C180', 'C182', 'C185',
'C188', 'C206', 'C207', 'C210', 'C310', 'C337', 'SR20', 'SR22', 'S22T', 'PA18',
'PA24', 'PA28', 'P28A', 'P28B', 'P28R', 'PA32', 'P32R', 'PA34', 'PA38', 'PA44',
'PA46', 'DA20', 'DA40', 'DA42', 'DA62', 'BE33', 'BE35', 'BE36', 'BE58', 'BE76',
'BE19', 'BE23', 'BE24', 'M20P', 'M20T', 'AA1', 'AA5', 'GLAS', 'COL4', 'RV4',
'RV6', 'RV7', 'RV8', 'RV9', 'RV10', 'RV14', 'GA8', 'G115', 'BL8', 'CH7',
'C150',
'C152',
'C162',
'C172',
'C72R',
'C175',
'C177',
'C180',
'C182',
'C185',
'C188',
'C206',
'C207',
'C210',
'C310',
'C337',
'SR20',
'SR22',
'S22T',
'PA18',
'PA24',
'PA28',
'P28A',
'P28B',
'P28R',
'PA32',
'P32R',
'PA34',
'PA38',
'PA44',
'PA46',
'DA20',
'DA40',
'DA42',
'DA62',
'BE33',
'BE35',
'BE36',
'BE58',
'BE76',
'BE19',
'BE23',
'BE24',
'M20P',
'M20T',
'AA1',
'AA5',
'GLAS',
'COL4',
'RV4',
'RV6',
'RV7',
'RV8',
'RV9',
'RV10',
'RV14',
'GA8',
'G115',
'BL8',
'CH7',
]);
// GEV addition (2026-08-15 Hangar fleet): business jets — distinct class so the
// Citation II GLB + a slimmer glyph read apart from airliners.
const BIZJET = new Set([
'C500', 'C501', 'C510', 'C525', 'C25A', 'C25B', 'C25C', 'C25M', 'C550',
'C551', 'C560', 'C56X', 'C650', 'C680', 'C68A', 'C700', 'C750',
'CL30', 'CL35', 'CL60', 'GLF2', 'GLF3', 'GLF4', 'GLF5', 'GLF6', 'GA5C',
'GA6C', 'G150', 'G280', 'GL5T', 'GL7T', 'GLEX',
'LJ23', 'LJ24', 'LJ25', 'LJ31', 'LJ35', 'LJ40', 'LJ45', 'LJ55', 'LJ60',
'LJ70', 'LJ75', 'FA10', 'FA20', 'FA50', 'FA7X', 'FA8X', 'F900', 'F2TH',
'H25A', 'H25B', 'H25C', 'HDJT', 'E50P', 'E55P', 'E545', 'E550', 'PC24',
'PRM1', 'BE40', 'ASTR', 'WW24',
'C500',
'C501',
'C510',
'C525',
'C25A',
'C25B',
'C25C',
'C25M',
'C550',
'C551',
'C560',
'C56X',
'C650',
'C680',
'C68A',
'C700',
'C750',
'CL30',
'CL35',
'CL60',
'GLF2',
'GLF3',
'GLF4',
'GLF5',
'GLF6',
'GA5C',
'GA6C',
'G150',
'G280',
'GL5T',
'GL7T',
'GLEX',
'LJ23',
'LJ24',
'LJ25',
'LJ31',
'LJ35',
'LJ40',
'LJ45',
'LJ55',
'LJ60',
'LJ70',
'LJ75',
'FA10',
'FA20',
'FA50',
'FA7X',
'FA8X',
'F900',
'F2TH',
'H25A',
'H25B',
'H25C',
'HDJT',
'E50P',
'E55P',
'E545',
'E550',
'PC24',
'PRM1',
'BE40',
'ASTR',
'WW24',
'SF50', // Cirrus Vision Jet — a JET (was mis-set in LIGHT before the bizjet class existed)
]);
// GEV addition (2026-08-15 Hangar fleet): large UAVs — ICAO designators seen on
// ADS-B/adsb.lol for Predator/Reaper/Global Hawk-class airframes.
const UAV = new Set([
'Q1', 'Q4', 'Q9', 'MQ1', 'MQ4', 'MQ9', 'RQ4', 'TB2', 'SHDW', 'HERN',
'Q1',
'Q4',
'Q9',
'MQ1',
'MQ4',
'MQ9',
'RQ4',
'TB2',
'SHDW',
'HERN',
]);
// GEV addition: fast jets, for the military layer's adsb.lol `t` codes.
const FASTJET = new Set([
'F16', 'F15', 'F18', 'FA18', 'F14', 'F22', 'F35', 'F4', 'F5', 'A10', 'AV8B',
'TYPH', 'EUFI', 'RFAL', 'RAFL', 'GRIP', 'JAS39', 'TOR', 'MIR2', 'M2000',
'SU27', 'SU30', 'SU33', 'SU34', 'SU35', 'SU57', 'MG29', 'MIG29', 'MG31', 'J20',
'T38', 'HAWK', 'L39', 'M346', 'T7A',
'F16',
'F15',
'F18',
'FA18',
'F14',
'F22',
'F35',
'F4',
'F5',
'A10',
'AV8B',
'TYPH',
'EUFI',
'RFAL',
'RAFL',
'GRIP',
'JAS39',
'TOR',
'MIR2',
'M2000',
'SU27',
'SU30',
'SU33',
'SU34',
'SU35',
'SU57',
'MG29',
'MIG29',
'MG31',
'J20',
'T38',
'HAWK',
'L39',
'M346',
'T7A',
]);
/** OpenSky extended-states integer category → class. */
const OPENSKY_CATEGORY = {
2: 'light', // Light (< 15 500 lbs)
3: 'airliner', // Small
4: 'airliner', // Large
5: 'airliner', // High-vortex large (B757)
6: 'widebody', // Heavy
7: 'fastjet', // High performance (>5g, >400 kt)
2: 'light', // Light (< 15 500 lbs)
3: 'airliner', // Small
4: 'airliner', // Large
5: 'airliner', // High-vortex large (B757)
6: 'widebody', // Heavy
7: 'fastjet', // High performance (>5g, >400 kt)
8: 'helicopter', // Rotorcraft
9: 'glider',
};
/** ADS-B emitter-category string → class. */
const EMITTER_CATEGORY = {
A1: 'light', A2: 'light', A3: 'airliner', A4: 'airliner',
A5: 'widebody', A6: 'fastjet', A7: 'helicopter', B1: 'glider',
A1: 'light',
A2: 'light',
A3: 'airliner',
A4: 'airliner',
A5: 'widebody',
A6: 'fastjet',
A7: 'helicopter',
B1: 'glider',
};
export function classifyAircraft({ typeCode, category } = {}) {
const code = String(typeCode || '').trim().toUpperCase();
const code = String(typeCode || '')
.trim()
.toUpperCase();
if (code) {
if (FASTJET.has(code)) return 'fastjet';
if (UAV.has(code)) return 'uav';
@@ -104,37 +429,59 @@ export function classifyAircraft({ typeCode, category } = {}) {
if (LIGHT.has(code)) return 'light';
return 'airliner'; // known type code, not in a special set → default jet
}
if (Number.isFinite(category) && OPENSKY_CATEGORY[category]) return OPENSKY_CATEGORY[category];
const cat = String(category || '').trim().toUpperCase();
if (Number.isFinite(category) && OPENSKY_CATEGORY[category])
return OPENSKY_CATEGORY[category];
const cat = String(category || '')
.trim()
.toUpperCase();
if (EMITTER_CATEGORY[cat]) return EMITTER_CATEGORY[cat];
return 'airliner';
}
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
export const CLASS_SCALE_2D = {
light: 0.62, glider: 0.58, turboprop: 0.86, airliner: 1.0,
widebody: 1.3, quadjet: 1.45, helicopter: 0.82, fastjet: 0.8,
bizjet: 0.72, uav: 0.75,
light: 0.62,
glider: 0.58,
turboprop: 0.86,
airliner: 1.0,
widebody: 1.3,
quadjet: 1.45,
helicopter: 0.82,
fastjet: 0.8,
bizjet: 0.72,
uav: 0.75,
};
/** 3D model scale multipliers — clamped [0.75, 1.45] while every class shares
* the single airplane.glb (a jet mesh at C172 scale reads wrong). Widen when
* real per-class models land in CLASS_MODEL_URL. */
export const CLASS_SCALE_3D = {
light: 0.75, glider: 0.75, turboprop: 0.85, airliner: 1.0,
widebody: 1.3, quadjet: 1.45, helicopter: 0.8, fastjet: 0.8,
bizjet: 0.8, uav: 0.8,
light: 0.75,
glider: 0.75,
turboprop: 0.85,
airliner: 1.0,
widebody: 1.3,
quadjet: 1.45,
helicopter: 0.8,
fastjet: 0.8,
bizjet: 0.8,
uav: 0.8,
};
/** Per-class glTF — all the shared airplane today; drop real assets in here.
* NOTE for future models: each asset may need its own heading offset (the
* shared GLB uses MODEL_HEADING_OFFSET_DEG = 180 in both layers). */
export const CLASS_MODEL_URL = {
light: '/models/airplane.glb', glider: '/models/airplane.glb',
turboprop: '/models/airplane.glb', airliner: '/models/airplane.glb',
widebody: '/models/airplane.glb', quadjet: '/models/airplane.glb',
helicopter: '/models/airplane.glb', fastjet: '/models/airplane.glb',
bizjet: '/models/airplane.glb', uav: '/models/airplane.glb',
light: '/models/airplane.glb',
glider: '/models/airplane.glb',
turboprop: '/models/airplane.glb',
airliner: '/models/airplane.glb',
widebody: '/models/airplane.glb',
quadjet: '/models/airplane.glb',
helicopter: '/models/airplane.glb',
fastjet: '/models/airplane.glb',
bizjet: '/models/airplane.glb',
uav: '/models/airplane.glb',
};
/** Real per-class GLBs (2026-08-15 Hangar fleet, owner picks; CC-BY 4.0 —
@@ -148,10 +495,10 @@ export const CLASS_MODEL_URL = {
* pinned by modelScale.test.mjs. Classes NOT listed (airliner, quadjet,
* glider, fastjet) still render the shared airplane.glb via CLASS_MODEL_URL. */
export const CLASS_MODEL_REAL = {
helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 },
light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 },
bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 },
uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 },
widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 },
turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 },
helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 },
light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 },
bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 },
uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 },
widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 },
turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 },
};
+7 -3
View File
@@ -35,9 +35,11 @@ const VIEW = 96;
const C = VIEW / 2; // 48 — glyph centre
// Hairline dark edge (scaled for the 96 box: ~1.5u ≈ the old 0.5u at 32).
const STROKE = 'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"';
const STROKE =
'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"';
// Heavier edge for the chart-symbol ("bold") glyphs adopted 2026-08-15.
const STROKE_BOLD = 'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"';
const STROKE_BOLD =
'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"';
// Softer white for translucent detail (prop discs, rotor disc) — still white so
// the tint multiplies cleanly; only the alpha differs.
const DISC = 'fill="white" fill-opacity="0.5"';
@@ -239,7 +241,9 @@ const BODIES = {
const _iconCache = new Map();
const _b64 = (s) =>
typeof btoa === 'function' ? btoa(s) : Buffer.from(s, 'utf8').toString('base64');
typeof btoa === 'function'
? btoa(s)
: Buffer.from(s, 'utf8').toString('base64');
/** Fleet raster: billboards render at ~40–58 DEVICE px (width 20–24 CSS ×
* Retina × class scale). Cesium's billboard atlas has no mipmaps, so a big
+3 -1
View File
@@ -11,5 +11,7 @@ export function aircraftIncludedInNearby({
modelRendering = false,
includeHidden = false,
} = {}) {
return Boolean(includeHidden || isTracked || billboardShown || modelRendering);
return Boolean(
includeHidden || isTracked || billboardShown || modelRendering,
);
}
+50 -19
View File
@@ -10,7 +10,7 @@ export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
startLimbRatio: 0.5,
scaleFloor: 0.45,
alphaFloor: 0.35,
combinedAlphaFloor: 0.20,
combinedAlphaFloor: 0.2,
globeViewBlendStartM: 3_500_000,
globeViewBlendEndM: 4_500_000,
earthRadiusM: 6_378_137,
@@ -36,10 +36,19 @@ function resolvedParams(overrides) {
return { ..._params, ...overrides };
}
function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning, result) {
if (!Number.isFinite(cameraDistanceM) || cameraDistanceM < 0
|| !Number.isFinite(cameraHeightM) || cameraHeightM <= 0
|| cameraHeightM >= tuning.globeViewBlendEndM) {
function aircraftRecessionFactorsResolved(
cameraDistanceM,
cameraHeightM,
tuning,
result,
) {
if (
!Number.isFinite(cameraDistanceM) ||
cameraDistanceM < 0 ||
!Number.isFinite(cameraHeightM) ||
cameraHeightM <= 0 ||
cameraHeightM >= tuning.globeViewBlendEndM
) {
result.scale = 1;
result.alpha = 1;
result.limbRatio = null;
@@ -60,11 +69,13 @@ function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning
return result;
}
const rawT = (limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio);
const rawT =
(limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio);
const t = clamp(rawT, 0, 1);
const limbEase = t * t * (3 - 2 * t); // smoothstep: zero slope at both band edges
const globeRawT = (cameraHeightM - tuning.globeViewBlendStartM)
/ (tuning.globeViewBlendEndM - tuning.globeViewBlendStartM);
const globeRawT =
(cameraHeightM - tuning.globeViewBlendStartM) /
(tuning.globeViewBlendEndM - tuning.globeViewBlendStartM);
const globeT = clamp(globeRawT, 0, 1);
const globeEase = globeT * globeT * (3 - 2 * globeT);
const strength = 1 - globeEase;
@@ -84,8 +95,10 @@ export function setAircraftRecessionParams(patch = {}) {
if (Number.isFinite(patch.startLimbRatio)) {
next.startLimbRatio = clamp(patch.startLimbRatio, 0, 0.99);
}
if (Number.isFinite(patch.scaleFloor)) next.scaleFloor = clamp(patch.scaleFloor, 0.05, 1);
if (Number.isFinite(patch.alphaFloor)) next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1);
if (Number.isFinite(patch.scaleFloor))
next.scaleFloor = clamp(patch.scaleFloor, 0.05, 1);
if (Number.isFinite(patch.alphaFloor))
next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1);
if (Number.isFinite(patch.combinedAlphaFloor)) {
next.combinedAlphaFloor = clamp(patch.combinedAlphaFloor, 0.05, 1);
}
@@ -109,8 +122,10 @@ export function setAircraftRecessionParams(patch = {}) {
if (next.globeViewBlendStartM >= next.globeViewBlendEndM) {
next.globeViewBlendStartM = Math.max(1, next.globeViewBlendEndM - 1);
}
if (Number.isFinite(patch.earthRadiusM)) next.earthRadiusM = Math.max(1, patch.earthRadiusM);
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon);
if (Number.isFinite(patch.earthRadiusM))
next.earthRadiusM = Math.max(1, patch.earthRadiusM);
if (Number.isFinite(patch.writeEpsilon))
next.writeEpsilon = Math.max(0, patch.writeEpsilon);
_params = next;
return { ..._params };
}
@@ -149,7 +164,10 @@ export function cameraLimbDistanceM(
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [params]
* @returns {{scale:number,alpha:number,limbRatio:number|null}}
*/
export function aircraftRecessionFactors({ cameraDistanceM, cameraHeightM }, params) {
export function aircraftRecessionFactors(
{ cameraDistanceM, cameraHeightM },
params,
) {
const tuning = resolvedParams(params);
return aircraftRecessionFactorsResolved(
cameraDistanceM,
@@ -202,12 +220,17 @@ export function applyAircraftBillboardTreatment({
const alpha = baseAlpha * composedTreatment;
let scaleWrites = 0;
let alphaWrites = 0;
if (!Number.isFinite(billboard.scale) || Math.abs(billboard.scale - scale) > tuning.writeEpsilon) {
if (
!Number.isFinite(billboard.scale) ||
Math.abs(billboard.scale - scale) > tuning.writeEpsilon
) {
billboard.scale = scale;
scaleWrites = 1;
}
if (!Number.isFinite(billboard.color?.alpha)
|| Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon) {
if (
!Number.isFinite(billboard.color?.alpha) ||
Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon
) {
billboard.color = baseColor.withAlpha(alpha);
alphaWrites = 1;
}
@@ -228,11 +251,19 @@ export function applyAircraftBillboardTreatment({
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
* @returns {number} One when a color write occurred, otherwise zero.
*/
export function applyAircraftModelTreatment({ model, baseColor, alpha, params }) {
export function applyAircraftModelTreatment({
model,
baseColor,
alpha,
params,
}) {
const tuning = resolvedParams(params);
if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
if (Number.isFinite(model.color?.alpha)
&& Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon) return 0;
if (
Number.isFinite(model.color?.alpha) &&
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon
)
return 0;
model.color = baseColor.withAlpha(alpha);
return 1;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationAlpr } from '../app/layers/alprCameras.js';
import { createSourceSlot } from '../app/sourceSlot.js';
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createOverpassAlprSource } from '../layers/alpr/index.js';
export * from '../layers/alpr/index.js';
const slot = createSourceSlot(
+110 -40
View File
@@ -28,11 +28,39 @@ import { pointInRing } from './naturalEarthRegions.js';
/** Layers the engine understands, with the fields queries may reference. */
export const ANALYST_LAYERS = {
flights: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass'], flags: ['military', 'onGround'] },
military: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'], flags: ['military', 'onGround'] },
'ais-live-vessels': { numeric: ['speedKts', 'courseDeg'], text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'], flags: [] },
'local-firms': { numeric: ['frp'], text: ['confidence', 'satellite'], flags: [] },
earthquakes: { numeric: ['magnitude', 'depthKm'], text: ['place'], flags: [] },
flights: {
numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
text: [
'callsign',
'icao24',
'originCountry',
'operator',
'routeOrigin',
'routeDestination',
'aircraftClass',
],
flags: ['military', 'onGround'],
},
military: {
numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'],
flags: ['military', 'onGround'],
},
'ais-live-vessels': {
numeric: ['speedKts', 'courseDeg'],
text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'],
flags: [],
},
'local-firms': {
numeric: ['frp'],
text: ['confidence', 'satellite'],
flags: [],
},
earthquakes: {
numeric: ['magnitude', 'depthKm'],
text: ['place'],
flags: [],
},
};
const EARTH_R_KM = 6371;
@@ -42,8 +70,9 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
const d2r = Math.PI / 180;
const dLat = (lat2 - lat1) * d2r;
const dLon = (lon2 - lon1) * d2r;
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
}
@@ -55,17 +84,25 @@ export function applyFilter(records, filter) {
const got = r[field];
if (got === null || got === undefined) return false;
switch (op) {
case 'gt': return Number(got) > Number(value);
case 'gte': return Number(got) >= Number(value);
case 'lt': return Number(got) < Number(value);
case 'lte': return Number(got) <= Number(value);
case 'gt':
return Number(got) > Number(value);
case 'gte':
return Number(got) >= Number(value);
case 'lt':
return Number(got) < Number(value);
case 'lte':
return Number(got) <= Number(value);
case 'eq': {
if (typeof got === 'boolean' || typeof value === 'boolean') return Boolean(got) === Boolean(value);
if (typeof got === 'boolean' || typeof value === 'boolean')
return Boolean(got) === Boolean(value);
return String(got).toLowerCase() === String(value).toLowerCase();
}
case 'neq': return String(got).toLowerCase() !== String(value).toLowerCase();
case 'contains': return String(got).toLowerCase().includes(String(value).toLowerCase());
default: return true;
case 'neq':
return String(got).toLowerCase() !== String(value).toLowerCase();
case 'contains':
return String(got).toLowerCase().includes(String(value).toLowerCase());
default:
return true;
}
});
}
@@ -74,15 +111,23 @@ export function applyFilter(records, filter) {
export function applyScope(records, scope, resolved) {
if (!scope || scope.kind === 'anywhere') return records;
if (scope.kind === 'region' && resolved?.ring) {
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon)
&& pointInRing(resolved.ring, r.lat, r.lon));
return records.filter(
(r) =>
Number.isFinite(r.lat) &&
Number.isFinite(r.lon) &&
pointInRing(resolved.ring, r.lat, r.lon),
);
}
if (scope.kind === 'radius' || scope.kind === 'view') {
const c = resolved?.center;
const km = resolved?.km;
if (!c || !Number.isFinite(km)) return records;
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon)
&& haversineKm(c.lat, c.lon, r.lat, r.lon) <= km);
return records.filter(
(r) =>
Number.isFinite(r.lat) &&
Number.isFinite(r.lon) &&
haversineKm(c.lat, c.lon, r.lat, r.lon) <= km,
);
}
return records;
}
@@ -108,9 +153,12 @@ export function createAnalystEngine(providers) {
let lastResult = null;
async function query(spec = {}) {
const layers = (spec.followUp && lastResult)
? null // follow-up: re-filter the remembered set, no re-snapshot
: (Array.isArray(spec.layers) && spec.layers.length ? spec.layers : ['flights']);
const layers =
spec.followUp && lastResult
? null // follow-up: re-filter the remembered set, no re-snapshot
: Array.isArray(spec.layers) && spec.layers.length
? spec.layers
: ['flights'];
// 1) Source records
let records;
@@ -164,20 +212,23 @@ export function createAnalystEngine(providers) {
// camera made the two disagree — a parked, high-altitude camera answered
// "46 within 250 km" while the panel showed a far larger contact-centred
// count. Same question, two numbers.
const explicitCenter = scope.center && Number.isFinite(scope.center.lat)
? scope.center
: null;
const subject = explicitCenter ? null : providers.getContextSubject?.() || null;
const explicitCenter =
scope.center && Number.isFinite(scope.center.lat) ? scope.center : null;
const subject = explicitCenter
? null
: providers.getContextSubject?.() || null;
const view = providers.getViewContext();
// Only a subject that actually SUPPLIED the centre may name it. A
// subject present but without usable coordinates silently fell back to
// the camera while the label still read "within 250 km of <contact>" —
// a count centred on one place, reported as centred on another, with
// nothing in the payload to show which.
const subjectCenter = Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
? { lat: subject.lat, lon: subject.lon }
: null;
const center = explicitCenter || subjectCenter || { lat: view.lat, lon: view.lon };
const subjectCenter =
Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
? { lat: subject.lat, lon: subject.lon }
: null;
const center = explicitCenter ||
subjectCenter || { lat: view.lat, lon: view.lon };
resolvedScope = { center, km: Number(scope.km) || 100 };
if (subjectCenter) resolvedScope.centeredOn = subject.label || null;
scopeNote = resolvedScope.centeredOn
@@ -188,7 +239,10 @@ export function createAnalystEngine(providers) {
: `within ${resolvedScope.km} km`;
} else if (scope.kind === 'view') {
const view = providers.getViewContext();
resolvedScope = { center: { lat: view.lat, lon: view.lon }, km: view.viewRadiusKm };
resolvedScope = {
center: { lat: view.lat, lon: view.lon },
km: view.viewRadiusKm,
};
scopeNote = `view:${Math.round(view.viewRadiusKm)}km`;
scopeLabel = 'in view';
} else {
@@ -205,15 +259,22 @@ export function createAnalystEngine(providers) {
if (sortBy === 'distance') {
const ref = resolvedScope?.center || providers.getViewContext();
for (const it of items) {
it.distanceKm = (Number.isFinite(it.lat) && Number.isFinite(it.lon))
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) / 10 : null;
it.distanceKm =
Number.isFinite(it.lat) && Number.isFinite(it.lon)
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) /
10
: null;
}
}
if (sortBy) {
const dir = spec.sortDir === 'asc' ? 1 : -1;
items.sort((a, b) => (Number(a[sortBy]) - Number(b[sortBy])) * dir
|| String(a.id).localeCompare(String(b.id)));
if (sortBy === 'distance') items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9));
items.sort(
(a, b) =>
(Number(a[sortBy]) - Number(b[sortBy])) * dir ||
String(a.id).localeCompare(String(b.id)),
);
if (sortBy === 'distance')
items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9));
}
const limit = Math.max(1, Math.min(50, Number(spec.limit) || 10));
const top = items.slice(0, limit);
@@ -223,7 +284,10 @@ export function createAnalystEngine(providers) {
count: items.length,
items: top,
truncated: items.length > top.length,
summary: summarize(items, sortBy && sortBy !== 'distance' ? sortBy : null),
summary: summarize(
items,
sortBy && sortBy !== 'distance' ? sortBy : null,
),
scopeLabel,
coverage: {
layersQueried,
@@ -233,7 +297,9 @@ export function createAnalystEngine(providers) {
},
// Surfaced so the narration can name the centre it measured from rather
// than implying a view-centred answer.
...(resolvedScope?.centeredOn ? { centeredOn: resolvedScope.centeredOn } : {}),
...(resolvedScope?.centeredOn
? { centeredOn: resolvedScope.centeredOn }
: {}),
};
lastResult = { items, coverage: result.coverage };
return result;
@@ -241,7 +307,11 @@ export function createAnalystEngine(providers) {
return {
query,
reset() { lastResult = null; },
hasMemory() { return Boolean(lastResult); },
reset() {
lastResult = null;
},
hasMemory() {
return Boolean(lastResult);
},
};
}
File diff suppressed because it is too large. Load diff
+345
View File
@@ -0,0 +1,345 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import {
createBhoteKoshiEmbeddedMedia,
createTrimmedYouTubePlayback,
embeddedMediaFrameUrl,
publicEmbeddedMediaUrl,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
function trimmedPlaybackFixture() {
let now = 0;
let nextId = 0;
const timers = new Map();
const globalRef = {
setTimeout(fn, delay) { const id = ++nextId; timers.set(id, { at: now + delay, fn }); return id; },
clearTimeout(id) { timers.delete(id); },
};
const advance = (ms) => {
const until = now + ms;
while (true) {
const entry = [...timers].filter(([, item]) => item.at <= until).sort((a, b) => a[1].at - b[1].at)[0];
if (!entry) break;
const [id, item] = entry; timers.delete(id); now = item.at; item.fn();
}
now = until;
};
let events;
const calls = [];
let time = 0;
let state = -1;
const player = {
mute: () => calls.push('mute'), playVideo: () => calls.push('play'),
pauseVideo: () => calls.push('pause'), destroy: () => calls.push('destroy'),
getCurrentTime: () => time,
getPlayerState: () => state,
};
const api = { Player: class { constructor(_iframe, options) { events = options.events; return player; } } };
const statuses = [];
const session = createTrimmedYouTubePlayback({ iframe: {}, endAtSec: 7, globalRef,
loadApi: () => api, onStatus: (phase) => statuses.push(phase) });
return { session, advance, calls, timers, statuses, player, get events() { return events; }, setTime(value) { time = value; }, setState(value) { state = value; } };
}
test('trimmed YouTube reconciles already-playing READY and missed PLAYING callbacks', async () => {
for (const alreadyPlaying of [true, false]) {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
if (alreadyPlaying) { f.setState(1); f.setTime(1); }
f.events.onReady({ target: f.player });
if (!alreadyPlaying) { f.setState(1); f.setTime(1); f.advance(100); }
f.setTime(6); f.advance(5000);
assert.deepEqual(f.session.getState(), { phase: 'playing', currentTime: 6 });
f.setTime(7); f.advance(100);
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
assert.deepEqual(f.statuses, ['completed']);
assert.equal(f.timers.size, 0);
f.session.destroy();
}
});
test('trimmed YouTube accepts ENDED before PLAYING and never replays a completed READY', async () => {
for (const endedCallback of [true, false]) {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
f.setState(0); f.setTime(7);
if (endedCallback) f.events.onStateChange({ data: 0, target: f.player });
f.events.onReady({ target: f.player });
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
assert.deepEqual(f.calls, ['pause']);
assert.deepEqual(f.statuses, ['completed']);
assert.equal(f.timers.size, 0);
f.session.destroy();
}
});
test('trimmed YouTube waits through delayed startup, stops at source 0:07, and releases its timers', async () => {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
f.advance(3000);
assert.equal(f.session.getState().phase, 'starting');
f.events.onReady({ target: f.player });
f.events.onStateChange({ data: 1 });
f.setTime(4); f.advance(4000);
assert.equal(f.session.getState().phase, 'playing', 'seven wall-clock seconds do not truncate a delayed clip');
f.setTime(7); f.advance(100);
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
assert.deepEqual(f.statuses, ['completed']);
assert.equal(f.timers.size, 0);
assert.equal(f.calls.at(-1), 'pause');
f.session.destroy();
});
test('trimmed YouTube bounds blocked, failed, stalled and never-started playback', async () => {
for (const outcome of ['blocked', 'unavailable', 'timeout', 'stalled']) {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
if (outcome === 'blocked') f.events.onAutoplayBlocked();
else if (outcome === 'unavailable') f.events.onError();
else if (outcome === 'stalled') { f.events.onStateChange({ data: 1 }); f.advance(12000); }
else f.advance(5000);
assert.equal(f.session.getState().phase, outcome === 'stalled' ? 'timeout' : outcome);
assert.equal(f.timers.size, 0);
const calls = [...f.calls];
f.events.onReady({ target: f.player });
f.events.onStateChange({ data: 1 });
assert.deepEqual(f.calls, calls, 'late provider callbacks cannot restart a terminal attempt');
f.session.destroy();
}
});
test('trimmed YouTube teardown revokes pending SDK and player callbacks', async () => {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
f.session.destroy();
f.events.onReady({ target: f.player });
f.events.onStateChange({ data: 1 });
f.events.onError(); f.advance(20000);
assert.deepEqual(f.calls, ['destroy']);
assert.deepEqual(f.statuses, []);
assert.equal(f.timers.size, 0);
let constructCount = 0;
let resolveApi;
const session = createTrimmedYouTubePlayback({ iframe: {}, endAtSec: 7,
loadApi: () => new Promise((resolve) => { resolveApi = resolve; }) });
await Promise.resolve();
session.destroy();
resolveApi({ Player: class { constructor() { constructCount++; } } });
await Promise.resolve(); await Promise.resolve();
assert.equal(constructCount, 0);
});
test('pausing YouTube removes its frame and rejects a late load from the stopped generation', () => {
const nodes = [];
class Element extends EventTarget {
constructor(tag) { super(); this.tag = tag; this.children = []; this.dataset = {}; this.classList = { add() {}, remove() {} }; nodes.push(this); }
setAttribute() {}
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
remove() { if (this.parent) this.parent.children = this.parent.children.filter((child) => child !== this); this.parent = null; }
}
const documentRef = {
body: new Element('body'),
createElement: (tag) => new Element(tag),
createElementNS: (_, tag) => new Element(tag),
};
const media = createBhoteKoshiEmbeddedMedia({ viewer: {}, documentRef, globalRef: {} });
const options = { observation: { title: 'Clip', media: { sourceUrl: 'https://youtu.be/DbqRexFxv3k' } }, anchor: {}, autoplay: true };
assert.equal(media.show(options), true);
const frame = nodes.find((node) => node.tag === 'iframe');
const card = nodes.find((node) => node.tag === 'section');
assert.ok(card.parent);
assert.equal(media.pause(), true);
assert.equal(card.parent, null);
frame.dispatchEvent(new Event('load'));
assert.equal(card.parent, null);
assert.equal(media.show(options), true, 'an explicit replay creates a fresh player');
media.destroy();
assert.equal(documentRef.body.children.length, 0);
});
test('resolves supported embedded callout providers from canonical public URLs', () => {
assert.deepEqual(
resolveEmbeddedMediaSource('https://www.youtube.com/watch?v=DbqRexFxv3k'),
{
provider: 'youtube',
id: 'DbqRexFxv3k',
url: 'https://www.youtube.com/watch?v=DbqRexFxv3k',
},
);
assert.equal(
resolveEmbeddedMediaSource('https://www.facebook.com/reel/1571491657757829').provider,
'facebook',
);
assert.deepEqual(
resolveEmbeddedMediaSource('https://x.com/Indowatchosint/status/2092640554568007958'),
{
provider: 'x',
id: '2092640554568007958',
url: 'https://x.com/Indowatchosint/status/2092640554568007958',
},
);
assert.equal(
resolveEmbeddedMediaSource('https://twitter.com/Indowatchosint/status/2092640554568007958').provider,
'x',
);
assert.equal(
resolveEmbeddedMediaSource('https://www.youtube.com/shorts/DbqRexFxv3k').id,
'DbqRexFxv3k',
);
});
test('rejects deceptive hosts, non-post X links, private targets, and iframe markup', () => {
for (const value of [
'https://x.com.evil.example/user/status/2092640554568007958',
'https://x.com/Indowatchosint',
'https://youtube.com.evil.example/watch?v=DbqRexFxv3k',
'http://www.youtube.com/watch?v=DbqRexFxv3k',
'https://localhost/video.mp4',
'<iframe src="https://x.com/user/status/2092640554568007958"></iframe>',
]) {
assert.equal(resolveEmbeddedMediaSource(value), null, value);
}
assert.equal(publicEmbeddedMediaUrl('https://user:[email protected]/name/status/123'), null);
});
test('builds privacy-aware provider frames and keeps X on its widget runtime', () => {
const youtube = resolveEmbeddedMediaSource('https://youtu.be/DbqRexFxv3k');
const youtubeFrame = new URL(embeddedMediaFrameUrl(youtube, { autoplay: true }));
assert.equal(youtubeFrame.hostname, 'www.youtube-nocookie.com');
assert.equal(youtubeFrame.pathname, '/embed/DbqRexFxv3k');
assert.equal(youtubeFrame.searchParams.get('autoplay'), '1');
assert.equal(youtubeFrame.searchParams.get('mute'), '1');
const timestampedFrame = new URL(embeddedMediaFrameUrl(youtube, {
autoplay: true,
startAtSec: 26,
}));
assert.equal(timestampedFrame.searchParams.get('start'), '26');
const facebook = resolveEmbeddedMediaSource('https://www.facebook.com/watch/?v=456');
const facebookFrame = new URL(embeddedMediaFrameUrl(facebook, { autoplay: false }));
assert.equal(facebookFrame.hostname, 'www.facebook.com');
assert.equal(facebookFrame.pathname, '/plugins/video.php');
assert.equal(facebookFrame.searchParams.get('href'), facebook.url);
assert.equal(facebookFrame.searchParams.get('autoplay'), 'false');
const x = resolveEmbeddedMediaSource('https://x.com/Indowatchosint/status/2092640554568007958');
assert.equal(embeddedMediaFrameUrl(x), null);
});
test('Mailung playback stops at source timestamp 0:07 without changing other clips', async () => {
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
const bounded = event.evidenceSpine.filter(point => point.media?.embedEndAtSec != null);
assert.deepEqual(bounded.map(point => point.id), ['mailung-bazzar']);
const media = bounded[0].media;
assert.equal(media.embedEndAtSec, 7);
const source = resolveEmbeddedMediaSource(media.sourceUrl);
for (const autoplay of [false, true]) {
const url = new URL(embeddedMediaFrameUrl(source, {
autoplay, startAtSec: media.clipInSec, endAtSec: media.embedEndAtSec,
}));
assert.equal(url.searchParams.get('end'), '7');
}
for (const endAtSec of [undefined, null, NaN, Infinity, -1, 0, 3]) {
const url = new URL(embeddedMediaFrameUrl(source, {startAtSec: 3, endAtSec}));
assert.equal(url.searchParams.has('end'), false);
}
});
function preloadFixture(options = {}) {
class Element extends EventTarget {
constructor(tag) {
super(); this.tag = tag; this.children = []; this.dataset = {};
this.classList = { add() {}, remove() {}, contains() { return false; } };
}
setAttribute() {}
removeAttribute() {}
append(...children) {
for (const child of children) { child.remove(); child.parent = this; this.children.push(child); }
}
remove() {
if (this.parent) this.parent.children = this.parent.children.filter(child => child !== this);
this.parent = null;
}
}
const documentRef = {
body: new Element('body'),
createElement: tag => new Element(tag),
createElementNS: (_, tag) => new Element(tag),
};
const media = createBhoteKoshiEmbeddedMedia({ viewer: {}, documentRef, globalRef: {}, ...options });
return { media, root: documentRef.body.children[0] };
}
for (const operation of ['pause', 'hide', 'destroy']) {
test(`${operation} removes a hidden YouTube preload`, () => {
const { media, root } = preloadFixture();
media.warm({ sourceUrl: 'https://youtu.be/DbqRexFxv3k' });
assert.equal(root.children.length, 1);
media[operation]();
assert.equal(root.children.length, 0);
media.destroy();
});
}
test('camera updates retain a preload until the visible player loads', () => {
const { media, root } = preloadFixture();
const observation = { media: { sourceUrl: 'https://youtu.be/DbqRexFxv3k' } };
media.warm({ observation });
const frame = root.children[0];
media.hide({ preserveWarm: true });
media.warm({ observation });
assert.equal(root.children[0], frame);
assert.equal(media.show({ observation, anchor: {} }), true);
assert.equal(frame.parent, root, 'keep the preload while the visible player starts');
const card = root.children.find(node => node.tag === 'section');
const body = card.children.find(node => node.className === 'bhote-embedded-callout-body');
const player = body.children.find(node => node.className === 'bhote-embedded-callout-player');
player.children.find(node => node.tag === 'iframe').dispatchEvent(new Event('load'));
assert.equal(frame.parent, null, 'visible player readiness releases the preload');
media.destroy();
assert.equal(root.children.length, 0);
});
test('hiding revokes a pending Facebook SDK before it can mount or subscribe', async () => {
let resolveApi;
const calls = [];
const { media, root } = preloadFixture({
facebookLoader: () => new Promise(resolve => { resolveApi = resolve; }),
});
media.warm({ sourceUrl: 'https://www.facebook.com/reel/1571491657757829' });
assert.equal(root.children.length, 1);
media.hide();
assert.equal(root.children.length, 0);
resolveApi({ Event: { subscribe() { calls.push('subscribe'); } }, XFBML: { parse() { calls.push('parse'); } } });
await Promise.resolve(); await Promise.resolve();
assert.deepEqual(calls, []);
media.destroy();
});
test('pausing cancels a mounted Facebook preload and rejects its late ready callback', async () => {
let ready;
let unsubscribed = 0;
const timers = new Set();
const playerCalls = [];
const { media, root } = preloadFixture({
globalRef: { setTimeout(fn) { timers.add(fn); return fn; }, clearTimeout(fn) { timers.delete(fn); } },
facebookLoader: async () => ({
Event: { subscribe(_name, callback) { ready = callback; }, unsubscribe() { unsubscribed++; } },
XFBML: { parse() {} },
}),
});
media.warm({ sourceUrl: 'https://www.facebook.com/reel/1571491657757829' });
await Promise.resolve(); await Promise.resolve();
assert.equal(timers.size, 1);
media.pause();
assert.equal(root.children.length, 0);
assert.equal(timers.size, 0);
assert.equal(unsubscribed, 1);
ready({ type: 'video', instance: { play() { playerCalls.push('play'); }, pause() { playerCalls.push('pause'); } } });
assert.deepEqual(playerCalls, []);
media.destroy();
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+499
View File
@@ -0,0 +1,499 @@
/**
* Simplified GeoPera river centerline from the point nearest the owner-provided
* Debris-Dammed Lake observation through Trishuli Bazaar. Every fourth published
* vertex is retained, plus the exact endpoint nearest the verified bazaar pin.
*
* Source: https://github.com/geo-pera/bhotekoshi-2026-reconstruction/blob/main/vectors/river_centerline.geojson
* License: CC BY-NC 4.0 (derived data; Planet Labs PBC / Vantor open data)
*/
export const BHOTE_KOSHI_FLOOD_PATH = Object.freeze([
Object.freeze([85.48476, 28.33255]),
Object.freeze([85.48353, 28.33308]),
Object.freeze([85.48231, 28.33279]),
Object.freeze([85.48108, 28.33278]),
Object.freeze([85.47985, 28.33304]),
Object.freeze([85.47863, 28.33275]),
Object.freeze([85.47741, 28.33301]),
Object.freeze([85.47618, 28.33299]),
Object.freeze([85.47496, 28.33325]),
Object.freeze([85.47373, 28.33351]),
Object.freeze([85.4725, 28.33377]),
Object.freeze([85.47128, 28.33375]),
Object.freeze([85.47005, 28.33401]),
Object.freeze([85.46881, 28.33481]),
Object.freeze([85.46759, 28.33506]),
Object.freeze([85.46636, 28.33532]),
Object.freeze([85.46513, 28.33585]),
Object.freeze([85.4639, 28.33584]),
Object.freeze([85.46267, 28.33609]),
Object.freeze([85.46145, 28.33635]),
Object.freeze([85.46022, 28.33661]),
Object.freeze([85.45899, 28.33659]),
Object.freeze([85.45777, 28.33685]),
Object.freeze([85.45655, 28.33629]),
Object.freeze([85.45533, 28.33628]),
Object.freeze([85.45411, 28.336]),
Object.freeze([85.45289, 28.33544]),
Object.freeze([85.45167, 28.33543]),
Object.freeze([85.45045, 28.33514]),
Object.freeze([85.44922, 28.33513]),
Object.freeze([85.448, 28.33511]),
Object.freeze([85.44677, 28.33537]),
Object.freeze([85.44554, 28.33563]),
Object.freeze([85.44432, 28.33534]),
Object.freeze([85.4431, 28.33533]),
Object.freeze([85.44188, 28.33504]),
Object.freeze([85.44065, 28.3353]),
Object.freeze([85.43943, 28.33529]),
Object.freeze([85.43821, 28.335]),
Object.freeze([85.43699, 28.33472]),
Object.freeze([85.43577, 28.33416]),
Object.freeze([85.43456, 28.33334]),
Object.freeze([85.43335, 28.33251]),
Object.freeze([85.43214, 28.33141]),
Object.freeze([85.43093, 28.33059]),
Object.freeze([85.42971, 28.33057]),
Object.freeze([85.42848, 28.33056]),
Object.freeze([85.42726, 28.33027]),
Object.freeze([85.42604, 28.33026]),
Object.freeze([85.42482, 28.33025]),
Object.freeze([85.4236, 28.32996]),
Object.freeze([85.42238, 28.32968]),
Object.freeze([85.42116, 28.32939]),
Object.freeze([85.41995, 28.32829]),
Object.freeze([85.41873, 28.32774]),
Object.freeze([85.41752, 28.32718]),
Object.freeze([85.41629, 28.32744]),
Object.freeze([85.41508, 28.32634]),
Object.freeze([85.41387, 28.32524]),
Object.freeze([85.41297, 28.32415]),
Object.freeze([85.41268, 28.32306]),
Object.freeze([85.41239, 28.32198]),
Object.freeze([85.41241, 28.3209]),
Object.freeze([85.41273, 28.31982]),
Object.freeze([85.41275, 28.31873]),
Object.freeze([85.41276, 28.31765]),
Object.freeze([85.41308, 28.31657]),
Object.freeze([85.41249, 28.31548]),
Object.freeze([85.4122, 28.3144]),
Object.freeze([85.41252, 28.31332]),
Object.freeze([85.41254, 28.31223]),
Object.freeze([85.41194, 28.31114]),
Object.freeze([85.41196, 28.31006]),
Object.freeze([85.41167, 28.30897]),
Object.freeze([85.41077, 28.30788]),
Object.freeze([85.40955, 28.30705]),
Object.freeze([85.40865, 28.30596]),
Object.freeze([85.40744, 28.30513]),
Object.freeze([85.40623, 28.30404]),
Object.freeze([85.40503, 28.30294]),
Object.freeze([85.40381, 28.30238]),
Object.freeze([85.4026, 28.30156]),
Object.freeze([85.40139, 28.30046]),
Object.freeze([85.40019, 28.29936]),
Object.freeze([85.39898, 28.29827]),
Object.freeze([85.39777, 28.29717]),
Object.freeze([85.39748, 28.29608]),
Object.freeze([85.39658, 28.29499]),
Object.freeze([85.39629, 28.2939]),
Object.freeze([85.3957, 28.29281]),
Object.freeze([85.39449, 28.29172]),
Object.freeze([85.39328, 28.29062]),
Object.freeze([85.39238, 28.28953]),
Object.freeze([85.39117, 28.28897]),
Object.freeze([85.38996, 28.28787]),
Object.freeze([85.38875, 28.28678]),
Object.freeze([85.38754, 28.28622]),
Object.freeze([85.38632, 28.28593]),
Object.freeze([85.38511, 28.28511]),
Object.freeze([85.3839, 28.28401]),
Object.freeze([85.383, 28.28292]),
Object.freeze([85.38179, 28.28182]),
Object.freeze([85.38089, 28.28073]),
Object.freeze([85.37968, 28.28017]),
Object.freeze([85.37846, 28.27934]),
Object.freeze([85.37725, 28.27852]),
Object.freeze([85.37666, 28.27743]),
Object.freeze([85.37668, 28.27634]),
Object.freeze([85.37669, 28.27526]),
Object.freeze([85.37701, 28.27418]),
Object.freeze([85.37642, 28.27309]),
Object.freeze([85.37644, 28.27201]),
Object.freeze([85.37615, 28.27092]),
Object.freeze([85.37616, 28.26984]),
Object.freeze([85.37648, 28.26876]),
Object.freeze([85.37558, 28.26767]),
Object.freeze([85.37499, 28.26658]),
Object.freeze([85.37378, 28.26548]),
Object.freeze([85.37288, 28.26439]),
Object.freeze([85.37198, 28.26329]),
Object.freeze([85.37169, 28.26221]),
Object.freeze([85.37048, 28.26111]),
Object.freeze([85.36928, 28.26001]),
Object.freeze([85.36806, 28.25973]),
Object.freeze([85.36685, 28.25863]),
Object.freeze([85.36656, 28.25754]),
Object.freeze([85.36566, 28.25645]),
Object.freeze([85.36507, 28.25536]),
Object.freeze([85.36478, 28.25427]),
Object.freeze([85.36449, 28.25318]),
Object.freeze([85.36512, 28.25211]),
Object.freeze([85.36452, 28.25102]),
Object.freeze([85.36423, 28.24993]),
Object.freeze([85.36425, 28.24885]),
Object.freeze([85.36335, 28.24776]),
Object.freeze([85.36245, 28.24666]),
Object.freeze([85.36124, 28.24556]),
Object.freeze([85.36004, 28.24447]),
Object.freeze([85.35882, 28.24391]),
Object.freeze([85.35823, 28.24282]),
Object.freeze([85.35733, 28.24173]),
Object.freeze([85.35734, 28.24064]),
Object.freeze([85.35797, 28.23957]),
Object.freeze([85.35799, 28.23849]),
Object.freeze([85.3577, 28.2374]),
Object.freeze([85.35802, 28.23632]),
Object.freeze([85.35834, 28.23524]),
Object.freeze([85.35928, 28.23417]),
Object.freeze([85.3596, 28.23309]),
Object.freeze([85.36023, 28.23202]),
Object.freeze([85.36024, 28.23093]),
Object.freeze([85.36057, 28.22985]),
Object.freeze([85.36058, 28.22877]),
Object.freeze([85.3609, 28.22769]),
Object.freeze([85.36092, 28.22661]),
Object.freeze([85.36094, 28.22553]),
Object.freeze([85.36126, 28.22445]),
Object.freeze([85.36067, 28.22336]),
Object.freeze([85.36007, 28.22227]),
Object.freeze([85.35886, 28.22117]),
Object.freeze([85.35796, 28.22008]),
Object.freeze([85.35706, 28.21898]),
Object.freeze([85.35616, 28.21789]),
Object.freeze([85.35526, 28.21679]),
Object.freeze([85.35467, 28.2157]),
Object.freeze([85.35469, 28.21462]),
Object.freeze([85.3547, 28.21354]),
Object.freeze([85.35472, 28.21246]),
Object.freeze([85.35443, 28.21137]),
Object.freeze([85.35414, 28.21028]),
Object.freeze([85.35416, 28.2092]),
Object.freeze([85.35448, 28.20812]),
Object.freeze([85.3548, 28.20704]),
Object.freeze([85.35482, 28.20596]),
Object.freeze([85.35392, 28.20486]),
Object.freeze([85.35332, 28.20377]),
Object.freeze([85.35303, 28.20269]),
Object.freeze([85.35275, 28.2016]),
Object.freeze([85.35246, 28.20052]),
Object.freeze([85.35247, 28.19943]),
Object.freeze([85.35188, 28.19834]),
Object.freeze([85.35128, 28.19725]),
Object.freeze([85.35038, 28.19616]),
Object.freeze([85.3501, 28.19507]),
Object.freeze([85.3495, 28.19398]),
Object.freeze([85.34891, 28.19289]),
Object.freeze([85.34862, 28.1918]),
Object.freeze([85.34802, 28.19071]),
Object.freeze([85.34743, 28.18962]),
Object.freeze([85.34622, 28.18853]),
Object.freeze([85.34593, 28.18744]),
Object.freeze([85.34565, 28.18635]),
Object.freeze([85.34475, 28.18526]),
Object.freeze([85.34385, 28.18417]),
Object.freeze([85.34325, 28.18308]),
Object.freeze([85.34266, 28.18199]),
Object.freeze([85.34237, 28.1809]),
Object.freeze([85.34239, 28.17982]),
Object.freeze([85.34271, 28.17874]),
Object.freeze([85.34272, 28.17765]),
Object.freeze([85.34274, 28.17657]),
Object.freeze([85.34215, 28.17548]),
Object.freeze([85.34216, 28.1744]),
Object.freeze([85.34249, 28.17332]),
Object.freeze([85.34189, 28.17223]),
Object.freeze([85.34191, 28.17115]),
Object.freeze([85.34192, 28.17006]),
Object.freeze([85.34255, 28.16899]),
Object.freeze([85.34226, 28.1679]),
Object.freeze([85.34289, 28.16683]),
Object.freeze([85.3423, 28.16574]),
Object.freeze([85.3417, 28.16465]),
Object.freeze([85.34048, 28.16436]),
Object.freeze([85.33928, 28.16353]),
Object.freeze([85.33807, 28.16271]),
Object.freeze([85.33747, 28.16162]),
Object.freeze([85.33718, 28.16053]),
Object.freeze([85.33628, 28.15971]),
Object.freeze([85.33507, 28.15888]),
Object.freeze([85.33385, 28.15859]),
Object.freeze([85.33295, 28.15777]),
Object.freeze([85.33174, 28.15694]),
Object.freeze([85.33053, 28.15639]),
Object.freeze([85.32932, 28.15529]),
Object.freeze([85.32842, 28.1542]),
Object.freeze([85.32721, 28.15337]),
Object.freeze([85.32631, 28.15228]),
Object.freeze([85.3251, 28.15145]),
Object.freeze([85.32389, 28.15089]),
Object.freeze([85.32267, 28.15061]),
Object.freeze([85.32147, 28.14978]),
Object.freeze([85.32026, 28.14895]),
Object.freeze([85.31904, 28.1484]),
Object.freeze([85.31783, 28.14784]),
Object.freeze([85.31663, 28.14674]),
Object.freeze([85.31541, 28.14619]),
Object.freeze([85.3142, 28.14536]),
Object.freeze([85.313, 28.14426]),
Object.freeze([85.3124, 28.14317]),
Object.freeze([85.31181, 28.14208]),
Object.freeze([85.31058, 28.14261]),
Object.freeze([85.30936, 28.14232]),
Object.freeze([85.30816, 28.14122]),
Object.freeze([85.30695, 28.14012]),
Object.freeze([85.30575, 28.13903]),
Object.freeze([85.30485, 28.13793]),
Object.freeze([85.30456, 28.13685]),
Object.freeze([85.30366, 28.13575]),
Object.freeze([85.30307, 28.13466]),
Object.freeze([85.30248, 28.13357]),
Object.freeze([85.30127, 28.13274]),
Object.freeze([85.30067, 28.13165]),
Object.freeze([85.30039, 28.13057]),
Object.freeze([85.3001, 28.12948]),
Object.freeze([85.29889, 28.12838]),
Object.freeze([85.29799, 28.12729]),
Object.freeze([85.29709, 28.12647]),
Object.freeze([85.29588, 28.12591]),
Object.freeze([85.29467, 28.12508]),
Object.freeze([85.29347, 28.12398]),
Object.freeze([85.29226, 28.12316]),
Object.freeze([85.29105, 28.12206]),
Object.freeze([85.28985, 28.12096]),
Object.freeze([85.28864, 28.11986]),
Object.freeze([85.28775, 28.11877]),
Object.freeze([85.28653, 28.11821]),
Object.freeze([85.28532, 28.11765]),
Object.freeze([85.28411, 28.11683]),
Object.freeze([85.2829, 28.116]),
Object.freeze([85.28231, 28.11491]),
Object.freeze([85.28172, 28.11382]),
Object.freeze([85.28082, 28.11272]),
Object.freeze([85.27961, 28.11217]),
Object.freeze([85.2787, 28.11134]),
Object.freeze([85.27749, 28.11079]),
Object.freeze([85.27629, 28.10969]),
Object.freeze([85.27508, 28.10859]),
Object.freeze([85.27388, 28.10749]),
Object.freeze([85.27267, 28.10667]),
Object.freeze([85.27177, 28.10584]),
Object.freeze([85.27056, 28.10474]),
Object.freeze([85.26936, 28.10392]),
Object.freeze([85.26815, 28.10309]),
Object.freeze([85.26695, 28.10199]),
Object.freeze([85.26574, 28.10089]),
Object.freeze([85.26454, 28.09979]),
Object.freeze([85.26364, 28.0987]),
Object.freeze([85.26274, 28.09761]),
Object.freeze([85.26153, 28.09678]),
Object.freeze([85.26033, 28.09568]),
Object.freeze([85.25911, 28.09539]),
Object.freeze([85.2579, 28.09511]),
Object.freeze([85.25668, 28.09509]),
Object.freeze([85.25546, 28.09481]),
Object.freeze([85.25425, 28.09398]),
Object.freeze([85.25304, 28.09342]),
Object.freeze([85.25183, 28.09286]),
Object.freeze([85.25061, 28.09285]),
Object.freeze([85.24939, 28.09256]),
Object.freeze([85.24818, 28.09201]),
Object.freeze([85.24696, 28.09199]),
Object.freeze([85.24573, 28.09252]),
Object.freeze([85.24451, 28.0925]),
Object.freeze([85.24328, 28.09248]),
Object.freeze([85.24207, 28.0922]),
Object.freeze([85.24085, 28.09191]),
Object.freeze([85.23963, 28.0919]),
Object.freeze([85.23841, 28.09161]),
Object.freeze([85.2372, 28.09132]),
Object.freeze([85.23598, 28.09104]),
Object.freeze([85.23476, 28.09102]),
Object.freeze([85.23355, 28.09019]),
Object.freeze([85.23234, 28.08964]),
Object.freeze([85.23113, 28.08881]),
Object.freeze([85.22993, 28.08771]),
Object.freeze([85.22872, 28.08688]),
Object.freeze([85.22782, 28.08579]),
Object.freeze([85.22692, 28.08496]),
Object.freeze([85.22571, 28.08414]),
Object.freeze([85.2245, 28.08358]),
Object.freeze([85.22329, 28.08302]),
Object.freeze([85.22208, 28.08219]),
Object.freeze([85.22087, 28.08164]),
Object.freeze([85.21966, 28.08108]),
Object.freeze([85.21876, 28.08026]),
Object.freeze([85.21755, 28.07943]),
Object.freeze([85.21635, 28.07833]),
Object.freeze([85.21514, 28.07723]),
Object.freeze([85.21394, 28.0764]),
Object.freeze([85.21272, 28.07585]),
Object.freeze([85.21151, 28.07529]),
Object.freeze([85.2103, 28.075]),
Object.freeze([85.2094, 28.07391]),
Object.freeze([85.20881, 28.07282]),
Object.freeze([85.20791, 28.07172]),
Object.freeze([85.20762, 28.07063]),
Object.freeze([85.20672, 28.06954]),
Object.freeze([85.20552, 28.06844]),
Object.freeze([85.20523, 28.06735]),
Object.freeze([85.20495, 28.06627]),
Object.freeze([85.20588, 28.0652]),
Object.freeze([85.2062, 28.06412]),
Object.freeze([85.20683, 28.06304]),
Object.freeze([85.20716, 28.06196]),
Object.freeze([85.20717, 28.06088]),
Object.freeze([85.2078, 28.05981]),
Object.freeze([85.20751, 28.05872]),
Object.freeze([85.20814, 28.05765]),
Object.freeze([85.20847, 28.05657]),
Object.freeze([85.20787, 28.05548]),
Object.freeze([85.20759, 28.05439]),
Object.freeze([85.20699, 28.0533]),
Object.freeze([85.2061, 28.0522]),
Object.freeze([85.20489, 28.05138]),
Object.freeze([85.20369, 28.05028]),
Object.freeze([85.2031, 28.04919]),
Object.freeze([85.2022, 28.04809]),
Object.freeze([85.20191, 28.04701]),
Object.freeze([85.20101, 28.04591]),
Object.freeze([85.19981, 28.04508]),
Object.freeze([85.1986, 28.04398]),
Object.freeze([85.19739, 28.04343]),
Object.freeze([85.1965, 28.04233]),
Object.freeze([85.19529, 28.0415]),
Object.freeze([85.19439, 28.04041]),
Object.freeze([85.1938, 28.03932]),
Object.freeze([85.19382, 28.03824]),
Object.freeze([85.19384, 28.03715]),
Object.freeze([85.19355, 28.03607]),
Object.freeze([85.19357, 28.03498]),
Object.freeze([85.19328, 28.0339]),
Object.freeze([85.1936, 28.03282]),
Object.freeze([85.19362, 28.03174]),
Object.freeze([85.19364, 28.03065]),
Object.freeze([85.19305, 28.02956]),
Object.freeze([85.19215, 28.02847]),
Object.freeze([85.19156, 28.02738]),
Object.freeze([85.19097, 28.02629]),
Object.freeze([85.18976, 28.02573]),
Object.freeze([85.18854, 28.02571]),
Object.freeze([85.18732, 28.02515]),
Object.freeze([85.18611, 28.0246]),
Object.freeze([85.18522, 28.0235]),
Object.freeze([85.18493, 28.02242]),
Object.freeze([85.18556, 28.02134]),
Object.freeze([85.1868, 28.02027]),
Object.freeze([85.18742, 28.0192]),
Object.freeze([85.18714, 28.01811]),
Object.freeze([85.18746, 28.01703]),
Object.freeze([85.18656, 28.01594]),
Object.freeze([85.18536, 28.01484]),
Object.freeze([85.18446, 28.01375]),
Object.freeze([85.18418, 28.01266]),
Object.freeze([85.18389, 28.01157]),
Object.freeze([85.1836, 28.01049]),
Object.freeze([85.18362, 28.0094]),
Object.freeze([85.18364, 28.00832]),
Object.freeze([85.18366, 28.00724]),
Object.freeze([85.18368, 28.00615]),
Object.freeze([85.1843, 28.00508]),
Object.freeze([85.18432, 28.004]),
Object.freeze([85.18404, 28.00291]),
Object.freeze([85.18375, 28.00182]),
Object.freeze([85.18438, 28.00075]),
Object.freeze([85.18501, 27.99967]),
Object.freeze([85.18533, 27.9986]),
Object.freeze([85.18596, 27.99752]),
Object.freeze([85.18567, 27.99643]),
Object.freeze([85.18477, 27.99534]),
Object.freeze([85.18357, 27.99424]),
Object.freeze([85.18237, 27.99314]),
Object.freeze([85.18239, 27.99206]),
Object.freeze([85.18302, 27.99098]),
Object.freeze([85.18273, 27.9899]),
Object.freeze([85.18152, 27.98907]),
Object.freeze([85.18062, 27.98824]),
Object.freeze([85.18034, 27.98716]),
Object.freeze([85.18066, 27.98608]),
Object.freeze([85.18037, 27.98499]),
Object.freeze([85.18039, 27.98391]),
Object.freeze([85.18041, 27.98283]),
Object.freeze([85.18043, 27.98174]),
Object.freeze([85.18075, 27.98067]),
Object.freeze([85.18138, 27.97959]),
Object.freeze([85.1814, 27.97851]),
Object.freeze([85.18172, 27.97743]),
Object.freeze([85.18204, 27.97635]),
Object.freeze([85.18236, 27.97527]),
Object.freeze([85.18299, 27.9742]),
Object.freeze([85.18362, 27.97312]),
Object.freeze([85.18425, 27.97205]),
Object.freeze([85.18396, 27.97096]),
Object.freeze([85.18275, 27.9704]),
Object.freeze([85.18154, 27.97012]),
Object.freeze([85.18033, 27.96956]),
Object.freeze([85.17911, 27.96927]),
Object.freeze([85.17821, 27.96818]),
Object.freeze([85.17762, 27.96709]),
Object.freeze([85.17703, 27.966]),
Object.freeze([85.17613, 27.9649]),
Object.freeze([85.17492, 27.96434]),
Object.freeze([85.17371, 27.96406]),
Object.freeze([85.1725, 27.9635]),
Object.freeze([85.17128, 27.96321]),
Object.freeze([85.17008, 27.96238]),
Object.freeze([85.16888, 27.96129]),
Object.freeze([85.16859, 27.9602]),
Object.freeze([85.168, 27.95911]),
Object.freeze([85.16771, 27.95802]),
Object.freeze([85.16682, 27.95693]),
Object.freeze([85.16561, 27.95637]),
Object.freeze([85.1644, 27.95581]),
Object.freeze([85.16318, 27.95552]),
Object.freeze([85.16198, 27.95442]),
Object.freeze([85.1623, 27.95335]),
Object.freeze([85.16232, 27.95226]),
Object.freeze([85.16234, 27.95118]),
Object.freeze([85.16236, 27.9501]),
Object.freeze([85.16238, 27.94902]),
Object.freeze([85.1627, 27.94794]),
Object.freeze([85.16241, 27.94685]),
Object.freeze([85.16152, 27.94576]),
Object.freeze([85.16093, 27.94466]),
Object.freeze([85.16034, 27.94357]),
Object.freeze([85.15974, 27.94248]),
Object.freeze([85.15915, 27.94139]),
Object.freeze([85.15887, 27.9403]),
Object.freeze([85.15797, 27.93921]),
Object.freeze([85.15738, 27.93812]),
Object.freeze([85.15679, 27.93703]),
Object.freeze([85.1562, 27.93594]),
Object.freeze([85.15561, 27.93485]),
Object.freeze([85.15532, 27.93376]),
Object.freeze([85.15473, 27.93267]),
Object.freeze([85.15352, 27.93184]),
Object.freeze([85.15229, 27.93291]),
Object.freeze([85.15105, 27.9337]),
Object.freeze([85.15015, 27.93288]),
Object.freeze([85.14987, 27.93179]),
Object.freeze([85.14988, 27.93071]),
Object.freeze([85.14929, 27.92962]),
Object.freeze([85.14901, 27.92853]),
Object.freeze([85.14994, 27.92746]),
Object.freeze([85.15057, 27.92639]),
Object.freeze([85.15089, 27.92531]),
Object.freeze([85.15121, 27.92423]),
Object.freeze([85.15123, 27.92315]),
Object.freeze([85.15034, 27.92205]),
Object.freeze([85.14943, 27.92177]),
]);
+117
View File
@@ -0,0 +1,117 @@
// Overview pins follow the event pack through Devighat.
// Charaudi remains a separate evidence shot, and none of these observation
// locations extends the mapped flood path.
export const INCIDENT_OVERVIEW_PLACES = Object.freeze(
[
[
'immediate-collapse-viewpoint',
'COLLAPSE VIEWPOINT',
28.3632333,
85.4423639,
2850,
'left',
],
[
'debris-dammed-lake',
'DAMMED LAKE',
28.3346111,
85.4861389,
2750,
'right',
],
[
'second-landslide',
'SECOND LANDSLIDE',
28.3379706,
85.4688823,
2750,
'left',
],
[
'gyirong-border-gate',
'BORDER GATE',
28.2808338,
85.3780248,
1818,
'right',
],
['timure-cluster', 'TIMURE', 28.2519361, 85.3665833, 1689, 'left'],
[
'syabru-besi',
'SYABRU BESI',
28.1602583333333,
85.3325666666667,
1417,
'right',
],
['dhunche', 'DHUNCHE', 28.1131861111111, 85.2953305555556, 1300, 'left'],
[
'mailung-upper-trishuli',
'UPPER TRISHULI',
28.090925,
85.2322916666667,
950,
'right',
],
[
'mailung-bazzar',
'MAILUNG',
28.0761666666667,
85.2125277777778,
800,
'left',
],
[
'dandagaun',
'DANDAGAUN',
28.0751944444444,
85.2076944444444,
780,
'right-lower',
],
[
'dandagaun-viewpoint',
'DANDAGAUN VIEWPOINT',
28.0354,
85.1894361111111,
700,
'right',
],
[
'betrawati-bazaar',
'BETRAWATI',
27.9758992572246,
85.1835331689033,
600,
'left',
],
[
'bhainse',
'BHAINSE',
27.9623111111111,
85.1739777777778,
500,
'right-lower',
],
[
'bidur-trishuli-bridge',
'BIDUR BRIDGE',
27.920858,
85.148029,
510,
'left',
],
[
'devighat-taadi-khola-bridge',
'DEVIGHAT BRIDGE',
27.8585278,
85.1090318,
350,
'right',
],
].map(([id, title, lat, lon, heightM, placement]) =>
Object.freeze({ id, title, lat, lon, heightM, placement }),
),
);
export const INCIDENT_OVERVIEW_HOLD_SEC = 11;
File diff suppressed because it is too large. Load diff
+671
View File
@@ -0,0 +1,671 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import * as Cesium from 'cesium';
import { SceneDirector } from '../scenes/director.js';
import {
anchoredCalloutPresentation,
BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID,
BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
BHOTE_KOSHI_REGIONAL_PRESENTATION,
BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
closeRegionRing,
createBhoteKoshiLocatorLayer,
largestGeoJsonRing,
NEPAL_CONTEXT_PRESENTATION,
revealPolylinePositions,
} from './bhoteKoshiLocator.js';
function viewerFixture() {
const added = [];
const removed = [];
return {
creditDisplay: { addStaticCredit() {} },
dataSources: {
add(source) { added.push(source); },
remove(source) { removed.push(source); },
},
_test: { added, removed },
};
}
function nonRunningAnimation() {
return {
scheduleFrame() { return 1; },
cancelFrame() {},
};
}
function overlayHostFixture() {
const publications = [];
const clears = [];
return {
setEntries(sourceId, entries, options) {
publications.push({ sourceId, entries, options });
},
clearSource(sourceId) {
clears.push(sourceId);
},
_test: { publications, clears },
};
}
test('Bhote Koshi locator closes valid region rings once', () => {
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [88, 30]]), [
[80, 26], [88, 26], [88, 30], [80, 26],
]);
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [80, 26]]), [
[80, 26], [88, 26], [80, 26],
]);
assert.deepEqual(closeRegionRing([[80, 26], ['bad', 27]]), []);
});
test('Bhote Koshi locator selects the largest GeoJSON country exterior', () => {
const main = [[80, 26], [88, 26], [88, 30], [80, 30], [80, 26]];
const island = [[82, 25], [83, 25], [82, 25]];
assert.equal(largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }), main);
assert.equal(largestGeoJsonRing({
type: 'MultiPolygon',
coordinates: [[island], [main]],
}), main);
assert.deepEqual(largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }), []);
});
test('Bhote Koshi locator reveals a border progressively', () => {
const positions = [
new Cesium.Cartesian3(0, 0, 0),
new Cesium.Cartesian3(10, 0, 0),
new Cesium.Cartesian3(20, 0, 0),
];
assert.deepEqual(revealPolylinePositions(positions, 0), [positions[0], positions[0]]);
assert.deepEqual(revealPolylinePositions(positions, 0.25), [
positions[0],
new Cesium.Cartesian3(5, 0, 0),
]);
assert.equal(revealPolylinePositions(positions, 1), positions);
});
test('Bhote Koshi locator stages its anchored callout from dot to leader to text', () => {
assert.deepEqual(anchoredCalloutPresentation(0), {
alpha: 0,
leaderProgress: 0,
contentAlpha: 0,
scale: 0.96,
});
assert.deepEqual(anchoredCalloutPresentation(1200), {
alpha: 1,
leaderProgress: 1,
contentAlpha: 1,
scale: 1,
});
});
test('Bhote Koshi regional locator sequences Nepal, border, then the incident', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
const renders = [];
let nowMs = 0;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
}),
requestRender: (reason) => renders.push(reason),
overlayHost,
now: () => nowMs,
...nonRunningAnimation(),
});
assert.equal(await layer.init(viewer), true);
assert.equal(await layer.enable(viewer, { origin: 'scene' }), true);
assert.equal(viewer._test.added.length, 1);
const source = viewer._test.added[0];
const ids = source.entities.values.map((entity) => entity.id);
for (const id of [
'nepal-context-halo',
'nepal-border-fill',
'nepal-border-highlight',
'bhote-koshi-incident-halo',
]) {
assert.ok(ids.includes(id), `${id} belongs to the regional sequence`);
}
const incidentHalo = source.entities.getById('bhote-koshi-incident-halo');
const border = source.entities.getById('nepal-border-highlight');
const publication = overlayHost._test.publications.at(-1);
assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
assert.equal(publication.entries.length, 2);
const nepal = publication.entries.find(({ id }) => id === 'nepal-context-callout');
const incident = publication.entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
assert.equal(nepal.variant, 'label');
assert.equal(nepal.title, 'NEPAL');
assert.equal(nepal.anchorDot, true);
assert.equal(incident.variant, 'card');
assert.equal(incident.paintLane, 'thumbnail');
assert.equal(incident.title, 'BHOTE KOSHI FLOOD INCIDENT');
assert.deepEqual(incident.details, ['NEPAL · 26 AUG 2026']);
assert.equal(incident.anchorDot, true);
assert.equal(incident.leaderStyle, 'elbow');
assert.equal('image' in incident, false);
assert.equal('requireImage' in incident, false);
assert.equal(incident.sourceAlpha(), 0);
assert.equal(incident.leaderProgress(), 0);
assert.equal(incident.contentAlpha(), 0);
assert.equal(incident.presentationScale(), 0.96);
assert.equal(nepal.sourceAlpha(), 0);
assert.ok(border.polyline.positions.getValue().length < 5);
assert.equal(incidentHalo.show.getValue(), false);
assert.equal(incidentHalo.point.pixelSize.getValue(), 16);
nowMs = 3100;
assert.equal(nepal.sourceAlpha(), 1);
assert.equal(border.polyline.positions.getValue().length, 5);
assert.equal(incidentHalo.show.getValue(), false);
nowMs = 3300;
assert.equal(incidentHalo.show.getValue(), true);
assert.equal(incident.sourceAlpha(), 0);
nowMs = 4500;
assert.equal(incident.sourceAlpha(), 1);
assert.equal(incident.leaderProgress(), 1);
assert.equal(incident.contentAlpha(), 1);
assert.equal(incident.presentationScale(), 1);
assert.equal(layer.getStats().status, 'nominal');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.ok(renders.includes('bhote-koshi-locator-boundary'));
assert.equal(await layer.disable(), true);
assert.equal(viewer._test.removed.length, 1);
assert.equal(overlayHost._test.clears.at(-1), BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
assert.equal(layer.getStats().status, 'idle');
});
test('Bhote Koshi locator switches between Nepal context and the regional incident treatment', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
}),
requestRender() {},
overlayHost,
...nonRunningAnimation(),
});
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
await layer.enable(viewer);
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
'nepal-context-halo',
]);
const nepalLabel = overlayHost._test.publications.at(-1).entries[0];
assert.equal(nepalLabel.variant, 'label');
assert.equal(nepalLabel.placement, 'right');
assert.equal(nepalLabel.anchorDot, true);
assert.equal(layer.getStats().coverage, 'Nepal context');
assert.deepEqual(layer.getRowControls().chips.map(({ id, active }) => ({ id, active })), [
{ id: 'nepal-context', active: true },
{ id: 'bhote-koshi-regional', active: false },
{ id: 'bhote-koshi-city-context', active: false },
{ id: 'bhote-koshi-incident-places', active: false },
{ id: 'bhote-koshi-flood-path', active: false },
{ id: 'bhote-koshi-path-overview', active: false },
{ id: 'bhote-koshi-trigger-record', active: false },
]);
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
assert.equal(
overlayHost._test.publications.at(-1).entries
.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
'card',
);
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
'nepal-context-halo',
]);
await layer.destroy();
});
test('Incident Corridor reveals all fifteen overview pins and keeps layout active through the sequence', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let nowMs = 0;
let pulseFrame = null;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null, requestRender() {},
overlayHost,
now: () => nowMs,
scheduleFrame(callback) { pulseFrame = callback; return 1; },
cancelFrame() {},
});
layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION });
await layer.enable(viewer);
const source = viewer._test.added[0];
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
assert.equal(event.evidenceSpine.length, 16);
assert.equal(event.evidenceSpine.at(-1).id, 'charaudi');
assert.equal(source.entities.values.length, 15);
const labels = overlayHost._test.publications.at(-1).entries;
assert.equal(labels.length, 15);
assert.equal(overlayHost._test.publications.at(-1).options.moving, true);
for (const [index, point] of INCIDENT_OVERVIEW_PLACES.entries()) {
const id = `bhote-koshi-place-${point.id}`;
const pin = labels.find((entry) => entry.id === id);
assert.ok(pin, point.id);
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${point.title}`);
assert.equal(pin.variant, 'label');
const location = Cesium.Cartographic.fromCartesian(pin.position);
assert.ok(Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6);
nowMs = 349 + index * 650;
assert.equal(pin.sourceAlpha(), 0);
nowMs += 1;
assert.equal(pin.sourceAlpha(), 0);
assert.equal(source.entities.values.filter(entity => entity.id.endsWith('-halo') && entity.show.getValue()).length, 1);
nowMs += 520;
assert.equal(pin.sourceAlpha(), 1);
}
assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000);
// A repeat halo cycle must not erase already revealed labels.
nowMs = 350 + 15 * 650;
assert.equal(source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo').show.getValue(), true);
assert.equal(labels.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge').sourceAlpha(), 1);
assert.equal(labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'), false);
assert.equal(typeof pulseFrame, 'function');
pulseFrame();
assert.equal(overlayHost._test.publications.at(-1).options.moving, false);
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.equal(source.entities.values.some(entity => entity.id.startsWith('bhote-koshi-place-')), false);
await layer.destroy();
});
test('Bhote Koshi flood path draws a solid cyan sourced route after the place shot', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let nowMs = 0;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
now: () => nowMs,
...nonRunningAnimation(),
});
layer.setParams({ presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
await layer.enable(viewer);
const source = viewer._test.added[0];
const path = source.entities.getById('bhote-koshi-flood-path');
const labels = overlayHost._test.publications.at(-1).entries;
const trigger = labels.find(({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint');
const bidur = labels.find(({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge');
assert.equal(source.entities.values.length, 1);
assert.equal(labels.length, 15);
assert.equal(bidur.title, '14 · BIDUR BRIDGE');
for (const place of INCIDENT_OVERVIEW_PLACES) {
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
assert.equal(pin.sourceAlpha(), 1, place.id);
}
assert.equal(path.polyline.clampToGround.getValue(), true);
assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty);
assert.ok(path.polyline.material.color.getValue().equalsEpsilon(
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
Cesium.Math.EPSILON7,
));
assert.equal(path.show.getValue(), false);
assert.equal(trigger.sourceAlpha(), 1);
assert.equal(bidur.sourceAlpha(), 1);
nowMs = 250;
assert.equal(path.show.getValue(), true);
assert.equal(path.polyline.positions.getValue().length, 2);
nowMs = 2250;
const halfwayCount = path.polyline.positions.getValue().length;
assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2);
nowMs = 4250;
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], [85.48476, 28.33255]);
// More observation pins must not extend or straighten the sourced route.
assert.deepEqual(path.polyline.positions.getValue(), BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => (
Cesium.Cartesian3.fromDegrees(lon, lat)
)));
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.at(-1), [85.14943, 27.92177]);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Flood path');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
await layer.destroy();
});
test('Bhote Koshi path-overview presentation keeps the route settled without Nepal border', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let nowMs = 0;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
}),
requestRender() {},
overlayHost,
now: () => nowMs,
...nonRunningAnimation(),
});
layer.setParams({ presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
await layer.enable(viewer);
const source = viewer._test.added[0];
const path = source.entities.getById('bhote-koshi-flood-path');
const labels = overlayHost._test.publications.at(-1).entries;
assert.equal(source.entities.values.length, 1);
assert.equal(labels.length, 15);
for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) {
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${place.title}`);
assert.equal(pin.sourceAlpha(), 1);
}
assert.equal(path.show.getValue(), true);
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
assert.ok(Cesium.Cartesian3.equals(path.polyline.positions.getValue()[0],
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255)));
assert.equal(source.entities.getById('nepal-border-fill'), undefined);
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
assert.equal(
source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo'),
undefined,
);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Path overview');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
await layer.destroy();
});
test('Bhote Koshi trigger-record presentation reveals sourced incident details', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let nowMs = 0;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
now: () => nowMs,
...nonRunningAnimation(),
});
layer.setParams({
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
preview: true,
});
await layer.enable(viewer);
const source = viewer._test.added[0];
const publication = overlayHost._test.publications.at(-1);
const record = publication.entries[0];
assert.deepEqual(source.entities.values.map(({ id }) => id), [
'bhote-koshi-flood-path',
'bhote-koshi-trigger-record-halo',
]);
assert.equal(record.title, 'USGS INITIAL REPORT');
assert.deepEqual(record.details, [
'26 AUG 2026 · 08:37 NPT',
'28.2710° N · 85.5150° E',
'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE',
'USGS EVENT us7000tbwb · OPEN SOURCE',
]);
assert.equal(record.interactive, true);
assert.equal(typeof record.activate, 'function');
assert.equal(record.sourceAlpha(), 0);
nowMs = 1200;
assert.equal(record.sourceAlpha(), 1);
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Trigger record');
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
});
await layer.destroy();
});
test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
const moveStart = new Cesium.Event();
const moveEnd = new Cesium.Event();
const flights = [];
let armedCallback = null;
viewer.camera = {
moveStart,
moveEnd,
flyTo(options) { flights.push(options); },
cancelFlight() {},
};
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
scheduleDelay(callback) {
armedCallback = callback;
return 1;
},
cancelDelay() {},
...nonRunningAnimation(),
});
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
await layer.enable(viewer);
assert.equal(flights.length, 0);
assert.equal(typeof armedCallback, 'function');
moveStart.raiseEvent();
assert.equal(flights.length, 0);
moveEnd.raiseEvent();
assert.equal(flights.length, 1);
assert.equal(flights[0].duration, 4.6);
const destination = Cesium.Cartographic.fromCartesian(flights[0].destination);
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6);
assert.ok(Math.abs(destination.height - 10526) < 1e-4);
flights[0].complete();
assert.equal(flights.length, 2);
assert.equal(flights[1].duration, 2.8);
const orbit = Cesium.Cartographic.fromCartesian(flights[1].destination);
assert.ok(Math.abs(Cesium.Math.toDegrees(orbit.latitude) - 28.3769) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(orbit.longitude) - 85.4923) < 1e-6);
assert.ok(Math.abs(orbit.height - 10526) < 1e-4);
await layer.destroy();
});
for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
test(`Scene Stop revokes locator ${phase} and stale callbacks cannot own a successor`, async () => {
const viewer = viewerFixture();
const timers = new Map();
const callbacks = [];
const flights = [];
let nextTimer = 0;
const moveStart = new Cesium.Event();
const moveEnd = new Cesium.Event();
viewer.camera = {
moveStart, moveEnd,
flyTo(options) { flights.push(options); },
cancelFlight() {},
};
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
overlayHost: overlayHostFixture(),
requestRender() {},
scheduleDelay(callback) {
callbacks.push(callback);
timers.set(++nextTimer, callback);
return nextTimer;
},
cancelDelay(id) { timers.delete(id); },
...nonRunningAnimation(),
});
const director = {
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
viewer,
_sceneSeekGeneration: 0,
_loadGeneration: 0,
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
};
try {
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
await layer.enable(viewer);
if (phase === 'move-end') moveStart.raiseEvent();
if (phase === 'approach' || phase === 'orbit') callbacks[0]();
if (phase === 'orbit') flights[0].complete();
const oldFlights = [...flights];
SceneDirector.prototype.stopScene.call(director);
assert.equal(timers.size, 0, 'Stop cancels the actual delayed work');
assert.equal(moveStart.numberOfListeners, 0);
assert.equal(moveEnd.numberOfListeners, 0);
callbacks[0]();
moveEnd.raiseEvent();
for (const flight of oldFlights) flight.complete();
assert.equal(flights.length, oldFlights.length, 'no post-Stop approach or orbit');
assert.equal(layer.getStats().count, 1, 'Stop preserves the visible locator');
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
callbacks.at(-1)();
const successor = flights.at(-1);
const count = flights.length;
callbacks[0]();
for (const flight of oldFlights) { flight.cancel(); flight.complete(); }
assert.equal(flights.length, count, 'old callbacks cannot add a flight');
successor.complete();
assert.equal(flights.length, count + 1, 'successor retains its own orbit');
} finally { await layer.destroy(); }
});
}
test('Bhote Koshi locator keeps the regional context settled while nearby cities stagger in', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let nowMs = 0;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
}),
requestRender() {},
overlayHost,
now: () => nowMs,
...nonRunningAnimation(),
});
layer.setParams({ presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
await layer.enable(viewer);
const source = viewer._test.added[0];
const borderFill = source.entities.getById('nepal-border-fill');
const entries = overlayHost._test.publications.at(-1).entries;
const incident = entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
const kathmandu = entries.find(({ id }) => id === 'bhote-koshi-city-kathmandu');
const bhaktapur = entries.find(({ id }) => id === 'bhote-koshi-city-bhaktapur');
const dhulikhel = entries.find(({ id }) => id === 'bhote-koshi-city-dhulikhel');
assert.equal(borderFill.show.getValue(), true);
assert.equal(incident.contentAlpha(), 1);
assert.equal(kathmandu.title, 'KATHMANDU');
assert.equal(bhaktapur.title, 'BHAKTAPUR');
assert.equal(dhulikhel.title, 'DHULIKHEL');
assert.ok([kathmandu, bhaktapur, dhulikhel].every(({ variant, anchorDot }) => (
variant === 'label' && anchorDot === true
)));
assert.equal(kathmandu.sourceAlpha(), 0);
assert.equal(bhaktapur.sourceAlpha(), 0);
assert.equal(dhulikhel.sourceAlpha(), 0);
nowMs = 350;
assert.equal(kathmandu.sourceAlpha(), 0);
assert.equal(bhaktapur.sourceAlpha(), 0);
nowMs = 850;
assert.equal(kathmandu.sourceAlpha(), 1);
assert.ok(bhaktapur.sourceAlpha() > 0);
assert.ok(dhulikhel.sourceAlpha() > 0);
nowMs = 1300;
assert.equal(dhulikhel.sourceAlpha(), 1);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Nearby cities');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
await layer.destroy();
});
test('Bhote Koshi locator degrades to labels when the regional boundary is unavailable', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
const originalWarn = console.warn;
console.warn = () => {};
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
...nonRunningAnimation(),
});
try {
await layer.enable(viewer);
const ids = viewer._test.added[0].entities.values.map((entity) => entity.id);
assert.ok(ids.includes('nepal-context-halo'));
assert.ok(ids.includes('bhote-koshi-incident-halo'));
assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false);
assert.equal(ids.includes('nepal-border-highlight'), false);
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
'nepal-context-callout',
'bhote-koshi-incident-callout',
]);
assert.equal(layer.getStats().status, 'degraded');
} finally {
console.warn = originalWarn;
await layer.destroy();
}
});
test('Bhote Koshi regional locator waits for its boundary before starting the full sequence', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let releaseBoundary;
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => boundary,
requestRender() {},
overlayHost,
...nonRunningAnimation(),
});
const enabling = layer.enable(viewer);
assert.ok(viewer._test.added[0].entities.getById('nepal-context-halo'));
assert.equal(viewer._test.added[0].entities.getById('nepal-border-highlight'), undefined);
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), undefined);
assert.equal(overlayHost._test.publications.length, 1);
assert.deepEqual(overlayHost._test.publications[0].entries.map(({ id }) => id), [
'nepal-context-callout',
]);
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30], [80, 30]] });
await enabling;
assert.ok(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'));
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
assert.equal(overlayHost._test.publications.length, 2);
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
'nepal-context-callout',
'bhote-koshi-incident-callout',
]);
await layer.destroy();
});
test('Bhote Koshi locator cancels a stale boundary resolution and removes its surface', async () => {
const viewer = viewerFixture();
const controller = new AbortController();
let releaseBoundary;
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => boundary,
requestRender() {},
...nonRunningAnimation(),
});
const enabling = layer.enable(viewer, { signal: controller.signal });
controller.abort();
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30]] });
await assert.rejects(enabling, (error) => error?.name === 'AbortError');
assert.equal(viewer._test.removed.length, 1);
});
+97
View File
@@ -0,0 +1,97 @@
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
export const SOURCE_PATH_BEAT_IDS = Object.freeze([
'debris-dammed-lake',
'second-landslide',
'dhunche',
'mailung-upper-trishuli',
'mailung-bazzar',
'dandagaun',
'dandagaun-viewpoint',
'betrawati-bazaar',
'bhainse',
'bidur-trishuli-bridge',
]);
function nearestVertex(point, route) {
if (!point || !Number.isFinite(point.lon) || !Number.isFinite(point.lat))
return -1;
const longitudeScale = Math.cos((point.lat * Math.PI) / 180);
let nearest = -1;
let distance = 1000; // Witness viewpoints can sit beside the river, not on it.
route.forEach(([lon, lat], index) => {
const metres =
Math.hypot((lon - point.lon) * longitudeScale, lat - point.lat) * 111195;
if (metres < distance) {
distance = metres;
nearest = index;
}
});
return nearest;
}
/** Split the already-bundled GeoPera centreline; never connect witness pins. */
export function buildSourceShotPaths(event, route = BHOTE_KOSHI_FLOOD_PATH) {
const observations = new Map(
(event.evidenceSpine || []).map((point) => [point.id, point]),
);
const result = new Map();
const add = (id, from, to, { joinComparison = false } = {}) => {
let start = nearestVertex(from, route);
const end = nearestVertex(to, route);
if (start < 0 || end <= start) return;
const startHeight = from.fallbackElevationM ?? from.elevationM;
const endHeight = to.fallbackElevationM ?? to.elevationM;
if (!Number.isFinite(startHeight) || !Number.isFinite(endHeight)) return;
if (joinComparison) {
// The raster corridor ends between simplified centreline vertices.
// Snapping to its nearest vertex can move the head upstream on entry.
// Keep that already-sourced endpoint and continue to the next vertex
// downstream of it, rather than replaying the upstream half-segment.
const [lon, lat] = route[start];
const [nextLon, nextLat] = route[start + 1];
const scale = Math.cos((from.lat * Math.PI) / 180);
const along =
(from.lon - lon) * (nextLon - lon) * scale * scale +
(from.lat - lat) * (nextLat - lat);
if (along > 0) start++;
}
const coordinates = route.slice(start, end + 1);
if (
joinComparison &&
(coordinates[0][0] !== from.lon || coordinates[0][1] !== from.lat)
) {
coordinates.unshift([from.lon, from.lat]);
}
result.set(
id,
coordinates.map(([lon, lat], index) => ({
lon,
lat,
// Stable source profile for explicit source-elevation shots; otherwise a
// fallback when neither rendered mesh nor elevation terrain can be sampled.
fallbackElevationM:
startHeight +
((endHeight - startHeight) * index) / (coordinates.length - 1),
})),
);
};
add(
'debris-dammed-lake',
observations.get('debris-dammed-lake'),
observations.get('second-landslide'),
);
add(
'second-landslide',
observations.get('second-landslide'),
observations.get('gyirong-border-gate'),
);
let previous = event.reconstruction?.corridor?.at(-1);
for (const id of SOURCE_PATH_BEAT_IDS.slice(2)) {
const point = observations.get(id);
add(id, previous, point, { joinComparison: id === 'dhunche' });
// Missing coverage breaks continuity rather than inventing a long connector.
previous = point;
}
return result;
}
+58
View File
@@ -0,0 +1,58 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { buildSourceShotPaths, SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url)));
test('overview and evidence paths start at the lake reach, never the earlier trigger reach', () => {
const lakeStart = [85.48476, 28.33255];
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], lakeStart);
assert.equal(BHOTE_KOSHI_FLOOD_PATH.some(([lon, lat]) => lon === 85.5306 && lat === 28.2916), false);
const paths = buildSourceShotPaths(event);
const first = paths.get('debris-dammed-lake')[0];
assert.deepEqual([first.lon, first.lat], lakeStart);
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.slice(0, paths.get('debris-dammed-lake').length),
paths.get('debris-dammed-lake').map(({lon, lat}) => [lon, lat]));
});
test('ten shot paths use only the existing centreline and join at shared endpoints', () => {
const paths = buildSourceShotPaths(event);
assert.deepEqual([...paths.keys()], SOURCE_PATH_BEAT_IDS);
const vertices = new Set(BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => `${lon},${lat}`));
const boundary = event.reconstruction.corridor.at(-1);
vertices.add(`${boundary.lon},${boundary.lat}`); // Exact sourced comparison handoff.
for (const points of paths.values()) {
assert.ok(points.length >= 2);
assert.ok(points.every(({ lon, lat, fallbackElevationM }) => (
vertices.has(`${lon},${lat}`) && Number.isFinite(fallbackElevationM)
)));
}
const coordinate = (point) => [point.lon, point.lat];
for (const ids of [SOURCE_PATH_BEAT_IDS.slice(0, 2), SOURCE_PATH_BEAT_IDS.slice(2)]) {
for (let index = 1; index < ids.length; index++) {
assert.deepEqual(coordinate(paths.get(ids[index - 1]).at(-1)), coordinate(paths.get(ids[index])[0]));
}
}
assert.deepEqual(coordinate(paths.get('bidur-trishuli-bridge').at(-1)), BHOTE_KOSHI_FLOOD_PATH.at(-1));
for (const id of ['immediate-collapse-viewpoint', 'devighat-taadi-khola-bridge', 'charaudi']) {
assert.equal(paths.has(id), false, 'out-of-coverage media must not gain a fabricated river route');
}
});
test('missing and distant observations fail closed without connecting witness coordinates', () => {
const broken = structuredClone(event);
broken.evidenceSpine.find(({ id }) => id === 'dhunche').lon = 0;
const paths = buildSourceShotPaths(broken);
assert.equal(paths.has('dhunche'), false);
assert.equal(paths.has('mailung-upper-trishuli'), false);
assert.equal(buildSourceShotPaths({}).size, 0);
});
test('Dhunche starts at the exact comparison endpoint without stepping upstream first', () => {
const boundary = event.reconstruction.corridor.at(-1);
const path = buildSourceShotPaths(event).get('dhunche');
assert.deepEqual([path[0].lon, path[0].lat], [boundary.lon, boundary.lat]);
assert.ok(path[1].lat < boundary.lat, 'the first new vertex is downstream of the comparison endpoint');
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { createApplicationBikeshare } from '../app/layers/bikeshare.js';
import { createSourceSlot } from '../app/sourceSlot.js';
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createBikeshareSource } from '../layers/bikeshare/source.js';
const sourceSlot = createSourceSlot(
+93
View File
@@ -0,0 +1,93 @@
// src/data/cameraSensitivity.js
/**
* Shared claims on `camera.percentageChanged`.
*
* `camera.changed` fires once the view has moved by this fraction, and it is a
* single number on the camera that every listener in the app shares. Several
* layers want it finer than the default — traffic, bikeshare and transit all
* ask for 0.05 so they notice they have entered a coverage area — and each of
* them used to write the number directly and hand back whatever it remembered.
*
* That cannot work, because a number is not an owner. Two layers asking for the
* same 0.05 write the same value, so when the first one leaves it sees its own
* number still in place, concludes nothing else has claimed it, and restores
* the coarse default out from under a layer that is still running. The symptom
* is a camera-driven layer that quietly stops noticing the camera: Transit is
* turned off and Bikeshare stops loading new cities until something else nudges
* it.
*
* So ownership is tracked explicitly. Claims are named, the camera gets the
* finest value anyone has asked for, and the original value returns only when
* the last claim is gone. Claiming twice under one name replaces that claim.
*
* This is bookkeeping, not policy: it never decides what a layer should ask
* for, only that nobody's answer is taken away while they still need it.
*/
/** @type {WeakMap<object, {base: number, claims: Map<string, number>}>} */
const _cameras = new WeakMap();
/** The value Cesium starts a camera at, used when the camera reports nothing. */
const DEFAULT_PERCENTAGE_CHANGED = 0.5;
function ledgerFor(camera) {
let ledger = _cameras.get(camera);
if (!ledger) {
const base = Number.isFinite(camera.percentageChanged)
? camera.percentageChanged
: DEFAULT_PERCENTAGE_CHANGED;
ledger = { base, claims: new Map() };
_cameras.set(camera, ledger);
}
return ledger;
}
function applyLedger(camera, ledger) {
let value = ledger.base;
for (const claimed of ledger.claims.values()) {
if (claimed < value) value = claimed;
}
camera.percentageChanged = value;
return value;
}
/**
* Ask for a camera-change sensitivity at least as fine as `value`.
* @param {object|null|undefined} camera A Cesium camera (or anything with `percentageChanged`).
* @param {string} owner Stable claim name, usually the layer id.
* @param {number} value Requested `percentageChanged`.
* @returns {number|null} The value now in force, or null when there is no camera.
*/
export function claimCameraSensitivity(camera, owner, value) {
if (!camera || !owner || !Number.isFinite(value)) return null;
const ledger = ledgerFor(camera);
ledger.claims.set(owner, value);
return applyLedger(camera, ledger);
}
/**
* Give up one claim. The camera keeps the finest value still claimed by
* somebody else, and returns to its original only when none are left.
* @param {object|null|undefined} camera
* @param {string} owner
* @returns {number|null} The value now in force, or null when there is no camera.
*/
export function releaseCameraSensitivity(camera, owner) {
if (!camera || !owner) return null;
const ledger = _cameras.get(camera);
if (!ledger) return null;
if (!ledger.claims.delete(owner)) return camera.percentageChanged;
const applied = applyLedger(camera, ledger);
if (ledger.claims.size === 0) _cameras.delete(camera);
return applied;
}
/**
* Who currently holds a claim. For tests and diagnostics only.
* @param {object|null|undefined} camera
* @returns {Array<string>}
*/
export function cameraSensitivityClaims(camera) {
const ledger = camera ? _cameras.get(camera) : null;
return ledger ? [...ledger.claims.keys()] : [];
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationCctv } from '../app/layers/cctv.js';
import { createSourceSlot } from '../app/sourceSlot.js';
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createCctvSource } from '../layers/cctv/source.js';
const sourceSlot = createSourceSlot(
+67 -19
View File
@@ -110,8 +110,12 @@ export const CCTV_THUMBNAIL_ALTITUDE_SCALE = Object.freeze({
*/
export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
if (pinned) return Number.isFinite(sy);
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0) return false;
const safeTop = Math.min(CCTV_CARD_SAFE_TOP_MAX_PX, viewH * CCTV_CARD_SAFE_TOP_RATIO);
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0)
return false;
const safeTop = Math.min(
CCTV_CARD_SAFE_TOP_MAX_PX,
viewH * CCTV_CARD_SAFE_TOP_RATIO,
);
return sy >= safeTop;
}
@@ -126,11 +130,24 @@ export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
* @param {number} [options.limit]
* @returns {string[]} Accepted ids, nearest-first.
*/
export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {}) {
export function declutterCctvCards(
candidates,
{ minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {},
) {
const valid = (Array.isArray(candidates) ? candidates : [])
.filter((c) => c && typeof c.id === 'string' && c.id
&& Number.isFinite(c.sx) && Number.isFinite(c.sy))
.sort((a, b) => (a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) || a.id.localeCompare(b.id));
.filter(
(c) =>
c &&
typeof c.id === 'string' &&
c.id &&
Number.isFinite(c.sx) &&
Number.isFinite(c.sy),
)
.sort(
(a, b) =>
(a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) ||
a.id.localeCompare(b.id),
);
const minSq = minSepPx * minSepPx;
const accepted = [];
const acceptedIds = [];
@@ -140,7 +157,10 @@ export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX
for (let i = 0; i < accepted.length; i++) {
const dx = candidate.sx - accepted[i].sx;
const dy = candidate.sy - accepted[i].sy;
if (dx * dx + dy * dy < minSq) { clear = false; break; }
if (dx * dx + dy * dy < minSq) {
clear = false;
break;
}
}
if (!clear) continue;
accepted.push(candidate);
@@ -165,15 +185,26 @@ export function cardScaleForAltitude(cameraHeightM) {
const h = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : 0;
if (h <= CCTV_CARD_SCALE_FULL_M) return { scale: 1, alpha: 1 };
if (h <= CCTV_CARD_SCALE_MID_M) {
const t = (h - CCTV_CARD_SCALE_FULL_M) / (CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M);
const t =
(h - CCTV_CARD_SCALE_FULL_M) /
(CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M);
const s = t * t * (3 - 2 * t); // smoothstep — no visible kink at either end
return { scale: 1 + (CCTV_CARD_SCALE_AT_MID - 1) * s, alpha: 1 };
}
const t = Math.min(1, (h - CCTV_CARD_SCALE_MID_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M));
const scale = CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t;
if (h >= CCTV_CARD_FADE_END_M) return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 };
const t = Math.min(
1,
(h - CCTV_CARD_SCALE_MID_M) /
(CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M),
);
const scale =
CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t;
if (h >= CCTV_CARD_FADE_END_M)
return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 };
if (h <= CCTV_CARD_FADE_START_M) return { scale, alpha: 1 };
const alpha = 1 - (h - CCTV_CARD_FADE_START_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
const alpha =
1 -
(h - CCTV_CARD_FADE_START_M) /
(CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
return { scale, alpha };
}
@@ -192,17 +223,23 @@ export function cardScaleForAltitude(cameraHeightM) {
* @param {number} [input.sinceLastLaunchMs] - Ms since the last fetch launch.
* @returns {{mode:('burst'|'steady'), launch:boolean}}
*/
export function cardFetchPolicy({ coldFill = false, inFlight = 0, sinceLastLaunchMs = Infinity } = {}) {
export function cardFetchPolicy({
coldFill = false,
inFlight = 0,
sinceLastLaunchMs = Infinity,
} = {}) {
if (coldFill) {
return {
mode: 'burst',
launch: inFlight < CCTV_CARD_FETCH_BURST_LIMIT
&& sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
launch:
inFlight < CCTV_CARD_FETCH_BURST_LIMIT &&
sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
};
}
return {
mode: 'steady',
launch: inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
launch:
inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
};
}
@@ -230,7 +267,12 @@ export function createFrameSlot() {
export function applyFrameResult(prev, result, nowMs) {
const base = prev || createFrameSlot();
if (result?.ok && result.frame) {
return { frame: result.frame, stamp: nowMs, failCount: 0, lastAttemptAt: nowMs };
return {
frame: result.frame,
stamp: nowMs,
failCount: 0,
lastAttemptAt: nowMs,
};
}
return {
frame: base.frame,
@@ -278,11 +320,17 @@ export function frameFetchDue(slot, refreshMs, nowMs) {
* @param {number} [cap]
* @returns {string[]} Ids to drop from the cache.
*/
export function planFrameCachePrune(slots, keepIds, cap = CCTV_FRAME_CACHE_MAX) {
export function planFrameCachePrune(
slots,
keepIds,
cap = CCTV_FRAME_CACHE_MAX,
) {
const keep = new Set(keepIds);
const spare = (Array.isArray(slots) ? slots : [])
.filter((slot) => slot && typeof slot.id === 'string' && !keep.has(slot.id))
.sort((a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id));
.sort(
(a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id),
);
const capacity = Math.max(0, cap - keep.size);
return spare.slice(capacity).map((slot) => slot.id);
}
+173 -44
View File
@@ -74,7 +74,11 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
const toPlane = Cesium.Cartesian3.subtract(planeOrigin, rayOrigin, scratchW);
const s = Cesium.Cartesian3.dot(toPlane, planeNormal) / denom;
if (s < 0) return null;
const hit = Cesium.Cartesian3.multiplyByScalar(rayDir, s, new Cesium.Cartesian3());
const hit = Cesium.Cartesian3.multiplyByScalar(
rayDir,
s,
new Cesium.Cartesian3(),
);
return Cesium.Cartesian3.add(rayOrigin, hit, hit);
}
@@ -89,7 +93,10 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
*/
export function ringAngle(hitPoint, center, basisA, basisB) {
const v = Cesium.Cartesian3.subtract(hitPoint, center, scratchW);
return Math.atan2(Cesium.Cartesian3.dot(v, basisB), Cesium.Cartesian3.dot(v, basisA));
return Math.atan2(
Cesium.Cartesian3.dot(v, basisB),
Cesium.Cartesian3.dot(v, basisA),
);
}
/**
@@ -150,7 +157,11 @@ function enuAxes(position) {
/** a*sa + b*sb (fresh Cartesian3). */
function combine2(a, sa, b, sb) {
const out = Cesium.Cartesian3.multiplyByScalar(a, sa, new Cesium.Cartesian3());
const out = Cesium.Cartesian3.multiplyByScalar(
a,
sa,
new Cesium.Cartesian3(),
);
const t = Cesium.Cartesian3.multiplyByScalar(b, sb, new Cesium.Cartesian3());
return Cesium.Cartesian3.add(out, t, out);
}
@@ -165,7 +176,12 @@ function combine2(a, sa, b, sb) {
function viewAxesFor(headingDeg, pitchDeg, axes) {
const h = toRadians(headingDeg);
const p = toRadians(pitchDeg);
const forwardHoriz = combine2(axes.east, Math.sin(h), axes.north, Math.cos(h));
const forwardHoriz = combine2(
axes.east,
Math.sin(h),
axes.north,
Math.cos(h),
);
const right = combine2(axes.east, Math.cos(h), axes.north, -Math.sin(h));
const view = combine2(forwardHoriz, Math.cos(p), axes.up, Math.sin(p));
return { forwardHoriz, right, view };
@@ -176,7 +192,12 @@ function ringPositions(center, radius, basisA, basisB) {
const positions = [];
for (let i = 0; i <= RING_SEGMENTS; i++) {
const angle = (i / RING_SEGMENTS) * 2 * Math.PI;
const offset = combine2(basisA, Math.cos(angle) * radius, basisB, Math.sin(angle) * radius);
const offset = combine2(
basisA,
Math.cos(angle) * radius,
basisB,
Math.sin(angle) * radius,
);
positions.push(Cesium.Cartesian3.add(center, offset, offset));
}
return positions;
@@ -202,7 +223,12 @@ function ringPositions(center, radius, basisA, basisB) {
* destroy: function(): void, isDragging: function(): boolean,
* isEnabled: function(): boolean}}
*/
export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, endPatch }) {
export function createCalibrationGizmo({
viewer,
getActiveRecord,
applyPatch,
endPatch,
}) {
let enabled = false;
let drag = null; // { part, startCal, basePose, refs... }
let hoveredId = null;
@@ -225,20 +251,24 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
function polylinePart(part, color, width, arrow = false) {
addEntity(part, {
...(arrow ? {
position: Cesium.Cartesian3.ZERO,
point: {
pixelSize: 14,
color,
outlineColor: Cesium.Color.BLACK.withAlpha(0.7),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
} : {}),
...(arrow
? {
position: Cesium.Cartesian3.ZERO,
point: {
pixelSize: 14,
color,
outlineColor: Cesium.Color.BLACK.withAlpha(0.7),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
}
: {}),
polyline: {
positions: [],
width,
material: arrow ? new Cesium.PolylineArrowMaterialProperty(color) : color.withAlpha(0.9),
material: arrow
? new Cesium.PolylineArrowMaterialProperty(color)
: color.withAlpha(0.9),
// Standard gizmo convention: parts behind geometry stay clearly
// visible, just dimmed (a street-level mount buries half the heading
// ring in sloped photogrammetry tiles — smoke-tested 2026-07-05).
@@ -286,16 +316,42 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
}
const mount = positions.mount;
const axes = enuAxes(mount);
const { forwardHoriz, right, view } = viewAxesFor(camera.headingDeg, camera.pitchDeg, axes);
const { forwardHoriz, right, view } = viewAxesFor(
camera.headingDeg,
camera.pitchDeg,
axes,
);
const rangeM = Math.max(1, Number(camera.rangeM) || 1);
const radius = Math.min(RING_RADIUS_MAX_M, Math.max(RING_RADIUS_MIN_M, rangeM * RING_RADIUS_FACTOR));
const radius = Math.min(
RING_RADIUS_MAX_M,
Math.max(RING_RADIUS_MIN_M, rangeM * RING_RADIUS_FACTOR),
);
const arrowLen = radius * ARROW_LENGTH_FACTOR;
entities.get('ring-heading').polyline.positions = ringPositions(mount, radius, axes.north, axes.east);
entities.get('ring-pitch').polyline.positions = ringPositions(mount, radius, forwardHoriz, axes.up);
const arrowTo = (dir) => [mount, Cesium.Cartesian3.add(
mount, Cesium.Cartesian3.multiplyByScalar(dir, arrowLen, new Cesium.Cartesian3()), new Cesium.Cartesian3()
)];
entities.get('ring-heading').polyline.positions = ringPositions(
mount,
radius,
axes.north,
axes.east,
);
entities.get('ring-pitch').polyline.positions = ringPositions(
mount,
radius,
forwardHoriz,
axes.up,
);
const arrowTo = (dir) => [
mount,
Cesium.Cartesian3.add(
mount,
Cesium.Cartesian3.multiplyByScalar(
dir,
arrowLen,
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
),
];
const eastArrow = arrowTo(axes.east);
const northArrow = arrowTo(axes.north);
const upArrow = arrowTo(axes.up);
@@ -306,8 +362,16 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
entities.get('move-up').polyline.positions = upArrow;
entities.get('move-up').position = upArrow[1];
entities.get('handle-range').position = positions.capCenter;
entities.get('handle-fov-l').position = Cesium.Cartesian3.midpoint(positions.tl, positions.bl, new Cesium.Cartesian3());
entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint(positions.tr, positions.br, new Cesium.Cartesian3());
entities.get('handle-fov-l').position = Cesium.Cartesian3.midpoint(
positions.tl,
positions.bl,
new Cesium.Cartesian3(),
);
entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint(
positions.tr,
positions.br,
new Cesium.Cartesian3(),
);
for (const entity of entities.values()) entity.show = true;
}
@@ -343,8 +407,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
return null;
}
if (typeof window !== 'undefined' && window.__gevGizmoDebug) {
console.debug('[CCTV:gizmo] drillPick @', windowPosition?.x, windowPosition?.y, '→',
JSON.stringify(results.map((r) => String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name))));
console.debug(
'[CCTV:gizmo] drillPick @',
windowPosition?.x,
windowPosition?.y,
'→',
JSON.stringify(
results.map((r) =>
String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name),
),
),
);
}
for (const picked of results) {
const part = gizmoPartFrom(picked);
@@ -379,7 +452,8 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
for (const [name, entity] of entities.entries()) {
if (!entity.point) continue;
const hot = hoveredId === `${GIZMO_ID_PREFIX}${name}`;
entity.point.pixelSize = (name === 'handle-range' ? 13 : 10) + (hot ? 4 : 0);
entity.point.pixelSize =
(name === 'handle-range' ? 13 : 10) + (hot ? 4 : 0);
}
setCursor(hoveredId ? 'grab' : '');
}
@@ -397,7 +471,11 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
const mount = Cesium.Cartesian3.clone(positions.mount);
const capCenter = Cesium.Cartesian3.clone(positions.capCenter);
const axes = enuAxes(mount);
const { forwardHoriz, right, view } = viewAxesFor(camera.headingDeg, camera.pitchDeg, axes);
const { forwardHoriz, right, view } = viewAxesFor(
camera.headingDeg,
camera.pitchDeg,
axes,
);
const startCal = { ...camera.calibration };
const basePose = { ...camera.basePose };
const state = {
@@ -405,7 +483,15 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
// mid-drag active-camera switch (voice select / auto-hop) onto a
// different camera — its basePose makes the captured offsets wrong.
record,
part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view,
part,
startCal,
basePose,
mount,
capCenter,
axes,
forwardHoriz,
right,
view,
effectiveRangeM: geometry.rangeM,
startAngle: null,
startParam: null,
@@ -419,8 +505,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
if (!hit) return false;
state.startAngle = ringAngle(hit, mount, forwardHoriz, axes.up); // 0 = level, + = up
} else if (part === 'move-east' || part === 'move-north' || part === 'move-up') {
const dir = part === 'move-east' ? axes.east : part === 'move-north' ? axes.north : axes.up;
} else if (
part === 'move-east' ||
part === 'move-north' ||
part === 'move-up'
) {
const dir =
part === 'move-east'
? axes.east
: part === 'move-north'
? axes.north
: axes.up;
state.axisDir = dir;
const t = closestParamOnAxis(ray.origin, ray.direction, mount, dir);
if (t === null) return false;
@@ -448,26 +543,49 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
function dragPatch(windowPosition) {
const ray = pickRay(windowPosition);
if (!ray || !drag) return null;
const { part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view } = drag;
const {
part,
startCal,
basePose,
mount,
capCenter,
axes,
forwardHoriz,
right,
view,
} = drag;
if (part === 'ring-heading') {
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, axes.up);
if (!hit) return null;
const angle = ringAngle(hit, mount, axes.north, axes.east);
return { headingDeg: startCal.headingDeg + toDeg(signedAngleDelta(drag.startAngle, angle)) };
return {
headingDeg:
startCal.headingDeg + toDeg(signedAngleDelta(drag.startAngle, angle)),
};
}
if (part === 'ring-pitch') {
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
if (!hit) return null;
const angle = ringAngle(hit, mount, forwardHoriz, axes.up);
return { pitchDeg: startCal.pitchDeg + toDeg(signedAngleDelta(drag.startAngle, angle)) };
return {
pitchDeg:
startCal.pitchDeg + toDeg(signedAngleDelta(drag.startAngle, angle)),
};
}
if (part === 'move-east' || part === 'move-north' || part === 'move-up') {
const t = closestParamOnAxis(ray.origin, ray.direction, mount, drag.axisDir);
const t = closestParamOnAxis(
ray.origin,
ray.direction,
mount,
drag.axisDir,
);
if (t === null) return null;
const delta = t - drag.startParam;
if (part === 'move-east') return { offsetEastM: startCal.offsetEastM + delta };
if (part === 'move-north') return { offsetNorthM: startCal.offsetNorthM + delta };
if (part === 'move-east')
return { offsetEastM: startCal.offsetEastM + delta };
if (part === 'move-north')
return { offsetNorthM: startCal.offsetNorthM + delta };
return { heightM: startCal.heightM + delta };
}
if (part === 'handle-range') {
@@ -478,11 +596,16 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
if (part === 'handle-fov-l' || part === 'handle-fov-r') {
const hit = rayPlaneIntersect(ray.origin, ray.direction, capCenter, view);
if (!hit) return null;
const lateral = Math.abs(Cesium.Cartesian3.dot(
Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()), right
));
const lateral = Math.abs(
Cesium.Cartesian3.dot(
Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()),
right,
),
);
const halfW = Math.max(0.5, lateral);
const fovDeg = toDeg(2 * Math.atan(halfW / Math.max(1, drag.effectiveRangeM)));
const fovDeg = toDeg(
2 * Math.atan(halfW / Math.max(1, drag.effectiveRangeM)),
);
return { fovDeg: fovDeg - basePose.fovDeg };
}
return null;
@@ -531,7 +654,13 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
return;
}
const patch = dragPatch(event.endPosition);
debugLog('dragPatch', drag.part, event.endPosition, '→', patch ? JSON.stringify(patch) : null);
debugLog(
'dragPatch',
drag.part,
event.endPosition,
'→',
patch ? JSON.stringify(patch) : null,
);
if (patch) applyPatch(patch, drag.record);
return;
}
+9 -8
View File
@@ -46,7 +46,7 @@ export function cameraHue(index) {
* @returns {{fill: Cesium.Color, fillActive: Cesium.Color, line: Cesium.Color, lineActive: Cesium.Color}}
*/
export function viewshedColors(hueDeg) {
const hue = ((Number(hueDeg) % 360) + 360) % 360 / 360;
const hue = (((Number(hueDeg) % 360) + 360) % 360) / 360;
return {
fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
fillActive: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE),
@@ -66,7 +66,13 @@ export function viewshedColors(hueDeg) {
* @returns {{positions: Float64Array, indices: Uint16Array}}
*/
export function frustumVolumeGeometryData(positions) {
const pts = [positions.mount, positions.tl, positions.tr, positions.br, positions.bl];
const pts = [
positions.mount,
positions.tl,
positions.tr,
positions.br,
positions.bl,
];
const flat = new Float64Array(15);
pts.forEach((p, i) => {
flat[i * 3] = p.x;
@@ -75,12 +81,7 @@ export function frustumVolumeGeometryData(positions) {
});
// apex=0, tl=1, tr=2, br=3, bl=4 — 4 side faces + far cap (2 triangles).
const indices = new Uint16Array([
0, 1, 2,
0, 2, 3,
0, 3, 4,
0, 4, 1,
1, 2, 3,
1, 3, 4,
0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1, 1, 2, 3, 1, 3, 4,
]);
return { positions: flat, indices };
}
+1 -2
View File
@@ -21,7 +21,7 @@ export function nextCockpitNearContacts(
) {
const previous = previousIds instanceof Set ? previousIds : new Set();
const addSq = Math.max(0, Number(addDistanceM) || 0) ** 2;
const keepSq = Math.max(addSq, (Math.max(0, Number(keepDistanceM) || 0) ** 2));
const keepSq = Math.max(addSq, Math.max(0, Number(keepDistanceM) || 0) ** 2);
const next = new Set();
for (const [id, distanceSq] of distancesSquared || []) {
@@ -32,4 +32,3 @@ export function nextCockpitNearContacts(
return next;
}
+27 -8
View File
@@ -42,7 +42,9 @@ export const CONTACT_MATCH_TIER = Object.freeze({
* @returns {string} Canonical form, or '' when there is nothing to compare.
*/
export function canonicalizeContactId(value) {
return String(value ?? '').replace(/[^a-z0-9]/gi, '').toUpperCase();
return String(value ?? '')
.replace(/[^a-z0-9]/gi, '')
.toUpperCase();
}
/**
@@ -57,16 +59,31 @@ export function canonicalizeContactId(value) {
* @param {string} [params.registration] Contact's registration, if any.
* @returns {number} A {@link CONTACT_MATCH_TIER} value; `NONE` when no match.
*/
export function rankContactMatch({ query, hex = '', callsign = '', registration = '' }) {
const rawQuery = String(query ?? '').trim().toLowerCase();
export function rankContactMatch({
query,
hex = '',
callsign = '',
registration = '',
}) {
const rawQuery = String(query ?? '')
.trim()
.toLowerCase();
if (!rawQuery) return CONTACT_MATCH_TIER.NONE;
if (String(hex ?? '').trim().toLowerCase() === rawQuery) return CONTACT_MATCH_TIER.HEX_EXACT;
if (
String(hex ?? '')
.trim()
.toLowerCase() === rawQuery
)
return CONTACT_MATCH_TIER.HEX_EXACT;
const callsignText = String(callsign ?? '').trim().toLowerCase();
const callsignText = String(callsign ?? '')
.trim()
.toLowerCase();
// Callsigns carry no separators, so they compare raw — canonicalizing them
// would silently widen the long-standing callsign contract.
if (callsignText && callsignText === rawQuery) return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
if (callsignText && callsignText === rawQuery)
return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
const canonicalQuery = canonicalizeContactId(rawQuery);
const canonicalRegistration = canonicalizeContactId(registration);
@@ -74,11 +91,13 @@ export function rankContactMatch({ query, hex = '', callsign = '', registration
return CONTACT_MATCH_TIER.REGISTRATION_EXACT;
}
if (callsignText && callsignText.startsWith(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
if (callsignText && callsignText.startsWith(rawQuery))
return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
if (canonicalQuery && canonicalRegistration.startsWith(canonicalQuery)) {
return CONTACT_MATCH_TIER.REGISTRATION_PREFIX;
}
if (callsignText && callsignText.includes(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
if (callsignText && callsignText.includes(rawQuery))
return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
if (canonicalQuery && canonicalRegistration.includes(canonicalQuery)) {
return CONTACT_MATCH_TIER.REGISTRATION_SUBSTRING;
}
+691
View File
@@ -0,0 +1,691 @@
/** Portable retained report-time playback. No scene or scheduling ownership. */
export const FIX_BYTES = 48;
export const FIX_FLAGS = Object.freeze({
VEHICLE_TIME: 1,
FEED_TIME: 2,
RECEIPT_TIME: 4,
FLOOR: 8,
BREAK: 16,
LIVE: 32,
});
const clampDelay = (n) => Math.max(25_000, Math.min(120_000, n));
export function createLagReservoir(capacity = 256) {
return { values: new Float64Array(capacity), count: 0, cursor: 0 };
}
function addLag(reservoir, value) {
if (!reservoir || !Number.isFinite(value) || value < 0) return;
reservoir.values[reservoir.cursor] = value;
reservoir.cursor = (reservoir.cursor + 1) % reservoir.values.length;
reservoir.count = Math.min(reservoir.count + 1, reservoir.values.length);
}
function percentile(reservoir, rank) {
if (!reservoir?.count) return 0;
const sorted = reservoir.values.slice(0, reservoir.count).sort();
return sorted[Math.ceil(sorted.length * rank) - 1];
}
/** Optional shared payload budget. Call trimTrack on lower-priority tracks first. */
export function createHistoryBudget(maxBytes = 32 * 1024 * 1024) {
return { maxBytes, allocatedBytes: 0 };
}
/** Numeric fields are reserved up front, including unknown mesh heights. */
export function createFixSample() {
return {
t: NaN,
lat: NaN,
lon: NaN,
heightM: NaN,
h: NaN,
receivedAt: NaN,
bearingDeg: NaN,
flags: 0,
epoch: 0,
seq: -1,
};
}
export function createTrack({
capacity = 128,
retentionMs = 900_000,
policy = {},
budget = null,
} = {}) {
capacity = Math.max(2, Math.min(128, Math.floor(capacity)));
return {
capacity,
retentionMs,
policy,
budget,
metricsScratchA: createFixSample(),
metricsScratchB: createFixSample(),
storage: new DataView(new ArrayBuffer(0)),
slots: 0,
head: 0,
count: 0,
baseSeq: 0,
revision: 0,
truncated: false,
epochs: new Map(),
lag: createLagReservoir(32),
targetDelayMs: 25_000,
displayT: NaN,
wallAnchor: NaN,
monoAnchor: NaN,
lastMono: NaN,
offset: 0,
rate: 1,
phase: 'buffering',
resets: 0,
resetReason: null,
acceptedAt: NaN,
acceptedMono: NaN,
resumePending: false,
bracket: 0,
metricsRevision: -1,
metricsIndex: -1,
from: createFixSample(),
to: createFixSample(),
metrics: { speed: NaN, course: NaN, nextMotionT: Infinity },
latest: createFixSample(),
pending: null,
pendingCount: 0,
};
}
function address(track, index) {
return ((track.head + index) % track.slots) * FIX_BYTES;
}
export function readFix(track, index, out = {}) {
if (index < 0 || index >= track.count) return null;
const v = track.storage,
o = address(track, index);
out.t = v.getFloat64(o);
out.lat = v.getFloat64(o + 8);
out.lon = v.getFloat64(o + 16);
out.heightM = v.getFloat32(o + 24);
out.h = out.heightM;
out.receivedAt = v.getFloat64(o + 28);
out.bearingDeg = v.getFloat32(o + 36);
out.flags = v.getUint32(o + 40);
out.epoch = v.getUint32(o + 44);
out.seq = track.baseSeq + index;
return out;
}
function writeFix(track, index, fix) {
const v = track.storage,
o = address(track, index);
v.setFloat64(o, fix.t);
v.setFloat64(o + 8, fix.lat);
v.setFloat64(o + 16, fix.lon);
v.setFloat32(o + 24, fix.heightM ?? fix.h ?? NaN);
v.setFloat64(o + 28, fix.receivedAt ?? NaN);
v.setFloat32(o + 36, fix.bearingDeg ?? NaN);
v.setUint32(o + 40, fix.flags || 0);
v.setUint32(o + 44, fix.epoch || 0);
}
function resize(track, slots) {
const bytes = slots * FIX_BYTES,
delta = bytes - track.storage.byteLength;
if (
delta > 0 &&
track.budget &&
track.budget.allocatedBytes + delta > track.budget.maxBytes
)
return false;
const old = track.storage,
head = track.head,
oldSlots = track.slots;
const next = new Uint8Array(bytes),
previous = new Uint8Array(old.buffer);
for (let i = 0; i < track.count; i++) {
const o = ((head + i) % oldSlots) * FIX_BYTES;
next.set(previous.subarray(o, o + FIX_BYTES), i * FIX_BYTES);
}
track.storage = new DataView(next.buffer);
track.slots = slots;
track.head = 0;
if (track.budget) track.budget.allocatedBytes += delta;
return true;
}
export function destroyTrack(track) {
if (track.budget) track.budget.allocatedBytes -= track.storage.byteLength;
track.storage = new DataView(new ArrayBuffer(0));
track.count = 0;
track.slots = 0;
track.epochs.clear();
}
function timeAt(track, i) {
return track.storage.getFloat64(address(track, i));
}
function dropOldest(track) {
track.head = (track.head + 1) % track.slots;
track.count--;
track.baseSeq++;
track.revision++;
track.truncated = true;
}
/** Retention never evicts the bracket currently on screen. */
export function trimTrack(track, nowMs, keep = track.capacity) {
while (
track.count > 2 &&
(track.count > keep || timeAt(track, 1) < nowMs - track.retentionMs)
) {
if (Number.isFinite(track.displayT) && timeAt(track, 1) > track.displayT)
break;
dropOldest(track);
}
if (keep < track.slots && track.count <= keep)
resize(track, Math.max(2, track.count));
const used = new Set();
for (let i = 0; i < track.count; i++)
used.add(track.storage.getUint32(address(track, i) + 44));
for (const id of track.epochs.keys())
if (!used.has(id)) track.epochs.delete(id);
}
export function validFix(fix) {
return (
Number.isFinite(fix?.t) &&
fix.t >= 0 &&
Number.isFinite(fix.lat) &&
Math.abs(fix.lat) <= 90 &&
Number.isFinite(fix.lon) &&
Math.abs(fix.lon) <= 180
);
}
function distance(a, b) {
const lon = ((b.lon - a.lon + 540) % 360) - 180;
return Math.hypot(
(b.lat - a.lat) * 111320,
lon * 111320 * Math.cos((a.lat * Math.PI) / 180),
);
}
function append(track, fix) {
if (track.count === track.slots && track.slots < track.capacity)
resize(track, Math.min(track.capacity, Math.max(8, track.slots * 2)));
if (track.count === track.slots) {
if (track.count < 2) return false;
if (Number.isFinite(track.displayT) && timeAt(track, 1) > track.displayT) {
// Pin both active ends; drop the next future observation and mark the gap.
if (track.count < 4) return false;
const scratch = {};
for (let i = 2; i < track.count - 1; i++) {
readFix(track, i + 1, scratch);
if (i === 2) scratch.flags |= FIX_FLAGS.BREAK;
writeFix(track, i, scratch);
}
track.count--;
track.truncated = true;
} else dropOldest(track);
}
writeFix(track, track.count++, fix);
track.revision++;
return true;
}
export function insertFix(track, fix, observation = {}) {
if (
!validFix(fix) ||
(Number.isFinite(observation.wallNowMs) &&
fix.t > observation.wallNowMs + 10_000)
)
return { accepted: false, reason: 'malformed' };
const last = readFix(track, track.count - 1, track.latest);
if (last && fix.t === last.t)
return {
accepted: false,
reason:
fix.lat === last.lat && fix.lon === last.lon ? 'repeat' : 'conflict',
};
let reason = last ? 'moved' : 'first';
if (
last &&
(fix.t < last.t || (track.policy.accept && !track.policy.accept(last, fix)))
) {
reason = fix.t < last.t ? 'out-of-order' : 'implausible';
const prior = track.pending;
if (
!prior ||
prior.reason !== reason ||
fix.t <= prior.t ||
(track.policy.accept && !track.policy.accept(prior, fix))
) {
if (!prior || fix.t !== prior.t) {
track.pending = { ...fix, reason };
track.pendingCount = 1;
}
return { accepted: false, reason };
}
track.pendingCount++;
const required = reason === 'out-of-order' ? 3 : 2;
if (track.pendingCount < required) {
track.pending = { ...fix, reason };
return { accepted: false, reason };
}
// Clock correction starts a new time domain. A spatial replacement retains a visible gap.
if (reason === 'out-of-order') {
track.count = 0;
track.head = 0;
track.baseSeq += 128;
}
const epoch = (last.epoch + 1) >>> 0;
append(track, {
...prior,
epoch,
flags: (prior.flags || 0) | FIX_FLAGS.BREAK,
});
fix = { ...fix, epoch };
if (reason === 'out-of-order') track.displayT = NaN;
track.resetReason = reason;
track.resets++;
}
const receivedAt =
observation.receivedAt ?? fix.receivedAt ?? observation.wallNowMs ?? fix.t;
trimTrack(track, receivedAt);
const accepted = append(track, {
...fix,
receivedAt,
flags: (fix.flags || 0) | FIX_FLAGS.LIVE,
});
if (accepted) {
if (!observation.replayed) {
if (last && fix.t > last.t && reason === 'moved') {
addLag(track.lag, receivedAt - last.t);
addLag(track.policy.feedLag, receivedAt - last.t);
track.targetDelayMs = clampDelay(
Math.max(
percentile(track.lag, 0.95),
percentile(track.policy.feedLag, 0.9),
) + 5000,
);
} else if (!last)
track.targetDelayMs = clampDelay(
Math.max(0, receivedAt - fix.t) + 20_000,
);
}
track.acceptedAt = receivedAt;
track.acceptedMono = observation.monoNowMs ?? NaN;
track.resumePending = true;
track.pending = null;
track.pendingCount = 0;
}
return { accepted, reason: accepted ? reason : 'capacity' };
}
/** Bounded backfill; live positions win conflicts and the clock/health are untouched. */
export function mergeHistory(track, fixes) {
const merged = new Map();
for (const fix of fixes.slice(-128))
if (
validFix(fix) &&
(!Number.isFinite(track.acceptedAt) ||
fix.t >= track.acceptedAt - track.retentionMs)
)
merged.set(fix.t, { ...fix, flags: (fix.flags || 0) & ~FIX_FLAGS.LIVE });
for (let i = 0; i < track.count; i++) {
const fix = readFix(track, i);
merged.set(fix.t, fix);
}
const retained = Array.from({ length: track.count }, (_, i) =>
readFix(track, i),
);
const active = Number.isFinite(track.displayT)
? retained.findLastIndex((f) => f.t <= track.displayT)
: -1;
const pinnedFrom = retained[active],
pinnedTo = retained[active + 1];
const trusted = new Set(retained.map((f) => f.t));
const candidates = [...merged.values()]
.sort((a, b) => a.t - b.t)
.filter(
(f) =>
trusted.has(f.t) ||
!pinnedFrom ||
!pinnedTo ||
f.t < pinnedFrom.t ||
f.t > pinnedTo.t,
);
const ordered = [];
// Start from an admitted live position, including when walking backwards
// into retained history. A lone outlier is quarantined in either direction.
// Never borrow the live pending observation as a second vote for itself.
function admit(sequence, anchor, backwards = false) {
let last = anchor,
pending = null;
const plausible = (a, b) =>
!track.policy.accept ||
(backwards ? track.policy.accept(b, a) : track.policy.accept(a, b));
for (const fix of sequence) {
if (trusted.has(fix.t) || !last || plausible(last, fix)) {
ordered.push(fix);
last = fix;
pending = null;
} else if (pending && plausible(pending, fix)) {
// Two distinct coherent observations establish a replacement segment.
// Keep a visible gap between it and the authoritative live path.
if (backwards) last.flags |= FIX_FLAGS.BREAK;
else pending.flags |= FIX_FLAGS.BREAK;
ordered.push(pending, fix);
last = fix;
pending = null;
} else pending = fix;
}
}
const first = retained[0];
if (first) {
admit(
candidates.filter((f) => f.t < first.t).reverse(),
merged.get(first.t),
true,
);
admit(
candidates.filter((f) => f.t >= first.t),
null,
);
} else admit(candidates, null);
ordered.sort((a, b) => a.t - b.t);
while (ordered.length > track.capacity) {
const i = ordered.findIndex(
(f) => f.t !== pinnedFrom?.t && f.t !== pinnedTo?.t,
);
if (i > 0 && i + 1 < ordered.length)
ordered[i + 1].flags |= FIX_FLAGS.BREAK;
ordered.splice(Math.max(0, i), 1);
track.truncated = true;
}
for (let i = 1; i < ordered.length; i++) {
if (track.policy.accept && !track.policy.accept(ordered[i - 1], ordered[i]))
ordered[i].flags |= FIX_FLAGS.BREAK;
}
track.count = 0;
track.head = 0;
track.baseSeq += 128;
for (const fix of ordered) append(track, fix);
track.revision++;
track.bracket = 0;
}
export function correctHeight(track, seq, heightM) {
const i = seq - track.baseSeq;
if (i < 0 || i >= track.count || !Number.isFinite(heightM)) return false;
const o = address(track, i),
value = Math.fround(heightM);
if (track.storage.getFloat32(o + 24) === value) return false;
track.storage.setFloat32(o + 24, value);
track.storage.setUint32(
o + 40,
track.storage.getUint32(o + 40) | FIX_FLAGS.FLOOR,
);
track.revision++;
return true;
}
function anchoredWall(track, clocks) {
if (!Number.isFinite(track.wallAnchor)) {
track.wallAnchor = clocks.wallNowMs;
track.monoAnchor = clocks.monoNowMs;
}
return track.wallAnchor + Math.max(0, clocks.monoNowMs - track.monoAnchor);
}
/** Stable numeric fields avoid dictionary properties and boxed doubles in hot samples. */
export function createSample() {
return {
displayT: NaN,
revision: 0,
nextWakeMonoMs: Infinity,
phase: 'buffering',
motion: 'unknown',
fromSeq: -1,
toSeq: -1,
lat: NaN,
lon: NaN,
heightM: NaN,
segmentCourseDeg: NaN,
segmentSpeedMps: NaN,
fraction: NaN,
fromT: NaN,
toT: NaN,
nextMotionT: Infinity,
latestReportAgeMs: NaN,
actualDelayMs: NaN,
};
}
/** Sample an explicit report time into caller-owned storage. */
export function sampleAt(track, displayTimeMs, out) {
out.displayT = displayTimeMs;
return sampleCurrent(track, out);
}
/** The frame path keeps its changing double in storage across this call. */
function sampleCurrent(track, out) {
const sampleTime = out.displayT;
out.revision = track.revision;
out.nextWakeMonoMs = Infinity;
out.phase = track.phase;
out.motion = 'unknown';
out.fromSeq = -1;
out.toSeq = -1;
out.lat = NaN;
out.lon = NaN;
out.heightM = NaN;
out.segmentCourseDeg = NaN;
out.segmentSpeedMps = 0;
if (!track.count) return out;
let i = Math.min(track.bracket, track.count - 1);
if (timeAt(track, i) > sampleTime) i = 0;
while (i + 1 < track.count && timeAt(track, i + 1) <= sampleTime) i++;
track.bracket = i;
const a = readFix(track, i, track.from);
const j = sampleTime < a.t ? i : Math.min(i + 1, track.count - 1);
const b = readFix(track, j, track.to);
const continuous =
i !== j && a.epoch === b.epoch && !(b.flags & FIX_FLAGS.BREAK);
out.fromSeq = a.seq;
out.toSeq = continuous ? b.seq : a.seq;
out.fraction = continuous
? Math.max(0, Math.min(1, (sampleTime - a.t) / (b.t - a.t)))
: 1;
out.fromT = a.t;
out.toT = continuous ? b.t : a.t;
if (
track.metricsRevision !== track.revision ||
track.metricsIndex !== i ||
track.metricsTo !== j
) {
const metres = continuous ? distance(a, b) : 0;
const metrics = track.metrics;
metrics.speed = continuous ? (metres * 1000) / (b.t - a.t) : 0;
metrics.course = NaN;
if (metres >= (track.policy.courseMinTravelM ?? 12)) {
metrics.course =
((Math.atan2(
(((b.lon - a.lon + 540) % 360) - 180) *
Math.cos((a.lat * Math.PI) / 180),
b.lat - a.lat,
) *
180) /
Math.PI +
360) %
360;
} else {
// Tiny displacement holds the last reliable course in this epoch.
// With no reliable displacement yet, use the earliest known bearing.
const p = track.metricsScratchA,
q = track.metricsScratchB;
for (let k = i; k >= 0; k--) {
readFix(track, k, q);
if (q.epoch !== a.epoch) break;
if (Number.isFinite(q.bearingDeg)) metrics.course = q.bearingDeg;
if (k === 0 || q.flags & FIX_FLAGS.BREAK) continue;
readFix(track, k - 1, p);
if (
p.epoch === q.epoch &&
distance(p, q) >= (track.policy.courseMinTravelM ?? 12)
) {
metrics.course =
((Math.atan2(
(((q.lon - p.lon + 540) % 360) - 180) *
Math.cos((p.lat * Math.PI) / 180),
q.lat - p.lat,
) *
180) /
Math.PI +
360) %
360;
break;
}
}
}
metrics.nextMotionT = Infinity;
const p = track.metricsScratchA,
q = track.metricsScratchB;
for (let k = i; k + 1 < track.count; k++) {
readFix(track, k, p);
readFix(track, k + 1, q);
if (
p.lat !== q.lat ||
p.lon !== q.lon ||
p.epoch !== q.epoch ||
q.flags & FIX_FLAGS.BREAK
) {
metrics.nextMotionT =
p.epoch !== q.epoch || q.flags & FIX_FLAGS.BREAK ? q.t : p.t;
break;
}
}
track.metricsRevision = track.revision;
track.metricsIndex = i;
track.metricsTo = j;
}
const f = out.fraction;
out.lat = continuous ? a.lat + (b.lat - a.lat) * f : a.lat;
out.lon = continuous
? a.lon + (((b.lon - a.lon + 540) % 360) - 180) * f
: a.lon;
if (out.lon > 180) out.lon -= 360;
if (out.lon < -180) out.lon += 360;
out.heightM = continuous
? a.heightM + (b.heightM - a.heightM) * f
: a.heightM;
out.segmentCourseDeg = track.metrics.course;
out.segmentSpeedMps = track.metrics.speed;
out.motion = continuous
? track.metrics.speed > 0
? 'moving'
: 'stopped'
: 'unknown';
out.nextMotionT = track.metrics.nextMotionT;
out.latestReportAgeMs = Number.isFinite(track.wallAnchor)
? track.wallAnchor +
track.lastMono -
track.monoAnchor -
timeAt(track, track.count - 1)
: NaN;
out.actualDelayMs = Number.isFinite(track.wallAnchor)
? track.wallAnchor + track.lastMono - track.monoAnchor - sampleTime
: NaN;
return out;
}
export function advance(track, clocks, out) {
const wall = anchoredWall(track, clocks),
previousMono = track.lastMono;
const dt = Number.isFinite(previousMono)
? Math.max(0, clocks.monoNowMs - previousMono)
: 0;
track.lastMono = clocks.monoNowMs;
if (!track.count) return sampleAt(track, NaN, out);
const newest = timeAt(track, track.count - 1),
oldest = timeAt(track, 0);
const ceiling = Math.min(newest, wall - track.targetDelayMs + track.offset);
const delayBlocked =
track.offset === 0 &&
track.phase !== 'playing' &&
ceiling < track.displayT &&
track.displayT < newest;
if (!Number.isFinite(track.displayT))
track.displayT = Math.min(
newest,
wall - track.targetDelayMs + track.offset,
);
else if (
track.offset === 0 &&
track.rate !== 0 &&
(ceiling - track.displayT > 90_000 ||
(track.truncated && track.displayT < oldest))
) {
track.resetReason =
ceiling - track.displayT > 90_000 ? 'backlog' : 'unavailable-bracket';
track.displayT = ceiling;
track.resets++;
} else if (track.rate !== 0) {
// While playing, finish the active segment at 1x even if lag grows.
const activeCeiling =
track.offset < 0 || track.phase === 'playing' ? newest : ceiling;
let next = track.displayT + dt * track.rate;
if (track.phase === 'held' && track.resumePending) {
const sinceReceipt = Number.isFinite(track.acceptedMono)
? Math.max(0, clocks.monoNowMs - track.acceptedMono)
: 0;
next = track.displayT + Math.min(dt, sinceReceipt) * track.rate;
}
track.displayT = Math.max(track.displayT, Math.min(next, activeCeiling));
}
track.phase =
track.rate === 0
? 'paused'
: track.displayT < oldest || delayBlocked
? 'buffering'
: track.displayT >= newest
? 'held'
: 'playing';
track.resumePending = false;
out.displayT = track.displayT;
sampleCurrent(track, out);
if (out.motion !== 'moving' || track.phase !== 'playing') {
const boundary = Math.max(oldest, out.nextMotionT);
if (
Number.isFinite(boundary) &&
boundary > track.displayT &&
track.rate > 0
)
out.nextWakeMonoMs =
clocks.monoNowMs + (boundary - track.displayT) / track.rate;
}
if (delayBlocked && track.rate > 0)
out.nextWakeMonoMs =
clocks.monoNowMs + Math.max(1, track.displayT - ceiling);
if (out.latestReportAgeMs > (track.policy.staleMs ?? 90_000))
out.motion = 'stale';
return out;
}
export function seek(track, displayTimeMs, clocks) {
if (!Number.isFinite(displayTimeMs))
throw new TypeError('A seek needs a finite time');
anchoredWall(track, clocks);
track.displayT = displayTimeMs;
track.offset = -1;
track.rate = 0;
track.phase = 'paused';
track.lastMono = clocks.monoNowMs;
track.bracket = 0;
track.resets++;
track.resetReason = 'seek';
}
export function setTimeOffsetMs(track, offset, clocks) {
if (!Number.isFinite(offset) || offset > 0)
throw new RangeError('Offset must be zero or negative');
if (Number.isFinite(track.displayT)) {
track.resets++;
track.resetReason = 'time-offset';
}
track.offset = offset;
track.rate = 1;
track.displayT = anchoredWall(track, clocks) - track.targetDelayMs + offset;
track.lastMono = clocks.monoNowMs;
track.phase = 'buffering';
track.bracket = 0;
}
export function setRate(track, rate, clocks) {
if (!Number.isFinite(rate) || rate < 0 || rate > 16)
throw new RangeError('Rate must be in [0, 16]');
if (track.offset === 0 && rate !== 0 && rate !== 1)
throw new RangeError('Live playback uses 1x or hold');
track.rate = rate;
track.lastMono = clocks.monoNowMs;
track.phase = rate === 0 ? 'paused' : 'buffering';
}
+302
View File
@@ -0,0 +1,302 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createTrack,
createLagReservoir,
createHistoryBudget,
insertFix,
readFix,
advance,
sampleAt,
seek,
setRate,
setTimeOffsetMs,
mergeHistory,
correctHeight,
trimTrack,
destroyTrack,
FIX_BYTES,
FIX_FLAGS,
} from './contactPlayback.js';
const fix = (t, metres = t / 100) => ({
t,
lat: 42 + metres / 111320,
lon: -71,
bearingDeg: 0,
});
const clocks = (t) => ({ wallNowMs: t, monoNowMs: t });
const put = (track, t, metres, receipt = t) =>
insertFix(track, fix(t, metres), { receivedAt: receipt, wallNowMs: receipt });
test('10 s reports / 15 s polls and a 6 m crawl move at report speed on ordinary frames', () => {
for (const speed of [10, 0.6]) {
const track = createTrack(),
out = {};
let prior = null,
moving = 0,
pollJumps = 0;
for (let now = 5000; now <= 180000; now += 16) {
if (
!prior ||
Math.floor((now - 5000) / 15000) !== Math.floor((now - 5016) / 15000)
) {
const t = Math.floor((now - 5000) / 10000) * 10000;
put(track, t, (t / 1000) * speed, now);
}
advance(track, clocks(now), out);
if (prior && out.displayT >= 0 && prior.displayT >= 0) {
const d = (out.lat - prior.lat) * 111320;
const dt = out.displayT - prior.displayT;
assert.ok(dt >= 0 && dt <= 16.0001);
if (dt > 0 && d > 0) {
assert.ok(Math.abs(d / (dt / 1000) - speed) < speed * 0.02);
moving++;
}
if (d > speed * 0.0161) pollJumps++;
}
prior = { ...out };
}
assert.ok(
moving > 5000,
`non-vacuous ${speed} m/s trace: ${moving} moving frames`,
);
assert.equal(pollJumps, 0);
}
});
test('stationary observations survive, schedule departure, and distinguish underflow', () => {
const track = createTrack(),
out = {};
put(track, 0, 0);
put(track, 15000, 0);
put(track, 30000, 6);
setTimeOffsetMs(track, 0, clocks(35000));
advance(track, clocks(35000), out);
assert.equal(out.motion, 'stopped');
assert.equal(out.nextWakeMonoMs, 40000);
advance(track, clocks(40000), out);
assert.equal(out.motion, 'moving');
advance(track, clocks(55000), out);
assert.equal(out.phase, 'held');
assert.equal(out.segmentSpeedMps, 0);
});
test('same-time conflicts and invalid coordinates never make zero-duration segments', () => {
const track = createTrack();
put(track, 0, 0);
assert.equal(put(track, 0, 0).reason, 'repeat');
assert.equal(put(track, 0, 1).reason, 'conflict');
for (const value of [
{ t: 1, lat: 91, lon: 0 },
{ t: 1, lat: 0, lon: NaN },
{ t: -1, lat: 0, lon: 0 },
])
assert.equal(insertFix(track, value).accepted, false);
assert.equal(track.count, 1);
assert.equal(put(track, 15000, 0).accepted, true);
});
test('ring wrap retains 15 minutes and pins an active bracket under capacity pressure', () => {
const track = createTrack({ capacity: 8 });
for (let i = 0; i < 8; i++) put(track, i * 10000, i);
seek(track, 5000, clocks(80000));
for (let i = 8; i < 30; i++) put(track, i * 10000, i);
const sample = sampleAt(track, 5000, {});
assert.equal(sample.fromT, 0);
assert.equal(sample.toT, 10000);
assert.equal(track.count, 8);
assert.equal(track.storage.byteLength, 8 * FIX_BYTES);
assert.equal(track.truncated, true);
assert.ok(readFix(track, 2).flags & FIX_FLAGS.BREAK);
seek(track, 250000, clocks(300000));
trimTrack(track, 300000, 2);
assert.ok(track.count <= 5);
});
test('backward seek and history merge leave clock and live health alone', () => {
const track = createTrack(),
out = {};
put(track, 30000, 30);
put(track, 45000, 45);
seek(track, 37500, clocks(80000));
const health = track.acceptedAt;
mergeHistory(track, [fix(0, 0), fix(15000, 15), fix(30000, 999)]);
assert.equal(track.displayT, 37500);
assert.equal(track.acceptedAt, health);
assert.equal(readFix(track, 2).lat, fix(30000, 30).lat);
seek(track, 7500, clocks(80000));
advance(track, clocks(90000), out);
assert.equal(out.displayT, 7500);
assert.equal(out.phase, 'paused');
setRate(track, 1, clocks(90000));
advance(track, clocks(91000), out);
assert.equal(out.displayT, 8500);
});
test('system-clock changes do not alter animation elapsed time', () => {
const track = createTrack(),
out = {};
put(track, 0, 0);
put(track, 60000, 600);
advance(track, clocks(60000), out);
const before = out.displayT;
advance(track, { wallNowMs: 999999999, monoNowMs: 60016 }, out);
assert.equal(out.displayT - before, 16);
setTimeOffsetMs(track, -10000, clocks(60016));
assert.equal(track.resetReason, 'time-offset');
assert.equal(track.resets, 1);
});
test('a 90 s outage holds, then resumes without a sprint; backlog reset adds no second delay', () => {
const track = createTrack(),
out = {};
put(track, 0, 0);
put(track, 15000, 15);
for (let t = 25000; t <= 120000; t += 100) advance(track, clocks(t), out);
assert.equal(out.displayT, 15000);
put(track, 105000, 105, 120000);
advance(track, clocks(120100), out);
assert.ok(out.displayT - 15000 <= 100);
track.targetDelayMs = 25000;
put(track, 300000, 300, 300000);
track.targetDelayMs = 25000;
advance(track, clocks(300000), out);
assert.equal(track.resets, 1);
assert.equal(track.resetReason, 'backlog');
assert.equal(out.displayT, 275000);
});
test('lag measures receipt of current minus report of previous and ignores cached deliveries', () => {
const feedLag = createLagReservoir(),
track = createTrack({ policy: { feedLag } });
put(track, 0, 0, 5000);
put(track, 10000, 100, 20000);
assert.equal(track.lag.values[0], 20000);
put(track, 10000, 100, 80000);
assert.equal(track.lag.count, 1);
put(track, 20000, 200, 50000);
assert.equal(track.targetDelayMs, 45000);
});
test('displayed bearing never reads a future fix; height corrections revise typed storage', () => {
const track = createTrack();
insertFix(track, { ...fix(0, 0), bearingDeg: 90 });
insertFix(track, { ...fix(15000, 6), bearingDeg: 270 });
const out = sampleAt(track, 7500, {});
assert.equal(out.segmentCourseDeg, 90);
assert.ok(Number.isNaN(out.heightM));
correctHeight(track, out.fromSeq, 11.5);
correctHeight(track, out.toSeq, 21.5);
assert.equal(sampleAt(track, 7500, out).heightM, 16.5);
});
test('allocation budget is hard and destruction returns payload bytes', () => {
const budget = createHistoryBudget(48 * 16),
tracks = Array.from({ length: 3 }, () => createTrack({ budget }));
for (const track of tracks)
for (let t = 0; t < 100; t++) put(track, t * 1000, t);
assert.ok(budget.allocatedBytes <= budget.maxBytes);
for (const track of tracks) destroyTrack(track);
assert.equal(budget.allocatedBytes, 0);
});
test('tiny displacement holds a preceding reliable course instead of a changed bearing', () => {
const track = createTrack();
insertFix(track, { ...fix(0, 0), bearingDeg: 90 });
insertFix(track, { ...fix(15000, 100), bearingDeg: 270 });
insertFix(track, { ...fix(30000, 106), bearingDeg: 180 });
assert.equal(sampleAt(track, 20000, {}).segmentCourseDeg, 0);
});
test('an epoch gap wakes at its far boundary without drawing a connecting segment', () => {
const track = createTrack(),
out = {};
insertFix(track, { ...fix(0, 0), epoch: 1 });
insertFix(track, { ...fix(30000, 100), epoch: 2 });
advance(track, clocks(40000), out);
assert.equal(out.segmentSpeedMps, 0);
assert.equal(out.nextWakeMonoMs, 65000);
});
test('increased lag is adopted while rebuffering, with one monotonic wake and no slow playback', () => {
const track = createTrack(),
out = {};
put(track, 0, 0);
put(track, 15000, 15);
for (let now = 25000; now <= 120000; now += 100)
advance(track, clocks(now), out);
insertFix(track, fix(105000, 105), {
receivedAt: 120000,
wallNowMs: 120000,
monoNowMs: 120000,
});
advance(track, clocks(120100), out);
assert.equal(out.displayT, 15000);
assert.equal(out.phase, 'buffering');
assert.equal(out.nextWakeMonoMs, 125000);
advance(track, { wallNowMs: -9999999, monoNowMs: 125000 }, out);
assert.equal(out.displayT, 15000);
advance(track, { wallNowMs: 9999999, monoNowMs: 125016 }, out);
assert.equal(out.displayT, 15016);
});
test('capacity rejection leaves both lag reservoirs and clock policy unchanged', () => {
const feedLag = createLagReservoir();
const track = createTrack({ capacity: 2, policy: { feedLag } });
put(track, 0, 0);
put(track, 10000, 10);
seek(track, 5000, clocks(10000));
const policyState = () =>
structuredClone({
lag: track.lag,
feedLag,
targetDelayMs: track.targetDelayMs,
acceptedAt: track.acceptedAt,
acceptedMono: track.acceptedMono,
resumePending: track.resumePending,
displayT: track.displayT,
wallAnchor: track.wallAnchor,
monoAnchor: track.monoAnchor,
resetReason: track.resetReason,
resets: track.resets,
});
const before = policyState();
assert.deepEqual(put(track, 20000, 20, 300000), {
accepted: false,
reason: 'capacity',
});
assert.deepEqual(policyState(), before);
});
test('sampleAt writes the supplied output without replacing nested scratch', () => {
const track = createTrack();
put(track, 0, 0);
put(track, 15000, 150);
const out = {},
from = track.from,
to = track.to,
metrics = track.metrics;
for (let i = 0; i < 10000; i++) {
assert.equal(sampleAt(track, i, out), out);
assert.equal(track.from, from);
assert.equal(track.to, to);
assert.equal(track.metrics, metrics);
}
});
test('backfill pins the live bracket and quarantines outliers on either side', () => {
const track = createTrack({
policy: {
accept: (a, b) =>
Math.abs(b.lat - a.lat) * 111320 <= ((b.t - a.t) / 1000) * 36,
},
});
put(track, 10000, 0);
put(track, 25000, 100);
seek(track, 17500, clocks(40000));
const before = { ...sampleAt(track, 17500, {}) };
mergeHistory(track, [fix(0, -111320), fix(17500, 25), fix(40000, 111320)]);
const after = sampleAt(track, 17500, {});
assert.equal(track.count, 2);
assert.equal(after.fromT, before.fromT);
assert.equal(after.toT, before.toT);
assert.equal(after.lat, before.lat);
mergeHistory(track, [fix(40000, 111320), fix(55000, 111420)]);
assert.equal(
track.count,
4,
'two coherent replacement fixes can be retained',
);
assert.ok(readFix(track, 2).flags & FIX_FLAGS.BREAK);
assert.equal(sampleAt(track, 30000, {}).lat, fix(25000, 100).lat);
});
+229
View File
@@ -0,0 +1,229 @@
import * as Cesium from 'cesium';
const ALPHA_STOPS = [
[0, 0.8],
[120000, 0.45],
[600000, 0.2],
[900000, 0.08],
];
export function trailAlpha(ageMs) {
const stops = ALPHA_STOPS;
for (let i = 1; i < stops.length; i++) {
if (ageMs === stops[i][0]) return stops[i][1];
if (ageMs < stops[i][0]) {
const [a, x] = stops[i - 1],
[b, y] = stops[i];
return x + ((y - x) * Math.max(0, ageMs - a)) / (b - a);
}
}
return 0.08;
}
/** One static history batch and one frequently updated head. Never entities. */
export function createContactTrailRenderer(scene) {
const heads = scene.primitives.add(new Cesium.PolylineCollection());
const backing = heads.add({
positions: [],
width: 4,
show: false,
material: Cesium.Material.fromType('Color', {
color: new Cesium.Color(0.02, 0.03, 0.05, 0.8),
}),
});
const head = heads.add({
positions: [],
width: 2,
show: false,
material: Cesium.Material.fromType('Color', {
color: Cesium.Color.WHITE.clone(),
}),
});
let body = null,
segments = [],
revision = -1,
visible = true,
style = '#5EF08A';
let lastSecond = -1,
completed = -1,
active = null;
const styleColor = Cesium.Color.fromCssColorString(style);
const attributeIds = [];
const color = new Cesium.Color(),
endpoint = new Cesium.Cartesian3();
const headPositions = [new Cesium.Cartesian3(), endpoint];
let rebuilds = 0;
function tint(alpha, dark = false) {
if (dark) {
color.red = 0.02;
color.green = 0.03;
color.blue = 0.05;
} else Cesium.Color.clone(styleColor, color);
color.alpha = alpha;
return color;
}
function replaceHistory(next) {
if (next.revision === revision) return;
revision = next.revision;
segments = next.segments;
completed = -1;
lastSecond = -1;
active = null;
if (body) scene.primitives.remove(body);
body = null;
const instances = [];
attributeIds.length = 0;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
attributeIds.push([`${i}:0`, `${i}:1`]);
if (segment.positions.length < 2) continue;
for (let lane = 0; lane < 2; lane++) {
instances.push(
new Cesium.GeometryInstance({
id: `${i}:${lane}`,
geometry: new Cesium.PolylineGeometry({
positions: segment.positions,
width: lane ? 2 : 4,
arcType: Cesium.ArcType.NONE,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
tint(0.8, !lane),
),
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
tint(0.8, !lane),
),
show: new Cesium.ShowGeometryInstanceAttribute(false),
},
}),
);
}
}
if (instances.length) {
const depthFailAppearance = new Cesium.PolylineColorAppearance({
translucent: true,
fragmentShaderSource:
'in vec4 v_color; void main() { out_FragColor = czm_gammaCorrect(vec4(v_color.rgb, v_color.a * 0.2)); }',
});
body = scene.primitives.add(
new Cesium.Primitive({
geometryInstances: instances,
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
depthFailAppearance,
allowPicking: false,
asynchronous: false,
show: visible,
}),
);
rebuilds++;
}
}
function setDisplaySample(sample, renderedPosition) {
if (!sample || !renderedPosition) {
head.show = false;
backing.show = false;
return;
}
const second = Math.floor(sample.displayT / 1000);
let lo = 0,
hi = segments.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (segments[mid].toT <= sample.displayT) lo = mid + 1;
else hi = mid;
}
const done = lo - 1;
const candidate = segments[lo];
const segment =
candidate &&
candidate.fromT <= sample.displayT &&
candidate.fromSeq === sample.fromSeq &&
candidate.toSeq === sample.toSeq
? candidate
: null;
if (body?.ready && done !== completed) {
for (
let i = Math.min(done, completed) + 1;
i <= Math.max(done, completed);
i++
) {
for (let lane = 0; lane < 2; lane++) {
const attributes = body.getGeometryInstanceAttributes(
attributeIds[i][lane],
);
if (!attributes) continue;
attributes.show[0] = i <= done ? 1 : 0;
attributes.show = attributes.show;
}
}
completed = done;
}
if (body?.ready && second !== lastSecond) {
for (let i = 0; i < segments.length; i++) {
const alpha = trailAlpha(sample.displayT - segments[i].toT);
for (let lane = 0; lane < 2; lane++) {
const attributes = body.getGeometryInstanceAttributes(
attributeIds[i][lane],
);
if (!attributes) continue;
Cesium.ColorGeometryInstanceAttribute.toValue(
tint(alpha, !lane),
attributes.color,
);
attributes.color = attributes.color;
Cesium.ColorGeometryInstanceAttribute.toValue(
tint(alpha, !lane),
attributes.depthFailColor,
);
attributes.depthFailColor = attributes.depthFailColor;
}
}
lastSecond = second;
}
const show = visible && !!segment && sample.fraction < 1;
head.show = show;
backing.show = show;
if (!show) return;
if (active !== segment) {
active = segment;
headPositions[0] = segment.positions[0];
headPositions[1] = endpoint;
}
Cesium.Cartesian3.clone(renderedPosition, endpoint);
head.positions = headPositions;
backing.positions = headPositions;
Cesium.Color.clone(tint(0.8), head.material.uniforms.color);
}
return {
replaceHistory,
setDisplaySample,
setStyle(next) {
if (next === style) return;
style = next;
Cesium.Color.fromCssColorString(style, styleColor);
lastSecond = -1;
},
setVisible(next) {
visible = next;
heads.show = next;
if (body) body.show = next;
},
destroy() {
if (body) scene.primitives.remove(body);
scene.primitives.remove(heads);
body = null;
segments = [];
},
diagnostics() {
return {
revision,
rebuilds,
segments: segments.length,
body,
head,
backing,
entitiesAdded: 0,
};
},
};
}
+210
View File
@@ -0,0 +1,210 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
createContactTrailRenderer,
trailAlpha,
} from './contactTrailRenderer.js';
function fixture(t) {
for (const key of [
'HTMLCanvasElement',
'HTMLImageElement',
'ImageBitmap',
'OffscreenCanvas',
]) {
const prior = globalThis[key];
globalThis[key] = class {};
t.after(() => {
if (prior === undefined) delete globalThis[key];
else globalThis[key] = prior;
});
}
const primitives = new Cesium.PrimitiveCollection();
t.after(() => primitives.destroy());
return { primitives };
}
test('body clips future subdivisions, head ends at exact marker, and frames retain geometry', (t) => {
const scene = fixture(t),
renderer = createContactTrailRenderer(scene);
const positions = [0, 1, 2].map((i) =>
Cesium.Cartesian3.fromDegrees(-71, 42 + i * 0.001, 11.5),
);
renderer.replaceHistory({
revision: 1,
segments: [
{
fromSeq: 0,
toSeq: 1,
fromT: 0,
toT: 10000,
positions: positions.slice(0, 2),
},
{
fromSeq: 0,
toSeq: 1,
fromT: 10000,
toT: 20000,
positions: positions.slice(1, 3),
},
],
});
const { body } = renderer.diagnostics();
assert.equal(body.allowPicking, false);
// The constructor defers this check until shader preparation. Run the real
// Cesium validator without WebGL, including a missing-attribute control.
const source = body.depthFailAppearance.vertexShaderSource;
body._batchTableAttributeIndices = { color: 0 };
assert.throws(
() => Cesium.Primitive._updateColorAttribute(body, source, true),
/depthFailColor/,
);
for (const instance of body.geometryInstances) {
assert.deepEqual(
instance.attributes.depthFailColor.value,
instance.attributes.color.value,
);
}
body._batchTableAttributeIndices = { color: 0, depthFailColor: 1 };
assert.doesNotThrow(() =>
Cesium.Primitive._updateColorAttribute(body, source, true),
);
const attributes = new Map(
body.geometryInstances.map((instance) => [
instance.id,
{
show: new Uint8Array([0]),
color: new Uint8Array(4),
depthFailColor: new Uint8Array(4),
},
]),
);
// Simulate Cesium's completed upload; exercise public attribute writes.
body._ready = true;
body.getGeometryInstanceAttributes = (id) => attributes.get(id);
const marker = Cesium.Cartesian3.lerp(
positions[1],
positions[2],
0.5,
new Cesium.Cartesian3(),
);
const sample = { displayT: 15000, fromSeq: 0, toSeq: 1, fraction: 0.75 };
for (let i = 0; i < 120; i++) renderer.setDisplaySample(sample, marker);
assert.deepEqual(
[...attributes.values()].map((a) => a.show[0]),
[1, 1, 0, 0],
);
for (const a of attributes.values())
assert.deepEqual(a.depthFailColor, a.color);
const result = renderer.diagnostics();
assert.equal(result.body, body);
assert.equal(result.rebuilds, 1);
assert.ok(Cesium.Cartesian3.equals(result.head.positions.at(-1), marker));
assert.equal(result.head.positions.length, 2);
renderer.setDisplaySample(
{ ...sample, displayT: 5000, fraction: 0.25 },
positions[0],
);
assert.ok(
[...attributes.values()].every((a) => a.show[0] === 0),
'rewind hides future geometry again',
);
renderer.destroy();
assert.equal(scene.primitives.length, 0);
});
test('trail age curve uses the specified four alpha anchors', () => {
assert.equal(trailAlpha(0), 0.8);
assert.equal(trailAlpha(120000), 0.45);
assert.equal(trailAlpha(600000), 0.2);
assert.equal(trailAlpha(900000), 0.08);
assert.ok(Math.abs(trailAlpha(60000) - 0.625) < 1e-10);
});
test('the maximum prepared body stays below the 2 MiB geometry budget', () => {
const geometry = Cesium.PolylineGeometry.createGeometry(
new Cesium.PolylineGeometry({
positions: [
Cesium.Cartesian3.fromDegrees(-71, 42, 11.5),
Cesium.Cartesian3.fromDegrees(-71, 42.0002, 11.5),
],
width: 2,
arcType: Cesium.ArcType.NONE,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
}),
);
const bytes =
Object.values(geometry.attributes).reduce(
(total, attribute) => total + (attribute?.values?.byteLength || 0),
0,
) + geometry.indices.byteLength;
assert.ok(bytes * 2 * 2047 < 2 * 1024 * 1024);
});
test('subdivision crossings only write the changed show range and age colors once per second', (t) => {
const renderer = createContactTrailRenderer(fixture(t));
const positions = [
Cesium.Cartesian3.fromDegrees(-71, 42),
Cesium.Cartesian3.fromDegrees(-71, 42.001),
];
renderer.replaceHistory({
revision: 1,
segments: Array.from({ length: 100 }, (_, i) => ({
fromSeq: 0,
toSeq: 1,
fromT: i * 500,
toT: (i + 1) * 500,
positions,
})),
});
const { body } = renderer.diagnostics();
const counts = { show: 0, color: 0, depthFailColor: 0 };
const attributes = new Map(
body.geometryInstances.map((instance) => {
const result = {};
for (const name of Object.keys(counts)) {
let value = instance.attributes[name].value;
Object.defineProperty(result, name, {
get: () => value,
set(next) {
value = next;
counts[name]++;
},
});
}
return [instance.id, result];
}),
);
body._ready = true;
body.getGeometryInstanceAttributes = (id) => attributes.get(id);
const display = (displayT) =>
renderer.setDisplaySample(
{ displayT, fromSeq: 0, toSeq: 1, fraction: 0.1 },
positions[0],
);
const reset = () => {
for (const name of Object.keys(counts)) counts[name] = 0;
};
display(100);
assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 });
reset();
display(600);
assert.deepEqual(counts, { show: 2, color: 0, depthFailColor: 0 });
reset();
display(100);
assert.deepEqual(counts, { show: 2, color: 0, depthFailColor: 0 });
assert.ok(
[...attributes.values()].every((a) => a.show[0] === 0),
'backward seek hides the completed range',
);
reset();
display(1100);
assert.deepEqual(counts, { show: 4, color: 200, depthFailColor: 200 });
reset();
display(1200);
assert.deepEqual(counts, { show: 0, color: 0, depthFailColor: 0 });
renderer.setStyle('#FF4538');
display(1200);
assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 });
for (const a of attributes.values())
assert.deepEqual(a.color, a.depthFailColor);
});
+23 -11
View File
@@ -48,7 +48,9 @@ export function selectEntityContext(entity) {
store.selectedEntityId = contextId;
store.selectedAt = Date.now();
const record = store.entities.get(contextId);
window.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: record }));
window.dispatchEvent(
new CustomEvent('gev:entity-selected', { detail: record }),
);
return record;
}
@@ -78,7 +80,8 @@ export function selectTrackedSubjectContext(metadata) {
const store = getContextStore();
const id = String(metadata.id);
for (const [key, record] of store.entities) {
if (record?.layerId === metadata.layerId && key !== id) store.entities.delete(key);
if (record?.layerId === metadata.layerId && key !== id)
store.entities.delete(key);
}
// Reuse the existing carrier so a per-poll refresh does not churn identity.
const carrier = store.entities.get(id)?.entity || { __gevContextId: id };
@@ -146,16 +149,21 @@ export function getSelectedEntityContext({ dataManager = null } = {}) {
* rather than being deselected. Readouts that stay on screen hold their
* last-known values for an eviction and only tear down on a deliberate clear.
*/
export function clearSelectedEntityContextForLayer(layerId, { evicted = false } = {}) {
export function clearSelectedEntityContextForLayer(
layerId,
{ evicted = false } = {},
) {
const store = getContextStore();
if (!store.selectedEntityId) return;
const record = store.entities.get(store.selectedEntityId);
if (record?.layerId === layerId) {
store.selectedEntityId = null;
store.selectedAt = null;
window.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', {
detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' },
}));
window.dispatchEvent(
new CustomEvent('gev:entity-selection-cleared', {
detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' },
}),
);
}
}
@@ -166,16 +174,19 @@ export function clearSelectedEntityContextForLayer(layerId, { evicted = false }
export function removeEntityContextsForLayer(layerId, { retainIds } = {}) {
const store = getContextStore();
for (const [id, record] of store.entities) {
if (record?.layerId === layerId && !retainIds?.has(id)) store.entities.delete(id);
if (record?.layerId === layerId && !retainIds?.has(id))
store.entities.delete(id);
}
if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) {
store.selectedEntityId = null;
store.selectedAt = null;
// A viewport refresh dropped the record out from under the selection —
// the user did not deselect anything.
window.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', {
detail: { layerId, reason: 'evicted' },
}));
window.dispatchEvent(
new CustomEvent('gev:entity-selection-cleared', {
detail: { layerId, reason: 'evicted' },
}),
);
}
}
@@ -183,6 +194,7 @@ export function isContextRecordActive(record, dataManager = null) {
if (!record) return false;
if (record.entity?.show === false) return false;
if (record.dataSource && record.dataSource.show === false) return false;
if (dataManager && record.layerId && !dataManager.isEnabled(record.layerId)) return false;
if (dataManager && record.layerId && !dataManager.isEnabled(record.layerId))
return false;
return true;
}
+48 -1
View File
@@ -99,7 +99,7 @@ export const DATA_CREDITS = [
{
key: 'cockpit-place-osm',
html:
'Cockpit place context: ' +
'Cockpit place context and last-resort place search: ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'via Nominatim (ODbL 1.0)',
},
@@ -183,6 +183,10 @@ export const DATA_CREDITS = [
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (ODbL) · ' +
'<a href="https://www.openstreetmap.org/fixthemap" target="_blank" rel="noopener">fix the map</a>',
},
{
key: 'gtfs-rt',
html: 'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
},
{
key: 'radio-browser',
html:
@@ -287,6 +291,49 @@ export const NATURAL_EARTH_CREDIT = {
'<a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a> (public domain)',
};
/**
* Per-feed transit credit, registered the first time that feed's vehicles
* render (see `src/data/transitFeeds.js` for the license of each).
* @param {{ id: string, attribution: string, license: string, licenseUrl: string }} feed
* @returns {{ key: string, html: string }}
*/
export function transitFeedCredit(feed) {
const escape = (text) =>
String(text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
return {
key: `transit-${feed.id}`,
html:
`Transit (${escape(feed.attribution)}): ` +
`<a href="${escape(feed.licenseUrl)}" target="_blank" rel="noopener">${escape(feed.license)}</a>`,
};
}
/** Registered when the Bhote Koshi event reconstruction activates. */
export const BHOTE_KOSHI_CREDIT = {
key: 'bhote-koshi-2026',
html:
'Bhote Koshi 2026 event imagery and derived reconstruction: ' +
'<a href="https://vantor.com/company/open-data-program" target="_blank" rel="noopener">Vantor Open Data</a> ' +
'and <a href="https://github.com/geo-pera/bhotekoshi-2026-reconstruction" target="_blank" rel="noopener">GeoPera</a> ' +
'(CC BY-NC 4.0); terrain © Re:Earth / Mapterhorn (CC BY 4.0)',
};
/** Registered when the scene-friendly Nepal incident locator activates. */
export const BHOTE_KOSHI_LOCATOR_CREDIT = {
key: 'bhote-koshi-locator',
html:
'Nepal administrative boundary and nearby-city locations: ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'(ODbL 1.0); incident-place anchors use an owner-curated, source-verified geolocation union; ' +
'flood corridor derived from the ' +
'<a href="https://github.com/geo-pera/bhotekoshi-2026-reconstruction/blob/main/vectors/river_centerline.geojson" target="_blank" rel="noopener">GeoPera river centerline</a> ' +
'(CC BY-NC 4.0)',
};
/** @type {Set<string>} Keys of dynamic credits already registered this session. */
const _dynamicCreditKeys = new Set();
+240 -70
View File
@@ -1,3 +1,4 @@
import { paintTransitBracket } from './detectionDraw.js';
import * as Cesium from 'cesium';
import { governorRequestRender } from '../renderGovernor.js';
import {
@@ -10,6 +11,7 @@ import {
import {
acquireAlpha,
appendCornerBracket,
appendTransitBracket,
resolveTier,
measureTrackLabel,
nearFarScale,
@@ -217,6 +219,8 @@ let _hostLane = null;
* @type {{surface:HTMLCanvasElement|null,setActive:Function,requestPaint:Function,unregister:Function}|null}
*/
let _calloutLane = null;
let _transitBracketPaths = new Map();
let _transitBracketAlpha = 1;
/**
* Callouts solved by the sensor lane this frame, replayed by the callout lane.
* The array and its rows are POOLED — `_calloutCount` is the live length — so a
@@ -275,7 +279,10 @@ let _cockpitModeListener = null;
*/
export function initDetection(viewer, layers, onModeChange) {
if (_cockpitModeListener && typeof window !== 'undefined') {
window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener);
window.removeEventListener(
'gev:cockpit-mode-changed',
_cockpitModeListener,
);
}
_hostLane?.unregister?.();
_calloutLane?.unregister?.();
@@ -319,7 +326,10 @@ export function initDetection(viewer, layers, onModeChange) {
/** Release the host lane and all retained detection runtime state. */
export function destroyDetection() {
if (_cockpitModeListener && typeof window !== 'undefined') {
window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener);
window.removeEventListener(
'gev:cockpit-mode-changed',
_cockpitModeListener,
);
}
_cockpitModeListener = null;
_cockpitActive = false;
@@ -329,6 +339,7 @@ export function destroyDetection() {
_hostLane = null;
_calloutLane?.unregister?.();
_calloutLane = null;
_transitBracketPaths.clear();
_calloutCount = 0;
_viewer = null;
_layers = [];
@@ -392,7 +403,8 @@ export function setMode(modeLabel) {
_mode = MODE_OFF;
} else {
const requestedDensity = defaultDensityForProfile(target);
if (profileForDensity(_densityPct) !== target) _densityPct = requestedDensity;
if (profileForDensity(_densityPct) !== target)
_densityPct = requestedDensity;
_mode = _modeForDensity();
_lastNonOffMode = _mode;
}
@@ -457,7 +469,10 @@ export function isDetectionSuspended() {
*/
export function setDetectionTuning(options = {}) {
let modeChanged = false;
if (typeof options.densityPct === 'number' && Number.isFinite(options.densityPct)) {
if (
typeof options.densityPct === 'number' &&
Number.isFinite(options.densityPct)
) {
const nextDensity = canonicalizeDensity(options.densityPct);
if (nextDensity !== _densityPct) {
_densityPct = nextDensity;
@@ -472,7 +487,10 @@ export function setDetectionTuning(options = {}) {
}
}
if (options.allocationStrategy != null) {
const nextStrategy = normalizeAllocationStrategy(options.allocationStrategy, _allocationStrategy);
const nextStrategy = normalizeAllocationStrategy(
options.allocationStrategy,
_allocationStrategy,
);
if (nextStrategy !== _allocationStrategy) {
_allocationStrategy = nextStrategy;
_labelSolveDirty = true;
@@ -517,7 +535,14 @@ export function markDetectionSourcesChanged(reason = 'sources-changed') {
/** Read-only diagnostics for unit/browser QA. */
export function getDetectionDiagnostics() {
return _lastDiagnostics ? JSON.parse(JSON.stringify(_lastDiagnostics)) : null;
if (!_lastDiagnostics) return null;
const result = JSON.parse(JSON.stringify(_lastDiagnostics));
// Readback only: expose painted plates so pixel QA can exclude occluded
// sprites/strokes. Nothing is copied on the production paint path.
result.calloutRects = _calloutPool
.slice(0, _calloutCount)
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
return result;
}
function _publishDiagnostics() {
@@ -525,19 +550,30 @@ function _publishDiagnostics() {
const dataset = _hostCanvas.dataset;
dataset.profile = _lastDiagnostics.profile || 'OFF';
dataset.densityPct = String(_lastDiagnostics.densityPct ?? _densityPct);
dataset.allocationStrategy = _lastDiagnostics.allocationStrategy || _allocationStrategy;
dataset.allocationStrategy =
_lastDiagnostics.allocationStrategy || _allocationStrategy;
dataset.solveRevision = String(_lastDiagnostics.solveRevision ?? 0);
dataset.collectiveLabelBudget = String(_lastDiagnostics.collectiveLabelBudget ?? 0);
dataset.collectiveLabelBudget = String(
_lastDiagnostics.collectiveLabelBudget ?? 0,
);
dataset.labelsByLayer = JSON.stringify(_lastDiagnostics.labelsByLayer || {});
dataset.entitlementByLayer = JSON.stringify(_lastDiagnostics.entitlementByLayer || {});
dataset.entitlementByLayer = JSON.stringify(
_lastDiagnostics.entitlementByLayer || {},
);
dataset.labeledKeys = JSON.stringify(_lastDiagnostics.labeledKeys || []);
dataset.labelAlphaByKey = JSON.stringify(_lastDiagnostics.labelAlphaByKey || {});
dataset.bracketOpacityCounts = JSON.stringify(_lastDiagnostics.bracketOpacityCounts || {});
dataset.labelAlphaByKey = JSON.stringify(
_lastDiagnostics.labelAlphaByKey || {},
);
dataset.bracketOpacityCounts = JSON.stringify(
_lastDiagnostics.bracketOpacityCounts || {},
);
dataset.keyholeRadius = String(_lastDiagnostics.keyholeRadius ?? 0);
dataset.keyholeFeatherPx = String(_lastDiagnostics.keyholeFeatherPx ?? 0);
dataset.demandByLayer = JSON.stringify(_lastDiagnostics.demandByLayer || {});
dataset.cohortByLayer = JSON.stringify(_lastDiagnostics.cohortByLayer || {});
dataset.placementBuildCount = String(_lastDiagnostics.placementBuildCount ?? 0);
dataset.placementBuildCount = String(
_lastDiagnostics.placementBuildCount ?? 0,
);
dataset.observationCount = String(_lastDiagnostics.observationCount ?? 0);
dataset.selectedCount = String(_lastDiagnostics.selectedCount ?? 0);
dataset.fadingCount = String(_lastDiagnostics.fadingCount ?? 0);
@@ -622,7 +658,11 @@ function _applyModeState() {
_labelArbiter.clear();
_lastLabelSolveAt = 0;
_labelSolveDirty = true;
_lastSolveSnapshot = { demandByLayer: {}, cohortByLayer: {}, cohortCount: 0 };
_lastSolveSnapshot = {
demandByLayer: {},
cohortByLayer: {},
cohortCount: 0,
};
} else {
_enableTime = _nowMs(); // restart the subtle fade-in on (re)activation
_syncSurfaceVisibility();
@@ -655,8 +695,10 @@ function _shouldPaintDetectionLane(frame) {
});
if (decision.skip) {
_throttleSkipCount++;
if (_lastDiagnostics) _lastDiagnostics.throttleSkipCount = _throttleSkipCount;
if (decision.requestFollowUp) governorRequestRender('detection-paint-skipped');
if (_lastDiagnostics)
_lastDiagnostics.throttleSkipCount = _throttleSkipCount;
if (decision.requestFollowUp)
governorRequestRender('detection-paint-skipped');
return false;
}
return true;
@@ -677,8 +719,13 @@ function _paintDetectionLane(frame) {
_labelSolveDirty = true;
}
const start = performance.now();
const result = _drawOverlay(frame)
|| { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
const result = _drawOverlay(frame) || {
didSolve: false,
solveMs: 0,
fadingCount: 0,
animatingCount: 0,
solvePending: false,
};
_lastRenderMs = performance.now() - start;
_lastSolveMs = result.solveMs || 0;
_lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs);
@@ -695,17 +742,19 @@ function _paintDetectionLane(frame) {
// exactly what makes it safe where the old blanket hold was not. On a parked
// scene with nothing animating it asks for nothing, and the governor stays
// idle. See `detectionRenderDemand.js`.
if (detectionNeedsFollowUpFrame({
active: _mode !== MODE_OFF && !_suspended,
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at
// 220 ms, and the settled alpha never painted).
nowMs: Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(),
enabledAtMs: _enableTime,
fadeMs: FADE_MS,
animatingLabelCount: result.animatingCount || 0,
solvePending: result.solvePending === true,
})) {
if (
detectionNeedsFollowUpFrame({
active: _mode !== MODE_OFF && !_suspended,
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at
// 220 ms, and the settled alpha never painted).
nowMs: Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(),
enabledAtMs: _enableTime,
fadeMs: FADE_MS,
animatingLabelCount: result.animatingCount || 0,
solvePending: result.solvePending === true,
})
) {
governorRequestRender('detection-animation');
}
}
@@ -794,7 +843,8 @@ function _drawModeBanner(visibleCount, sampledCount) {
if (!_debugBanner) return;
const mode = MODE_LABELS[_mode];
const paused = _suspended ? ` PAUSED:${_suspendReason || 'transition'}` : '';
const allocation = _allocationStrategy === ALLOCATION_ELASTIC ? 'ELASTIC' : 'WEIGHTED';
const allocation =
_allocationStrategy === ALLOCATION_ELASTIC ? 'ELASTIC' : 'WEIGHTED';
const text = `${mode} VIS:${visibleCount} SRC:${sampledCount} DENS:${_densityPct}% ${allocation} ${_lastRenderMs.toFixed(1)}ms${paused}`;
_ctx.fillStyle = _theme.labelBg;
_ctx.fillRect(12, 12, Math.max(180, _ctx.measureText(text).width + 12), 16);
@@ -890,16 +940,27 @@ function _buildLabelPlacements(
const placements = [];
for (const placement of raw) {
const { cardX, cardY } = placement;
if (cardX < margin || cardY < margin || cardX + card.w > width - margin || cardY + card.h > height - margin) {
if (
cardX < margin ||
cardY < margin ||
cardX + card.w > width - margin ||
cardY + card.h > height - margin
) {
continue;
}
const cardRect = { x: cardX, y: cardY, w: card.w, h: card.h };
if (rectIntersectsAny(cardRect, occlusionRects)) continue;
const leadToX = placement.leadToSide.endsWith('E') ? cardX + card.w : cardX;
const leadToY = placement.leadToSide.startsWith('S') ? cardY + card.h : cardY;
const leadToY = placement.leadToSide.startsWith('S')
? cardY + card.h
: cardY;
const centerX = cardX + card.w * 0.5;
const centerY = cardY + card.h * 0.5;
const radialAlpha = keyholeLabelAlphaFromGeometry(centerX, centerY, keyhole);
const radialAlpha = keyholeLabelAlphaFromGeometry(
centerX,
centerY,
keyhole,
);
if (radialAlpha <= 0) continue;
placements.push({
...placement,
@@ -930,18 +991,37 @@ function _buildLabelPlacements(
*/
function _stashCallout(entry, acquireFade, keyhole) {
const { candidate, placement, temporalAlpha } = entry;
const radialAlpha = keyholeLabelAlphaFromGeometry(placement.centerX, placement.centerY, keyhole);
const radialAlpha = keyholeLabelAlphaFromGeometry(
placement.centerX,
placement.centerY,
keyhole,
);
const alpha = acquireFade * temporalAlpha * radialAlpha;
if (alpha <= 0.001) return;
let row = _calloutPool[_calloutCount];
if (!row) {
row = {
x: 0, y: 0, w: 0, h: 0,
primaryX: 0, microX: 0, baseline: 0,
leadFromX: 0, leadFromY: 0, leadToX: 0, leadToY: 0,
plate: '', plateScale: 1, accent: '', label: '', primary: '', micro: '',
font: FONT, microFont: MICRO_FONT, alpha: 1,
x: 0,
y: 0,
w: 0,
h: 0,
primaryX: 0,
microX: 0,
baseline: 0,
leadFromX: 0,
leadFromY: 0,
leadToX: 0,
leadToY: 0,
plate: '',
plateScale: 1,
accent: '',
label: '',
primary: '',
micro: '',
font: FONT,
microFont: MICRO_FONT,
alpha: 1,
};
_calloutPool[_calloutCount] = row;
}
@@ -981,9 +1061,20 @@ function _stashCallout(entry, acquireFade, keyhole) {
* @param {Object} frame Host paint frame.
*/
function _paintCalloutLane(frame) {
if (_mode === MODE_OFF || _suspended || _calloutCount === 0) return;
if (_mode === MODE_OFF || _suspended) return;
const ctx = frame.ctx;
if (!ctx) return;
for (const bands of _transitBracketPaths.values()) {
for (const entry of bands) {
if (entry)
paintTransitBracket(
ctx,
entry.path,
entry.color,
entry.alpha * _transitBracketAlpha,
);
}
}
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
for (let i = 0; i < _calloutCount; i++) {
@@ -1006,7 +1097,14 @@ function _plateScaleForBackdrop(skyFactor) {
}
/** Materialize one bounded rich-callout candidate for the existing arbiter. */
function _materializeCandidate(obj, width, height, keyhole, occlusionRects, cameraPosition) {
function _materializeCandidate(
obj,
width,
height,
keyhole,
occlusionRects,
cameraPosition,
) {
const primary = obj._candidatePrimary;
const micro = obj._candidateMicro;
const card = measureTrackLabel(primary, micro, _charWidth);
@@ -1027,7 +1125,10 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
for (const placement of placements) {
centerDistance = Math.min(
centerDistance,
Math.hypot(placement.centerX - keyhole.centerX, placement.centerY - keyhole.centerY),
Math.hypot(
placement.centerX - keyhole.centerX,
placement.centerY - keyhole.centerY,
),
);
keyholeAlpha = Math.max(keyholeAlpha, placement.keyholeAlpha);
}
@@ -1048,7 +1149,9 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
// Costed here rather than in the object sweep: the sweep walks every
// observation in view, while this runs only for callouts that actually
// placed — a budgeted handful per frame.
plateScale: _plateScaleForBackdrop(skyBackdropFactor(cameraPosition, obj.position)),
plateScale: _plateScaleForBackdrop(
skyBackdropFactor(cameraPosition, obj.position),
),
primary,
micro,
hasMicro: card.hasMicro,
@@ -1061,9 +1164,16 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
* for a bounded cohort on solve ticks.
*/
function _drawOverlay(frame) {
_transitBracketPaths.clear();
const { width, height } = frame;
if (!_ctx || width <= 0 || height <= 0) {
return { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
return {
didSolve: false,
solveMs: 0,
fadingCount: 0,
animatingCount: 0,
solvePending: false,
};
}
// ONE timestamp per frame, monotonic, shared with the demand policy below and
@@ -1071,12 +1181,16 @@ function _drawOverlay(frame) {
// animation's final state is the same frame that ends its demand.
const now = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
const bracketPresentationOpacity = detectionBracketOpacity(_cockpitActive);
const shouldSolve = _labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
const shouldSolve =
_labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
const calloutOcclusionRects = frame.uiRects;
const selectedIdentities = shouldSolve
? _labelArbiter.liveIdentities({ includeFading: false, now })
: null;
const renderIdentities = _labelArbiter.liveIdentities({ includeFading: true, now });
const renderIdentities = _labelArbiter.liveIdentities({
includeFading: true,
now,
});
const objects = _collectDetectableObjects();
const sampledCount = objects.length;
if (objects.length === 0) {
@@ -1100,7 +1214,9 @@ function _drawOverlay(frame) {
profile: MODE_LABELS[_mode],
densityPct: _densityPct,
allocationStrategy: _allocationStrategy,
viewScale: viewScaleForAltitude(_viewer?.camera?.positionCartographic?.height),
viewScale: viewScaleForAltitude(
_viewer?.camera?.positionCartographic?.height,
),
candidateCount: 0,
observationCount: 0,
visibleCount: 0,
@@ -1128,7 +1244,13 @@ function _drawOverlay(frame) {
// whole mechanism exists to remove.
_lastLabelSolveAt = now;
_labelSolveDirty = false;
return { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
return {
didSolve: false,
solveMs: 0,
fadingCount: 0,
animatingCount: 0,
solvePending: false,
};
}
// Horizon culling, keyhole geometry, and camera transforms are shared with
@@ -1163,7 +1285,10 @@ function _drawOverlay(frame) {
const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
const bracketPaths = new Map();
const pathFor = (map, color, alpha) => {
const band = Math.max(1, Math.min(BRACKET_ALPHA_STEPS, Math.ceil(alpha * BRACKET_ALPHA_STEPS)));
const band = Math.max(
1,
Math.min(BRACKET_ALPHA_STEPS, Math.ceil(alpha * BRACKET_ALPHA_STEPS)),
);
let bands = map.get(color);
if (!bands) {
bands = new Array(BRACKET_ALPHA_STEPS + 1);
@@ -1201,8 +1326,10 @@ function _drawOverlay(frame) {
const clipW = vp3 * px + vp7 * py + vp11 * pz + vp15;
if (clipW <= 0) continue;
const invW = 1 / clipW;
const sx = ((vp0 * px + vp4 * py + vp8 * pz + vp12) * invW * 0.5 + 0.5) * width;
const sy = (0.5 - (vp1 * px + vp5 * py + vp9 * pz + vp13) * invW * 0.5) * height;
const sx =
((vp0 * px + vp4 * py + vp8 * pz + vp12) * invW * 0.5 + 0.5) * width;
const sy =
(0.5 - (vp1 * px + vp5 * py + vp9 * pz + vp13) * invW * 0.5) * height;
// Tracked objects (skipLabel) get larger boxes. AIR reticles scale with the
// plane's on-screen size (same scaleByDistance curve as the billboards) so
@@ -1213,26 +1340,49 @@ function _drawOverlay(frame) {
if (obj.type === 'AIR') {
const bscale = nearFarScale(
Cesium.Cartesian3.distance(camPos, obj.position),
BILL_NEAR, BILL_NEAR_SCALE, BILL_FAR, BILL_FAR_SCALE,
BILL_NEAR,
BILL_NEAR_SCALE,
BILL_FAR,
BILL_FAR_SCALE,
);
halfW = _clamp((isTracked ? 14 : 9) * bscale, 7, 48);
halfH = _clamp((isTracked ? 11 : 7) * bscale, 5, 38);
} else if (obj.tier?.startsWith('transit_') && obj.bracketHalfWidth > 0) {
halfW = obj.bracketHalfWidth;
halfH = obj.bracketHalfHeight;
} else {
halfW = _mode === MODE_DENSE ? (isTracked ? 28 : 11) : 16;
halfH = _mode === MODE_DENSE ? (isTracked ? 22 : 7) : 10;
}
// Viewport bounds check with padding
if (sx < -halfW || sx > width + halfW || sy < -halfH || sy > height + halfH) continue;
if (sx < -halfW || sx > width + halfW || sy < -halfH || sy > height + halfH)
continue;
// Tier color drives the bracket, accent bar, and leader line for this object.
// The bracket follows the same linear radial keyhole fade as its callout.
const color = colorFor(resolveTier(obj));
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
const bracketAlpha = detectionBracketAlpha(obj.type, keyholeAlpha, keyholeOutsideOpacity);
const bracketAlpha = detectionBracketAlpha(
obj.type,
keyholeAlpha,
keyholeOutsideOpacity,
);
if (bracketAlpha > 0) {
appendCornerBracket(pathFor(bracketPaths, color, bracketAlpha), sx, sy, halfW, halfH);
const transit = obj.tier?.startsWith('transit_');
(transit ? appendTransitBracket : appendCornerBracket)(
pathFor(
transit ? _transitBracketPaths : bracketPaths,
color,
bracketAlpha,
),
sx,
sy,
halfW,
halfH,
);
visibleCount++;
if (obj.type === 'AIR') aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
if (obj.type === 'AIR')
aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
if (bracketAlpha >= 1) bracketOpacityCounts.full++;
else bracketOpacityCounts.partial++;
} else {
@@ -1266,8 +1416,10 @@ function _drawOverlay(frame) {
if (shouldSolve && keyholeAlpha > 0) {
demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1);
if (!cohortBuilders.has(layerId)) cohortBuilders.set(layerId, new BoundedCohort(256));
const incumbent = selectedIdentities?.get(layerId)?.has(sourceId) || false;
if (!cohortBuilders.has(layerId))
cohortBuilders.set(layerId, new BoundedCohort(256));
const incumbent =
selectedIdentities?.get(layerId)?.has(sourceId) || false;
cohortBuilders.get(layerId).consider(obj, incumbent);
}
@@ -1281,13 +1433,17 @@ function _drawOverlay(frame) {
calloutOcclusionRects,
camPos,
);
if (candidate && !candidateMap.has(candidate.key)) candidateMap.set(candidate.key, candidate);
if (candidate && !candidateMap.has(candidate.key))
candidateMap.set(candidate.key, candidate);
}
}
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
const ambientBudget = Math.max(0, collectiveBudget - Math.min(collectiveBudget, protectedVisibleCount));
const ambientBudget = Math.max(
0,
collectiveBudget - Math.min(collectiveBudget, protectedVisibleCount),
);
let didSolve = false;
let solveMs = 0;
if (shouldSolve) {
@@ -1300,7 +1456,9 @@ function _drawOverlay(frame) {
const solveCandidates = [];
const cohortByLayer = {};
for (const [layerId, builder] of cohortBuilders) {
const cohort = builder.values(cohortCapForQuota(quotas.get(layerId) || 0));
const cohort = builder.values(
cohortCapForQuota(quotas.get(layerId) || 0),
);
for (const obj of cohort) {
const key = _detectionKey(layerId, obj._cohortSourceId);
let candidate = candidateMap.get(key);
@@ -1318,7 +1476,9 @@ function _drawOverlay(frame) {
}
if (candidate) solveCandidates.push(candidate);
}
cohortByLayer[layerId] = solveCandidates.filter((candidate) => candidate.layerId === layerId).length;
cohortByLayer[layerId] = solveCandidates.filter(
(candidate) => candidate.layerId === layerId,
).length;
}
const solveStarted = performance.now();
_labelArbiter.solve(solveCandidates, {
@@ -1346,6 +1506,7 @@ function _drawOverlay(frame) {
const fade = acquireAlpha(_enableTime, now, FADE_MS);
const bracketWidth = _mode === MODE_DENSE ? 1 : 1.25;
_transitBracketAlpha = fade * bracketPresentationOpacity;
// Brackets — batched by tier color and linear radial-opacity band.
_ctx.lineWidth = bracketWidth;
@@ -1398,14 +1559,17 @@ function _drawOverlay(frame) {
labelsByLayer: arbiterDiagnostics.labelsByLayer || {},
entitlementByLayer: arbiterDiagnostics.quotas || {},
labeledKeys: Array.from(_labelArbiter.selectedKeys),
labelAlphaByKey: Object.fromEntries(renderEntries.map((entry) => [
entry.candidate.key,
entry.temporalAlpha * keyholeLabelAlphaFromGeometry(
entry.placement.centerX,
entry.placement.centerY,
keyhole,
),
])),
labelAlphaByKey: Object.fromEntries(
renderEntries.map((entry) => [
entry.candidate.key,
entry.temporalAlpha *
keyholeLabelAlphaFromGeometry(
entry.placement.centerX,
entry.placement.centerY,
keyhole,
),
]),
),
solveRevision: _labelArbiter.solveRevision,
keyholeRadius: keyhole.radius,
keyholeFeatherPx: keyhole.featherPx,
@@ -1430,5 +1594,11 @@ function _drawOverlay(frame) {
_drawModeBanner(visibleCount, sampledCount);
// `_labelSolveDirty` surviving a paint means the solve was owed and did not
// run — the frame that carried the request cannot be the last one.
return { didSolve, solveMs, fadingCount, animatingCount, solvePending: _labelSolveDirty };
return {
didSolve,
solveMs,
fadingCount,
animatingCount,
solvePending: _labelSolveDirty,
};
}
+41 -12
View File
@@ -19,11 +19,14 @@ export function stableIdentityHash(layerId, sourceId) {
/** Negative means a is a better deterministic contender than b. */
export function compareCohortContenders(a, b) {
const priorityDelta = (Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
const priorityDelta =
(Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
if (priorityDelta) return priorityDelta;
const bandDelta = (Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
const bandDelta =
(Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
if (bandDelta) return bandDelta;
const hashDelta = (Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
const hashDelta =
(Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
if (hashDelta) return hashDelta;
return String(a?._cohortSourceId).localeCompare(String(b?._cohortSourceId));
}
@@ -39,8 +42,14 @@ function isWorse(a, b) {
*/
export class BoundedCohort {
constructor(maxSize = DEFAULT_MAX_COHORT, hardMax = DEFAULT_MAX_COHORT) {
const ceiling = Math.max(1, Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT));
this.maxSize = Math.max(1, Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT)));
const ceiling = Math.max(
1,
Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT),
);
this.maxSize = Math.max(
1,
Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT)),
);
this.incumbents = [];
this.heap = [];
}
@@ -62,11 +71,20 @@ export class BoundedCohort {
}
values(limit = this.maxSize) {
const cap = Math.max(0, Math.min(this.maxSize, Math.floor(Number(limit) || 0)));
const incumbents = this.incumbents.slice().sort(compareCohortContenders).slice(0, cap);
const cap = Math.max(
0,
Math.min(this.maxSize, Math.floor(Number(limit) || 0)),
);
const incumbents = this.incumbents
.slice()
.sort(compareCohortContenders)
.slice(0, cap);
const remaining = Math.max(0, cap - incumbents.length);
if (remaining === 0) return incumbents;
const contenders = this.heap.slice().sort(compareCohortContenders).slice(0, remaining);
const contenders = this.heap
.slice()
.sort(compareCohortContenders)
.slice(0, remaining);
return incumbents.concat(contenders);
}
@@ -75,7 +93,10 @@ export class BoundedCohort {
while (current > 0) {
const parent = Math.floor((current - 1) / 2);
if (!isWorse(this.heap[current], this.heap[parent])) break;
[this.heap[current], this.heap[parent]] = [this.heap[parent], this.heap[current]];
[this.heap[current], this.heap[parent]] = [
this.heap[parent],
this.heap[current],
];
current = parent;
}
}
@@ -86,10 +107,18 @@ export class BoundedCohort {
const left = current * 2 + 1;
const right = left + 1;
let worst = current;
if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst])) worst = left;
if (right < this.heap.length && isWorse(this.heap[right], this.heap[worst])) worst = right;
if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst]))
worst = left;
if (
right < this.heap.length &&
isWorse(this.heap[right], this.heap[worst])
)
worst = right;
if (worst === current) return;
[this.heap[current], this.heap[worst]] = [this.heap[worst], this.heap[current]];
[this.heap[current], this.heap[worst]] = [
this.heap[worst],
this.heap[current],
];
current = worst;
}
}
+33 -5
View File
@@ -211,11 +211,14 @@ export function measureTrackLabel(primary, micro, charWidth) {
*/
export function rectIntersectsAny(rect, obstacles, padding = 0) {
if (!rect || !Array.isArray(obstacles)) return false;
return obstacles.some((obstacle) => obstacle
&& rect.x < obstacle.x + obstacle.w + padding
&& rect.x + rect.w + padding > obstacle.x
&& rect.y < obstacle.y + obstacle.h + padding
&& rect.y + rect.h + padding > obstacle.y);
return obstacles.some(
(obstacle) =>
obstacle &&
rect.x < obstacle.x + obstacle.w + padding &&
rect.x + rect.w + padding > obstacle.x &&
rect.y < obstacle.y + obstacle.h + padding &&
rect.y + rect.h + padding > obstacle.y,
);
}
/**
@@ -237,3 +240,28 @@ export function nearFarScale(distance, near, nearValue, far, farValue) {
const t = (distance - near) / (far - near);
return nearValue + t * (farValue - nearValue);
}
/** Transit keeps literal mode colour on the normal-composite surface. */
export function paintTransitBracket(ctx, path, color, alpha = 1) {
ctx.save?.();
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = alpha;
ctx.strokeStyle = '#05080C';
ctx.lineWidth = 3.25;
ctx.stroke(path);
ctx.strokeStyle = color;
ctx.lineWidth = 1.25;
ctx.stroke(path);
ctx.restore?.();
}
/** Centre 1.25 px strokes on pixels, so their cores are fully covered. */
export function appendTransitBracket(sink, sx, sy, halfW, halfH) {
appendCornerBracket(
sink,
Math.floor(sx) + 0.5,
Math.floor(sy) + 0.5,
Math.round(halfW),
Math.round(halfH),
);
}
+31
View File
@@ -161,3 +161,34 @@ test('nearFarScale interpolates by distance and clamps to the near/far values',
const mid = nearFarScale((1000 + 8000000) / 2, 1000, 3.0, 8000000, 0.5);
assert.ok(Math.abs(mid - 1.75) < 1e-6, `midpoint ~1.75, got ${mid}`);
});
test('transit brackets paint a 1.25 px mode stroke over 3.25 px dark backing', async () => {
const { paintTransitBracket } = await import('./detectionDraw.js');
const strokes = [];
const ctx = { stroke(path) { strokes.push([path, this.lineWidth, this.strokeStyle, this.globalAlpha]); } };
const path = {};
paintTransitBracket(ctx, path, '#FF4538', 0.75);
assert.deepEqual(strokes, [[path, 3.25, '#05080C', 0.75], [path, 1.25, '#FF4538', 0.75]]);
});
test('transit bracket backing and colour use source-over under every inherited sensor theme', async () => {
const {paintTransitBracket}=await import('./detectionDraw.js');
for(const theme of ['normal','thermal','surveillance','noir','retro']) {
const strokes=[];
const ctx={globalCompositeOperation:'screen',globalAlpha:0.3,stroke(p){strokes.push([this.globalCompositeOperation,this.lineWidth,this.strokeStyle]);},save(){this.saved=[this.globalCompositeOperation,this.globalAlpha];},restore(){[this.globalCompositeOperation,this.globalAlpha]=this.saved;}};
paintTransitBracket(ctx,{},'#FF4538',1);
assert.deepEqual(strokes,[['source-over',3.25,'#05080C'],['source-over',1.25,'#FF4538']],theme);
assert.equal(ctx.globalCompositeOperation,'screen');
}
});
test('transit bracket stroke centres have full pixel coverage at the failing fractional positions', async () => {
const {appendTransitBracket}=await import('./detectionDraw.js');
for(const sy of [305.076,490.54,279.56]) {
const points=[],sink={moveTo:(x,y)=>points.push([x,y]),lineTo:(x,y)=>points.push([x,y])};
appendTransitBracket(sink,759.016,sy,11,7);
assert.equal(points.length,12);
for(const [x,y] of points){assert.equal(x%1,0.5);assert.equal(y%1,0.5);}
}
});
+87 -1
View File
@@ -24,6 +24,7 @@ import {
setOverlayEntries,
} from '../overlays/worldOverlay.js';
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
import { composeLabel, resolveTier } from './detectionDraw.js';
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
assert.equal(countFadingRenderEntries([
@@ -97,7 +98,7 @@ function mockContext(target, trace) {
// set at some point during the frame. globalAlpha rides along for the same
// reason: the backdrop feather is an alpha, not a colour.
fill(path) { record('fill', path, this.fillStyle, this.globalAlpha); },
stroke(path) { record('stroke', path); },
stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth); },
fillRect(...args) { record('fillRect', ...args); },
fillText(...args) { record('fillText', ...args); },
drawImage(...args) { record('drawImage', ...args); },
@@ -378,6 +379,12 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
env.postRender.raise();
const first = getDetectionDiagnostics();
assert.equal(first.profile, 'DENSE');
assert.ok(Array.isArray(first.calloutRects));
if (first.calloutRects.length) {
const x=first.calloutRects[0].x;
first.calloutRects[0].x=NaN;
assert.equal(getDetectionDiagnostics().calloutRects[0].x,x,'QA owns a copy, never a live plate');
}
assert.equal(first.observationCount, 2);
assert.equal(first.didSolve, true);
assert.ok(first.solveRevision > 0);
@@ -874,3 +881,82 @@ test('the backdrop feather reaches the canvas as a lighter plate against sky', (
env.cleanup();
}
});
test('a transit vehicle is a contact: box, route, mode and operator on the shared canvas', () => {
// The owner's finding was that buses were "green dots moving around
// clumsily" — the layer rendered them and the detection overlay ignored
// them, because transit published no detectable objects at all. This drives
// the real host with the shape the layer now returns.
const env = installEnvironment();
const objects = [
{
position: new Cesium.Cartesian3(0, 0, 6_356_752),
sourceId: 'mbta:y1276',
tier: 'transit_bus',
id: '111',
type: 'VEH',
klass: 'MBTA',
metric: 'BUS 34 KM/H',
},
{
position: new Cesium.Cartesian3(0, 900, 6_356_752),
sourceId: 'mbta:R-548BBB14',
id: 'RED',
type: 'VEH',
klass: 'MBTA',
metric: 'METRO STOPPED',
skipLabel: true,
},
];
try {
initWorldOverlay(env.viewer);
initDetection(
env.viewer,
[{ id: 'transit', getDetectableObjects: () => objects }],
() => {},
);
setMode('DENSE');
env.advance(250);
env.postRender.raise();
env.advance(250);
env.postRender.raise();
const painted = env.ctx.calls
.filter(([name]) => name === 'fillText')
.map(([, text]) => text);
assert.ok(
painted.includes('111'),
`the route is the bright line; painted ${JSON.stringify(painted)}`,
);
// The host paints a single-line track label — bright id, one dim micro
// field — so the mode and what the vehicle is doing share that field.
assert.ok(
painted.some((text) => String(text).includes('BUS')),
`the mode rides along on the micro line; painted ${JSON.stringify(painted)}`,
);
assert.ok(
painted.some((text) => String(text).includes('34 KM/H')),
'and so does what the vehicle is doing',
);
assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#5EF08A' && width === 1.25));
assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#05080C' && width === 3.25));
assert.ok(!env.detectionCtx.calls.some(([name, , color]) => name === 'stroke' && color === '#5EF08A'));
// The operator travels on the object for the two-tier card form, which is
// where `klass` is read; the host's track label does not paint it.
assert.equal(composeLabel(objects[0]).secondary, 'MBTA · BUS 34 KM/H');
// The selected vehicle carries its own fuller card, so detection must not
// label it a second time.
assert.equal(
painted.includes('RED'),
false,
'skipLabel keeps the selected vehicle from being labelled twice',
);
} finally {
env.cleanup();
}
});
test('transit contacts take the vehicle tier, not the civil-air one', () => {
assert.equal(resolveTier({ type: 'VEH' }), 'vehicle');
assert.equal(resolveTier({ type: 'AIR' }), 'civil');
assert.equal(resolveTier({ type: 'SEA' }), 'sea');
});
+66 -20
View File
@@ -8,7 +8,10 @@
export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]);
export const ALLOCATION_ELASTIC = 'ELASTIC';
export const ALLOCATION_WEIGHTED = 'WEIGHTED';
export const ALLOCATION_STRATEGIES = Object.freeze([ALLOCATION_ELASTIC, ALLOCATION_WEIGHTED]);
export const ALLOCATION_STRATEGIES = Object.freeze([
ALLOCATION_ELASTIC,
ALLOCATION_WEIGHTED,
]);
// The keyhole's default 1% outside opacity is appropriate for text, but it
// made side aircraft brackets effectively disappear while the same contacts
@@ -71,10 +74,16 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
: AIRCRAFT_BRACKET_FLOOR_ANCHOR;
if (outside <= 0) return 0;
if (outside <= AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
return AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR);
return (
AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR)
);
}
const progress = (outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) / (1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR);
return AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress;
const progress =
(outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) /
(1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR);
return (
AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress
);
}
/**
@@ -85,7 +94,11 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
* the default, so a caller that does not know it reproduces the shipped look.
* @returns {number} Paint alpha, 0-1.
*/
export function detectionBracketAlpha(type, keyholeAlpha, outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
export function detectionBracketAlpha(
type,
keyholeAlpha,
outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR,
) {
const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0));
if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha;
return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha);
@@ -97,7 +110,7 @@ export function detectionHorizontalSector(screenX, viewportWidth) {
const x = Number(screenX);
if (!(width > 0) || !Number.isFinite(x)) return 'front';
if (x < width / 3) return 'left';
if (x > width * 2 / 3) return 'right';
if (x > (width * 2) / 3) return 'right';
return 'front';
}
@@ -140,19 +153,29 @@ export function defaultDensityForProfile(profile) {
/** Normalize current and legacy profile labels. OFF remains an enabled-state value. */
export function normalizeProfile(profile) {
const raw = String(profile || '').trim().toUpperCase();
const raw = String(profile || '')
.trim()
.toUpperCase();
if (raw === 'OFF') return 'OFF';
if (raw === 'SPARSE' || raw === 'SURVEY') return 'SPARSE';
if (raw === 'BALANCED' || raw === 'NORMAL') return 'BALANCED';
if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON') return 'DENSE';
if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON')
return 'DENSE';
return null;
}
/** Normalize the user-selectable layer allocation strategy. */
export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELASTIC) {
const raw = String(strategy || '').trim().toUpperCase();
export function normalizeAllocationStrategy(
strategy,
fallback = ALLOCATION_ELASTIC,
) {
const raw = String(strategy || '')
.trim()
.toUpperCase();
if (ALLOCATION_STRATEGIES.includes(raw)) return raw;
const normalizedFallback = String(fallback || '').trim().toUpperCase();
const normalizedFallback = String(fallback || '')
.trim()
.toUpperCase();
return ALLOCATION_STRATEGIES.includes(normalizedFallback)
? normalizedFallback
: ALLOCATION_ELASTIC;
@@ -160,7 +183,9 @@ export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELAS
/** Classify camera altitude into the shared label-budget view scale. */
export function viewScaleForAltitude(altitudeM) {
const altitude = Number.isFinite(Number(altitudeM)) ? Math.max(0, Number(altitudeM)) : 1e9;
const altitude = Number.isFinite(Number(altitudeM))
? Math.max(0, Number(altitudeM))
: 1e9;
if (altitude < 1200) return 'street';
if (altitude < 4500) return 'city';
if (altitude < 20000) return 'metro';
@@ -181,25 +206,46 @@ export function labelBudgetFor(altitudeM, densityPct) {
* density was below 75; legacy Sparse remains inside the Sparse band.
*/
export function migrateDetectionState(mode, densityPct, fallbackDensity = 50) {
const rawMode = String(mode || '').trim().toUpperCase();
const rawMode = String(mode || '')
.trim()
.toUpperCase();
const normalized = normalizeProfile(rawMode);
if (normalized === 'OFF') {
const supplied = Number.isFinite(Number(densityPct)) ? densityPct : fallbackDensity;
const supplied = Number.isFinite(Number(densityPct))
? densityPct
: fallbackDensity;
const stop = canonicalizeDensity(supplied, fallbackDensity);
return { enabled: false, profile: profileForDensity(stop), densityPct: stop };
return {
enabled: false,
profile: profileForDensity(stop),
densityPct: stop,
};
}
if (rawMode === 'PANOPTIC' || rawMode === 'GOD' || rawMode === 'ON') {
const density = Number.isFinite(Number(densityPct)) ? Math.max(75, Number(densityPct)) : 75;
return { enabled: true, profile: 'DENSE', densityPct: canonicalizeDensity(density) };
const density = Number.isFinite(Number(densityPct))
? Math.max(75, Number(densityPct))
: 75;
return {
enabled: true,
profile: 'DENSE',
densityPct: canonicalizeDensity(density),
};
}
if (rawMode === 'SPARSE' || rawMode === 'SURVEY') {
const density = Number.isFinite(Number(densityPct)) ? Math.min(25, Number(densityPct)) : 25;
const density = Number.isFinite(Number(densityPct))
? Math.min(25, Number(densityPct))
: 25;
const stop = canonicalizeDensity(density, 25);
return { enabled: true, profile: 'SPARSE', densityPct: stop };
}
if (normalized === 'BALANCED' || normalized === 'DENSE') {
const supplied = Number.isFinite(Number(densityPct)) ? canonicalizeDensity(densityPct) : defaultDensityForProfile(normalized);
const stop = profileForDensity(supplied) === normalized ? supplied : defaultDensityForProfile(normalized);
const supplied = Number.isFinite(Number(densityPct))
? canonicalizeDensity(densityPct)
: defaultDensityForProfile(normalized);
const stop =
profileForDensity(supplied) === normalized
? supplied
: defaultDensityForProfile(normalized);
return { enabled: true, profile: normalized, densityPct: stop };
}
const stop = canonicalizeDensity(densityPct, fallbackDensity);
-1
View File
@@ -10,4 +10,3 @@ export const COCKPIT_BRACKET_OPACITY = 0.45;
export function detectionBracketOpacity(cockpitActive) {
return cockpitActive === true ? COCKPIT_BRACKET_OPACITY : 1;
}
+1 -1
View File
@@ -17,7 +17,7 @@ test('Cockpit reduces only the bracket presentation multiplier', () => {
test('detection owns and releases the Cockpit lifecycle listener', async () => {
const source = await readFile(new URL('./detection.js', import.meta.url), 'utf8');
assert.match(source, /addEventListener\('gev:cockpit-mode-changed', _cockpitModeListener\)/);
assert.match(source, /removeEventListener\('gev:cockpit-mode-changed', _cockpitModeListener\)/);
assert.match(source, /removeEventListener\(\s*'gev:cockpit-mode-changed',\s*_cockpitModeListener,?\s*\)/);
assert.match(
source,
/fade \* entry\.alpha \* bracketPresentationOpacity/,
+4 -3
View File
@@ -158,9 +158,10 @@ export function detectionPaintSkipDecision({
frameCount,
thresholdMs = DETECTION_PAINT_SKIP_THRESHOLD_MS,
} = {}) {
const skip = !layoutChanged
&& Number(lastPaintMs) > thresholdMs
&& Number(frameCount) % 2 !== 0;
const skip =
!layoutChanged &&
Number(lastPaintMs) > thresholdMs &&
Number(frameCount) % 2 !== 0;
return { skip, requestFollowUp: skip };
}
+4 -4
View File
@@ -277,7 +277,7 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
assert.match(source, /governorRequestRender\('detection-visibility'\)/,
'mode/suspend transitions request their own repaint');
assert.match(source, /if \(detectionNeedsFollowUpFrame\(\{[\s\S]*?\}\)\) \{\s*\n\s*governorRequestRender\('detection-animation'\);/,
assert.match(source, /if \(\s*detectionNeedsFollowUpFrame\(\{[\s\S]*?\}\)\s*\) \{\s*\n\s*governorRequestRender\('detection-animation'\);/,
'the follow-up frame is gated on the policy, never unconditional');
// The demand call must read the FRAME's timestamp, not a fresh sample.
@@ -306,7 +306,7 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
// the emptiest possible scene. (Caught by the governor gate at 301 renders/5 s
// with zero layers; the fix settles the solve there, because with nothing
// detectable it is vacuously complete.)
const emptyExit = /\/\/ Drop the replay buffer with it[\s\S]*?return \{ didSolve: false[^\n]*\n/.exec(source)?.[0];
const emptyExit = /\/\/ Drop the replay buffer with it[\s\S]*?return \{\s*didSolve: false[\s\S]*?\};/.exec(source)?.[0];
assert.ok(emptyExit, 'the zero-objects exit is still identifiable');
assert.match(emptyExit, /_labelSolveDirty = false;/,
'the zero-objects exit settles the solve instead of carrying it forward');
@@ -318,7 +318,7 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
assert.ok(drawOverlay, 'detection.js still has a draw pass');
assert.doesNotMatch(drawOverlay, /Date\.now\(\)/,
'the draw pass must run on the frame timestamp, not a wall clock');
assert.match(drawOverlay, /const now = Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\);/);
assert.match(drawOverlay, /const now =\s*Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\);/);
assert.match(source, /_enableTime = _nowMs\(\);/,
'the enable stamp shares the monotonic clock the fade is measured against');
@@ -331,7 +331,7 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
'the valve asks the shared policy instead of inlining its own threshold');
assert.doesNotMatch(shouldPaint, /_lastPaintMs > 22/,
'the old inline threshold must be gone, not shadowing the policy');
assert.match(shouldPaint, /if \(decision\.requestFollowUp\) governorRequestRender\('detection-paint-skipped'\)/,
assert.match(shouldPaint, /if \(decision\.requestFollowUp\)\s*governorRequestRender\('detection-paint-skipped'\)/,
'skipping a paint must hand the request forward, not swallow it');
// The scanline must not go back to the frame counter: that is what made a
+3 -1
View File
@@ -24,7 +24,9 @@
* @returns {number} Heading in degrees [0..360), or NaN if unrecognized.
*/
export function directionToHeading(value, allowBare = false) {
const text = String(value || '').trim().toUpperCase();
const text = String(value || '')
.trim()
.toUpperCase();
if (!text) return NaN;
// Explicit travel/intercardinal forms — safe on free-form text (a street
// name almost never contains "NORTHBOUND" or a lone "NB" token).
+54
View File
@@ -0,0 +1,54 @@
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
/**
* Normalize heterogeneous layer stats into one honest control-chip state.
* @param {object|null} stats Layer getStats() result.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state.
*/
export function layerFeedState(stats = {}) {
const state = stats || {};
const status =
typeof state.status === 'string' ? state.status.toLowerCase() : '';
const source = `${state.source || ''} ${state.coverage || ''}`;
const hasExplicitFallback = typeof state.fallback === 'boolean';
const hasPriorData = Number(state.count) > 0 || Boolean(state.lastUpdate);
const presentedError =
state.error || state.lastError || state.managerRefreshError;
if (['unavailable', 'offline', 'down', 'error'].includes(status))
return 'unavailable';
if (
(presentedError ||
state.unavailable === true ||
state.available === false) &&
!hasPriorData &&
!GUIDANCE_STATUSES.includes(status)
) {
return 'unavailable';
}
if (state.loading) return 'loading';
// Guidance states ask the user to act (zoom in, run a search) — normal
// operation, not feed faults. One honesty carve-out: layers keep their
// rendered records through the guidance state, so a genuinely stale cache
// still reads STALE; a guidance prompt alone never reads DEGRADED.
if (GUIDANCE_STATUSES.includes(status)) {
return state.stale ? 'stale' : 'nominal';
}
if (
state.fallback === true ||
status === 'fallback' ||
state.mode === 'sim' ||
/\bfallback\b/i.test(source) ||
(!hasExplicitFallback && /\badsb\.lol\b/i.test(source))
) {
return 'fallback';
}
if (state.stale || status === 'stale') return 'stale';
if (
state.degraded ||
presentedError ||
state.unavailable === true ||
state.available === false
)
return 'degraded';
return 'nominal';
}
+6 -2
View File
@@ -79,8 +79,12 @@ export function parseAcquisitionMs(date, time, cache = new Map()) {
const hhmm = String(time ?? '0000').padStart(4, '0');
const hours = Number(hhmm.slice(0, 2));
const minutes = Number(hhmm.slice(2, 4));
const valid = Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day)
&& Number.isFinite(hours) && Number.isFinite(minutes);
const valid =
Number.isFinite(year) &&
Number.isFinite(month) &&
Number.isFinite(day) &&
Number.isFinite(hours) &&
Number.isFinite(minutes);
const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
cache.set(key, ms);
return ms;
+1 -1
View File
@@ -1,6 +1,6 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationFirms, firmsServices } from '../app/layers/firms.js';
import { createSourceSlot } from '../app/sourceSlot.js';
import { createSourceSlot } from '../sources/sourceSlot.js';
import {
createFirmsHelpers,
createFirmsSource,
+11 -5
View File
@@ -12,10 +12,14 @@ export const FIRMS_OVERLAY_SOURCE_ID = 'firms';
export function fireDetectionKey(fire) {
const lat = Number.isFinite(fire?.lat) ? fire.lat.toFixed(4) : 'x';
const lon = Number.isFinite(fire?.lon) ? fire.lon.toFixed(4) : 'x';
const acq = Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? String(fire.acqMs) : '0';
const source = satelliteShortName(fire?.satellite)
|| String(fire?.sensor || '').trim().toUpperCase()
|| 'x';
const acq =
Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? String(fire.acqMs) : '0';
const source =
satelliteShortName(fire?.satellite) ||
String(fire?.sensor || '')
.trim()
.toUpperCase() ||
'x';
return `firms:${lat}:${lon}:${acq}:${source}`;
}
@@ -37,7 +41,9 @@ export function accentForSeverity(stopName) {
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
export function satelliteShortName(satellite) {
const s = String(satellite || '').trim().toUpperCase();
const s = String(satellite || '')
.trim()
.toUpperCase();
if (s === 'N20' || s === 'NOAA-20') return 'N20';
if (s === 'N21' || s === 'NOAA-21') return 'N21';
if (s === 'N' || s === 'NPP' || s === 'SUOMI NPP') return 'SNPP';
+29 -9
View File
@@ -45,7 +45,9 @@ export function median(values) {
if (!Array.isArray(values) || values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const mid = sorted.length >> 1;
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
return sorted.length % 2 === 1
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
}
/** Bearing (degrees, 0 = north, clockwise) of a projected dx/dy vector. */
@@ -98,13 +100,19 @@ export function matchFlowToRoads(roads, flowSegments) {
const roadCount = Array.isArray(roads) ? roads.length : 0;
const matches = new Array(roadCount).fill(null);
const empty = { matches, matchedCount: 0, candidateCount: 0 };
if (roadCount === 0 || !Array.isArray(flowSegments) || flowSegments.length === 0) {
if (
roadCount === 0 ||
!Array.isArray(flowSegments) ||
flowSegments.length === 0
) {
return empty;
}
// Local equirectangular projection anchored at the first flow coordinate —
// over a ≤0.05° fetch box the distortion is negligible.
const anchor = flowSegments.find((f) => Array.isArray(f?.coords) && f.coords.length >= 2);
const anchor = flowSegments.find(
(f) => Array.isArray(f?.coords) && f.coords.length >= 2,
);
if (!anchor) return empty;
const [refLon, refLat] = anchor.coords[0];
const mPerDegLon = M_PER_DEG_LAT * Math.cos((refLat * Math.PI) / 180);
@@ -114,7 +122,8 @@ export function matchFlowToRoads(roads, flowSegments) {
// ── 1. Spatial hash of flow segments (midpoint-keyed 100 m cells) ──
/** @type {Map<string, Array<{ax:number,ay:number,bx:number,by:number,bearing:number,level:number,closure:boolean}>>} */
const grid = new Map();
const cellOf = (x, y) => `${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
const cellOf = (x, y) =>
`${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
for (const flow of flowSegments) {
const coords = flow?.coords;
@@ -173,7 +182,8 @@ export function matchFlowToRoads(roads, flowSegments) {
for (let i = 0; i < coords.length; i++) {
xs[i] = projX(coords[i][0]);
ys[i] = projY(coords[i][1]);
if (i > 0) cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
if (i > 0)
cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
}
const totalLen = cum[coords.length - 1];
if (!(totalLen > 0)) continue;
@@ -188,10 +198,14 @@ export function matchFlowToRoads(roads, flowSegments) {
// cross-street flow lines meet at intersections.
const target = (totalLen * (s + 0.5)) / ROAD_SAMPLES;
while (cursor < coords.length - 1 && cum[cursor] < target) cursor++;
const segT = (target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
const segT =
(target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
const px = xs[cursor - 1] + (xs[cursor] - xs[cursor - 1]) * segT;
const py = ys[cursor - 1] + (ys[cursor] - ys[cursor - 1]) * segT;
const sampleBearing = bearingDeg(xs[cursor] - xs[cursor - 1], ys[cursor] - ys[cursor - 1]);
const sampleBearing = bearingDeg(
xs[cursor] - xs[cursor - 1],
ys[cursor] - ys[cursor - 1],
);
// 3×3 cell probe around the sample.
const cx = Math.floor(px / CELL_SIZE_M);
@@ -206,7 +220,11 @@ export function matchFlowToRoads(roads, flowSegments) {
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
if (d2 > radius2) continue;
hadCandidate = true; // within radius, bearing not yet checked
if (bearingDiffDeg(seg.bearing, sampleBearing) >= BEARING_TOLERANCE_DEG) continue;
if (
bearingDiffDeg(seg.bearing, sampleBearing) >=
BEARING_TOLERANCE_DEG
)
continue;
if (d2 <= bestDist2) {
bestDist2 = d2;
best = seg;
@@ -221,7 +239,9 @@ export function matchFlowToRoads(roads, flowSegments) {
}
if (hadCandidate) candidateCount++;
if (matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)) {
if (
matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)
) {
matches[r] = { level: median(matchedLevels), closure: matchedClosure };
matchedCount++;
}
+114 -50
View File
@@ -59,24 +59,35 @@ function focusTargetEmphasisResolved(
previouslyFarther,
) {
if (!target?.screenRect || !spriteScreenPosition) return 1;
if (!Number.isFinite(spriteScreenPosition.x) || !Number.isFinite(spriteScreenPosition.y)) return 1;
if (
!Number.isFinite(spriteScreenPosition.x) ||
!Number.isFinite(spriteScreenPosition.y)
)
return 1;
// Published rects already contain their publication-time padding. The
// delta keeps direct pure-function calls and runtime A/B overrides honest.
// The ambient extent makes overlap mean visual overlap, not center-point
// containment: a class-sized icon whose edge intrudes into the focus rect
// yields even when its center remains just outside it.
const paddingDelta = tuning.paddingPx - (Number.isFinite(target.paddingPx) ? target.paddingPx : 0);
const paddingDelta =
tuning.paddingPx -
(Number.isFinite(target.paddingPx) ? target.paddingPx : 0);
const hysteresis = previouslyCompeting ? tuning.hysteresisPx : 0;
const expansionX = paddingDelta + hysteresis
+ Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0);
const expansionY = paddingDelta + hysteresis
+ Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0);
const expansionX =
paddingDelta +
hysteresis +
Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0);
const expansionY =
paddingDelta +
hysteresis +
Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0);
const rect = target.screenRect;
const inside = spriteScreenPosition.x >= rect.left - expansionX
&& spriteScreenPosition.x <= rect.right + expansionX
&& spriteScreenPosition.y >= rect.top - expansionY
&& spriteScreenPosition.y <= rect.bottom + expansionY;
const inside =
spriteScreenPosition.x >= rect.left - expansionX &&
spriteScreenPosition.x <= rect.right + expansionX &&
spriteScreenPosition.y >= rect.top - expansionY &&
spriteScreenPosition.y <= rect.bottom + expansionY;
if (!inside) return 1;
const floor = clamp(tuning.dimFloor, 0.01, 1);
@@ -88,24 +99,33 @@ function focusTargetEmphasisResolved(
const fartherThreshold = Number.isFinite(target.cameraDistance)
? target.cameraDistance + (previouslyFarther ? -distanceBand : distanceBand)
: Number.NaN;
const farther = !Number.isFinite(spriteCameraDistance)
|| !Number.isFinite(fartherThreshold)
|| spriteCameraDistance >= fartherThreshold;
const farther =
!Number.isFinite(spriteCameraDistance) ||
!Number.isFinite(fartherThreshold) ||
spriteCameraDistance >= fartherThreshold;
if (farther) return floor;
if (tuning.nearerBehavior === 'partial') return floor + (1 - floor) * 0.5;
return 1;
}
function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
const from = clamp(Number.isFinite(current) ? current : 1, tuning.dimFloor, 1);
const from = clamp(
Number.isFinite(current) ? current : 1,
tuning.dimFloor,
1,
);
const to = clamp(Number.isFinite(target) ? target : 1, tuning.dimFloor, 1);
if (Math.abs(to - from) < Number.EPSILON) return to;
const duration = to < from ? tuning.attackMs : tuning.releaseMs;
if (duration <= 0) return to;
const t = clamp((Number.isFinite(elapsedMs) ? elapsedMs : 0) / duration, 0, 1);
const t = clamp(
(Number.isFinite(elapsedMs) ? elapsedMs : 0) / duration,
0,
1,
);
// Cubic ease-out reaches the exact endpoint at the configured duration and
// stays continuous when a moving contact reverses direction mid-transition.
const eased = 1 - ((1 - t) ** 3);
const eased = 1 - (1 - t) ** 3;
return clamp(from + (to - from) * eased, tuning.dimFloor, 1);
}
@@ -118,18 +138,24 @@ function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
*/
export function setFocusDeemphasisParams(patch = {}) {
const next = { ..._params };
if (Number.isFinite(patch.paddingPx)) next.paddingPx = Math.max(0, patch.paddingPx);
if (Number.isFinite(patch.dimFloor)) next.dimFloor = clamp(patch.dimFloor, 0.01, 1);
if (Number.isFinite(patch.paddingPx))
next.paddingPx = Math.max(0, patch.paddingPx);
if (Number.isFinite(patch.dimFloor))
next.dimFloor = clamp(patch.dimFloor, 0.01, 1);
if (['allow', 'dim', 'partial'].includes(patch.nearerBehavior)) {
next.nearerBehavior = patch.nearerBehavior;
}
if (Number.isFinite(patch.hysteresisPx)) next.hysteresisPx = Math.max(0, patch.hysteresisPx);
if (Number.isFinite(patch.hysteresisPx))
next.hysteresisPx = Math.max(0, patch.hysteresisPx);
if (Number.isFinite(patch.distanceHysteresisRatio)) {
next.distanceHysteresisRatio = clamp(patch.distanceHysteresisRatio, 0, 0.5);
}
if (Number.isFinite(patch.attackMs)) next.attackMs = Math.max(0, patch.attackMs);
if (Number.isFinite(patch.releaseMs)) next.releaseMs = Math.max(0, patch.releaseMs);
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon);
if (Number.isFinite(patch.attackMs))
next.attackMs = Math.max(0, patch.attackMs);
if (Number.isFinite(patch.releaseMs))
next.releaseMs = Math.max(0, patch.releaseMs);
if (Number.isFinite(patch.writeEpsilon))
next.writeEpsilon = Math.max(0, patch.writeEpsilon);
_params = next;
return { ..._params };
}
@@ -171,11 +197,22 @@ export function publishFocusTargetFromCachedPosition({
heightPx = 24,
params,
}) {
if (!ownerLayer || id === null || id === undefined || !scene || !camera || !displayPosition) {
if (
!ownerLayer ||
id === null ||
id === undefined ||
!scene ||
!camera ||
!displayPosition
) {
clearFocusTarget(ownerLayer, id);
return null;
}
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(scene, displayPosition, _scratchScreen);
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
displayPosition,
_scratchScreen,
);
if (!screen || !Number.isFinite(screen.x) || !Number.isFinite(screen.y)) {
clearFocusTarget(ownerLayer, id);
return null;
@@ -195,12 +232,19 @@ export function publishFocusTargetFromCachedPosition({
bottom: screen.y + halfHeight + padding,
},
paddingPx: padding,
cameraDistance: Cesium.Cartesian3.distance(camera.positionWC, displayPosition),
cameraDistance: Cesium.Cartesian3.distance(
camera.positionWC,
displayPosition,
),
frameNumber: scene.frameState?.frameNumber ?? -1,
};
if (appeared) {
for (const listener of _focusAppearListeners) {
try { listener(); } catch (e) { console.warn('[focus] appear listener error:', e); }
try {
listener();
} catch (e) {
console.warn('[focus] appear listener error:', e);
}
}
}
return _focusTarget;
@@ -311,15 +355,18 @@ export function smoothFocusEmphasis(current, target, elapsedMs, params) {
* every needed property before the next call; never retain it as a snapshot.
* @returns {{factor:number, changed:boolean, transitioning:boolean,active:boolean,desired:number}}
*/
export function advanceSpriteFocus(sprite, {
screenPosition,
cameraDistance,
nowMs,
target = _focusTarget,
params,
spriteHalfWidthPx = 0,
spriteHalfHeightPx = spriteHalfWidthPx,
}) {
export function advanceSpriteFocus(
sprite,
{
screenPosition,
cameraDistance,
nowMs,
target = _focusTarget,
params,
spriteHalfWidthPx = 0,
spriteHalfHeightPx = spriteHalfWidthPx,
},
) {
const tuning = resolvedParams(params);
let state = _spriteStates.get(sprite);
if (!state) {
@@ -369,12 +416,18 @@ export function advanceSpriteFocus(sprite, {
state.desired = desired;
}
const elapsedMs = Math.max(0, nowMs - state.transitionStartMs);
const next = smoothFocusEmphasisResolved(state.transitionFrom, desired, elapsedMs, tuning);
const next = smoothFocusEmphasisResolved(
state.transitionFrom,
desired,
elapsedMs,
tuning,
);
const changed = Math.abs(next - state.factor) > tuning.writeEpsilon;
state.factor = next;
state.desired = desired;
state.wasFarther = tuning.nearerBehavior !== 'dim'
&& Math.abs(desired - tuning.dimFloor) <= tuning.writeEpsilon;
state.wasFarther =
tuning.nearerBehavior !== 'dim' &&
Math.abs(desired - tuning.dimFloor) <= tuning.writeEpsilon;
const transitioning = Math.abs(next - desired) > tuning.writeEpsilon;
const active = Math.abs(next - 1) > tuning.writeEpsilon;
@@ -426,12 +479,18 @@ export function advanceProjectedSpriteFocus(
_advanceResult.desired = 1;
return _advanceResult;
}
const screen = position && scene
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, _scratchScreen)
: null;
const cameraDistance = position && camera?.positionWC
? Cesium.Cartesian3.distance(camera.positionWC, position)
: Number.NaN;
const screen =
position && scene
? Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
position,
_scratchScreen,
)
: null;
const cameraDistance =
position && camera?.positionWC
? Cesium.Cartesian3.distance(camera.positionWC, position)
: Number.NaN;
return advanceSpriteFocus(sprite, {
screenPosition: screen,
cameraDistance,
@@ -451,8 +510,10 @@ export function advanceProjectedSpriteFocus(
* @returns {boolean}
*/
export function focusPassIsNeeded(target, activeCount) {
return target !== null && target !== undefined
|| (Number.isFinite(activeCount) && activeCount > 0);
return (
(target !== null && target !== undefined) ||
(Number.isFinite(activeCount) && activeCount > 0)
);
}
/**
@@ -466,8 +527,9 @@ export function nearFarScalarValueAtDistance(scalar, cameraDistanceM) {
if (!scalar || !Number.isFinite(cameraDistanceM)) return 1;
const nearSq = scalar.near * scalar.near;
const farSq = scalar.far * scalar.far;
if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq) return 1;
const rawT = ((cameraDistanceM * cameraDistanceM) - nearSq) / (farSq - nearSq);
if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq)
return 1;
const rawT = (cameraDistanceM * cameraDistanceM - nearSq) / (farSq - nearSq);
const t = Math.pow(clamp(rawT, 0, 1), 0.2);
return scalar.nearValue + (scalar.farValue - scalar.nearValue) * t;
}
@@ -507,6 +569,8 @@ export function forgetSpriteFocus(sprite) {
*/
export function focusAlphaNeedsWrite(currentAlpha, nextAlpha, params) {
const tuning = resolvedParams(params);
return !Number.isFinite(currentAlpha)
|| Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon;
return (
!Number.isFinite(currentAlpha) ||
Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon
);
}
+1 -1
View File
@@ -51,7 +51,7 @@ export function geoidHeight(latDeg, lonDeg) {
if (!egm96Module) {
throw new Error(
'geoid.js: geoidHeight() called before ensureGeoidReady() resolved — ' +
'await ensureGeoidReady() first.'
'await ensureGeoidReady() first.',
);
}
return egm96Module.meanSeaLevel(latDeg, lonDeg);
+29 -1
View File
@@ -1,4 +1,32 @@
import { createGroundFloor } from '../services/groundFloor.js';
import * as terrain from './terrainHeights.js';
const groundFloor = createGroundFloor({ terrain });
export const { GROUND_FLOOR_LIFT_M, MESH_FLOOR_BELOW_PRIOR_M, MESH_FLOOR_ABOVE_PRIOR_M, coarseFloorCoord, floorAltitudeM, displayFloorHeightM, FLOOR_CELL_DEG, NEIGHBOR_FLOOR_MIN_SAMPLES, neighborFloorM, CORRIDOR_MAX_CELLS, CORRIDOR_WALK_STEP_DEG, CELL_HYSTERESIS_DEG, stickyFloorCell, corridorFloorCells, allocateCorridorCells, setMeshFloorPreferred, meshFloorPreferred, reportMeshFloorCell, meshFloorSampleWithinPrior, reportValidatedMeshFloorCell, cachedMeshFloor, _clearMeshFloorCellsForTest, cachedGroundFloor, FLOOR_RESOLVE_DEADLINE_MS, resolveGroundFloorCellsBounded, resolveGroundFloorCells, warmGroundFloor } = groundFloor;
export const {
GROUND_FLOOR_LIFT_M,
MESH_FLOOR_BELOW_PRIOR_M,
MESH_FLOOR_ABOVE_PRIOR_M,
coarseFloorCoord,
floorAltitudeM,
displayFloorHeightM,
FLOOR_CELL_DEG,
NEIGHBOR_FLOOR_MIN_SAMPLES,
neighborFloorM,
CORRIDOR_MAX_CELLS,
CORRIDOR_WALK_STEP_DEG,
CELL_HYSTERESIS_DEG,
stickyFloorCell,
corridorFloorCells,
allocateCorridorCells,
setMeshFloorPreferred,
meshFloorPreferred,
reportMeshFloorCell,
meshFloorSampleWithinPrior,
reportValidatedMeshFloorCell,
cachedMeshFloor,
_clearMeshFloorCellsForTest,
cachedGroundFloor,
FLOOR_RESOLVE_DEADLINE_MS,
resolveGroundFloorCellsBounded,
resolveGroundFloorCells,
warmGroundFloor,
} = groundFloor;
+3 -1
View File
@@ -1,4 +1,6 @@
import { createGroundSnap as createSnap } from '../services/groundSnap.js';
import * as groundFloor from './groundFloor.js';
/** Compatibility constructor using the default floor cache. */
export function createGroundSnap() { return createSnap({ groundFloor }); }
export function createGroundSnap() {
return createSnap({ groundFloor });
}
+274
View File
@@ -0,0 +1,274 @@
/**
* @module gtfsRealtime
* @description Minimal GTFS-Realtime decoder for VehiclePosition feeds.
*
* Decodes only what the Transit layer needs from a `FeedMessage` — the header
* and every entity's `VehiclePosition` — straight from the protobuf wire
* format with `pbf` (already a dependency via the TomTom flow tiles). No
* `gtfs-realtime-bindings`, no protobufjs, no generated code: the GTFS-RT
* schema is stable, and field numbers are the contract.
*
* Field numbers (gtfs-realtime.proto, v2.0):
* FeedMessage 1 header, 2 entity[]
* FeedHeader 1 gtfs_realtime_version, 2 incrementality, 3 timestamp
* FeedEntity 1 id, 2 is_deleted, 3 trip_update, 4 vehicle, 5 alert
* VehiclePosition 1 trip, 2 position, 3 current_stop_sequence,
* 4 current_status, 5 timestamp, 6 congestion_level,
* 7 stop_id, 8 vehicle, 9 occupancy_status
* TripDescriptor 1 trip_id, 2 start_time, 3 start_date,
* 4 schedule_relationship, 5 route_id, 6 direction_id
* Position 1 latitude, 2 longitude, 3 bearing, 4 odometer, 5 speed
* VehicleDescriptor 1 id, 2 label, 3 license_plate
*
* Unknown fields (extensions, newer additions) are skipped by pbf, so a feed
* that carries OVapi/NYCT extensions decodes the same as a plain one.
*
* Decoding is bounded, because a byte cap alone does not bound what a decode
* COSTS: entities past `GTFS_MAX_ENTITIES` are left unread so pbf skips them
* whole (the result says it truncated), and every string field — the feed
* header's version included — is refused past `GTFS_MAX_STRING_CHARS` rather
* than carried into the snapshot.
*
* Pure: safe to import from the browser layer, the Vite proxy, and node:test.
*/
import { PbfReader } from 'pbf';
/** VehiclePosition.VehicleStopStatus enum → label. */
export const VEHICLE_STOP_STATUS = Object.freeze({
0: 'INCOMING_AT',
1: 'STOPPED_AT',
2: 'IN_TRANSIT_TO',
});
/** VehiclePosition.OccupancyStatus enum → label. */
export const OCCUPANCY_STATUS = Object.freeze({
0: 'EMPTY',
1: 'MANY_SEATS_AVAILABLE',
2: 'FEW_SEATS_AVAILABLE',
3: 'STANDING_ROOM_ONLY',
4: 'CRUSHED_STANDING_ROOM_ONLY',
5: 'FULL',
6: 'NOT_ACCEPTING_PASSENGERS',
7: 'NO_DATA_AVAILABLE',
8: 'NOT_BOARDABLE',
});
/**
* Hard ceiling on decoded entities. Every registered feed is a metropolitan or
* national fleet — the largest observed is a few thousand vehicles — so this is
* two orders of magnitude above any honest payload.
*/
export const GTFS_MAX_ENTITIES = 50_000;
/** Longest accepted string field. GTFS ids, labels and stop ids are short. */
export const GTFS_MAX_STRING_CHARS = 256;
/** FeedHeader.Incrementality: only FULL_DATASET carries a complete snapshot. */
export const GTFS_INCREMENTALITY_FULL_DATASET = 0;
export const GTFS_INCREMENTALITY_DIFFERENTIAL = 1;
/**
* Read a length-delimited string, refusing one that is implausibly long.
* An over-long field marks its containing message `oversize`, and the whole
* record is then dropped — never truncated. Half an id is a different vehicle,
* not a shorter name for the same one.
* @param {object} pbf
* @param {object} target Message being read, flagged when the field is refused.
* @returns {string|null}
*/
function readBoundedString(pbf, target) {
const value = pbf.readString();
if (value.length <= GTFS_MAX_STRING_CHARS) return value;
target.oversize = true;
return null;
}
function readFeedHeader(tag, header, pbf) {
if (tag === 1) header.version = readBoundedString(pbf, header);
else if (tag === 2) header.incrementality = pbf.readVarint();
else if (tag === 3) header.timestamp = pbf.readVarint();
}
function readTripDescriptor(tag, trip, pbf) {
if (tag === 1) trip.tripId = readBoundedString(pbf, trip);
else if (tag === 2) trip.startTime = readBoundedString(pbf, trip);
else if (tag === 3) trip.startDate = readBoundedString(pbf, trip);
else if (tag === 4) trip.scheduleRelationship = pbf.readVarint();
else if (tag === 5) trip.routeId = readBoundedString(pbf, trip);
else if (tag === 6) trip.directionId = pbf.readVarint();
}
function readPosition(tag, position, pbf) {
if (tag === 1) position.latitude = pbf.readFloat();
else if (tag === 2) position.longitude = pbf.readFloat();
else if (tag === 3) position.bearing = pbf.readFloat();
else if (tag === 4) position.odometer = pbf.readDouble();
else if (tag === 5) position.speed = pbf.readFloat();
}
function readVehicleDescriptor(tag, descriptor, pbf) {
if (tag === 1) descriptor.id = readBoundedString(pbf, descriptor);
else if (tag === 2) descriptor.label = readBoundedString(pbf, descriptor);
else if (tag === 3)
descriptor.licensePlate = readBoundedString(pbf, descriptor);
}
function readVehiclePosition(tag, vehicle, pbf) {
if (tag === 1) vehicle.trip = pbf.readMessage(readTripDescriptor, {});
else if (tag === 2) vehicle.position = pbf.readMessage(readPosition, {});
else if (tag === 3) vehicle.currentStopSequence = pbf.readVarint();
else if (tag === 4) vehicle.currentStatus = pbf.readVarint();
else if (tag === 5) vehicle.timestamp = pbf.readVarint();
else if (tag === 6) vehicle.congestionLevel = pbf.readVarint();
else if (tag === 7) vehicle.stopId = readBoundedString(pbf, vehicle);
else if (tag === 8)
vehicle.vehicle = pbf.readMessage(readVehicleDescriptor, {});
else if (tag === 9) vehicle.occupancyStatus = pbf.readVarint();
}
function readFeedEntity(tag, entity, pbf) {
if (tag === 1) entity.id = readBoundedString(pbf, entity);
else if (tag === 2) entity.isDeleted = pbf.readBoolean();
else if (tag === 4) entity.vehicle = pbf.readMessage(readVehiclePosition, {});
// 3 (trip_update) and 5 (alert) are skipped: pbf advances past any tag the
// reader leaves untouched.
}
function readFeedMessage(tag, message, pbf) {
if (tag === 1) message.header = pbf.readMessage(readFeedHeader, {});
else if (tag === 2) {
// Past the cap the field is left UNREAD. pbf skips any field its handler
// did not consume, so the excess costs one length varint each instead of a
// full nested decode that is then thrown away.
if (message.entities.length >= GTFS_MAX_ENTITIES) {
message.truncated = true;
return;
}
message.entities.push(pbf.readMessage(readFeedEntity, {}));
}
}
/**
* Decode a raw GTFS-Realtime FeedMessage.
* @param {Uint8Array|ArrayBuffer} bytes Protobuf wire bytes.
* @returns {{ header: {version?: string, incrementality?: number, timestamp?: number},
* entities: object[], truncated: boolean }}
*/
export function decodeFeedMessage(bytes) {
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
const pbf = new PbfReader(view);
return pbf.readFields(readFeedMessage, {
header: {},
entities: [],
truncated: false,
});
}
function finiteOrNull(value) {
return Number.isFinite(value) ? value : null;
}
function nonEmptyString(value) {
const text = typeof value === 'string' ? value.trim() : '';
return text ? text : null;
}
/**
* True when a lat/lon pair is a usable surface position. Rejects non-finite
* values, out-of-range degrees, and the (0,0) null island a cold GPS reports.
* @param {number} lat
* @param {number} lon
* @returns {boolean}
*/
export function isPlausibleVehiclePosition(lat, lon) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return false;
if (Math.abs(lat) < 1e-6 && Math.abs(lon) < 1e-6) return false;
return true;
}
/**
* Flatten one decoded FeedEntity into the record the Transit layer renders,
* or null when the entity carries no usable vehicle position.
* @param {object} entity Decoded FeedEntity.
* @returns {object|null}
*/
export function normalizeVehicleEntity(entity) {
if (!entity || entity.isDeleted === true) return null;
const vehicle = entity.vehicle;
const position = vehicle?.position;
if (!vehicle || !position) return null;
// Any refused (over-long) string anywhere in this entity makes the whole
// record untrustworthy — drop it rather than render a half-identified dot.
if (
entity.oversize ||
vehicle.oversize ||
vehicle.trip?.oversize ||
vehicle.vehicle?.oversize
) {
return null;
}
const lat = finiteOrNull(position.latitude);
const lon = finiteOrNull(position.longitude);
if (!isPlausibleVehiclePosition(lat, lon)) return null;
const id = nonEmptyString(vehicle.vehicle?.id) || nonEmptyString(entity.id);
if (!id) return null;
const bearing = finiteOrNull(position.bearing);
const speed = finiteOrNull(position.speed);
const timestamp =
Number.isFinite(vehicle.timestamp) && vehicle.timestamp > 0
? vehicle.timestamp
: null;
return {
id,
lat: Number(lat.toFixed(6)),
lon: Number(lon.toFixed(6)),
bearing: bearing === null ? null : ((bearing % 360) + 360) % 360,
speedMps: speed === null || speed < 0 ? null : speed,
timestamp,
routeId: nonEmptyString(vehicle.trip?.routeId),
tripId: nonEmptyString(vehicle.trip?.tripId),
directionId: Number.isInteger(vehicle.trip?.directionId)
? vehicle.trip.directionId
: null,
label: nonEmptyString(vehicle.vehicle?.label),
stopId: nonEmptyString(vehicle.stopId),
status: VEHICLE_STOP_STATUS[vehicle.currentStatus] || null,
occupancy: OCCUPANCY_STATUS[vehicle.occupancyStatus] || null,
};
}
/**
* Decode a VehiclePositions feed into the compact snapshot the proxy serves.
* Duplicate vehicle ids keep the newest timestamp (feeds occasionally repeat
* a vehicle across two trip entities during a handover).
* @param {Uint8Array|ArrayBuffer} bytes Protobuf wire bytes.
* @returns {{ version: string|null, timestamp: number|null, incrementality: number,
* entityCount: number, truncated: boolean, vehicles: object[] }}
*/
export function decodeVehiclePositions(bytes) {
const message = decodeFeedMessage(bytes);
const byId = new Map();
for (const entity of message.entities) {
const record = normalizeVehicleEntity(entity);
if (!record) continue;
const existing = byId.get(record.id);
if (!existing || (record.timestamp ?? 0) >= (existing.timestamp ?? 0)) {
byId.set(record.id, record);
}
}
const headerTimestamp = message.header?.timestamp;
const incrementality = Number.isFinite(message.header?.incrementality)
? message.header.incrementality
: GTFS_INCREMENTALITY_FULL_DATASET;
return {
version: nonEmptyString(message.header?.version),
timestamp:
Number.isFinite(headerTimestamp) && headerTimestamp > 0
? headerTimestamp
: null,
incrementality,
entityCount: message.entities.length,
truncated: message.truncated === true,
vehicles: [...byId.values()],
};
}
+348
View File
@@ -0,0 +1,348 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PbfWriter } from 'pbf';
import {
GTFS_MAX_ENTITIES,
GTFS_MAX_STRING_CHARS,
decodeFeedMessage,
decodeVehiclePositions,
isPlausibleVehiclePosition,
normalizeVehicleEntity,
} from './gtfsRealtime.js';
/** Encode a nested message with a fresh writer and embed it as a bytes field. */
function embed(writer, tag, build) {
const inner = new PbfWriter();
build(inner);
writer.writeBytesField(tag, inner.finish());
}
/**
* Hand-encode one FeedEntity carrying a VehiclePosition, from the proto field
* numbers — the same contract the decoder reads, written independently.
*/
function writeVehicleEntity(
writer,
{
entityId,
vehicleId,
label,
lat,
lon,
bearing,
speed,
timestamp,
routeId,
tripId,
directionId,
stopId,
status,
occupancy,
isDeleted,
extraUnknownFields = false,
},
) {
embed(writer, 2, (entity) => {
if (entityId != null) entity.writeStringField(1, entityId);
if (isDeleted) entity.writeBooleanField(2, true);
embed(entity, 4, (vehicle) => {
if (routeId != null || tripId != null || directionId != null) {
embed(vehicle, 1, (trip) => {
if (tripId != null) trip.writeStringField(1, tripId);
if (routeId != null) trip.writeStringField(5, routeId);
if (directionId != null) trip.writeVarintField(6, directionId);
});
}
if (lat != null) {
embed(vehicle, 2, (position) => {
position.writeFloatField(1, lat);
position.writeFloatField(2, lon);
if (bearing != null) position.writeFloatField(3, bearing);
if (speed != null) position.writeFloatField(5, speed);
if (extraUnknownFields) position.writeDoubleField(4, 123456.5); // odometer
});
}
if (status != null) vehicle.writeVarintField(4, status);
if (timestamp != null) vehicle.writeVarintField(5, timestamp);
if (stopId != null) vehicle.writeStringField(7, stopId);
if (vehicleId != null || label != null) {
embed(vehicle, 8, (descriptor) => {
if (vehicleId != null) descriptor.writeStringField(1, vehicleId);
if (label != null) descriptor.writeStringField(2, label);
});
}
if (occupancy != null) vehicle.writeVarintField(9, occupancy);
if (extraUnknownFields)
vehicle.writeStringField(1001, 'operator-extension');
});
});
}
function encodeFeed(
entities,
{ version = '2.0', timestamp = 1_788_936_000 } = {},
) {
const writer = new PbfWriter();
embed(writer, 1, (header) => {
header.writeStringField(1, version);
header.writeVarintField(2, 0);
header.writeVarintField(3, timestamp);
});
for (const entity of entities) writeVehicleEntity(writer, entity);
return writer.finish();
}
test('decodes header and every VehiclePosition field the layer renders', () => {
const bytes = encodeFeed([
{
entityId: 'e1',
vehicleId: 'bus-17',
label: '1557',
lat: 44.927551,
lon: -93.386711,
bearing: 248,
speed: 11.2,
timestamp: 1_788_936_945,
routeId: '17',
tripId: '1134773',
directionId: 0,
stopId: '57458',
status: 1,
occupancy: 2,
},
]);
const snapshot = decodeVehiclePositions(bytes);
assert.equal(snapshot.version, '2.0');
assert.equal(snapshot.timestamp, 1_788_936_000);
assert.equal(snapshot.entityCount, 1);
assert.equal(snapshot.vehicles.length, 1);
const [v] = snapshot.vehicles;
assert.equal(v.id, 'bus-17');
assert.equal(v.label, '1557');
assert.ok(Math.abs(v.lat - 44.927551) < 1e-5);
assert.ok(Math.abs(v.lon + 93.386711) < 1e-5);
assert.equal(v.bearing, 248);
assert.ok(Math.abs(v.speedMps - 11.2) < 1e-5);
assert.equal(v.timestamp, 1_788_936_945);
assert.equal(v.routeId, '17');
assert.equal(v.tripId, '1134773');
assert.equal(v.directionId, 0);
assert.equal(v.stopId, '57458');
assert.equal(v.status, 'STOPPED_AT');
assert.equal(v.occupancy, 'FEW_SEATS_AVAILABLE');
});
test('unknown and extension fields are skipped, not misread', () => {
const bytes = encodeFeed([
{ entityId: 'x', lat: 51.3, lon: 5.33, extraUnknownFields: true },
]);
const snapshot = decodeVehiclePositions(bytes);
assert.equal(snapshot.vehicles.length, 1);
assert.equal(snapshot.vehicles[0].id, 'x');
assert.ok(Math.abs(snapshot.vehicles[0].lat - 51.3) < 1e-5);
});
test('entities without a usable position are dropped and never throw', () => {
const bytes = encodeFeed([
{ entityId: 'no-position' },
{ entityId: 'deleted', lat: 1, lon: 1, isDeleted: true },
{ entityId: 'null-island', lat: 0, lon: 0 },
{ entityId: 'out-of-range', lat: 91, lon: 10 },
{ entityId: 'nan', lat: Number.NaN, lon: 10 },
{ entityId: 'keeper', lat: 60.17, lon: 24.94 },
]);
const snapshot = decodeVehiclePositions(bytes);
assert.equal(snapshot.entityCount, 6);
assert.deepEqual(
snapshot.vehicles.map((v) => v.id),
['keeper'],
);
});
test('vehicle id falls back to the entity id and the vehicle is dropped when neither exists', () => {
const bytes = encodeFeed([
{ entityId: 'entity-only', lat: 42.3, lon: -71.1 },
{ lat: 42.3, lon: -71.1 },
]);
const snapshot = decodeVehiclePositions(bytes);
assert.deepEqual(
snapshot.vehicles.map((v) => v.id),
['entity-only'],
);
});
test('duplicate vehicle ids keep the newest timestamp', () => {
const bytes = encodeFeed([
{ entityId: 'a', vehicleId: 'v1', lat: 42.3, lon: -71.1, timestamp: 100 },
{ entityId: 'b', vehicleId: 'v1', lat: 42.31, lon: -71.11, timestamp: 200 },
{ entityId: 'c', vehicleId: 'v1', lat: 42.32, lon: -71.12, timestamp: 150 },
]);
const snapshot = decodeVehiclePositions(bytes);
assert.equal(snapshot.vehicles.length, 1);
assert.equal(snapshot.vehicles[0].timestamp, 200);
assert.ok(Math.abs(snapshot.vehicles[0].lat - 42.31) < 1e-5);
});
test('bearing is normalized to [0, 360) and negative speed is treated as unknown', () => {
const bytes = encodeFeed([
{ entityId: 'neg', lat: 30.2, lon: -97.7, bearing: -90, speed: -1 },
{ entityId: 'wrap', lat: 30.2, lon: -97.7, bearing: 450 },
]);
const [neg, wrap] = decodeVehiclePositions(bytes).vehicles;
assert.equal(neg.bearing, 270);
assert.equal(neg.speedMps, null);
assert.equal(wrap.bearing, 90);
});
test('missing header yields null version/timestamp rather than garbage', () => {
const writer = new PbfWriter();
writeVehicleEntity(writer, { entityId: 'solo', lat: 10, lon: 10 });
const snapshot = decodeVehiclePositions(writer.finish());
assert.equal(snapshot.version, null);
assert.equal(snapshot.timestamp, null);
assert.equal(snapshot.vehicles.length, 1);
});
test('decodeFeedMessage accepts an ArrayBuffer and an empty feed', () => {
const empty = decodeFeedMessage(new ArrayBuffer(0));
assert.deepEqual(empty, { header: {}, entities: [], truncated: false });
const bytes = encodeFeed([]);
const asBuffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
);
assert.equal(decodeFeedMessage(asBuffer).header.version, '2.0');
});
test('isPlausibleVehiclePosition and normalizeVehicleEntity guard their inputs', () => {
assert.equal(isPlausibleVehiclePosition(45, 90), true);
assert.equal(isPlausibleVehiclePosition(0, 0), false);
assert.equal(isPlausibleVehiclePosition(-90.0001, 0), false);
assert.equal(isPlausibleVehiclePosition(1, Infinity), false);
assert.equal(normalizeVehicleEntity(null), null);
assert.equal(normalizeVehicleEntity({ id: 'no-vehicle' }), null);
assert.equal(
normalizeVehicleEntity({
id: 'v',
vehicle: { position: { latitude: 1, longitude: 2 }, currentStatus: 99 },
})?.status,
null,
);
});
test('the decode is bounded by entity count as well as by bytes', () => {
const entities = [];
for (let index = 0; index < GTFS_MAX_ENTITIES + 25; index += 1) {
entities.push({
entityId: `e${index}`,
vehicleId: `v${index}`,
lat: 42 + index * 1e-6,
lon: -71,
timestamp: 1_788_936_000,
});
}
const decoded = decodeVehiclePositions(encodeFeed(entities));
assert.equal(decoded.entityCount, GTFS_MAX_ENTITIES);
assert.equal(decoded.truncated, true);
assert.equal(decoded.vehicles.length, GTFS_MAX_ENTITIES);
// A feed inside the cap is not flagged.
assert.equal(
decodeVehiclePositions(encodeFeed(entities.slice(0, 3))).truncated,
false,
);
});
test('an implausibly long string drops the whole record rather than truncating an id', () => {
const long = 'x'.repeat(GTFS_MAX_STRING_CHARS + 1);
const decoded = decodeVehiclePositions(
encodeFeed([
{
entityId: 'ok',
vehicleId: 'good',
lat: 42.1,
lon: -71.1,
timestamp: 1_788_936_000,
},
{
entityId: 'bad',
vehicleId: long,
lat: 42.2,
lon: -71.2,
timestamp: 1_788_936_000,
},
{
entityId: 'bad-route',
vehicleId: 'v3',
routeId: long,
lat: 42.3,
lon: -71.3,
timestamp: 1_788_936_000,
},
]),
);
// The entity is still counted — the decoder had to walk past it — but it is
// never rendered, because half an id is a different vehicle.
assert.equal(decoded.entityCount, 3);
assert.deepEqual(
decoded.vehicles.map((vehicle) => vehicle.id),
['good'],
);
// A string exactly at the cap is fine.
const atCap = 'y'.repeat(GTFS_MAX_STRING_CHARS);
const kept = decodeVehiclePositions(
encodeFeed([
{ entityId: 'e', vehicleId: atCap, lat: 42, lon: -71, timestamp: 1 },
]),
);
assert.deepEqual(
kept.vehicles.map((vehicle) => vehicle.id),
[atCap],
);
});
test('incrementality is reported, not silently discarded', () => {
assert.equal(decodeVehiclePositions(encodeFeed([])).incrementality, 0);
const writer = new PbfWriter();
embed(writer, 1, (header) => {
header.writeStringField(1, '2.0');
header.writeVarintField(2, 1);
header.writeVarintField(3, 1_788_936_000);
});
assert.equal(decodeVehiclePositions(writer.finish()).incrementality, 1);
});
test('the feed header version is bounded like every other string', () => {
const writer = new PbfWriter();
embed(writer, 1, (header) => {
header.writeStringField(1, 'v'.repeat(GTFS_MAX_STRING_CHARS + 1));
header.writeVarintField(2, 0);
header.writeVarintField(3, 1_788_936_000);
});
const decoded = decodeVehiclePositions(writer.finish());
// Refused, not carried: an unbounded header field is the one string a feed
// can grow without the entity cap ever seeing it.
assert.equal(decoded.version, null);
assert.equal(decoded.timestamp, 1_788_936_000);
});
test('entities past the cap are skipped whole rather than decoded and discarded', () => {
const decoded = decodeVehiclePositions(
encodeFeed(
Array.from({ length: GTFS_MAX_ENTITIES + 5 }, (_, index) => ({
entityId: `e${index}`,
vehicleId: `v${index}`,
lat: 42 + index * 1e-6,
lon: -71,
timestamp: 1_788_936_000,
})),
),
);
assert.equal(decoded.entityCount, GTFS_MAX_ENTITIES);
assert.equal(decoded.truncated, true);
// The skipped tail never becomes an object, so the last retained vehicle is
// the one at the cap and nothing beyond it appears.
assert.equal(
decoded.vehicles.some((item) => item.id === `v${GTFS_MAX_ENTITIES}`),
false,
);
});
+150 -14
View File
@@ -49,7 +49,12 @@ const _scratchWorldForward = new Cesium.Cartesian3();
* unavailable (off-screen/behind camera/degenerate).
* @returns {number|null} Rotation in radians, or `previous` when unknown.
*/
export function screenProjectedRotation(scene, position, courseDeg, previous = null) {
export function screenProjectedRotation(
scene,
position,
courseDeg,
previous = null,
) {
const camera = scene?.camera;
if (!camera?.rightWC || !camera?.upWC || !position) return previous;
@@ -58,20 +63,26 @@ export function screenProjectedRotation(scene, position, courseDeg, previous = n
Math.sin(courseRad) * FORWARD_PROBE_M,
Math.cos(courseRad) * FORWARD_PROBE_M,
0,
_scratchForward
_scratchForward,
);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
position,
Cesium.Ellipsoid.WGS84,
_scratchEnu,
);
Cesium.Matrix4.multiplyByPointAsVector(
enu,
_scratchForward,
_scratchWorldForward,
);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position, Cesium.Ellipsoid.WGS84, _scratchEnu);
Cesium.Matrix4.multiplyByPointAsVector(enu, _scratchForward, _scratchWorldForward);
// Screen x follows camera-right. Window y grows downward, the opposite of
// camera-up. Projecting the vector itself avoids clipping/behind-camera
// failure modes from the old forward-point worldToWindowCoordinates probe.
const dx = Cesium.Cartesian3.dot(_scratchWorldForward, camera.rightWC);
const dy = -Cesium.Cartesian3.dot(_scratchWorldForward, camera.upWC);
if (
(dx * dx + dy * dy)
< MIN_SCREEN_COMPONENT_M * MIN_SCREEN_COMPONENT_M
) return previous;
if (dx * dx + dy * dy < MIN_SCREEN_COMPONENT_M * MIN_SCREEN_COMPONENT_M)
return previous;
// Window y grows downward; rotation 0 = icon pointing screen-up.
// Icon direction in window coords after CCW rotation r is (-sin r, -cos r),
@@ -94,13 +105,20 @@ export function stabilizeScreenRotation(
next,
deadbandRad = ROTATION_DEADBAND_RAD,
) {
if (!Number.isFinite(next)) return Number.isFinite(previous) ? previous : null;
if (!Number.isFinite(next))
return Number.isFinite(previous) ? previous : null;
if (!Number.isFinite(previous)) return next;
const delta = Math.atan2(Math.sin(next - previous), Math.cos(next - previous));
const delta = Math.atan2(
Math.sin(next - previous),
Math.cos(next - previous),
);
return Math.abs(delta) < Math.max(0, deadbandRad) ? previous : next;
}
const _occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, new Cesium.Cartesian3());
const _occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
new Cesium.Cartesian3(),
);
/**
* Half-width of the crossfade band centred on the ellipsoid silhouette, in
@@ -169,7 +187,11 @@ const _wgs84OneOverRadii = Cesium.Ellipsoid.WGS84.oneOverRadii;
* @param {number} [featherRad=HORIZON_FEATHER_RAD] Half-width of the blend band.
* @returns {number} 0 (ground behind) … 1 (sky behind).
*/
export function skyBackdropFactor(cameraPosition, position, featherRad = HORIZON_FEATHER_RAD) {
export function skyBackdropFactor(
cameraPosition,
position,
featherRad = HORIZON_FEATHER_RAD,
) {
if (!cameraPosition || !position) return 0;
const s = _wgs84OneOverRadii;
@@ -236,6 +258,120 @@ export function horizonOccluder(camera) {
*/
export function cameraPoseSignature(camera) {
const p = camera.positionWC;
return `${Math.round(p.x / 10)}:${Math.round(p.y / 10)}:${Math.round(p.z / 10)}:` +
`${camera.heading.toFixed(3)}:${camera.pitch.toFixed(3)}:${camera.roll.toFixed(3)}`;
return (
`${Math.round(p.x / 10)}:${Math.round(p.y / 10)}:${Math.round(p.z / 10)}:` +
`${camera.heading.toFixed(3)}:${camera.pitch.toFixed(3)}:${camera.roll.toFixed(3)}`
);
}
/**
* Meters ahead used by the exact projection below. Short on purpose: the point
* of a perspective projection is that it is LOCAL to the contact, and a two
* kilometre probe on a bus at six hundred metres would sample a part of the
* frustum the vehicle is nowhere near.
*/
const NEAR_PROBE_M = 30;
const _scratchProbe = new Cesium.Cartesian3();
const _scratchWindowAt = new Cesium.Cartesian2();
const _scratchWindowAhead = new Cesium.Cartesian2();
/**
* Billboard rotation for a SURFACE contact, projected exactly — perspective
* division included — instead of through the camera-basis approximation.
*
* `screenProjectedRotation` above deliberately projects the course vector onto
* the camera's right/up basis, which is orthographic: exact at the viewport
* centre and an approximation away from it. That is the right trade for
* aircraft, where the alternative loses continuity through a tracked orbit and
* the contacts are far enough away for the error to be small.
*
* It is the wrong trade for street-level vehicles. A probe with a real Cesium
* camera at 600 m and −45° pitch, on a northbound contact 350 m right of
* centre, put the orthographic answer at 0° where the true screen direction of
* travel was 22.5°. At that pitch and that range the vehicles fill the frame
* away from the centre, and every one of them points visibly wrong.
*
* So this projects two nearby world points through the actual view-projection
* and reads the angle between them on screen. When either point cannot be
* projected — behind the camera, off the frustum, degenerate — it falls back to
* the camera-basis answer rather than inventing one.
*
* @param {Cesium.Scene} scene The scene (projection source).
* @param {Cesium.Cartesian3} position Entity world position.
* @param {number} courseDeg Course in degrees clockwise from north.
* @param {number|null} previous Rotation to keep when projection is unavailable.
* @returns {number|null} Rotation in radians, or the fallback.
*/
export function perspectiveProjectedRotation(
scene,
position,
courseDeg,
previous = null,
) {
if (!scene?.camera || !position || !Number.isFinite(courseDeg)) {
return previous;
}
const courseRad = Cesium.Math.toRadians(courseDeg);
Cesium.Cartesian3.fromElements(
Math.sin(courseRad) * NEAR_PROBE_M,
Math.cos(courseRad) * NEAR_PROBE_M,
0,
_scratchForward,
);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
position,
Cesium.Ellipsoid.WGS84,
_scratchEnu,
);
Cesium.Matrix4.multiplyByPointAsVector(
enu,
_scratchForward,
_scratchWorldForward,
);
Cesium.Cartesian3.add(position, _scratchWorldForward, _scratchProbe);
const at = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
position,
_scratchWindowAt,
);
const ahead = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
_scratchProbe,
_scratchWindowAhead,
);
if (!at || !ahead) {
return screenProjectedRotation(scene, position, courseDeg, previous);
}
const dx = ahead.x - at.x;
const dy = ahead.y - at.y;
const separation = Math.hypot(dx, dy);
// Window y grows downward; rotation 0 = icon pointing screen-up.
const exact = Math.atan2(-dx, -dy);
if (separation >= PERSPECTIVE_BLEND_MAX_PX) return exact;
// Below a pixel or so of separation the exact answer is dominated by
// projection noise, and above it the camera-basis answer is the wrong one
// by up to twenty-odd degrees. A hard cutoff between the two flipped a
// sprite by 23° for a 20 m change of camera altitude; the two answers are
// blended across the band instead, the short way round, so the rotation is
// continuous through it.
const basis = screenProjectedRotation(scene, position, courseDeg, previous);
if (!Number.isFinite(basis)) return exact;
if (separation <= PERSPECTIVE_BLEND_MIN_PX) return basis;
const weight =
(separation - PERSPECTIVE_BLEND_MIN_PX) /
(PERSPECTIVE_BLEND_MAX_PX - PERSPECTIVE_BLEND_MIN_PX);
const delta = Math.atan2(Math.sin(exact - basis), Math.cos(exact - basis));
return basis + delta * weight;
}
/**
* Probe separations (px) between which the exact and camera-basis answers
* are blended. At or below the floor the camera-basis answer is used alone;
* at or above the ceiling the exact one.
*/
export const PERSPECTIVE_BLEND_MIN_PX = 0.5;
export const PERSPECTIVE_BLEND_MAX_PX = 3;
/** Exported for tests: how far ahead the exact projection probes. */
export const PERSPECTIVE_PROBE_M = NEAR_PROBE_M;
+342 -53
View File
@@ -47,8 +47,12 @@ test('off-center oblique contacts pin the documented perspective divergence', ()
const right = new Cesium.Cartesian3(0, 1, 0);
const up = new Cesium.Cartesian3(Math.sin(pitch), 0, Math.cos(pitch));
const forward = new Cesium.Cartesian3(-Math.cos(pitch), 0, Math.sin(pitch));
const basisRotation = screenProjectedRotation({ camera: { rightWC: right, upWC: up } },
equatorPosition, 0, null);
const basisRotation = screenProjectedRotation(
{ camera: { rightWC: right, upWC: up } },
equatorPosition,
0,
null,
);
// Exact pinhole derivative for a contact 2 km right of center and 5 km in
// front. North has a depth component under this oblique camera, so exact
@@ -65,26 +69,29 @@ test('off-center oblique contacts pin the documented perspective divergence', ()
const exactRotation = Math.atan2(-exactScreenX, exactScreenUp);
assert.ok(Math.abs(basisRotation) < 1e-12);
assert.ok(Math.abs(wrappedDelta(exactRotation, basisRotation)) > Cesium.Math.toRadians(5));
assert.ok(
Math.abs(wrappedDelta(exactRotation, basisRotation)) >
Cesium.Math.toRadians(5),
);
});
test('screen rotation holds sub-degree projection noise', () => {
const previous = 1;
assert.equal(
stabilizeScreenRotation(previous, previous + (0.25 * Math.PI / 180)),
stabilizeScreenRotation(previous, previous + (0.25 * Math.PI) / 180),
previous,
);
});
test('screen rotation accepts deliberate camera-orbit movement', () => {
const previous = 1;
const next = previous + (2 * Math.PI / 180);
const next = previous + (2 * Math.PI) / 180;
assert.equal(stabilizeScreenRotation(previous, next), next);
});
test('screen rotation compares across the wrapped angle boundary', () => {
const previous = Math.PI - (0.1 * Math.PI / 180);
const next = -Math.PI + (0.1 * Math.PI / 180);
const previous = Math.PI - (0.1 * Math.PI) / 180;
const next = -Math.PI + (0.1 * Math.PI) / 180;
assert.equal(stabilizeScreenRotation(previous, next), previous);
});
@@ -102,10 +109,12 @@ const cruiseCamera = Cesium.Cartesian3.fromDegrees(0, 0, CRUISE_M);
* tangent condition is exact here and the expected answer is derived, not
* guessed.
*/
const tangentLonDeg = Cesium.Math.toDegrees(Math.acos(
Cesium.Ellipsoid.WGS84.maximumRadius
/ (Cesium.Ellipsoid.WGS84.maximumRadius + CRUISE_M),
));
const tangentLonDeg = Cesium.Math.toDegrees(
Math.acos(
Cesium.Ellipsoid.WGS84.maximumRadius /
(Cesium.Ellipsoid.WGS84.maximumRadius + CRUISE_M),
),
);
test('a contact above the silhouette is read against sky', () => {
// Another aircraft at the same flight level 200 km ahead. The chord between
@@ -124,7 +133,10 @@ test('a contact below the silhouette keeps ground behind it', () => {
// the horizon, so the plate has real imagery to separate the text from.
const below = Cesium.Cartesian3.fromDegrees(30_000 / 111_320, 0, 2_000);
assert.equal(skyBackdropFactor(cruiseCamera, below), 0);
assert.equal(skyBackdropFactor(cruiseCamera, Cesium.Cartesian3.fromDegrees(0.05, 0, 0)), 0);
assert.equal(
skyBackdropFactor(cruiseCamera, Cesium.Cartesian3.fromDegrees(0.05, 0, 0)),
0,
);
});
test('the silhouette itself lands exactly mid-band', () => {
@@ -144,10 +156,12 @@ test('the silhouette itself lands exactly mid-band', () => {
function climbSweep(step = 500) {
const samples = [];
for (let height = 0; height <= 12_000; height += step) {
samples.push(skyBackdropFactor(
cruiseCamera,
Cesium.Cartesian3.fromDegrees(100_000 / 111_320, 0, height),
));
samples.push(
skyBackdropFactor(
cruiseCamera,
Cesium.Cartesian3.fromDegrees(100_000 / 111_320, 0, height),
),
);
}
return samples;
}
@@ -163,9 +177,16 @@ test('the band lerps monotonically and saturates on both sides', () => {
);
}
assert.equal(samples[0], 0, 'well below the horizon is fully grounded');
assert.equal(samples[samples.length - 1], 1, 'well above the horizon is fully sky');
assert.equal(
samples[samples.length - 1],
1,
'well above the horizon is fully sky',
);
const partial = samples.filter((value) => value > 0 && value < 1);
assert.ok(partial.length >= 3, `expected a real blend band, saw ${partial.length} partial samples`);
assert.ok(
partial.length >= 3,
`expected a real blend band, saw ${partial.length} partial samples`,
);
});
test('the band, not the geometry, is what smooths the crossing', () => {
@@ -174,13 +195,19 @@ test('the band, not the geometry, is what smooths the crossing', () => {
// if the two ever get conflated, only one of these two tests goes red.
const hard = [];
for (let height = 0; height <= 12_000; height += 500) {
hard.push(skyBackdropFactor(
cruiseCamera,
Cesium.Cartesian3.fromDegrees(100_000 / 111_320, 0, height),
0,
));
hard.push(
skyBackdropFactor(
cruiseCamera,
Cesium.Cartesian3.fromDegrees(100_000 / 111_320, 0, height),
0,
),
);
}
assert.deepEqual([...new Set(hard)], [0, 1], 'a zero band must produce only the two end states');
assert.deepEqual(
[...new Set(hard)],
[0, 1],
'a zero band must produce only the two end states',
);
});
/**
@@ -200,12 +227,19 @@ test('below the ellipsoid, a contact against open sky still reads as sky', () =>
// ~6.5° above eye level — the whole band is 1.09° per side, so this is not a
// near call. Before the fix a sub-ellipsoid camera fell into the degenerate
// guard and every one of these came back 0: a row of dark boxes on empty sky.
const approach = Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT + 8_000 * DEG_PER_M_LAT, 900);
const approach = Cesium.Cartesian3.fromDegrees(
JFK_LON,
JFK_LAT + 8_000 * DEG_PER_M_LAT,
900,
);
assert.equal(skyBackdropFactor(jfkCockpit, approach), 1);
// Straight overhead is the unambiguous case, from under the surface as much
// as from cruise altitude.
assert.equal(
skyBackdropFactor(jfkCockpit, Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT, 10_000)),
skyBackdropFactor(
jfkCockpit,
Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT, 10_000),
),
1,
);
});
@@ -225,7 +259,11 @@ test('below the ellipsoid, a contact on the ramp still keeps its plate', () => {
assert.equal(
skyBackdropFactor(
jfkCockpit,
Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT + 400 * DEG_PER_M_LAT, JFK_RAMP_H - 5),
Cesium.Cartesian3.fromDegrees(
JFK_LON,
JFK_LAT + 400 * DEG_PER_M_LAT,
JFK_RAMP_H - 5,
),
),
0,
);
@@ -246,14 +284,21 @@ test('at the surface the horizon is exactly eye level, geodetic not geocentric',
new Cesium.Cartesian3(20_000, 0, 0),
new Cesium.Cartesian3(),
);
const alongHorizontal = Cesium.Cartesian3.add(onSurface, east, new Cesium.Cartesian3());
const alongHorizontal = Cesium.Cartesian3.add(
onSurface,
east,
new Cesium.Cartesian3(),
);
assert.ok(
Math.abs(skyBackdropFactor(onSurface, alongHorizontal) - 0.5) < 1e-5,
`eye level must be the band centre, got ${skyBackdropFactor(onSurface, alongHorizontal)}`,
);
// The geocentric radial is NOT the answer: a contact perpendicular to it
// lands measurably off centre, so this test cannot pass by accident.
const radialUp = Cesium.Cartesian3.normalize(onSurface, new Cesium.Cartesian3());
const radialUp = Cesium.Cartesian3.normalize(
onSurface,
new Cesium.Cartesian3(),
);
const perpToRadial = Cesium.Cartesian3.cross(
radialUp,
Cesium.Cartesian3.UNIT_Z,
@@ -265,7 +310,11 @@ test('at the surface the horizon is exactly eye level, geodetic not geocentric',
20_000,
perpToRadial,
);
const alongRadialPlane = Cesium.Cartesian3.add(onSurface, perpToRadial, new Cesium.Cartesian3());
const alongRadialPlane = Cesium.Cartesian3.add(
onSurface,
perpToRadial,
new Cesium.Cartesian3(),
);
assert.ok(
Math.abs(skyBackdropFactor(onSurface, alongRadialPlane) - 0.5) > 0.05,
'a geocentric horizontal would be a different plane; the test must be able to tell',
@@ -284,7 +333,9 @@ function surfaceCrossingSweep(from = -50, to = 50, step = 1) {
const contact = Cesium.Cartesian3.fromDegrees(30_000 / 111_319.49, 0, 0);
const samples = [];
for (let height = from; height <= to; height += step) {
samples.push(skyBackdropFactor(Cesium.Cartesian3.fromDegrees(0, 0, height), contact));
samples.push(
skyBackdropFactor(Cesium.Cartesian3.fromDegrees(0, 0, height), contact),
);
}
return samples;
}
@@ -300,7 +351,10 @@ test('the answer stays continuous as the camera rises through the surface', () =
for (let i = 1; i < samples.length; i++) {
worst = Math.max(worst, Math.abs(samples[i] - samples[i - 1]));
}
assert.ok(worst < 0.05, `no step across the surface may pop; worst adjacent jump ${worst}`);
assert.ok(
worst < 0.05,
`no step across the surface may pop; worst adjacent jump ${worst}`,
);
});
test('the crossover has no seam at the surface itself', () => {
@@ -308,9 +362,18 @@ test('the crossover has no seam at the surface itself', () => {
// 1 cm is 5.6e-5 rad, a thousandth of the band, so any visible difference
// here is a discontinuity in the code rather than in the geometry.
const contact = Cesium.Cartesian3.fromDegrees(30_000 / 111_319.49, 0, 0);
const under = skyBackdropFactor(Cesium.Cartesian3.fromDegrees(0, 0, -0.01), contact);
const over = skyBackdropFactor(Cesium.Cartesian3.fromDegrees(0, 0, 0.01), contact);
assert.ok(Math.abs(under - over) < 5e-3, `seam at the surface: ${under} vs ${over}`);
const under = skyBackdropFactor(
Cesium.Cartesian3.fromDegrees(0, 0, -0.01),
contact,
);
const over = skyBackdropFactor(
Cesium.Cartesian3.fromDegrees(0, 0, 0.01),
contact,
);
assert.ok(
Math.abs(under - over) < 5e-3,
`seam at the surface: ${under} vs ${over}`,
);
});
test('a sub-surface camera carries real information, not one frozen answer', () => {
@@ -319,7 +382,10 @@ test('a sub-surface camera carries real information, not one frozen answer', ()
// sub-surface camera heights must produce different answers.
const belowOnly = surfaceCrossingSweep(-200, -1, 1);
const spread = Math.max(...belowOnly) - Math.min(...belowOnly);
assert.ok(spread > 0.05, `a sub-surface camera must still discriminate; spread ${spread}`);
assert.ok(
spread > 0.05,
`a sub-surface camera must still discriminate; spread ${spread}`,
);
assert.ok(
belowOnly.every((value) => value > 0 && value < 1),
'this sweep is deliberately mid-band the whole way',
@@ -334,7 +400,11 @@ test('a sub-surface camera carries real information, not one frozen answer', ()
* a future edit to the shared lines could drift the cruise answer while every
* sub-surface test stayed green.
*/
function legacyAboveSurfaceFactor(cameraPosition, position, featherRad = HORIZON_FEATHER_RAD) {
function legacyAboveSurfaceFactor(
cameraPosition,
position,
featherRad = HORIZON_FEATHER_RAD,
) {
const s = Cesium.Ellipsoid.WGS84.oneOverRadii;
const cx = cameraPosition.x * s.x;
const cy = cameraPosition.y * s.y;
@@ -415,22 +485,39 @@ test('below the surface the backdrop test agrees with the occluder beside it', (
const camera = Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT, -18);
const occluder = horizonOccluder({ positionWC: camera });
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(camera);
const offsetBy = (up) => Cesium.Cartesian3.add(
camera,
Cesium.Matrix4.multiplyByPointAsVector(
enu,
new Cesium.Cartesian3(0, 20_000, up),
const offsetBy = (up) =>
Cesium.Cartesian3.add(
camera,
Cesium.Matrix4.multiplyByPointAsVector(
enu,
new Cesium.Cartesian3(0, 20_000, up),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
);
// 20 km out and 600 m clear of eye level is well outside the 1.09° band.
const aboveEye = offsetBy(600);
const belowEye = offsetBy(-600);
assert.equal(occluder.isPointVisible(aboveEye), true, 'the occluder keeps what is above eye level');
assert.equal(skyBackdropFactor(camera, aboveEye), 1, 'and the backdrop test must call it sky');
assert.equal(occluder.isPointVisible(belowEye), false, 'the occluder culls what is below it');
assert.equal(skyBackdropFactor(camera, belowEye), 0, 'and the backdrop test must call it ground');
assert.equal(
occluder.isPointVisible(aboveEye),
true,
'the occluder keeps what is above eye level',
);
assert.equal(
skyBackdropFactor(camera, aboveEye),
1,
'and the backdrop test must call it sky',
);
assert.equal(
occluder.isPointVisible(belowEye),
false,
'the occluder culls what is below it',
);
assert.equal(
skyBackdropFactor(camera, belowEye),
0,
'and the backdrop test must call it ground',
);
});
test('a degenerate camera or contact reports ground rather than throwing', () => {
@@ -442,12 +529,24 @@ test('a degenerate camera or contact reports ground rather than throwing', () =>
assert.equal(skyBackdropFactor(cruiseCamera, cruiseCamera), 0);
// The planet's centre has no local vertical to measure against.
assert.equal(skyBackdropFactor(Cesium.Cartesian3.ZERO, cruiseCamera), 0);
assert.equal(skyBackdropFactor(new Cesium.Cartesian3(NaN, NaN, NaN), cruiseCamera), 0);
assert.equal(skyBackdropFactor(cruiseCamera, new Cesium.Cartesian3(NaN, NaN, NaN)), 0);
assert.equal(
skyBackdropFactor(new Cesium.Cartesian3(NaN, NaN, NaN), cruiseCamera),
0,
);
assert.equal(
skyBackdropFactor(cruiseCamera, new Cesium.Cartesian3(NaN, NaN, NaN)),
0,
);
// An infinite coordinate normalizes to NaN a few lines later, and a NaN plate
// alpha reaches the canvas as an invisible label rather than a caught error.
assert.equal(skyBackdropFactor(new Cesium.Cartesian3(Infinity, 0, 0), cruiseCamera), 0);
assert.equal(skyBackdropFactor(cruiseCamera, new Cesium.Cartesian3(Infinity, 0, 0)), 0);
assert.equal(
skyBackdropFactor(new Cesium.Cartesian3(Infinity, 0, 0), cruiseCamera),
0,
);
assert.equal(
skyBackdropFactor(cruiseCamera, new Cesium.Cartesian3(Infinity, 0, 0)),
0,
);
});
test('the feather band is a named constant in a sane screen-space range', () => {
@@ -456,3 +555,193 @@ test('the feather band is a named constant in a sane screen-space range', () =>
// band of arc-seconds would pop.
assert.ok(HORIZON_FEATHER_RAD > 0.002 && HORIZON_FEATHER_RAD < 0.09);
});
// --- Perspective projection through a real view-projection --------------------
import {
PERSPECTIVE_BLEND_MAX_PX,
PERSPECTIVE_BLEND_MIN_PX,
PERSPECTIVE_PROBE_M,
perspectiveProjectedRotation,
} from './iconOrientation.js';
/**
* A scene the exact projector accepts: 3D mode, a canvas, and a camera with a
* real view matrix and perspective frustum. The camera sits `altitudeM` above
* the origin and looks north, pitched down by `pitchDeg`, with world east as
* screen right — the oblique street-level pose where the camera-basis answer
* is wrong off-centre and the exact one is not.
*/
function obliqueScene(
altitudeM,
pitchDeg,
{ width = 1280, height = 800 } = {},
) {
const position = Cesium.Cartesian3.fromDegrees(0, 0, altitudeM);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position);
const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3());
const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3());
const up = Cesium.Matrix4.getColumn(enu, 2, new Cesium.Cartesian3());
const pitch = Cesium.Math.toRadians(pitchDeg);
const direction = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.add(
Cesium.Cartesian3.multiplyByScalar(
north,
Math.cos(pitch),
new Cesium.Cartesian3(),
),
Cesium.Cartesian3.multiplyByScalar(
up,
-Math.sin(pitch),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
const cameraUp = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.add(
Cesium.Cartesian3.multiplyByScalar(
north,
Math.sin(pitch),
new Cesium.Cartesian3(),
),
Cesium.Cartesian3.multiplyByScalar(
up,
Math.cos(pitch),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
const viewMatrix = Cesium.Matrix4.computeView(
position,
direction,
cameraUp,
east,
new Cesium.Matrix4(),
);
const frustum = new Cesium.PerspectiveFrustum({
fov: Cesium.Math.toRadians(60),
aspectRatio: width / height,
near: 1,
far: 1e7,
});
return {
frameState: { mode: Cesium.SceneMode.SCENE3D },
canvas: { clientWidth: width, clientHeight: height },
camera: {
viewMatrix,
frustum,
rightWC: east,
upWC: cameraUp,
positionWC: position,
},
};
}
/** Probe separation (px) the helper sees for a contact and course in a scene. */
function probeSeparation(scene, contact, courseDeg) {
const courseRad = Cesium.Math.toRadians(courseDeg);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(contact);
const forward = Cesium.Matrix4.multiplyByPointAsVector(
enu,
new Cesium.Cartesian3(
Math.sin(courseRad) * PERSPECTIVE_PROBE_M,
Math.cos(courseRad) * PERSPECTIVE_PROBE_M,
0,
),
new Cesium.Cartesian3(),
);
const probe = Cesium.Cartesian3.add(
contact,
forward,
new Cesium.Cartesian3(),
);
const at = Cesium.SceneTransforms.worldToWindowCoordinates(scene, contact);
const ahead = Cesium.SceneTransforms.worldToWindowCoordinates(scene, probe);
const dx = ahead.x - at.x;
const dy = ahead.y - at.y;
return { separation: Math.hypot(dx, dy), exact: Math.atan2(-dx, -dy) };
}
test('the perspective helper is continuous through the sub-pixel band, not a 23° flip', () => {
// Codex's reproduction: a contact near the right edge of an oblique view,
// the camera altitude walked so the probe separation crosses half a pixel.
// The rotation jumped 23° → 0° between two adjacent altitudes. The exact
// and camera-basis answers now blend across a band, the short way round.
//
// The contact keeps the SAME screen position across the walk — its ground
// offsets scale with the altitude — so the two answers disagree by the
// same twenty-odd degrees at every altitude while the separation alone
// walks from well above the band to well below it.
const course = 0; // northbound
const contactFor = (altitude) =>
Cesium.Cartesian3.fromDegrees(
(0.7 * altitude) / 111_320,
(1.2 * altitude) / 111_320,
0,
);
const samples = [];
// Geometric steps: separation scales with altitude, so equal ratios give
// equal-sized steps in the quantity the band is defined on.
for (let altitude = 500; altitude <= 600_000; altitude *= 1.02) {
const scene = obliqueScene(altitude, 45);
const contact = contactFor(altitude);
if (!Cesium.SceneTransforms.worldToWindowCoordinates(scene, contact))
continue;
const { separation, exact } = probeSeparation(scene, contact, course);
samples.push({
altitude,
separation,
exact,
rotation: perspectiveProjectedRotation(scene, contact, course, null),
basis: screenProjectedRotation(scene, contact, course, null),
});
}
const inBand = samples.filter(
(s) =>
s.separation > PERSPECTIVE_BLEND_MIN_PX &&
s.separation < PERSPECTIVE_BLEND_MAX_PX,
);
const above = samples.filter((s) => s.separation >= PERSPECTIVE_BLEND_MAX_PX);
const below = samples.filter((s) => s.separation <= PERSPECTIVE_BLEND_MIN_PX);
assert.ok(
inBand.length >= 5 && above.length >= 5 && below.length >= 5,
`the walk crosses the whole band (${below.length}/${inBand.length}/${above.length})`,
);
// Off-centre and oblique, the two answers genuinely disagree — that is the
// divergence the exact projection exists to fix.
const disagreement = Math.abs(
wrappedDelta(above[0].rotation, above[0].basis),
);
assert.ok(
disagreement > Cesium.Math.toRadians(5),
`exact and camera-basis differ by ${Cesium.Math.toDegrees(disagreement).toFixed(1)}° above the band`,
);
let worstStep = 0;
for (let i = 1; i < samples.length; i += 1) {
worstStep = Math.max(
worstStep,
Math.abs(wrappedDelta(samples[i].rotation, samples[i - 1].rotation)),
);
}
assert.ok(
worstStep < Cesium.Math.toRadians(3),
`no step between neighbouring altitudes exceeds 3° (worst ${Cesium.Math.toDegrees(worstStep).toFixed(2)}°)`,
);
// Below the band it is the camera-basis answer; above it the exact one, unblended.
for (const s of below)
assert.ok(
Math.abs(wrappedDelta(s.rotation, s.basis)) < 1e-6,
`basis alone at ${s.separation.toFixed(2)} px`,
);
for (const s of above)
assert.ok(
Math.abs(wrappedDelta(s.rotation, s.exact)) < 1e-6,
`exact alone at ${s.separation.toFixed(2)} px`,
);
});
+25 -6
View File
@@ -29,8 +29,14 @@ export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
}
export function findNextIssPass({
satrec, latDeg, lonDeg, fromMs,
minElevDeg = 10, horizonHours = 24, coarseStepSec = 30, fineStepSec = 5,
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg = 10,
horizonHours = 24,
coarseStepSec = 30,
fineStepSec = 5,
}) {
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
const horizonMs = fromMs + horizonHours * 3600_000;
@@ -41,13 +47,17 @@ export function findNextIssPass({
// pass, that's still "the next pass" for a voice answer — accept it.
let hit = null;
for (let t = fromMs; t <= horizonMs; t += coarse) {
if (elev(t) >= minElevDeg) { hit = t; break; }
if (elev(t) >= minElevDeg) {
hit = t;
break;
}
}
if (hit == null) return null;
// Refine rise: walk back in fine steps to the first sample ≥ threshold.
let riseMs = hit;
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg) riseMs -= fine;
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg)
riseMs -= fine;
// Walk forward through the pass tracking the peak until we drop below.
let maxElevDeg = -90;
@@ -56,11 +66,20 @@ export function findNextIssPass({
while (t <= horizonMs) {
const e = elev(t);
if (e < minElevDeg && t > riseMs) break;
if (e > maxElevDeg) { maxElevDeg = e; maxElevMs = t; }
if (e > maxElevDeg) {
maxElevDeg = e;
maxElevMs = t;
}
t += fine;
}
const setMs = t;
const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
return { riseMs, setMs, maxElevDeg, maxElevMs, riseAzDeg: rise ? rise.azDeg : 0 };
return {
riseMs,
setMs,
maxElevDeg,
maxElevMs,
riseAzDeg: rise ? rise.azDeg : 0,
};
}
+175 -72
View File
@@ -16,11 +16,15 @@ function clampInt(value, min, max) {
}
function demandEntries(demandByLayer) {
const entries = demandByLayer instanceof Map
? Array.from(demandByLayer.entries())
: Object.entries(demandByLayer || {});
const entries =
demandByLayer instanceof Map
? Array.from(demandByLayer.entries())
: Object.entries(demandByLayer || {});
return entries
.map(([layerId, demand]) => [String(layerId), Math.max(0, Math.floor(Number(demand) || 0))])
.map(([layerId, demand]) => [
String(layerId),
Math.max(0, Math.floor(Number(demand) || 0)),
])
.filter(([, demand]) => demand > 0)
.sort(([a], [b]) => a.localeCompare(b));
}
@@ -104,7 +108,9 @@ function allocateLayerQuotasInto(
weighted.length = idCount;
priorityOrder.length = idCount;
remainders.length = idCount;
priorityOrder.sort((a, b) => b.weight - a.weight || a.layerId.localeCompare(b.layerId));
priorityOrder.sort(
(a, b) => b.weight - a.weight || a.layerId.localeCompare(b.layerId),
);
let remaining = capacity;
if (capacity >= idCount) {
@@ -115,7 +121,7 @@ function allocateLayerQuotasInto(
let apportioned = 0;
for (let i = 0; i < idCount; i++) {
const entry = weighted[i];
const exact = remaining * entry.weight / totalWeight;
const exact = (remaining * entry.weight) / totalWeight;
const room = entry.count - (quotas.get(entry.layerId) || 0);
const whole = Math.min(room, Math.floor(exact));
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
@@ -123,9 +129,12 @@ function allocateLayerQuotasInto(
entry.fraction = exact - Math.floor(exact);
}
remaining -= apportioned;
remainders.sort((a, b) => b.fraction - a.fraction
|| b.weight - a.weight
|| a.layerId.localeCompare(b.layerId));
remainders.sort(
(a, b) =>
b.fraction - a.fraction ||
b.weight - a.weight ||
a.layerId.localeCompare(b.layerId),
);
for (let i = 0; i < idCount && remaining > 0; i++) {
const entry = remainders[i];
const current = quotas.get(entry.layerId) || 0;
@@ -156,7 +165,12 @@ function allocateLayerQuotasInto(
* Allocate a collective capacity across non-empty layers. Both strategies are
* work-conserving and deterministic; unused entitlement is always borrowed.
*/
export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATION_ELASTIC, layerWeights = {}) {
export function allocateLayerQuotas(
demandByLayer,
capacity,
strategy = ALLOCATION_ELASTIC,
layerWeights = {},
) {
const entries = demandEntries(demandByLayer);
const totalDemand = entries.reduce((sum, [, demand]) => sum + demand, 0);
const cap = clampInt(capacity, 0, totalDemand);
@@ -198,15 +212,24 @@ export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATI
if (remaining > 0 && totalWeight > 0) {
let apportioned = 0;
for (const entry of weighted) {
const exact = remaining * entry.weight / totalWeight;
const exact = (remaining * entry.weight) / totalWeight;
const room = entry.count - (quotas.get(entry.layerId) || 0);
const whole = Math.min(room, Math.floor(exact));
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
apportioned += whole;
remainders.push({ layerId: entry.layerId, fraction: exact - Math.floor(exact), weight: entry.weight });
remainders.push({
layerId: entry.layerId,
fraction: exact - Math.floor(exact),
weight: entry.weight,
});
}
remaining -= apportioned;
remainders.sort((a, b) => b.fraction - a.fraction || b.weight - a.weight || a.layerId.localeCompare(b.layerId));
remainders.sort(
(a, b) =>
b.fraction - a.fraction ||
b.weight - a.weight ||
a.layerId.localeCompare(b.layerId),
);
for (const entry of remainders) {
if (remaining <= 0) break;
const current = quotas.get(entry.layerId) || 0;
@@ -220,20 +243,24 @@ export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATI
}
function rectIsFinite(rect) {
return rect
&& Number.isFinite(rect.x)
&& Number.isFinite(rect.y)
&& Number.isFinite(rect.w)
&& Number.isFinite(rect.h)
&& rect.w > 0
&& rect.h > 0;
return (
rect &&
Number.isFinite(rect.x) &&
Number.isFinite(rect.y) &&
Number.isFinite(rect.w) &&
Number.isFinite(rect.h) &&
rect.w > 0 &&
rect.h > 0
);
}
function overlaps(a, b, padding = 4) {
return a.x < b.x + b.w + padding
&& a.x + a.w + padding > b.x
&& a.y < b.y + b.h + padding
&& a.y + a.h + padding > b.y;
return (
a.x < b.x + b.w + padding &&
a.x + a.w + padding > b.x &&
a.y < b.y + b.h + padding &&
a.y + a.h + padding > b.y
);
}
const CELL_ORIGIN = 8192;
@@ -271,8 +298,10 @@ class SpatialHash {
* never hide a real collision.
*/
_cellKey(x, y) {
const cx = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, x)) + CELL_ORIGIN;
const cy = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, y)) + CELL_ORIGIN;
const cx =
Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, x)) + CELL_ORIGIN;
const cy =
Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, y)) + CELL_ORIGIN;
// Both coordinates occupy 14 bits. Bit-packing forces the lookup key to a
// Smi; arithmetic multiplication materializes a boxed double at each
// spatial-hash get/add on this hot solve path.
@@ -325,8 +354,14 @@ function candidateCompare(a, b, states, now) {
// A stateless candidate is never lifetime-pinned: its source shipped as a
// per-frame rebuild, so it must be free to lose its slot the moment something
// better wants it. Plain incumbency ordering below still applies.
const aPinned = !!aState?.selected && a.stateless !== true && now - aState.selectedAt < MIN_LIFETIME_MS;
const bPinned = !!bState?.selected && b.stateless !== true && now - bState.selectedAt < MIN_LIFETIME_MS;
const aPinned =
!!aState?.selected &&
a.stateless !== true &&
now - aState.selectedAt < MIN_LIFETIME_MS;
const bPinned =
!!bState?.selected &&
b.stateless !== true &&
now - bState.selectedAt < MIN_LIFETIME_MS;
if (aPinned !== bPinned) return aPinned ? -1 : 1;
const aIncumbent = !!aState?.selected;
const bIncumbent = !!bState?.selected;
@@ -346,12 +381,20 @@ function candidateCompare(a, b, states, now) {
function cacheCandidateAnchorScalars(candidate) {
let anchorX = Number.NaN;
let anchorY = Number.NaN;
if (Number.isFinite(candidate?.screenX) && Number.isFinite(candidate?.screenY)) {
if (
Number.isFinite(candidate?.screenX) &&
Number.isFinite(candidate?.screenY)
) {
anchorX = candidate.screenX;
anchorY = candidate.screenY;
} else {
const placement = Array.isArray(candidate?.placements) ? candidate.placements[0] : null;
if (Number.isFinite(placement?.leadFromX) && Number.isFinite(placement?.leadFromY)) {
const placement = Array.isArray(candidate?.placements)
? candidate.placements[0]
: null;
if (
Number.isFinite(placement?.leadFromX) &&
Number.isFinite(placement?.leadFromY)
) {
anchorX = placement.leadFromX;
anchorY = placement.leadFromY;
} else if (rectIsFinite(placement?.rect)) {
@@ -401,7 +444,16 @@ class SpatialCandidateQueue {
* stay unboxed. Few-placement candidates already blocked by the
* monotonically growing collision field are dismissed before anchor scans.
*/
reset(candidates, count, anchors, anchorCount, attemptStamps, stamp, spatial, states) {
reset(
candidates,
count,
anchors,
anchorCount,
attemptStamps,
stamp,
spatial,
states,
) {
this.candidates = candidates;
this.count = count;
this.anchorCount = anchorCount;
@@ -529,10 +581,13 @@ class SpatialCandidateQueue {
const rectY = placementYs[rectIndex];
const rectW = placementWs[rectIndex];
const rectH = placementHs[rectIndex];
if (rectX < anchorRectX + anchorRectW + 4
&& rectX + rectW + 4 > anchorRectX
&& rectY < anchorRectY + anchorRectH + 4
&& rectY + rectH + 4 > anchorRectY) freeMask &= ~bit;
if (
rectX < anchorRectX + anchorRectW + 4 &&
rectX + rectW + 4 > anchorRectX &&
rectY < anchorRectY + anchorRectH + 4 &&
rectY + rectH + 4 > anchorRectY
)
freeMask &= ~bit;
}
placementMasks[i] = freeMask;
if (freeMask === 0 && this._dismissBlockedKey(i)) continue;
@@ -558,7 +613,8 @@ class SpatialCandidateQueue {
bestIndex = i;
continue;
}
const priorityDelta = (Number(candidate.priority) || 0) - (Number(best.priority) || 0);
const priorityDelta =
(Number(candidate.priority) || 0) - (Number(best.priority) || 0);
if (priorityDelta !== 0) {
if (priorityDelta > 0) {
best = candidate;
@@ -621,7 +677,8 @@ function firstFreePlacement(candidate, stickyCorner, spatial) {
if (stickyCorner) {
for (let i = 0; i < placements.length; i++) {
const placement = placements[i];
if (placement?.corner !== stickyCorner || !rectIsFinite(placement.rect)) continue;
if (placement?.corner !== stickyCorner || !rectIsFinite(placement.rect))
continue;
if (!spatial.collides(placement.rect)) return placement;
}
}
@@ -790,7 +847,13 @@ export class LabelArbiter {
let candidateCount = 0;
for (let i = 0; i < source.length; i++) {
const candidate = source[i];
if (!candidate || !candidate.key || !candidate.layerId || !(candidate.keyholeAlpha > 0)) continue;
if (
!candidate ||
!candidate.key ||
!candidate.layerId ||
!(candidate.keyholeAlpha > 0)
)
continue;
cacheCandidateAnchorScalars(candidate);
candidateList[candidateCount++] = candidate;
let bucket = byLayer.get(candidate.layerId);
@@ -815,13 +878,15 @@ export class LabelArbiter {
});
} else if (options.demandByLayer != null) {
const supplied = demandEntries(options.demandByLayer);
for (let i = 0; i < supplied.length; i++) demand.set(supplied[i][0], supplied[i][1]);
for (let i = 0; i < supplied.length; i++)
demand.set(supplied[i][0], supplied[i][1]);
}
// A supplied demand map describes the full lightweight field while the
// candidate list may be only a bounded materialized cohort. Keep defensive
// support for candidate layers omitted by an incomplete caller map.
byLayer.forEach((bucket, layerId) => {
if (bucket.count > 0 && !demand.has(layerId)) demand.set(layerId, bucket.count);
if (bucket.count > 0 && !demand.has(layerId))
demand.set(layerId, bucket.count);
});
const activeLayers = this._activeLayersScratch;
@@ -857,14 +922,20 @@ export class LabelArbiter {
let sameActiveLayers = activeCount === this.lastActiveLayers.length;
for (let i = 0; sameActiveLayers && i < activeCount; i++) {
if (activeLayers[i] !== this.lastActiveLayers[i]) sameActiveLayers = false;
if (activeLayers[i] !== this.lastActiveLayers[i])
sameActiveLayers = false;
}
const preserveIncumbents = options.preserveIncumbents !== false && sameActiveLayers;
const preserveIncumbents =
options.preserveIncumbents !== false && sameActiveLayers;
let spatialQueueBuildCount = 0;
let spatialQueueNextCount = 0;
const attempt = (candidate, allowOverlapFallback = false) => {
if (selectedCount >= capacity || attemptStamps.get(candidate.key) === stamp) return false;
if (
selectedCount >= capacity ||
attemptStamps.get(candidate.key) === stamp
)
return false;
attemptStamps.set(candidate.key, stamp);
const previous = this.states.get(candidate.key);
const stateless = candidate.stateless === true;
@@ -872,10 +943,12 @@ export class LabelArbiter {
// very next solve, as their stateless originals did) and carry no sticky
// corner, so above/below is re-decided from geometry every solve instead
// of being pinned by whichever side happened to be free once.
if (!stateless && !previous?.selected && previous?.cooldownUntil > now) return false;
if (!stateless && !previous?.selected && previous?.cooldownUntil > now)
return false;
const sticky = stateless ? undefined : previous?.corner;
let placement = firstFreePlacement(candidate, sticky, spatial);
if (!placement && allowOverlapFallback) placement = firstOrderedPlacement(candidate, sticky);
if (!placement && allowOverlapFallback)
placement = firstOrderedPlacement(candidate, sticky);
if (!placement) return false;
spatial.add(placement.rect);
selectStamps.set(candidate.key, stamp);
@@ -953,7 +1026,8 @@ export class LabelArbiter {
}
let retainedIncumbents = 0;
for (let i = 0; i < selectedCount; i++) {
if (this.selectedKeys.has(selectedCandidates[i].key)) retainedIncumbents++;
if (this.selectedKeys.has(selectedCandidates[i].key))
retainedIncumbents++;
}
const exitingStates = this._refreshStateList();
@@ -964,7 +1038,9 @@ export class LabelArbiter {
state.exitStartedAt = now;
// Stateless entries may be re-selected immediately; the cooldown exists
// to damp thrash for sources that shipped with hysteresis.
state.cooldownUntil = state.stateless ? 0 : now + FADE_OUT_MS + COOLDOWN_MS;
state.cooldownUntil = state.stateless
? 0
: now + FADE_OUT_MS + COOLDOWN_MS;
}
}
@@ -1002,7 +1078,8 @@ export class LabelArbiter {
this._stateListDirty = true;
}
if (labelsByLayer) {
labelsByLayer[candidate.layerId] = (labelsByLayer[candidate.layerId] || 0) + 1;
labelsByLayer[candidate.layerId] =
(labelsByLayer[candidate.layerId] || 0) + 1;
}
}
@@ -1013,7 +1090,10 @@ export class LabelArbiter {
const livingStates = this._refreshStateList();
for (let i = 0; i < livingStates.length; i++) {
const state = livingStates[i];
if (!state.selected && now - (state.exitStartedAt || now) > FADE_OUT_MS + COOLDOWN_MS) {
if (
!state.selected &&
now - (state.exitStartedAt || now) > FADE_OUT_MS + COOLDOWN_MS
) {
this.states.delete(state.key);
this._stateListDirty = true;
}
@@ -1026,25 +1106,28 @@ export class LabelArbiter {
});
for (let i = 0; i < droppedCount; i++) this.selectedKeys.delete(dropped[i]);
dropped.length = droppedCount;
for (let i = 0; i < selectedCount; i++) this.selectedKeys.add(selectedCandidates[i].key);
for (let i = 0; i < selectedCount; i++)
this.selectedKeys.add(selectedCandidates[i].key);
const previousActiveLayers = this.lastActiveLayers;
this.lastActiveLayers = activeLayers;
this._activeLayersScratch = previousActiveLayers;
this.solveRevision++;
this.lastDiagnostics = collectDiagnostics ? {
strategy,
capacity,
selectedCount,
quotas: Object.fromEntries(quotas),
demand: Object.fromEntries(demand),
labelsByLayer,
solveRevision: this.solveRevision,
spatialQueueBuildCount,
spatialQueueNextCount,
eligibleIncumbents,
retainedIncumbents,
} : null;
this.lastDiagnostics = collectDiagnostics
? {
strategy,
capacity,
selectedCount,
quotas: Object.fromEntries(quotas),
demand: Object.fromEntries(demand),
labelsByLayer,
solveRevision: this.solveRevision,
spatialQueueBuildCount,
spatialQueueNextCount,
eligibleIncumbents,
retainedIncumbents,
}
: null;
selectedCandidates.length = selectedCount;
selectedPlacements.length = selectedCount;
pruneStampMap(attemptStamps, candidateCount);
@@ -1057,9 +1140,15 @@ export class LabelArbiter {
* A caller-owned output array enables allocation-free per-frame rendering.
*/
renderEntries(currentCandidates, now = Date.now(), out = []) {
const current = currentCandidates instanceof Map
? currentCandidates
: new Map((currentCandidates || []).map((candidate) => [candidate.key, candidate]));
const current =
currentCandidates instanceof Map
? currentCandidates
: new Map(
(currentCandidates || []).map((candidate) => [
candidate.key,
candidate,
]),
);
const states = this._refreshStateList();
let outIndex = 0;
for (let i = 0; i < states.length; i++) {
@@ -1070,14 +1159,23 @@ export class LabelArbiter {
// fully painted this solve or gone. No enter ramp, no exit tail.
temporalAlpha = state.selected ? 1 : 0;
} else if (state.selected) {
temporalAlpha = Math.min(1, Math.max(0, (now - state.enterStartedAt) / FADE_IN_MS));
temporalAlpha = Math.min(
1,
Math.max(0, (now - state.enterStartedAt) / FADE_IN_MS),
);
} else {
temporalAlpha = 1 - Math.min(1, Math.max(0, (now - state.exitStartedAt) / FADE_OUT_MS));
temporalAlpha =
1 -
Math.min(1, Math.max(0, (now - state.exitStartedAt) / FADE_OUT_MS));
}
if (temporalAlpha <= 0) continue;
const candidate = current.get(state.key) || state.lastCandidate;
if (!candidate) continue;
const placement = renderPlacement(candidate, state.stateless ? undefined : state.corner, state.lastPlacement);
const placement = renderPlacement(
candidate,
state.stateless ? undefined : state.corner,
state.lastPlacement,
);
if (!placement) continue;
const entry = out[outIndex] || (out[outIndex] = {});
entry.candidate = candidate;
@@ -1100,11 +1198,16 @@ export class LabelArbiter {
for (const state of this.states.values()) {
if (!state.selected) {
if (!includeFading) continue;
if (!Number.isFinite(state.exitStartedAt) || now - state.exitStartedAt >= FADE_OUT_MS) continue;
if (
!Number.isFinite(state.exitStartedAt) ||
now - state.exitStartedAt >= FADE_OUT_MS
)
continue;
}
const candidate = state.lastCandidate;
if (!candidate?.layerId || candidate.sourceId == null) continue;
if (!grouped.has(candidate.layerId)) grouped.set(candidate.layerId, new Set());
if (!grouped.has(candidate.layerId))
grouped.set(candidate.layerId, new Set());
grouped.get(candidate.layerId).add(candidate.sourceId);
}
return grouped;
+449 -179
View File
File diff suppressed because it is too large. Load diff
+11 -4
View File
@@ -158,8 +158,9 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 18);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 18);
assert.equal(REGISTERED_LAYER_IDS.length, 21);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 21);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.throws(
() => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]),
@@ -226,8 +227,14 @@ test('v2 codec distinguishes absent from empty and keeps canonical deterministic
});
test('unknown enabled-layer tokens reject the payload instead of becoming an empty set', () => {
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=z')), null);
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=c.z')), null);
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=unknown')), null);
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=c.unknown')), null);
});
test('Nepal event and locator have distinct enabled-only share tokens', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=h.z'));
assert.deepEqual(decoded.enabledLayerIds, ['bhote-koshi-2026', 'bhote-koshi-locator']);
assert.ok(encode(decoded).includes('l=h.z'));
});
test('unknown and forbidden option fields are ignored while missing options use codec defaults', () => {
+4
View File
@@ -2200,6 +2200,10 @@ export class LayerLifecycle {
icon: entry.module.icon,
source: entry.module.source,
showInTogglePanel: entry.module.showInTogglePanel !== false,
// Registry id of the provider key this layer needs, if any (mirrors
// showInTogglePanel). The layer reports stats.keyRequired while that
// key is absent; the panel builds the guidance text from the pair.
requiresKeyId: entry.module.requiresKeyId || null,
enabled: entry.enabled,
lifecycleState: entry.lifecycleState,
lifecycleUncertain: entry.lifecycleUncertain,
+10 -29
View File
@@ -1,15 +1,5 @@
import { governorRequestRender } from '../renderGovernor.js';
import {
clearSelectedEntityContextForLayer,
registerEntityContext,
removeEntityContextsForLayer,
selectEntityContext,
} from './contextStore.js';
import {
clearOverlaySource,
setOverlayEntries,
setOverlaySourceVisible,
} from '../overlays/worldOverlay.js';
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
export { localGeoJsonServices } from '../app/localGeojsonServices.js';
import {
createLocalGeoJsonLayer as createLayer,
createLocalInfrastructureOverlayPublisher as createPublisher,
@@ -17,26 +7,14 @@ import {
export * from './localGeojsonCore.js';
/** Existing standalone application operations, shared by its local layers. */
export const localGeoJsonServices = Object.freeze({
overlayHost: Object.freeze({
clearSource: clearOverlaySource,
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
}),
clearSelectedEntityContextForLayer,
registerEntityContext,
removeEntityContextsForLayer,
selectEntityContext,
governorRequestRender,
});
/** Create a layer using the standalone app's operations; retain overlay overrides. */
export function createLocalGeoJsonLayer(options) {
return createLayer(options, {
...localGeoJsonServices,
overlayHost: options.overlayHost === undefined
? localGeoJsonServices.overlayHost : options.overlayHost,
overlayHost:
options.overlayHost === undefined
? localGeoJsonServices.overlayHost
: options.overlayHost,
});
}
@@ -44,6 +22,9 @@ export function createLocalGeoJsonLayer(options) {
export function createLocalInfrastructureOverlayPublisher(options) {
return createPublisher({
...options,
host: options.host === undefined ? localGeoJsonServices.overlayHost : options.host,
host:
options.host === undefined
? localGeoJsonServices.overlayHost
: options.host,
});
}
+1 -6
View File
@@ -15,9 +15,4 @@ const fires = createFirmsHeatmapLayer({
source: 'NASA FIRMS · LIVE',
});
export default [
datacenters,
dams,
submarineCablesLayer,
fires,
];
export default [datacenters, dams, submarineCablesLayer, fires];
+4 -1
View File
@@ -1,4 +1,7 @@
import { createMeshFloorSampler } from '../services/meshFloorSampler.js';
import * as groundFloor from './groundFloor.js';
const meshFloorSampler = createMeshFloorSampler({ groundFloor, eventTarget: typeof window === 'undefined' ? null : window });
const meshFloorSampler = createMeshFloorSampler({
groundFloor,
eventTarget: typeof window === 'undefined' ? null : window,
});
export const { sampleMeshFloorCells, cachedGroundFloor } = meshFloorSampler;
+73 -18
View File
@@ -18,10 +18,13 @@ export const AWARENESS_RELATIONSHIP = Object.freeze({
* @param {{initialRadiusM?: number, maxRadiusM?: number}} [options] Bounds.
* @returns {{candidate: T, radiusM: number}|null} First match and radius used.
*/
export function findByDoublingRadius(search, {
initialRadiusM = AWARENESS_RADIUS_M,
maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
} = {}) {
export function findByDoublingRadius(
search,
{
initialRadiusM = AWARENESS_RADIUS_M,
maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
} = {},
) {
if (typeof search !== 'function') return null;
if (!Number.isFinite(initialRadiusM) || initialRadiusM <= 0) return null;
if (!Number.isFinite(maxRadiusM) || maxRadiusM < initialRadiusM) return null;
@@ -39,13 +42,22 @@ export function findByDoublingRadius(search, {
/** @param {number} meters @returns {string} */
export function formatAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
return meters < 1000
? `${Math.round(meters)} m`
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
}
/** Return a stable visible label without leaking missing values into UI text. */
export function formatAwarenessLabel(value) {
if (value && typeof value === 'object') {
for (const candidate of [value.callsign, value.name, value.label, value.id, value.icao24, value.mmsi]) {
for (const candidate of [
value.callsign,
value.name,
value.label,
value.id,
value.icao24,
value.mmsi,
]) {
const label = formatAwarenessLabel(candidate);
if (label !== '—') return label;
}
@@ -61,19 +73,41 @@ export function formatAwarenessLabel(value) {
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range
* claim in this MVP.
*/
export function summarizeAwarenessCohort(items, { available = true, stale = false, limit = 3 } = {}) {
if (!available) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed unavailable' };
if (stale) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed stale' };
export function summarizeAwarenessCohort(
items,
{ available = true, stale = false, limit = 3 } = {},
) {
if (!available)
return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: 'feed unavailable',
};
if (stale)
return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: 'feed stale',
};
const normalized = (Array.isArray(items) ? items : [])
.filter((item) => Number.isFinite(item?.distanceM ?? item?.distance)
&& (item.distanceM ?? item.distance) >= 0)
.filter(
(item) =>
Number.isFinite(item?.distanceM ?? item?.distance) &&
(item.distanceM ?? item.distance) >= 0,
)
.map((item) => ({ ...item, distanceM: item.distanceM ?? item.distance }))
.sort((a, b) => a.distanceM - b.distanceM);
return {
relationship: normalized.length ? AWARENESS_RELATIONSHIP.NEARBY : AWARENESS_RELATIONSHIP.UNKNOWN,
relationship: normalized.length
? AWARENESS_RELATIONSHIP.NEARBY
: AWARENESS_RELATIONSHIP.UNKNOWN,
count: normalized.length,
nearest: normalized.slice(0, limit),
reason: normalized.length ? 'observed or mapped nearby context' : 'no observed or mapped objects in current feeds',
reason: normalized.length
? 'observed or mapped nearby context'
: 'no observed or mapped objects in current feeds',
};
}
@@ -84,21 +118,40 @@ export function summarizeAwarenessCohort(items, { available = true, stale = fals
* @param {Iterable<string>} [visitedKeys] Previously visited layer/id keys.
* @returns {Array<{layerId: string, id: string, item: Object}>} Selectable targets.
*/
export function getAwarenessNavigationTargets(cohorts, subject = null, visitedKeys = []) {
export function getAwarenessNavigationTargets(
cohorts,
subject = null,
visitedKeys = [],
) {
const visited = new Set(visitedKeys);
const targets = [];
for (const cohort of Array.isArray(cohorts) ? cohorts : []) {
const layerId = cohort?.id;
if (!['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(layerId)) continue;
if (
![
'flights',
'military',
'ais-live-vessels',
'military-installations',
].includes(layerId)
)
continue;
const sourceItems = Array.isArray(cohort?.summary?.navigationNearest)
? cohort.summary.navigationNearest
: (Array.isArray(cohort?.summary?.nearest) ? cohort.summary.nearest : []);
: Array.isArray(cohort?.summary?.nearest)
? cohort.summary.nearest
: [];
for (const item of sourceItems) {
const id = item?.icao24 || item?.mmsi || item?.id;
if (!id) continue;
const key = `${layerId}:${id}`;
if (key === `${subject?.layerId}:${subject?.id}`) continue;
targets.push({ layerId, id: String(id), item, visited: visited.has(key) });
targets.push({
layerId,
id: String(id),
item,
visited: visited.has(key),
});
}
}
targets.sort((a, b) => {
@@ -108,7 +161,9 @@ export function getAwarenessNavigationTargets(cohorts, subject = null, visitedKe
// reached vessels, installations, or the other flight layer at all.
const visitedOrder = Number(a.visited) - Number(b.visited);
if (visitedOrder) return visitedOrder;
const subjectOrder = Number(a.layerId !== subject?.layerId) - Number(b.layerId !== subject?.layerId);
const subjectOrder =
Number(a.layerId !== subject?.layerId) -
Number(b.layerId !== subject?.layerId);
if (subjectOrder) return subjectOrder;
return (a.item.distanceM ?? Infinity) - (b.item.distanceM ?? Infinity);
});
+57 -18
View File
@@ -37,10 +37,14 @@ const MAX_FOOTPRINT_POINTS = 400;
* @returns {string} Display label, never empty.
*/
export function humanizeInstallationClass(klass) {
const key = String(klass || '').trim().toLowerCase();
const key = String(klass || '')
.trim()
.toLowerCase();
if (LABEL_BY_CLASS[key]) return LABEL_BY_CLASS[key];
const words = key.replaceAll('_', ' ').trim();
return words ? `${words[0].toUpperCase()}${words.slice(1)}` : 'Mapped installation';
return words
? `${words[0].toUpperCase()}${words.slice(1)}`
: 'Mapped installation';
}
function finiteLatitude(value) {
@@ -54,7 +58,8 @@ function finiteLongitude(value) {
function pointFrom(element) {
const lat = Number(element?.lat ?? element?.center?.lat);
const longitude = Number(element?.lon ?? element?.center?.lon);
if (finiteLatitude(lat) && finiteLongitude(longitude)) return { latitude: lat, longitude };
if (finiteLatitude(lat) && finiteLongitude(longitude))
return { latitude: lat, longitude };
// Bounds midpoint fallback (field test 2026-08-28, Warendorf: nothing drawn).
// The proxy asks for `out center tags geom`, but Overpass takes the LAST
@@ -67,8 +72,12 @@ function pointFrom(element) {
// recovers all of them without touching the query or the footprint path.
const bounds = element?.bounds;
// JSON nulls, booleans, and empty strings must not coerce to a false 0,0 site.
if (![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon]
.every((value) => typeof value === 'number' && Number.isFinite(value))) return null;
if (
![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon].every(
(value) => typeof value === 'number' && Number.isFinite(value),
)
)
return null;
const south = Number(bounds?.minlat);
const west = Number(bounds?.minlon);
const north = Number(bounds?.maxlat);
@@ -79,17 +88,28 @@ function pointFrom(element) {
// A single installation footprint is never wider than the request bbox cap
// (10°): an ascending box that spans the antimeridian (minlon -179, maxlon
// 179) passes the ordering check yet midpoints to longitude 0.
if (finiteLatitude(south) && finiteLatitude(north)
&& finiteLongitude(west) && finiteLongitude(east)
&& south <= north && west <= east
&& north - south <= 10 && east - west <= 10) {
if (
finiteLatitude(south) &&
finiteLatitude(north) &&
finiteLongitude(west) &&
finiteLongitude(east) &&
south <= north &&
west <= east &&
north - south <= 10 &&
east - west <= 10
) {
return { latitude: (south + north) / 2, longitude: (west + east) / 2 };
}
return null;
}
function footprintFrom(element) {
if (!Array.isArray(element?.geometry) || element.geometry.length < 3 || element.geometry.length > MAX_FOOTPRINT_POINTS) return null;
if (
!Array.isArray(element?.geometry) ||
element.geometry.length < 3 ||
element.geometry.length > MAX_FOOTPRINT_POINTS
)
return null;
const points = [];
for (const point of element.geometry) {
const latitude = Number(point?.lat);
@@ -106,21 +126,30 @@ function footprintFrom(element) {
* @param {string} [retrievedAt]
* @returns {{records: Array, droppedCount: number}}
*/
export function normalizeMilitaryInstallations(payload, retrievedAt = new Date().toISOString()) {
export function normalizeMilitaryInstallations(
payload,
retrievedAt = new Date().toISOString(),
) {
const records = [];
const ids = new Set();
let droppedCount = 0;
for (const element of Array.isArray(payload?.elements) ? payload.elements : []) {
for (const element of Array.isArray(payload?.elements)
? payload.elements
: []) {
const type = String(element?.type || '');
const osmId = Number(element?.id);
if (!['node', 'way', 'relation'].includes(type) || !Number.isSafeInteger(osmId)) {
if (
!['node', 'way', 'relation'].includes(type) ||
!Number.isSafeInteger(osmId)
) {
droppedCount += 1;
continue;
}
const id = `osm:${type}:${osmId}`;
const tags = element?.tags || {};
const klass = CLASS_BY_MILITARY_TAG[String(tags.military || '').toLowerCase()]
|| (tags.landuse === 'military' ? 'military_land' : null);
const klass =
CLASS_BY_MILITARY_TAG[String(tags.military || '').toLowerCase()] ||
(tags.landuse === 'military' ? 'military_land' : null);
const point = pointFrom(element);
if (!klass || !point || ids.has(id)) {
droppedCount += 1;
@@ -134,7 +163,9 @@ export function normalizeMilitaryInstallations(payload, retrievedAt = new Date()
// geometry, while a footprint-less way/relation has unknown extent.
osmType: type,
class: klass,
name: String(tags.name || tags['name:en'] || '').trim() || humanizeInstallationClass(klass),
name:
String(tags.name || tags['name:en'] || '').trim() ||
humanizeInstallationClass(klass),
...point,
footprint: footprintFrom(element),
sources: [{ name: 'OpenStreetMap', id: `${type}/${osmId}`, retrievedAt }],
@@ -152,6 +183,14 @@ export function isValidInstallationBoundingBox(value) {
const west = Number(box.west);
const north = Number(box.north);
const east = Number(box.east);
return finiteLatitude(south) && finiteLatitude(north) && finiteLongitude(west) && finiteLongitude(east)
&& south < north && west < east && north - south <= 10 && east - west <= 10;
return (
finiteLatitude(south) &&
finiteLatitude(north) &&
finiteLongitude(west) &&
finiteLongitude(east) &&
south < north &&
west < east &&
north - south <= 10 &&
east - west <= 10
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationInstallations } from '../app/layers/militaryInstallations.js';
import { createSourceSlot } from '../app/sourceSlot.js';
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createInstallationSource } from '../layers/installations/index.js';
const sourceSlot = createSourceSlot(
+33 -7
View File
@@ -77,7 +77,9 @@ export const MODEL_TRAIL_ANCHOR_NATIVE = Object.freeze({
* trail anchor has to ride the same chain or it lands on a different axis than
* the aircraft it is supposed to be attached to. */
const AXIS_CORRECTION = Cesium.Matrix4.multiplyTransformation(
Cesium.Axis.Y_UP_TO_Z_UP, Cesium.Axis.Z_UP_TO_X_UP, new Cesium.Matrix4(),
Cesium.Axis.Y_UP_TO_Z_UP,
Cesium.Axis.Z_UP_TO_X_UP,
new Cesium.Matrix4(),
);
const _anchorScratch = new Cesium.Cartesian3();
const _chainScratch = new Cesium.Matrix4();
@@ -124,7 +126,11 @@ export function modelAnchorWorld(model, nativeAnchor, result) {
components || Cesium.Matrix4.IDENTITY,
_chainScratch,
);
Cesium.Matrix4.multiplyTransformation(_chainScratch, AXIS_CORRECTION, _chainScratch);
Cesium.Matrix4.multiplyTransformation(
_chainScratch,
AXIS_CORRECTION,
_chainScratch,
);
return Cesium.Matrix4.multiplyByPoint(_chainScratch, _anchorScratch, result);
}
@@ -220,9 +226,17 @@ export function trailHeadStart(start, anchor, center, radiusM, result) {
// a real trail.
if (!anchor || !center || !result) return start;
if (!Number.isFinite(radiusM) || radiusM <= 0) return start;
const startD = Math.hypot(start.x - center.x, start.y - center.y, start.z - center.z);
const startD = Math.hypot(
start.x - center.x,
start.y - center.y,
start.z - center.z,
);
if (startD >= radiusM) return start;
const anchorD = Math.hypot(anchor.x - center.x, anchor.y - center.y, anchor.z - center.z);
const anchorD = Math.hypot(
anchor.x - center.x,
anchor.y - center.y,
anchor.z - center.z,
);
// A degenerate shell (an anchor at or outside the envelope — no shipped asset
// has one, since every anchor is a measured hull point) leaves nothing to
// ramp across; fall back to the containment verdict rather than divide by it.
@@ -252,8 +266,20 @@ export function modelVisualAnchor(modelMatrix, nativeCenter, scale, result) {
const x = (nativeCenter?.[0] || 0) * safeScale;
const y = (nativeCenter?.[1] || 0) * safeScale;
const z = (nativeCenter?.[2] || 0) * safeScale;
result.x = modelMatrix[0] * x + modelMatrix[4] * y + modelMatrix[8] * z + modelMatrix[12];
result.y = modelMatrix[1] * x + modelMatrix[5] * y + modelMatrix[9] * z + modelMatrix[13];
result.z = modelMatrix[2] * x + modelMatrix[6] * y + modelMatrix[10] * z + modelMatrix[14];
result.x =
modelMatrix[0] * x +
modelMatrix[4] * y +
modelMatrix[8] * z +
modelMatrix[12];
result.y =
modelMatrix[1] * x +
modelMatrix[5] * y +
modelMatrix[9] * z +
modelMatrix[13];
result.z =
modelMatrix[2] * x +
modelMatrix[6] * y +
modelMatrix[10] * z +
modelMatrix[14];
return result;
}
+102 -27
View File
@@ -29,10 +29,14 @@ export function norm360(deg) {
export function liftRepeatedGroundFix(newest, candidatePosition, onGround) {
if (!onGround || !newest?.position || !candidatePosition) return false;
const stored = Cesium.Cartographic.fromCartesian(
newest.position, Cesium.Ellipsoid.WGS84, _mmCarto,
newest.position,
Cesium.Ellipsoid.WGS84,
_mmCarto,
);
const candidate = Cesium.Cartographic.fromCartesian(
candidatePosition, Cesium.Ellipsoid.WGS84, _mmCandidateCarto,
candidatePosition,
Cesium.Ellipsoid.WGS84,
_mmCandidateCarto,
);
if (!stored || !candidate || candidate.height <= stored.height) return false;
Cesium.Cartesian3.fromRadians(
@@ -62,7 +66,11 @@ export function courseBetweenCartesians(from, to, minChordM = 25) {
const dy = to.y - from.y;
const dz = to.z - from.z;
if (dx * dx + dy * dy + dz * dz < minChordM * minChordM) return null;
const carto = Cesium.Cartographic.fromCartesian(from, Cesium.Ellipsoid.WGS84, _mmCarto);
const carto = Cesium.Cartographic.fromCartesian(
from,
Cesium.Ellipsoid.WGS84,
_mmCarto,
);
if (!carto) return null;
const sLat = Math.sin(carto.latitude);
const cLat = Math.cos(carto.latitude);
@@ -110,7 +118,10 @@ export function speedRamp(speedMps) {
if (!Number.isFinite(speedMps)) return 1;
if (speedMps <= COURSE_TRACK_ONLY_MPS) return 0;
if (speedMps >= COURSE_CHORD_ONLY_MPS) return 1;
return (speedMps - COURSE_TRACK_ONLY_MPS) / (COURSE_CHORD_ONLY_MPS - COURSE_TRACK_ONLY_MPS);
return (
(speedMps - COURSE_TRACK_ONLY_MPS) /
(COURSE_CHORD_ONLY_MPS - COURSE_TRACK_ONLY_MPS)
);
}
/** Shortest-arc angle lerp: from → to by t (t=0 → from, t=1 → to). Also the
@@ -141,10 +152,16 @@ export function displayedKinematics({
reportedTrackDeg,
} = {}) {
return {
speedMps: Number.isFinite(derivedSpeedMps) ? Math.max(0, derivedSpeedMps)
: (Number.isFinite(reportedSpeedMps) ? Math.max(0, reportedSpeedMps) : null),
trackDeg: Number.isFinite(derivedTrackDeg) ? derivedTrackDeg
: (Number.isFinite(reportedTrackDeg) ? reportedTrackDeg : null),
speedMps: Number.isFinite(derivedSpeedMps)
? Math.max(0, derivedSpeedMps)
: Number.isFinite(reportedSpeedMps)
? Math.max(0, reportedSpeedMps)
: null,
trackDeg: Number.isFinite(derivedTrackDeg)
? derivedTrackDeg
: Number.isFinite(reportedTrackDeg)
? reportedTrackDeg
: null,
};
}
@@ -167,10 +184,17 @@ export function staleCoastLimitSeconds({
maximumSec = 300,
} = {}) {
const floor = Math.max(0, Number.isFinite(minimumSec) ? minimumSec : 60);
const ceiling = Math.max(floor, Number.isFinite(maximumSec) ? maximumSec : 300);
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(lastContactEpochMs)) return floor;
const ceiling = Math.max(
floor,
Number.isFinite(maximumSec) ? maximumSec : 300,
);
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(lastContactEpochMs))
return floor;
const contactLeadSec = Math.max(0, (lastContactEpochMs - fixEpochMs) / 1000);
const grace = Math.max(0, Number.isFinite(contactGraceSec) ? contactGraceSec : 60);
const grace = Math.max(
0,
Number.isFinite(contactGraceSec) ? contactGraceSec : 60,
);
return Math.min(ceiling, Math.max(floor, contactLeadSec + grace));
}
@@ -197,18 +221,31 @@ export function limitCourseStep(prevDeg, targetDeg, maxDegPerSec, dtSec) {
* is GPS-vector noise, not a turn (samples without a speed keep the legacy
* behavior: no way to tell, so they still count).
*/
export function estimateTurnRateDps(samples, noiseFloorDps = 0.4, maxDps = 4, minSpeedMps = 0) {
export function estimateTurnRateDps(
samples,
noiseFloorDps = 0.4,
maxDps = 4,
minSpeedMps = 0,
) {
if (!samples || samples.length < 2) return 0;
let sum = 0;
let n = 0;
for (let i = 1; i < samples.length; i++) {
const dt = samples[i].tSec - samples[i - 1].tSec;
if (dt < 2 || dt > 120) continue;
if (!Number.isFinite(samples[i].trackDeg) || !Number.isFinite(samples[i - 1].trackDeg)) continue;
if (
!Number.isFinite(samples[i].trackDeg) ||
!Number.isFinite(samples[i - 1].trackDeg)
)
continue;
if (minSpeedMps > 0) {
const v0 = samples[i - 1].speedMps;
const v1 = samples[i].speedMps;
if ((Number.isFinite(v0) && v0 < minSpeedMps) || (Number.isFinite(v1) && v1 < minSpeedMps)) continue;
if (
(Number.isFinite(v0) && v0 < minSpeedMps) ||
(Number.isFinite(v1) && v1 < minSpeedMps)
)
continue;
}
sum += norm180(samples[i].trackDeg - samples[i - 1].trackDeg) / dt;
n += 1;
@@ -274,13 +311,31 @@ const _scratchProjectArc = { east: 0, north: 0, endCourseDeg: 0 };
* @param {number} dtSec - Seconds ahead; 0 or negative returns the start.
* @returns {{lat: number, lon: number}} Projected coordinate, degrees.
*/
export function projectGroundArcLatLon(lat, lon, courseDeg, speedMps, turnRateDps, dtSec) {
export function projectGroundArcLatLon(
lat,
lon,
courseDeg,
speedMps,
turnRateDps,
dtSec,
) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return { lat, lon };
if (!Number.isFinite(courseDeg) || !Number.isFinite(speedMps) || !Number.isFinite(dtSec)
|| speedMps <= 0 || dtSec <= 0) {
if (
!Number.isFinite(courseDeg) ||
!Number.isFinite(speedMps) ||
!Number.isFinite(dtSec) ||
speedMps <= 0 ||
dtSec <= 0
) {
return { lat, lon };
}
const off = arcOffsetEnu(speedMps, courseDeg, turnRateDps || 0, dtSec, _scratchProjectArc);
const off = arcOffsetEnu(
speedMps,
courseDeg,
turnRateDps || 0,
dtSec,
_scratchProjectArc,
);
// Guard the pole: cos(lat) → 0 would send the longitude to infinity.
const cosLat = Math.max(Math.cos(lat * DEG), 1e-6);
return {
@@ -320,8 +375,15 @@ export function projectGroundArcLatLon(lat, lon, courseDeg, speedMps, turnRateDp
* @returns {Array<{lat: number, lon: number}>} Polyline, display position first.
*/
export function corridorPathLatLon({
extrapolating, displayLat, displayLon, courseDeg, speedMps, turnRateDps = 0,
fixLat, fixLon, lookaheadSec,
extrapolating,
displayLat,
displayLon,
courseDeg,
speedMps,
turnRateDps = 0,
fixLat,
fixLon,
lookaheadSec,
}) {
if (!Number.isFinite(displayLat) || !Number.isFinite(displayLon)) return [];
const start = { lat: displayLat, lon: displayLon };
@@ -338,9 +400,16 @@ export function corridorPathLatLon({
const n = Math.max(1, Math.ceil(arcLengthM / CORRIDOR_SAMPLE_SPACING_M));
const out = [start];
for (let i = 1; i <= n; i++) {
out.push(projectGroundArcLatLon(
displayLat, displayLon, courseDeg, speed, turnRateDps, cappedSec * (i / n),
));
out.push(
projectGroundArcLatLon(
displayLat,
displayLon,
courseDeg,
speed,
turnRateDps,
cappedSec * (i / n),
),
);
}
return out;
}
@@ -385,13 +454,17 @@ const _forwardFixOffset = new Cesium.Cartesian3();
* velocity: number, track: number}|null} Forward synthetic fix, or null.
*/
export function synthesizeForwardKinematicsFix(newest, next = {}) {
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs)) return null;
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs))
return null;
const startEpochMs = Number.isFinite(newest.epochMs)
? newest.epochMs
: Cesium.JulianDate.toDate(newest.time).getTime();
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs) return null;
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs)
return null;
const previousVelocity = Number.isFinite(newest.velocity) ? Math.max(0, newest.velocity) : 0;
const previousVelocity = Number.isFinite(newest.velocity)
? Math.max(0, newest.velocity)
: 0;
const previousTrack = Number.isFinite(newest.track) ? newest.track : 0;
const dtSec = (next.epochMs - startEpochMs) / 1000;
arcOffsetEnu(
@@ -421,7 +494,9 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
time: Cesium.JulianDate.fromDate(new Date(next.epochMs)),
epochMs: next.epochMs,
position,
velocity: Number.isFinite(next.velocity) ? Math.max(0, next.velocity) : previousVelocity,
velocity: Number.isFinite(next.velocity)
? Math.max(0, next.velocity)
: previousVelocity,
track: Number.isFinite(next.track) ? next.track : previousTrack,
};
}
+3 -2
View File
@@ -32,7 +32,8 @@ export function resolvePickId(picked) {
if (id === null || id === undefined) return undefined;
if (typeof id === 'object') {
// AIS vessel record (id object with .mmsi) or Cesium Entity (.id string)
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number') return id.mmsi;
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number')
return id.mmsi;
if (typeof id.id === 'string' || typeof id.id === 'number') return id.id;
return undefined;
}
@@ -40,7 +41,7 @@ export function resolvePickId(picked) {
};
let id = unwrap(picked.id);
if (id === undefined) id = unwrap(picked.primitive?.id);
return (typeof id === 'string' || typeof id === 'number') ? String(id) : null;
return typeof id === 'string' || typeof id === 'number' ? String(id) : null;
}
/**
+1 -1
View File
@@ -1,6 +1,6 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationRadio } from '../app/layers/radio.js';
import { createSourceSlot } from '../app/sourceSlot.js';
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createRadioSource } from '../layers/radio/source.js';
const sourceSlot = createSourceSlot(
+22 -13
View File
@@ -1,6 +1,7 @@
const RADIO_COUNTRY_MAX_LENGTH = 80;
const ISO_ALPHA_2_CODES = Object.freeze(`
const ISO_ALPHA_2_CODES = Object.freeze(
`
AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR
GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO
@@ -8,12 +9,16 @@ JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME
MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO
RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV
TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW
`.trim().split(/\s+/));
`
.trim()
.split(/\s+/),
);
const ISO_ALPHA_2_SET = new Set(ISO_ALPHA_2_CODES);
const ENGLISH_REGION_NAMES = typeof Intl?.DisplayNames === 'function'
? new Intl.DisplayNames(['en'], { type: 'region' })
: null;
const ENGLISH_REGION_NAMES =
typeof Intl?.DisplayNames === 'function'
? new Intl.DisplayNames(['en'], { type: 'region' })
: null;
function countryKey(value) {
return String(value || '')
@@ -29,7 +34,8 @@ function countryKey(value) {
const COUNTRY_NAME_TO_CODE = new Map();
for (const code of ISO_ALPHA_2_CODES) {
const displayName = ENGLISH_REGION_NAMES?.of(code);
if (displayName && displayName !== code) COUNTRY_NAME_TO_CODE.set(countryKey(displayName), code);
if (displayName && displayName !== code)
COUNTRY_NAME_TO_CODE.set(countryKey(displayName), code);
}
for (const [name, code] of Object.entries({
@@ -71,7 +77,8 @@ for (const [name, code] of Object.entries({
'east timor': 'TL',
'cabo verde': 'CV',
vatican: 'VA',
})) COUNTRY_NAME_TO_CODE.set(countryKey(name), code);
}))
COUNTRY_NAME_TO_CODE.set(countryKey(name), code);
function canonicalCountryName(code) {
return ENGLISH_REGION_NAMES?.of(code) || code;
@@ -90,17 +97,19 @@ export function normalizeRadioCountryInput(value) {
}
const trimmed = value.normalize('NFKC').trim().replace(/\s+/g, ' ');
if (
!trimmed
|| trimmed.length > RADIO_COUNTRY_MAX_LENGTH
|| /[\u0000-\u001f\u007f]/.test(trimmed)
|| !/^[\p{L}\p{M}.&'’()\-\s]+$/u.test(trimmed)
) return Object.freeze({ valid: false, empty: false, code: '', name: '' });
!trimmed ||
trimmed.length > RADIO_COUNTRY_MAX_LENGTH ||
/[\u0000-\u001f\u007f]/.test(trimmed) ||
!/^[\p{L}\p{M}.&'’()\-\s]+$/u.test(trimmed)
)
return Object.freeze({ valid: false, empty: false, code: '', name: '' });
const possibleCode = trimmed.toUpperCase();
const code = ISO_ALPHA_2_SET.has(possibleCode)
? possibleCode
: COUNTRY_NAME_TO_CODE.get(countryKey(trimmed)) || '';
if (!code) return Object.freeze({ valid: false, empty: false, code: '', name: '' });
if (!code)
return Object.freeze({ valid: false, empty: false, code: '', name: '' });
return Object.freeze({
valid: true,
empty: false,
+2 -1
View File
@@ -2,6 +2,7 @@ import { applicationServices } from '../services/application.js';
export * from './regionalModel.js';
/** Fetch a bounded regional brief through the same-origin dev/preview proxy. */
export async function fetchRegionalBrief(latitude, longitude, { signal } = {}) {
if (![latitude, longitude].every(Number.isFinite)) throw new Error('Valid coordinates are required');
if (![latitude, longitude].every(Number.isFinite))
throw new Error('Valid coordinates are required');
return applicationServices.regional.getBrief(latitude, longitude, { signal });
}
Loaded 100 of 332 files, more files were not shown because too many files have changed in this diff. Show more