mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Merge remote-tracking branch 'origin/main' into fix/realtime-debug-log-hardening
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
4fd168ff3c
332 files changed
+56273
-6359
No files matched your search
+455
-105
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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 || '')
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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([], [])),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+18
-7
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -11,5 +11,7 @@ export function aircraftIncludedInNearby({
|
||||
modelRendering = false,
|
||||
includeHidden = false,
|
||||
} = {}) {
|
||||
return Boolean(includeHidden || isTracked || billboardShown || modelRendering);
|
||||
return Boolean(
|
||||
includeHidden || isTracked || billboardShown || modelRendering,
|
||||
);
|
||||
}
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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]),
|
||||
]);
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);}
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -10,4 +10,3 @@ export const COCKPIT_BRACKET_OPACITY = 0.45;
|
||||
export function detectionBracketOpacity(cockpitActive) {
|
||||
return cockpitActive === true ? COCKPIT_BRACKET_OPACITY : 1;
|
||||
}
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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()],
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -15,9 +15,4 @@ const fires = createFirmsHeatmapLayer({
|
||||
source: 'NASA FIRMS · LIVE',
|
||||
});
|
||||
|
||||
export default [
|
||||
datacenters,
|
||||
dams,
|
||||
submarineCablesLayer,
|
||||
fires,
|
||||
];
|
||||
export default [datacenters, dams, submarineCablesLayer, fires];
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,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
Reference in new issue
Block a user