style: format remaining owned runtime modules

This commit is contained in:
Sameh Khamis committed 2026-09-15 07:26:32 -07:00
1 parent ae99843c14
commit 2aca7aecbb
94 files changed
+8865 -3151

No files matched your search

+455 -105
View File
@@ -1,12 +1,20 @@
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js';
import { isRateLimitedOutcome, resolveAnnotationTarget, sampleGroundHeight } from './annotationResolver.js';
import {
holdContinuousRender,
releaseContinuousRender,
} from '../renderGovernor.js';
import {
isRateLimitedOutcome,
resolveAnnotationTarget,
sampleGroundHeight,
} from './annotationResolver.js';
import { ringCentroid } from './drawMode.js';
// Dev convenience: expose the app's Cesium instance for console/preview probing
// (single shared module instance — avoids dual-Cesium state bugs when testing).
if (typeof window !== 'undefined' && !window.__CESIUM__) window.__CESIUM__ = Cesium;
if (typeof window !== 'undefined' && !window.__CESIUM__)
window.__CESIUM__ = Cesium;
/**
* Annotation engine — the voice agent's "whiteboard" over the 3D world.
@@ -25,7 +33,13 @@ const COLORS = new Set(['primary', 'amber', 'cyan', 'green', 'red']);
// Entity FACTS the voice model may attach to an annotation (annotate_map.entityKind) —
// what kind of thing the target IS, routing the resolver (e.g. point_feature keeps a
// monument point-first). Unknown values are dropped, never guessed.
const ENTITY_KINDS = new Set(['building', 'compound', 'district', 'street', 'point_feature']);
const ENTITY_KINDS = new Set([
'building',
'compound',
'district',
'street',
'point_feature',
]);
const DEFAULT_TTL_MS = 22_000;
const FADE_MS = 1200;
// Hard ceiling on simultaneously-live marks. Protects against a runaway voice
@@ -51,11 +65,14 @@ const OUTLINE_UPGRADE_CONCURRENCY = 2;
* retrying immediately. A thrown resolver counts as definitive — the pre-retry
* catch→point behavior. Exported for tests.
*/
export async function resolveOutlineWithRetry(resolveOutline, {
delaysMs = OUTLINE_RETRY_DELAYS_MS,
isStale = () => false,
waitFn = wait,
} = {}) {
export async function resolveOutlineWithRetry(
resolveOutline,
{
delaysMs = OUTLINE_RETRY_DELAYS_MS,
isStale = () => false,
waitFn = wait,
} = {},
) {
let retriedRateLimit = false;
for (let attempt = 0; ; attempt += 1) {
let fp;
@@ -68,7 +85,9 @@ export async function resolveOutlineWithRetry(resolveOutline, {
// A throttle gets one deliberately spaced retry. If that retry is throttled
// too, stop this mark's outline task instead of replaying the batch storm.
if (retriedRateLimit || attempt >= delaysMs.length) return undefined;
const retryAfterMs = Number.isFinite(fp.retryAfterMs) ? fp.retryAfterMs : 0;
const retryAfterMs = Number.isFinite(fp.retryAfterMs)
? fp.retryAfterMs
: 0;
await waitFn(Math.max(retryAfterMs, delaysMs[attempt]));
if (isStale()) return undefined;
retriedRateLimit = true;
@@ -89,7 +108,9 @@ export async function resolveOutlineWithRetry(resolveOutline, {
* geometry is unknown. Exported for tests.
*/
export function normalizeTargetKey(target) {
const raw = String(target ?? '').trim().toLowerCase();
const raw = String(target ?? '')
.trim()
.toLowerCase();
if (!raw) return null;
const head = raw.split(',')[0].trim();
return head || raw;
@@ -123,12 +144,22 @@ export function createAnnotationEngine({
function emitOutlineEvent(evt) {
for (const listener of outlineListeners) {
try { listener(evt); } catch { /* a listener error must never break the upgrade */ }
try {
listener(evt);
} catch {
/* a listener error must never break the upgrade */
}
}
}
function abortPending() {
for (const c of activeControllers) { try { c.abort(); } catch { /* no-op */ } }
for (const c of activeControllers) {
try {
c.abort();
} catch {
/* no-op */
}
}
activeControllers.clear();
}
@@ -174,7 +205,9 @@ export function createAnnotationEngine({
function rollbackRendererState(anno) {
try {
renderer.remove(anno);
} catch { /* the renderer is already in a bad way; the original error wins */ }
} catch {
/* the renderer is already in a bad way; the original error wins */
}
}
function ensureTicking() {
@@ -226,7 +259,15 @@ export function createAnnotationEngine({
* @returns {Promise<{ok, drawn, failed, ids, results}>}
*/
async function annotate(requests, opts = {}) {
if (destroyed) return { ok: false, drawn: 0, failed: 0, ids: [], results: [], error: 'destroyed' };
if (destroyed)
return {
ok: false,
drawn: 0,
failed: 0,
ids: [],
results: [],
error: 'destroyed',
};
const list = Array.isArray(requests) ? requests : [requests];
if (opts.clearPrevious) clear(); // bumps generation + aborts older pending work
@@ -247,7 +288,11 @@ export function createAnnotationEngine({
// allSettled gives per-item error isolation (one failed item never aborts the batch); the
// mutation pass below then runs in ORDER, so de-dup, the synchronous live-cap check, and output
// order are all preserved exactly as the old serial loop had them.
const settled = await Promise.allSettled(list.map((spec) => resolveSpec(spec, controller.signal, opts.flyTo === true)));
const settled = await Promise.allSettled(
list.map((spec) =>
resolveSpec(spec, controller.signal, opts.flyTo === true),
),
);
try {
for (let i = 0; i < list.length; i += 1) {
@@ -270,8 +315,15 @@ export function createAnnotationEngine({
// stacked. De-dup runs BEFORE the cap so a re-narration still refreshes when full.
const dup = findDuplicate(anno);
if (dup) {
const labelChanged = String(dup.label || '').trim().toLowerCase() !== String(anno.label || '').trim().toLowerCase();
const colorChanged = (dup.color || 'primary') !== (anno.color || 'primary');
const labelChanged =
String(dup.label || '')
.trim()
.toLowerCase() !==
String(anno.label || '')
.trim()
.toLowerCase();
const colorChanged =
(dup.color || 'primary') !== (anno.color || 'primary');
if (labelChanged || colorChanged) {
// Same place + shape, but a NEW label and/or color ("Marina" → "Marina District",
// or a recolor) → replace in place so the latest caption/color wins (count
@@ -305,13 +357,26 @@ export function createAnnotationEngine({
ids.push(anno.id);
if (!firstAnchor) firstAnchor = anno;
if (typeof resolved.resolveOutline === 'function') {
startOutlineUpgrade(anno, resolved.resolveOutline, myGen, controller);
startOutlineUpgrade(
anno,
resolved.resolveOutline,
myGen,
controller,
);
}
results.push({ ...okResult(anno, resolved, anno.id), duplicate: true });
results.push({
...okResult(anno, resolved, anno.id),
duplicate: true,
});
} else {
// Identical re-narration → refresh the existing mark's lifecycle in place (keep
// it visible whether persistent or TTL-based, and revive a fading-out one).
if (persist) { dup.ttlMs = null; } else { dup.createdAt = performance.now(); dup.ttlMs = anno.ttlMs; }
if (persist) {
dup.ttlMs = null;
} else {
dup.createdAt = performance.now();
dup.ttlMs = anno.ttlMs;
}
dup.expiring = false;
dup.fadeStart = null;
ids.push(dup.id);
@@ -319,11 +384,19 @@ export function createAnnotationEngine({
// A re-narration is also a RETRY: if the existing mark never got its outline
// (transient Overpass miss), attach this call's deferred resolver to it.
if (!dup.ring && typeof resolved.resolveOutline === 'function') {
startOutlineUpgrade(dup, resolved.resolveOutline, myGen, controller);
startOutlineUpgrade(
dup,
resolved.resolveOutline,
myGen,
controller,
);
}
// Same result builder as the fresh path (full metadata) but the existing id —
// route fallback / approximate honesty fields survive a re-narration.
results.push({ ...okResult(anno, resolved, dup.id), duplicate: true });
results.push({
...okResult(anno, resolved, dup.id),
duplicate: true,
});
}
continue;
}
@@ -350,12 +423,23 @@ export function createAnnotationEngine({
if (!firstAnchor) firstAnchor = anno;
// Progressive: the point is on the board; trace the outline behind the narration.
if (typeof resolved.resolveOutline === 'function') {
startOutlineUpgrade(anno, resolved.resolveOutline, myGen, controller);
startOutlineUpgrade(
anno,
resolved.resolveOutline,
myGen,
controller,
);
}
results.push(okResult(anno, resolved, anno.id));
} catch (error) {
if (superseded()) break;
results.push(failResult(spec, error?.message || 'annotation failed', Array.isArray(error?.failedTargets) ? error.failedTargets : null));
results.push(
failResult(
spec,
error?.message || 'annotation failed',
Array.isArray(error?.failedTargets) ? error.failedTargets : null,
),
);
}
}
} finally {
@@ -365,7 +449,14 @@ export function createAnnotationEngine({
// A clear/new-topic landed mid-flight: drop everything this call produced and
// do not touch the renderer or camera — the board now belongs to a newer call.
if (superseded()) {
return { ok: false, drawn: 0, failed: results.length, ids: [], results, aborted: true };
return {
ok: false,
drawn: 0,
failed: results.length,
ids: [],
results,
aborted: true,
};
}
if (ids.length) {
@@ -393,7 +484,8 @@ export function createAnnotationEngine({
if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);
if (type === 'route') {
const points = Array.isArray(spec.points) ? spec.points : [];
if (points.length < 2) throw new Error('a route needs at least 2 waypoints');
if (points.length < 2)
throw new Error('a route needs at least 2 waypoints');
const resolvedPts = [];
const failed = [];
for (const pt of points) {
@@ -425,7 +517,12 @@ export function createAnnotationEngine({
}
// Real street-following route (OSM/OSRM), mode-aware.
const mode = normalizeMode(spec.mode);
const routed = await fetchRoute(resolvedPts.map((p) => [p.lon, p.lat]), mode, signal, placeSearch);
const routed = await fetchRoute(
resolvedPts.map((p) => [p.lon, p.lat]),
mode,
signal,
placeSearch,
);
if (routed) {
return {
path: routed.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
@@ -439,8 +536,16 @@ export function createAnnotationEngine({
// Routing unavailable: do NOT pass off straight segments as a real route. Draw
// a clearly-labeled direct line with great-circle distance and NO travel time.
let straight = 0;
for (let i = 1; i < resolvedPts.length; i += 1) straight += greatCircleM(resolvedPts[i - 1], resolvedPts[i]);
return { path: resolvedPts, distanceM: straight, durationS: null, mode, source: resolvedPts[0].source, fallback: true };
for (let i = 1; i < resolvedPts.length; i += 1)
straight += greatCircleM(resolvedPts[i - 1], resolvedPts[i]);
return {
path: resolvedPts,
distanceM: straight,
durationS: null,
mode,
source: resolvedPts[0].source,
fallback: true,
};
}
if (type === 'arrow') {
const from = await resolveTarget({
@@ -479,9 +584,15 @@ export function createAnnotationEngine({
err.failedTargets = failed;
throw err;
}
return { from, to, distanceM: greatCircleM(from, to), source: from.source };
return {
from,
to,
distanceM: greatCircleM(from, to),
source: from.source,
};
}
const wantFootprint = type === 'area' ? spec.footprint !== false : Boolean(spec.footprint);
const wantFootprint =
type === 'area' ? spec.footprint !== false : Boolean(spec.footprint);
return resolveTarget({
placeSearch,
viewer,
@@ -491,7 +602,8 @@ export function createAnnotationEngine({
screenX: spec.screenX,
screenY: spec.screenY,
footprint: wantFootprint,
intent: spec.intent === 'around_the_thing' ? 'around_the_thing' : 'the_thing',
intent:
spec.intent === 'around_the_thing' ? 'around_the_thing' : 'the_thing',
entityKind: ENTITY_KINDS.has(spec?.entityKind) ? spec.entityKind : null,
// The label often carries the ask's true shape when the target omits it
// ("target: Texas State Capitol" + "label: Capitol grounds") — a resolver HINT only.
@@ -525,11 +637,15 @@ export function createAnnotationEngine({
}
function drainOutlineQueue() {
while (activeOutlineUpgrades < OUTLINE_UPGRADE_CONCURRENCY && outlineQueue.length) {
while (
activeOutlineUpgrades < OUTLINE_UPGRADE_CONCURRENCY &&
outlineQueue.length
) {
const task = outlineQueue.shift();
const stale = task.myGen !== generation
|| task.controller.signal.aborted
|| !annotations.has(task.anno.id);
const stale =
task.myGen !== generation ||
task.controller.signal.aborted ||
!annotations.has(task.anno.id);
if (stale) {
if (task.anno._outlineTask === task) task.anno._outlineTask = null;
releaseController(task.controller);
@@ -549,7 +665,10 @@ export function createAnnotationEngine({
// proxy caches the late completion, so the retry is usually a warm cache hit.
fp = await resolveOutlineWithRetry(resolveOutline, {
delaysMs: outlineRetryDelaysMs,
isStale: () => myGen !== generation || controller.signal.aborted || !annotations.has(anno.id),
isStale: () =>
myGen !== generation ||
controller.signal.aborted ||
!annotations.has(anno.id),
});
if (myGen !== generation || controller.signal.aborted) return; // board superseded
if (!annotations.has(anno.id)) return; // mark replaced/removed while resolving
@@ -577,11 +696,22 @@ export function createAnnotationEngine({
// wins, exactly like the add-time replace path). Not an auto-clear — it removes
// a second representation of the same mark, never a distinct one.
for (const other of annotations.values()) {
if (other.id === anno.id || other.type !== 'area' || other.pendingOutline) continue;
if (!other.anchor || Math.abs(other.anchor.lon - anno.anchor.lon) >= 5e-4
|| Math.abs(other.anchor.lat - anno.anchor.lat) >= 5e-4) continue;
if (Boolean(other.synthesized) !== Boolean(anno.synthesized)) continue;
if ((other.footprintKind || null) !== (anno.footprintKind || null)) continue;
if (
other.id === anno.id ||
other.type !== 'area' ||
other.pendingOutline
)
continue;
if (
!other.anchor ||
Math.abs(other.anchor.lon - anno.anchor.lon) >= 5e-4 ||
Math.abs(other.anchor.lat - anno.anchor.lat) >= 5e-4
)
continue;
if (Boolean(other.synthesized) !== Boolean(anno.synthesized))
continue;
if ((other.footprintKind || null) !== (anno.footprintKind || null))
continue;
if (!ringsEqual(other.ring, anno.ring)) continue;
annotations.delete(other.id);
renderer.remove(other);
@@ -629,7 +759,12 @@ export function createAnnotationEngine({
// Route-specific signal so the voice layer can be honest about an OSRM outage:
// fallback=true means a straight direct line, not a real route.
...(anno.type === 'route'
? { fallback: Boolean(anno.fallback), mode: anno.mode, distanceM: anno.distanceM, durationS: anno.durationS }
? {
fallback: Boolean(anno.fallback),
mode: anno.mode,
distanceM: anno.distanceM,
durationS: anno.durationS,
}
: {}),
};
}
@@ -643,13 +778,18 @@ export function createAnnotationEngine({
* degenerate geocode stacks on one point (several Capitol monuments) would erase each other. */
function findDuplicate(anno) {
if (!anno.anchor) return null;
const near = (a, b) => a && b && Math.abs(a.lon - b.lon) < 5e-4 && Math.abs(a.lat - b.lat) < 5e-4;
const near = (a, b) =>
a &&
b &&
Math.abs(a.lon - b.lon) < 5e-4 &&
Math.abs(a.lat - b.lat) < 5e-4;
// FULL per-vertex geometry comparison (the live cap is only 120 and this runs solely for
// candidates that already matched type+label+anchor, so it's cheap) — so two DIFFERENT
// same-length rings/paths at the same anchor are never falsely merged.
const ringSame = ringsEqual;
const pathSame = (a, b) => {
const la = a ? a.length : 0; const lb = b ? b.length : 0;
const la = a ? a.length : 0;
const lb = b ? b.length : 0;
if (la !== lb) return false;
for (let i = 0; i < la; i++) if (!near(a[i], b[i])) return false;
return true;
@@ -674,10 +814,12 @@ export function createAnnotationEngine({
// the identity; the target is only the stand-in while geometry is unknown.
if (!anno.targetKey || !ex.targetKey) continue;
if (anno.targetKey !== ex.targetKey) continue;
if ((ex.intentKey || 'thing') !== (anno.intentKey || 'thing')) continue;
if ((ex.intentKey || 'thing') !== (anno.intentKey || 'thing'))
continue;
} else {
if (Boolean(ex.synthesized) !== Boolean(anno.synthesized)) continue;
if ((ex.footprintKind || null) !== (anno.footprintKind || null)) continue;
if ((ex.footprintKind || null) !== (anno.footprintKind || null))
continue;
if (!ringSame(ex.ring, anno.ring)) continue;
}
} else if (anno.type === 'arrow') {
@@ -691,7 +833,13 @@ export function createAnnotationEngine({
// point (e.g. several Texas Capitol monuments all geocoding to the dome centroid) — NOT a
// duplicate. Only collapse a genuine re-narration of the SAME point (same label); distinct
// labels stay as separate marks (their callouts de-collide in the renderer).
const sameLabel = String(ex.label || '').trim().toLowerCase() === String(anno.label || '').trim().toLowerCase();
const sameLabel =
String(ex.label || '')
.trim()
.toLowerCase() ===
String(anno.label || '')
.trim()
.toLowerCase();
if (!sameLabel) continue;
}
return ex;
@@ -703,7 +851,11 @@ export function createAnnotationEngine({
const type = normalizeType(spec?.type);
const id = `anno-${++_seq}`;
const color = COLORS.has(spec?.color) ? spec.color : 'primary';
const label = cleanLabel(spec?.label) || resolved?.label || resolved?.from?.label || null;
const label =
cleanLabel(spec?.label) ||
resolved?.label ||
resolved?.from?.label ||
null;
const now = performance.now();
const base = {
@@ -712,17 +864,27 @@ export function createAnnotationEngine({
color,
label,
createdAt: now,
ttlMs: persist ? null : (Number(spec?.ttlMs) || DEFAULT_TTL_MS),
ttlMs: persist ? null : Number(spec?.ttlMs) || DEFAULT_TTL_MS,
alpha: 0, // animate in
bornAt: now,
expiring: false,
};
if (type === 'route') {
const path = resolved.path.map((p) => ({ lon: p.lon, lat: p.lat, height: p.height }));
const path = resolved.path.map((p) => ({
lon: p.lon,
lat: p.lat,
height: p.height,
}));
return {
...base,
label: composeRouteLabel(base.label, resolved.distanceM, resolved.durationS, resolved.mode, resolved.fallback),
label: composeRouteLabel(
base.label,
resolved.distanceM,
resolved.durationS,
resolved.mode,
resolved.fallback,
),
anchor: path[0],
to: null,
path,
@@ -738,8 +900,16 @@ export function createAnnotationEngine({
return {
...base,
label: appendDistance(base.label, resolved.distanceM),
anchor: { lon: resolved.from.lon, lat: resolved.from.lat, height: resolved.from.height },
to: { lon: resolved.to.lon, lat: resolved.to.lat, height: resolved.to.height },
anchor: {
lon: resolved.from.lon,
lat: resolved.from.lat,
height: resolved.from.height,
},
to: {
lon: resolved.to.lon,
lat: resolved.to.lat,
height: resolved.to.height,
},
ring: null,
};
}
@@ -814,13 +984,17 @@ export function createAnnotationEngine({
if (m.to) points.push(m.to);
}
if (!points.length || points.some((p) => isPointOnScreen(p))) return;
console.log(`[Annotations] auto-framing ${marks.length} off-screen mark(s)`);
console.log(
`[Annotations] auto-framing ${marks.length} off-screen mark(s)`,
);
assistFlightUntil = performance.now() + 2600; // ~flight duration + settle
if (marks.length === 1) {
frameAnnotation(marks[0]);
return;
}
const cart = points.map((p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0));
const cart = points.map((p) =>
Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0),
);
const sphere = Cesium.BoundingSphere.fromPoints(cart);
viewer.camera.flyToBoundingSphere(sphere, {
offset: new Cesium.HeadingPitchRange(
@@ -841,9 +1015,20 @@ export function createAnnotationEngine({
try {
const camera = viewer.camera;
const pos = Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0);
const cv = camera.frustum.computeCullingVolume(camera.position, camera.direction, camera.up);
if (cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) === Cesium.Intersect.OUTSIDE) return false;
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, camera.position);
const cv = camera.frustum.computeCullingVolume(
camera.position,
camera.direction,
camera.up,
);
if (
cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) ===
Cesium.Intersect.OUTSIDE
)
return false;
const occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
camera.position,
);
return occluder.isPointVisible(pos);
} catch {
return true;
@@ -853,15 +1038,24 @@ export function createAnnotationEngine({
function frameAnnotation(anno) {
try {
const target = anno.to
? { lon: (anno.anchor.lon + anno.to.lon) / 2, lat: (anno.anchor.lat + anno.to.lat) / 2 }
? {
lon: (anno.anchor.lon + anno.to.lon) / 2,
lat: (anno.anchor.lat + anno.to.lat) / 2,
}
: anno.anchor;
// No ring yet (progressive outline still resolving) → size the flight from the
// Places viewport box when we have one, so a big compound isn't framed at
// building scale while its outline is traced. Never re-fly when the ring lands.
const range = anno.ring ? ringRange(anno.ring) : (viewportRange(anno.viewport) || 600);
const range = anno.ring
? ringRange(anno.ring)
: viewportRange(anno.viewport) || 600;
viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
Cesium.Cartesian3.fromDegrees(target.lon, target.lat, anno.anchor.height || 0),
Cesium.Cartesian3.fromDegrees(
target.lon,
target.lat,
anno.anchor.height || 0,
),
range,
),
{
@@ -884,7 +1078,9 @@ export function createAnnotationEngine({
destroy() {
if (destroyed) return;
destroyed = true;
try { clear(); } finally {
try {
clear();
} finally {
if (tickHandle != null) cancelAnimationFrame(tickHandle);
tickHandle = null;
outlineListeners.clear();
@@ -912,12 +1108,36 @@ export function createAnnotationEngine({
* Lays down a small San Francisco "tour" the way the voice agent would.
*/
async demo() {
return annotate([
{ type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' },
{ type: 'area', target: 'Presidio of San Francisco', label: 'The Presidio (former Army base)', color: 'green', footprint: true },
{ type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'cyan' },
{ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina District, San Francisco', label: 'next to the Marina' },
], { flyTo: true, clearPrevious: true, persist: true });
return annotate(
[
{
type: 'highlight',
target: 'Palace of Fine Arts, San Francisco',
label: 'Palace of Fine Arts',
color: 'amber',
},
{
type: 'area',
target: 'Presidio of San Francisco',
label: 'The Presidio (former Army base)',
color: 'green',
footprint: true,
},
{
type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco',
label: 'ILM / Lucasfilm',
color: 'cyan',
},
{
type: 'arrow',
target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina District, San Francisco',
label: 'next to the Marina',
},
],
{ flyTo: true, clearPrevious: true, persist: true },
);
},
/**
@@ -927,34 +1147,107 @@ export function createAnnotationEngine({
*/
async tour() {
clear();
flyTo({ lon: -122.4486, lat: 37.7960, height: 520, heading: 0, pitch: -26, duration: 3 });
flyTo({
lon: -122.4486,
lat: 37.796,
height: 520,
heading: 0,
pitch: -26,
duration: 3,
});
if (destroyed) return { ok: false };
await wait(3200);
await annotate([{ type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' }], { persist: true });
await annotate(
[
{
type: 'highlight',
target: 'Palace of Fine Arts, San Francisco',
label: 'Palace of Fine Arts',
color: 'amber',
},
],
{ persist: true },
);
if (destroyed) return { ok: false };
await wait(2600);
await annotate([{ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina Green, San Francisco', label: 'next to the Marina', color: 'cyan' }], { persist: true });
await annotate(
[
{
type: 'arrow',
target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina Green, San Francisco',
label: 'next to the Marina',
color: 'cyan',
},
],
{ persist: true },
);
if (destroyed) return { ok: false };
await wait(2600);
flyTo({ lon: -122.4545, lat: 37.7880, height: 1500, heading: 18, pitch: -32, duration: 3 });
flyTo({
lon: -122.4545,
lat: 37.788,
height: 1500,
heading: 18,
pitch: -32,
duration: 3,
});
if (destroyed) return { ok: false };
await wait(3200);
await annotate([{ type: 'area', target: 'Presidio of San Francisco', label: 'The Presidio — a former Army base', color: 'green', footprint: true }], { persist: true });
await annotate(
[
{
type: 'area',
target: 'Presidio of San Francisco',
label: 'The Presidio — a former Army base',
color: 'green',
footprint: true,
},
],
{ persist: true },
);
if (destroyed) return { ok: false };
await wait(2800);
await annotate([{ type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'red' }], { persist: true });
await annotate(
[
{
type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco',
label: 'ILM / Lucasfilm',
color: 'red',
},
],
{ persist: true },
);
if (destroyed) return { ok: false };
await wait(2600);
await annotate([{ type: 'route', color: 'amber', label: 'Crissy Field shoreline', points: [
{ target: 'Palace of Fine Arts, San Francisco' },
{ target: 'Crissy Field, San Francisco' },
{ target: 'Fort Point, San Francisco' },
] }], { persist: true });
await annotate(
[
{
type: 'route',
color: 'amber',
label: 'Crissy Field shoreline',
points: [
{ target: 'Palace of Fine Arts, San Francisco' },
{ target: 'Crissy Field, San Francisco' },
{ target: 'Fort Point, San Francisco' },
],
},
],
{ persist: true },
);
return { ok: true, steps: 6 };
},
};
function flyTo({ lon, lat, height, heading = 0, pitch = -30, duration = 2.5 }) {
function flyTo({
lon,
lat,
height,
heading = 0,
pitch = -30,
duration = 2.5,
}) {
if (destroyed) return;
try {
viewer.camera.flyTo({
@@ -984,7 +1277,11 @@ function ringsEqual(a, b) {
const lb = b ? b.length : 0;
if (la !== lb) return false;
for (let i = 0; i < la; i++) {
if (Math.abs(a[i][0] - b[i][0]) >= 1e-5 || Math.abs(a[i][1] - b[i][1]) >= 1e-5) return false;
if (
Math.abs(a[i][0] - b[i][0]) >= 1e-5 ||
Math.abs(a[i][1] - b[i][1]) >= 1e-5
)
return false;
}
return true;
}
@@ -1010,7 +1307,8 @@ function specTargets(spec) {
* arrow endpoint) when known; otherwise it derives them from the spec.
*/
function failResult(spec, error, failedTargets) {
const targets = (failedTargets && failedTargets.length) ? failedTargets : specTargets(spec);
const targets =
failedTargets && failedTargets.length ? failedTargets : specTargets(spec);
return {
ok: false,
label: spec?.label || spec?.target || targets[0] || null,
@@ -1022,7 +1320,8 @@ function failResult(spec, error, failedTargets) {
function normalizeType(type) {
const t = String(type || '').toLowerCase();
if (t === 'area' || t === 'polygon' || t === 'outline' || t === 'compound') return 'area';
if (t === 'area' || t === 'polygon' || t === 'outline' || t === 'compound')
return 'area';
if (t === 'route' || t === 'path' || t === 'line') return 'route';
if (t === 'arrow' || t === 'vector' || t === 'connector') return 'arrow';
if (t === 'label' || t === 'callout' || t === 'text') return 'label';
@@ -1036,9 +1335,10 @@ function computeAlpha(anno, now) {
if (anno.ttlMs == null && !anno.expiring) return fadeIn;
const fadeStart = anno.fadeStart != null
? anno.fadeStart
: anno.createdAt + (anno.ttlMs || 0);
const fadeStart =
anno.fadeStart != null
? anno.fadeStart
: anno.createdAt + (anno.ttlMs || 0);
if (now < fadeStart) return fadeIn;
const out = 1 - (now - fadeStart) / FADE_MS;
if (out <= 0) anno.expiring = true;
@@ -1052,7 +1352,10 @@ function pendingAnimation(anno, now) {
if (now - anno.bornAt < 260) return true; // fading in
if (anno.expiring) return true; // fading out
if (anno.ttlMs != null) {
const fadeStart = anno.fadeStart != null ? anno.fadeStart : anno.createdAt + (anno.ttlMs || 0);
const fadeStart =
anno.fadeStart != null
? anno.fadeStart
: anno.createdAt + (anno.ttlMs || 0);
return now < fadeStart + FADE_MS; // TTL fade still ahead or in progress
}
return false; // persistent + faded in → stable
@@ -1110,16 +1413,30 @@ function round5(n) {
/** A spec whose geometry was supplied by hand (drawTool.js) rather than by a name. */
function isManualSpec(spec, type) {
if (!spec || spec.manual !== true) return false;
if (type === 'route') return Array.isArray(spec.path) && spec.path.length >= 2;
if (type === 'route')
return Array.isArray(spec.path) && spec.path.length >= 2;
if (type === 'area') return Array.isArray(spec.ring) && spec.ring.length >= 3;
return Number.isFinite(Number(spec.latitude)) && Number.isFinite(Number(spec.longitude));
return (
Number.isFinite(Number(spec.latitude)) &&
Number.isFinite(Number(spec.longitude))
);
}
/** [lon, lat] pairs or {lon, lat} objects → [lon, lat] pairs, invalid entries dropped. */
function manualPairs(list) {
return (Array.isArray(list) ? list : [])
.map((p) => (Array.isArray(p) ? [Number(p[0]), Number(p[1])] : [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)]))
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180);
.map((p) =>
Array.isArray(p)
? [Number(p[0]), Number(p[1])]
: [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)],
)
.filter(
([lon, lat]) =>
Number.isFinite(lon) &&
Number.isFinite(lat) &&
Math.abs(lat) <= 90 &&
Math.abs(lon) <= 180,
);
}
/**
@@ -1132,15 +1449,28 @@ function resolveManualSpec(spec, type, viewer) {
// height 0 is a placeholder, not a placement: the world renderer draws a
// route with clampToGround + CESIUM_3D_TILE classification, so the line is
// draped onto the photoreal surface whatever this number says.
const pts = manualPairs(spec.path).map(([lon, lat]) => ({ lon, lat, height: 0 }));
const pts = manualPairs(spec.path).map(([lon, lat]) => ({
lon,
lat,
height: 0,
}));
if (pts.length < 2) throw new Error('a drawn line needs at least 2 points');
let distanceM = 0;
for (let i = 1; i < pts.length; i += 1) distanceM += greatCircleM(pts[i - 1], pts[i]);
return { path: pts, distanceM, durationS: null, mode: 'manual', source: 'manual', fallback: false };
for (let i = 1; i < pts.length; i += 1)
distanceM += greatCircleM(pts[i - 1], pts[i]);
return {
path: pts,
distanceM,
durationS: null,
mode: 'manual',
source: 'manual',
fallback: false,
};
}
if (type === 'area') {
const ring = manualPairs(spec.ring);
if (ring.length < 3) throw new Error('a drawn area needs at least 3 points');
if (ring.length < 3)
throw new Error('a drawn area needs at least 3 points');
// Averaged through ringCentroid, which unwraps longitudes first: a ring
// straddling the antimeridian has coordinates like [179.999, -179.999], and
// a raw mean of those puts the anchor on the Greenwich meridian, half a
@@ -1151,13 +1481,25 @@ function resolveManualSpec(spec, type, viewer) {
const lon = centre.lon;
const lat = centre.lat;
return {
lon, lat, height: sampleGroundHeight(viewer, lon, lat),
ring, footprintKind: 'area', buildingHeight: null, synthesized: false, source: 'manual',
lon,
lat,
height: sampleGroundHeight(viewer, lon, lat),
ring,
footprintKind: 'area',
buildingHeight: null,
synthesized: false,
source: 'manual',
};
}
const lon = Number(spec.longitude);
const lat = Number(spec.latitude);
return { lon, lat, height: sampleGroundHeight(viewer, lon, lat), ring: null, source: 'manual' };
return {
lon,
lat,
height: sampleGroundHeight(viewer, lon, lat),
ring: null,
source: 'manual',
};
}
/** The ring's distinct positions: a closing vertex that repeats the first is dropped. */
@@ -1165,20 +1507,26 @@ function closedRingWithoutRepeat(ring) {
if (ring.length < 2) return ring;
const [firstLon, firstLat] = ring[0];
const [lastLon, lastLat] = ring[ring.length - 1];
return firstLon === lastLon && firstLat === lastLat ? ring.slice(0, -1) : ring;
return firstLon === lastLon && firstLat === lastLat
? ring.slice(0, -1)
: ring;
}
function normalizeMode(m) {
const t = String(m || '').toLowerCase();
if (t === 'car' || t === 'drive' || t === 'driving') return 'car';
if (t === 'bike' || t === 'cycle' || t === 'cycling' || t === 'bicycle') return 'bike';
if (t === 'bike' || t === 'cycle' || t === 'cycling' || t === 'bicycle')
return 'bike';
return 'foot';
}
/** Route failure remains an explicitly labelled direct line in the caller. */
async function fetchRoute(coordPairs, mode, signal, service) {
try { return await service.route?.(coordPairs, mode, { signal }) || null; }
catch { return null; }
try {
return (await service.route?.(coordPairs, mode, { signal })) || null;
} catch {
return null;
}
}
function greatCircleM(a, b) {
@@ -1188,7 +1536,9 @@ function greatCircleM(a, b) {
const dLon = toRad(b.lon - a.lon);
const la1 = toRad(a.lat);
const la2 = toRad(b.lat);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
}
+15 -2
View File
@@ -1,4 +1,17 @@
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({
boundarySource: applicationServices.boundaries,
});
export const {
resolveAnnotationTarget,
refineScope,
isRateLimitedOutcome,
isGroundsLikeAsk,
selectFootprint,
viewportBias,
placesNearViewRecovery,
resolveRegionRingForQuery,
pickWorldFromScreen,
sampleGroundHeight,
} = resolver;
+10 -2
View File
@@ -42,7 +42,11 @@ export function createHybridAnnotationRenderer(viewer) {
entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
screen.add(entry.screenProxy);
}
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
} else if (
anno.type === 'route' &&
Array.isArray(anno.path) &&
anno.path.length >= 2
) {
// Drape the path on the tiles (world), caption it with a screen callout
// at the path midpoint.
entry.worldProxy = liveProxy(anno, { label: null });
@@ -86,7 +90,11 @@ export function createHybridAnnotationRenderer(viewer) {
} else if (anno.label) {
screen.add(screenProxy);
}
entry.screenProxy = entry.screenProxy ? screenProxy : (anno.label ? screenProxy : null);
entry.screenProxy = entry.screenProxy
? screenProxy
: anno.label
? screenProxy
: null;
}
/**
+17 -3
View File
@@ -10,13 +10,27 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
* footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver,
* and voice tool wiring are identical to the other two branches.
*/
export function initAnnotations({ viewer, tileset = null, placeSearch, resolver }) {
export function initAnnotations({
viewer,
tileset = null,
placeSearch,
resolver,
}) {
// World-space footprint draping; clamped marks can use the photoreal tiles.
if (tileset) {
try { tileset.enableCollision = true; } catch { /* older tileset */ }
try {
tileset.enableCollision = true;
} catch {
/* older tileset */
}
}
const renderer = createHybridAnnotationRenderer(viewer);
const engine = createAnnotationEngine({ viewer, renderer, placeSearch, resolveTarget: resolver?.resolveAnnotationTarget });
const engine = createAnnotationEngine({
viewer,
renderer,
placeSearch,
resolveTarget: resolver?.resolveAnnotationTarget,
});
window.__gevAnnotations = engine;
return engine;
}
+254 -71
View File
@@ -37,9 +37,9 @@ const PALETTE = {
// Altitude-scaled marker geometry. Zoomed in (≤ NEAR_H) reticles are full-size; pulling
// back shrinks them toward small recon dots (MIN floor) so many world-anchored marks no
// longer collapse into an overlapping blob when their anchors project close together.
const MARK_SCALE_NEAR_H = 800; // m: at/below this, full-size reticles
const MARK_SCALE_FAR_H = 6000; // m: at/above this, minimum size
const MARK_SCALE_MIN = 0.32; // floor so dots never vanish
const MARK_SCALE_NEAR_H = 800; // m: at/below this, full-size reticles
const MARK_SCALE_FAR_H = 6000; // m: at/above this, minimum size
const MARK_SCALE_MIN = 0.32; // floor so dots never vanish
const RING_OUTER_R = 34;
const RING_INNER_R = 18;
const DOT_R = 5;
@@ -51,16 +51,22 @@ function markScale(h) {
return 1 - t * (1 - MARK_SCALE_MIN);
}
export function createScreenAnnotationRenderer(viewer, {
overlayPaintRect = getOverlayPaintRect,
activeTrackedReadoutId = getActiveTrackedReadoutId,
} = {}) {
export function createScreenAnnotationRenderer(
viewer,
{
overlayPaintRect = getOverlayPaintRect,
activeTrackedReadoutId = getActiveTrackedReadoutId,
} = {},
) {
injectStyles();
const { layer, svg, defs } = buildOverlay();
document.body.appendChild(layer);
const scene = viewer.scene;
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, scene.camera.positionWC);
const occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
scene.camera.positionWC,
);
const records = new Map(); // anno.id -> { anno, group, parts }
const scratch = new Cesium.Cartesian2();
const scratchDir = new Cesium.Cartesian3();
@@ -112,8 +118,13 @@ export function createScreenAnnotationRenderer(viewer, {
return cached.h;
}
try {
if (scene.clampToHeightSupported && typeof scene.clampToHeight === 'function') {
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
if (
scene.clampToHeightSupported &&
typeof scene.clampToHeight === 'function'
) {
const c = scene.clampToHeight(
Cesium.Cartesian3.fromDegrees(lon, lat, 0),
);
if (c) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) {
@@ -123,7 +134,9 @@ export function createScreenAnnotationRenderer(viewer, {
}
}
}
} catch { /* tiles not ready */ }
} catch {
/* tiles not ready */
}
const fallback = cached ? cached.h : 0;
heightCache.set(key, { h: fallback, settled: false, gen: projGen });
trimHeightCache();
@@ -140,7 +153,8 @@ export function createScreenAnnotationRenderer(viewer, {
// "draw-on" gesture, so they never masquerade as an authoritative boundary.
parts.poly = svgEl('polygon', {
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw',
fill: c, stroke: c,
fill: c,
stroke: c,
'fill-opacity': anno.synthesized ? '0.08' : '0.14',
'stroke-width': '2.5',
...(anno.synthesized ? { 'stroke-dasharray': '9 7' } : {}),
@@ -151,25 +165,41 @@ export function createScreenAnnotationRenderer(viewer, {
if (parts.label) group.appendChild(parts.label.node);
} else if (anno.type === 'arrow' && anno.to) {
parts.path = svgEl('path', {
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c,
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
class: 'gev-anno-arrow gev-draw',
fill: 'none',
stroke: c,
'stroke-width': '3',
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
filter: 'url(#gev-sketch)',
});
ensureArrowMarker(defs, anno.color || 'primary', c);
group.appendChild(parts.path);
parts.label = makeCallout(anno.label, c);
if (parts.label) group.appendChild(parts.label.node);
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
} else if (
anno.type === 'route' &&
Array.isArray(anno.path) &&
anno.path.length >= 2
) {
// Multi-waypoint path: a drawn-on polyline with a dot at each waypoint.
parts.poly = svgEl('polyline', {
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c,
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
class: 'gev-anno-arrow gev-draw',
fill: 'none',
stroke: c,
'stroke-width': '3',
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
filter: 'url(#gev-sketch)',
});
ensureArrowMarker(defs, anno.color || 'primary', c);
group.appendChild(parts.poly);
parts.dots = anno.path.map(() => {
const dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: '4' });
const dot = svgEl('circle', {
class: 'gev-anno-dot',
fill: c,
stroke: '#06121c',
'stroke-width': '2',
r: '4',
});
group.appendChild(dot);
return dot;
});
@@ -178,14 +208,39 @@ export function createScreenAnnotationRenderer(viewer, {
} else {
// pin / highlight / label — pulsing target rings + a marker dot + callout
if (anno.type !== 'label') {
parts.ringOuter = svgEl('circle', { class: 'gev-anno-ring gev-pulse', fill: 'none', stroke: c, 'stroke-width': '2', r: '34' });
parts.ringInner = svgEl('circle', { class: 'gev-anno-ring', fill: c, 'fill-opacity': '0.12', stroke: c, 'stroke-width': '2.5', r: '18', filter: 'url(#gev-sketch)' });
parts.ringOuter = svgEl('circle', {
class: 'gev-anno-ring gev-pulse',
fill: 'none',
stroke: c,
'stroke-width': '2',
r: '34',
});
parts.ringInner = svgEl('circle', {
class: 'gev-anno-ring',
fill: c,
'fill-opacity': '0.12',
stroke: c,
'stroke-width': '2.5',
r: '18',
filter: 'url(#gev-sketch)',
});
group.appendChild(parts.ringOuter);
group.appendChild(parts.ringInner);
}
parts.dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: anno.type === 'label' ? '4' : '5' });
parts.dot = svgEl('circle', {
class: 'gev-anno-dot',
fill: c,
stroke: '#06121c',
'stroke-width': '2',
r: anno.type === 'label' ? '4' : '5',
});
group.appendChild(parts.dot);
parts.leader = svgEl('line', { class: 'gev-anno-leader', stroke: c, 'stroke-width': '1.5', 'stroke-opacity': '0.7' });
parts.leader = svgEl('line', {
class: 'gev-anno-leader',
stroke: c,
'stroke-width': '1.5',
'stroke-opacity': '0.7',
});
group.appendChild(parts.leader);
parts.label = makeCallout(anno.label, c);
if (parts.label) group.appendChild(parts.label.node);
@@ -209,18 +264,26 @@ export function createScreenAnnotationRenderer(viewer, {
// any length. Guarded on the specific property so an unrelated transition
// (none here, but defensive) doesn't clear it early.
for (const drawEl of group.querySelectorAll('.gev-draw')) {
drawEl.addEventListener('transitionend', (e) => {
if (e.propertyName === 'stroke-dashoffset') {
drawEl.style.strokeDasharray = 'none';
}
}, { once: true });
drawEl.addEventListener(
'transitionend',
(e) => {
if (e.propertyName === 'stroke-dashoffset') {
drawEl.style.strokeDasharray = 'none';
}
},
{ once: true },
);
}
projectAll();
} catch (error) {
// Hard, immediate unwind — a mark that never finished has nothing to
// fade out, and the caller (engine rollback) must find a clean board.
records.delete(anno.id);
try { group.remove(); } catch { /* never inserted */ }
try {
group.remove();
} catch {
/* never inserted */
}
if (records.size === 0) heightCache.clear();
throw error;
}
@@ -269,13 +332,22 @@ export function createScreenAnnotationRenderer(viewer, {
// Anchor at the real surface height (cached + validated), so marks sit on
// the ground/building instead of at sea level. The settle-once cache keeps
// it stable (no per-frame jitter that earlier broke nearby projections).
const world = Cesium.Cartesian3.fromDegrees(lon, lat, groundHeight(lon, lat));
const world = Cesium.Cartesian3.fromDegrees(
lon,
lat,
groundHeight(lon, lat),
);
// Reject points behind the camera (they produce wrapped/extreme coords).
Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir);
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0) return null;
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0)
return null;
// Reject points beyond the globe horizon.
if (!occluder.isPointVisible(world)) return null;
const win = Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, scratch);
const win = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
world,
scratch,
);
if (!win || !Number.isFinite(win.x) || !Number.isFinite(win.y)) return null;
// Reject absurd off-screen projections (anchor not in view).
const w = scene.canvas.clientWidth || scene.canvas.width;
@@ -291,9 +363,9 @@ export function createScreenAnnotationRenderer(viewer, {
// tracked subject's screen FOOTPRINT is faded. The complete card footprint comes
// from the host's ACTUAL painted rectangle after final layout; the billboard extent
// is unioned for aircraft/satellites that also own a native tracked graphic.
const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint
const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1)
const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop
const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint
const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1)
const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop
const _scratchTrackedWin = new Cesium.Cartesian2();
// Evaluate a NearFarScalar (billboard scaleByDistance) at a camera distance.
@@ -317,11 +389,16 @@ export function createScreenAnnotationRenderer(viewer, {
const ent = viewer.trackedEntity;
const now = Cesium.JulianDate.now();
const world = typeof ent?.gevDisplayPosition === 'function'
? ent.gevDisplayPosition()
: null;
const world =
typeof ent?.gevDisplayPosition === 'function'
? ent.gevDisplayPosition()
: null;
const win = world
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, _scratchTrackedWin)
? Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
world,
_scratchTrackedWin,
)
: null;
// Billboard box — centered on the anchor, magnified by scaleByDistance.
@@ -331,8 +408,13 @@ export function createScreenAnnotationRenderer(viewer, {
const baseH = bb.height?.getValue?.(now) ?? 28;
let scale = 1;
const sbd = bb.scaleByDistance?.getValue?.(now);
if (sbd) scale = nearFarValue(sbd, Cesium.Cartesian3.distance(scene.camera.positionWC, world));
const hw = (baseW * scale) / 2; const hh = (baseH * scale) / 2;
if (sbd)
scale = nearFarValue(
sbd,
Cesium.Cartesian3.distance(scene.camera.positionWC, world),
);
const hw = (baseW * scale) / 2;
const hh = (baseH * scale) / 2;
const bbLeft = win.x - hw;
const bbRight = win.x + hw;
const bbTop = win.y - hh;
@@ -345,7 +427,12 @@ export function createScreenAnnotationRenderer(viewer, {
if (![left, right, top, bottom].every(Number.isFinite)) return null;
const m = TRACKED_BBOX_MARGIN;
return { left: left - m, right: right + m, top: top - m, bottom: bottom + m };
return {
left: left - m,
right: right + m,
top: top - m,
bottom: bottom + m,
};
}
function projectAll() {
@@ -357,7 +444,9 @@ export function createScreenAnnotationRenderer(viewer, {
const w = scene.canvas.clientWidth || scene.canvas.width;
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
// Shrink point-marker geometry as the camera pulls back (declutter the blob).
const mScale = markScale(viewer.camera.positionCartographic?.height ?? 1000);
const mScale = markScale(
viewer.camera.positionCartographic?.height ?? 1000,
);
for (const rec of records.values()) {
const { anno, group, parts } = rec;
@@ -369,17 +458,22 @@ export function createScreenAnnotationRenderer(viewer, {
if (anno.type === 'area' && parts.poly) {
const pts = [];
let cx = 0; let cy = 0; let n = 0;
let cx = 0;
let cy = 0;
let n = 0;
for (const [lon, lat] of anno.ring) {
const p = project(lon, lat);
if (!p) continue;
pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
cx += p.x; cy += p.y; n++;
cx += p.x;
cy += p.y;
n++;
}
if (pts.length >= 3) {
parts.poly.setAttribute('points', pts.join(' '));
group.style.display = '';
if (parts.label) positionCallout(parts.label, cx / n, cy / n - 6, true);
if (parts.label)
positionCallout(parts.label, cx / n, cy / n - 6, true);
} else {
group.style.display = 'none';
}
@@ -388,16 +482,27 @@ export function createScreenAnnotationRenderer(viewer, {
const b = project(anno.to.lon, anno.to.lat);
if (a && b) {
// gentle arc so the connector reads as a drawn gesture, not a ruler line
const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2;
const dx = b.x - a.x; const dy = b.y - a.y;
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
const bow = Math.min(60, len * 0.18);
const ctrlX = mx - (dy / len) * bow;
const ctrlY = my + (dx / len) * bow;
parts.path.setAttribute('d', `M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`);
parts.path.setAttribute(
'd',
`M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`,
);
group.style.display = '';
// Anchor the label to the (stable) endpoint midpoint, lifted toward the bow.
if (parts.label) positionCallout(parts.label, mx + (ctrlX - mx) * 0.5, my + (ctrlY - my) * 0.5 - 8, true);
if (parts.label)
positionCallout(
parts.label,
mx + (ctrlX - mx) * 0.5,
my + (ctrlY - my) * 0.5 - 8,
true,
);
} else {
group.style.display = 'none';
}
@@ -412,13 +517,22 @@ export function createScreenAnnotationRenderer(viewer, {
// dots at each resolvable waypoint; hide the rest
parts.dots.forEach((dot, i) => {
const p = projected[i];
if (p) { dot.setAttribute('cx', p.x.toFixed(1)); dot.setAttribute('cy', p.y.toFixed(1)); dot.style.display = ''; }
else { dot.style.display = 'none'; }
if (p) {
dot.setAttribute('cx', p.x.toFixed(1));
dot.setAttribute('cy', p.y.toFixed(1));
dot.style.display = '';
} else {
dot.style.display = 'none';
}
});
group.style.display = '';
// label at the middle waypoint
const mid = projected.filter(Boolean)[Math.floor(projected.filter(Boolean).length / 2)];
if (parts.label && mid) positionCallout(parts.label, mid.x, mid.y - 12, true);
const mid =
projected.filter(Boolean)[
Math.floor(projected.filter(Boolean).length / 2)
];
if (parts.label && mid)
positionCallout(parts.label, mid.x, mid.y - 12, true);
} else {
group.style.display = 'none';
}
@@ -427,16 +541,40 @@ export function createScreenAnnotationRenderer(viewer, {
if (p) {
group.style.display = '';
const baseDotR = anno.type === 'label' ? LABEL_DOT_R : DOT_R;
if (parts.dot) { parts.dot.setAttribute('cx', p.x.toFixed(1)); parts.dot.setAttribute('cy', p.y.toFixed(1)); parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1)); }
if (parts.ringOuter) { parts.ringOuter.setAttribute('cx', p.x.toFixed(1)); parts.ringOuter.setAttribute('cy', p.y.toFixed(1)); parts.ringOuter.setAttribute('r', (RING_OUTER_R * mScale).toFixed(1)); }
if (parts.ringInner) { parts.ringInner.setAttribute('cx', p.x.toFixed(1)); parts.ringInner.setAttribute('cy', p.y.toFixed(1)); parts.ringInner.setAttribute('r', (RING_INNER_R * mScale).toFixed(1)); }
if (parts.dot) {
parts.dot.setAttribute('cx', p.x.toFixed(1));
parts.dot.setAttribute('cy', p.y.toFixed(1));
parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1));
}
if (parts.ringOuter) {
parts.ringOuter.setAttribute('cx', p.x.toFixed(1));
parts.ringOuter.setAttribute('cy', p.y.toFixed(1));
parts.ringOuter.setAttribute(
'r',
(RING_OUTER_R * mScale).toFixed(1),
);
}
if (parts.ringInner) {
parts.ringInner.setAttribute('cx', p.x.toFixed(1));
parts.ringInner.setAttribute('cy', p.y.toFixed(1));
parts.ringInner.setAttribute(
'r',
(RING_INNER_R * mScale).toFixed(1),
);
}
if (parts.label) {
// Keep the callout tucked near the (now smaller) reticle by scaling its offset.
const lx = p.x + 18 * mScale; const ly = p.y - 34 * mScale;
const lx = p.x + 18 * mScale;
const ly = p.y - 34 * mScale;
positionCallout(parts.label, lx, ly, false);
if (parts.leader) {
parts.leader.setAttribute('x1', p.x.toFixed(1)); parts.leader.setAttribute('y1', p.y.toFixed(1));
parts.leader.setAttribute('x2', lx.toFixed(1)); parts.leader.setAttribute('y2', (ly + parts.label.height).toFixed(1));
parts.leader.setAttribute('x1', p.x.toFixed(1));
parts.leader.setAttribute('y1', p.y.toFixed(1));
parts.leader.setAttribute('x2', lx.toFixed(1));
parts.leader.setAttribute(
'y2',
(ly + parts.label.height).toFixed(1),
);
parts.label._leader = parts.leader; // so de-collision can re-point it
}
}
@@ -450,7 +588,12 @@ export function createScreenAnnotationRenderer(viewer, {
const placed = [];
for (const rec of records.values()) {
const cal = rec.parts.label;
if (cal && cal.sized && rec.group.style.display !== 'none' && Number.isFinite(cal._x)) {
if (
cal &&
cal.sized &&
rec.group.style.display !== 'none' &&
Number.isFinite(cal._x)
) {
placed.push(cal);
}
}
@@ -471,7 +614,11 @@ export function createScreenAnnotationRenderer(viewer, {
let overlapping = false;
if (trackedRect) {
let bb = null;
try { bb = group.getBBox(); } catch { bb = null; }
try {
bb = group.getBBox();
} catch {
bb = null;
}
if (bb && (bb.width !== 0 || bb.height !== 0)) {
// HYSTERESIS: a mark that's already hidden must clear the rect by
// TRACKED_HYSTERESIS_PX before it starts showing again; one that's visible fades
@@ -479,8 +626,11 @@ export function createScreenAnnotationRenderer(viewer, {
// frame-to-frame (the pulsing reticle stroke + de-collided cards keep the bbox in
// motion), which was the source of the flicker.
const m = cur < 0.5 ? TRACKED_HYSTERESIS_PX : 0;
overlapping = bb.x <= trackedRect.right + m && bb.x + bb.width >= trackedRect.left - m
&& bb.y <= trackedRect.bottom + m && bb.y + bb.height >= trackedRect.top - m;
overlapping =
bb.x <= trackedRect.right + m &&
bb.x + bb.width >= trackedRect.left - m &&
bb.y <= trackedRect.bottom + m &&
bb.y + bb.height >= trackedRect.top - m;
}
}
const target = overlapping ? 0 : 1;
@@ -490,7 +640,10 @@ export function createScreenAnnotationRenderer(viewer, {
// (The CSS `transition: opacity` was removed from .gev-anno so this per-frame JS ease
// is the ONLY easing — previously the two fought and produced an opacity oscillation.)
if (rec._trackedFade !== 1) {
group.setAttribute('opacity', String((anno.alpha ?? 1) * rec._trackedFade));
group.setAttribute(
'opacity',
String((anno.alpha ?? 1) * rec._trackedFade),
);
}
}
}
@@ -501,7 +654,13 @@ export function createScreenAnnotationRenderer(viewer, {
rec.group.classList.remove('gev-in');
rec.group.classList.add('gev-out');
const node = rec.group;
window.setTimeout(() => { try { node.remove(); } catch { /* gone */ } }, 360);
window.setTimeout(() => {
try {
node.remove();
} catch {
/* gone */
}
}, 360);
records.delete(anno.id);
// Board emptied → drop accumulated height samples (natural reset point).
if (records.size === 0) heightCache.clear();
@@ -513,8 +672,16 @@ export function createScreenAnnotationRenderer(viewer, {
}
function destroy() {
try { scene.postRender.removeEventListener(onPostRender); } catch { /* torn down */ }
try { layer.remove(); } catch { /* gone */ }
try {
scene.postRender.removeEventListener(onPostRender);
} catch {
/* torn down */
}
try {
layer.remove();
} catch {
/* gone */
}
records.clear();
heightCache.clear();
}
@@ -562,7 +729,15 @@ function makeCallout(text, c) {
node.appendChild(rect);
node.appendChild(accent);
node.appendChild(t);
const callout = { node, rect, text: t, accent, width: 0, height: 26, sized: false };
const callout = {
node,
rect,
text: t,
accent,
width: 0,
height: 26,
sized: false,
};
// size the card to the text once it is in the DOM
requestAnimationFrame(() => sizeCallout(callout));
return callout;
@@ -581,7 +756,9 @@ function sizeCallout(callout) {
callout.width = w;
callout.height = h;
callout.sized = true;
} catch { /* not laid out yet */ }
} catch {
/* not laid out yet */
}
}
function positionCallout(callout, x, y, center) {
@@ -589,7 +766,10 @@ function positionCallout(callout, x, y, center) {
const tx = center ? x - callout.width / 2 : x;
callout._x = tx;
callout._y = y;
callout.node.setAttribute('transform', `translate(${tx.toFixed(1)}, ${y.toFixed(1)})`);
callout.node.setAttribute(
'transform',
`translate(${tx.toFixed(1)}, ${y.toFixed(1)})`,
);
}
/**
@@ -607,7 +787,10 @@ function decollideCallouts(callouts) {
const overlapY = a._y < b._y + b.height && a._y + a.height > b._y;
if (overlapX && overlapY) {
a._y = b._y + b.height + 5;
a.node.setAttribute('transform', `translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`);
a.node.setAttribute(
'transform',
`translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`,
);
if (a._leader) {
a._leader.setAttribute('x2', a._x.toFixed(1));
a._leader.setAttribute('y2', a._y.toFixed(1));
+200 -106
View File
@@ -58,7 +58,9 @@ export function createWorldAnnotationRenderer(viewer) {
ensureFlowFabricRegistered();
function colorFor(anno) {
return Cesium.Color.fromCssColorString(PALETTE[anno.color] || PALETTE.primary);
return Cesium.Color.fromCssColorString(
PALETTE[anno.color] || PALETTE.primary,
);
}
// Target-ring radius (meters) scaled to camera height so it reads at any
@@ -86,7 +88,11 @@ export function createWorldAnnotationRenderer(viewer) {
const entities = [];
anno._entities = entities;
if (anno.ring && anno.ring.length >= 3 && anno.footprintKind === 'building') {
if (
anno.ring &&
anno.ring.length >= 3 &&
anno.footprintKind === 'building'
) {
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION
// volume that tints the real photogrammetry mesh (dome, walls, roof —
// everything inside the column) amber and pulses. A flat footprint
@@ -94,93 +100,134 @@ export function createWorldAnnotationRenderer(viewer) {
// actual tiles. A faint wireframe cage rides on top as a secondary cue.
const buffered = bufferRing(anno.ring, 3);
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
const groundH = sampleGroundOutside(viewer.scene, anno.ring)
?? (Number.isFinite(anno.anchor.height) ? anno.anchor.height - (anno.buildingHeight || 25) : 0);
const groundH =
sampleGroundOutside(viewer.scene, anno.ring) ??
(Number.isFinite(anno.anchor.height)
? anno.anchor.height - (anno.buildingHeight || 25)
: 0);
const baseH = groundH - 3;
// Tall headroom is free: classification only colors where tiles exist, so
// empty air above the roof isn't tinted — this just guarantees we cover
// under-tagged building heights.
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
// 1) Classification volume — tints the mesh amber, pulsing.
entities.push(dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
classificationType: CLASSIFY,
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.45, pulse: true })),
},
}));
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
classificationType: CLASSIFY,
material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.45, pulse: true }),
),
},
}),
);
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
// the footprint so the extruded outline draws ~14 clean verticals, not one
// per (often 100+) ring vertex.
const cagePositions = Cesium.Cartesian3.fromDegreesArray(decimateRing(buffered, 14).flat());
entities.push(dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
fill: false,
outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}));
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
decimateRing(buffered, 14).flat(),
);
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
fill: false,
outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}),
);
// 3) Crisp draped base outline so the footprint reads on the ground.
entities.push(dataSource.entities.add({
polyline: {
positions,
width: 3,
material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.25, color: liveColor(anno, base, { alpha: 0.7 }) }),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
entities.push(
dataSource.entities.add({
polyline: {
positions,
width: 3,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.25,
color: liveColor(anno, base, { alpha: 0.7 }),
}),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
} else if (anno.ring && anno.ring.length >= 3) {
// Larger area (district / compound / park) → flat fill draped on the tiles
// + a glowing outline. Draping is right here: you can't extrude a whole
// neighbourhood, and the GIS overlay shows the boundary clearly.
const fillPositions = Cesium.Cartesian3.fromDegreesArray(anno.ring.flat());
entities.push(dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: anno.synthesized ? 0.10 : 0.20, pulse: true })),
classificationType: CLASSIFY,
},
}));
entities.push(dataSource.entities.add({
polyline: {
positions: fillPositions,
width: 6,
// Synthesized → DASHED outline (signals "approximate, not an authoritative
// boundary", research §8.4/§8.6); real footprints → solid glow.
material: anno.synthesized
? new Cesium.PolylineDashMaterialProperty({ color: liveColor(anno, base, { alpha: 0.95 }), dashLength: 24 })
: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.35, color: liveColor(anno, base, { alpha: 1 }) }),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
const fillPositions = Cesium.Cartesian3.fromDegreesArray(
anno.ring.flat(),
);
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, {
alpha: anno.synthesized ? 0.1 : 0.2,
pulse: true,
}),
),
classificationType: CLASSIFY,
},
}),
);
entities.push(
dataSource.entities.add({
polyline: {
positions: fillPositions,
width: 6,
// Synthesized → DASHED outline (signals "approximate, not an authoritative
// boundary", research §8.4/§8.6); real footprints → solid glow.
material: anno.synthesized
? new Cesium.PolylineDashMaterialProperty({
color: liveColor(anno, base, { alpha: 0.95 }),
dashLength: 24,
})
: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.35,
color: liveColor(anno, base, { alpha: 1 }),
}),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
} else if (
anno.type === 'route' &&
Array.isArray(anno.path) &&
anno.path.length >= 2
) {
// A real path (street-following) draped on the 3D tiles, with dashes that
// FLOW toward the destination (custom st.s + time material; works on the
// clamped/classified ground polyline).
const positions = Cesium.Cartesian3.fromDegreesArray(
anno.path.flatMap((p) => [p.lon, p.lat]),
);
entities.push(dataSource.entities.add({
polyline: {
positions,
width: 9,
material: new FlowMaterialProperty(PALETTE[anno.color] || PALETTE.primary),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
entities.push(
dataSource.entities.add({
polyline: {
positions,
width: 9,
material: new FlowMaterialProperty(
PALETTE[anno.color] || PALETTE.primary,
),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
} else if (anno.type === 'arrow' && anno.to) {
// Connector draped across the ground from origin to destination.
@@ -188,24 +235,30 @@ export function createWorldAnnotationRenderer(viewer) {
Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
Cesium.Cartesian3.fromDegrees(anno.to.lon, anno.to.lat),
];
entities.push(dataSource.entities.add({
polyline: {
positions,
width: 16,
material: new Cesium.PolylineArrowMaterialProperty(liveColor(anno, base, { alpha: 0.95 })),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
entities.push(
dataSource.entities.add({
polyline: {
positions,
width: 16,
material: new Cesium.PolylineArrowMaterialProperty(
liveColor(anno, base, { alpha: 0.95 }),
),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
if (anno.label) {
const mid = {
lon: (anno.anchor.lon + anno.to.lon) / 2,
lat: (anno.anchor.lat + anno.to.lat) / 2,
};
entities.push(dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
label: labelGraphic(anno, base),
}));
entities.push(
dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
label: labelGraphic(anno, base),
}),
);
}
} else {
// pin / highlight / label — a camera-proportional target ring + a marker.
@@ -213,16 +266,23 @@ export function createWorldAnnotationRenderer(viewer) {
// Radius scales with camera height so the ring reads at any altitude.
// semiMajor === semiMinor is required AND must hold every frame, so both
// axes read the SAME ringRadius() (camera height is constant per frame).
entities.push(dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
ellipse: {
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.38, pulse: true })),
outline: false,
classificationType: CLASSIFY,
},
}));
entities.push(
dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(
anno.anchor.lon,
anno.anchor.lat,
),
ellipse: {
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.38, pulse: true }),
),
outline: false,
classificationType: CLASSIFY,
},
}),
);
}
entities.push(labelMarker(anno, base, { point: true }));
}
@@ -232,14 +292,16 @@ export function createWorldAnnotationRenderer(viewer) {
function labelMarker(anno, base, { point }) {
return dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
point: point ? {
pixelSize: anno.type === 'label' ? 8 : 14,
color: liveColor(anno, base, { alpha: 1 }),
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
outlineWidth: 3,
heightReference: CLAMP,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
} : undefined,
point: point
? {
pixelSize: anno.type === 'label' ? 8 : 14,
color: liveColor(anno, base, { alpha: 1 }),
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
outlineWidth: 3,
heightReference: CLAMP,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
}
: undefined,
label: anno.label ? labelGraphic(anno, base) : undefined,
});
}
@@ -256,7 +318,10 @@ export function createWorldAnnotationRenderer(viewer) {
pixelOffset: new Cesium.Cartesian2(0, -16),
showBackground: true,
backgroundColor: new Cesium.CallbackProperty(
() => Cesium.Color.fromCssColorString('#0b1622').withAlpha(0.72 * (anno.alpha ?? 1)),
() =>
Cesium.Color.fromCssColorString('#0b1622').withAlpha(
0.72 * (anno.alpha ?? 1),
),
false,
),
backgroundPadding: new Cesium.Cartesian2(8, 5),
@@ -357,8 +422,16 @@ export function FlowMaterialProperty(colorCss) {
this._definitionChanged = new Cesium.Event();
}
Object.defineProperties(FlowMaterialProperty.prototype, {
isConstant: { get() { return false; } }, // re-evaluated each frame → it animates
definitionChanged: { get() { return this._definitionChanged; } },
isConstant: {
get() {
return false;
},
}, // re-evaluated each frame → it animates
definitionChanged: {
get() {
return this._definitionChanged;
},
},
});
FlowMaterialProperty.prototype.getType = function getType() {
return 'GevRouteFlow';
@@ -394,10 +467,17 @@ function decimateRing(ring, n) {
* tiles under those points aren't loaded yet.
*/
function sampleGroundOutside(scene, ring) {
if (!scene?.clampToHeightSupported || typeof scene.clampToHeight !== 'function') return null;
if (
!scene?.clampToHeightSupported ||
typeof scene.clampToHeight !== 'function'
)
return null;
let clon = 0;
let clat = 0;
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
for (const [lon, lat] of ring) {
clon += lon;
clat += lat;
}
clon /= ring.length;
clat /= ring.length;
const latS = 111320;
@@ -407,7 +487,16 @@ function sampleGroundOutside(scene, ring) {
maxR = Math.max(maxR, Math.hypot((lon - clon) * lonS, (lat - clat) * latS));
}
const out = maxR * 1.5 + 12;
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7, 0.7], [-0.7, -0.7], [0.7, -0.7], [-0.7, 0.7]];
const dirs = [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
[0.7, 0.7],
[-0.7, -0.7],
[0.7, -0.7],
[-0.7, 0.7],
];
const samples = [];
for (const [dx, dy] of dirs) {
const lon = clon + (dx * out) / lonS;
@@ -418,7 +507,9 @@ function sampleGroundOutside(scene, ring) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
}
} catch { /* tile not ready */ }
} catch {
/* tile not ready */
}
}
if (!samples.length) return null;
samples.sort((a, b) => a - b);
@@ -433,7 +524,10 @@ function sampleGroundOutside(scene, ring) {
function bufferRing(ring, meters) {
let clon = 0;
let clat = 0;
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
for (const [lon, lat] of ring) {
clon += lon;
clat += lat;
}
clon /= ring.length;
clat /= ring.length;
const latScale = 111320;
+473 -141
View File
File diff suppressed because it is too large. Load diff
+18 -7
View File
@@ -22,9 +22,11 @@ export function activateCctvCameraFromWorldClick(
) {
if (!cameraId || typeof activate !== 'function') return false;
if (activate(cameraId) !== CCTV_ACTIVATION_RESULT.ACTIVATED) return false;
eventTarget.dispatchEvent(new CustomEvent(CCTV_FOCUS_REQUEST_EVENT, {
detail: { cameraId },
}));
eventTarget.dispatchEvent(
new CustomEvent(CCTV_FOCUS_REQUEST_EVENT, {
detail: { cameraId },
}),
);
return true;
}
@@ -36,8 +38,12 @@ export function activateCctvCameraFromWorldClick(
* @returns {() => void} Listener disposer.
*/
export function registerCctvFocusRequestListener(eventTarget, listener) {
if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener
|| typeof listener !== 'function') return () => {};
if (
!eventTarget?.addEventListener ||
!eventTarget?.removeEventListener ||
typeof listener !== 'function'
)
return () => {};
eventTarget.addEventListener(CCTV_FOCUS_REQUEST_EVENT, listener);
let disposed = false;
return () => {
@@ -57,11 +63,16 @@ export function registerCctvFocusRequestListener(eventTarget, listener) {
*/
export function routeCctvFocusRequest(event, runExplicitFocus, focusCamera) {
const cameraId = event?.detail?.cameraId;
if (typeof cameraId !== 'string' || !cameraId || typeof runExplicitFocus !== 'function') {
if (
typeof cameraId !== 'string' ||
!cameraId ||
typeof runExplicitFocus !== 'function'
) {
return false;
}
return runExplicitFocus(
() => cameraId,
(selectedId) => focusCamera(selectedId, CCTV_WORLD_CLICK_FOCUS_DURATION_SEC),
(selectedId) =>
focusCamera(selectedId, CCTV_WORLD_CLICK_FOCUS_DURATION_SEC),
);
}
+186 -56
View File
@@ -90,16 +90,30 @@ export function getKeyholeGeometry(width, height) {
* floor through a band derived from the live keyhole radius.
*/
export function keyholeLabelAlpha(labelX, labelY, width, height) {
return keyholeLabelAlphaFromGeometry(labelX, labelY, getKeyholeGeometry(width, height));
return keyholeLabelAlphaFromGeometry(
labelX,
labelY,
getKeyholeGeometry(width, height),
);
}
/** Compute keyhole opacity from geometry already cached by a hot render loop. */
export function keyholeLabelAlphaFromGeometry(labelX, labelY, geometry) {
if (!geometry || !(geometry.radius > 0) || !Number.isFinite(labelX) || !Number.isFinite(labelY)) return 0;
if (
!geometry ||
!(geometry.radius > 0) ||
!Number.isFinite(labelX) ||
!Number.isFinite(labelY)
)
return 0;
const feather = geometry.featherPx;
const distance = Math.hypot(labelX - geometry.centerX, labelY - geometry.centerY);
const distance = Math.hypot(
labelX - geometry.centerX,
labelY - geometry.centerY,
);
if (distance <= geometry.radius) return 1;
if (!(feather > 0) || distance >= geometry.radius + feather) return keyholeOutsideOpacity;
if (!(feather > 0) || distance >= geometry.radius + feather)
return keyholeOutsideOpacity;
const progress = clamp((distance - geometry.radius) / feather, 0, 1);
return 1 - (1 - keyholeOutsideOpacity) * progress;
}
@@ -131,7 +145,11 @@ export function isCelestialRingStyleSupported(styleName) {
* @param {number} lastAngle - Previous stable angle in radians.
* @returns {{angle:number, opacity:number, stable:boolean}}
*/
export function celestialScreenAngle(rightComponent, upComponent, lastAngle = 0) {
export function celestialScreenAngle(
rightComponent,
upComponent,
lastAngle = 0,
) {
const planeLength = Math.hypot(rightComponent, upComponent);
if (!Number.isFinite(planeLength) || planeLength < CELESTIAL_PLANE_EPSILON) {
return {
@@ -170,26 +188,42 @@ export function isFullGlobeInsideKeyhole(geometry, wasVisible = false) {
keyholeCenterY,
keyholeRadius,
} = geometry || {};
const values = [earthCenterX, earthCenterY, earthRadius, keyholeCenterX, keyholeCenterY, keyholeRadius];
if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0) return false;
const offset = Math.hypot(earthCenterX - keyholeCenterX, earthCenterY - keyholeCenterY);
const values = [
earthCenterX,
earthCenterY,
earthRadius,
keyholeCenterX,
keyholeCenterY,
keyholeRadius,
];
if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0)
return false;
const offset = Math.hypot(
earthCenterX - keyholeCenterX,
earthCenterY - keyholeCenterY,
);
const clearance = keyholeRadius - (offset + earthRadius);
return clearance >= (wasVisible ? GLOBE_EXIT_CLEARANCE_PX : GLOBE_ENTER_CLEARANCE_PX);
return (
clearance >=
(wasVisible ? GLOBE_EXIT_CLEARANCE_PX : GLOBE_ENTER_CLEARANCE_PX)
);
}
/** Return the Earth-disc radius in CSS pixels for a perspective camera. */
export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) {
const earthRadiusM = Cesium.Ellipsoid.WGS84.maximumRadius;
if (
!Number.isFinite(cameraDistance)
|| cameraDistance <= earthRadiusM
|| !(viewportHeight > 0)
|| !Number.isFinite(fovy)
|| fovy <= 0
|| fovy >= Math.PI
) return null;
!Number.isFinite(cameraDistance) ||
cameraDistance <= earthRadiusM ||
!(viewportHeight > 0) ||
!Number.isFinite(fovy) ||
fovy <= 0 ||
fovy >= Math.PI
)
return null;
const angularRadius = Math.asin(clamp(earthRadiusM / cameraDistance, 0, 1));
const radius = (viewportHeight * 0.5) * Math.tan(angularRadius) / Math.tan(fovy * 0.5);
const radius =
(viewportHeight * 0.5 * Math.tan(angularRadius)) / Math.tan(fovy * 0.5);
return Number.isFinite(radius) && radius > 0 ? radius : null;
}
@@ -216,7 +250,11 @@ export function projectEarthDiscToViewport(
if (!camera || !scene || !(width > 0) || !(height > 0)) return null;
const distance = Cesium.Cartesian3.magnitude(camera.positionWC);
if (!Number.isFinite(distance) || distance <= Cesium.Ellipsoid.WGS84.maximumRadius) return null;
if (
!Number.isFinite(distance) ||
distance <= Cesium.Ellipsoid.WGS84.maximumRadius
)
return null;
const toCenter = Cesium.Cartesian3.negate(
camera.positionWC,
@@ -229,9 +267,14 @@ export function projectEarthDiscToViewport(
Cesium.Cartesian3.ZERO,
scratchCenter,
);
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) return null;
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y))
return null;
const earthRadius = earthDiscScreenRadius(distance, height, camera.frustum?.fovy);
const earthRadius = earthDiscScreenRadius(
distance,
height,
camera.frustum?.fovy,
);
if (!earthRadius) return null;
const keyhole = getKeyholeGeometry(width, height);
@@ -286,7 +329,12 @@ function drawSunRays(ctx, cx, cy, radius, innerRadius, angle) {
glow.addColorStop(0.58, 'rgba(255, 235, 188, 0.027)');
glow.addColorStop(1, 'rgba(255, 242, 214, 0)');
ctx.fillStyle = glow;
ctx.fillRect(cx - radius - 4, cy - radius - 4, (radius + 4) * 2, (radius + 4) * 2);
ctx.fillRect(
cx - radius - 4,
cy - radius - 4,
(radius + 4) * 2,
(radius + 4) * 2,
);
ctx.lineCap = 'round';
ctx.shadowColor = 'rgba(255, 232, 171, 0.11)';
@@ -299,7 +347,10 @@ function drawSunRays(ctx, cx, cy, radius, innerRadius, angle) {
const ex = sx + Math.cos(rayAngle) * rayLength;
const ey = sy + Math.sin(rayAngle) * rayLength;
const lineGradient = ctx.createLinearGradient(sx, sy, ex, ey);
lineGradient.addColorStop(0, `rgba(255, 230, 166, ${0.075 - Math.abs(i) * 0.011})`);
lineGradient.addColorStop(
0,
`rgba(255, 230, 166, ${0.075 - Math.abs(i) * 0.011})`,
);
lineGradient.addColorStop(1, 'rgba(255, 226, 156, 0)');
ctx.strokeStyle = lineGradient;
ctx.lineWidth = i === 0 ? 1.8 : 0.9;
@@ -340,7 +391,8 @@ export class CelestialRing {
this.viewer = viewer;
this.enabled = !!enabled;
this.visible = false;
this._onAutoDisable = typeof onAutoDisable === 'function' ? onAutoDisable : null;
this._onAutoDisable =
typeof onAutoDisable === 'function' ? onAutoDisable : null;
this._focusInProgress = false;
this._ephemerisDirty = true;
this._ephemerisUpdateCount = 0;
@@ -364,7 +416,9 @@ export class CelestialRing {
this._fixedMatrix = new Cesium.Matrix3();
this._buildDOM();
this._removePostRender = viewer.scene.postRender.addEventListener(() => this._draw());
this._removePostRender = viewer.scene.postRender.addEventListener(() =>
this._draw(),
);
// Pre-existing staleness fix (perf wave 2 review): the ephemeris was
// sampled once per visible-enable from the FROZEN app clock, so the
// sun/moon markers aged with the app. Resample real wall time each
@@ -394,18 +448,26 @@ export class CelestialRing {
this._sunCanvas = document.createElement('canvas');
this._sunCanvas.className = 'celestial-ring-canvas celestial-sun-canvas';
this._sunCtx = this._sunCanvas.getContext('2d', { alpha: true, desynchronized: true });
this._sunCtx = this._sunCanvas.getContext('2d', {
alpha: true,
desynchronized: true,
});
this._moonCanvas = document.createElement('canvas');
this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-canvas';
this._moonCtx = this._moonCanvas.getContext('2d', { alpha: true, desynchronized: true });
this._moonCtx = this._moonCanvas.getContext('2d', {
alpha: true,
desynchronized: true,
});
this._sunMarker = document.createElement('span');
this._sunMarker.className = 'celestial-marker celestial-sun material-symbols-outlined';
this._sunMarker.className =
'celestial-marker celestial-sun material-symbols-outlined';
this._sunMarker.textContent = 'light_mode';
this._moonMarker = document.createElement('span');
this._moonMarker.className = 'celestial-marker celestial-moon material-symbols-outlined';
this._moonMarker.className =
'celestial-marker celestial-moon material-symbols-outlined';
this._moonMarker.textContent = 'dark_mode';
this._root.append(
@@ -413,7 +475,7 @@ export class CelestialRing {
this._sunCanvas,
this._moonCanvas,
this._sunMarker,
this._moonMarker
this._moonMarker,
);
this.viewer.container.appendChild(this._root);
}
@@ -458,15 +520,24 @@ export class CelestialRing {
const height = canvas.clientHeight || canvas.height;
const cartographic = this.viewer.camera.positionCartographic;
const fovy = this.viewer.camera.frustum?.fovy;
if (!(height > 0) || !cartographic || !Number.isFinite(fovy) || fovy <= 0 || fovy >= Math.PI) {
if (
!(height > 0) ||
!cartographic ||
!Number.isFinite(fovy) ||
fovy <= 0 ||
fovy >= Math.PI
) {
return false;
}
const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius;
const keyholeRadius = getKeyholeGeometry(canvas.clientWidth || canvas.width, height).radius;
const keyholeRadius = getKeyholeGeometry(
canvas.clientWidth || canvas.width,
height,
).radius;
const targetScreenRadius = keyholeRadius * FULL_GLOBE_RADIUS_RATIO;
const angularRadius = Math.atan(
(targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5)
(targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5),
);
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
@@ -480,7 +551,7 @@ export class CelestialRing {
destination: Cesium.Cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
altitude
altitude,
),
orientation: {
heading: this.viewer.camera.heading,
@@ -500,7 +571,10 @@ export class CelestialRing {
/** Clear the backing canvas. */
_clear() {
for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) {
for (const [canvas, ctx] of [
[this._sunCanvas, this._sunCtx],
[this._moonCanvas, this._moonCtx],
]) {
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
}
this._ringOutline?.style.setProperty('display', 'none');
@@ -512,12 +586,23 @@ export class CelestialRing {
/** Resize the canvas backing store while drawing in CSS pixels. */
_resize(width, height) {
const nativeDpr = Math.max(1, window.devicePixelRatio || 1);
const pixelsScale = Math.sqrt(CELESTIAL_MAX_BACKING_PIXELS / (width * height));
const dimensionScale = CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height);
const dpr = Math.min(nativeDpr, CELESTIAL_MAX_DEVICE_PIXEL_RATIO, pixelsScale, dimensionScale);
const pixelsScale = Math.sqrt(
CELESTIAL_MAX_BACKING_PIXELS / (width * height),
);
const dimensionScale =
CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height);
const dpr = Math.min(
nativeDpr,
CELESTIAL_MAX_DEVICE_PIXEL_RATIO,
pixelsScale,
dimensionScale,
);
const bw = Math.max(1, Math.round(width * dpr));
const bh = Math.max(1, Math.round(height * dpr));
for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) {
for (const [canvas, ctx] of [
[this._sunCanvas, this._sunCtx],
[this._moonCanvas, this._moonCtx],
]) {
if (canvas.width !== bw || canvas.height !== bh) {
canvas.width = bw;
canvas.height = bh;
@@ -536,14 +621,25 @@ export class CelestialRing {
_updateEphemeris(time) {
if (!this._ephemerisDirty) return true;
Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(time, this._sunInertial);
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(time, this._moonInertial);
const matrix = Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix)
|| Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix);
Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(
time,
this._sunInertial,
);
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(
time,
this._moonInertial,
);
const matrix =
Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix) ||
Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix);
if (!matrix) return false;
Cesium.Matrix3.multiplyByVector(matrix, this._sunInertial, this._sunFixed);
Cesium.Matrix3.multiplyByVector(matrix, this._moonInertial, this._moonFixed);
Cesium.Matrix3.multiplyByVector(
matrix,
this._moonInertial,
this._moonFixed,
);
Cesium.Cartesian3.normalize(this._sunFixed, this._sunFixed);
Cesium.Cartesian3.normalize(this._moonFixed, this._moonFixed);
this._ephemerisDirty = false;
@@ -574,8 +670,14 @@ export class CelestialRing {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const margin = 14;
const maxRadiusX = Math.abs(cos) > 1e-4 ? (cx - margin) / Math.abs(cos) : Number.POSITIVE_INFINITY;
const maxRadiusY = Math.abs(sin) > 1e-4 ? (cy - margin) / Math.abs(sin) : Number.POSITIVE_INFINITY;
const maxRadiusX =
Math.abs(cos) > 1e-4
? (cx - margin) / Math.abs(cos)
: Number.POSITIVE_INFINITY;
const maxRadiusY =
Math.abs(sin) > 1e-4
? (cy - margin) / Math.abs(sin)
: Number.POSITIVE_INFINITY;
const markerRadius = Math.min(radius, maxRadiusX, maxRadiusY);
marker.style.left = `${cx + cos * markerRadius}px`;
marker.style.top = `${cy + sin * markerRadius}px`;
@@ -596,14 +698,24 @@ export class CelestialRing {
const sunKey = `${outlineKey}:${Math.round(rayInnerRadius)}`;
if (sunKey !== this._sunRenderKey) {
this._sunCtx.clearRect(0, 0, this._sunCanvas.width, this._sunCanvas.height);
this._sunCtx.clearRect(
0,
0,
this._sunCanvas.width,
this._sunCanvas.height,
);
drawSunRays(this._sunCtx, cx, cy, radius, rayInnerRadius, 0);
drawTaperedArc(this._sunCtx, cx, cy, radius, 0, '222, 190, 89', 0.56);
this._sunRenderKey = sunKey;
}
if (outlineKey !== this._moonRenderKey) {
this._moonCtx.clearRect(0, 0, this._moonCanvas.width, this._moonCanvas.height);
this._moonCtx.clearRect(
0,
0,
this._moonCanvas.width,
this._moonCanvas.height,
);
drawMoonHaze(this._moonCtx, cx, cy, radius, 0);
drawTaperedArc(this._moonCtx, cx, cy, radius, 0, '48, 201, 229', 0.42);
this._moonRenderKey = outlineKey;
@@ -624,7 +736,9 @@ export class CelestialRing {
const disc = this._projectedEarthDisc(width, height);
const wasVisible = this.visible;
const nextVisible = disc ? isFullGlobeInsideKeyhole(disc, wasVisible) : false;
const nextVisible = disc
? isFullGlobeInsideKeyhole(disc, wasVisible)
: false;
this.visible = nextVisible;
this._root.classList.toggle('visible', nextVisible);
this._root.dataset.globeVisible = String(nextVisible);
@@ -645,12 +759,12 @@ export class CelestialRing {
const sunProjection = celestialScreenAngle(
Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC),
Cesium.Cartesian3.dot(this._sunFixed, camera.upWC),
this._sunAngle
this._sunAngle,
);
const moonProjection = celestialScreenAngle(
Cesium.Cartesian3.dot(this._moonFixed, camera.rightWC),
Cesium.Cartesian3.dot(this._moonFixed, camera.upWC),
this._moonAngle
this._moonAngle,
);
if (sunProjection.stable) this._sunAngle = sunProjection.angle;
if (moonProjection.stable) this._moonAngle = moonProjection.angle;
@@ -660,7 +774,10 @@ export class CelestialRing {
const cx = width * 0.5;
const cy = height * 0.5;
const radius = disc.keyholeRadius - RING_INSET_PX;
const rayInnerRadius = Math.min(radius - 12, disc.earthRadius + Math.max(38, radius * 0.065));
const rayInnerRadius = Math.min(
radius - 12,
disc.earthRadius + Math.max(38, radius * 0.065),
);
this._renderEffectLayers(cx, cy, radius, rayInnerRadius);
this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`;
this._sunCanvas.style.opacity = String(this._sunOpacity);
@@ -671,16 +788,24 @@ export class CelestialRing {
// stroke. Near conjunction (such as a new moon), keep the true bearings but
// move the moon into a second radial lane so both bodies remain legible.
const markerRadius = radius - MARKER_INSET_PX;
const markersCollide = circularAngleDistance(this._sunAngle, this._moonAngle)
< MARKER_COLLISION_ANGLE;
this._positionMarker(this._sunMarker, cx, cy, markerRadius, this._sunAngle, this._sunOpacity);
const markersCollide =
circularAngleDistance(this._sunAngle, this._moonAngle) <
MARKER_COLLISION_ANGLE;
this._positionMarker(
this._sunMarker,
cx,
cy,
markerRadius,
this._sunAngle,
this._sunOpacity,
);
this._positionMarker(
this._moonMarker,
cx,
cy,
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
this._moonAngle,
this._moonOpacity
this._moonOpacity,
);
this._debug = {
enabled: true,
@@ -698,7 +823,12 @@ export class CelestialRing {
/** Read-only geometry snapshot for browser QA. */
getDebugState() {
return this._debug ? { ...this._debug, disc: this._debug.disc ? { ...this._debug.disc } : null } : null;
return this._debug
? {
...this._debug,
disc: this._debug.disc ? { ...this._debug.disc } : null,
}
: null;
}
/** Detach the render hook and remove all overlay DOM. */
+133 -47
View File
@@ -1,6 +1,9 @@
import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium';
import { deriveWeatherEffectProfile, weatherAltitudeFactors } from './weatherEffectsMath.js';
import {
deriveWeatherEffectProfile,
weatherAltitudeFactors,
} from './weatherEffectsMath.js';
const WEATHER_REFRESH_MS = 5 * 60_000;
const CLOUD_FRAME_MS = 1000 / 12;
@@ -117,28 +120,47 @@ function compileShader(gl, type, source) {
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
const error = gl.getShaderInfoLog(shader) || 'Unknown shader compilation error';
const error =
gl.getShaderInfoLog(shader) || 'Unknown shader compilation error';
gl.deleteShader(shader);
throw new Error(error);
}
function greatCircleM(a, b) {
if (![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(Number.isFinite)) return Infinity;
if (
![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(
Number.isFinite,
)
)
return Infinity;
const latitudeA = Cesium.Math.toRadians(a.latitude);
const latitudeB = Cesium.Math.toRadians(b.latitude);
const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude);
const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude);
const haversine = Math.sin(latitudeDelta / 2) ** 2
+ Math.cos(latitudeA) * Math.cos(latitudeB) * Math.sin(longitudeDelta / 2) ** 2;
return 6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine));
const haversine =
Math.sin(latitudeDelta / 2) ** 2 +
Math.cos(latitudeA) *
Math.cos(latitudeB) *
Math.sin(longitudeDelta / 2) ** 2;
return (
6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine))
);
}
/** Return whether cockpit weather must refresh for elapsed time or movement. */
export function cockpitWeatherRefreshDue({ nowMs, fetchedAt, anchor, point, hasWeather }) {
export function cockpitWeatherRefreshDue({
nowMs,
fetchedAt,
anchor,
point,
hasWeather,
}) {
if (!hasWeather) return true;
if (!Number.isFinite(nowMs) || !Number.isFinite(fetchedAt)) return true;
return nowMs - fetchedAt >= WEATHER_REFRESH_MS
|| greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M;
return (
nowMs - fetchedAt >= WEATHER_REFRESH_MS ||
greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M
);
}
/** Returns the capped framebuffer size used by the cockpit cloud pass. */
@@ -192,18 +214,24 @@ export class CockpitCloudEffectsController {
this.pending = null;
this.abort = null;
this.destroyed = false;
this.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this.reducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
this.enabled = this.readEnabledPreference();
this.onResize = () => this.resize();
this.onCockpitMode = (event) => {
const active = event?.detail?.active ?? document.body.classList.contains('cockpit-mode');
const active =
event?.detail?.active ??
document.body.classList.contains('cockpit-mode');
if (active && this.enabled) this.start();
else this.stop();
};
this.onEnabledChange = (event) => {
const requested = event?.detail?.enabled;
this.setEnabled(typeof requested === 'boolean' ? requested : !this.enabled);
this.setEnabled(
typeof requested === 'boolean' ? requested : !this.enabled,
);
};
window.addEventListener('resize', this.onResize);
window.addEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
@@ -222,9 +250,11 @@ export class CockpitCloudEffectsController {
}
emitEnabledState() {
window.dispatchEvent(new CustomEvent('gev:cockpit-weather-state', {
detail: { enabled: this.enabled },
}));
window.dispatchEvent(
new CustomEvent('gev:cockpit-weather-state', {
detail: { enabled: this.enabled },
}),
);
}
setEnabled(enabled) {
@@ -232,7 +262,9 @@ export class CockpitCloudEffectsController {
this.enabled = next;
try {
localStorage.setItem(WEATHER_ENABLED_STORAGE_KEY, next ? '1' : '0');
} catch { /* best effort */ }
} catch {
/* best effort */
}
if (!next) {
this.stop();
} else if (document.body.classList.contains('cockpit-mode')) {
@@ -255,7 +287,11 @@ export class CockpitCloudEffectsController {
if (!gl) throw new Error('WebGL unavailable');
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
const fragmentShader = compileShader(
gl,
gl.FRAGMENT_SHADER,
FRAGMENT_SHADER,
);
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
@@ -263,15 +299,18 @@ export class CockpitCloudEffectsController {
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(program) || 'Cloud shader link failed');
throw new Error(
gl.getProgramInfoLog(program) || 'Cloud shader link failed',
);
}
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, -1, 1,
-1, 1, 1, -1, 1, 1,
]), gl.STATIC_DRAW);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW,
);
const position = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
@@ -293,7 +332,8 @@ export class CockpitCloudEffectsController {
resize() {
if (!this.gl) return;
const size = cockpitCloudRenderSize(window.innerWidth, window.innerHeight);
if (this.canvas.width === size.width && this.canvas.height === size.height) return;
if (this.canvas.width === size.width && this.canvas.height === size.height)
return;
this.canvas.width = size.width;
this.canvas.height = size.height;
this.gl.viewport(0, 0, size.width, size.height);
@@ -313,7 +353,13 @@ export class CockpitCloudEffectsController {
}
start() {
if (this.destroyed || !this.enabled || this.frame !== null || this.suspended) return;
if (
this.destroyed ||
!this.enabled ||
this.frame !== null ||
this.suspended
)
return;
if (!this.gl) {
this.initializeRenderer();
this.resize();
@@ -353,16 +399,21 @@ export class CockpitCloudEffectsController {
}
async refresh() {
if (this.destroyed || !this.enabled || this.pending || this.suspended
|| !document.body.classList.contains('cockpit-mode')) {
if (
this.destroyed ||
!this.enabled ||
this.pending ||
this.suspended ||
!document.body.classList.contains('cockpit-mode')
) {
return this.pending;
}
const point = this.cameraPoint();
if (!point) return null;
if (
this.weather
&& Date.now() - this.fetchedAt < WEATHER_REFRESH_MS
&& greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M
this.weather &&
Date.now() - this.fetchedAt < WEATHER_REFRESH_MS &&
greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M
) {
this.applyWeather(this.weather, point.altitudeM);
return this.weather;
@@ -370,11 +421,17 @@ export class CockpitCloudEffectsController {
this.abort?.abort();
this.abort = new AbortController();
const pending = this.weatherService.getConditions(point.latitude, point.longitude, { signal: this.abort.signal });
const pending = this.weatherService.getConditions(
point.latitude,
point.longitude,
{ signal: this.abort.signal },
);
const request = this.abort;
this.pending = pending.then(async (payload) => {
this.pending = pending
.then(async (payload) => {
if (this.abort !== request || request.signal.aborted) return null;
if (!payload?.weather) throw new Error('Cloud weather observation unavailable');
if (!payload?.weather)
throw new Error('Cloud weather observation unavailable');
this.weather = payload.weather;
this.fetchedAt = Date.now();
this.anchor = point;
@@ -391,7 +448,10 @@ export class CockpitCloudEffectsController {
return null;
})
.finally(() => {
if (this.abort === request) { this.pending = null; this.abort = null; }
if (this.abort === request) {
this.pending = null;
this.abort = null;
}
});
return this.pending;
}
@@ -399,12 +459,17 @@ export class CockpitCloudEffectsController {
applyWeather(weather, altitudeM) {
const profile = deriveWeatherEffectProfile(weather);
const altitude = weatherAltitudeFactors(altitudeM);
this.targetStrength = profile.available ? profile.cloud * altitude.cloud : 0;
this.targetStrength = profile.available
? profile.cloud * altitude.cloud
: 0;
this.windDirectionDeg = profile.windDirectionDeg || 0;
this.windStrength = profile.wind || 0;
this.canvas.dataset.cloudStrength = this.targetStrength.toFixed(3);
this.canvas.dataset.weatherCode = String(weather?.weatherCode ?? 'unknown');
if (this.reducedMotion && document.body.classList.contains('cockpit-mode')) {
if (
this.reducedMotion &&
document.body.classList.contains('cockpit-mode')
) {
this.strength = this.targetStrength;
const visible = this.strength > 0.035;
this.canvas.classList.toggle('active', visible);
@@ -414,7 +479,11 @@ export class CockpitCloudEffectsController {
}
tick(timeMs) {
if (this.destroyed || !this.enabled || !document.body.classList.contains('cockpit-mode')) {
if (
this.destroyed ||
!this.enabled ||
!document.body.classList.contains('cockpit-mode')
) {
this.stop();
return;
}
@@ -431,20 +500,27 @@ export class CockpitCloudEffectsController {
if (timeMs - this.lastRefreshCheckMs >= 1000) {
this.lastRefreshCheckMs = timeMs;
const point = this.cameraPoint();
if (point && cockpitWeatherRefreshDue({
nowMs: Date.now(),
fetchedAt: this.fetchedAt,
anchor: this.anchor,
point,
hasWeather: Boolean(this.weather),
})) void this.refresh();
if (
point &&
cockpitWeatherRefreshDue({
nowMs: Date.now(),
fetchedAt: this.fetchedAt,
anchor: this.anchor,
point,
hasWeather: Boolean(this.weather),
})
)
void this.refresh();
}
const blend = this.reducedMotion ? 1 : 0.08;
this.strength += (this.targetStrength - this.strength) * blend;
const visible = this.strength > 0.035;
this.canvas.classList.toggle('active', visible);
if (visible && (this.reducedMotion || timeMs - this.lastFrameMs >= CLOUD_FRAME_MS)) {
if (
visible &&
(this.reducedMotion || timeMs - this.lastFrameMs >= CLOUD_FRAME_MS)
) {
this.lastFrameMs = timeMs;
this.render(timeMs / 1000);
} else if (!visible) {
@@ -468,9 +544,16 @@ export class CockpitCloudEffectsController {
const windRadians = Cesium.Math.toRadians(this.windDirectionDeg);
const windScale = 0.008 + this.windStrength * 0.022;
gl.useProgram(this.program);
gl.uniform2f(this.locations.resolution, this.canvas.width, this.canvas.height);
gl.uniform2f(
this.locations.resolution,
this.canvas.width,
this.canvas.height,
);
gl.uniform1f(this.locations.time, timeSec);
gl.uniform1f(this.locations.strength, Math.min(1, Math.max(0, this.strength)));
gl.uniform1f(
this.locations.strength,
Math.min(1, Math.max(0, this.strength)),
);
gl.uniform2f(
this.locations.wind,
Math.sin(windRadians) * windScale,
@@ -517,7 +600,10 @@ export class CockpitCloudEffectsController {
this.stop();
window.removeEventListener('resize', this.onResize);
window.removeEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
window.removeEventListener('gev:cockpit-weather-toggle', this.onEnabledChange);
window.removeEventListener(
'gev:cockpit-weather-toggle',
this.onEnabledChange,
);
if (this.program && this.gl) this.gl.deleteProgram(this.program);
this.canvas.remove();
}
+32 -15
View File
@@ -8,7 +8,11 @@
*/
/** Force the prior aircraft layer to reacquire Cesium and durable tracking ownership. */
export function restoreAircraftTrackingOwner(layer, id, { origin = 'programmatic' } = {}) {
export function restoreAircraftTrackingOwner(
layer,
id,
{ origin = 'programmatic' } = {},
) {
if (!layer?.trackById || !id) return false;
layer.stopTracking?.({ origin });
return Boolean(layer.trackById(id, { origin }));
@@ -33,11 +37,13 @@ export function enterCockpitWithTracking({
rollbackTarget = undefined,
selectionOrigin = 'programmatic',
}) {
const currentTarget = aircraftTrackingTarget(cockpitView?.readAircraftInfo?.());
const restoreTarget = rollbackTarget === undefined ? currentTarget : rollbackTarget;
const key = (target) => target?.layerId && target?.id
? `${target.layerId}:${target.id}`
: null;
const currentTarget = aircraftTrackingTarget(
cockpitView?.readAircraftInfo?.(),
);
const restoreTarget =
rollbackTarget === undefined ? currentTarget : rollbackTarget;
const key = (target) =>
target?.layerId && target?.id ? `${target.layerId}:${target.id}` : null;
let activeLayer = currentLayer;
let activeTarget = currentTarget;
let entryError = null;
@@ -49,8 +55,14 @@ export function enterCockpitWithTracking({
if (selectingTarget) {
activeLayer = selectedLayer;
activeTarget = selectedTarget;
if (!selectedLayer.trackById?.(selectedTarget.id, { origin: selectionOrigin })) {
entryError = new Error('Selected aircraft could not be tracked for Cockpit entry');
if (
!selectedLayer.trackById?.(selectedTarget.id, {
origin: selectionOrigin,
})
) {
entryError = new Error(
'Selected aircraft could not be tracked for Cockpit entry',
);
}
}
if (!entryError) entered = Boolean(cockpitView.enter());
@@ -66,12 +78,15 @@ export function enterCockpitWithTracking({
// programmatic rollback would fix the camera while leaving the failed
// target durable in local/share state.
activeLayer?.stopTracking?.({ origin: selectionOrigin });
if (restoreTarget && !restoreAircraftTrackingOwner(
rollbackLayer,
restoreTarget.id,
{ origin: selectionOrigin },
)) {
entryError ||= new Error('Prior aircraft tracking could not be restored');
if (
restoreTarget &&
!restoreAircraftTrackingOwner(rollbackLayer, restoreTarget.id, {
origin: selectionOrigin,
})
) {
entryError ||= new Error(
'Prior aircraft tracking could not be restored',
);
}
} catch (error) {
entryError ||= error instanceof Error ? error : new Error(String(error));
@@ -87,6 +102,8 @@ export function enterCockpitWithTracking({
return {
entered,
error: entered ? null : entryError?.message || 'Cockpit entry was unavailable',
error: entered
? null
: entryError?.message || 'Cockpit entry was unavailable',
};
}
+15 -4
View File
@@ -75,11 +75,16 @@ export function contactsDetectionEnterPlan(current, restore) {
* @returns {?{mode: string, densityPct: ?number}} State to replay, or null when
* there is nothing to do.
*/
export function contactsDetectionExitPlan(restore, current, styleOwnsDetection = false) {
export function contactsDetectionExitPlan(
restore,
current,
styleOwnsDetection = false,
) {
if (!restore || styleOwnsDetection) return null;
const saved = normalizeDetectionState(restore);
const now = normalizeDetectionState(current);
if (saved.mode === now.mode && saved.densityPct === now.densityPct) return null;
if (saved.mode === now.mode && saved.densityPct === now.densityPct)
return null;
return saved;
}
@@ -119,7 +124,11 @@ export function applyContactsDetection({
if (plan.turnOn) applyPreset();
return { restore: plan.restore, changed: plan.turnOn };
}
const plan = contactsDetectionExitPlan(restore, getState(), styleOwnsDetection);
const plan = contactsDetectionExitPlan(
restore,
getState(),
styleOwnsDetection,
);
if (plan) restoreState(plan);
return { restore: null, changed: Boolean(plan) };
}
@@ -145,7 +154,9 @@ export function shareableDetectionState({ owned, liveMode, liveDensityPct }) {
if (!owned) return { mode: liveMode, densityPct: liveDensityPct };
return {
mode: owned.mode ?? liveMode,
densityPct: Number.isFinite(owned.densityPct) ? owned.densityPct : liveDensityPct,
densityPct: Number.isFinite(owned.densityPct)
? owned.densityPct
: liveDensityPct,
};
}
+125 -72
View File
@@ -1,5 +1,11 @@
const CONTEXT_DEPENDENCIES = Object.freeze({
flights: new Set(['military-awareness', 'flights', 'military', 'ais-live-vessels', 'military-installations']),
flights: new Set([
'military-awareness',
'flights',
'military',
'ais-live-vessels',
'military-installations',
]),
'space-missions': new Set(['rocket-launches', 'satellites']),
});
const CONTEXT_COMPANIONS = new Set(['radio']);
@@ -11,11 +17,13 @@ export function isExplicitUserIntentOrigin(origin, layerId = null) {
/** Preserve explicit layer intent that completes while a stale restore is queued. */
export function recordContextRestoreExplicitChange({ restoreState, change }) {
if (
!restoreState?.enabledLayerIds
|| change?.type !== 'visibility'
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
) return false;
if (!restoreState.explicitLayerStates) restoreState.explicitLayerStates = new Map();
!restoreState?.enabledLayerIds ||
change?.type !== 'visibility' ||
!isExplicitUserIntentOrigin(change.origin, change.layerId)
)
return false;
if (!restoreState.explicitLayerStates)
restoreState.explicitLayerStates = new Map();
restoreState.explicitLayerStates.set(change.layerId, Boolean(change.enabled));
if (change.enabled) restoreState.enabledLayerIds.add(change.layerId);
else restoreState.enabledLayerIds.delete(change.layerId);
@@ -23,30 +31,41 @@ export function recordContextRestoreExplicitChange({ restoreState, change }) {
}
/** Settle every explicit Context-restore replay without losing semantic failures. */
export async function settleContextIntentReplay({ restoreState, setEnabled, notificationToken = null }) {
export async function settleContextIntentReplay({
restoreState,
setEnabled,
notificationToken = null,
}) {
if (restoreState?.cancelled) return null;
const entries = [...(restoreState?.explicitLayerStates || [])];
const results = await Promise.allSettled(entries.map(([layerId, enabled]) => (
setEnabled(layerId, enabled, {
origin: 'context-intent-replay',
...(notificationToken ? { notificationToken } : {}),
})
)));
const failedIndexes = results.flatMap((result, index) => (
result.status === 'rejected' || result.value === false ? [index] : []
));
const results = await Promise.allSettled(
entries.map(([layerId, enabled]) =>
setEnabled(layerId, enabled, {
origin: 'context-intent-replay',
...(notificationToken ? { notificationToken } : {}),
}),
),
);
const failedIndexes = results.flatMap((result, index) =>
result.status === 'rejected' || result.value === false ? [index] : [],
);
if (failedIndexes.length === 0) return null;
const failedLayerIds = failedIndexes.map((index) => entries[index][0]);
const rejected = failedIndexes
.map((index) => results[index])
.find((result) => result.status === 'rejected');
const error = rejected?.reason instanceof Error
? rejected.reason
: new Error(`Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`);
error.failedLayerIds = [...new Set([
...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []),
...failedLayerIds,
])];
const error =
rejected?.reason instanceof Error
? rejected.reason
: new Error(
`Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`,
);
error.failedLayerIds = [
...new Set([
...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []),
...failedLayerIds,
]),
];
return error;
}
@@ -54,18 +73,29 @@ export async function settleContextIntentReplay({ restoreState, setEnabled, noti
export function mergeContextTransitionErrors(primaryError, secondaryError) {
if (!primaryError) return secondaryError || null;
if (!secondaryError) return primaryError;
primaryError.failedLayerIds = [...new Set([
...(Array.isArray(primaryError.failedLayerIds) ? primaryError.failedLayerIds : []),
...(Array.isArray(secondaryError.failedLayerIds) ? secondaryError.failedLayerIds : []),
])];
primaryError.failedLayerIds = [
...new Set([
...(Array.isArray(primaryError.failedLayerIds)
? primaryError.failedLayerIds
: []),
...(Array.isArray(secondaryError.failedLayerIds)
? secondaryError.failedLayerIds
: []),
]),
];
return primaryError;
}
/** Settle a user-facing Context action and convert every failure form to false. */
export async function settleUserFacingContextAction({ operation, onFailure, falseIsFailure = true }) {
export async function settleUserFacingContextAction({
operation,
onFailure,
falseIsFailure = true,
}) {
try {
const result = await operation();
if (falseIsFailure && result === false) throw new Error('Context transition did not complete');
if (falseIsFailure && result === false)
throw new Error('Context transition did not complete');
return result;
} catch (error) {
try {
@@ -83,7 +113,10 @@ export async function settleUserFacingContextAction({ operation, onFailure, fals
* one into `userAdded` makes exit restoration re-enable the mode layer the
* user just turned off.
*/
export const CONTEXT_ENTRY_LAYER_IDS = Object.freeze(['military-awareness', 'rocket-launches']);
export const CONTEXT_ENTRY_LAYER_IDS = Object.freeze([
'military-awareness',
'rocket-launches',
]);
/**
* Leave a Context transaction: publish the settled coordination flag, then
@@ -108,7 +141,8 @@ export function settleContextModeChange(owner, changing = false) {
if (!owner) return;
const wasChanging = Boolean(owner._contextModeChanging);
owner._contextModeChanging = Boolean(changing);
if (wasChanging && !owner._contextModeChanging) owner._syncContextModeButtons?.();
if (wasChanging && !owner._contextModeChanging)
owner._syncContextModeButtons?.();
}
/**
@@ -142,19 +176,24 @@ export async function runWithContextModeChanging(owner, operation) {
* @param {object|null} input.change DataLayerManager visibility notification.
* @returns {boolean} Whether the active Context bundle should be disabled.
*/
export function shouldExitContextForLayerChange({ contextMode, globalContextEnabled, change }) {
export function shouldExitContextForLayerChange({
contextMode,
globalContextEnabled,
change,
}) {
if (
change?.type !== 'visibility'
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
|| change.layerId === 'military-awareness'
|| CONTEXT_COMPANIONS.has(change.layerId)
change?.type !== 'visibility' ||
!isExplicitUserIntentOrigin(change.origin, change.layerId) ||
change.layerId === 'military-awareness' ||
CONTEXT_COMPANIONS.has(change.layerId)
) {
return false;
}
const active = Boolean(globalContextEnabled || contextMode);
if (!active) return false;
const dependencies = CONTEXT_DEPENDENCIES[contextMode] || new Set(['military-awareness']);
const dependencies =
CONTEXT_DEPENDENCIES[contextMode] || new Set(['military-awareness']);
if (change.enabled) return false;
return Boolean(contextMode && dependencies.has(change.layerId));
}
@@ -181,10 +220,10 @@ export function contextAllowedLayerIds(contextMode) {
*/
export function shouldCaptureContextSession(change) {
return Boolean(
['visibility-requested', 'visibility-will-change'].includes(change?.type)
&& isExplicitUserIntentOrigin(change.origin, change.layerId)
&& change.enabled
&& CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId)
['visibility-requested', 'visibility-will-change'].includes(change?.type) &&
isExplicitUserIntentOrigin(change.origin, change.layerId) &&
change.enabled &&
CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId),
);
}
@@ -194,12 +233,12 @@ export function shouldCaptureContextSession(change) {
*/
export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
return Boolean(
clearInFlight
&& change?.type === 'visibility-requested'
&& change.layerId === 'rocket-launches'
&& change.enabled === true
&& isExplicitUserIntentOrigin(change.origin, change.layerId)
&& Number.isInteger(change.intentEpoch)
clearInFlight &&
change?.type === 'visibility-requested' &&
change.layerId === 'rocket-launches' &&
change.enabled === true &&
isExplicitUserIntentOrigin(change.origin, change.layerId) &&
Number.isInteger(change.intentEpoch),
);
}
@@ -218,11 +257,15 @@ export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
* @param {string|null} [input.effectiveContextMode] Committed or entering mode.
* @returns {boolean} Whether the snapshot's userAdded set was modified.
*/
export function recordContextSessionUserChange({ snapshot, change, effectiveContextMode = null }) {
export function recordContextSessionUserChange({
snapshot,
change,
effectiveContextMode = null,
}) {
if (
!snapshot?.userAdded
|| change?.type !== 'visibility'
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
!snapshot?.userAdded ||
change?.type !== 'visibility' ||
!isExplicitUserIntentOrigin(change.origin, change.layerId)
) {
return false;
}
@@ -233,7 +276,11 @@ export function recordContextSessionUserChange({ snapshot, change, effectiveCont
snapshot.userAdded.add(change.layerId);
return true;
}
if (contextAllowedLayerIds(effectiveContextMode).has(change.layerId) && !isCompanion) return false;
if (
contextAllowedLayerIds(effectiveContextMode).has(change.layerId) &&
!isCompanion
)
return false;
snapshot.userAdded.add(change.layerId);
if (isCompanion) snapshot.userRemoved?.delete(change.layerId);
return true;
@@ -276,11 +323,15 @@ export function contextSnapshotLayerIds(
* @param {string|null} [input.layerName] User-facing layer name.
* @returns {string|null} Honest user-facing refusal reason, or null when allowed.
*/
export function contextLayerEnableBlockReason({ contextMode, change, layerName = null }) {
export function contextLayerEnableBlockReason({
contextMode,
change,
layerName = null,
}) {
if (
contextMode !== 'space-missions'
|| change?.enabled !== true
|| contextAllowedLayerIds('space-missions').has(change.layerId)
contextMode !== 'space-missions' ||
change?.enabled !== true ||
contextAllowedLayerIds('space-missions').has(change.layerId)
) {
return null;
}
@@ -297,20 +348,20 @@ export function contextLayerEnableBlockReason({ contextMode, change, layerName =
* @param {object|null} input.change Manager visibility notification.
* @returns {'ignore'|'replacement'|'restore'} Cancellation disposition.
*/
export function spaceMissionEntryCancellationDisposition({
change,
}) {
export function spaceMissionEntryCancellationDisposition({ change }) {
if (
change?.type !== 'visibility-cancelled'
|| change.layerId !== 'rocket-launches'
|| change.enabled !== true
) return 'ignore';
change?.type !== 'visibility-cancelled' ||
change.layerId !== 'rocket-launches' ||
change.enabled !== true
)
return 'ignore';
if (
change.cancellationReason === 'superseded'
&& Number.isInteger(change.successorIntentEpoch)
&& change.successorIntentEpoch > change.intentEpoch
&& change.successorEnabled === true
) return 'replacement';
change.cancellationReason === 'superseded' &&
Number.isInteger(change.successorIntentEpoch) &&
change.successorIntentEpoch > change.intentEpoch &&
change.successorEnabled === true
)
return 'replacement';
return 'restore';
}
@@ -348,10 +399,12 @@ export function cockpitEntryAllowed({
flightsEnabled,
militaryEnabled,
}) {
return contextMode === 'flights'
&& !contextModeChanging
&& Boolean(flightsEnabled)
&& Boolean(militaryEnabled);
return (
contextMode === 'flights' &&
!contextModeChanging &&
Boolean(flightsEnabled) &&
Boolean(militaryEnabled)
);
}
/**
+6 -2
View File
@@ -3,7 +3,9 @@
* Cesium retains ownership of showing and hiding its required credit lightbox.
*/
export function configureCreditKeyboardAccess(root = document) {
const expand = root?.querySelector?.('#cesium-credits .cesium-credit-expand-link');
const expand = root?.querySelector?.(
'#cesium-credits .cesium-credit-expand-link',
);
const lightbox = root?.querySelector?.('.cesium-credit-lightbox');
const close = lightbox?.querySelector?.('.cesium-credit-lightbox-close');
if (!expand || !lightbox || !close) return false;
@@ -44,7 +46,9 @@ export function configureCreditKeyboardAccess(root = document) {
// A long Space hold may hand control to push-to-talk and move focus away.
// Cancelling the armed release prevents that eventual keyup from also
// activating the attribution control.
control.addEventListener('blur', () => { spacePressed = false; });
control.addEventListener('blur', () => {
spacePressed = false;
});
};
const closeAndRestoreFocus = () => {
expand.setAttribute('aria-expanded', 'false');
+414 -67
View File
@@ -8,90 +8,415 @@
*/
const HELI = new Set([
'EC20', 'EC25', 'EC30', 'EC35', 'EC45', 'EC55', 'AS50', 'AS55', 'AS65', 'AS32',
'A109', 'A119', 'A139', 'A169', 'A189', 'B06', 'B06T', 'B407', 'B412', 'B427',
'B429', 'B430', 'B505', 'S76', 'S92', 'S61', 'S64', 'H60', 'H500', 'MD52',
'MD60', 'R22', 'R44', 'R66', 'EXEC', 'EXPL', 'GAZL', 'LYNX', 'NH90', 'PUMA',
'SCAV', 'UH1', 'B105', 'B212', 'B214', 'B222', 'AC', 'H47', 'H64',
'EC20',
'EC25',
'EC30',
'EC35',
'EC45',
'EC55',
'AS50',
'AS55',
'AS65',
'AS32',
'A109',
'A119',
'A139',
'A169',
'A189',
'B06',
'B06T',
'B407',
'B412',
'B427',
'B429',
'B430',
'B505',
'S76',
'S92',
'S61',
'S64',
'H60',
'H500',
'MD52',
'MD60',
'R22',
'R44',
'R66',
'EXEC',
'EXPL',
'GAZL',
'LYNX',
'NH90',
'PUMA',
'SCAV',
'UH1',
'B105',
'B212',
'B214',
'B222',
'AC',
'H47',
'H64',
]);
const QUAD = new Set([
'B741', 'B742', 'B743', 'B744', 'B748', 'B74S', 'B74R', 'B74D', 'A388', 'A342',
'A343', 'A345', 'A346', 'A124', 'C5M', 'A225', 'IL96', 'B52', 'A140',
'B741',
'B742',
'B743',
'B744',
'B748',
'B74S',
'B74R',
'B74D',
'A388',
'A342',
'A343',
'A345',
'A346',
'A124',
'C5M',
'A225',
'IL96',
'B52',
'A140',
]);
const WIDE = new Set([
'A306', 'A30B', 'A310', 'A332', 'A333', 'A338', 'A339', 'A359', 'A35K', 'B762',
'B763', 'B764', 'B772', 'B77L', 'B773', 'B77W', 'B778', 'B779', 'B788', 'B789',
'B78X', 'MD11', 'IL86', 'DC10', 'L101', 'A337', 'B767', 'B777', 'B787',
'C17', 'K35R', // military heavies that read as widebodies
'A306',
'A30B',
'A310',
'A332',
'A333',
'A338',
'A339',
'A359',
'A35K',
'B762',
'B763',
'B764',
'B772',
'B77L',
'B773',
'B77W',
'B778',
'B779',
'B788',
'B789',
'B78X',
'MD11',
'IL86',
'DC10',
'L101',
'A337',
'B767',
'B777',
'B787',
'C17',
'K35R', // military heavies that read as widebodies
]);
const TPROP = new Set([
'DH8A', 'DH8B', 'DH8C', 'DH8D', 'AT43', 'AT44', 'AT45', 'AT46', 'AT72', 'AT73',
'AT75', 'AT76', 'SF34', 'SB20', 'SW3', 'SW4', 'E110', 'E120', 'C208', 'C212',
'C408', 'PC12', 'B190', 'BE20', 'B350', 'B300', 'JS31', 'JS32', 'JS41', 'D228',
'D328', 'F50', 'F27', 'ATP', 'TBM7', 'TBM8', 'TBM9', 'TBM0', 'PC6', 'C441',
'C425', 'DHC6', 'DHC7', 'C130', 'AN12', 'AN26', 'AN32', 'SH36', 'CVLT', 'SAAB',
'DH8A',
'DH8B',
'DH8C',
'DH8D',
'AT43',
'AT44',
'AT45',
'AT46',
'AT72',
'AT73',
'AT75',
'AT76',
'SF34',
'SB20',
'SW3',
'SW4',
'E110',
'E120',
'C208',
'C212',
'C408',
'PC12',
'B190',
'BE20',
'B350',
'B300',
'JS31',
'JS32',
'JS41',
'D228',
'D328',
'F50',
'F27',
'ATP',
'TBM7',
'TBM8',
'TBM9',
'TBM0',
'PC6',
'C441',
'C425',
'DHC6',
'DHC7',
'C130',
'AN12',
'AN26',
'AN32',
'SH36',
'CVLT',
'SAAB',
'A400',
]);
const GLIDER = new Set([
'DISC', 'DUOD', 'VENT', 'NIMB', 'NIM3', 'NIM4', 'JANS', 'ARCE', 'DG40', 'DG80',
'DG1T', 'DG30', 'DG50', 'LS3', 'LS4', 'LS6', 'LS7', 'LS8', 'STD3', 'G103',
'G102', 'G104', 'PW5', 'PW6', 'L13', 'L23', 'L33', 'PIK', 'PEGA', 'KEST',
'TWIN', 'AS33', 'ASW', 'ASG', 'ASK', 'VENS', 'GLID', 'MOSQ', 'DIMO',
'DISC',
'DUOD',
'VENT',
'NIMB',
'NIM3',
'NIM4',
'JANS',
'ARCE',
'DG40',
'DG80',
'DG1T',
'DG30',
'DG50',
'LS3',
'LS4',
'LS6',
'LS7',
'LS8',
'STD3',
'G103',
'G102',
'G104',
'PW5',
'PW6',
'L13',
'L23',
'L33',
'PIK',
'PEGA',
'KEST',
'TWIN',
'AS33',
'ASW',
'ASG',
'ASK',
'VENS',
'GLID',
'MOSQ',
'DIMO',
]);
const LIGHT = new Set([
'C150', 'C152', 'C162', 'C172', 'C72R', 'C175', 'C177', 'C180', 'C182', 'C185',
'C188', 'C206', 'C207', 'C210', 'C310', 'C337', 'SR20', 'SR22', 'S22T', 'PA18',
'PA24', 'PA28', 'P28A', 'P28B', 'P28R', 'PA32', 'P32R', 'PA34', 'PA38', 'PA44',
'PA46', 'DA20', 'DA40', 'DA42', 'DA62', 'BE33', 'BE35', 'BE36', 'BE58', 'BE76',
'BE19', 'BE23', 'BE24', 'M20P', 'M20T', 'AA1', 'AA5', 'GLAS', 'COL4', 'RV4',
'RV6', 'RV7', 'RV8', 'RV9', 'RV10', 'RV14', 'GA8', 'G115', 'BL8', 'CH7',
'C150',
'C152',
'C162',
'C172',
'C72R',
'C175',
'C177',
'C180',
'C182',
'C185',
'C188',
'C206',
'C207',
'C210',
'C310',
'C337',
'SR20',
'SR22',
'S22T',
'PA18',
'PA24',
'PA28',
'P28A',
'P28B',
'P28R',
'PA32',
'P32R',
'PA34',
'PA38',
'PA44',
'PA46',
'DA20',
'DA40',
'DA42',
'DA62',
'BE33',
'BE35',
'BE36',
'BE58',
'BE76',
'BE19',
'BE23',
'BE24',
'M20P',
'M20T',
'AA1',
'AA5',
'GLAS',
'COL4',
'RV4',
'RV6',
'RV7',
'RV8',
'RV9',
'RV10',
'RV14',
'GA8',
'G115',
'BL8',
'CH7',
]);
// GEV addition (2026-08-15 Hangar fleet): business jets — distinct class so the
// Citation II GLB + a slimmer glyph read apart from airliners.
const BIZJET = new Set([
'C500', 'C501', 'C510', 'C525', 'C25A', 'C25B', 'C25C', 'C25M', 'C550',
'C551', 'C560', 'C56X', 'C650', 'C680', 'C68A', 'C700', 'C750',
'CL30', 'CL35', 'CL60', 'GLF2', 'GLF3', 'GLF4', 'GLF5', 'GLF6', 'GA5C',
'GA6C', 'G150', 'G280', 'GL5T', 'GL7T', 'GLEX',
'LJ23', 'LJ24', 'LJ25', 'LJ31', 'LJ35', 'LJ40', 'LJ45', 'LJ55', 'LJ60',
'LJ70', 'LJ75', 'FA10', 'FA20', 'FA50', 'FA7X', 'FA8X', 'F900', 'F2TH',
'H25A', 'H25B', 'H25C', 'HDJT', 'E50P', 'E55P', 'E545', 'E550', 'PC24',
'PRM1', 'BE40', 'ASTR', 'WW24',
'C500',
'C501',
'C510',
'C525',
'C25A',
'C25B',
'C25C',
'C25M',
'C550',
'C551',
'C560',
'C56X',
'C650',
'C680',
'C68A',
'C700',
'C750',
'CL30',
'CL35',
'CL60',
'GLF2',
'GLF3',
'GLF4',
'GLF5',
'GLF6',
'GA5C',
'GA6C',
'G150',
'G280',
'GL5T',
'GL7T',
'GLEX',
'LJ23',
'LJ24',
'LJ25',
'LJ31',
'LJ35',
'LJ40',
'LJ45',
'LJ55',
'LJ60',
'LJ70',
'LJ75',
'FA10',
'FA20',
'FA50',
'FA7X',
'FA8X',
'F900',
'F2TH',
'H25A',
'H25B',
'H25C',
'HDJT',
'E50P',
'E55P',
'E545',
'E550',
'PC24',
'PRM1',
'BE40',
'ASTR',
'WW24',
'SF50', // Cirrus Vision Jet — a JET (was mis-set in LIGHT before the bizjet class existed)
]);
// GEV addition (2026-08-15 Hangar fleet): large UAVs — ICAO designators seen on
// ADS-B/adsb.lol for Predator/Reaper/Global Hawk-class airframes.
const UAV = new Set([
'Q1', 'Q4', 'Q9', 'MQ1', 'MQ4', 'MQ9', 'RQ4', 'TB2', 'SHDW', 'HERN',
'Q1',
'Q4',
'Q9',
'MQ1',
'MQ4',
'MQ9',
'RQ4',
'TB2',
'SHDW',
'HERN',
]);
// GEV addition: fast jets, for the military layer's adsb.lol `t` codes.
const FASTJET = new Set([
'F16', 'F15', 'F18', 'FA18', 'F14', 'F22', 'F35', 'F4', 'F5', 'A10', 'AV8B',
'TYPH', 'EUFI', 'RFAL', 'RAFL', 'GRIP', 'JAS39', 'TOR', 'MIR2', 'M2000',
'SU27', 'SU30', 'SU33', 'SU34', 'SU35', 'SU57', 'MG29', 'MIG29', 'MG31', 'J20',
'T38', 'HAWK', 'L39', 'M346', 'T7A',
'F16',
'F15',
'F18',
'FA18',
'F14',
'F22',
'F35',
'F4',
'F5',
'A10',
'AV8B',
'TYPH',
'EUFI',
'RFAL',
'RAFL',
'GRIP',
'JAS39',
'TOR',
'MIR2',
'M2000',
'SU27',
'SU30',
'SU33',
'SU34',
'SU35',
'SU57',
'MG29',
'MIG29',
'MG31',
'J20',
'T38',
'HAWK',
'L39',
'M346',
'T7A',
]);
/** OpenSky extended-states integer category → class. */
const OPENSKY_CATEGORY = {
2: 'light', // Light (< 15 500 lbs)
3: 'airliner', // Small
4: 'airliner', // Large
5: 'airliner', // High-vortex large (B757)
6: 'widebody', // Heavy
7: 'fastjet', // High performance (>5g, >400 kt)
2: 'light', // Light (< 15 500 lbs)
3: 'airliner', // Small
4: 'airliner', // Large
5: 'airliner', // High-vortex large (B757)
6: 'widebody', // Heavy
7: 'fastjet', // High performance (>5g, >400 kt)
8: 'helicopter', // Rotorcraft
9: 'glider',
};
/** ADS-B emitter-category string → class. */
const EMITTER_CATEGORY = {
A1: 'light', A2: 'light', A3: 'airliner', A4: 'airliner',
A5: 'widebody', A6: 'fastjet', A7: 'helicopter', B1: 'glider',
A1: 'light',
A2: 'light',
A3: 'airliner',
A4: 'airliner',
A5: 'widebody',
A6: 'fastjet',
A7: 'helicopter',
B1: 'glider',
};
export function classifyAircraft({ typeCode, category } = {}) {
const code = String(typeCode || '').trim().toUpperCase();
const code = String(typeCode || '')
.trim()
.toUpperCase();
if (code) {
if (FASTJET.has(code)) return 'fastjet';
if (UAV.has(code)) return 'uav';
@@ -104,37 +429,59 @@ export function classifyAircraft({ typeCode, category } = {}) {
if (LIGHT.has(code)) return 'light';
return 'airliner'; // known type code, not in a special set → default jet
}
if (Number.isFinite(category) && OPENSKY_CATEGORY[category]) return OPENSKY_CATEGORY[category];
const cat = String(category || '').trim().toUpperCase();
if (Number.isFinite(category) && OPENSKY_CATEGORY[category])
return OPENSKY_CATEGORY[category];
const cat = String(category || '')
.trim()
.toUpperCase();
if (EMITTER_CATEGORY[cat]) return EMITTER_CATEGORY[cat];
return 'airliner';
}
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
export const CLASS_SCALE_2D = {
light: 0.62, glider: 0.58, turboprop: 0.86, airliner: 1.0,
widebody: 1.3, quadjet: 1.45, helicopter: 0.82, fastjet: 0.8,
bizjet: 0.72, uav: 0.75,
light: 0.62,
glider: 0.58,
turboprop: 0.86,
airliner: 1.0,
widebody: 1.3,
quadjet: 1.45,
helicopter: 0.82,
fastjet: 0.8,
bizjet: 0.72,
uav: 0.75,
};
/** 3D model scale multipliers — clamped [0.75, 1.45] while every class shares
* the single airplane.glb (a jet mesh at C172 scale reads wrong). Widen when
* real per-class models land in CLASS_MODEL_URL. */
export const CLASS_SCALE_3D = {
light: 0.75, glider: 0.75, turboprop: 0.85, airliner: 1.0,
widebody: 1.3, quadjet: 1.45, helicopter: 0.8, fastjet: 0.8,
bizjet: 0.8, uav: 0.8,
light: 0.75,
glider: 0.75,
turboprop: 0.85,
airliner: 1.0,
widebody: 1.3,
quadjet: 1.45,
helicopter: 0.8,
fastjet: 0.8,
bizjet: 0.8,
uav: 0.8,
};
/** Per-class glTF — all the shared airplane today; drop real assets in here.
* NOTE for future models: each asset may need its own heading offset (the
* shared GLB uses MODEL_HEADING_OFFSET_DEG = 180 in both layers). */
export const CLASS_MODEL_URL = {
light: '/models/airplane.glb', glider: '/models/airplane.glb',
turboprop: '/models/airplane.glb', airliner: '/models/airplane.glb',
widebody: '/models/airplane.glb', quadjet: '/models/airplane.glb',
helicopter: '/models/airplane.glb', fastjet: '/models/airplane.glb',
bizjet: '/models/airplane.glb', uav: '/models/airplane.glb',
light: '/models/airplane.glb',
glider: '/models/airplane.glb',
turboprop: '/models/airplane.glb',
airliner: '/models/airplane.glb',
widebody: '/models/airplane.glb',
quadjet: '/models/airplane.glb',
helicopter: '/models/airplane.glb',
fastjet: '/models/airplane.glb',
bizjet: '/models/airplane.glb',
uav: '/models/airplane.glb',
};
/** Real per-class GLBs (2026-08-15 Hangar fleet, owner picks; CC-BY 4.0 —
@@ -148,10 +495,10 @@ export const CLASS_MODEL_URL = {
* pinned by modelScale.test.mjs. Classes NOT listed (airliner, quadjet,
* glider, fastjet) still render the shared airplane.glb via CLASS_MODEL_URL. */
export const CLASS_MODEL_REAL = {
helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 },
light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 },
bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 },
uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 },
widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 },
turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 },
helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 },
light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 },
bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 },
uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 },
widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 },
turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 },
};
+7 -3
View File
@@ -35,9 +35,11 @@ const VIEW = 96;
const C = VIEW / 2; // 48 — glyph centre
// Hairline dark edge (scaled for the 96 box: ~1.5u ≈ the old 0.5u at 32).
const STROKE = 'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"';
const STROKE =
'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"';
// Heavier edge for the chart-symbol ("bold") glyphs adopted 2026-08-15.
const STROKE_BOLD = 'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"';
const STROKE_BOLD =
'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"';
// Softer white for translucent detail (prop discs, rotor disc) — still white so
// the tint multiplies cleanly; only the alpha differs.
const DISC = 'fill="white" fill-opacity="0.5"';
@@ -239,7 +241,9 @@ const BODIES = {
const _iconCache = new Map();
const _b64 = (s) =>
typeof btoa === 'function' ? btoa(s) : Buffer.from(s, 'utf8').toString('base64');
typeof btoa === 'function'
? btoa(s)
: Buffer.from(s, 'utf8').toString('base64');
/** Fleet raster: billboards render at ~40–58 DEVICE px (width 20–24 CSS ×
* Retina × class scale). Cesium's billboard atlas has no mipmaps, so a big
+3 -1
View File
@@ -11,5 +11,7 @@ export function aircraftIncludedInNearby({
modelRendering = false,
includeHidden = false,
} = {}) {
return Boolean(includeHidden || isTracked || billboardShown || modelRendering);
return Boolean(
includeHidden || isTracked || billboardShown || modelRendering,
);
}
+50 -19
View File
@@ -10,7 +10,7 @@ export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
startLimbRatio: 0.5,
scaleFloor: 0.45,
alphaFloor: 0.35,
combinedAlphaFloor: 0.20,
combinedAlphaFloor: 0.2,
globeViewBlendStartM: 3_500_000,
globeViewBlendEndM: 4_500_000,
earthRadiusM: 6_378_137,
@@ -36,10 +36,19 @@ function resolvedParams(overrides) {
return { ..._params, ...overrides };
}
function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning, result) {
if (!Number.isFinite(cameraDistanceM) || cameraDistanceM < 0
|| !Number.isFinite(cameraHeightM) || cameraHeightM <= 0
|| cameraHeightM >= tuning.globeViewBlendEndM) {
function aircraftRecessionFactorsResolved(
cameraDistanceM,
cameraHeightM,
tuning,
result,
) {
if (
!Number.isFinite(cameraDistanceM) ||
cameraDistanceM < 0 ||
!Number.isFinite(cameraHeightM) ||
cameraHeightM <= 0 ||
cameraHeightM >= tuning.globeViewBlendEndM
) {
result.scale = 1;
result.alpha = 1;
result.limbRatio = null;
@@ -60,11 +69,13 @@ function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning
return result;
}
const rawT = (limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio);
const rawT =
(limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio);
const t = clamp(rawT, 0, 1);
const limbEase = t * t * (3 - 2 * t); // smoothstep: zero slope at both band edges
const globeRawT = (cameraHeightM - tuning.globeViewBlendStartM)
/ (tuning.globeViewBlendEndM - tuning.globeViewBlendStartM);
const globeRawT =
(cameraHeightM - tuning.globeViewBlendStartM) /
(tuning.globeViewBlendEndM - tuning.globeViewBlendStartM);
const globeT = clamp(globeRawT, 0, 1);
const globeEase = globeT * globeT * (3 - 2 * globeT);
const strength = 1 - globeEase;
@@ -84,8 +95,10 @@ export function setAircraftRecessionParams(patch = {}) {
if (Number.isFinite(patch.startLimbRatio)) {
next.startLimbRatio = clamp(patch.startLimbRatio, 0, 0.99);
}
if (Number.isFinite(patch.scaleFloor)) next.scaleFloor = clamp(patch.scaleFloor, 0.05, 1);
if (Number.isFinite(patch.alphaFloor)) next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1);
if (Number.isFinite(patch.scaleFloor))
next.scaleFloor = clamp(patch.scaleFloor, 0.05, 1);
if (Number.isFinite(patch.alphaFloor))
next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1);
if (Number.isFinite(patch.combinedAlphaFloor)) {
next.combinedAlphaFloor = clamp(patch.combinedAlphaFloor, 0.05, 1);
}
@@ -109,8 +122,10 @@ export function setAircraftRecessionParams(patch = {}) {
if (next.globeViewBlendStartM >= next.globeViewBlendEndM) {
next.globeViewBlendStartM = Math.max(1, next.globeViewBlendEndM - 1);
}
if (Number.isFinite(patch.earthRadiusM)) next.earthRadiusM = Math.max(1, patch.earthRadiusM);
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon);
if (Number.isFinite(patch.earthRadiusM))
next.earthRadiusM = Math.max(1, patch.earthRadiusM);
if (Number.isFinite(patch.writeEpsilon))
next.writeEpsilon = Math.max(0, patch.writeEpsilon);
_params = next;
return { ..._params };
}
@@ -149,7 +164,10 @@ export function cameraLimbDistanceM(
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [params]
* @returns {{scale:number,alpha:number,limbRatio:number|null}}
*/
export function aircraftRecessionFactors({ cameraDistanceM, cameraHeightM }, params) {
export function aircraftRecessionFactors(
{ cameraDistanceM, cameraHeightM },
params,
) {
const tuning = resolvedParams(params);
return aircraftRecessionFactorsResolved(
cameraDistanceM,
@@ -202,12 +220,17 @@ export function applyAircraftBillboardTreatment({
const alpha = baseAlpha * composedTreatment;
let scaleWrites = 0;
let alphaWrites = 0;
if (!Number.isFinite(billboard.scale) || Math.abs(billboard.scale - scale) > tuning.writeEpsilon) {
if (
!Number.isFinite(billboard.scale) ||
Math.abs(billboard.scale - scale) > tuning.writeEpsilon
) {
billboard.scale = scale;
scaleWrites = 1;
}
if (!Number.isFinite(billboard.color?.alpha)
|| Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon) {
if (
!Number.isFinite(billboard.color?.alpha) ||
Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon
) {
billboard.color = baseColor.withAlpha(alpha);
alphaWrites = 1;
}
@@ -228,11 +251,19 @@ export function applyAircraftBillboardTreatment({
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
* @returns {number} One when a color write occurred, otherwise zero.
*/
export function applyAircraftModelTreatment({ model, baseColor, alpha, params }) {
export function applyAircraftModelTreatment({
model,
baseColor,
alpha,
params,
}) {
const tuning = resolvedParams(params);
if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
if (Number.isFinite(model.color?.alpha)
&& Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon) return 0;
if (
Number.isFinite(model.color?.alpha) &&
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon
)
return 0;
model.color = baseColor.withAlpha(alpha);
return 1;
}
+110 -40
View File
@@ -28,11 +28,39 @@ import { pointInRing } from './naturalEarthRegions.js';
/** Layers the engine understands, with the fields queries may reference. */
export const ANALYST_LAYERS = {
flights: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass'], flags: ['military', 'onGround'] },
military: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'], flags: ['military', 'onGround'] },
'ais-live-vessels': { numeric: ['speedKts', 'courseDeg'], text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'], flags: [] },
'local-firms': { numeric: ['frp'], text: ['confidence', 'satellite'], flags: [] },
earthquakes: { numeric: ['magnitude', 'depthKm'], text: ['place'], flags: [] },
flights: {
numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
text: [
'callsign',
'icao24',
'originCountry',
'operator',
'routeOrigin',
'routeDestination',
'aircraftClass',
],
flags: ['military', 'onGround'],
},
military: {
numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'],
flags: ['military', 'onGround'],
},
'ais-live-vessels': {
numeric: ['speedKts', 'courseDeg'],
text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'],
flags: [],
},
'local-firms': {
numeric: ['frp'],
text: ['confidence', 'satellite'],
flags: [],
},
earthquakes: {
numeric: ['magnitude', 'depthKm'],
text: ['place'],
flags: [],
},
};
const EARTH_R_KM = 6371;
@@ -42,8 +70,9 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
const d2r = Math.PI / 180;
const dLat = (lat2 - lat1) * d2r;
const dLon = (lon2 - lon1) * d2r;
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
}
@@ -55,17 +84,25 @@ export function applyFilter(records, filter) {
const got = r[field];
if (got === null || got === undefined) return false;
switch (op) {
case 'gt': return Number(got) > Number(value);
case 'gte': return Number(got) >= Number(value);
case 'lt': return Number(got) < Number(value);
case 'lte': return Number(got) <= Number(value);
case 'gt':
return Number(got) > Number(value);
case 'gte':
return Number(got) >= Number(value);
case 'lt':
return Number(got) < Number(value);
case 'lte':
return Number(got) <= Number(value);
case 'eq': {
if (typeof got === 'boolean' || typeof value === 'boolean') return Boolean(got) === Boolean(value);
if (typeof got === 'boolean' || typeof value === 'boolean')
return Boolean(got) === Boolean(value);
return String(got).toLowerCase() === String(value).toLowerCase();
}
case 'neq': return String(got).toLowerCase() !== String(value).toLowerCase();
case 'contains': return String(got).toLowerCase().includes(String(value).toLowerCase());
default: return true;
case 'neq':
return String(got).toLowerCase() !== String(value).toLowerCase();
case 'contains':
return String(got).toLowerCase().includes(String(value).toLowerCase());
default:
return true;
}
});
}
@@ -74,15 +111,23 @@ export function applyFilter(records, filter) {
export function applyScope(records, scope, resolved) {
if (!scope || scope.kind === 'anywhere') return records;
if (scope.kind === 'region' && resolved?.ring) {
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon)
&& pointInRing(resolved.ring, r.lat, r.lon));
return records.filter(
(r) =>
Number.isFinite(r.lat) &&
Number.isFinite(r.lon) &&
pointInRing(resolved.ring, r.lat, r.lon),
);
}
if (scope.kind === 'radius' || scope.kind === 'view') {
const c = resolved?.center;
const km = resolved?.km;
if (!c || !Number.isFinite(km)) return records;
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon)
&& haversineKm(c.lat, c.lon, r.lat, r.lon) <= km);
return records.filter(
(r) =>
Number.isFinite(r.lat) &&
Number.isFinite(r.lon) &&
haversineKm(c.lat, c.lon, r.lat, r.lon) <= km,
);
}
return records;
}
@@ -108,9 +153,12 @@ export function createAnalystEngine(providers) {
let lastResult = null;
async function query(spec = {}) {
const layers = (spec.followUp && lastResult)
? null // follow-up: re-filter the remembered set, no re-snapshot
: (Array.isArray(spec.layers) && spec.layers.length ? spec.layers : ['flights']);
const layers =
spec.followUp && lastResult
? null // follow-up: re-filter the remembered set, no re-snapshot
: Array.isArray(spec.layers) && spec.layers.length
? spec.layers
: ['flights'];
// 1) Source records
let records;
@@ -164,20 +212,23 @@ export function createAnalystEngine(providers) {
// camera made the two disagree — a parked, high-altitude camera answered
// "46 within 250 km" while the panel showed a far larger contact-centred
// count. Same question, two numbers.
const explicitCenter = scope.center && Number.isFinite(scope.center.lat)
? scope.center
: null;
const subject = explicitCenter ? null : providers.getContextSubject?.() || null;
const explicitCenter =
scope.center && Number.isFinite(scope.center.lat) ? scope.center : null;
const subject = explicitCenter
? null
: providers.getContextSubject?.() || null;
const view = providers.getViewContext();
// Only a subject that actually SUPPLIED the centre may name it. A
// subject present but without usable coordinates silently fell back to
// the camera while the label still read "within 250 km of <contact>" —
// a count centred on one place, reported as centred on another, with
// nothing in the payload to show which.
const subjectCenter = Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
? { lat: subject.lat, lon: subject.lon }
: null;
const center = explicitCenter || subjectCenter || { lat: view.lat, lon: view.lon };
const subjectCenter =
Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
? { lat: subject.lat, lon: subject.lon }
: null;
const center = explicitCenter ||
subjectCenter || { lat: view.lat, lon: view.lon };
resolvedScope = { center, km: Number(scope.km) || 100 };
if (subjectCenter) resolvedScope.centeredOn = subject.label || null;
scopeNote = resolvedScope.centeredOn
@@ -188,7 +239,10 @@ export function createAnalystEngine(providers) {
: `within ${resolvedScope.km} km`;
} else if (scope.kind === 'view') {
const view = providers.getViewContext();
resolvedScope = { center: { lat: view.lat, lon: view.lon }, km: view.viewRadiusKm };
resolvedScope = {
center: { lat: view.lat, lon: view.lon },
km: view.viewRadiusKm,
};
scopeNote = `view:${Math.round(view.viewRadiusKm)}km`;
scopeLabel = 'in view';
} else {
@@ -205,15 +259,22 @@ export function createAnalystEngine(providers) {
if (sortBy === 'distance') {
const ref = resolvedScope?.center || providers.getViewContext();
for (const it of items) {
it.distanceKm = (Number.isFinite(it.lat) && Number.isFinite(it.lon))
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) / 10 : null;
it.distanceKm =
Number.isFinite(it.lat) && Number.isFinite(it.lon)
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) /
10
: null;
}
}
if (sortBy) {
const dir = spec.sortDir === 'asc' ? 1 : -1;
items.sort((a, b) => (Number(a[sortBy]) - Number(b[sortBy])) * dir
|| String(a.id).localeCompare(String(b.id)));
if (sortBy === 'distance') items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9));
items.sort(
(a, b) =>
(Number(a[sortBy]) - Number(b[sortBy])) * dir ||
String(a.id).localeCompare(String(b.id)),
);
if (sortBy === 'distance')
items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9));
}
const limit = Math.max(1, Math.min(50, Number(spec.limit) || 10));
const top = items.slice(0, limit);
@@ -223,7 +284,10 @@ export function createAnalystEngine(providers) {
count: items.length,
items: top,
truncated: items.length > top.length,
summary: summarize(items, sortBy && sortBy !== 'distance' ? sortBy : null),
summary: summarize(
items,
sortBy && sortBy !== 'distance' ? sortBy : null,
),
scopeLabel,
coverage: {
layersQueried,
@@ -233,7 +297,9 @@ export function createAnalystEngine(providers) {
},
// Surfaced so the narration can name the centre it measured from rather
// than implying a view-centred answer.
...(resolvedScope?.centeredOn ? { centeredOn: resolvedScope.centeredOn } : {}),
...(resolvedScope?.centeredOn
? { centeredOn: resolvedScope.centeredOn }
: {}),
};
lastResult = { items, coverage: result.coverage };
return result;
@@ -241,7 +307,11 @@ export function createAnalystEngine(providers) {
return {
query,
reset() { lastResult = null; },
hasMemory() { return Boolean(lastResult); },
reset() {
lastResult = null;
},
hasMemory() {
return Boolean(lastResult);
},
};
}
+67 -19
View File
@@ -110,8 +110,12 @@ export const CCTV_THUMBNAIL_ALTITUDE_SCALE = Object.freeze({
*/
export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
if (pinned) return Number.isFinite(sy);
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0) return false;
const safeTop = Math.min(CCTV_CARD_SAFE_TOP_MAX_PX, viewH * CCTV_CARD_SAFE_TOP_RATIO);
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0)
return false;
const safeTop = Math.min(
CCTV_CARD_SAFE_TOP_MAX_PX,
viewH * CCTV_CARD_SAFE_TOP_RATIO,
);
return sy >= safeTop;
}
@@ -126,11 +130,24 @@ export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
* @param {number} [options.limit]
* @returns {string[]} Accepted ids, nearest-first.
*/
export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {}) {
export function declutterCctvCards(
candidates,
{ minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {},
) {
const valid = (Array.isArray(candidates) ? candidates : [])
.filter((c) => c && typeof c.id === 'string' && c.id
&& Number.isFinite(c.sx) && Number.isFinite(c.sy))
.sort((a, b) => (a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) || a.id.localeCompare(b.id));
.filter(
(c) =>
c &&
typeof c.id === 'string' &&
c.id &&
Number.isFinite(c.sx) &&
Number.isFinite(c.sy),
)
.sort(
(a, b) =>
(a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) ||
a.id.localeCompare(b.id),
);
const minSq = minSepPx * minSepPx;
const accepted = [];
const acceptedIds = [];
@@ -140,7 +157,10 @@ export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX
for (let i = 0; i < accepted.length; i++) {
const dx = candidate.sx - accepted[i].sx;
const dy = candidate.sy - accepted[i].sy;
if (dx * dx + dy * dy < minSq) { clear = false; break; }
if (dx * dx + dy * dy < minSq) {
clear = false;
break;
}
}
if (!clear) continue;
accepted.push(candidate);
@@ -165,15 +185,26 @@ export function cardScaleForAltitude(cameraHeightM) {
const h = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : 0;
if (h <= CCTV_CARD_SCALE_FULL_M) return { scale: 1, alpha: 1 };
if (h <= CCTV_CARD_SCALE_MID_M) {
const t = (h - CCTV_CARD_SCALE_FULL_M) / (CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M);
const t =
(h - CCTV_CARD_SCALE_FULL_M) /
(CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M);
const s = t * t * (3 - 2 * t); // smoothstep — no visible kink at either end
return { scale: 1 + (CCTV_CARD_SCALE_AT_MID - 1) * s, alpha: 1 };
}
const t = Math.min(1, (h - CCTV_CARD_SCALE_MID_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M));
const scale = CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t;
if (h >= CCTV_CARD_FADE_END_M) return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 };
const t = Math.min(
1,
(h - CCTV_CARD_SCALE_MID_M) /
(CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M),
);
const scale =
CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t;
if (h >= CCTV_CARD_FADE_END_M)
return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 };
if (h <= CCTV_CARD_FADE_START_M) return { scale, alpha: 1 };
const alpha = 1 - (h - CCTV_CARD_FADE_START_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
const alpha =
1 -
(h - CCTV_CARD_FADE_START_M) /
(CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
return { scale, alpha };
}
@@ -192,17 +223,23 @@ export function cardScaleForAltitude(cameraHeightM) {
* @param {number} [input.sinceLastLaunchMs] - Ms since the last fetch launch.
* @returns {{mode:('burst'|'steady'), launch:boolean}}
*/
export function cardFetchPolicy({ coldFill = false, inFlight = 0, sinceLastLaunchMs = Infinity } = {}) {
export function cardFetchPolicy({
coldFill = false,
inFlight = 0,
sinceLastLaunchMs = Infinity,
} = {}) {
if (coldFill) {
return {
mode: 'burst',
launch: inFlight < CCTV_CARD_FETCH_BURST_LIMIT
&& sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
launch:
inFlight < CCTV_CARD_FETCH_BURST_LIMIT &&
sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
};
}
return {
mode: 'steady',
launch: inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
launch:
inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
};
}
@@ -230,7 +267,12 @@ export function createFrameSlot() {
export function applyFrameResult(prev, result, nowMs) {
const base = prev || createFrameSlot();
if (result?.ok && result.frame) {
return { frame: result.frame, stamp: nowMs, failCount: 0, lastAttemptAt: nowMs };
return {
frame: result.frame,
stamp: nowMs,
failCount: 0,
lastAttemptAt: nowMs,
};
}
return {
frame: base.frame,
@@ -278,11 +320,17 @@ export function frameFetchDue(slot, refreshMs, nowMs) {
* @param {number} [cap]
* @returns {string[]} Ids to drop from the cache.
*/
export function planFrameCachePrune(slots, keepIds, cap = CCTV_FRAME_CACHE_MAX) {
export function planFrameCachePrune(
slots,
keepIds,
cap = CCTV_FRAME_CACHE_MAX,
) {
const keep = new Set(keepIds);
const spare = (Array.isArray(slots) ? slots : [])
.filter((slot) => slot && typeof slot.id === 'string' && !keep.has(slot.id))
.sort((a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id));
.sort(
(a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id),
);
const capacity = Math.max(0, cap - keep.size);
return spare.slice(capacity).map((slot) => slot.id);
}
+173 -44
View File
@@ -74,7 +74,11 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
const toPlane = Cesium.Cartesian3.subtract(planeOrigin, rayOrigin, scratchW);
const s = Cesium.Cartesian3.dot(toPlane, planeNormal) / denom;
if (s < 0) return null;
const hit = Cesium.Cartesian3.multiplyByScalar(rayDir, s, new Cesium.Cartesian3());
const hit = Cesium.Cartesian3.multiplyByScalar(
rayDir,
s,
new Cesium.Cartesian3(),
);
return Cesium.Cartesian3.add(rayOrigin, hit, hit);
}
@@ -89,7 +93,10 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
*/
export function ringAngle(hitPoint, center, basisA, basisB) {
const v = Cesium.Cartesian3.subtract(hitPoint, center, scratchW);
return Math.atan2(Cesium.Cartesian3.dot(v, basisB), Cesium.Cartesian3.dot(v, basisA));
return Math.atan2(
Cesium.Cartesian3.dot(v, basisB),
Cesium.Cartesian3.dot(v, basisA),
);
}
/**
@@ -150,7 +157,11 @@ function enuAxes(position) {
/** a*sa + b*sb (fresh Cartesian3). */
function combine2(a, sa, b, sb) {
const out = Cesium.Cartesian3.multiplyByScalar(a, sa, new Cesium.Cartesian3());
const out = Cesium.Cartesian3.multiplyByScalar(
a,
sa,
new Cesium.Cartesian3(),
);
const t = Cesium.Cartesian3.multiplyByScalar(b, sb, new Cesium.Cartesian3());
return Cesium.Cartesian3.add(out, t, out);
}
@@ -165,7 +176,12 @@ function combine2(a, sa, b, sb) {
function viewAxesFor(headingDeg, pitchDeg, axes) {
const h = toRadians(headingDeg);
const p = toRadians(pitchDeg);
const forwardHoriz = combine2(axes.east, Math.sin(h), axes.north, Math.cos(h));
const forwardHoriz = combine2(
axes.east,
Math.sin(h),
axes.north,
Math.cos(h),
);
const right = combine2(axes.east, Math.cos(h), axes.north, -Math.sin(h));
const view = combine2(forwardHoriz, Math.cos(p), axes.up, Math.sin(p));
return { forwardHoriz, right, view };
@@ -176,7 +192,12 @@ function ringPositions(center, radius, basisA, basisB) {
const positions = [];
for (let i = 0; i <= RING_SEGMENTS; i++) {
const angle = (i / RING_SEGMENTS) * 2 * Math.PI;
const offset = combine2(basisA, Math.cos(angle) * radius, basisB, Math.sin(angle) * radius);
const offset = combine2(
basisA,
Math.cos(angle) * radius,
basisB,
Math.sin(angle) * radius,
);
positions.push(Cesium.Cartesian3.add(center, offset, offset));
}
return positions;
@@ -202,7 +223,12 @@ function ringPositions(center, radius, basisA, basisB) {
* destroy: function(): void, isDragging: function(): boolean,
* isEnabled: function(): boolean}}
*/
export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, endPatch }) {
export function createCalibrationGizmo({
viewer,
getActiveRecord,
applyPatch,
endPatch,
}) {
let enabled = false;
let drag = null; // { part, startCal, basePose, refs... }
let hoveredId = null;
@@ -225,20 +251,24 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
function polylinePart(part, color, width, arrow = false) {
addEntity(part, {
...(arrow ? {
position: Cesium.Cartesian3.ZERO,
point: {
pixelSize: 14,
color,
outlineColor: Cesium.Color.BLACK.withAlpha(0.7),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
} : {}),
...(arrow
? {
position: Cesium.Cartesian3.ZERO,
point: {
pixelSize: 14,
color,
outlineColor: Cesium.Color.BLACK.withAlpha(0.7),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
}
: {}),
polyline: {
positions: [],
width,
material: arrow ? new Cesium.PolylineArrowMaterialProperty(color) : color.withAlpha(0.9),
material: arrow
? new Cesium.PolylineArrowMaterialProperty(color)
: color.withAlpha(0.9),
// Standard gizmo convention: parts behind geometry stay clearly
// visible, just dimmed (a street-level mount buries half the heading
// ring in sloped photogrammetry tiles — smoke-tested 2026-07-05).
@@ -286,16 +316,42 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
}
const mount = positions.mount;
const axes = enuAxes(mount);
const { forwardHoriz, right, view } = viewAxesFor(camera.headingDeg, camera.pitchDeg, axes);
const { forwardHoriz, right, view } = viewAxesFor(
camera.headingDeg,
camera.pitchDeg,
axes,
);
const rangeM = Math.max(1, Number(camera.rangeM) || 1);
const radius = Math.min(RING_RADIUS_MAX_M, Math.max(RING_RADIUS_MIN_M, rangeM * RING_RADIUS_FACTOR));
const radius = Math.min(
RING_RADIUS_MAX_M,
Math.max(RING_RADIUS_MIN_M, rangeM * RING_RADIUS_FACTOR),
);
const arrowLen = radius * ARROW_LENGTH_FACTOR;
entities.get('ring-heading').polyline.positions = ringPositions(mount, radius, axes.north, axes.east);
entities.get('ring-pitch').polyline.positions = ringPositions(mount, radius, forwardHoriz, axes.up);
const arrowTo = (dir) => [mount, Cesium.Cartesian3.add(
mount, Cesium.Cartesian3.multiplyByScalar(dir, arrowLen, new Cesium.Cartesian3()), new Cesium.Cartesian3()
)];
entities.get('ring-heading').polyline.positions = ringPositions(
mount,
radius,
axes.north,
axes.east,
);
entities.get('ring-pitch').polyline.positions = ringPositions(
mount,
radius,
forwardHoriz,
axes.up,
);
const arrowTo = (dir) => [
mount,
Cesium.Cartesian3.add(
mount,
Cesium.Cartesian3.multiplyByScalar(
dir,
arrowLen,
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
),
];
const eastArrow = arrowTo(axes.east);
const northArrow = arrowTo(axes.north);
const upArrow = arrowTo(axes.up);
@@ -306,8 +362,16 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
entities.get('move-up').polyline.positions = upArrow;
entities.get('move-up').position = upArrow[1];
entities.get('handle-range').position = positions.capCenter;
entities.get('handle-fov-l').position = Cesium.Cartesian3.midpoint(positions.tl, positions.bl, new Cesium.Cartesian3());
entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint(positions.tr, positions.br, new Cesium.Cartesian3());
entities.get('handle-fov-l').position = Cesium.Cartesian3.midpoint(
positions.tl,
positions.bl,
new Cesium.Cartesian3(),
);
entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint(
positions.tr,
positions.br,
new Cesium.Cartesian3(),
);
for (const entity of entities.values()) entity.show = true;
}
@@ -343,8 +407,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
return null;
}
if (typeof window !== 'undefined' && window.__gevGizmoDebug) {
console.debug('[CCTV:gizmo] drillPick @', windowPosition?.x, windowPosition?.y, '→',
JSON.stringify(results.map((r) => String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name))));
console.debug(
'[CCTV:gizmo] drillPick @',
windowPosition?.x,
windowPosition?.y,
'→',
JSON.stringify(
results.map((r) =>
String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name),
),
),
);
}
for (const picked of results) {
const part = gizmoPartFrom(picked);
@@ -379,7 +452,8 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
for (const [name, entity] of entities.entries()) {
if (!entity.point) continue;
const hot = hoveredId === `${GIZMO_ID_PREFIX}${name}`;
entity.point.pixelSize = (name === 'handle-range' ? 13 : 10) + (hot ? 4 : 0);
entity.point.pixelSize =
(name === 'handle-range' ? 13 : 10) + (hot ? 4 : 0);
}
setCursor(hoveredId ? 'grab' : '');
}
@@ -397,7 +471,11 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
const mount = Cesium.Cartesian3.clone(positions.mount);
const capCenter = Cesium.Cartesian3.clone(positions.capCenter);
const axes = enuAxes(mount);
const { forwardHoriz, right, view } = viewAxesFor(camera.headingDeg, camera.pitchDeg, axes);
const { forwardHoriz, right, view } = viewAxesFor(
camera.headingDeg,
camera.pitchDeg,
axes,
);
const startCal = { ...camera.calibration };
const basePose = { ...camera.basePose };
const state = {
@@ -405,7 +483,15 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
// mid-drag active-camera switch (voice select / auto-hop) onto a
// different camera — its basePose makes the captured offsets wrong.
record,
part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view,
part,
startCal,
basePose,
mount,
capCenter,
axes,
forwardHoriz,
right,
view,
effectiveRangeM: geometry.rangeM,
startAngle: null,
startParam: null,
@@ -419,8 +505,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
if (!hit) return false;
state.startAngle = ringAngle(hit, mount, forwardHoriz, axes.up); // 0 = level, + = up
} else if (part === 'move-east' || part === 'move-north' || part === 'move-up') {
const dir = part === 'move-east' ? axes.east : part === 'move-north' ? axes.north : axes.up;
} else if (
part === 'move-east' ||
part === 'move-north' ||
part === 'move-up'
) {
const dir =
part === 'move-east'
? axes.east
: part === 'move-north'
? axes.north
: axes.up;
state.axisDir = dir;
const t = closestParamOnAxis(ray.origin, ray.direction, mount, dir);
if (t === null) return false;
@@ -448,26 +543,49 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
function dragPatch(windowPosition) {
const ray = pickRay(windowPosition);
if (!ray || !drag) return null;
const { part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view } = drag;
const {
part,
startCal,
basePose,
mount,
capCenter,
axes,
forwardHoriz,
right,
view,
} = drag;
if (part === 'ring-heading') {
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, axes.up);
if (!hit) return null;
const angle = ringAngle(hit, mount, axes.north, axes.east);
return { headingDeg: startCal.headingDeg + toDeg(signedAngleDelta(drag.startAngle, angle)) };
return {
headingDeg:
startCal.headingDeg + toDeg(signedAngleDelta(drag.startAngle, angle)),
};
}
if (part === 'ring-pitch') {
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
if (!hit) return null;
const angle = ringAngle(hit, mount, forwardHoriz, axes.up);
return { pitchDeg: startCal.pitchDeg + toDeg(signedAngleDelta(drag.startAngle, angle)) };
return {
pitchDeg:
startCal.pitchDeg + toDeg(signedAngleDelta(drag.startAngle, angle)),
};
}
if (part === 'move-east' || part === 'move-north' || part === 'move-up') {
const t = closestParamOnAxis(ray.origin, ray.direction, mount, drag.axisDir);
const t = closestParamOnAxis(
ray.origin,
ray.direction,
mount,
drag.axisDir,
);
if (t === null) return null;
const delta = t - drag.startParam;
if (part === 'move-east') return { offsetEastM: startCal.offsetEastM + delta };
if (part === 'move-north') return { offsetNorthM: startCal.offsetNorthM + delta };
if (part === 'move-east')
return { offsetEastM: startCal.offsetEastM + delta };
if (part === 'move-north')
return { offsetNorthM: startCal.offsetNorthM + delta };
return { heightM: startCal.heightM + delta };
}
if (part === 'handle-range') {
@@ -478,11 +596,16 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
if (part === 'handle-fov-l' || part === 'handle-fov-r') {
const hit = rayPlaneIntersect(ray.origin, ray.direction, capCenter, view);
if (!hit) return null;
const lateral = Math.abs(Cesium.Cartesian3.dot(
Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()), right
));
const lateral = Math.abs(
Cesium.Cartesian3.dot(
Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()),
right,
),
);
const halfW = Math.max(0.5, lateral);
const fovDeg = toDeg(2 * Math.atan(halfW / Math.max(1, drag.effectiveRangeM)));
const fovDeg = toDeg(
2 * Math.atan(halfW / Math.max(1, drag.effectiveRangeM)),
);
return { fovDeg: fovDeg - basePose.fovDeg };
}
return null;
@@ -531,7 +654,13 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
return;
}
const patch = dragPatch(event.endPosition);
debugLog('dragPatch', drag.part, event.endPosition, '→', patch ? JSON.stringify(patch) : null);
debugLog(
'dragPatch',
drag.part,
event.endPosition,
'→',
patch ? JSON.stringify(patch) : null,
);
if (patch) applyPatch(patch, drag.record);
return;
}
+9 -8
View File
@@ -46,7 +46,7 @@ export function cameraHue(index) {
* @returns {{fill: Cesium.Color, fillActive: Cesium.Color, line: Cesium.Color, lineActive: Cesium.Color}}
*/
export function viewshedColors(hueDeg) {
const hue = ((Number(hueDeg) % 360) + 360) % 360 / 360;
const hue = (((Number(hueDeg) % 360) + 360) % 360) / 360;
return {
fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
fillActive: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE),
@@ -66,7 +66,13 @@ export function viewshedColors(hueDeg) {
* @returns {{positions: Float64Array, indices: Uint16Array}}
*/
export function frustumVolumeGeometryData(positions) {
const pts = [positions.mount, positions.tl, positions.tr, positions.br, positions.bl];
const pts = [
positions.mount,
positions.tl,
positions.tr,
positions.br,
positions.bl,
];
const flat = new Float64Array(15);
pts.forEach((p, i) => {
flat[i * 3] = p.x;
@@ -75,12 +81,7 @@ export function frustumVolumeGeometryData(positions) {
});
// apex=0, tl=1, tr=2, br=3, bl=4 — 4 side faces + far cap (2 triangles).
const indices = new Uint16Array([
0, 1, 2,
0, 2, 3,
0, 3, 4,
0, 4, 1,
1, 2, 3,
1, 3, 4,
0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1, 1, 2, 3, 1, 3, 4,
]);
return { positions: flat, indices };
}
+1 -2
View File
@@ -21,7 +21,7 @@ export function nextCockpitNearContacts(
) {
const previous = previousIds instanceof Set ? previousIds : new Set();
const addSq = Math.max(0, Number(addDistanceM) || 0) ** 2;
const keepSq = Math.max(addSq, (Math.max(0, Number(keepDistanceM) || 0) ** 2));
const keepSq = Math.max(addSq, Math.max(0, Number(keepDistanceM) || 0) ** 2);
const next = new Set();
for (const [id, distanceSq] of distancesSquared || []) {
@@ -32,4 +32,3 @@ export function nextCockpitNearContacts(
return next;
}
+27 -8
View File
@@ -42,7 +42,9 @@ export const CONTACT_MATCH_TIER = Object.freeze({
* @returns {string} Canonical form, or '' when there is nothing to compare.
*/
export function canonicalizeContactId(value) {
return String(value ?? '').replace(/[^a-z0-9]/gi, '').toUpperCase();
return String(value ?? '')
.replace(/[^a-z0-9]/gi, '')
.toUpperCase();
}
/**
@@ -57,16 +59,31 @@ export function canonicalizeContactId(value) {
* @param {string} [params.registration] Contact's registration, if any.
* @returns {number} A {@link CONTACT_MATCH_TIER} value; `NONE` when no match.
*/
export function rankContactMatch({ query, hex = '', callsign = '', registration = '' }) {
const rawQuery = String(query ?? '').trim().toLowerCase();
export function rankContactMatch({
query,
hex = '',
callsign = '',
registration = '',
}) {
const rawQuery = String(query ?? '')
.trim()
.toLowerCase();
if (!rawQuery) return CONTACT_MATCH_TIER.NONE;
if (String(hex ?? '').trim().toLowerCase() === rawQuery) return CONTACT_MATCH_TIER.HEX_EXACT;
if (
String(hex ?? '')
.trim()
.toLowerCase() === rawQuery
)
return CONTACT_MATCH_TIER.HEX_EXACT;
const callsignText = String(callsign ?? '').trim().toLowerCase();
const callsignText = String(callsign ?? '')
.trim()
.toLowerCase();
// Callsigns carry no separators, so they compare raw — canonicalizing them
// would silently widen the long-standing callsign contract.
if (callsignText && callsignText === rawQuery) return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
if (callsignText && callsignText === rawQuery)
return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
const canonicalQuery = canonicalizeContactId(rawQuery);
const canonicalRegistration = canonicalizeContactId(registration);
@@ -74,11 +91,13 @@ export function rankContactMatch({ query, hex = '', callsign = '', registration
return CONTACT_MATCH_TIER.REGISTRATION_EXACT;
}
if (callsignText && callsignText.startsWith(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
if (callsignText && callsignText.startsWith(rawQuery))
return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
if (canonicalQuery && canonicalRegistration.startsWith(canonicalQuery)) {
return CONTACT_MATCH_TIER.REGISTRATION_PREFIX;
}
if (callsignText && callsignText.includes(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
if (callsignText && callsignText.includes(rawQuery))
return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
if (canonicalQuery && canonicalRegistration.includes(canonicalQuery)) {
return CONTACT_MATCH_TIER.REGISTRATION_SUBSTRING;
}
+23 -11
View File
@@ -48,7 +48,9 @@ export function selectEntityContext(entity) {
store.selectedEntityId = contextId;
store.selectedAt = Date.now();
const record = store.entities.get(contextId);
window.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: record }));
window.dispatchEvent(
new CustomEvent('gev:entity-selected', { detail: record }),
);
return record;
}
@@ -78,7 +80,8 @@ export function selectTrackedSubjectContext(metadata) {
const store = getContextStore();
const id = String(metadata.id);
for (const [key, record] of store.entities) {
if (record?.layerId === metadata.layerId && key !== id) store.entities.delete(key);
if (record?.layerId === metadata.layerId && key !== id)
store.entities.delete(key);
}
// Reuse the existing carrier so a per-poll refresh does not churn identity.
const carrier = store.entities.get(id)?.entity || { __gevContextId: id };
@@ -146,16 +149,21 @@ export function getSelectedEntityContext({ dataManager = null } = {}) {
* rather than being deselected. Readouts that stay on screen hold their
* last-known values for an eviction and only tear down on a deliberate clear.
*/
export function clearSelectedEntityContextForLayer(layerId, { evicted = false } = {}) {
export function clearSelectedEntityContextForLayer(
layerId,
{ evicted = false } = {},
) {
const store = getContextStore();
if (!store.selectedEntityId) return;
const record = store.entities.get(store.selectedEntityId);
if (record?.layerId === layerId) {
store.selectedEntityId = null;
store.selectedAt = null;
window.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', {
detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' },
}));
window.dispatchEvent(
new CustomEvent('gev:entity-selection-cleared', {
detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' },
}),
);
}
}
@@ -166,16 +174,19 @@ export function clearSelectedEntityContextForLayer(layerId, { evicted = false }
export function removeEntityContextsForLayer(layerId, { retainIds } = {}) {
const store = getContextStore();
for (const [id, record] of store.entities) {
if (record?.layerId === layerId && !retainIds?.has(id)) store.entities.delete(id);
if (record?.layerId === layerId && !retainIds?.has(id))
store.entities.delete(id);
}
if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) {
store.selectedEntityId = null;
store.selectedAt = null;
// A viewport refresh dropped the record out from under the selection —
// the user did not deselect anything.
window.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', {
detail: { layerId, reason: 'evicted' },
}));
window.dispatchEvent(
new CustomEvent('gev:entity-selection-cleared', {
detail: { layerId, reason: 'evicted' },
}),
);
}
}
@@ -183,6 +194,7 @@ export function isContextRecordActive(record, dataManager = null) {
if (!record) return false;
if (record.entity?.show === false) return false;
if (record.dataSource && record.dataSource.show === false) return false;
if (dataManager && record.layerId && !dataManager.isEnabled(record.layerId)) return false;
if (dataManager && record.layerId && !dataManager.isEnabled(record.layerId))
return false;
return true;
}
+7 -7
View File
@@ -185,8 +185,7 @@ export const DATA_CREDITS = [
},
{
key: 'gtfs-rt',
html:
'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
html: 'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
},
{
key: 'radio-browser',
@@ -299,11 +298,12 @@ export const NATURAL_EARTH_CREDIT = {
* @returns {{ key: string, html: string }}
*/
export function transitFeedCredit(feed) {
const escape = (text) => String(text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
const escape = (text) =>
String(text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
return {
key: `transit-${feed.id}`,
html:
+219 -70
View File
@@ -279,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?.();
@@ -323,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;
@@ -397,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;
}
@@ -462,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;
@@ -477,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;
@@ -526,7 +539,9 @@ export function getDetectionDiagnostics() {
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 }));
result.calloutRects = _calloutPool
.slice(0, _calloutCount)
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
return result;
}
@@ -535,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);
@@ -632,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();
@@ -665,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;
@@ -687,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);
@@ -705,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');
}
}
@@ -804,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);
@@ -900,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,
@@ -940,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;
}
@@ -996,7 +1066,13 @@ function _paintCalloutLane(frame) {
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);
if (entry)
paintTransitBracket(
ctx,
entry.path,
entry.color,
entry.alpha * _transitBracketAlpha,
);
}
}
ctx.textAlign = 'left';
@@ -1021,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);
@@ -1042,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);
}
@@ -1063,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,
@@ -1079,7 +1167,13 @@ 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
@@ -1087,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) {
@@ -1116,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,
@@ -1144,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
@@ -1179,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);
@@ -1217,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
@@ -1229,7 +1340,10 @@ 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);
@@ -1241,18 +1355,34 @@ function _drawOverlay(frame) {
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) {
const transit = obj.tier?.startsWith('transit_');
(transit ? appendTransitBracket : appendCornerBracket)(pathFor(transit ? _transitBracketPaths : bracketPaths, color, bracketAlpha), sx, sy, halfW, halfH);
(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 {
@@ -1286,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);
}
@@ -1301,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) {
@@ -1320,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);
@@ -1338,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, {
@@ -1419,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,
@@ -1451,5 +1594,11 @@ function _drawOverlay(frame) {
_drawModeBanner(visibleCount, sampledCount);
// `_labelSolveDirty` surviving a paint means the solve was owed and did not
// run — the frame that carried the request cannot be the last one.
return { didSolve, solveMs, fadingCount, animatingCount, solvePending: _labelSolveDirty };
return {
didSolve,
solveMs,
fadingCount,
animatingCount,
solvePending: _labelSolveDirty,
};
}
+41 -12
View File
@@ -19,11 +19,14 @@ export function stableIdentityHash(layerId, sourceId) {
/** Negative means a is a better deterministic contender than b. */
export function compareCohortContenders(a, b) {
const priorityDelta = (Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
const priorityDelta =
(Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
if (priorityDelta) return priorityDelta;
const bandDelta = (Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
const bandDelta =
(Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
if (bandDelta) return bandDelta;
const hashDelta = (Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
const hashDelta =
(Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
if (hashDelta) return hashDelta;
return String(a?._cohortSourceId).localeCompare(String(b?._cohortSourceId));
}
@@ -39,8 +42,14 @@ function isWorse(a, b) {
*/
export class BoundedCohort {
constructor(maxSize = DEFAULT_MAX_COHORT, hardMax = DEFAULT_MAX_COHORT) {
const ceiling = Math.max(1, Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT));
this.maxSize = Math.max(1, Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT)));
const ceiling = Math.max(
1,
Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT),
);
this.maxSize = Math.max(
1,
Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT)),
);
this.incumbents = [];
this.heap = [];
}
@@ -62,11 +71,20 @@ export class BoundedCohort {
}
values(limit = this.maxSize) {
const cap = Math.max(0, Math.min(this.maxSize, Math.floor(Number(limit) || 0)));
const incumbents = this.incumbents.slice().sort(compareCohortContenders).slice(0, cap);
const cap = Math.max(
0,
Math.min(this.maxSize, Math.floor(Number(limit) || 0)),
);
const incumbents = this.incumbents
.slice()
.sort(compareCohortContenders)
.slice(0, cap);
const remaining = Math.max(0, cap - incumbents.length);
if (remaining === 0) return incumbents;
const contenders = this.heap.slice().sort(compareCohortContenders).slice(0, remaining);
const contenders = this.heap
.slice()
.sort(compareCohortContenders)
.slice(0, remaining);
return incumbents.concat(contenders);
}
@@ -75,7 +93,10 @@ export class BoundedCohort {
while (current > 0) {
const parent = Math.floor((current - 1) / 2);
if (!isWorse(this.heap[current], this.heap[parent])) break;
[this.heap[current], this.heap[parent]] = [this.heap[parent], this.heap[current]];
[this.heap[current], this.heap[parent]] = [
this.heap[parent],
this.heap[current],
];
current = parent;
}
}
@@ -86,10 +107,18 @@ export class BoundedCohort {
const left = current * 2 + 1;
const right = left + 1;
let worst = current;
if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst])) worst = left;
if (right < this.heap.length && isWorse(this.heap[right], this.heap[worst])) worst = right;
if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst]))
worst = left;
if (
right < this.heap.length &&
isWorse(this.heap[right], this.heap[worst])
)
worst = right;
if (worst === current) return;
[this.heap[current], this.heap[worst]] = [this.heap[worst], this.heap[current]];
[this.heap[current], this.heap[worst]] = [
this.heap[worst],
this.heap[current],
];
current = worst;
}
}
+15 -8
View File
@@ -211,11 +211,14 @@ export function measureTrackLabel(primary, micro, charWidth) {
*/
export function rectIntersectsAny(rect, obstacles, padding = 0) {
if (!rect || !Array.isArray(obstacles)) return false;
return obstacles.some((obstacle) => obstacle
&& rect.x < obstacle.x + obstacle.w + padding
&& rect.x + rect.w + padding > obstacle.x
&& rect.y < obstacle.y + obstacle.h + padding
&& rect.y + rect.h + padding > obstacle.y);
return obstacles.some(
(obstacle) =>
obstacle &&
rect.x < obstacle.x + obstacle.w + padding &&
rect.x + rect.w + padding > obstacle.x &&
rect.y < obstacle.y + obstacle.h + padding &&
rect.y + rect.h + padding > obstacle.y,
);
}
/**
@@ -238,7 +241,6 @@ export function nearFarScale(distance, near, nearValue, far, farValue) {
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?.();
@@ -255,6 +257,11 @@ export function paintTransitBracket(ctx, path, color, alpha = 1) {
/** 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));
appendCornerBracket(
sink,
Math.floor(sx) + 0.5,
Math.floor(sy) + 0.5,
Math.round(halfW),
Math.round(halfH),
);
}
+66 -20
View File
@@ -8,7 +8,10 @@
export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]);
export const ALLOCATION_ELASTIC = 'ELASTIC';
export const ALLOCATION_WEIGHTED = 'WEIGHTED';
export const ALLOCATION_STRATEGIES = Object.freeze([ALLOCATION_ELASTIC, ALLOCATION_WEIGHTED]);
export const ALLOCATION_STRATEGIES = Object.freeze([
ALLOCATION_ELASTIC,
ALLOCATION_WEIGHTED,
]);
// The keyhole's default 1% outside opacity is appropriate for text, but it
// made side aircraft brackets effectively disappear while the same contacts
@@ -71,10 +74,16 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
: AIRCRAFT_BRACKET_FLOOR_ANCHOR;
if (outside <= 0) return 0;
if (outside <= AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
return AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR);
return (
AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR)
);
}
const progress = (outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) / (1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR);
return AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress;
const progress =
(outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) /
(1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR);
return (
AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress
);
}
/**
@@ -85,7 +94,11 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
* the default, so a caller that does not know it reproduces the shipped look.
* @returns {number} Paint alpha, 0-1.
*/
export function detectionBracketAlpha(type, keyholeAlpha, outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
export function detectionBracketAlpha(
type,
keyholeAlpha,
outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR,
) {
const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0));
if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha;
return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha);
@@ -97,7 +110,7 @@ export function detectionHorizontalSector(screenX, viewportWidth) {
const x = Number(screenX);
if (!(width > 0) || !Number.isFinite(x)) return 'front';
if (x < width / 3) return 'left';
if (x > width * 2 / 3) return 'right';
if (x > (width * 2) / 3) return 'right';
return 'front';
}
@@ -140,19 +153,29 @@ export function defaultDensityForProfile(profile) {
/** Normalize current and legacy profile labels. OFF remains an enabled-state value. */
export function normalizeProfile(profile) {
const raw = String(profile || '').trim().toUpperCase();
const raw = String(profile || '')
.trim()
.toUpperCase();
if (raw === 'OFF') return 'OFF';
if (raw === 'SPARSE' || raw === 'SURVEY') return 'SPARSE';
if (raw === 'BALANCED' || raw === 'NORMAL') return 'BALANCED';
if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON') return 'DENSE';
if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON')
return 'DENSE';
return null;
}
/** Normalize the user-selectable layer allocation strategy. */
export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELASTIC) {
const raw = String(strategy || '').trim().toUpperCase();
export function normalizeAllocationStrategy(
strategy,
fallback = ALLOCATION_ELASTIC,
) {
const raw = String(strategy || '')
.trim()
.toUpperCase();
if (ALLOCATION_STRATEGIES.includes(raw)) return raw;
const normalizedFallback = String(fallback || '').trim().toUpperCase();
const normalizedFallback = String(fallback || '')
.trim()
.toUpperCase();
return ALLOCATION_STRATEGIES.includes(normalizedFallback)
? normalizedFallback
: ALLOCATION_ELASTIC;
@@ -160,7 +183,9 @@ export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELAS
/** Classify camera altitude into the shared label-budget view scale. */
export function viewScaleForAltitude(altitudeM) {
const altitude = Number.isFinite(Number(altitudeM)) ? Math.max(0, Number(altitudeM)) : 1e9;
const altitude = Number.isFinite(Number(altitudeM))
? Math.max(0, Number(altitudeM))
: 1e9;
if (altitude < 1200) return 'street';
if (altitude < 4500) return 'city';
if (altitude < 20000) return 'metro';
@@ -181,25 +206,46 @@ export function labelBudgetFor(altitudeM, densityPct) {
* density was below 75; legacy Sparse remains inside the Sparse band.
*/
export function migrateDetectionState(mode, densityPct, fallbackDensity = 50) {
const rawMode = String(mode || '').trim().toUpperCase();
const rawMode = String(mode || '')
.trim()
.toUpperCase();
const normalized = normalizeProfile(rawMode);
if (normalized === 'OFF') {
const supplied = Number.isFinite(Number(densityPct)) ? densityPct : fallbackDensity;
const supplied = Number.isFinite(Number(densityPct))
? densityPct
: fallbackDensity;
const stop = canonicalizeDensity(supplied, fallbackDensity);
return { enabled: false, profile: profileForDensity(stop), densityPct: stop };
return {
enabled: false,
profile: profileForDensity(stop),
densityPct: stop,
};
}
if (rawMode === 'PANOPTIC' || rawMode === 'GOD' || rawMode === 'ON') {
const density = Number.isFinite(Number(densityPct)) ? Math.max(75, Number(densityPct)) : 75;
return { enabled: true, profile: 'DENSE', densityPct: canonicalizeDensity(density) };
const density = Number.isFinite(Number(densityPct))
? Math.max(75, Number(densityPct))
: 75;
return {
enabled: true,
profile: 'DENSE',
densityPct: canonicalizeDensity(density),
};
}
if (rawMode === 'SPARSE' || rawMode === 'SURVEY') {
const density = Number.isFinite(Number(densityPct)) ? Math.min(25, Number(densityPct)) : 25;
const density = Number.isFinite(Number(densityPct))
? Math.min(25, Number(densityPct))
: 25;
const stop = canonicalizeDensity(density, 25);
return { enabled: true, profile: 'SPARSE', densityPct: stop };
}
if (normalized === 'BALANCED' || normalized === 'DENSE') {
const supplied = Number.isFinite(Number(densityPct)) ? canonicalizeDensity(densityPct) : defaultDensityForProfile(normalized);
const stop = profileForDensity(supplied) === normalized ? supplied : defaultDensityForProfile(normalized);
const supplied = Number.isFinite(Number(densityPct))
? canonicalizeDensity(densityPct)
: defaultDensityForProfile(normalized);
const stop =
profileForDensity(supplied) === normalized
? supplied
: defaultDensityForProfile(normalized);
return { enabled: true, profile: normalized, densityPct: stop };
}
const stop = canonicalizeDensity(densityPct, fallbackDensity);
-1
View File
@@ -10,4 +10,3 @@ export const COCKPIT_BRACKET_OPACITY = 0.45;
export function detectionBracketOpacity(cockpitActive) {
return cockpitActive === true ? COCKPIT_BRACKET_OPACITY : 1;
}
+4 -3
View File
@@ -158,9 +158,10 @@ export function detectionPaintSkipDecision({
frameCount,
thresholdMs = DETECTION_PAINT_SKIP_THRESHOLD_MS,
} = {}) {
const skip = !layoutChanged
&& Number(lastPaintMs) > thresholdMs
&& Number(frameCount) % 2 !== 0;
const skip =
!layoutChanged &&
Number(lastPaintMs) > thresholdMs &&
Number(frameCount) % 2 !== 0;
return { skip, requestFollowUp: skip };
}
+3 -1
View File
@@ -24,7 +24,9 @@
* @returns {number} Heading in degrees [0..360), or NaN if unrecognized.
*/
export function directionToHeading(value, allowBare = false) {
const text = String(value || '').trim().toUpperCase();
const text = String(value || '')
.trim()
.toUpperCase();
if (!text) return NaN;
// Explicit travel/intercardinal forms — safe on free-form text (a street
// name almost never contains "NORTHBOUND" or a lone "NB" token).
+6 -2
View File
@@ -79,8 +79,12 @@ export function parseAcquisitionMs(date, time, cache = new Map()) {
const hhmm = String(time ?? '0000').padStart(4, '0');
const hours = Number(hhmm.slice(0, 2));
const minutes = Number(hhmm.slice(2, 4));
const valid = Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day)
&& Number.isFinite(hours) && Number.isFinite(minutes);
const valid =
Number.isFinite(year) &&
Number.isFinite(month) &&
Number.isFinite(day) &&
Number.isFinite(hours) &&
Number.isFinite(minutes);
const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
cache.set(key, ms);
return ms;
+11 -5
View File
@@ -12,10 +12,14 @@ export const FIRMS_OVERLAY_SOURCE_ID = 'firms';
export function fireDetectionKey(fire) {
const lat = Number.isFinite(fire?.lat) ? fire.lat.toFixed(4) : 'x';
const lon = Number.isFinite(fire?.lon) ? fire.lon.toFixed(4) : 'x';
const acq = Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? String(fire.acqMs) : '0';
const source = satelliteShortName(fire?.satellite)
|| String(fire?.sensor || '').trim().toUpperCase()
|| 'x';
const acq =
Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? String(fire.acqMs) : '0';
const source =
satelliteShortName(fire?.satellite) ||
String(fire?.sensor || '')
.trim()
.toUpperCase() ||
'x';
return `firms:${lat}:${lon}:${acq}:${source}`;
}
@@ -37,7 +41,9 @@ export function accentForSeverity(stopName) {
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
export function satelliteShortName(satellite) {
const s = String(satellite || '').trim().toUpperCase();
const s = String(satellite || '')
.trim()
.toUpperCase();
if (s === 'N20' || s === 'NOAA-20') return 'N20';
if (s === 'N21' || s === 'NOAA-21') return 'N21';
if (s === 'N' || s === 'NPP' || s === 'SUOMI NPP') return 'SNPP';
+29 -9
View File
@@ -45,7 +45,9 @@ export function median(values) {
if (!Array.isArray(values) || values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const mid = sorted.length >> 1;
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
return sorted.length % 2 === 1
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
}
/** Bearing (degrees, 0 = north, clockwise) of a projected dx/dy vector. */
@@ -98,13 +100,19 @@ export function matchFlowToRoads(roads, flowSegments) {
const roadCount = Array.isArray(roads) ? roads.length : 0;
const matches = new Array(roadCount).fill(null);
const empty = { matches, matchedCount: 0, candidateCount: 0 };
if (roadCount === 0 || !Array.isArray(flowSegments) || flowSegments.length === 0) {
if (
roadCount === 0 ||
!Array.isArray(flowSegments) ||
flowSegments.length === 0
) {
return empty;
}
// Local equirectangular projection anchored at the first flow coordinate —
// over a ≤0.05° fetch box the distortion is negligible.
const anchor = flowSegments.find((f) => Array.isArray(f?.coords) && f.coords.length >= 2);
const anchor = flowSegments.find(
(f) => Array.isArray(f?.coords) && f.coords.length >= 2,
);
if (!anchor) return empty;
const [refLon, refLat] = anchor.coords[0];
const mPerDegLon = M_PER_DEG_LAT * Math.cos((refLat * Math.PI) / 180);
@@ -114,7 +122,8 @@ export function matchFlowToRoads(roads, flowSegments) {
// ── 1. Spatial hash of flow segments (midpoint-keyed 100 m cells) ──
/** @type {Map<string, Array<{ax:number,ay:number,bx:number,by:number,bearing:number,level:number,closure:boolean}>>} */
const grid = new Map();
const cellOf = (x, y) => `${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
const cellOf = (x, y) =>
`${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
for (const flow of flowSegments) {
const coords = flow?.coords;
@@ -173,7 +182,8 @@ export function matchFlowToRoads(roads, flowSegments) {
for (let i = 0; i < coords.length; i++) {
xs[i] = projX(coords[i][0]);
ys[i] = projY(coords[i][1]);
if (i > 0) cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
if (i > 0)
cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
}
const totalLen = cum[coords.length - 1];
if (!(totalLen > 0)) continue;
@@ -188,10 +198,14 @@ export function matchFlowToRoads(roads, flowSegments) {
// cross-street flow lines meet at intersections.
const target = (totalLen * (s + 0.5)) / ROAD_SAMPLES;
while (cursor < coords.length - 1 && cum[cursor] < target) cursor++;
const segT = (target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
const segT =
(target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
const px = xs[cursor - 1] + (xs[cursor] - xs[cursor - 1]) * segT;
const py = ys[cursor - 1] + (ys[cursor] - ys[cursor - 1]) * segT;
const sampleBearing = bearingDeg(xs[cursor] - xs[cursor - 1], ys[cursor] - ys[cursor - 1]);
const sampleBearing = bearingDeg(
xs[cursor] - xs[cursor - 1],
ys[cursor] - ys[cursor - 1],
);
// 3×3 cell probe around the sample.
const cx = Math.floor(px / CELL_SIZE_M);
@@ -206,7 +220,11 @@ export function matchFlowToRoads(roads, flowSegments) {
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
if (d2 > radius2) continue;
hadCandidate = true; // within radius, bearing not yet checked
if (bearingDiffDeg(seg.bearing, sampleBearing) >= BEARING_TOLERANCE_DEG) continue;
if (
bearingDiffDeg(seg.bearing, sampleBearing) >=
BEARING_TOLERANCE_DEG
)
continue;
if (d2 <= bestDist2) {
bestDist2 = d2;
best = seg;
@@ -221,7 +239,9 @@ export function matchFlowToRoads(roads, flowSegments) {
}
if (hadCandidate) candidateCount++;
if (matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)) {
if (
matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)
) {
matches[r] = { level: median(matchedLevels), closure: matchedClosure };
matchedCount++;
}
+114 -50
View File
@@ -59,24 +59,35 @@ function focusTargetEmphasisResolved(
previouslyFarther,
) {
if (!target?.screenRect || !spriteScreenPosition) return 1;
if (!Number.isFinite(spriteScreenPosition.x) || !Number.isFinite(spriteScreenPosition.y)) return 1;
if (
!Number.isFinite(spriteScreenPosition.x) ||
!Number.isFinite(spriteScreenPosition.y)
)
return 1;
// Published rects already contain their publication-time padding. The
// delta keeps direct pure-function calls and runtime A/B overrides honest.
// The ambient extent makes overlap mean visual overlap, not center-point
// containment: a class-sized icon whose edge intrudes into the focus rect
// yields even when its center remains just outside it.
const paddingDelta = tuning.paddingPx - (Number.isFinite(target.paddingPx) ? target.paddingPx : 0);
const paddingDelta =
tuning.paddingPx -
(Number.isFinite(target.paddingPx) ? target.paddingPx : 0);
const hysteresis = previouslyCompeting ? tuning.hysteresisPx : 0;
const expansionX = paddingDelta + hysteresis
+ Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0);
const expansionY = paddingDelta + hysteresis
+ Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0);
const expansionX =
paddingDelta +
hysteresis +
Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0);
const expansionY =
paddingDelta +
hysteresis +
Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0);
const rect = target.screenRect;
const inside = spriteScreenPosition.x >= rect.left - expansionX
&& spriteScreenPosition.x <= rect.right + expansionX
&& spriteScreenPosition.y >= rect.top - expansionY
&& spriteScreenPosition.y <= rect.bottom + expansionY;
const inside =
spriteScreenPosition.x >= rect.left - expansionX &&
spriteScreenPosition.x <= rect.right + expansionX &&
spriteScreenPosition.y >= rect.top - expansionY &&
spriteScreenPosition.y <= rect.bottom + expansionY;
if (!inside) return 1;
const floor = clamp(tuning.dimFloor, 0.01, 1);
@@ -88,24 +99,33 @@ function focusTargetEmphasisResolved(
const fartherThreshold = Number.isFinite(target.cameraDistance)
? target.cameraDistance + (previouslyFarther ? -distanceBand : distanceBand)
: Number.NaN;
const farther = !Number.isFinite(spriteCameraDistance)
|| !Number.isFinite(fartherThreshold)
|| spriteCameraDistance >= fartherThreshold;
const farther =
!Number.isFinite(spriteCameraDistance) ||
!Number.isFinite(fartherThreshold) ||
spriteCameraDistance >= fartherThreshold;
if (farther) return floor;
if (tuning.nearerBehavior === 'partial') return floor + (1 - floor) * 0.5;
return 1;
}
function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
const from = clamp(Number.isFinite(current) ? current : 1, tuning.dimFloor, 1);
const from = clamp(
Number.isFinite(current) ? current : 1,
tuning.dimFloor,
1,
);
const to = clamp(Number.isFinite(target) ? target : 1, tuning.dimFloor, 1);
if (Math.abs(to - from) < Number.EPSILON) return to;
const duration = to < from ? tuning.attackMs : tuning.releaseMs;
if (duration <= 0) return to;
const t = clamp((Number.isFinite(elapsedMs) ? elapsedMs : 0) / duration, 0, 1);
const t = clamp(
(Number.isFinite(elapsedMs) ? elapsedMs : 0) / duration,
0,
1,
);
// Cubic ease-out reaches the exact endpoint at the configured duration and
// stays continuous when a moving contact reverses direction mid-transition.
const eased = 1 - ((1 - t) ** 3);
const eased = 1 - (1 - t) ** 3;
return clamp(from + (to - from) * eased, tuning.dimFloor, 1);
}
@@ -118,18 +138,24 @@ function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
*/
export function setFocusDeemphasisParams(patch = {}) {
const next = { ..._params };
if (Number.isFinite(patch.paddingPx)) next.paddingPx = Math.max(0, patch.paddingPx);
if (Number.isFinite(patch.dimFloor)) next.dimFloor = clamp(patch.dimFloor, 0.01, 1);
if (Number.isFinite(patch.paddingPx))
next.paddingPx = Math.max(0, patch.paddingPx);
if (Number.isFinite(patch.dimFloor))
next.dimFloor = clamp(patch.dimFloor, 0.01, 1);
if (['allow', 'dim', 'partial'].includes(patch.nearerBehavior)) {
next.nearerBehavior = patch.nearerBehavior;
}
if (Number.isFinite(patch.hysteresisPx)) next.hysteresisPx = Math.max(0, patch.hysteresisPx);
if (Number.isFinite(patch.hysteresisPx))
next.hysteresisPx = Math.max(0, patch.hysteresisPx);
if (Number.isFinite(patch.distanceHysteresisRatio)) {
next.distanceHysteresisRatio = clamp(patch.distanceHysteresisRatio, 0, 0.5);
}
if (Number.isFinite(patch.attackMs)) next.attackMs = Math.max(0, patch.attackMs);
if (Number.isFinite(patch.releaseMs)) next.releaseMs = Math.max(0, patch.releaseMs);
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon);
if (Number.isFinite(patch.attackMs))
next.attackMs = Math.max(0, patch.attackMs);
if (Number.isFinite(patch.releaseMs))
next.releaseMs = Math.max(0, patch.releaseMs);
if (Number.isFinite(patch.writeEpsilon))
next.writeEpsilon = Math.max(0, patch.writeEpsilon);
_params = next;
return { ..._params };
}
@@ -171,11 +197,22 @@ export function publishFocusTargetFromCachedPosition({
heightPx = 24,
params,
}) {
if (!ownerLayer || id === null || id === undefined || !scene || !camera || !displayPosition) {
if (
!ownerLayer ||
id === null ||
id === undefined ||
!scene ||
!camera ||
!displayPosition
) {
clearFocusTarget(ownerLayer, id);
return null;
}
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(scene, displayPosition, _scratchScreen);
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
displayPosition,
_scratchScreen,
);
if (!screen || !Number.isFinite(screen.x) || !Number.isFinite(screen.y)) {
clearFocusTarget(ownerLayer, id);
return null;
@@ -195,12 +232,19 @@ export function publishFocusTargetFromCachedPosition({
bottom: screen.y + halfHeight + padding,
},
paddingPx: padding,
cameraDistance: Cesium.Cartesian3.distance(camera.positionWC, displayPosition),
cameraDistance: Cesium.Cartesian3.distance(
camera.positionWC,
displayPosition,
),
frameNumber: scene.frameState?.frameNumber ?? -1,
};
if (appeared) {
for (const listener of _focusAppearListeners) {
try { listener(); } catch (e) { console.warn('[focus] appear listener error:', e); }
try {
listener();
} catch (e) {
console.warn('[focus] appear listener error:', e);
}
}
}
return _focusTarget;
@@ -311,15 +355,18 @@ export function smoothFocusEmphasis(current, target, elapsedMs, params) {
* every needed property before the next call; never retain it as a snapshot.
* @returns {{factor:number, changed:boolean, transitioning:boolean,active:boolean,desired:number}}
*/
export function advanceSpriteFocus(sprite, {
screenPosition,
cameraDistance,
nowMs,
target = _focusTarget,
params,
spriteHalfWidthPx = 0,
spriteHalfHeightPx = spriteHalfWidthPx,
}) {
export function advanceSpriteFocus(
sprite,
{
screenPosition,
cameraDistance,
nowMs,
target = _focusTarget,
params,
spriteHalfWidthPx = 0,
spriteHalfHeightPx = spriteHalfWidthPx,
},
) {
const tuning = resolvedParams(params);
let state = _spriteStates.get(sprite);
if (!state) {
@@ -369,12 +416,18 @@ export function advanceSpriteFocus(sprite, {
state.desired = desired;
}
const elapsedMs = Math.max(0, nowMs - state.transitionStartMs);
const next = smoothFocusEmphasisResolved(state.transitionFrom, desired, elapsedMs, tuning);
const next = smoothFocusEmphasisResolved(
state.transitionFrom,
desired,
elapsedMs,
tuning,
);
const changed = Math.abs(next - state.factor) > tuning.writeEpsilon;
state.factor = next;
state.desired = desired;
state.wasFarther = tuning.nearerBehavior !== 'dim'
&& Math.abs(desired - tuning.dimFloor) <= tuning.writeEpsilon;
state.wasFarther =
tuning.nearerBehavior !== 'dim' &&
Math.abs(desired - tuning.dimFloor) <= tuning.writeEpsilon;
const transitioning = Math.abs(next - desired) > tuning.writeEpsilon;
const active = Math.abs(next - 1) > tuning.writeEpsilon;
@@ -426,12 +479,18 @@ export function advanceProjectedSpriteFocus(
_advanceResult.desired = 1;
return _advanceResult;
}
const screen = position && scene
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, _scratchScreen)
: null;
const cameraDistance = position && camera?.positionWC
? Cesium.Cartesian3.distance(camera.positionWC, position)
: Number.NaN;
const screen =
position && scene
? Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
position,
_scratchScreen,
)
: null;
const cameraDistance =
position && camera?.positionWC
? Cesium.Cartesian3.distance(camera.positionWC, position)
: Number.NaN;
return advanceSpriteFocus(sprite, {
screenPosition: screen,
cameraDistance,
@@ -451,8 +510,10 @@ export function advanceProjectedSpriteFocus(
* @returns {boolean}
*/
export function focusPassIsNeeded(target, activeCount) {
return target !== null && target !== undefined
|| (Number.isFinite(activeCount) && activeCount > 0);
return (
(target !== null && target !== undefined) ||
(Number.isFinite(activeCount) && activeCount > 0)
);
}
/**
@@ -466,8 +527,9 @@ export function nearFarScalarValueAtDistance(scalar, cameraDistanceM) {
if (!scalar || !Number.isFinite(cameraDistanceM)) return 1;
const nearSq = scalar.near * scalar.near;
const farSq = scalar.far * scalar.far;
if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq) return 1;
const rawT = ((cameraDistanceM * cameraDistanceM) - nearSq) / (farSq - nearSq);
if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq)
return 1;
const rawT = (cameraDistanceM * cameraDistanceM - nearSq) / (farSq - nearSq);
const t = Math.pow(clamp(rawT, 0, 1), 0.2);
return scalar.nearValue + (scalar.farValue - scalar.nearValue) * t;
}
@@ -507,6 +569,8 @@ export function forgetSpriteFocus(sprite) {
*/
export function focusAlphaNeedsWrite(currentAlpha, nextAlpha, params) {
const tuning = resolvedParams(params);
return !Number.isFinite(currentAlpha)
|| Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon;
return (
!Number.isFinite(currentAlpha) ||
Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon
);
}
+1 -1
View File
@@ -51,7 +51,7 @@ export function geoidHeight(latDeg, lonDeg) {
if (!egm96Module) {
throw new Error(
'geoid.js: geoidHeight() called before ensureGeoidReady() resolved — ' +
'await ensureGeoidReady() first.'
'await ensureGeoidReady() first.',
);
}
return egm96Module.meanSeaLevel(latDeg, lonDeg);
+29 -1
View File
@@ -1,4 +1,32 @@
import { createGroundFloor } from '../services/groundFloor.js';
import * as terrain from './terrainHeights.js';
const groundFloor = createGroundFloor({ terrain });
export const { GROUND_FLOOR_LIFT_M, MESH_FLOOR_BELOW_PRIOR_M, MESH_FLOOR_ABOVE_PRIOR_M, coarseFloorCoord, floorAltitudeM, displayFloorHeightM, FLOOR_CELL_DEG, NEIGHBOR_FLOOR_MIN_SAMPLES, neighborFloorM, CORRIDOR_MAX_CELLS, CORRIDOR_WALK_STEP_DEG, CELL_HYSTERESIS_DEG, stickyFloorCell, corridorFloorCells, allocateCorridorCells, setMeshFloorPreferred, meshFloorPreferred, reportMeshFloorCell, meshFloorSampleWithinPrior, reportValidatedMeshFloorCell, cachedMeshFloor, _clearMeshFloorCellsForTest, cachedGroundFloor, FLOOR_RESOLVE_DEADLINE_MS, resolveGroundFloorCellsBounded, resolveGroundFloorCells, warmGroundFloor } = groundFloor;
export const {
GROUND_FLOOR_LIFT_M,
MESH_FLOOR_BELOW_PRIOR_M,
MESH_FLOOR_ABOVE_PRIOR_M,
coarseFloorCoord,
floorAltitudeM,
displayFloorHeightM,
FLOOR_CELL_DEG,
NEIGHBOR_FLOOR_MIN_SAMPLES,
neighborFloorM,
CORRIDOR_MAX_CELLS,
CORRIDOR_WALK_STEP_DEG,
CELL_HYSTERESIS_DEG,
stickyFloorCell,
corridorFloorCells,
allocateCorridorCells,
setMeshFloorPreferred,
meshFloorPreferred,
reportMeshFloorCell,
meshFloorSampleWithinPrior,
reportValidatedMeshFloorCell,
cachedMeshFloor,
_clearMeshFloorCellsForTest,
cachedGroundFloor,
FLOOR_RESOLVE_DEADLINE_MS,
resolveGroundFloorCellsBounded,
resolveGroundFloorCells,
warmGroundFloor,
} = groundFloor;
+3 -1
View File
@@ -1,4 +1,6 @@
import { createGroundSnap as createSnap } from '../services/groundSnap.js';
import * as groundFloor from './groundFloor.js';
/** Compatibility constructor using the default floor cache. */
export function createGroundSnap() { return createSnap({ groundFloor }); }
export function createGroundSnap() {
return createSnap({ groundFloor });
}
+25 -6
View File
@@ -29,8 +29,14 @@ export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
}
export function findNextIssPass({
satrec, latDeg, lonDeg, fromMs,
minElevDeg = 10, horizonHours = 24, coarseStepSec = 30, fineStepSec = 5,
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg = 10,
horizonHours = 24,
coarseStepSec = 30,
fineStepSec = 5,
}) {
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
const horizonMs = fromMs + horizonHours * 3600_000;
@@ -41,13 +47,17 @@ export function findNextIssPass({
// pass, that's still "the next pass" for a voice answer — accept it.
let hit = null;
for (let t = fromMs; t <= horizonMs; t += coarse) {
if (elev(t) >= minElevDeg) { hit = t; break; }
if (elev(t) >= minElevDeg) {
hit = t;
break;
}
}
if (hit == null) return null;
// Refine rise: walk back in fine steps to the first sample ≥ threshold.
let riseMs = hit;
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg) riseMs -= fine;
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg)
riseMs -= fine;
// Walk forward through the pass tracking the peak until we drop below.
let maxElevDeg = -90;
@@ -56,11 +66,20 @@ export function findNextIssPass({
while (t <= horizonMs) {
const e = elev(t);
if (e < minElevDeg && t > riseMs) break;
if (e > maxElevDeg) { maxElevDeg = e; maxElevMs = t; }
if (e > maxElevDeg) {
maxElevDeg = e;
maxElevMs = t;
}
t += fine;
}
const setMs = t;
const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
return { riseMs, setMs, maxElevDeg, maxElevMs, riseAzDeg: rise ? rise.azDeg : 0 };
return {
riseMs,
setMs,
maxElevDeg,
maxElevMs,
riseAzDeg: rise ? rise.azDeg : 0,
};
}
+175 -72
View File
@@ -16,11 +16,15 @@ function clampInt(value, min, max) {
}
function demandEntries(demandByLayer) {
const entries = demandByLayer instanceof Map
? Array.from(demandByLayer.entries())
: Object.entries(demandByLayer || {});
const entries =
demandByLayer instanceof Map
? Array.from(demandByLayer.entries())
: Object.entries(demandByLayer || {});
return entries
.map(([layerId, demand]) => [String(layerId), Math.max(0, Math.floor(Number(demand) || 0))])
.map(([layerId, demand]) => [
String(layerId),
Math.max(0, Math.floor(Number(demand) || 0)),
])
.filter(([, demand]) => demand > 0)
.sort(([a], [b]) => a.localeCompare(b));
}
@@ -104,7 +108,9 @@ function allocateLayerQuotasInto(
weighted.length = idCount;
priorityOrder.length = idCount;
remainders.length = idCount;
priorityOrder.sort((a, b) => b.weight - a.weight || a.layerId.localeCompare(b.layerId));
priorityOrder.sort(
(a, b) => b.weight - a.weight || a.layerId.localeCompare(b.layerId),
);
let remaining = capacity;
if (capacity >= idCount) {
@@ -115,7 +121,7 @@ function allocateLayerQuotasInto(
let apportioned = 0;
for (let i = 0; i < idCount; i++) {
const entry = weighted[i];
const exact = remaining * entry.weight / totalWeight;
const exact = (remaining * entry.weight) / totalWeight;
const room = entry.count - (quotas.get(entry.layerId) || 0);
const whole = Math.min(room, Math.floor(exact));
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
@@ -123,9 +129,12 @@ function allocateLayerQuotasInto(
entry.fraction = exact - Math.floor(exact);
}
remaining -= apportioned;
remainders.sort((a, b) => b.fraction - a.fraction
|| b.weight - a.weight
|| a.layerId.localeCompare(b.layerId));
remainders.sort(
(a, b) =>
b.fraction - a.fraction ||
b.weight - a.weight ||
a.layerId.localeCompare(b.layerId),
);
for (let i = 0; i < idCount && remaining > 0; i++) {
const entry = remainders[i];
const current = quotas.get(entry.layerId) || 0;
@@ -156,7 +165,12 @@ function allocateLayerQuotasInto(
* Allocate a collective capacity across non-empty layers. Both strategies are
* work-conserving and deterministic; unused entitlement is always borrowed.
*/
export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATION_ELASTIC, layerWeights = {}) {
export function allocateLayerQuotas(
demandByLayer,
capacity,
strategy = ALLOCATION_ELASTIC,
layerWeights = {},
) {
const entries = demandEntries(demandByLayer);
const totalDemand = entries.reduce((sum, [, demand]) => sum + demand, 0);
const cap = clampInt(capacity, 0, totalDemand);
@@ -198,15 +212,24 @@ export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATI
if (remaining > 0 && totalWeight > 0) {
let apportioned = 0;
for (const entry of weighted) {
const exact = remaining * entry.weight / totalWeight;
const exact = (remaining * entry.weight) / totalWeight;
const room = entry.count - (quotas.get(entry.layerId) || 0);
const whole = Math.min(room, Math.floor(exact));
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
apportioned += whole;
remainders.push({ layerId: entry.layerId, fraction: exact - Math.floor(exact), weight: entry.weight });
remainders.push({
layerId: entry.layerId,
fraction: exact - Math.floor(exact),
weight: entry.weight,
});
}
remaining -= apportioned;
remainders.sort((a, b) => b.fraction - a.fraction || b.weight - a.weight || a.layerId.localeCompare(b.layerId));
remainders.sort(
(a, b) =>
b.fraction - a.fraction ||
b.weight - a.weight ||
a.layerId.localeCompare(b.layerId),
);
for (const entry of remainders) {
if (remaining <= 0) break;
const current = quotas.get(entry.layerId) || 0;
@@ -220,20 +243,24 @@ export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATI
}
function rectIsFinite(rect) {
return rect
&& Number.isFinite(rect.x)
&& Number.isFinite(rect.y)
&& Number.isFinite(rect.w)
&& Number.isFinite(rect.h)
&& rect.w > 0
&& rect.h > 0;
return (
rect &&
Number.isFinite(rect.x) &&
Number.isFinite(rect.y) &&
Number.isFinite(rect.w) &&
Number.isFinite(rect.h) &&
rect.w > 0 &&
rect.h > 0
);
}
function overlaps(a, b, padding = 4) {
return a.x < b.x + b.w + padding
&& a.x + a.w + padding > b.x
&& a.y < b.y + b.h + padding
&& a.y + a.h + padding > b.y;
return (
a.x < b.x + b.w + padding &&
a.x + a.w + padding > b.x &&
a.y < b.y + b.h + padding &&
a.y + a.h + padding > b.y
);
}
const CELL_ORIGIN = 8192;
@@ -271,8 +298,10 @@ class SpatialHash {
* never hide a real collision.
*/
_cellKey(x, y) {
const cx = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, x)) + CELL_ORIGIN;
const cy = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, y)) + CELL_ORIGIN;
const cx =
Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, x)) + CELL_ORIGIN;
const cy =
Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, y)) + CELL_ORIGIN;
// Both coordinates occupy 14 bits. Bit-packing forces the lookup key to a
// Smi; arithmetic multiplication materializes a boxed double at each
// spatial-hash get/add on this hot solve path.
@@ -325,8 +354,14 @@ function candidateCompare(a, b, states, now) {
// A stateless candidate is never lifetime-pinned: its source shipped as a
// per-frame rebuild, so it must be free to lose its slot the moment something
// better wants it. Plain incumbency ordering below still applies.
const aPinned = !!aState?.selected && a.stateless !== true && now - aState.selectedAt < MIN_LIFETIME_MS;
const bPinned = !!bState?.selected && b.stateless !== true && now - bState.selectedAt < MIN_LIFETIME_MS;
const aPinned =
!!aState?.selected &&
a.stateless !== true &&
now - aState.selectedAt < MIN_LIFETIME_MS;
const bPinned =
!!bState?.selected &&
b.stateless !== true &&
now - bState.selectedAt < MIN_LIFETIME_MS;
if (aPinned !== bPinned) return aPinned ? -1 : 1;
const aIncumbent = !!aState?.selected;
const bIncumbent = !!bState?.selected;
@@ -346,12 +381,20 @@ function candidateCompare(a, b, states, now) {
function cacheCandidateAnchorScalars(candidate) {
let anchorX = Number.NaN;
let anchorY = Number.NaN;
if (Number.isFinite(candidate?.screenX) && Number.isFinite(candidate?.screenY)) {
if (
Number.isFinite(candidate?.screenX) &&
Number.isFinite(candidate?.screenY)
) {
anchorX = candidate.screenX;
anchorY = candidate.screenY;
} else {
const placement = Array.isArray(candidate?.placements) ? candidate.placements[0] : null;
if (Number.isFinite(placement?.leadFromX) && Number.isFinite(placement?.leadFromY)) {
const placement = Array.isArray(candidate?.placements)
? candidate.placements[0]
: null;
if (
Number.isFinite(placement?.leadFromX) &&
Number.isFinite(placement?.leadFromY)
) {
anchorX = placement.leadFromX;
anchorY = placement.leadFromY;
} else if (rectIsFinite(placement?.rect)) {
@@ -401,7 +444,16 @@ class SpatialCandidateQueue {
* stay unboxed. Few-placement candidates already blocked by the
* monotonically growing collision field are dismissed before anchor scans.
*/
reset(candidates, count, anchors, anchorCount, attemptStamps, stamp, spatial, states) {
reset(
candidates,
count,
anchors,
anchorCount,
attemptStamps,
stamp,
spatial,
states,
) {
this.candidates = candidates;
this.count = count;
this.anchorCount = anchorCount;
@@ -529,10 +581,13 @@ class SpatialCandidateQueue {
const rectY = placementYs[rectIndex];
const rectW = placementWs[rectIndex];
const rectH = placementHs[rectIndex];
if (rectX < anchorRectX + anchorRectW + 4
&& rectX + rectW + 4 > anchorRectX
&& rectY < anchorRectY + anchorRectH + 4
&& rectY + rectH + 4 > anchorRectY) freeMask &= ~bit;
if (
rectX < anchorRectX + anchorRectW + 4 &&
rectX + rectW + 4 > anchorRectX &&
rectY < anchorRectY + anchorRectH + 4 &&
rectY + rectH + 4 > anchorRectY
)
freeMask &= ~bit;
}
placementMasks[i] = freeMask;
if (freeMask === 0 && this._dismissBlockedKey(i)) continue;
@@ -558,7 +613,8 @@ class SpatialCandidateQueue {
bestIndex = i;
continue;
}
const priorityDelta = (Number(candidate.priority) || 0) - (Number(best.priority) || 0);
const priorityDelta =
(Number(candidate.priority) || 0) - (Number(best.priority) || 0);
if (priorityDelta !== 0) {
if (priorityDelta > 0) {
best = candidate;
@@ -621,7 +677,8 @@ function firstFreePlacement(candidate, stickyCorner, spatial) {
if (stickyCorner) {
for (let i = 0; i < placements.length; i++) {
const placement = placements[i];
if (placement?.corner !== stickyCorner || !rectIsFinite(placement.rect)) continue;
if (placement?.corner !== stickyCorner || !rectIsFinite(placement.rect))
continue;
if (!spatial.collides(placement.rect)) return placement;
}
}
@@ -790,7 +847,13 @@ export class LabelArbiter {
let candidateCount = 0;
for (let i = 0; i < source.length; i++) {
const candidate = source[i];
if (!candidate || !candidate.key || !candidate.layerId || !(candidate.keyholeAlpha > 0)) continue;
if (
!candidate ||
!candidate.key ||
!candidate.layerId ||
!(candidate.keyholeAlpha > 0)
)
continue;
cacheCandidateAnchorScalars(candidate);
candidateList[candidateCount++] = candidate;
let bucket = byLayer.get(candidate.layerId);
@@ -815,13 +878,15 @@ export class LabelArbiter {
});
} else if (options.demandByLayer != null) {
const supplied = demandEntries(options.demandByLayer);
for (let i = 0; i < supplied.length; i++) demand.set(supplied[i][0], supplied[i][1]);
for (let i = 0; i < supplied.length; i++)
demand.set(supplied[i][0], supplied[i][1]);
}
// A supplied demand map describes the full lightweight field while the
// candidate list may be only a bounded materialized cohort. Keep defensive
// support for candidate layers omitted by an incomplete caller map.
byLayer.forEach((bucket, layerId) => {
if (bucket.count > 0 && !demand.has(layerId)) demand.set(layerId, bucket.count);
if (bucket.count > 0 && !demand.has(layerId))
demand.set(layerId, bucket.count);
});
const activeLayers = this._activeLayersScratch;
@@ -857,14 +922,20 @@ export class LabelArbiter {
let sameActiveLayers = activeCount === this.lastActiveLayers.length;
for (let i = 0; sameActiveLayers && i < activeCount; i++) {
if (activeLayers[i] !== this.lastActiveLayers[i]) sameActiveLayers = false;
if (activeLayers[i] !== this.lastActiveLayers[i])
sameActiveLayers = false;
}
const preserveIncumbents = options.preserveIncumbents !== false && sameActiveLayers;
const preserveIncumbents =
options.preserveIncumbents !== false && sameActiveLayers;
let spatialQueueBuildCount = 0;
let spatialQueueNextCount = 0;
const attempt = (candidate, allowOverlapFallback = false) => {
if (selectedCount >= capacity || attemptStamps.get(candidate.key) === stamp) return false;
if (
selectedCount >= capacity ||
attemptStamps.get(candidate.key) === stamp
)
return false;
attemptStamps.set(candidate.key, stamp);
const previous = this.states.get(candidate.key);
const stateless = candidate.stateless === true;
@@ -872,10 +943,12 @@ export class LabelArbiter {
// very next solve, as their stateless originals did) and carry no sticky
// corner, so above/below is re-decided from geometry every solve instead
// of being pinned by whichever side happened to be free once.
if (!stateless && !previous?.selected && previous?.cooldownUntil > now) return false;
if (!stateless && !previous?.selected && previous?.cooldownUntil > now)
return false;
const sticky = stateless ? undefined : previous?.corner;
let placement = firstFreePlacement(candidate, sticky, spatial);
if (!placement && allowOverlapFallback) placement = firstOrderedPlacement(candidate, sticky);
if (!placement && allowOverlapFallback)
placement = firstOrderedPlacement(candidate, sticky);
if (!placement) return false;
spatial.add(placement.rect);
selectStamps.set(candidate.key, stamp);
@@ -953,7 +1026,8 @@ export class LabelArbiter {
}
let retainedIncumbents = 0;
for (let i = 0; i < selectedCount; i++) {
if (this.selectedKeys.has(selectedCandidates[i].key)) retainedIncumbents++;
if (this.selectedKeys.has(selectedCandidates[i].key))
retainedIncumbents++;
}
const exitingStates = this._refreshStateList();
@@ -964,7 +1038,9 @@ export class LabelArbiter {
state.exitStartedAt = now;
// Stateless entries may be re-selected immediately; the cooldown exists
// to damp thrash for sources that shipped with hysteresis.
state.cooldownUntil = state.stateless ? 0 : now + FADE_OUT_MS + COOLDOWN_MS;
state.cooldownUntil = state.stateless
? 0
: now + FADE_OUT_MS + COOLDOWN_MS;
}
}
@@ -1002,7 +1078,8 @@ export class LabelArbiter {
this._stateListDirty = true;
}
if (labelsByLayer) {
labelsByLayer[candidate.layerId] = (labelsByLayer[candidate.layerId] || 0) + 1;
labelsByLayer[candidate.layerId] =
(labelsByLayer[candidate.layerId] || 0) + 1;
}
}
@@ -1013,7 +1090,10 @@ export class LabelArbiter {
const livingStates = this._refreshStateList();
for (let i = 0; i < livingStates.length; i++) {
const state = livingStates[i];
if (!state.selected && now - (state.exitStartedAt || now) > FADE_OUT_MS + COOLDOWN_MS) {
if (
!state.selected &&
now - (state.exitStartedAt || now) > FADE_OUT_MS + COOLDOWN_MS
) {
this.states.delete(state.key);
this._stateListDirty = true;
}
@@ -1026,25 +1106,28 @@ export class LabelArbiter {
});
for (let i = 0; i < droppedCount; i++) this.selectedKeys.delete(dropped[i]);
dropped.length = droppedCount;
for (let i = 0; i < selectedCount; i++) this.selectedKeys.add(selectedCandidates[i].key);
for (let i = 0; i < selectedCount; i++)
this.selectedKeys.add(selectedCandidates[i].key);
const previousActiveLayers = this.lastActiveLayers;
this.lastActiveLayers = activeLayers;
this._activeLayersScratch = previousActiveLayers;
this.solveRevision++;
this.lastDiagnostics = collectDiagnostics ? {
strategy,
capacity,
selectedCount,
quotas: Object.fromEntries(quotas),
demand: Object.fromEntries(demand),
labelsByLayer,
solveRevision: this.solveRevision,
spatialQueueBuildCount,
spatialQueueNextCount,
eligibleIncumbents,
retainedIncumbents,
} : null;
this.lastDiagnostics = collectDiagnostics
? {
strategy,
capacity,
selectedCount,
quotas: Object.fromEntries(quotas),
demand: Object.fromEntries(demand),
labelsByLayer,
solveRevision: this.solveRevision,
spatialQueueBuildCount,
spatialQueueNextCount,
eligibleIncumbents,
retainedIncumbents,
}
: null;
selectedCandidates.length = selectedCount;
selectedPlacements.length = selectedCount;
pruneStampMap(attemptStamps, candidateCount);
@@ -1057,9 +1140,15 @@ export class LabelArbiter {
* A caller-owned output array enables allocation-free per-frame rendering.
*/
renderEntries(currentCandidates, now = Date.now(), out = []) {
const current = currentCandidates instanceof Map
? currentCandidates
: new Map((currentCandidates || []).map((candidate) => [candidate.key, candidate]));
const current =
currentCandidates instanceof Map
? currentCandidates
: new Map(
(currentCandidates || []).map((candidate) => [
candidate.key,
candidate,
]),
);
const states = this._refreshStateList();
let outIndex = 0;
for (let i = 0; i < states.length; i++) {
@@ -1070,14 +1159,23 @@ export class LabelArbiter {
// fully painted this solve or gone. No enter ramp, no exit tail.
temporalAlpha = state.selected ? 1 : 0;
} else if (state.selected) {
temporalAlpha = Math.min(1, Math.max(0, (now - state.enterStartedAt) / FADE_IN_MS));
temporalAlpha = Math.min(
1,
Math.max(0, (now - state.enterStartedAt) / FADE_IN_MS),
);
} else {
temporalAlpha = 1 - Math.min(1, Math.max(0, (now - state.exitStartedAt) / FADE_OUT_MS));
temporalAlpha =
1 -
Math.min(1, Math.max(0, (now - state.exitStartedAt) / FADE_OUT_MS));
}
if (temporalAlpha <= 0) continue;
const candidate = current.get(state.key) || state.lastCandidate;
if (!candidate) continue;
const placement = renderPlacement(candidate, state.stateless ? undefined : state.corner, state.lastPlacement);
const placement = renderPlacement(
candidate,
state.stateless ? undefined : state.corner,
state.lastPlacement,
);
if (!placement) continue;
const entry = out[outIndex] || (out[outIndex] = {});
entry.candidate = candidate;
@@ -1100,11 +1198,16 @@ export class LabelArbiter {
for (const state of this.states.values()) {
if (!state.selected) {
if (!includeFading) continue;
if (!Number.isFinite(state.exitStartedAt) || now - state.exitStartedAt >= FADE_OUT_MS) continue;
if (
!Number.isFinite(state.exitStartedAt) ||
now - state.exitStartedAt >= FADE_OUT_MS
)
continue;
}
const candidate = state.lastCandidate;
if (!candidate?.layerId || candidate.sourceId == null) continue;
if (!grouped.has(candidate.layerId)) grouped.set(candidate.layerId, new Set());
if (!grouped.has(candidate.layerId))
grouped.set(candidate.layerId, new Set());
grouped.get(candidate.layerId).add(candidate.sourceId);
}
return grouped;
+438 -179
View File
File diff suppressed because it is too large. Load diff
+8 -3
View File
@@ -11,8 +11,10 @@ export * from './localGeojsonCore.js';
export function createLocalGeoJsonLayer(options) {
return createLayer(options, {
...localGeoJsonServices,
overlayHost: options.overlayHost === undefined
? localGeoJsonServices.overlayHost : options.overlayHost,
overlayHost:
options.overlayHost === undefined
? localGeoJsonServices.overlayHost
: options.overlayHost,
});
}
@@ -20,6 +22,9 @@ export function createLocalGeoJsonLayer(options) {
export function createLocalInfrastructureOverlayPublisher(options) {
return createPublisher({
...options,
host: options.host === undefined ? localGeoJsonServices.overlayHost : options.host,
host:
options.host === undefined
? localGeoJsonServices.overlayHost
: options.host,
});
}
+1 -6
View File
@@ -15,9 +15,4 @@ const fires = createFirmsHeatmapLayer({
source: 'NASA FIRMS · LIVE',
});
export default [
datacenters,
dams,
submarineCablesLayer,
fires,
];
export default [datacenters, dams, submarineCablesLayer, fires];
+4 -1
View File
@@ -1,4 +1,7 @@
import { createMeshFloorSampler } from '../services/meshFloorSampler.js';
import * as groundFloor from './groundFloor.js';
const meshFloorSampler = createMeshFloorSampler({ groundFloor, eventTarget: typeof window === 'undefined' ? null : window });
const meshFloorSampler = createMeshFloorSampler({
groundFloor,
eventTarget: typeof window === 'undefined' ? null : window,
});
export const { sampleMeshFloorCells, cachedGroundFloor } = meshFloorSampler;
+73 -18
View File
@@ -18,10 +18,13 @@ export const AWARENESS_RELATIONSHIP = Object.freeze({
* @param {{initialRadiusM?: number, maxRadiusM?: number}} [options] Bounds.
* @returns {{candidate: T, radiusM: number}|null} First match and radius used.
*/
export function findByDoublingRadius(search, {
initialRadiusM = AWARENESS_RADIUS_M,
maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
} = {}) {
export function findByDoublingRadius(
search,
{
initialRadiusM = AWARENESS_RADIUS_M,
maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
} = {},
) {
if (typeof search !== 'function') return null;
if (!Number.isFinite(initialRadiusM) || initialRadiusM <= 0) return null;
if (!Number.isFinite(maxRadiusM) || maxRadiusM < initialRadiusM) return null;
@@ -39,13 +42,22 @@ export function findByDoublingRadius(search, {
/** @param {number} meters @returns {string} */
export function formatAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
return meters < 1000
? `${Math.round(meters)} m`
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
}
/** Return a stable visible label without leaking missing values into UI text. */
export function formatAwarenessLabel(value) {
if (value && typeof value === 'object') {
for (const candidate of [value.callsign, value.name, value.label, value.id, value.icao24, value.mmsi]) {
for (const candidate of [
value.callsign,
value.name,
value.label,
value.id,
value.icao24,
value.mmsi,
]) {
const label = formatAwarenessLabel(candidate);
if (label !== '—') return label;
}
@@ -61,19 +73,41 @@ export function formatAwarenessLabel(value) {
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range
* claim in this MVP.
*/
export function summarizeAwarenessCohort(items, { available = true, stale = false, limit = 3 } = {}) {
if (!available) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed unavailable' };
if (stale) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed stale' };
export function summarizeAwarenessCohort(
items,
{ available = true, stale = false, limit = 3 } = {},
) {
if (!available)
return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: 'feed unavailable',
};
if (stale)
return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: 'feed stale',
};
const normalized = (Array.isArray(items) ? items : [])
.filter((item) => Number.isFinite(item?.distanceM ?? item?.distance)
&& (item.distanceM ?? item.distance) >= 0)
.filter(
(item) =>
Number.isFinite(item?.distanceM ?? item?.distance) &&
(item.distanceM ?? item.distance) >= 0,
)
.map((item) => ({ ...item, distanceM: item.distanceM ?? item.distance }))
.sort((a, b) => a.distanceM - b.distanceM);
return {
relationship: normalized.length ? AWARENESS_RELATIONSHIP.NEARBY : AWARENESS_RELATIONSHIP.UNKNOWN,
relationship: normalized.length
? AWARENESS_RELATIONSHIP.NEARBY
: AWARENESS_RELATIONSHIP.UNKNOWN,
count: normalized.length,
nearest: normalized.slice(0, limit),
reason: normalized.length ? 'observed or mapped nearby context' : 'no observed or mapped objects in current feeds',
reason: normalized.length
? 'observed or mapped nearby context'
: 'no observed or mapped objects in current feeds',
};
}
@@ -84,21 +118,40 @@ export function summarizeAwarenessCohort(items, { available = true, stale = fals
* @param {Iterable<string>} [visitedKeys] Previously visited layer/id keys.
* @returns {Array<{layerId: string, id: string, item: Object}>} Selectable targets.
*/
export function getAwarenessNavigationTargets(cohorts, subject = null, visitedKeys = []) {
export function getAwarenessNavigationTargets(
cohorts,
subject = null,
visitedKeys = [],
) {
const visited = new Set(visitedKeys);
const targets = [];
for (const cohort of Array.isArray(cohorts) ? cohorts : []) {
const layerId = cohort?.id;
if (!['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(layerId)) continue;
if (
![
'flights',
'military',
'ais-live-vessels',
'military-installations',
].includes(layerId)
)
continue;
const sourceItems = Array.isArray(cohort?.summary?.navigationNearest)
? cohort.summary.navigationNearest
: (Array.isArray(cohort?.summary?.nearest) ? cohort.summary.nearest : []);
: Array.isArray(cohort?.summary?.nearest)
? cohort.summary.nearest
: [];
for (const item of sourceItems) {
const id = item?.icao24 || item?.mmsi || item?.id;
if (!id) continue;
const key = `${layerId}:${id}`;
if (key === `${subject?.layerId}:${subject?.id}`) continue;
targets.push({ layerId, id: String(id), item, visited: visited.has(key) });
targets.push({
layerId,
id: String(id),
item,
visited: visited.has(key),
});
}
}
targets.sort((a, b) => {
@@ -108,7 +161,9 @@ export function getAwarenessNavigationTargets(cohorts, subject = null, visitedKe
// reached vessels, installations, or the other flight layer at all.
const visitedOrder = Number(a.visited) - Number(b.visited);
if (visitedOrder) return visitedOrder;
const subjectOrder = Number(a.layerId !== subject?.layerId) - Number(b.layerId !== subject?.layerId);
const subjectOrder =
Number(a.layerId !== subject?.layerId) -
Number(b.layerId !== subject?.layerId);
if (subjectOrder) return subjectOrder;
return (a.item.distanceM ?? Infinity) - (b.item.distanceM ?? Infinity);
});
+57 -18
View File
@@ -37,10 +37,14 @@ const MAX_FOOTPRINT_POINTS = 400;
* @returns {string} Display label, never empty.
*/
export function humanizeInstallationClass(klass) {
const key = String(klass || '').trim().toLowerCase();
const key = String(klass || '')
.trim()
.toLowerCase();
if (LABEL_BY_CLASS[key]) return LABEL_BY_CLASS[key];
const words = key.replaceAll('_', ' ').trim();
return words ? `${words[0].toUpperCase()}${words.slice(1)}` : 'Mapped installation';
return words
? `${words[0].toUpperCase()}${words.slice(1)}`
: 'Mapped installation';
}
function finiteLatitude(value) {
@@ -54,7 +58,8 @@ function finiteLongitude(value) {
function pointFrom(element) {
const lat = Number(element?.lat ?? element?.center?.lat);
const longitude = Number(element?.lon ?? element?.center?.lon);
if (finiteLatitude(lat) && finiteLongitude(longitude)) return { latitude: lat, longitude };
if (finiteLatitude(lat) && finiteLongitude(longitude))
return { latitude: lat, longitude };
// Bounds midpoint fallback (field test 2026-08-28, Warendorf: nothing drawn).
// The proxy asks for `out center tags geom`, but Overpass takes the LAST
@@ -67,8 +72,12 @@ function pointFrom(element) {
// recovers all of them without touching the query or the footprint path.
const bounds = element?.bounds;
// JSON nulls, booleans, and empty strings must not coerce to a false 0,0 site.
if (![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon]
.every((value) => typeof value === 'number' && Number.isFinite(value))) return null;
if (
![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon].every(
(value) => typeof value === 'number' && Number.isFinite(value),
)
)
return null;
const south = Number(bounds?.minlat);
const west = Number(bounds?.minlon);
const north = Number(bounds?.maxlat);
@@ -79,17 +88,28 @@ function pointFrom(element) {
// A single installation footprint is never wider than the request bbox cap
// (10°): an ascending box that spans the antimeridian (minlon -179, maxlon
// 179) passes the ordering check yet midpoints to longitude 0.
if (finiteLatitude(south) && finiteLatitude(north)
&& finiteLongitude(west) && finiteLongitude(east)
&& south <= north && west <= east
&& north - south <= 10 && east - west <= 10) {
if (
finiteLatitude(south) &&
finiteLatitude(north) &&
finiteLongitude(west) &&
finiteLongitude(east) &&
south <= north &&
west <= east &&
north - south <= 10 &&
east - west <= 10
) {
return { latitude: (south + north) / 2, longitude: (west + east) / 2 };
}
return null;
}
function footprintFrom(element) {
if (!Array.isArray(element?.geometry) || element.geometry.length < 3 || element.geometry.length > MAX_FOOTPRINT_POINTS) return null;
if (
!Array.isArray(element?.geometry) ||
element.geometry.length < 3 ||
element.geometry.length > MAX_FOOTPRINT_POINTS
)
return null;
const points = [];
for (const point of element.geometry) {
const latitude = Number(point?.lat);
@@ -106,21 +126,30 @@ function footprintFrom(element) {
* @param {string} [retrievedAt]
* @returns {{records: Array, droppedCount: number}}
*/
export function normalizeMilitaryInstallations(payload, retrievedAt = new Date().toISOString()) {
export function normalizeMilitaryInstallations(
payload,
retrievedAt = new Date().toISOString(),
) {
const records = [];
const ids = new Set();
let droppedCount = 0;
for (const element of Array.isArray(payload?.elements) ? payload.elements : []) {
for (const element of Array.isArray(payload?.elements)
? payload.elements
: []) {
const type = String(element?.type || '');
const osmId = Number(element?.id);
if (!['node', 'way', 'relation'].includes(type) || !Number.isSafeInteger(osmId)) {
if (
!['node', 'way', 'relation'].includes(type) ||
!Number.isSafeInteger(osmId)
) {
droppedCount += 1;
continue;
}
const id = `osm:${type}:${osmId}`;
const tags = element?.tags || {};
const klass = CLASS_BY_MILITARY_TAG[String(tags.military || '').toLowerCase()]
|| (tags.landuse === 'military' ? 'military_land' : null);
const klass =
CLASS_BY_MILITARY_TAG[String(tags.military || '').toLowerCase()] ||
(tags.landuse === 'military' ? 'military_land' : null);
const point = pointFrom(element);
if (!klass || !point || ids.has(id)) {
droppedCount += 1;
@@ -134,7 +163,9 @@ export function normalizeMilitaryInstallations(payload, retrievedAt = new Date()
// geometry, while a footprint-less way/relation has unknown extent.
osmType: type,
class: klass,
name: String(tags.name || tags['name:en'] || '').trim() || humanizeInstallationClass(klass),
name:
String(tags.name || tags['name:en'] || '').trim() ||
humanizeInstallationClass(klass),
...point,
footprint: footprintFrom(element),
sources: [{ name: 'OpenStreetMap', id: `${type}/${osmId}`, retrievedAt }],
@@ -152,6 +183,14 @@ export function isValidInstallationBoundingBox(value) {
const west = Number(box.west);
const north = Number(box.north);
const east = Number(box.east);
return finiteLatitude(south) && finiteLatitude(north) && finiteLongitude(west) && finiteLongitude(east)
&& south < north && west < east && north - south <= 10 && east - west <= 10;
return (
finiteLatitude(south) &&
finiteLatitude(north) &&
finiteLongitude(west) &&
finiteLongitude(east) &&
south < north &&
west < east &&
north - south <= 10 &&
east - west <= 10
);
}
+33 -7
View File
@@ -77,7 +77,9 @@ export const MODEL_TRAIL_ANCHOR_NATIVE = Object.freeze({
* trail anchor has to ride the same chain or it lands on a different axis than
* the aircraft it is supposed to be attached to. */
const AXIS_CORRECTION = Cesium.Matrix4.multiplyTransformation(
Cesium.Axis.Y_UP_TO_Z_UP, Cesium.Axis.Z_UP_TO_X_UP, new Cesium.Matrix4(),
Cesium.Axis.Y_UP_TO_Z_UP,
Cesium.Axis.Z_UP_TO_X_UP,
new Cesium.Matrix4(),
);
const _anchorScratch = new Cesium.Cartesian3();
const _chainScratch = new Cesium.Matrix4();
@@ -124,7 +126,11 @@ export function modelAnchorWorld(model, nativeAnchor, result) {
components || Cesium.Matrix4.IDENTITY,
_chainScratch,
);
Cesium.Matrix4.multiplyTransformation(_chainScratch, AXIS_CORRECTION, _chainScratch);
Cesium.Matrix4.multiplyTransformation(
_chainScratch,
AXIS_CORRECTION,
_chainScratch,
);
return Cesium.Matrix4.multiplyByPoint(_chainScratch, _anchorScratch, result);
}
@@ -220,9 +226,17 @@ export function trailHeadStart(start, anchor, center, radiusM, result) {
// a real trail.
if (!anchor || !center || !result) return start;
if (!Number.isFinite(radiusM) || radiusM <= 0) return start;
const startD = Math.hypot(start.x - center.x, start.y - center.y, start.z - center.z);
const startD = Math.hypot(
start.x - center.x,
start.y - center.y,
start.z - center.z,
);
if (startD >= radiusM) return start;
const anchorD = Math.hypot(anchor.x - center.x, anchor.y - center.y, anchor.z - center.z);
const anchorD = Math.hypot(
anchor.x - center.x,
anchor.y - center.y,
anchor.z - center.z,
);
// A degenerate shell (an anchor at or outside the envelope — no shipped asset
// has one, since every anchor is a measured hull point) leaves nothing to
// ramp across; fall back to the containment verdict rather than divide by it.
@@ -252,8 +266,20 @@ export function modelVisualAnchor(modelMatrix, nativeCenter, scale, result) {
const x = (nativeCenter?.[0] || 0) * safeScale;
const y = (nativeCenter?.[1] || 0) * safeScale;
const z = (nativeCenter?.[2] || 0) * safeScale;
result.x = modelMatrix[0] * x + modelMatrix[4] * y + modelMatrix[8] * z + modelMatrix[12];
result.y = modelMatrix[1] * x + modelMatrix[5] * y + modelMatrix[9] * z + modelMatrix[13];
result.z = modelMatrix[2] * x + modelMatrix[6] * y + modelMatrix[10] * z + modelMatrix[14];
result.x =
modelMatrix[0] * x +
modelMatrix[4] * y +
modelMatrix[8] * z +
modelMatrix[12];
result.y =
modelMatrix[1] * x +
modelMatrix[5] * y +
modelMatrix[9] * z +
modelMatrix[13];
result.z =
modelMatrix[2] * x +
modelMatrix[6] * y +
modelMatrix[10] * z +
modelMatrix[14];
return result;
}
+102 -27
View File
@@ -29,10 +29,14 @@ export function norm360(deg) {
export function liftRepeatedGroundFix(newest, candidatePosition, onGround) {
if (!onGround || !newest?.position || !candidatePosition) return false;
const stored = Cesium.Cartographic.fromCartesian(
newest.position, Cesium.Ellipsoid.WGS84, _mmCarto,
newest.position,
Cesium.Ellipsoid.WGS84,
_mmCarto,
);
const candidate = Cesium.Cartographic.fromCartesian(
candidatePosition, Cesium.Ellipsoid.WGS84, _mmCandidateCarto,
candidatePosition,
Cesium.Ellipsoid.WGS84,
_mmCandidateCarto,
);
if (!stored || !candidate || candidate.height <= stored.height) return false;
Cesium.Cartesian3.fromRadians(
@@ -62,7 +66,11 @@ export function courseBetweenCartesians(from, to, minChordM = 25) {
const dy = to.y - from.y;
const dz = to.z - from.z;
if (dx * dx + dy * dy + dz * dz < minChordM * minChordM) return null;
const carto = Cesium.Cartographic.fromCartesian(from, Cesium.Ellipsoid.WGS84, _mmCarto);
const carto = Cesium.Cartographic.fromCartesian(
from,
Cesium.Ellipsoid.WGS84,
_mmCarto,
);
if (!carto) return null;
const sLat = Math.sin(carto.latitude);
const cLat = Math.cos(carto.latitude);
@@ -110,7 +118,10 @@ export function speedRamp(speedMps) {
if (!Number.isFinite(speedMps)) return 1;
if (speedMps <= COURSE_TRACK_ONLY_MPS) return 0;
if (speedMps >= COURSE_CHORD_ONLY_MPS) return 1;
return (speedMps - COURSE_TRACK_ONLY_MPS) / (COURSE_CHORD_ONLY_MPS - COURSE_TRACK_ONLY_MPS);
return (
(speedMps - COURSE_TRACK_ONLY_MPS) /
(COURSE_CHORD_ONLY_MPS - COURSE_TRACK_ONLY_MPS)
);
}
/** Shortest-arc angle lerp: from → to by t (t=0 → from, t=1 → to). Also the
@@ -141,10 +152,16 @@ export function displayedKinematics({
reportedTrackDeg,
} = {}) {
return {
speedMps: Number.isFinite(derivedSpeedMps) ? Math.max(0, derivedSpeedMps)
: (Number.isFinite(reportedSpeedMps) ? Math.max(0, reportedSpeedMps) : null),
trackDeg: Number.isFinite(derivedTrackDeg) ? derivedTrackDeg
: (Number.isFinite(reportedTrackDeg) ? reportedTrackDeg : null),
speedMps: Number.isFinite(derivedSpeedMps)
? Math.max(0, derivedSpeedMps)
: Number.isFinite(reportedSpeedMps)
? Math.max(0, reportedSpeedMps)
: null,
trackDeg: Number.isFinite(derivedTrackDeg)
? derivedTrackDeg
: Number.isFinite(reportedTrackDeg)
? reportedTrackDeg
: null,
};
}
@@ -167,10 +184,17 @@ export function staleCoastLimitSeconds({
maximumSec = 300,
} = {}) {
const floor = Math.max(0, Number.isFinite(minimumSec) ? minimumSec : 60);
const ceiling = Math.max(floor, Number.isFinite(maximumSec) ? maximumSec : 300);
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(lastContactEpochMs)) return floor;
const ceiling = Math.max(
floor,
Number.isFinite(maximumSec) ? maximumSec : 300,
);
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(lastContactEpochMs))
return floor;
const contactLeadSec = Math.max(0, (lastContactEpochMs - fixEpochMs) / 1000);
const grace = Math.max(0, Number.isFinite(contactGraceSec) ? contactGraceSec : 60);
const grace = Math.max(
0,
Number.isFinite(contactGraceSec) ? contactGraceSec : 60,
);
return Math.min(ceiling, Math.max(floor, contactLeadSec + grace));
}
@@ -197,18 +221,31 @@ export function limitCourseStep(prevDeg, targetDeg, maxDegPerSec, dtSec) {
* is GPS-vector noise, not a turn (samples without a speed keep the legacy
* behavior: no way to tell, so they still count).
*/
export function estimateTurnRateDps(samples, noiseFloorDps = 0.4, maxDps = 4, minSpeedMps = 0) {
export function estimateTurnRateDps(
samples,
noiseFloorDps = 0.4,
maxDps = 4,
minSpeedMps = 0,
) {
if (!samples || samples.length < 2) return 0;
let sum = 0;
let n = 0;
for (let i = 1; i < samples.length; i++) {
const dt = samples[i].tSec - samples[i - 1].tSec;
if (dt < 2 || dt > 120) continue;
if (!Number.isFinite(samples[i].trackDeg) || !Number.isFinite(samples[i - 1].trackDeg)) continue;
if (
!Number.isFinite(samples[i].trackDeg) ||
!Number.isFinite(samples[i - 1].trackDeg)
)
continue;
if (minSpeedMps > 0) {
const v0 = samples[i - 1].speedMps;
const v1 = samples[i].speedMps;
if ((Number.isFinite(v0) && v0 < minSpeedMps) || (Number.isFinite(v1) && v1 < minSpeedMps)) continue;
if (
(Number.isFinite(v0) && v0 < minSpeedMps) ||
(Number.isFinite(v1) && v1 < minSpeedMps)
)
continue;
}
sum += norm180(samples[i].trackDeg - samples[i - 1].trackDeg) / dt;
n += 1;
@@ -274,13 +311,31 @@ const _scratchProjectArc = { east: 0, north: 0, endCourseDeg: 0 };
* @param {number} dtSec - Seconds ahead; 0 or negative returns the start.
* @returns {{lat: number, lon: number}} Projected coordinate, degrees.
*/
export function projectGroundArcLatLon(lat, lon, courseDeg, speedMps, turnRateDps, dtSec) {
export function projectGroundArcLatLon(
lat,
lon,
courseDeg,
speedMps,
turnRateDps,
dtSec,
) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return { lat, lon };
if (!Number.isFinite(courseDeg) || !Number.isFinite(speedMps) || !Number.isFinite(dtSec)
|| speedMps <= 0 || dtSec <= 0) {
if (
!Number.isFinite(courseDeg) ||
!Number.isFinite(speedMps) ||
!Number.isFinite(dtSec) ||
speedMps <= 0 ||
dtSec <= 0
) {
return { lat, lon };
}
const off = arcOffsetEnu(speedMps, courseDeg, turnRateDps || 0, dtSec, _scratchProjectArc);
const off = arcOffsetEnu(
speedMps,
courseDeg,
turnRateDps || 0,
dtSec,
_scratchProjectArc,
);
// Guard the pole: cos(lat) → 0 would send the longitude to infinity.
const cosLat = Math.max(Math.cos(lat * DEG), 1e-6);
return {
@@ -320,8 +375,15 @@ export function projectGroundArcLatLon(lat, lon, courseDeg, speedMps, turnRateDp
* @returns {Array<{lat: number, lon: number}>} Polyline, display position first.
*/
export function corridorPathLatLon({
extrapolating, displayLat, displayLon, courseDeg, speedMps, turnRateDps = 0,
fixLat, fixLon, lookaheadSec,
extrapolating,
displayLat,
displayLon,
courseDeg,
speedMps,
turnRateDps = 0,
fixLat,
fixLon,
lookaheadSec,
}) {
if (!Number.isFinite(displayLat) || !Number.isFinite(displayLon)) return [];
const start = { lat: displayLat, lon: displayLon };
@@ -338,9 +400,16 @@ export function corridorPathLatLon({
const n = Math.max(1, Math.ceil(arcLengthM / CORRIDOR_SAMPLE_SPACING_M));
const out = [start];
for (let i = 1; i <= n; i++) {
out.push(projectGroundArcLatLon(
displayLat, displayLon, courseDeg, speed, turnRateDps, cappedSec * (i / n),
));
out.push(
projectGroundArcLatLon(
displayLat,
displayLon,
courseDeg,
speed,
turnRateDps,
cappedSec * (i / n),
),
);
}
return out;
}
@@ -385,13 +454,17 @@ const _forwardFixOffset = new Cesium.Cartesian3();
* velocity: number, track: number}|null} Forward synthetic fix, or null.
*/
export function synthesizeForwardKinematicsFix(newest, next = {}) {
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs)) return null;
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs))
return null;
const startEpochMs = Number.isFinite(newest.epochMs)
? newest.epochMs
: Cesium.JulianDate.toDate(newest.time).getTime();
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs) return null;
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs)
return null;
const previousVelocity = Number.isFinite(newest.velocity) ? Math.max(0, newest.velocity) : 0;
const previousVelocity = Number.isFinite(newest.velocity)
? Math.max(0, newest.velocity)
: 0;
const previousTrack = Number.isFinite(newest.track) ? newest.track : 0;
const dtSec = (next.epochMs - startEpochMs) / 1000;
arcOffsetEnu(
@@ -421,7 +494,9 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
time: Cesium.JulianDate.fromDate(new Date(next.epochMs)),
epochMs: next.epochMs,
position,
velocity: Number.isFinite(next.velocity) ? Math.max(0, next.velocity) : previousVelocity,
velocity: Number.isFinite(next.velocity)
? Math.max(0, next.velocity)
: previousVelocity,
track: Number.isFinite(next.track) ? next.track : previousTrack,
};
}
+3 -2
View File
@@ -32,7 +32,8 @@ export function resolvePickId(picked) {
if (id === null || id === undefined) return undefined;
if (typeof id === 'object') {
// AIS vessel record (id object with .mmsi) or Cesium Entity (.id string)
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number') return id.mmsi;
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number')
return id.mmsi;
if (typeof id.id === 'string' || typeof id.id === 'number') return id.id;
return undefined;
}
@@ -40,7 +41,7 @@ export function resolvePickId(picked) {
};
let id = unwrap(picked.id);
if (id === undefined) id = unwrap(picked.primitive?.id);
return (typeof id === 'string' || typeof id === 'number') ? String(id) : null;
return typeof id === 'string' || typeof id === 'number' ? String(id) : null;
}
/**
+22 -13
View File
@@ -1,6 +1,7 @@
const RADIO_COUNTRY_MAX_LENGTH = 80;
const ISO_ALPHA_2_CODES = Object.freeze(`
const ISO_ALPHA_2_CODES = Object.freeze(
`
AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR
GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO
@@ -8,12 +9,16 @@ JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME
MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO
RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV
TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW
`.trim().split(/\s+/));
`
.trim()
.split(/\s+/),
);
const ISO_ALPHA_2_SET = new Set(ISO_ALPHA_2_CODES);
const ENGLISH_REGION_NAMES = typeof Intl?.DisplayNames === 'function'
? new Intl.DisplayNames(['en'], { type: 'region' })
: null;
const ENGLISH_REGION_NAMES =
typeof Intl?.DisplayNames === 'function'
? new Intl.DisplayNames(['en'], { type: 'region' })
: null;
function countryKey(value) {
return String(value || '')
@@ -29,7 +34,8 @@ function countryKey(value) {
const COUNTRY_NAME_TO_CODE = new Map();
for (const code of ISO_ALPHA_2_CODES) {
const displayName = ENGLISH_REGION_NAMES?.of(code);
if (displayName && displayName !== code) COUNTRY_NAME_TO_CODE.set(countryKey(displayName), code);
if (displayName && displayName !== code)
COUNTRY_NAME_TO_CODE.set(countryKey(displayName), code);
}
for (const [name, code] of Object.entries({
@@ -71,7 +77,8 @@ for (const [name, code] of Object.entries({
'east timor': 'TL',
'cabo verde': 'CV',
vatican: 'VA',
})) COUNTRY_NAME_TO_CODE.set(countryKey(name), code);
}))
COUNTRY_NAME_TO_CODE.set(countryKey(name), code);
function canonicalCountryName(code) {
return ENGLISH_REGION_NAMES?.of(code) || code;
@@ -90,17 +97,19 @@ export function normalizeRadioCountryInput(value) {
}
const trimmed = value.normalize('NFKC').trim().replace(/\s+/g, ' ');
if (
!trimmed
|| trimmed.length > RADIO_COUNTRY_MAX_LENGTH
|| /[\u0000-\u001f\u007f]/.test(trimmed)
|| !/^[\p{L}\p{M}.&'’()\-\s]+$/u.test(trimmed)
) return Object.freeze({ valid: false, empty: false, code: '', name: '' });
!trimmed ||
trimmed.length > RADIO_COUNTRY_MAX_LENGTH ||
/[\u0000-\u001f\u007f]/.test(trimmed) ||
!/^[\p{L}\p{M}.&'’()\-\s]+$/u.test(trimmed)
)
return Object.freeze({ valid: false, empty: false, code: '', name: '' });
const possibleCode = trimmed.toUpperCase();
const code = ISO_ALPHA_2_SET.has(possibleCode)
? possibleCode
: COUNTRY_NAME_TO_CODE.get(countryKey(trimmed)) || '';
if (!code) return Object.freeze({ valid: false, empty: false, code: '', name: '' });
if (!code)
return Object.freeze({ valid: false, empty: false, code: '', name: '' });
return Object.freeze({
valid: true,
empty: false,
+2 -1
View File
@@ -2,6 +2,7 @@ import { applicationServices } from '../services/application.js';
export * from './regionalModel.js';
/** Fetch a bounded regional brief through the same-origin dev/preview proxy. */
export async function fetchRegionalBrief(latitude, longitude, { signal } = {}) {
if (![latitude, longitude].every(Number.isFinite)) throw new Error('Valid coordinates are required');
if (![latitude, longitude].every(Number.isFinite))
throw new Error('Valid coordinates are required');
return applicationServices.regional.getBrief(latitude, longitude, { signal });
}
+52 -20
View File
@@ -1,13 +1,18 @@
const MAX_ARTICLES = 5;
function cleanText(value, maxLength = 180) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxLength);
return String(value || '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLength);
}
function safeHttpUrl(value) {
try {
const parsed = new URL(String(value || ''));
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null;
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
? parsed.href
: null;
} catch {
return null;
}
@@ -17,14 +22,27 @@ function safeHttpUrl(value) {
export function normalizeRegionalPlace(payload) {
const address = payload?.address || {};
const locality = cleanText(
address.city || address.town || address.village || address.municipality
|| address.hamlet || address.county,
address.city ||
address.town ||
address.village ||
address.municipality ||
address.hamlet ||
address.county,
90,
);
const region = cleanText(
address.state || address.region || address.county,
90,
);
const region = cleanText(address.state || address.region || address.county, 90);
const country = cleanText(address.country, 90);
const label = [locality, region].filter((value, index, values) => value && values.indexOf(value) === index)
.join(', ') || country || cleanText(payload?.display_name, 120);
const label =
[locality, region]
.filter(
(value, index, values) => value && values.indexOf(value) === index,
)
.join(', ') ||
country ||
cleanText(payload?.display_name, 120);
if (!label) return null;
return {
label,
@@ -51,11 +69,16 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate);
const publishedAt = compactDate
? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z`
: Number.isNaN(Date.parse(rawDate)) ? null : new Date(rawDate).toISOString();
: Number.isNaN(Date.parse(rawDate))
? null
: new Date(rawDate).toISOString();
articles.push({
title,
url,
domain: cleanText(row?.domain || new URL(url).hostname.replace(/^www\./, ''), 80),
domain: cleanText(
row?.domain || new URL(url).hostname.replace(/^www\./, ''),
80,
),
publishedAt,
sourceCountry: cleanText(row?.sourcecountry, 60) || null,
});
@@ -74,11 +97,15 @@ export function normalizeRegionalWeather(payload) {
};
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
const observedRaw = typeof current.time === 'string' && !/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time)
? `${current.time}Z`
: current.time;
const observedRaw =
typeof current.time === 'string' &&
!/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time)
? `${current.time}Z`
: current.time;
return {
observedAt: Number.isNaN(Date.parse(observedRaw)) ? null : new Date(observedRaw).toISOString(),
observedAt: Number.isNaN(Date.parse(observedRaw))
? null
: new Date(observedRaw).toISOString(),
temperatureC: numberOrNull(current.temperature_2m),
apparentTemperatureC: numberOrNull(current.apparent_temperature),
precipitationMm: numberOrNull(current.precipitation),
@@ -109,14 +136,19 @@ export function weatherCodeLabel(code) {
/** Great-circle distance used to avoid refetching a regional brief every animation frame. */
export function regionalDistanceM(from, to) {
if (![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(Number.isFinite)) {
if (
![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(
Number.isFinite,
)
) {
return Infinity;
}
const phi1 = from.latitude * Math.PI / 180;
const phi2 = to.latitude * Math.PI / 180;
const deltaPhi = (to.latitude - from.latitude) * Math.PI / 180;
const deltaLambda = (to.longitude - from.longitude) * Math.PI / 180;
const a = Math.sin(deltaPhi / 2) ** 2
+ Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
const phi1 = (from.latitude * Math.PI) / 180;
const phi2 = (to.latitude * Math.PI) / 180;
const deltaPhi = ((to.latitude - from.latitude) * Math.PI) / 180;
const deltaLambda = ((to.longitude - from.longitude) * Math.PI) / 180;
const a =
Math.sin(deltaPhi / 2) ** 2 +
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
+13 -2
View File
@@ -26,7 +26,13 @@
* none of the above are usable — the caller applies its OWN existing sticky/default
* fallback (this helper never invents one, so it can't drift from the caller's policy).
*/
export function pickRenderAltitudeM({ geoAltM, baroAltM, onGround, surfaceM, geoidN }) {
export function pickRenderAltitudeM({
geoAltM,
baroAltM,
onGround,
surfaceM,
geoidN,
}) {
if (onGround && Number.isFinite(surfaceM)) {
return surfaceM;
}
@@ -89,7 +95,12 @@ export function reuseGroundedSurfaceM(currentM, previousM) {
* @param {number|null|undefined} params.geoidN - Geoid undulation N here.
* @returns {number|null} The geoid guess, or null when it must not be used.
*/
export function geoidSurfaceLastResortM({ geoAltM, baroAltM, priorRenderM, geoidN }) {
export function geoidSurfaceLastResortM({
geoAltM,
baroAltM,
priorRenderM,
geoidN,
}) {
if (Number.isFinite(geoAltM) || Number.isFinite(baroAltM)) return null;
if (Number.isFinite(priorRenderM)) return null;
return Number.isFinite(geoidN) ? geoidN : null;
+12 -6
View File
@@ -34,11 +34,14 @@ export const RETRY_COOLDOWN_MAX_MS = 300_000;
* @param {() => number} [options.now] - Clock seam for tests.
* @returns {() => Promise<T>} Memoized loader; retries once the cooldown elapses.
*/
export function createRetryableLoader(load, {
cooldownMs = RETRY_COOLDOWN_MS,
maxCooldownMs = RETRY_COOLDOWN_MAX_MS,
now = () => Date.now(),
} = {}) {
export function createRetryableLoader(
load,
{
cooldownMs = RETRY_COOLDOWN_MS,
maxCooldownMs = RETRY_COOLDOWN_MAX_MS,
now = () => Date.now(),
} = {},
) {
/** @type {Promise<T>|null} */
let inflight = null;
/** @type {*} Last rejection, replayed to callers inside the cooldown. */
@@ -51,7 +54,10 @@ export function createRetryableLoader(load, {
return function loadOnce() {
if (inflight) return inflight;
if (hasFailure) {
const wait = Math.min(cooldownMs * 2 ** (consecutiveFailures - 1), maxCooldownMs);
const wait = Math.min(
cooldownMs * 2 ** (consecutiveFailures - 1),
maxCooldownMs,
);
// Replay the recorded reason rather than re-running a loader that is
// probably still broken. Callers see identical behavior either way.
if (now() - failedAt < wait) return Promise.reject(failure);
+44 -12
View File
@@ -19,7 +19,8 @@ export function greatCircleKm(lat1, lon1, lat2, lon2) {
const p2 = lat2 * D2R;
const dp = (lat2 - lat1) * D2R;
const dl = (lon2 - lon1) * D2R;
const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
const a =
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
return R_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
@@ -28,7 +29,8 @@ function bearingRad(lat1, lon1, lat2, lon2) {
const p2 = lat2 * D2R;
const dl = (lon2 - lon1) * D2R;
const y = Math.sin(dl) * Math.cos(p2);
const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
const x =
Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
return Math.atan2(y, x);
}
@@ -42,8 +44,8 @@ export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) {
const NEAR_ENDPOINT_KM = 130;
const CROSS_TRACK_KM = 200;
const LOW_ALT_M = 3700; // ~12 000 ft
const VERT_TREND_MPS = 2; // ~400 fpm
const LOW_ALT_M = 3700; // ~12 000 ft
const VERT_TREND_MPS = 2; // ~400 fpm
const LOCAL_AIRPORT_KM = 150;
/**
@@ -55,16 +57,35 @@ const LOCAL_AIRPORT_KM = 150;
* @param {{lat:number|null, lon:number|null}|null} [p.destination]
* @returns {boolean} false ONLY when the route is confidently wrong.
*/
export function routePlausible({ latDeg, lonDeg, altitudeM = null, verticalRateMps = null, origin = null, destination = null }) {
export function routePlausible({
latDeg,
lonDeg,
altitudeM = null,
verticalRateMps = null,
origin = null,
destination = null,
}) {
const haveO = Number.isFinite(origin?.lat) && Number.isFinite(origin?.lon);
const haveD = Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
const haveD =
Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
if (!haveO && !haveD) return true; // no coordinates — cannot judge, do not hide
// (a) Geographic consistency: near an endpoint, or roughly on the path.
const near = (pt) => greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM;
const near = (pt) =>
greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM;
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
if (!geomOk && haveO && haveD) {
geomOk = Math.abs(crossTrackKm(latDeg, lonDeg, origin.lat, origin.lon, destination.lat, destination.lon)) < CROSS_TRACK_KM;
geomOk =
Math.abs(
crossTrackKm(
latDeg,
lonDeg,
origin.lat,
origin.lon,
destination.lat,
destination.lon,
),
) < CROSS_TRACK_KM;
} else if (!geomOk) {
geomOk = true; // only one endpoint known and not near — cannot judge, allow
}
@@ -72,13 +93,24 @@ export function routePlausible({ latDeg, lonDeg, altitudeM = null, verticalRateM
// (b) Vertical-trend consistency for low traffic (observer-free adaptation).
if (
Number.isFinite(altitudeM) && altitudeM < LOW_ALT_M &&
Number.isFinite(verticalRateMps) && Math.abs(verticalRateMps) > VERT_TREND_MPS
Number.isFinite(altitudeM) &&
altitudeM < LOW_ALT_M &&
Number.isFinite(verticalRateMps) &&
Math.abs(verticalRateMps) > VERT_TREND_MPS
) {
if (verticalRateMps > 0) {
if (haveO && greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM) return false; // departing — origin should be local
if (
haveO &&
greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM
)
return false; // departing — origin should be local
} else {
if (haveD && greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) > LOCAL_AIRPORT_KM) return false; // arriving — destination should be local
if (
haveD &&
greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) >
LOCAL_AIRPORT_KM
)
return false; // arriving — destination should be local
}
}
return true;
+19 -4
View File
@@ -69,7 +69,13 @@ export const SATELLITE_CLASSES = Object.freeze({
});
/** Legend/report order for the classes above. */
export const SATELLITE_CLASS_ORDER = Object.freeze(['station', 'nav', 'geo', 'visual', 'comms']);
export const SATELLITE_CLASS_ORDER = Object.freeze([
'station',
'nav',
'geo',
'visual',
'comms',
]);
/**
* CelesTrak group tag → { class, subtype }. Subtype names the specific
@@ -150,8 +156,11 @@ export function satelliteClassLabel(group, { isIss = false } = {}) {
export function tallySatelliteClasses(entries) {
const counts = Object.create(null);
for (const entry of entries || []) {
const descriptor = entry && typeof entry === 'object' ? entry : { group: entry };
const { klass } = satelliteClassOf(descriptor.group, { isIss: descriptor.isIss });
const descriptor =
entry && typeof entry === 'object' ? entry : { group: entry };
const { klass } = satelliteClassOf(descriptor.group, {
isIss: descriptor.isIss,
});
counts[klass] = (counts[klass] || 0) + 1;
}
return counts;
@@ -171,7 +180,13 @@ export function satelliteClassLegend(counts) {
const count = counts?.[klass];
if (!(count > 0)) continue;
const spec = SATELLITE_CLASSES[klass];
result.push({ klass, label: spec.label, color: spec.color, blurb: spec.blurb, count });
result.push({
klass,
label: spec.label,
color: spec.color,
blurb: spec.blurb,
count,
});
}
return result;
}
+2 -1
View File
@@ -42,7 +42,8 @@ const MAX_PICK_MAGNITUDE_M = 1_000_000_000;
export function isPickedWorldPosition(position) {
if (!position) return false;
const { x, y, z } = position;
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return false;
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
return false;
const magnitude = Math.hypot(x, y, z);
return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M;
}
+4 -2
View File
@@ -47,7 +47,8 @@ export function restoreSpriteOrder(viewer) {
if (!viewer || viewer.isDestroyed?.()) return;
const scene = viewer.scene;
const primitives = scene?.primitives;
if (!primitives || scene.isDestroyed?.() || primitives.isDestroyed?.()) return;
if (!primitives || scene.isDestroyed?.() || primitives.isDestroyed?.())
return;
for (const layerId of SPRITE_LAYER_ORDER) {
const collection = _collections.get(layerId);
@@ -71,6 +72,7 @@ export function restoreSpriteOrderOnEnable(
viewer,
restore = restoreSpriteOrder,
) {
if (!SPRITE_LAYER_ORDER.includes(layerId) || typeof restore !== 'function') return;
if (!SPRITE_LAYER_ORDER.includes(layerId) || typeof restore !== 'function')
return;
restore(viewer);
}
+8 -2
View File
@@ -1,4 +1,10 @@
import { createTerrainHeights } from '../services/terrainHeights.js';
import { applicationServices } from '../services/application.js';
const terrainHeights = createTerrainHeights({ source: applicationServices.terrain });
export const { cachedEllipsoidalGround, cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrainHeights;
const terrainHeights = createTerrainHeights({
source: applicationServices.terrain,
});
export const {
cachedEllipsoidalGround,
cachedRealEllipsoidalGround,
resolveEllipsoidalGround,
} = terrainHeights;
+3 -1
View File
@@ -38,7 +38,9 @@ export const TR3B_CLASS = 'tr3b';
/** @param {*} id Raw contact id. @returns {string} Normalized registry key. */
function key(id) {
return String(id ?? '').trim().toLowerCase();
return String(id ?? '')
.trim()
.toLowerCase();
}
/**
+37 -19
View File
@@ -26,17 +26,23 @@ export function trackedModelScaleForPixelCap({
maximumPixelSize,
}) {
if (
!Number.isFinite(baseScale) || baseScale <= 0
|| !Number.isFinite(nativeRadiusM) || nativeRadiusM <= 0
|| !Number.isFinite(rangeM) || rangeM <= 0
|| !Number.isFinite(viewportHeightPx) || viewportHeightPx <= 0
|| !Number.isFinite(fovyRad) || fovyRad <= 0
|| !Number.isFinite(maximumPixelSize) || maximumPixelSize <= 0
) return baseScale;
!Number.isFinite(baseScale) ||
baseScale <= 0 ||
!Number.isFinite(nativeRadiusM) ||
nativeRadiusM <= 0 ||
!Number.isFinite(rangeM) ||
rangeM <= 0 ||
!Number.isFinite(viewportHeightPx) ||
viewportHeightPx <= 0 ||
!Number.isFinite(fovyRad) ||
fovyRad <= 0 ||
!Number.isFinite(maximumPixelSize) ||
maximumPixelSize <= 0
)
return baseScale;
const focalLengthPx = viewportHeightPx / (2 * Math.tan(fovyRad / 2));
const projectedDiameterPx = (
2 * nativeRadiusM * baseScale * focalLengthPx
) / rangeM;
const projectedDiameterPx =
(2 * nativeRadiusM * baseScale * focalLengthPx) / rangeM;
if (projectedDiameterPx <= maximumPixelSize) return baseScale;
return baseScale * (maximumPixelSize / projectedDiameterPx);
}
@@ -99,17 +105,25 @@ export function clampTrackedCameraPosition(
previousPosition,
minimumRangeM = MIN_TRACKED_RANGE_M,
) {
const crossedOrigin = Cesium.Cartesian3.dot(camera.position, previousPosition) <= 0;
const forwardDistance = -Cesium.Cartesian3.dot(camera.position, camera.direction);
const crossedOrigin =
Cesium.Cartesian3.dot(camera.position, previousPosition) <= 0;
const forwardDistance = -Cesium.Cartesian3.dot(
camera.position,
camera.direction,
);
const rangeSquared = Cesium.Cartesian3.magnitudeSquared(camera.position);
if (crossedOrigin) {
Cesium.Cartesian3.normalize(previousPosition, camera.position);
Cesium.Cartesian3.multiplyByScalar(camera.position, minimumRangeM, camera.position);
Cesium.Cartesian3.multiplyByScalar(
camera.position,
minimumRangeM,
camera.position,
);
return true;
}
if (
forwardDistance < minimumRangeM
|| rangeSquared < minimumRangeM * minimumRangeM
forwardDistance < minimumRangeM ||
rangeSquared < minimumRangeM * minimumRangeM
) {
Cesium.Cartesian3.multiplyByScalar(
camera.direction,
@@ -192,10 +206,14 @@ export function applyTrackedCameraFrame(viewer, entity, viewFrom) {
Cesium.Ellipsoid.WGS84,
trackedTransform,
);
const offset = typeof viewFrom.getValue === 'function'
? viewFrom.getValue(viewer.clock.currentTime, resolvedViewFrom)
: Cesium.Cartesian3.clone(viewFrom, resolvedViewFrom);
if (!offset || Cesium.Cartesian3.magnitudeSquared(offset) < Cesium.Math.EPSILON12) {
const offset =
typeof viewFrom.getValue === 'function'
? viewFrom.getValue(viewer.clock.currentTime, resolvedViewFrom)
: Cesium.Cartesian3.clone(viewFrom, resolvedViewFrom);
if (
!offset ||
Cesium.Cartesian3.magnitudeSquared(offset) < Cesium.Math.EPSILON12
) {
stop();
return;
}
+4 -1
View File
@@ -76,5 +76,8 @@ export const TRACKED_MODEL_EXIT_ALT_M =
*/
export function trackedModelZoomActive(cameraHeightM, wasActive = false) {
if (!Number.isFinite(cameraHeightM)) return false;
return cameraHeightM < (wasActive ? TRACKED_MODEL_EXIT_ALT_M : TRACKED_MODEL_ENTER_ALT_M);
return (
cameraHeightM <
(wasActive ? TRACKED_MODEL_EXIT_ALT_M : TRACKED_MODEL_ENTER_ALT_M)
);
}
+59 -21
View File
@@ -49,17 +49,24 @@ let _overlayHost = DEFAULT_TRACKED_OVERLAY_HOST;
* @param {string} accent Source-owned accent color.
* @returns {{title:string,details:string[],accent:string}}
*/
export function trackedLabelModelFromText(text, accent = WORLD_OVERLAY_STYLE.accent) {
export function trackedLabelModelFromText(
text,
accent = WORLD_OVERLAY_STYLE.accent,
) {
const raw = String(text || '').trim();
if (!raw) return { title: '', details: [], accent };
let lines;
if (raw.includes('\n')) {
lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
lines = raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
} else {
const parts = raw.split(' · ');
lines = parts.length > 1
? [parts[0].trim(), parts.slice(1).join(' · ').trim()]
: [raw];
lines =
parts.length > 1
? [parts[0].trim(), parts.slice(1).join(' · ').trim()]
: [raw];
}
return { title: lines[0] || '', details: lines.slice(1), accent };
}
@@ -101,7 +108,9 @@ export function cachedTrackedVisualPosition(entity) {
try {
const visual = entity.gevVisualPosition();
if (visual) return visual;
} catch { /* fall through to the display position */ }
} catch {
/* fall through to the display position */
}
}
return cachedTrackedDisplayPosition(entity);
}
@@ -140,7 +149,9 @@ export function createTrackedOverlayEntry(entity) {
collisionGroup: 'ambient-card',
priority: Number.MAX_SAFE_INTEGER,
title,
details: Array.isArray(model.details) ? model.details.map((line) => String(line)) : [],
details: Array.isArray(model.details)
? model.details.map((line) => String(line))
: [],
accent: model.accent || WORLD_OVERLAY_STYLE.accent,
cardStyle: model.cardStyle,
selected: model.selected === true,
@@ -149,9 +160,14 @@ export function createTrackedOverlayEntry(entity) {
leaderAnimationStartedAt: model.leaderAnimationStartedAt,
leaderDrawRatio: model.leaderDrawRatio,
anchorRadiusPx: Number.isFinite(Number(model.anchorRadiusPx))
? Math.max(0, Number(model.anchorRadiusPx)) : 10,
anchorRadiusScale: Object.prototype.hasOwnProperty.call(model, 'anchorRadiusScale')
? model.anchorRadiusScale : TRACKED_BILLBOARD_SCALE,
? Math.max(0, Number(model.anchorRadiusPx))
: 10,
anchorRadiusScale: Object.prototype.hasOwnProperty.call(
model,
'anchorRadiusScale',
)
? model.anchorRadiusScale
: TRACKED_BILLBOARD_SCALE,
minAnchorGapPx: 16,
anchorGapPaddingPx: 10,
verticalOnly: true,
@@ -203,7 +219,10 @@ export function getActiveTrackedReadoutId() {
}
/** Static-context layers whose click selection publishes the readout card. */
const READOUT_CONTEXT_LAYERS = new Set(['military-installations', 'alpr-cameras']);
const READOUT_CONTEXT_LAYERS = new Set([
'military-installations',
'alpr-cameras',
]);
/**
* Initialize the model bridge and selection listeners. No render listener is
@@ -215,10 +234,11 @@ export function initTrackedReadout(viewer) {
if (_viewer) destroyTrackedReadout();
_viewer = viewer;
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true);
_trackedEntityChangedRemove = viewer.trackedEntityChanged?.addEventListener?.(() => {
if (viewer.trackedEntity) _selectedContext = null;
syncActiveEntity();
}) || null;
_trackedEntityChangedRemove =
viewer.trackedEntityChanged?.addEventListener?.(() => {
if (viewer.trackedEntity) _selectedContext = null;
syncActiveEntity();
}) || null;
_contextSelectedHandler = (event) => {
const record = event.detail;
if (READOUT_CONTEXT_LAYERS.has(record?.layerId)) {
@@ -232,7 +252,10 @@ export function initTrackedReadout(viewer) {
}
};
_contextClearedHandler = (event) => {
if (!_selectedContext || event.detail?.layerId === _selectedContext.layerId) {
if (
!_selectedContext ||
event.detail?.layerId === _selectedContext.layerId
) {
_selectedContext = null;
syncActiveEntity();
}
@@ -242,8 +265,14 @@ export function initTrackedReadout(viewer) {
if (!_viewer?.trackedEntity) clearTrackedSource();
};
window.addEventListener('gev:entity-selected', _contextSelectedHandler);
window.addEventListener('gev:entity-selection-cleared', _contextClearedHandler);
window.addEventListener('gev:awareness-subject-selected', _aircraftSelectedHandler);
window.addEventListener(
'gev:entity-selection-cleared',
_contextClearedHandler,
);
window.addEventListener(
'gev:awareness-subject-selected',
_aircraftSelectedHandler,
);
syncActiveEntity();
// Boot-verification contract: the track regression harness asserts this
// exact line at init (dropped by the host migration; restored).
@@ -254,9 +283,18 @@ export function initTrackedReadout(viewer) {
export function destroyTrackedReadout() {
_trackedEntityChangedRemove?.();
_trackedEntityChangedRemove = null;
if (_contextSelectedHandler) window.removeEventListener('gev:entity-selected', _contextSelectedHandler);
if (_contextClearedHandler) window.removeEventListener('gev:entity-selection-cleared', _contextClearedHandler);
if (_aircraftSelectedHandler) window.removeEventListener('gev:awareness-subject-selected', _aircraftSelectedHandler);
if (_contextSelectedHandler)
window.removeEventListener('gev:entity-selected', _contextSelectedHandler);
if (_contextClearedHandler)
window.removeEventListener(
'gev:entity-selection-cleared',
_contextClearedHandler,
);
if (_aircraftSelectedHandler)
window.removeEventListener(
'gev:awareness-subject-selected',
_aircraftSelectedHandler,
);
_contextSelectedHandler = null;
_contextClearedHandler = null;
_aircraftSelectedHandler = null;
+10 -3
View File
@@ -28,8 +28,10 @@ export function isTrackingClickGesture(gesture = {}) {
const durationMs = Number.isFinite(gesture.durationMs)
? Math.max(0, gesture.durationMs)
: Number.POSITIVE_INFINITY;
return isTrackingSelectionGesture(gesture)
&& durationMs <= MAX_TRACKING_CLICK_DURATION_MS;
return (
isTrackingSelectionGesture(gesture) &&
durationMs <= MAX_TRACKING_CLICK_DURATION_MS
);
}
/**
@@ -53,7 +55,12 @@ export function bindTrackingClickGesture(handler, onClick, options = {}) {
let completedGesture = null;
const appendTravel = (position) => {
if (!pressActive || !Number.isFinite(position?.x) || !Number.isFinite(position?.y)) return;
if (
!pressActive ||
!Number.isFinite(position?.x) ||
!Number.isFinite(position?.y)
)
return;
if (previousPosition) {
travelPx += Math.hypot(
position.x - previousPosition.x,
+19 -9
View File
@@ -33,8 +33,8 @@ export function greatCircleKm(lat1, lon1, lat2, lon2) {
const p2 = toRad(lat2);
const dp = toRad(lat2 - lat1);
const dl = toRad(lon2 - lon1);
const a = Math.sin(dp / 2) ** 2
+ Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
const a =
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
}
@@ -49,7 +49,8 @@ function initialBearingRad(lat1, lon1, lat2, lon2) {
const p2 = toRad(lat2);
const dl = toRad(lon2 - lon1);
const y = Math.sin(dl) * Math.cos(p2);
const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
const x =
Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
return Math.atan2(y, x);
}
@@ -68,12 +69,15 @@ function destinationPoint(lat, lon, bearingRad, distKm) {
const p1 = toRad(lat);
const l1 = toRad(lon);
const p2 = Math.asin(
Math.sin(p1) * Math.cos(delta) + Math.cos(p1) * Math.sin(delta) * Math.cos(bearingRad)
);
const l2 = l1 + Math.atan2(
Math.sin(bearingRad) * Math.sin(delta) * Math.cos(p1),
Math.cos(delta) - Math.sin(p1) * Math.sin(p2)
Math.sin(p1) * Math.cos(delta) +
Math.cos(p1) * Math.sin(delta) * Math.cos(bearingRad),
);
const l2 =
l1 +
Math.atan2(
Math.sin(bearingRad) * Math.sin(delta) * Math.cos(p1),
Math.cos(delta) - Math.sin(p1) * Math.sin(p2),
);
// Normalize longitude to [-180, 180)
const lonDeg = ((toDeg(l2) + 540) % 360) - 180;
return { lat: toDeg(p2), lon: lonDeg };
@@ -100,7 +104,13 @@ function destinationPoint(lat, lon, bearingRad, distKm) {
* @returns {{lat:number, lon:number, source:'hit'|'nadir'|'pulled'}}
* The fetch center and which rule produced it.
*/
export function deriveFetchCenter({ nadirLat, nadirLon, hitLat, hitLon, maxPullKm = 12 }) {
export function deriveFetchCenter({
nadirLat,
nadirLon,
hitLat,
hitLon,
maxPullKm = 12,
}) {
if (!Number.isFinite(hitLat) || !Number.isFinite(hitLon)) {
return { lat: nadirLat, lon: nadirLon, source: 'nadir' };
}
+30 -9
View File
@@ -36,9 +36,9 @@
/** @const {Object<string,'mono'|'crt'>} Style name → non-normal profile. */
const PROFILE_BY_STYLE = {
surveillance: 'mono', // NVG — P43 phosphor × luma
thermal: 'mono', // FLIR — grayscale/ironbow × luma
noir: 'mono', // full desaturation
retro: 'crt', // CRT — hue survives, small dots don't
thermal: 'mono', // FLIR — grayscale/ironbow × luma
noir: 'mono', // full desaturation
retro: 'crt', // CRT — hue survives, small dots don't
};
/**
@@ -49,13 +49,33 @@ const PROFILE_BY_STYLE = {
*/
const DOT_STYLE = {
mono: {
jam: { rgba: [255, 255, 255, 0.95], sizeDelta: 3, outline: { rgba: [0, 0, 0, 0.9], width: 2 } },
slow: { rgba: [255, 255, 255, 0.9], sizeDelta: 1, outline: { rgba: [0, 0, 0, 0.85], width: 1 } },
free: { rgba: [255, 255, 255, 0.85], sizeDelta: 0, outline: { rgba: [0, 0, 0, 0.8], width: 1 } },
jam: {
rgba: [255, 255, 255, 0.95],
sizeDelta: 3,
outline: { rgba: [0, 0, 0, 0.9], width: 2 },
},
slow: {
rgba: [255, 255, 255, 0.9],
sizeDelta: 1,
outline: { rgba: [0, 0, 0, 0.85], width: 1 },
},
free: {
rgba: [255, 255, 255, 0.85],
sizeDelta: 0,
outline: { rgba: [0, 0, 0, 0.8], width: 1 },
},
},
crt: {
jam: { rgba: [255, 59, 48, 0.95], sizeDelta: 3, outline: { rgba: [0, 0, 0, 0.9], width: 2 } },
slow: { rgba: [255, 179, 0, 0.92], sizeDelta: 2, outline: { rgba: [0, 0, 0, 0.85], width: 1 } },
jam: {
rgba: [255, 59, 48, 0.95],
sizeDelta: 3,
outline: { rgba: [0, 0, 0, 0.9], width: 2 },
},
slow: {
rgba: [255, 179, 0, 0.92],
sizeDelta: 2,
outline: { rgba: [0, 0, 0, 0.85], width: 1 },
},
free: { rgba: [0, 255, 102, 0.9], sizeDelta: 1, outline: null },
},
};
@@ -123,7 +143,8 @@ export function presetDotOutline(styleName, bucket) {
* @returns {string|null} Tier key, or null (no override → stock 'vehicle').
*/
export function trafficBucketTier(bucket) {
if (bucket === 'free' || bucket === 'slow' || bucket === 'jam') return `veh_${bucket}`;
if (bucket === 'free' || bucket === 'slow' || bucket === 'jam')
return `veh_${bucket}`;
if (bucket === 'sim') return 'veh_nodata';
return null;
}
+3 -2
View File
@@ -33,13 +33,14 @@ const GAP_SPREAD_M = 6;
*/
export function queuePlatoons(totalLen, count, rng = Math.random) {
const platoons = [];
if (!Number.isFinite(totalLen) || totalLen <= 0 || !(count > 0)) return platoons;
if (!Number.isFinite(totalLen) || totalLen <= 0 || !(count > 0))
return platoons;
let placed = 0;
while (placed < count) {
const platoonSize = Math.min(
count - placed,
PLATOON_MIN + Math.floor(rng() * PLATOON_SPREAD)
PLATOON_MIN + Math.floor(rng() * PLATOON_SPREAD),
);
const anchor = rng() * totalLen;
const platoon = [];
+14 -3
View File
@@ -28,7 +28,9 @@ import { registerPickOwner } from './pickRegistry.js';
// plane being tracked. Claiming the 'gev-trail:' id namespace makes
// isOwnedByOtherLayer() true for every layer — clicking a trail is a no-op
// everywhere. Registered once at module load; the predicate is pure.
registerPickOwner('trails', (pickedId) => String(pickedId).startsWith('gev-trail:'));
registerPickOwner('trails', (pickedId) =>
String(pickedId).startsWith('gev-trail:'),
);
/** @type {number} Uniquifier for trail entity ids (Cesium requires unique entity ids). */
let _trailSeq = 0;
@@ -99,7 +101,12 @@ export function createTrail(viewer, { color, width = 2.5 }) {
for (const position of Array.isArray(cartesians) ? cartesians : []) {
if (!position) continue;
const last = positions[positions.length - 1];
if (last && Cesium.Cartesian3.distanceSquared(last, position) < MIN_SEGMENT_DISTANCE_SQ) continue;
if (
last &&
Cesium.Cartesian3.distanceSquared(last, position) <
MIN_SEGMENT_DISTANCE_SQ
)
continue;
positions.push(position);
}
current = positions.length >= 2 ? positions : [];
@@ -122,7 +129,11 @@ export function createTrail(viewer, { color, width = 2.5 }) {
destroyed = true;
current = [];
if (entity && viewer && !viewer.isDestroyed()) {
try { viewer.entities.remove(entity); } catch { /* torn down */ }
try {
viewer.entities.remove(entity);
} catch {
/* torn down */
}
}
entity = null;
},
+51 -12
View File
@@ -20,21 +20,48 @@ export const VESSEL_CARD_FADE_DISTANCE_M = 5_000_000;
*/
const TYPE_STYLES = [
{ pattern: /tanker/i, css: '#ffb347', accent: '255, 179, 71' },
{ pattern: /cargo|container|bulk|carrier/i, css: '#39d5ff', accent: '57, 213, 255' },
{ pattern: /passenger|ferry|cruise/i, css: '#ff7adf', accent: '255, 122, 223' },
{
pattern: /cargo|container|bulk|carrier/i,
css: '#39d5ff',
accent: '57, 213, 255',
},
{
pattern: /passenger|ferry|cruise/i,
css: '#ff7adf',
accent: '255, 122, 223',
},
{ pattern: /fishing/i, css: '#7cff9b', accent: '124, 255, 155' },
{ pattern: /tug|tow|pilot|supply|service/i, css: '#f7f0a3', accent: '247, 240, 163' },
{
pattern: /tug|tow|pilot|supply|service/i,
css: '#f7f0a3',
accent: '247, 240, 163',
},
];
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
const NUMERIC_TYPE_SPECIALS = {
30: 'FISHING', 31: 'TOWING', 32: 'TOWING', 33: 'DREDGER', 34: 'DIVE OPS',
35: 'MILITARY', 36: 'SAILING', 37: 'PLEASURE',
50: 'PILOT', 51: 'SAR', 52: 'TUG', 53: 'PORT TENDER', 54: 'ANTI-POLLUTION',
55: 'LAW ENFORCE', 58: 'MEDICAL',
30: 'FISHING',
31: 'TOWING',
32: 'TOWING',
33: 'DREDGER',
34: 'DIVE OPS',
35: 'MILITARY',
36: 'SAILING',
37: 'PLEASURE',
50: 'PILOT',
51: 'SAR',
52: 'TUG',
53: 'PORT TENDER',
54: 'ANTI-POLLUTION',
55: 'LAW ENFORCE',
58: 'MEDICAL',
};
const NUMERIC_TYPE_FAMILIES = {
4: 'HIGH-SPEED', 6: 'PASSENGER', 7: 'CARGO', 8: 'TANKER', 9: 'OTHER',
4: 'HIGH-SPEED',
6: 'PASSENGER',
7: 'CARGO',
8: 'TANKER',
9: 'OTHER',
};
/**
@@ -76,13 +103,22 @@ function styleForType(type) {
* @param {number} [rowLimit=900] Configured source row ceiling.
* @returns {number}
*/
export function vesselOverlayCohortLimit(width, height, rowLimit = VESSEL_DEFAULT_LABEL_LIMIT) {
export function vesselOverlayCohortLimit(
width,
height,
rowLimit = VESSEL_DEFAULT_LABEL_LIMIT,
) {
const w = Number(width);
const h = Number(height);
const requested = Number(rowLimit);
if (!(w > 0) || !(h > 0) || !(requested > 0)) return 0;
const gridCapacity = Math.ceil(w / VESSEL_LABEL_GRID_PX) * Math.ceil(h / VESSEL_LABEL_GRID_PX);
return Math.min(VESSEL_OVERLAY_MAX_COHORT, Math.floor(requested), gridCapacity);
const gridCapacity =
Math.ceil(w / VESSEL_LABEL_GRID_PX) * Math.ceil(h / VESSEL_LABEL_GRID_PX);
return Math.min(
VESSEL_OVERLAY_MAX_COHORT,
Math.floor(requested),
gridCapacity,
);
}
/**
@@ -93,7 +129,10 @@ export function vesselOverlayCohortLimit(width, height, rowLimit = VESSEL_DEFAUL
* @param {number} [fadeDistance=5000000] Ambient distance-fade endpoint.
* @returns {Object}
*/
export function applyVesselOverlayPolicy(card, fadeDistance = VESSEL_CARD_FADE_DISTANCE_M) {
export function applyVesselOverlayPolicy(
card,
fadeDistance = VESSEL_CARD_FADE_DISTANCE_M,
) {
const selected = card?.selected === true;
const rawGap = Number(card?.gapPx) || 10;
const gapPx = Math.max(12, rawGap + 8);
+67 -35
View File
@@ -143,7 +143,9 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
function resolveStore(kind, injected) {
if (injected !== undefined) return injected;
try {
return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;
return kind === 'session'
? globalThis.sessionStorage
: globalThis.localStorage;
} catch {
// Privacy-restricted storage should not make first launch silent.
return null;
@@ -211,8 +213,13 @@ export function shouldShowFirstRun({
if (params.get('welcome') === '0') return false;
// The demo/support escape hatch outranks both suppressions on purpose.
if (params.get('welcome') === '1') return true;
if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;
if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;
if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')
return false;
if (
readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) ===
'dismissed'
)
return false;
return true;
}
@@ -253,7 +260,10 @@ export function rememberFirstRunSessionDismissed(sessionStorageRef) {
* @param {() => Promise<any>} deps.flyToGlobe
* @returns {Promise<{ok: boolean, choice: string, result?: object, failedLayerIds?: string[]}>}
*/
export async function runFirstRunChoice(choice, { setContextMode, setLayerEnabled, flyToGlobe }) {
export async function runFirstRunChoice(
choice,
{ setContextMode, setLayerEnabled, flyToGlobe },
) {
const mission = FIRST_RUN_MISSIONS[choice];
if (!mission) return { ok: false, choice };
if (mission.kind === 'none') return { ok: true, choice };
@@ -267,15 +277,19 @@ export async function runFirstRunChoice(choice, { setContextMode, setLayerEnable
const flight = Promise.resolve()
.then(() => flyToGlobe())
.catch(() => null);
const outcomes = await Promise.all(mission.layerIds.map(async (layerId) => {
try {
return { layerId, ok: (await setLayerEnabled(layerId)) !== false };
} catch {
return { layerId, ok: false };
}
}));
const outcomes = await Promise.all(
mission.layerIds.map(async (layerId) => {
try {
return { layerId, ok: (await setLayerEnabled(layerId)) !== false };
} catch {
return { layerId, ok: false };
}
}),
);
await flight;
const failedLayerIds = outcomes.filter((entry) => !entry.ok).map((entry) => entry.layerId);
const failedLayerIds = outcomes
.filter((entry) => !entry.ok)
.map((entry) => entry.layerId);
return { ok: failedLayerIds.length === 0, choice, failedLayerIds };
}
@@ -325,20 +339,25 @@ export function initFirstRunExperience({
if (!root || root.dataset.initialized === 'true') return null;
root.dataset.initialized = 'true';
if (!shouldShowFirstRun({
hasShareState: styleManager?.hasShareState,
storage,
sessionStorageRef,
location,
})) {
if (
!shouldShowFirstRun({
hasShareState: styleManager?.hasShareState,
storage,
sessionStorageRef,
location,
})
) {
root.remove();
return null;
}
// The tile name is owner-switchable from one constant, so paint it from the
// module rather than trusting the markup to have been edited to match.
const environmentalTitle = root.querySelector('[data-first-run-environmental-title]');
if (environmentalTitle) environmentalTitle.textContent = environmentalLabel().title;
const environmentalTitle = root.querySelector(
'[data-first-run-environmental-title]',
);
if (environmentalTitle)
environmentalTitle.textContent = environmentalLabel().title;
const status = root.querySelector('[data-first-run-status]');
const suppressBox = root.querySelector('[data-first-run-suppress]');
@@ -387,10 +406,11 @@ export function initFirstRunExperience({
* is exactly how every one of those surfaces hides this card; the hit test
* then covers the overlays that leave the box intact and simply sit on top.
*/
const isTopmost = () => root.isConnected
&& root.classList.contains('visible')
&& root.getClientRects().length > 0
&& !coveredByOverlay();
const isTopmost = () =>
root.isConnected &&
root.classList.contains('visible') &&
root.getClientRects().length > 0 &&
!coveredByOverlay();
const dismiss = ({ restoreFocus = true } = {}) => {
if (closing) return;
@@ -416,10 +436,13 @@ export function initFirstRunExperience({
root.setAttribute('aria-busy', String(next));
// aria-disabled, not `disabled`: disabling the focused button drops focus to
// <body> mid-flight and strands a keyboard visitor outside the launcher.
for (const button of buttons) button.setAttribute('aria-disabled', String(next));
for (const button of buttons)
button.setAttribute('aria-disabled', String(next));
if (!status) return;
if (next) status.textContent = FIRST_RUN_MISSIONS[choice]?.busyText || 'Working…';
else if (status.dataset.sticky !== 'true') status.textContent = defaultStatus;
if (next)
status.textContent = FIRST_RUN_MISSIONS[choice]?.busyText || 'Working…';
else if (status.dataset.sticky !== 'true')
status.textContent = defaultStatus;
};
const onChoice = async (event) => {
@@ -438,13 +461,16 @@ export function initFirstRunExperience({
// does not decide panel chrome. This first-run click is an explicit
// visual choice, so reveal the result exactly as the visible
// Contacts / Space Missions tabs do.
styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });
styleManager.setPanelCollapsed?.('global-context-panel', false, {
explicit: true,
});
}
return result;
},
// `origin: 'user'` on purpose: a mission tile is a real person choosing
// these layers, so it persists exactly as clicking those rows would.
setLayerEnabled: (layerId) => dataManager.setEnabled(layerId, true, { origin: 'user' }),
setLayerEnabled: (layerId) =>
dataManager.setEnabled(layerId, true, { origin: 'user' }),
flyToGlobe: () => styleManager.resetToGlobeView(),
});
} catch (error) {
@@ -460,7 +486,8 @@ export function initFirstRunExperience({
const failed = outcome?.failedLayerIds?.length
? outcome.failedLayerIds
: outcome?.result?.failedLayerIds;
const detail = Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
const detail =
Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
if (status) {
status.dataset.sticky = 'true';
status.textContent = `Could not open that mission${detail}. Retry or explore manually.`;
@@ -479,7 +506,8 @@ export function initFirstRunExperience({
if (box) box.checked = !wanted;
if (!status) return;
status.dataset.sticky = 'true';
status.textContent = 'This browser is blocking storage, so that could not be saved.';
status.textContent =
'This browser is blocking storage, so that could not be saved.';
};
const keyboard = createSurfaceKeyboard({
@@ -585,13 +613,17 @@ export function initFirstRunExperience({
const onViewportResize = () => syncScrollAffordance();
globalThis.addEventListener?.('resize', onViewportResize);
const surfaceObserver = typeof globalThis.MutationObserver === 'function'
? new globalThis.MutationObserver(syncToExclusiveSurfaces)
: null;
const surfaceObserver =
typeof globalThis.MutationObserver === 'function'
? new globalThis.MutationObserver(syncToExclusiveSurfaces)
: null;
// Attributes only, no subtree: this is a class watch on one element, so it
// costs nothing per frame and never asks the render governor for a frame.
if (documentRef.body) {
surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });
surfaceObserver?.observe(documentRef.body, {
attributes: true,
attributeFilter: ['class'],
});
}
syncToExclusiveSurfaces();
+96 -31
View File
@@ -18,16 +18,36 @@ import * as Cesium from 'cesium';
import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './locations.js';
import { composeLocalityTag } from './hudLocality.js';
import { ellipsoidalToMslDisplayM, ensureGeoidReady, geoidHeight } from './data/geoid.js';
import {
ellipsoidalToMslDisplayM,
ensureGeoidReady,
geoidHeight,
} from './data/geoid.js';
import { getBasemapLabelContext } from './voice/gevActions.js';
import { isHudSummaryUnconfigured } from './hudSummaryResponse.js';
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
const HUD_COLORS = {
surveillance: { main: 'rgba(51, 255, 51, 0.8)', glow: 'rgba(51, 255, 51, 0.5)', border: 'rgba(51, 255, 51, 0.2)' },
thermal: { main: 'rgba(255, 255, 255, 0.7)', glow: 'rgba(255, 255, 255, 0.4)', border: 'rgba(255, 255, 255, 0.15)' },
retro: { main: 'rgba(255, 170, 0, 0.8)', glow: 'rgba(255, 170, 0, 0.5)', border: 'rgba(255, 170, 0, 0.2)' },
_default: { main: 'rgba(0, 255, 255, 0.6)', glow: 'rgba(0, 255, 255, 0.4)', border: 'rgba(0, 255, 255, 0.15)' },
surveillance: {
main: 'rgba(51, 255, 51, 0.8)',
glow: 'rgba(51, 255, 51, 0.5)',
border: 'rgba(51, 255, 51, 0.2)',
},
thermal: {
main: 'rgba(255, 255, 255, 0.7)',
glow: 'rgba(255, 255, 255, 0.4)',
border: 'rgba(255, 255, 255, 0.15)',
},
retro: {
main: 'rgba(255, 170, 0, 0.8)',
glow: 'rgba(255, 170, 0, 0.5)',
border: 'rgba(255, 170, 0, 0.2)',
},
_default: {
main: 'rgba(0, 255, 255, 0.6)',
glow: 'rgba(0, 255, 255, 0.4)',
border: 'rgba(0, 255, 255, 0.15)',
},
};
/** Shader modes that automatically show the HUD overlay. */
@@ -45,13 +65,14 @@ const HUD_SUMMARY_INTERVAL_MS = 15000;
const HUD_GEOID_CELL_DEG = 0.01;
/** Flattened list of all city POIs for nearest-point lookups. */
const NEARBY_POINTS = Object.values(CITY_POIS)
.flatMap((city) => city.pois.map((poi) => ({
const NEARBY_POINTS = Object.values(CITY_POIS).flatMap((city) =>
city.pois.map((poi) => ({
city: city.name,
poi: poi.name,
lat: poi.lat,
lon: poi.lon,
})));
})),
);
/**
* Full-screen intelligence HUD overlay rendered on top of the Cesium canvas.
@@ -66,7 +87,15 @@ export class IntelHUD {
* @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for
* camera telemetry and coordinate derivation.
*/
constructor(viewer, { placeSearch, summaryPolicy = {}, basemapContext = {}, summaryService = applicationServices.summary } = {}) {
constructor(
viewer,
{
placeSearch,
summaryPolicy = {},
basemapContext = {},
summaryService = applicationServices.summary,
} = {},
) {
this.summaryService = summaryService;
this.summaryPolicy = summaryPolicy;
this.basemapContext = basemapContext;
@@ -227,7 +256,8 @@ export class IntelHUD {
this._recBlinkInterval = setInterval(() => {
this._recBlinkState = !this._recBlinkState;
const dot = document.getElementById('hud-rec-dot');
if (dot) dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden';
if (dot)
dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden';
}, 800);
// Camera-derived data — 4 updates/second (250ms)
@@ -269,8 +299,12 @@ export class IntelHUD {
if (!this._geoidRequested) {
this._geoidRequested = true;
ensureGeoidReady()
.then(() => { this._geoidReady = true; })
.catch(() => { /* readout falls back to the uncorrected height */ });
.then(() => {
this._geoidReady = true;
})
.catch(() => {
/* readout falls back to the uncorrected height */
});
}
return null;
}
@@ -331,9 +365,13 @@ export class IntelHUD {
// Equation (GIQE) simplified form: NIIRS = 10.25 - 3.32 * log10(GSD_inches).
const gsd = Math.max(0.01, altM * 0.000375);
const gsdInches = gsd * 39.37;
const niirs = Math.max(0, Math.min(9, 10.25 - 3.32 * Math.log10(gsdInches)));
const niirs = Math.max(
0,
Math.min(9, 10.25 - 3.32 * Math.log10(gsdInches)),
);
const gsdEl = document.getElementById('hud-gsd');
if (gsdEl) gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
if (gsdEl)
gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
// Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
// ellipsoidal camera height, which reads far below zero wherever the geoid
@@ -344,7 +382,8 @@ export class IntelHUD {
const geoidN = this._geoidUndulationM(latDeg, lonDeg);
const altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
const sunEl = this._estimateSunElevation(latDeg, lonDeg);
if (altEl) altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
if (altEl)
altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
// Collection timestamp
const collEl = document.getElementById('hud-coll');
@@ -435,7 +474,10 @@ export class IntelHUD {
if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S';
else dir = decimal >= 0 ? 'E' : 'W';
const degStr = type === 'lon' ? String(deg).padStart(3, '0') : String(deg).padStart(2, '0');
const degStr =
type === 'lon'
? String(deg).padStart(3, '0')
: String(deg).padStart(2, '0');
return `${degStr}°${String(min).padStart(2, '0')}'${String(sec).padStart(5, '0')}"${dir}`;
}
@@ -457,12 +499,20 @@ export class IntelHUD {
const solarNoon = 12;
const hourAngle = (hours - solarNoon) * 15;
// Solar declination approximation (~23.45 deg amplitude sinusoidal over the year)
const declination = 23.45 * Math.sin(Cesium.Math.toRadians((360 / 365) * (now.getUTCDate() + 30 * now.getUTCMonth() - 81)));
const declination =
23.45 *
Math.sin(
Cesium.Math.toRadians(
(360 / 365) * (now.getUTCDate() + 30 * now.getUTCMonth() - 81),
),
);
const latRad = Cesium.Math.toRadians(lat);
const decRad = Cesium.Math.toRadians(declination);
const haRad = Cesium.Math.toRadians(hourAngle);
// Standard formula: sin(el) = sin(lat)*sin(dec) + cos(lat)*cos(dec)*cos(ha)
const sinEl = Math.sin(latRad) * Math.sin(decRad) + Math.cos(latRad) * Math.cos(decRad) * Math.cos(haRad);
const sinEl =
Math.sin(latRad) * Math.sin(decRad) +
Math.cos(latRad) * Math.cos(decRad) * Math.cos(haRad);
// Clamp to [-1,1] to guard against floating-point drift before asin
return Cesium.Math.toDegrees(Math.asin(Math.max(-1, Math.min(1, sinEl))));
}
@@ -489,8 +539,10 @@ export class IntelHUD {
_regionLabel(lat, lon) {
if (lat > 72) return 'ARCTIC';
if (lat < -60) return 'ANTARCTIC';
if (lat >= 5 && lat <= 83 && lon >= -170 && lon <= -50) return 'NORTH AMERICA';
if (lat >= -60 && lat <= 15 && lon >= -90 && lon <= -30) return 'SOUTH AMERICA';
if (lat >= 5 && lat <= 83 && lon >= -170 && lon <= -50)
return 'NORTH AMERICA';
if (lat >= -60 && lat <= 15 && lon >= -90 && lon <= -30)
return 'SOUTH AMERICA';
if (lat >= 34 && lat <= 72 && lon >= -25 && lon <= 45) return 'EUROPE';
if (lat >= -35 && lat <= 38 && lon >= -20 && lon <= 55) return 'AFRICA';
if (lat >= 5 && lat <= 80 && lon >= 45 && lon <= 180) return 'ASIA';
@@ -519,7 +571,10 @@ export class IntelHUD {
const latSpan = Math.abs(north - south);
// 111 km/deg is the approximate surface distance per degree of latitude;
// longitude distance is scaled by cos(lat) to account for meridian convergence.
const widthKm = Math.max(0, lonSpan * 111 * Math.cos(Cesium.Math.toRadians(latDeg)));
const widthKm = Math.max(
0,
lonSpan * 111 * Math.cos(Cesium.Math.toRadians(latDeg)),
);
const heightKm = Math.max(0, latSpan * 111);
return { widthKm, heightKm };
}
@@ -537,8 +592,9 @@ export class IntelHUD {
const toRad = (deg) => Cesium.Math.toRadians(deg);
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
@@ -583,9 +639,10 @@ export class IntelHUD {
// together, so they must never disagree. The view band above deliberately
// keeps the ellipsoidal height: its thresholds were tuned against it.
const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM;
const altTag = altDisplayM >= 1000
? `${(altDisplayM / 1000).toFixed(1)}KM`
: `${Math.round(altDisplayM)}M`;
const altTag =
altDisplayM >= 1000
? `${(altDisplayM / 1000).toFixed(1)}KM`
: `${Math.round(altDisplayM)}M`;
const winTag = window
? `${Math.max(1, Math.round(window.widthKm))}x${Math.max(1, Math.round(window.heightKm))}KM`
: 'N/A';
@@ -665,7 +722,9 @@ export class IntelHUD {
this._summaryRequest = controller;
try {
this.summaryPolicy.onRequest?.();
const response = await this.summaryService.summarize(context, { signal: controller.signal });
const response = await this.summaryService.summarize(context, {
signal: controller.signal,
});
const data = response.data;
if (revision !== this._summaryRevision) return;
if (isHudSummaryUnconfigured(response.status, data)) {
@@ -701,10 +760,16 @@ export class IntelHUD {
}
async _summaryContext() {
const labels = await getBasemapLabelContext(this.viewer, this.placeSearch, this.basemapContext);
const enabledLayers = this._dataManager?.getAll?.()
?.filter((layer) => layer.enabled)
.map((layer) => layer.name) || [];
const labels = await getBasemapLabelContext(
this.viewer,
this.placeSearch,
this.basemapContext,
);
const enabledLayers =
this._dataManager
?.getAll?.()
?.filter((layer) => layer.enabled)
.map((layer) => layer.name) || [];
return {
placeLabels: labels.placeLabels,
streetLabels: labels.streetLabels,
+12 -9
View File
@@ -23,13 +23,16 @@ export function keylessHudSummaryResponse(apiKey) {
/** Return true only for the deliberate, successful no-key capability response. */
export function isHudSummaryUnconfigured(status, data) {
const keys = data !== null && typeof data === 'object' && !Array.isArray(data)
? Object.keys(data)
: [];
return keys.length === 4
&& status === 200
&& data?.configured === false
&& data?.code === HUD_SUMMARY_UNCONFIGURED_CODE
&& data?.error === null
&& data?.summary === null;
const keys =
data !== null && typeof data === 'object' && !Array.isArray(data)
? Object.keys(data)
: [];
return (
keys.length === 4 &&
status === 200 &&
data?.configured === false &&
data?.code === HUD_SUMMARY_UNCONFIGURED_CODE &&
data?.error === null &&
data?.summary === null
);
}
+69 -26
View File
@@ -19,7 +19,9 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
/** Chip label — pure, exported for tests. */
export function keySetupChipLabel(status) {
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0));
return missing > 0 ? `POWER UP · ${missing} ${missing === 1 ? 'KEY' : 'KEYS'} WAITING` : 'POWERED UP';
return missing > 0
? `POWER UP · ${missing} ${missing === 1 ? 'KEY' : 'KEYS'} WAITING`
: 'POWERED UP';
}
/**
@@ -78,13 +80,17 @@ function buildRow(documentRef, key) {
const tier = documentRef.createElement('span');
tier.className = 'key-setup-tier';
tier.textContent = TIER_DOTS[key.tier] || '';
tier.title = key.tier === 'metered' ? 'Metered — a billing-enabled account' : 'Free key — register, paste, done';
tier.title =
key.tier === 'metered'
? 'Metered — a billing-enabled account'
: 'Free key — register, paste, done';
head.append(led, title, tier);
if (key.clientExposed) {
const exposed = documentRef.createElement('span');
exposed.className = 'key-setup-exposed';
exposed.textContent = 'browser-side';
exposed.title = 'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)';
exposed.title =
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)';
head.append(exposed);
}
if (external) {
@@ -93,7 +99,8 @@ function buildRow(documentRef, key) {
const badge = documentRef.createElement('span');
badge.className = 'key-setup-external';
badge.textContent = 'configured externally';
badge.title = 'Supplied by your environment, Keychain, or launcher — change it where it was set';
badge.title =
'Supplied by your environment, Keychain, or launcher — change it where it was set';
head.append(badge);
}
const get = documentRef.createElement('a');
@@ -144,7 +151,11 @@ function buildRow(documentRef, key) {
* Wire the chip + dialog. Fire-and-forget from main.js; resolves to null when
* the surface has no business existing (prod build, LAN visitor, no markup).
*/
export async function initKeySetup({ documentRef = globalThis.document, fetchImpl, signal } = {}) {
export async function initKeySetup({
documentRef = globalThis.document,
fetchImpl,
signal,
} = {}) {
const chip = documentRef?.getElementById?.('key-setup-chip');
const root = documentRef?.getElementById?.('key-setup');
if (!chip || !root || root.dataset.initialized === 'true') return null;
@@ -161,13 +172,19 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
chip.remove();
root.remove();
};
if (signal?.aborted) { destroy(); return null; }
if (signal?.aborted) {
destroy();
return null;
}
signal?.addEventListener('abort', destroy, { once: true });
const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis);
let status = null;
try {
const response = await doFetch('/api/setup/status', { cache: 'no-store', signal: lifetime.signal });
const response = await doFetch('/api/setup/status', {
cache: 'no-store',
signal: lifetime.signal,
});
if (!response.ok) throw new Error(String(response.status));
status = await response.json();
if (disposed) return null;
@@ -196,12 +213,14 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
chip.hidden = status.setCount >= status.total;
if (!rowsHost) return;
rowsHost.textContent = '';
for (const key of status.keys || []) rowsHost.append(buildRow(documentRef, key));
for (const key of status.keys || [])
rowsHost.append(buildRow(documentRef, key));
};
const visible = () => root.isConnected
&& root.classList.contains('visible')
&& root.getClientRects().length > 0;
const visible = () =>
root.isConnected &&
root.classList.contains('visible') &&
root.getClientRects().length > 0;
const keyboard = createSurfaceKeyboard({
root,
@@ -226,22 +245,29 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
if (!open) return;
open = false;
root.classList.remove('visible');
const hide = () => { if (!open) root.hidden = true; };
const hide = () => {
if (!open) root.hidden = true;
};
root.addEventListener('transitionend', hide, { once: true });
globalThis.setTimeout?.(hide, 400);
if (statusLine) statusLine.textContent = defaultStatusText;
keyboard.deactivate({ restoreFocus: true });
};
const say = (text) => { if (statusLine) statusLine.textContent = text; };
const say = (text) => {
if (statusLine) statusLine.textContent = text;
};
const storeLabel = () => (status?.store === 'pinokio-environment'
? 'your app configuration'
: 'your local .env');
const storeLabel = () =>
status?.store === 'pinokio-environment'
? 'your app configuration'
: 'your local .env';
const submitUpdates = async (updates, doneVerb) => {
if (disposed || busy) return;
const googleWasUnset = !status?.keys?.find((key) => key.id === 'google-maps')?.set;
const googleWasUnset = !status?.keys?.find(
(key) => key.id === 'google-maps',
)?.set;
busy = true;
applyButton?.setAttribute('aria-disabled', 'true');
say('Saving…');
@@ -258,13 +284,17 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
say(payload.error || `Save failed (${response.status}).`);
return;
}
for (const input of root.querySelectorAll('input[data-env-var]')) input.value = '';
for (const input of root.querySelectorAll('input[data-env-var]'))
input.value = '';
render(payload.status);
if (googleWasUnset && payload.saved?.includes('GOOGLE_MAPS_API_KEY')) {
const strip = () => {
try {
const next = stripKeylessBasemapFromHash(globalThis.location?.hash?.slice(1) || '');
if (next !== null) globalThis.history?.replaceState?.(null, '', `#${next}`);
const next = stripKeylessBasemapFromHash(
globalThis.location?.hash?.slice(1) || '',
);
if (next !== null)
globalThis.history?.replaceState?.(null, '', `#${next}`);
} catch {
// Continuity is a nicety, never a blocker.
}
@@ -272,9 +302,14 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
strip();
// The live share writer may re-serialize the still-OSM stack before
// the restart's reload lands, so strip again at the door.
globalThis.addEventListener?.('pagehide', strip, { once: true, signal: lifetime.signal });
globalThis.addEventListener?.('pagehide', strip, {
once: true,
signal: lifetime.signal,
});
}
say(`${doneVerb} ${storeLabel()}. Restarting — this page reloads itself.`);
say(
`${doneVerb} ${storeLabel()}. Restarting — this page reloads itself.`,
);
} catch (error) {
say(`Save failed: ${error?.message || error}`);
} finally {
@@ -287,7 +322,10 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
if (disposed || busy) return;
const inputs = [...root.querySelectorAll('input[data-env-var]')];
const updates = collectKeyUpdates(
inputs.map((input) => ({ envVar: input.dataset.envVar, value: input.value })),
inputs.map((input) => ({
envVar: input.dataset.envVar,
value: input.value,
})),
);
if (!Object.keys(updates).length) {
say('Paste at least one key first.');
@@ -313,8 +351,9 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
// Removal is destructive and — behind a framing defense that should already
// stop it — a clickjack target. A confirm turns a single aligned click into
// a deliberate two-step the lure cannot pre-satisfy.
const ok = typeof globalThis.confirm !== 'function'
|| globalThis.confirm('Remove this key from your saved configuration?');
const ok =
typeof globalThis.confirm !== 'function' ||
globalThis.confirm('Remove this key from your saved configuration?');
if (!ok) return;
void submitUpdates(
Object.fromEntries(envVars.map((name) => [name, null])),
@@ -327,7 +366,11 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
// Re-entry for a fully-keyed setup, demos, and support: ?setup=1 opens the
// dialog even though the chip has retired.
try {
if (new URLSearchParams(globalThis.location?.search || '').get('setup') === '1') openDialog();
if (
new URLSearchParams(globalThis.location?.search || '').get('setup') ===
'1'
)
openDialog();
} catch {
// An unparsable location never blocks init.
}
+15 -3
View File
@@ -1336,7 +1336,12 @@ function buildingPitch(bounds) {
return -32;
}
async function resolveBuildingBounds(lat, lon, query, boundaries = applicationServices.boundaries) {
async function resolveBuildingBounds(
lat,
lon,
query,
boundaries = applicationServices.boundaries,
) {
const overpassQuery = `
[out:json][timeout:10];
(
@@ -1352,8 +1357,15 @@ async function resolveBuildingBounds(lat, lon, query, boundaries = applicationSe
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 6000);
try {
const elements = await boundaries.query(overpassQuery, { signal: controller.signal });
return selectBuildingBounds(Array.isArray(elements) ? elements : [], lat, lon, query);
const elements = await boundaries.query(overpassQuery, {
signal: controller.signal,
});
return selectBuildingBounds(
Array.isArray(elements) ? elements : [],
lat,
lon,
query,
);
} catch {
return null;
} finally {
+38 -8
View File
@@ -13,9 +13,27 @@ const GAZE_EPSILON = 0.08;
* @param {number} [maxOffset=MAX_GAZE_SVG_UNITS] - Maximum SVG-space translation.
* @returns {{x:number, y:number}}
*/
export function calculateLogoGaze(clientX, clientY, rect, maxOffset = MAX_GAZE_SVG_UNITS) {
const values = [clientX, clientY, rect?.left, rect?.top, rect?.width, rect?.height, maxOffset];
if (!values.every(Number.isFinite) || rect.width <= 0 || rect.height <= 0 || maxOffset < 0) {
export function calculateLogoGaze(
clientX,
clientY,
rect,
maxOffset = MAX_GAZE_SVG_UNITS,
) {
const values = [
clientX,
clientY,
rect?.left,
rect?.top,
rect?.width,
rect?.height,
maxOffset,
];
if (
!values.every(Number.isFinite) ||
rect.width <= 0 ||
rect.height <= 0 ||
maxOffset < 0
) {
return { x: 0, y: 0 };
}
@@ -25,7 +43,7 @@ export function calculateLogoGaze(clientX, clientY, rect, maxOffset = MAX_GAZE_S
if (distance === 0) return { x: 0, y: 0 };
const strength = Math.min(distance / FULL_GAZE_DISTANCE_PX, 1);
const scale = maxOffset * strength / distance;
const scale = (maxOffset * strength) / distance;
return { x: dx * scale, y: dy * scale };
}
@@ -43,7 +61,9 @@ export function initLogoGaze(root = document) {
const logos = [...root.querySelectorAll('[data-logo-gaze]')];
if (!logos.length) return () => {};
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
const reducedMotion = window.matchMedia?.(
'(prefers-reduced-motion: reduce)',
).matches;
const states = logos.map((element) => ({
element,
parts: [],
@@ -68,7 +88,10 @@ export function initLogoGaze(root = document) {
const deltaX = state.targetX - state.currentX;
const deltaY = state.targetY - state.currentY;
if (Math.abs(deltaX) <= GAZE_EPSILON && Math.abs(deltaY) <= GAZE_EPSILON) {
if (
Math.abs(deltaX) <= GAZE_EPSILON &&
Math.abs(deltaY) <= GAZE_EPSILON
) {
state.currentX = state.targetX;
state.currentY = state.targetY;
} else {
@@ -94,7 +117,10 @@ export function initLogoGaze(root = document) {
const markup = await response.text();
if (disposed) return;
const svgDocument = new DOMParser().parseFromString(markup, 'image/svg+xml');
const svgDocument = new DOMParser().parseFromString(
markup,
'image/svg+xml',
);
if (svgDocument.querySelector('parsererror')) return;
const sourceSvg = svgDocument.documentElement;
@@ -122,7 +148,11 @@ export function initLogoGaze(root = document) {
const onPointerMove = (event) => {
if (reducedMotion || event.pointerType === 'touch') return;
for (const state of states) {
const gaze = calculateLogoGaze(event.clientX, event.clientY, state.element.getBoundingClientRect());
const gaze = calculateLogoGaze(
event.clientX,
event.clientY,
state.element.getBoundingClientRect(),
);
state.targetX = gaze.x;
state.targetY = gaze.y;
}
+14 -8
View File
@@ -30,12 +30,14 @@ export function announceNavigationAuthority(
{ eventTarget = globalThis.window, cancelPendingSelection = true } = {},
) {
if (typeof eventTarget?.dispatchEvent !== 'function') return false;
eventTarget.dispatchEvent(new CustomEvent(NAVIGATION_AUTHORITY_EVENT, {
detail: {
reason: String(reason || 'layer-focus'),
cancelPendingSelection: Boolean(cancelPendingSelection),
},
}));
eventTarget.dispatchEvent(
new CustomEvent(NAVIGATION_AUTHORITY_EVENT, {
detail: {
reason: String(reason || 'layer-focus'),
cancelPendingSelection: Boolean(cancelPendingSelection),
},
}),
);
return true;
}
@@ -46,8 +48,12 @@ export function announceNavigationAuthority(
* @returns {() => void} Idempotent disposer.
*/
export function registerNavigationAuthorityListener(eventTarget, listener) {
if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener
|| typeof listener !== 'function') return () => {};
if (
!eventTarget?.addEventListener ||
!eventTarget?.removeEventListener ||
typeof listener !== 'function'
)
return () => {};
eventTarget.addEventListener(NAVIGATION_AUTHORITY_EVENT, listener);
let disposed = false;
return () => {
+5 -2
View File
@@ -1,5 +1,8 @@
import * as Cesium from 'cesium';
import { holdContinuousRender, releaseContinuousRender } from './renderGovernor.js';
import {
holdContinuousRender,
releaseContinuousRender,
} from './renderGovernor.js';
/**
* OrbitController — smooth orbit around a target point.
@@ -54,7 +57,7 @@ export class OrbitController {
const hpr = new Cesium.HeadingPitchRange(
this.angle,
Cesium.Math.toRadians(this.pitch),
this.radius
this.radius,
);
this.viewer.camera.lookAt(this.target, hpr);
});
File diff suppressed because it is too large. Load diff
+264 -92
View File
@@ -49,22 +49,34 @@ export function clearWorldOverlayTextMeasureCache() {
/** Install the font-loading invalidation hooks once, when the API exists. */
export function installWorldOverlayFontInvalidation() {
if (_fontInvalidationInstalled || typeof document === 'undefined' || !document.fonts) return;
if (
_fontInvalidationInstalled ||
typeof document === 'undefined' ||
!document.fonts
)
return;
_fontInvalidationInstalled = true;
_observedFontSet = document.fonts;
const generation = ++_fontInvalidationGeneration;
Promise.resolve(document.fonts.ready)
.then(() => {
if (generation === _fontInvalidationGeneration) clearWorldOverlayTextMeasureCache();
if (generation === _fontInvalidationGeneration)
clearWorldOverlayTextMeasureCache();
})
.catch(() => {});
_observedFontSet.addEventListener?.('loadingdone', clearWorldOverlayTextMeasureCache);
_observedFontSet.addEventListener?.(
'loadingdone',
clearWorldOverlayTextMeasureCache,
);
}
/** Remove font hooks and cached measurements when the overlay host is destroyed. */
export function destroyWorldOverlayDraw() {
_fontInvalidationGeneration++;
_observedFontSet?.removeEventListener?.('loadingdone', clearWorldOverlayTextMeasureCache);
_observedFontSet?.removeEventListener?.(
'loadingdone',
clearWorldOverlayTextMeasureCache,
);
_observedFontSet = null;
_fontInvalidationInstalled = false;
clearWorldOverlayTextMeasureCache();
@@ -95,7 +107,8 @@ export function measureWorldOverlayText(ctx, text, font) {
}
fontCache.set(normalizedText, { width, usedAt: ++_textMeasureClock });
_textMeasureCacheSize++;
if (_textMeasureCacheSize > TEXT_MEASURE_CACHE_LIMIT) evictOldestTextMeasureEntry();
if (_textMeasureCacheSize > TEXT_MEASURE_CACHE_LIMIT)
evictOldestTextMeasureEntry();
return width;
}
@@ -113,7 +126,14 @@ export function getWorldOverlayTextMeasureCacheSize() {
* @param {number} h
* @param {number} radius
*/
export function roundedRectPath(path, x, y, w, h, radius = WORLD_OVERLAY_STYLE.radius) {
export function roundedRectPath(
path,
x,
y,
w,
h,
radius = WORLD_OVERLAY_STYLE.radius,
) {
if (typeof path.roundRect === 'function') {
path.roundRect(x, y, w, h, radius);
return;
@@ -137,11 +157,14 @@ export function roundedRectPath(path, x, y, w, h, radius = WORLD_OVERLAY_STYLE.r
* @param {number} [options.fadeStartRatio]
* @returns {number}
*/
export function distanceFade(distanceM, {
minDistance = 0,
maxDistance = Number.POSITIVE_INFINITY,
fadeStartRatio = 0.7,
} = {}) {
export function distanceFade(
distanceM,
{
minDistance = 0,
maxDistance = Number.POSITIVE_INFINITY,
fadeStartRatio = 0.7,
} = {},
) {
if (!Number.isFinite(distanceM)) return 0;
const min = Number.isFinite(minDistance) ? Math.max(0, minDistance) : 0;
if (distanceM < min) return 0;
@@ -192,17 +215,27 @@ export function altitudeScale(altitudeM, curve = null) {
const end = Number(curve.end);
const midValue = Number(curve.midValue);
const endValue = Number(curve.endValue);
if (!Number.isFinite(fullEnd) || !Number.isFinite(midEnd) || !Number.isFinite(end)
|| !Number.isFinite(midValue) || !Number.isFinite(endValue)) return 1;
if (
!Number.isFinite(fullEnd) ||
!Number.isFinite(midEnd) ||
!Number.isFinite(end) ||
!Number.isFinite(midValue) ||
!Number.isFinite(endValue)
)
return 1;
if (altitudeM <= fullEnd) return 1;
if (altitudeM <= midEnd) {
const span = Math.max(1, midEnd - fullEnd);
let progress = Math.max(0, Math.min(1, (altitudeM - fullEnd) / span));
if (curve.smoothToMid === true) progress = progress * progress * (3 - 2 * progress);
if (curve.smoothToMid === true)
progress = progress * progress * (3 - 2 * progress);
return 1 + (midValue - 1) * progress;
}
if (altitudeM >= end) return endValue;
const progress = Math.max(0, Math.min(1, (altitudeM - midEnd) / Math.max(1, end - midEnd)));
const progress = Math.max(
0,
Math.min(1, (altitudeM - midEnd) / Math.max(1, end - midEnd)),
);
return midValue + (endValue - midValue) * progress;
}
@@ -215,11 +248,14 @@ export function altitudeScale(altitudeM, curve = null) {
* @param {number} [options.fadeEnd]
* @returns {number}
*/
export function altitudeFade(altitudeM, {
minAltitude = Number.NEGATIVE_INFINITY,
fadeStart = Number.POSITIVE_INFINITY,
fadeEnd = Number.POSITIVE_INFINITY,
} = {}) {
export function altitudeFade(
altitudeM,
{
minAltitude = Number.NEGATIVE_INFINITY,
fadeStart = Number.POSITIVE_INFINITY,
fadeEnd = Number.POSITIVE_INFINITY,
} = {},
) {
if (!Number.isFinite(altitudeM)) return 1;
if (Number.isFinite(minAltitude) && altitudeM < minAltitude) return 0;
if (!Number.isFinite(fadeEnd)) return 1;
@@ -238,24 +274,32 @@ export function combinedOverlayAlpha({
altitudeFade: altitudeAlpha = 1,
keyholeEdgeFade = 1,
} = {}) {
return clampUnit(sourceAlpha)
* clampUnit(temporalFade)
* clampUnit(distanceAlpha)
* clampUnit(altitudeAlpha)
* clampUnit(keyholeEdgeFade);
return (
clampUnit(sourceAlpha) *
clampUnit(temporalFade) *
clampUnit(distanceAlpha) *
clampUnit(altitudeAlpha) *
clampUnit(keyholeEdgeFade)
);
}
function clampUnit(value) {
return Number.isFinite(Number(value)) ? Math.max(0, Math.min(1, Number(value))) : 1;
return Number.isFinite(Number(value))
? Math.max(0, Math.min(1, Number(value)))
: 1;
}
function trackDisplayText(entry) {
const title = entry?.title || '';
const detail = Array.isArray(entry?.details) ? entry.details[0] || '' : '';
if (entry._overlayTrackDisplayTitle !== title || entry._overlayTrackDisplayDetail !== detail) {
if (
entry._overlayTrackDisplayTitle !== title ||
entry._overlayTrackDisplayDetail !== detail
) {
entry._overlayTrackDisplayTitle = title;
entry._overlayTrackDisplayDetail = detail;
entry._overlayTrackDisplayText = title && detail ? `${title} · ${detail}` : title || detail;
entry._overlayTrackDisplayText =
title && detail ? `${title} · ${detail}` : title || detail;
}
return entry._overlayTrackDisplayText || '';
}
@@ -268,16 +312,23 @@ function trackDisplayText(entry) {
* @returns {{w:number,h:number,padX:number,padY:number,titleH:number,lineH:number,thumbW:number,thumbH:number}}
*/
export function measureOverlayEntry(ctx, entry, out = {}) {
const variant = entry?.selected ? 'selected' : String(entry?.variant || 'label');
const variant = entry?.selected
? 'selected'
: String(entry?.variant || 'label');
const details = Array.isArray(entry?.details) ? entry.details : [];
const selected = variant === 'selected';
const tracked = variant === 'tracked';
const tactical = entry?.cardStyle === 'tactical';
const titleFont = tracked
? WORLD_OVERLAY_STYLE.fontTrackedTitle
: selected ? WORLD_OVERLAY_STYLE.fontSelected : WORLD_OVERLAY_STYLE.fontTitle;
let titleWidth = measureWorldOverlayText(ctx, entry?.title || '',
variant === 'label' ? WORLD_OVERLAY_STYLE.fontLabel : titleFont);
: selected
? WORLD_OVERLAY_STYLE.fontSelected
: WORLD_OVERLAY_STYLE.fontTitle;
let titleWidth = measureWorldOverlayText(
ctx,
entry?.title || '',
variant === 'label' ? WORLD_OVERLAY_STYLE.fontLabel : titleFont,
);
if (variant === 'track' && details[0]) {
titleWidth = measureWorldOverlayText(
ctx,
@@ -292,16 +343,38 @@ export function measureOverlayEntry(ctx, entry, out = {}) {
measureWorldOverlayText(
ctx,
details[i],
tracked ? WORLD_OVERLAY_STYLE.fontTrackedDetail : WORLD_OVERLAY_STYLE.fontDetail,
tracked
? WORLD_OVERLAY_STYLE.fontTrackedDetail
: WORLD_OVERLAY_STYLE.fontDetail,
),
);
}
out.padX = tracked ? 13 : selected ? 12 : variant === 'label' || variant === 'track' ? 6 : 9;
out.padY = tracked ? 9 : selected ? 8 : variant === 'label' || variant === 'track' ? 4 : 6;
out.padX = tracked
? 13
: selected
? 12
: variant === 'label' || variant === 'track'
? 6
: 9;
out.padY = tracked
? 9
: selected
? 8
: variant === 'label' || variant === 'track'
? 4
: 6;
out.titleH = tactical
? (selected ? 14 : 12)
: tracked ? 13 : selected ? 15 : variant === 'label' || variant === 'track' ? 11 : 13;
? selected
? 14
: 12
: tracked
? 13
: selected
? 15
: variant === 'label' || variant === 'track'
? 11
: 13;
out.lineH = tracked ? 17 : selected ? 15 : 13;
out.thumbW = 0;
out.thumbH = 0;
@@ -325,7 +398,17 @@ export function measureOverlayEntry(ctx, entry, out = {}) {
return out;
}
function writePlacement(out, corner, x, y, w, h, anchorX, anchorY, signedLeaderOffset = 0) {
function writePlacement(
out,
corner,
x,
y,
w,
h,
anchorX,
anchorY,
signedLeaderOffset = 0,
) {
const placement = out || {};
placement.corner = corner;
placement.rect ||= {};
@@ -350,12 +433,17 @@ function writePlacement(out, corner, x, y, w, h, anchorX, anchorY, signedLeaderO
// pushed the card sideways off its anchor, which reads as "the card isn't
// attached to that camera".
placement.leadToX = anchorX;
placement.leadToY = corner === 'above' ? placement.rect.y + h : placement.rect.y;
placement.leadToY =
corner === 'above' ? placement.rect.y + h : placement.rect.y;
} else {
placement.leadFromX = anchorX;
placement.leadFromY = anchorY;
placement.leadToX = corner === 'left' ? placement.rect.x + w : placement.rect.x;
placement.leadToY = Math.max(placement.rect.y, Math.min(anchorY, placement.rect.y + h));
placement.leadToX =
corner === 'left' ? placement.rect.x + w : placement.rect.x;
placement.leadToY = Math.max(
placement.rect.y,
Math.min(anchorY, placement.rect.y + h),
);
}
return placement;
}
@@ -373,7 +461,10 @@ const VERTICAL_PLACEMENT_ORDERS = Object.freeze({
});
function clampPlacementCoordinate(value, size, viewportSize, margin = 4) {
return Math.max(margin, Math.min(value, Math.max(margin, viewportSize - size - margin)));
return Math.max(
margin,
Math.min(value, Math.max(margin, viewportSize - size - margin)),
);
}
/**
@@ -394,19 +485,22 @@ function clampPlacementCoordinate(value, size, viewportSize, margin = 4) {
* @param {Array<object>} [out]
* @returns {Array<object>}
*/
export function placementVariants({
anchorX,
anchorY,
width,
height,
viewportWidth,
viewportHeight,
gap = 12,
preferred = 'auto',
leaderOffset = 0,
verticalOnly = false,
viewportMargin = 4,
}, out = []) {
export function placementVariants(
{
anchorX,
anchorY,
width,
height,
viewportWidth,
viewportHeight,
gap = 12,
preferred = 'auto',
leaderOffset = 0,
verticalOnly = false,
viewportMargin = 4,
},
out = [],
) {
const automatic = anchorY - gap - height >= 4 ? 'above' : 'below';
const orders = verticalOnly ? VERTICAL_PLACEMENT_ORDERS : PLACEMENT_ORDERS;
const order = orders[preferred] || orders[automatic];
@@ -437,7 +531,9 @@ export function placementVariants({
height,
anchorX,
anchorY,
corner === 'above' || corner === 'left' ? negativeLeaderOffset : positiveLeaderOffset,
corner === 'above' || corner === 'left'
? negativeLeaderOffset
: positiveLeaderOffset,
);
}
out.length = order.length;
@@ -454,9 +550,15 @@ function drawLeader(ctx, placement, accent) {
if (placement.leaderOffset === 0) {
ctx.moveTo(placement.leadFromX, placement.leadFromY);
} else if (placement.corner === 'above' || placement.corner === 'below') {
ctx.moveTo(placement.leadFromX, placement.leadFromY + placement.leaderOffset);
ctx.moveTo(
placement.leadFromX,
placement.leadFromY + placement.leaderOffset,
);
} else {
ctx.moveTo(placement.leadFromX + placement.leaderOffset, placement.leadFromY);
ctx.moveTo(
placement.leadFromX + placement.leaderOffset,
placement.leadFromY,
);
}
ctx.lineTo(placement.leadToX, placement.leadToY);
ctx.stroke();
@@ -468,9 +570,13 @@ function drawCardChrome(ctx, entry, placement, selected = false) {
drawLeader(ctx, placement, accent);
ctx.beginPath();
roundedRectPath(ctx, x, y, w, h);
ctx.fillStyle = selected ? WORLD_OVERLAY_STYLE.selectedBackground : WORLD_OVERLAY_STYLE.background;
ctx.fillStyle = selected
? WORLD_OVERLAY_STYLE.selectedBackground
: WORLD_OVERLAY_STYLE.background;
ctx.fill();
ctx.strokeStyle = selected ? WORLD_OVERLAY_STYLE.selectedBorder : WORLD_OVERLAY_STYLE.border;
ctx.strokeStyle = selected
? WORLD_OVERLAY_STYLE.selectedBorder
: WORLD_OVERLAY_STYLE.border;
ctx.lineWidth = selected ? 1.25 : 1;
ctx.stroke();
ctx.fillStyle = accent;
@@ -482,7 +588,9 @@ function drawCardText(ctx, entry, placement, selected = false, topOffset = 0) {
const x = placement.rect.x + (selected ? 12 : 9);
let y = placement.rect.y + (selected ? 8 : 6) + topOffset;
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
ctx.font = selected ? WORLD_OVERLAY_STYLE.fontSelected : WORLD_OVERLAY_STYLE.fontTitle;
ctx.font = selected
? WORLD_OVERLAY_STYLE.fontSelected
: WORLD_OVERLAY_STYLE.fontTitle;
ctx.textBaseline = 'top';
ctx.fillText(String(entry.title || ''), x, y);
y += selected ? 15 : 13;
@@ -497,7 +605,8 @@ function drawCardText(ctx, entry, placement, selected = false, topOffset = 0) {
function colorWithAlpha(color, alpha) {
const text = String(color || WORLD_OVERLAY_STYLE.accent).trim();
const triplet = text.match(/^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})$/);
if (triplet) return `rgba(${triplet[1]}, ${triplet[2]}, ${triplet[3]}, ${alpha})`;
if (triplet)
return `rgba(${triplet[1]}, ${triplet[2]}, ${triplet[3]}, ${alpha})`;
const hex = text.match(/^#([0-9a-f]{6})$/i);
if (hex) {
const value = Number.parseInt(hex[1], 16);
@@ -534,33 +643,57 @@ function leaderDrawRatio(entry) {
return Number.isFinite(ratio) ? Math.max(0.1, Math.min(0.9, ratio)) : 0.68;
}
export function leaderRevealProgress(entry, timestamp = globalThis.performance?.now?.() ?? Date.now()) {
const linear = Math.min(1, animationLinearProgress(entry, timestamp) / leaderDrawRatio(entry));
export function leaderRevealProgress(
entry,
timestamp = globalThis.performance?.now?.() ?? Date.now(),
) {
const linear = Math.min(
1,
animationLinearProgress(entry, timestamp) / leaderDrawRatio(entry),
);
return 1 - Math.pow(1 - linear, 3);
}
export function tacticalCardRevealAlpha(entry, timestamp = globalThis.performance?.now?.() ?? Date.now()) {
export function tacticalCardRevealAlpha(
entry,
timestamp = globalThis.performance?.now?.() ?? Date.now(),
) {
if (!(Math.max(0, Number(entry?.leaderAnimationMs) || 0) > 0)) return 1;
const start = leaderDrawRatio(entry);
const linear = Math.max(0, Math.min(1,
(animationLinearProgress(entry, timestamp) - start) / Math.max(0.1, 1 - start)));
const linear = Math.max(
0,
Math.min(
1,
(animationLinearProgress(entry, timestamp) - start) /
Math.max(0.1, 1 - start),
),
);
return linear * linear * (3 - 2 * linear);
}
function drawTacticalLeader(ctx, entry, placement, accent, timestamp) {
ctx.strokeStyle = accent;
ctx.lineWidth = (entry.leaderStyle === 'elbow' ? 1.5 : 1) / (placement.paintScale || 1);
ctx.lineWidth =
(entry.leaderStyle === 'elbow' ? 1.5 : 1) / (placement.paintScale || 1);
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
if (entry.leaderStyle !== 'elbow'
|| (placement.corner !== 'above' && placement.corner !== 'below')) {
if (
entry.leaderStyle !== 'elbow' ||
(placement.corner !== 'above' && placement.corner !== 'below')
) {
if (placement.leaderOffset === 0) {
ctx.moveTo(placement.leadFromX, placement.leadFromY);
} else if (placement.corner === 'above' || placement.corner === 'below') {
ctx.moveTo(placement.leadFromX, placement.leadFromY + placement.leaderOffset);
ctx.moveTo(
placement.leadFromX,
placement.leadFromY + placement.leaderOffset,
);
} else {
ctx.moveTo(placement.leadFromX + placement.leaderOffset, placement.leadFromY);
ctx.moveTo(
placement.leadFromX + placement.leaderOffset,
placement.leadFromY,
);
}
ctx.lineTo(placement.leadToX, placement.leadToY);
ctx.stroke();
@@ -575,7 +708,9 @@ function drawTacticalLeader(ctx, entry, placement, accent, timestamp) {
const anchorY = placement.leadFromY;
const horizontalLength = Math.abs(anchorX - cardX);
const verticalLength = Math.abs(anchorY - cardY);
let remaining = leaderRevealProgress(entry, timestamp) * (horizontalLength + verticalLength);
let remaining =
leaderRevealProgress(entry, timestamp) *
(horizontalLength + verticalLength);
ctx.moveTo(anchorX, anchorY);
if (remaining <= horizontalLength) {
const direction = Math.sign(cardX - anchorX) || -1;
@@ -584,7 +719,10 @@ function drawTacticalLeader(ctx, entry, placement, accent, timestamp) {
ctx.lineTo(cardX, anchorY);
remaining -= horizontalLength;
const direction = Math.sign(cardY - anchorY) || -1;
ctx.lineTo(cardX, anchorY + direction * Math.min(verticalLength, remaining));
ctx.lineTo(
cardX,
anchorY + direction * Math.min(verticalLength, remaining),
);
}
ctx.stroke();
}
@@ -600,9 +738,15 @@ export function paintTacticalCard(ctx, entry, placement, alpha = 1) {
ctx.save();
ctx.globalAlpha = alpha;
drawTacticalLeader(ctx, entry, placement, entry.leaderStyle === 'elbow'
? colorWithAlpha(entry.accent, 0.95)
: accentColors.leader, animationTimestamp);
drawTacticalLeader(
ctx,
entry,
placement,
entry.leaderStyle === 'elbow'
? colorWithAlpha(entry.accent, 0.95)
: accentColors.leader,
animationTimestamp,
);
ctx.globalAlpha = alpha * tacticalCardRevealAlpha(entry, animationTimestamp);
@@ -625,13 +769,19 @@ export function paintTacticalCard(ctx, entry, placement, alpha = 1) {
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
ctx.font = selected ? WORLD_OVERLAY_STYLE.fontSelected : WORLD_OVERLAY_STYLE.fontTitle;
ctx.font = selected
? WORLD_OVERLAY_STYLE.fontSelected
: WORLD_OVERLAY_STYLE.fontTitle;
const titleBaseline = y + layout.padY + layout.titleH - 2;
ctx.fillText(String(entry.title || ''), x + layout.padX, titleBaseline);
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
ctx.font = WORLD_OVERLAY_STYLE.fontDetail;
for (let i = 0; i < details.length; i++) {
ctx.fillText(String(details[i]), x + layout.padX, titleBaseline + (i + 1) * layout.lineH);
ctx.fillText(
String(details[i]),
x + layout.padX,
titleBaseline + (i + 1) * layout.lineH,
);
}
ctx.restore();
return placement.rect;
@@ -645,7 +795,11 @@ export function paintLabel(ctx, entry, placement, alpha = 1) {
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
ctx.font = WORLD_OVERLAY_STYLE.fontLabel;
ctx.textBaseline = 'top';
ctx.fillText(String(entry.title || ''), placement.rect.x + 6, placement.rect.y + 4);
ctx.fillText(
String(entry.title || ''),
placement.rect.x + 6,
placement.rect.y + 4,
);
ctx.restore();
return placement.rect;
}
@@ -690,7 +844,11 @@ export function paintThumbnail(ctx, entry, placement, alpha = 1) {
ctx.save();
ctx.globalAlpha = alpha;
drawLeader(ctx, placement, entry.thumbnailLeaderColor || WORLD_OVERLAY_STYLE.leader);
drawLeader(
ctx,
placement,
entry.thumbnailLeaderColor || WORLD_OVERLAY_STYLE.leader,
);
ctx.beginPath();
roundedRectPath(ctx, x, y, w, h, Number(entry.thumbnailRadius) || 4);
ctx.fillStyle = entry.thumbnailBackground || WORLD_OVERLAY_STYLE.background;
@@ -711,13 +869,17 @@ export function paintThumbnail(ctx, entry, placement, alpha = 1) {
if (ruleHeight > 0) {
ctx.beginPath();
roundedRectPath(ctx, x, y, w, ruleHeight, Math.min(1, ruleHeight));
ctx.fillStyle = entry.thumbnailRuleColor || entry.accent || WORLD_OVERLAY_STYLE.accent;
ctx.fillStyle =
entry.thumbnailRuleColor || entry.accent || WORLD_OVERLAY_STYLE.accent;
ctx.fill();
}
const titleChars = Math.max(0, Math.floor(Number(entry.thumbnailTitleChars) || 0));
const title = entry._overlayThumbnailTitle
|| String(entry.title || '').toUpperCase();
const titleChars = Math.max(
0,
Math.floor(Number(entry.thumbnailTitleChars) || 0),
);
const title =
entry._overlayThumbnailTitle || String(entry.title || '').toUpperCase();
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = entry.thumbnailTitleColor || WORLD_OVERLAY_STYLE.title;
@@ -769,7 +931,11 @@ export function paintTracked(ctx, entry, placement, alpha = 1) {
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
ctx.font = WORLD_OVERLAY_STYLE.fontTrackedDetail;
for (let i = 0; i < details.length; i++) {
ctx.fillText(String(details[i]), centerX, titleBaseline + (i + 1) * layout.lineH);
ctx.fillText(
String(details[i]),
centerX,
titleBaseline + (i + 1) * layout.lineH,
);
}
ctx.restore();
return placement.rect;
@@ -842,7 +1008,8 @@ export function paintDetectionCallout(ctx, callout, alpha = 1) {
ctx.globalAlpha = alpha;
ctx.fillStyle = callout.label;
ctx.font = callout.font;
if (callout.primary) ctx.fillText(callout.primary, callout.primaryX, callout.baseline);
if (callout.primary)
ctx.fillText(callout.primary, callout.primaryX, callout.baseline);
if (callout.micro) {
ctx.globalAlpha = alpha * 0.8;
ctx.font = callout.microFont;
@@ -852,11 +1019,16 @@ export function paintDetectionCallout(ctx, callout, alpha = 1) {
/** Dispatch a normalized entry to its pure variant painter. */
export function paintOverlayEntry(ctx, entry, placement, alpha = 1) {
if (entry.cardStyle === 'tactical') return paintTacticalCard(ctx, entry, placement, alpha);
if (entry.variant === 'tracked') return paintTracked(ctx, entry, placement, alpha);
if (entry.selected || entry.variant === 'selected') return paintSelected(ctx, entry, placement, alpha);
if (entry.variant === 'thumbnail') return paintThumbnail(ctx, entry, placement, alpha);
if (entry.cardStyle === 'tactical')
return paintTacticalCard(ctx, entry, placement, alpha);
if (entry.variant === 'tracked')
return paintTracked(ctx, entry, placement, alpha);
if (entry.selected || entry.variant === 'selected')
return paintSelected(ctx, entry, placement, alpha);
if (entry.variant === 'thumbnail')
return paintThumbnail(ctx, entry, placement, alpha);
if (entry.variant === 'card') return paintCard(ctx, entry, placement, alpha);
if (entry.variant === 'track') return paintTrack(ctx, entry, placement, alpha);
if (entry.variant === 'track')
return paintTrack(ctx, entry, placement, alpha);
return paintLabel(ctx, entry, placement, alpha);
}
+2 -1
View File
@@ -61,7 +61,8 @@ function applyMode() {
* @returns {void}
*/
export function installRenderGovernor(viewer) {
if (!viewer?.scene) throw new TypeError('installRenderGovernor requires a Cesium viewer');
if (!viewer?.scene)
throw new TypeError('installRenderGovernor requires a Cesium viewer');
_viewer = viewer;
_installed = true;
// Never let Cesium re-render on simulation-time deltas behind our back —
+4 -4
View File
@@ -29,9 +29,9 @@ export function shouldExpandGlobalContextPanel({
restoring = false,
}) {
return Boolean(
GLOBAL_CONTEXT_EXPLICIT_ACTIONS.has(action)
&& explicitUserAction
&& succeeded
&& !restoring
GLOBAL_CONTEXT_EXPLICIT_ACTIONS.has(action) &&
explicitUserAction &&
succeeded &&
!restoring,
);
}
+223 -76
View File
@@ -60,7 +60,10 @@ function normalizeLayerEntry(entry) {
if (entry && typeof entry === 'object') {
return {
enabled: !!entry.enabled,
params: entry.params && typeof entry.params === 'object' ? deepClone(entry.params) : undefined,
params:
entry.params && typeof entry.params === 'object'
? deepClone(entry.params)
: undefined,
};
}
return { enabled: !!entry };
@@ -75,13 +78,18 @@ function normalizeLayerEntry(entry) {
* @param {number} [options.fallbackIntensity] - Default intensity if not stored
* @returns {{ enabled: boolean, intensity: number, version: number }}
*/
function normalizeBloomState(rawBloom = {}, { projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {}) {
function normalizeBloomState(
rawBloom = {},
{ projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {},
) {
// Determine which bloom scale the stored value was encoded under.
// Older projects (version < PROJECT_VERSION) used scale version 1.
const explicitVersion = Number(rawBloom.version);
const bloomVersion = Number.isFinite(explicitVersion)
? explicitVersion
: (projectVersion >= PROJECT_VERSION ? BLOOM_SCALE_VERSION : 1);
: projectVersion >= PROJECT_VERSION
? BLOOM_SCALE_VERSION
: 1;
const rawIntensity = Number.isFinite(Number(rawBloom.intensity))
? Number(rawBloom.intensity)
@@ -104,9 +112,10 @@ function normalizeBloomState(rawBloom = {}, { projectVersion = PROJECT_VERSION,
function recipeToScene(recipe) {
const post = recipe.post || {};
const ui = recipe.ui || {};
const styleParams = post.styleParams && typeof post.styleParams === 'object'
? deepClone(post.styleParams)
: {};
const styleParams =
post.styleParams && typeof post.styleParams === 'object'
? deepClone(post.styleParams)
: {};
// Normalize layer targets from the recipe into canonical form
const layers = {};
@@ -138,13 +147,15 @@ function recipeToScene(recipe) {
style: recipe.style || 'normal',
bloom: {
enabled: typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom,
intensity: typeof post.bloom === 'number'
? decodeBloomIntensity(post.bloom, 1)
: BLOOM_INTENSITY_DEFAULT,
intensity:
typeof post.bloom === 'number'
? decodeBloomIntensity(post.bloom, 1)
: BLOOM_INTENSITY_DEFAULT,
version: BLOOM_SCALE_VERSION,
},
sharpen: {
enabled: typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen,
enabled:
typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen,
intensity: 65,
},
hud: {
@@ -190,7 +201,11 @@ function createDefaultProject() {
* @param {number} [options.projectVersion] - Schema version of the enclosing project
* @returns {Object} Fully normalized shot
*/
function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION } = {}) {
function normalizeShot(
rawShot,
index = 0,
{ projectVersion = PROJECT_VERSION } = {},
) {
const camera = rawShot?.camera || {};
const visual = rawShot?.visual || {};
const bloom = visual.bloom || {};
@@ -201,7 +216,10 @@ function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION }
return {
id: rawShot?.id || uid('shot'),
title: rawShot?.title || `Shot ${index + 1}`,
durationSec: Math.max(0.2, Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC),
durationSec: Math.max(
0.2,
Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC,
),
holdSec: Math.max(0, Number(rawShot?.holdSec) || 0),
camera: {
lat: Number(camera.lat) || 0,
@@ -213,10 +231,21 @@ function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION }
},
visual: {
style: visual.style || 'normal',
bloom: normalizeBloomState(bloom, { projectVersion, fallbackIntensity: 50 }),
bloom: normalizeBloomState(bloom, {
projectVersion,
fallbackIntensity: 50,
}),
sharpen: {
enabled: !!sharpen.enabled,
intensity: Math.max(0, Math.min(100, Number.isFinite(Number(sharpen.intensity)) ? Number(sharpen.intensity) : 65)),
intensity: Math.max(
0,
Math.min(
100,
Number.isFinite(Number(sharpen.intensity))
? Number(sharpen.intensity)
: 65,
),
),
},
hud: {
visible: typeof hud.visible === 'boolean' ? hud.visible : true,
@@ -224,12 +253,26 @@ function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION }
},
detection: {
mode: typeof detection.mode === 'string' ? detection.mode : 'OFF',
density: Math.max(0, Math.min(100, Number.isFinite(Number(detection.density)) ? Number(detection.density) : 35)),
density: Math.max(
0,
Math.min(
100,
Number.isFinite(Number(detection.density))
? Number(detection.density)
: 35,
),
),
},
styleParams: visual.styleParams && typeof visual.styleParams === 'object' ? deepClone(visual.styleParams) : {},
styleParams:
visual.styleParams && typeof visual.styleParams === 'object'
? deepClone(visual.styleParams)
: {},
},
layers: Object.fromEntries(
Object.entries(rawShot?.layers || {}).map(([layerId, value]) => [layerId, normalizeLayerEntry(value)])
Object.entries(rawShot?.layers || {}).map(([layerId, value]) => [
layerId,
normalizeLayerEntry(value),
]),
),
};
}
@@ -241,7 +284,8 @@ function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION }
* @returns {Object} Fully normalized project at the current PROJECT_VERSION
*/
function normalizeProject(rawProject) {
if (!rawProject || typeof rawProject !== 'object') return createDefaultProject();
if (!rawProject || typeof rawProject !== 'object')
return createDefaultProject();
const projectVersion = Number.isFinite(Number(rawProject.version))
? Number(rawProject.version)
: 1;
@@ -250,7 +294,9 @@ function normalizeProject(rawProject) {
const scenes = scenesRaw
.map((scene, sceneIdx) => {
const shotsRaw = Array.isArray(scene?.shots) ? scene.shots : [];
const shots = shotsRaw.map((shot, shotIdx) => normalizeShot(shot, shotIdx, { projectVersion }));
const shots = shotsRaw.map((shot, shotIdx) =>
normalizeShot(shot, shotIdx, { projectVersion }),
);
return {
id: scene?.id || uid('scene'),
title: scene?.title || `Scene ${sceneIdx + 1}`,
@@ -316,12 +362,21 @@ export class SceneDirector {
this._selectedSceneId = this._project.scenes[0]?.id || null;
this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null;
this._presentation = { status: 'Ready', progress: 0, runtime: '', playbackActive: false, keyboardEnabled: false };
this._state = createStateChannel(() => ({ ...this.getPlaybackStatus(), ...this._presentation, hasRun: !!this._lastRunJson }));
this._presentation = {
status: 'Ready',
progress: 0,
runtime: '',
playbackActive: false,
keyboardEnabled: false,
};
this._state = createStateChannel(() => ({
...this.getPlaybackStatus(),
...this._presentation,
hasRun: !!this._lastRunJson,
}));
this._initUI();
}
_trackWork(promise) {
this._pendingWork ||= new Set();
this._pendingWork.add(promise);
@@ -372,7 +427,10 @@ export class SceneDirector {
// Private browsing / block-all-cookies / quota-exceeded throws here. The
// in-memory project stays usable this session, but persistence failed —
// tell the user instead of crashing the caller (M11).
console.warn('[Scenes] Could not persist project (storage unavailable):', e);
console.warn(
'[Scenes] Could not persist project (storage unavailable):',
e,
);
this._toastStorageError();
}
}
@@ -391,17 +449,30 @@ export class SceneDirector {
toast.classList.remove('visible');
}, 2600);
}
} catch { /* toast is best-effort */ }
} catch {
/* toast is best-effort */
}
}
/** Immutable playback snapshots and completed editing actions. */
subscribe(listener, options) { return this._state.subscribe(listener, options); }
subscribe(listener, options) {
return this._state.subscribe(listener, options);
}
_publish(change) { this._state?.publish(change); }
_publish(change) {
this._state?.publish(change);
}
_shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) {
if (type === 'shot-loaded') this._presentation.status = `Loaded: ${scene.title} / ${shot.title}`;
this._publish({ type, sceneId: scene.id, sceneTitle: scene.title, shot, index });
if (type === 'shot-loaded')
this._presentation.status = `Loaded: ${scene.title} / ${shot.title}`;
this._publish({
type,
sceneId: scene.id,
sceneTitle: scene.title,
shot,
index,
});
}
/**
@@ -424,7 +495,10 @@ export class SceneDirector {
this._selectedShotId = this._getSelectedScene()?.shots[0]?.id || null;
this._renderShotList();
},
selectShot: (id) => { this._selectedShotId = id; this._publish({ type: 'selection-changed' }); },
selectShot: (id) => {
this._selectedShotId = id;
this._publish({ type: 'selection-changed' });
},
renameShot: (sceneId, shotId, title) => {
const { scene, shot } = this._getShot(sceneId, shotId);
if (!shot) return;
@@ -450,7 +524,9 @@ export class SceneDirector {
/** Rebuild the scene dropdown options and sync the selected value. */
_renderSceneSelect() {
if (!this._project.scenes.some((scene) => scene.id === this._selectedSceneId)) {
if (
!this._project.scenes.some((scene) => scene.id === this._selectedSceneId)
) {
this._selectedSceneId = this._project.scenes[0]?.id || null;
}
this._publish({ type: 'scene-options-changed' });
@@ -463,7 +539,10 @@ export class SceneDirector {
*/
_renderShotList() {
const scene = this._getSelectedScene();
if (scene?.shots.length && !scene.shots.some((shot) => shot.id === this._selectedShotId)) {
if (
scene?.shots.length &&
!scene.shots.some((shot) => shot.id === this._selectedShotId)
) {
this._selectedShotId = scene.shots[0].id;
}
this._publish({ type: 'shots-changed' });
@@ -474,7 +553,11 @@ export class SceneDirector {
* @returns {Object|null} The scene, or null if no valid selection
*/
_getSelectedScene() {
return this._project.scenes.find((scene) => scene.id === this._selectedSceneId) || null;
return (
this._project.scenes.find(
(scene) => scene.id === this._selectedSceneId,
) || null
);
}
/**
@@ -511,7 +594,9 @@ export class SceneDirector {
const scene = this._getSelectedScene();
if (!scene) return;
this._project.scenes = this._project.scenes.filter((item) => item.id !== scene.id);
this._project.scenes = this._project.scenes.filter(
(item) => item.id !== scene.id,
);
// Restore default recipes if the user deleted all scenes
if (!this._project.scenes.length) {
this._project = createDefaultProject();
@@ -553,15 +638,18 @@ export class SceneDirector {
return;
}
const shot = normalizeShot({
id: uid('shot'),
title: `Shot ${scene.shots.length + 1}`,
durationSec: DEFAULT_SHOT_DURATION_SEC,
holdSec: DEFAULT_HOLD_SEC,
camera,
visual: this.styleManager.getVisualState(),
layers: this._captureLayerStates(),
}, scene.shots.length);
const shot = normalizeShot(
{
id: uid('shot'),
title: `Shot ${scene.shots.length + 1}`,
durationSec: DEFAULT_SHOT_DURATION_SEC,
holdSec: DEFAULT_HOLD_SEC,
camera,
visual: this.styleManager.getVisualState(),
layers: this._captureLayerStates(),
},
scene.shots.length,
);
scene.shots.push(shot);
this._selectedShotId = shot.id;
@@ -628,7 +716,8 @@ export class SceneDirector {
* @param {number} [options.flyDuration=2.2] - Camera flight duration in seconds
*/
loadShot(sceneId, shotId, options) {
if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' });
if (this._destroyed)
return Promise.resolve({ started: false, reason: 'destroyed' });
return this._trackWork(this._loadShot(sceneId, shotId, options));
}
@@ -652,7 +741,9 @@ export class SceneDirector {
this._renderSceneSelect();
this._renderShotList();
await this.styleManager.applyVisualState(shot.visual, { isCurrent: () => !token.cancelled });
await this.styleManager.applyVisualState(shot.visual, {
isCurrent: () => !token.cancelled,
});
if (token.cancelled) return;
await this._applyLayerStates(shot.layers || {}, token);
if (token.cancelled) return;
@@ -693,8 +784,12 @@ export class SceneDirector {
*/
_claimCameraOwnership() {
// Older/headless style managers may predate the facade — proceed then.
if (typeof this.styleManager?.runImmediateNavigation !== 'function') return true;
const claimed = this.styleManager.runImmediateNavigation('scene', () => true);
if (typeof this.styleManager?.runImmediateNavigation !== 'function')
return true;
const claimed = this.styleManager.runImmediateNavigation(
'scene',
() => true,
);
if (claimed === false) {
this._updateStatus('Camera unavailable — exit cockpit first');
return false;
@@ -712,13 +807,16 @@ export class SceneDirector {
if (!this._project.scenes.length) return [];
// Rotate the scene list so startSceneId comes first
const startIdx = Math.max(0, this._project.scenes.findIndex((scene) => scene.id === startSceneId));
const startIdx = Math.max(
0,
this._project.scenes.findIndex((scene) => scene.id === startSceneId),
);
const ordered = single
? this._project.scenes.slice(startIdx, startIdx + 1)
: [
...this._project.scenes.slice(startIdx),
...this._project.scenes.slice(0, startIdx),
];
...this._project.scenes.slice(startIdx),
...this._project.scenes.slice(0, startIdx),
];
// Flatten scenes into a sequential shot queue
const queue = [];
@@ -748,12 +846,17 @@ export class SceneDirector {
* @returns {{id: string, title: string, shots: number}|null}
*/
findSceneByQuery(query) {
const q = String(query ?? '').trim().toLowerCase();
const q = String(query ?? '')
.trim()
.toLowerCase();
if (!q) return null;
const scene = this._project.scenes.find((item) => item.id === query)
|| this._project.scenes.find((item) => item.title.toLowerCase() === q)
|| this._project.scenes.find((item) => item.title.toLowerCase().includes(q));
return scene ? { id: scene.id, title: scene.title, shots: scene.shots.length } : null;
const scene =
this._project.scenes.find((item) => item.id === query) ||
this._project.scenes.find((item) => item.title.toLowerCase() === q) ||
this._project.scenes.find((item) => item.title.toLowerCase().includes(q));
return scene
? { id: scene.id, title: scene.title, shots: scene.shots.length }
: null;
}
/**
@@ -765,8 +868,12 @@ export class SceneDirector {
running: this._running,
selectedSceneId: this._selectedSceneId,
selectedShotId: this._selectedShotId,
elapsedMs: this._activeRun ? Math.max(0, Date.now() - Date.parse(this._activeRun.startedAt)) : null,
estimatedDurationMs: this._activeRun ? Math.round(this._activeRun.estimatedDurationSec * 1000) : null,
elapsedMs: this._activeRun
? Math.max(0, Date.now() - Date.parse(this._activeRun.startedAt))
: null,
estimatedDurationMs: this._activeRun
? Math.round(this._activeRun.estimatedDurationSec * 1000)
: null,
sceneCount: this._project.scenes.length,
};
}
@@ -792,14 +899,18 @@ export class SceneDirector {
* @returns {Promise<{started: boolean, reason?: string, shots?: number}>}
*/
startScene(sceneId, options) {
if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' });
if (this._destroyed)
return Promise.resolve({ started: false, reason: 'destroyed' });
return this._trackWork(this._startScene(sceneId, options));
}
async _startScene(sceneId, { single = false } = {}) {
if (this._running) return { started: false, reason: 'already-running' };
const queue = this._buildPlaybackQueue(sceneId || this._selectedSceneId || this._project.scenes[0]?.id, { single });
const queue = this._buildPlaybackQueue(
sceneId || this._selectedSceneId || this._project.scenes[0]?.id,
{ single },
);
if (!queue.length) {
this._updateStatus('No shots to run');
return { started: false, reason: 'no-shots' };
@@ -872,7 +983,9 @@ export class SceneDirector {
this._renderSceneSelect();
this._renderShotList();
this._updateStatus(`Running ${idx + 1}/${queue.length}: ${scene.title} / ${shot.title}`);
this._updateStatus(
`Running ${idx + 1}/${queue.length}: ${scene.title} / ${shot.title}`,
);
this._updateRuntime(`${scene.title} · ${shot.title}`);
this._logEvent('shot_start', {
@@ -897,7 +1010,11 @@ export class SceneDirector {
if (token.cancelled) break;
await this._applyLayerStates(shot.layers || {}, token);
if (token.cancelled) break;
await this._flyCamera(shot.camera, shot.durationSec || DEFAULT_SHOT_DURATION_SEC, token);
await this._flyCamera(
shot.camera,
shot.durationSec || DEFAULT_SHOT_DURATION_SEC,
token,
);
if (token.cancelled) break;
// Hold on the final frame before transitioning to the next shot
await this._sleep((shot.holdSec || 0) * 1000, token);
@@ -917,7 +1034,9 @@ export class SceneDirector {
}
} catch (error) {
this._updateStatus(`Error: ${error.message || 'run failed'}`);
this._logEvent('scene_run_error', { message: error.message || 'unknown error' });
this._logEvent('scene_run_error', {
message: error.message || 'unknown error',
});
} finally {
this._finishRun();
}
@@ -930,13 +1049,17 @@ export class SceneDirector {
async runNextScene() {
if (this._destroyed || this._running) return;
const queue = this._buildPlaybackQueue(this._selectedSceneId || this._project.scenes[0]?.id);
const queue = this._buildPlaybackQueue(
this._selectedSceneId || this._project.scenes[0]?.id,
);
if (!queue.length) return;
// Find the shot after the current selection, wrapping to the start
let next = queue[0];
if (this._selectedShotId) {
const idx = queue.findIndex((item) => item.shot.id === this._selectedShotId);
const idx = queue.findIndex(
(item) => item.shot.id === this._selectedShotId,
);
if (idx >= 0) next = queue[(idx + 1) % queue.length];
}
@@ -1064,13 +1187,24 @@ export class SceneDirector {
if (token?.cancelled) return abort();
const signal = token?.signal;
const registered = new Set(this.dataManager.getAll().map((layer) => layer.id));
for (const { id, enabled, params } of sceneLayerPlan(targetStates, registered)) {
const settled = await this.dataManager.setEnabled(id, enabled, signal ? { signal } : undefined);
const registered = new Set(
this.dataManager.getAll().map((layer) => layer.id),
);
for (const { id, enabled, params } of sceneLayerPlan(
targetStates,
registered,
)) {
const settled = await this.dataManager.setEnabled(
id,
enabled,
signal ? { signal } : undefined,
);
if (token?.cancelled) return abort();
if (settled === false) {
refused.push(id);
console.warn(`[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`);
console.warn(
`[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`,
);
continue;
}
applied.push(id);
@@ -1105,7 +1239,8 @@ export class SceneDirector {
*/
async _exitIsolatingContextMode() {
// Older/headless style managers may predate the Context facade.
if (typeof this.styleManager?.getContextModeState !== 'function') return false;
if (typeof this.styleManager?.getContextModeState !== 'function')
return false;
if (typeof this.styleManager?.setContextMode !== 'function') return false;
const state = this.styleManager.getContextModeState() || {};
@@ -1115,9 +1250,17 @@ export class SceneDirector {
const result = await this.styleManager.setContextMode('off');
if (result && result.ok === false) {
console.warn(`[Scenes] Could not exit ${mode}:`, result.error || 'unknown reason');
this._updateStatus(`Could not exit ${mode} — scene layers may be refused`);
this._logEvent('context_mode_exit_failed', { mode, error: result.error || null });
console.warn(
`[Scenes] Could not exit ${mode}:`,
result.error || 'unknown reason',
);
this._updateStatus(
`Could not exit ${mode} — scene layers may be refused`,
);
this._logEvent('context_mode_exit_failed', {
mode,
error: result.error || null,
});
return false;
}
this._logEvent('context_mode_exited', { mode });
@@ -1140,11 +1283,14 @@ export class SceneDirector {
// a free-text search the shot has already flown away from.
this.styleManager?.clearSearchedLocation?.();
const duration = Math.max(0.2, Number(durationSec) || DEFAULT_SHOT_DURATION_SEC);
const duration = Math.max(
0.2,
Number(durationSec) || DEFAULT_SHOT_DURATION_SEC,
);
const destination = Cesium.Cartesian3.fromDegrees(
cameraState.lon,
cameraState.lat,
cameraState.alt
cameraState.alt,
);
await new Promise((resolve) => {
@@ -1244,7 +1390,9 @@ export class SceneDirector {
* Editing controls are disabled during a run; stop is disabled when idle.
* @param {boolean} isRunning
*/
_setButtons(isRunning) { this._publish({ type: 'buttons-changed', running: isRunning }); }
_setButtons(isRunning) {
this._publish({ type: 'buttons-changed', running: isRunning });
}
_setPlaybackActive(active) {
this._presentation.playbackActive = active;
@@ -1297,5 +1445,4 @@ export class SceneDirector {
payload: payload || null,
});
}
}
+240 -24
View File
@@ -22,11 +22,56 @@ export const SCENE_RECIPES = [
detectionMode: 'OFF',
},
cameraPath: [
{ lat: 20.0, lon: -30.0, alt: 19000000, heading: 25, pitch: -65, roll: 0, duration: 6, hold: 1 },
{ lat: 46.0, lon: 2.0, alt: 8000000, heading: 40, pitch: -52, roll: 0, duration: 5, hold: 1 },
{ lat: 35.0, lon: 139.0, alt: 4200000, heading: 22, pitch: -46, roll: 0, duration: 5, hold: 1 },
{ lat: 37.6, lon: -122.4, alt: 1700000, heading: 8, pitch: -40, roll: 0, duration: 5, hold: 1 },
{ lat: 0.0, lon: -20.0, alt: 12000000, heading: -10, pitch: -70, roll: 0, duration: 4, hold: 0 },
{
lat: 20.0,
lon: -30.0,
alt: 19000000,
heading: 25,
pitch: -65,
roll: 0,
duration: 6,
hold: 1,
},
{
lat: 46.0,
lon: 2.0,
alt: 8000000,
heading: 40,
pitch: -52,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 35.0,
lon: 139.0,
alt: 4200000,
heading: 22,
pitch: -46,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 37.6,
lon: -122.4,
alt: 1700000,
heading: 8,
pitch: -40,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 0.0,
lon: -20.0,
alt: 12000000,
heading: -10,
pitch: -70,
roll: 0,
duration: 4,
hold: 0,
},
],
},
{
@@ -55,11 +100,56 @@ export const SCENE_RECIPES = [
},
},
cameraPath: [
{ lat: 28.0, lon: -82.0, alt: 22000000, heading: 0, pitch: -82, roll: 0, duration: 7, hold: 1 },
{ lat: 10.0, lon: 20.0, alt: 12000000, heading: 18, pitch: -74, roll: 0, duration: 5, hold: 1 },
{ lat: 35.7, lon: 139.7, alt: 6500000, heading: 24, pitch: -64, roll: 0, duration: 5, hold: 1 },
{ lat: 48.8, lon: 2.3, alt: 3000000, heading: 32, pitch: -58, roll: 0, duration: 5, hold: 1 },
{ lat: 30.0, lon: -25.0, alt: 15000000, heading: -20, pitch: -78, roll: 0, duration: 4, hold: 0 },
{
lat: 28.0,
lon: -82.0,
alt: 22000000,
heading: 0,
pitch: -82,
roll: 0,
duration: 7,
hold: 1,
},
{
lat: 10.0,
lon: 20.0,
alt: 12000000,
heading: 18,
pitch: -74,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 35.7,
lon: 139.7,
alt: 6500000,
heading: 24,
pitch: -64,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 48.8,
lon: 2.3,
alt: 3000000,
heading: 32,
pitch: -58,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 30.0,
lon: -25.0,
alt: 15000000,
heading: -20,
pitch: -78,
roll: 0,
duration: 4,
hold: 0,
},
],
},
{
@@ -88,10 +178,46 @@ export const SCENE_RECIPES = [
},
},
cameraPath: [
{ lat: 37.0, lon: -121.0, alt: 5200000, heading: 20, pitch: -62, roll: 0, duration: 5, hold: 1 },
{ lat: 35.7, lon: 140.0, alt: 3100000, heading: 5, pitch: -55, roll: 0, duration: 5, hold: 1 },
{ lat: -36.8, lon: 174.7, alt: 2600000, heading: -12, pitch: -50, roll: 0, duration: 5, hold: 1 },
{ lat: 38.0, lon: -10.0, alt: 9000000, heading: 12, pitch: -70, roll: 0, duration: 4, hold: 0 },
{
lat: 37.0,
lon: -121.0,
alt: 5200000,
heading: 20,
pitch: -62,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 35.7,
lon: 140.0,
alt: 3100000,
heading: 5,
pitch: -55,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: -36.8,
lon: 174.7,
alt: 2600000,
heading: -12,
pitch: -50,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 38.0,
lon: -10.0,
alt: 9000000,
heading: 12,
pitch: -70,
roll: 0,
duration: 4,
hold: 0,
},
],
},
{
@@ -120,11 +246,56 @@ export const SCENE_RECIPES = [
},
},
cameraPath: [
{ lat: 40.73, lon: -74.0, alt: 1500000, heading: 25, pitch: -44, roll: 0, duration: 5, hold: 1 },
{ lat: 40.73, lon: -74.0, alt: 550000, heading: 58, pitch: -36, roll: 0, duration: 4, hold: 1 },
{ lat: 40.73, lon: -74.0, alt: 260000, heading: 102, pitch: -33, roll: 0, duration: 4, hold: 1 },
{ lat: 34.05, lon: -118.24, alt: 1200000, heading: 45, pitch: -42, roll: 0, duration: 5, hold: 1 },
{ lat: 51.5, lon: -0.12, alt: 1200000, heading: 22, pitch: -44, roll: 0, duration: 5, hold: 0 },
{
lat: 40.73,
lon: -74.0,
alt: 1500000,
heading: 25,
pitch: -44,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 40.73,
lon: -74.0,
alt: 550000,
heading: 58,
pitch: -36,
roll: 0,
duration: 4,
hold: 1,
},
{
lat: 40.73,
lon: -74.0,
alt: 260000,
heading: 102,
pitch: -33,
roll: 0,
duration: 4,
hold: 1,
},
{
lat: 34.05,
lon: -118.24,
alt: 1200000,
heading: 45,
pitch: -42,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 51.5,
lon: -0.12,
alt: 1200000,
heading: 22,
pitch: -44,
roll: 0,
duration: 5,
hold: 0,
},
],
},
{
@@ -152,11 +323,56 @@ export const SCENE_RECIPES = [
},
},
cameraPath: [
{ lat: 35.68, lon: 139.76, alt: 280000, heading: 30, pitch: -26, roll: 0, duration: 5, hold: 1 },
{ lat: 35.68, lon: 139.76, alt: 900000, heading: 26, pitch: -38, roll: 0, duration: 4, hold: 1 },
{ lat: 35.68, lon: 139.76, alt: 3800000, heading: 18, pitch: -52, roll: 0, duration: 5, hold: 1 },
{ lat: 20.0, lon: 110.0, alt: 9500000, heading: 8, pitch: -66, roll: 0, duration: 5, hold: 1 },
{ lat: 5.0, lon: 30.0, alt: 18000000, heading: -8, pitch: -78, roll: 0, duration: 6, hold: 0 },
{
lat: 35.68,
lon: 139.76,
alt: 280000,
heading: 30,
pitch: -26,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 35.68,
lon: 139.76,
alt: 900000,
heading: 26,
pitch: -38,
roll: 0,
duration: 4,
hold: 1,
},
{
lat: 35.68,
lon: 139.76,
alt: 3800000,
heading: 18,
pitch: -52,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 20.0,
lon: 110.0,
alt: 9500000,
heading: 8,
pitch: -66,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 5.0,
lon: 30.0,
alt: 18000000,
heading: -8,
pitch: -78,
roll: 0,
duration: 6,
hold: 0,
},
],
},
];
+18 -9
View File
@@ -80,7 +80,8 @@ export const SCENE_SELECTION_PARAM_PATTERN =
*/
export function stripSceneTrackingParams(params) {
if (!params || typeof params !== 'object') return undefined;
if (!SCENE_TRACKING_PARAM_KEYS.some((key) => Object.hasOwn(params, key))) return params;
if (!SCENE_TRACKING_PARAM_KEYS.some((key) => Object.hasOwn(params, key)))
return params;
const kept = {};
for (const [key, value] of Object.entries(params)) {
@@ -122,10 +123,12 @@ export const SCENE_EXCLUSIVITY_PROBE_LAYER_ID = '__scene-exclusivity-probe__';
*/
export function sceneRequiresContextModeExit(contextMode) {
if (!contextMode) return false;
return contextLayerEnableBlockReason({
contextMode,
change: { layerId: SCENE_EXCLUSIVITY_PROBE_LAYER_ID, enabled: true },
}) !== null;
return (
contextLayerEnableBlockReason({
contextMode,
change: { layerId: SCENE_EXCLUSIVITY_PROBE_LAYER_ID, enabled: true },
}) !== null
);
}
/**
@@ -144,9 +147,12 @@ export function sceneRequiresContextModeExit(contextMode) {
* @returns {Array<{ id: string, enabled: boolean, params: Object|undefined }>}
*/
export function sceneLayerPlan(targetStates, registeredIds) {
const known = registeredIds instanceof Set
? registeredIds
: (registeredIds ? new Set(registeredIds) : null);
const known =
registeredIds instanceof Set
? registeredIds
: registeredIds
? new Set(registeredIds)
: null;
const plan = [];
for (const [id, target] of Object.entries(targetStates || {})) {
@@ -154,7 +160,10 @@ export function sceneLayerPlan(targetStates, registeredIds) {
plan.push({
id,
enabled: !!(target && target.enabled),
params: target && target.params ? stripSceneTrackingParams(target.params) : undefined,
params:
target && target.params
? stripSceneTrackingParams(target.params)
: undefined,
});
}
return plan;
+65 -22
View File
@@ -133,9 +133,13 @@ export function scopeTerminusAlpha(heightM) {
if (h >= SCOPE_TERMINUS_FAR_M) return SCOPE_OUTSIDE_ALPHA;
if (h <= SCOPE_TERMINUS_NEAR_M) return SCOPE_TERMINUS_ALPHA_NEAR;
// 0 at the far edge → 1 at the near edge, eased so neither end steps visibly.
const t = (SCOPE_TERMINUS_FAR_M - h) / (SCOPE_TERMINUS_FAR_M - SCOPE_TERMINUS_NEAR_M);
const t =
(SCOPE_TERMINUS_FAR_M - h) / (SCOPE_TERMINUS_FAR_M - SCOPE_TERMINUS_NEAR_M);
const eased = t * t * (3 - 2 * t);
return SCOPE_OUTSIDE_ALPHA + (SCOPE_TERMINUS_ALPHA_NEAR - SCOPE_OUTSIDE_ALPHA) * eased;
return (
SCOPE_OUTSIDE_ALPHA +
(SCOPE_TERMINUS_ALPHA_NEAR - SCOPE_OUTSIDE_ALPHA) * eased
);
}
/**
@@ -149,7 +153,11 @@ export function quantizeScopeTerminusAlpha(alpha) {
// Round the PRODUCT too: n * 0.005 lands on values like 0.9400000000000001,
// which would ride straight into the rgba() string. The quantum is 3-decimal,
// so 3 decimals is lossless here.
return Math.round(Math.round(a / SCOPE_TERMINUS_QUANTUM) * SCOPE_TERMINUS_QUANTUM * 1000) / 1000;
return (
Math.round(
Math.round(a / SCOPE_TERMINUS_QUANTUM) * SCOPE_TERMINUS_QUANTUM * 1000,
) / 1000
);
}
/**
@@ -164,10 +172,14 @@ export function clampScopeTerminusPct(value) {
// boolean — is ABSENT, not zero. (Number(null) === 0 would otherwise pin the
// floor on every adaptive write.)
const raw = typeof value === 'string' ? value.trim() : value;
if (typeof raw !== 'number' && (typeof raw !== 'string' || raw === '')) return null;
if (typeof raw !== 'number' && (typeof raw !== 'string' || raw === ''))
return null;
const pct = Number(raw);
if (!Number.isFinite(pct)) return null;
return Math.max(SCOPE_TERMINUS_MIN_PCT, Math.min(SCOPE_TERMINUS_MAX_PCT, Math.round(pct)));
return Math.max(
SCOPE_TERMINUS_MIN_PCT,
Math.min(SCOPE_TERMINUS_MAX_PCT, Math.round(pct)),
);
}
/**
@@ -201,7 +213,10 @@ export function updateScopeTerminusForHeight(heightM) {
* @returns {void}
*/
function withCoalescedPaint(fn) {
if (_coalescingPaint) { fn(); return; } // already inside a scope
if (_coalescingPaint) {
fn();
return;
} // already inside a scope
_coalescingPaint = true;
_paintDirty = false;
try {
@@ -218,7 +233,9 @@ function withCoalescedPaint(fn) {
/** @returns {number} Quantized terminus the CURRENT camera + override imply. */
function currentTerminusTarget() {
return quantizeScopeTerminusAlpha(
_terminusOverride == null ? scopeTerminusAlpha(currentCameraHeightM()) : _terminusOverride,
_terminusOverride == null
? scopeTerminusAlpha(currentCameraHeightM())
: _terminusOverride,
);
}
@@ -228,7 +245,10 @@ export function setScopeTerminusOverride(alpha) {
} else {
// Same supported band as the `sce` hash key: a sub-globe-scale terminus is
// a hole in the mask, not a scope, so every entry point floors it.
_terminusOverride = Math.max(SCOPE_OUTSIDE_ALPHA, Math.min(1, Number(alpha)));
_terminusOverride = Math.max(
SCOPE_OUTSIDE_ALPHA,
Math.min(1, Number(alpha)),
);
}
// Re-resolve immediately against the live camera so the override is visible
// without waiting for the next sample.
@@ -256,7 +276,9 @@ export function getScopeTerminusRepaintCount() {
}
/** Backing-store scale actually used by the last draw(). */
export function scopeMaskDevicePixelRatio(ratio = (typeof window !== 'undefined' ? window.devicePixelRatio : 1)) {
export function scopeMaskDevicePixelRatio(
ratio = typeof window !== 'undefined' ? window.devicePixelRatio : 1,
) {
return Math.min(2, Number(ratio) || 1);
}
@@ -272,7 +294,8 @@ export function scopeMaskDevicePixelRatio(ratio = (typeof window !== 'undefined'
* @returns {void}
*/
function watchDevicePixelRatio() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
return;
teardownDevicePixelRatioWatch();
const dpr = window.devicePixelRatio || 1;
let query;
@@ -292,14 +315,18 @@ function watchDevicePixelRatio() {
draw();
});
};
if (typeof query.addEventListener === 'function') query.addEventListener('change', _dprListener, { once: true });
else if (typeof query.addListener === 'function') query.addListener(_dprListener);
if (typeof query.addEventListener === 'function')
query.addEventListener('change', _dprListener, { once: true });
else if (typeof query.addListener === 'function')
query.addListener(_dprListener);
}
function teardownDevicePixelRatioWatch() {
if (_dprQuery && _dprListener) {
if (typeof _dprQuery.removeEventListener === 'function') _dprQuery.removeEventListener('change', _dprListener);
else if (typeof _dprQuery.removeListener === 'function') _dprQuery.removeListener(_dprListener);
if (typeof _dprQuery.removeEventListener === 'function')
_dprQuery.removeEventListener('change', _dprListener);
else if (typeof _dprQuery.removeListener === 'function')
_dprQuery.removeListener(_dprListener);
}
_dprQuery = null;
_dprListener = null;
@@ -326,13 +353,18 @@ export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) {
centerY: keyhole.centerY,
innerR: Math.max(0, keyhole.radius - half),
outerR: keyhole.radius + half,
maxR: Math.hypot(Math.max(keyhole.centerX, width - keyhole.centerX),
Math.max(keyhole.centerY, height - keyhole.centerY)),
maxR: Math.hypot(
Math.max(keyhole.centerX, width - keyhole.centerX),
Math.max(keyhole.centerY, height - keyhole.centerY),
),
};
}
function draw() {
if (_coalescingPaint) { _paintDirty = true; return; } // one paint at scope exit
if (_coalescingPaint) {
_paintDirty = true;
return;
} // one paint at scope exit
if (!_canvas || !_container) return;
// SCOPE OFF is the cheapest state, not a painted one: bail out BEFORE the
// backing-store resize + clear (a full-viewport allocation) that used to run
@@ -377,8 +409,12 @@ function draw() {
return;
}
const gradient = ctx.createRadialGradient(
geo.centerX, geo.centerY, geo.innerR,
geo.centerX, geo.centerY, geo.outerR,
geo.centerX,
geo.centerY,
geo.innerR,
geo.centerX,
geo.centerY,
geo.outerR,
);
gradient.addColorStop(0, `rgba(${r},${g},${b},0)`);
gradient.addColorStop(1, `rgba(${r},${g},${b},${_terminusAlpha})`);
@@ -433,7 +469,8 @@ function watchCameraHeight(viewer) {
// throttle bookkeeping, no quantize — the listener is a single compare.
// setScopeMaskEnabled(true) re-syncs the alpha it skipped.
if (!_enabled) return;
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
const now =
typeof performance !== 'undefined' ? performance.now() : Date.now();
if (now - _lastTerminusSampleMs < SCOPE_TERMINUS_SAMPLE_MS) return;
_lastTerminusSampleMs = now;
updateScopeTerminusForHeight(currentCameraHeightM());
@@ -449,8 +486,14 @@ function watchCameraHeight(viewer) {
}
function teardownCameraHeightWatch() {
if (_cameraSampleRemover) { _cameraSampleRemover(); _cameraSampleRemover = null; }
if (_cameraMoveEndRemover) { _cameraMoveEndRemover(); _cameraMoveEndRemover = null; }
if (_cameraSampleRemover) {
_cameraSampleRemover();
_cameraSampleRemover = null;
}
if (_cameraMoveEndRemover) {
_cameraMoveEndRemover();
_cameraMoveEndRemover = null;
}
_lastTerminusSampleMs = -Infinity;
}
+156 -67
View File
@@ -5,7 +5,10 @@ import {
normalizeAllocationStrategy,
} from './data/detectionPolicy.js';
import { clampScopeTerminusPct } from './scopeMask.js';
import { decodeLayerStateParams, encodeLayerStateParams } from './data/layerState.js';
import {
decodeLayerStateParams,
encodeLayerStateParams,
} from './data/layerState.js';
/**
* Share Links — URL Hash State Management
@@ -44,12 +47,12 @@ const SHARE_PANEL_STATE_REGISTRY = Object.freeze([
{ id: 'param-slider-panel', token: 'm', pinnable: false },
]);
const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze(new Map(
SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry]),
));
const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze(
new Map(SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry])),
);
const URL_TO_STYLE = Object.fromEntries(
Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k])
Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k]),
);
const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({
@@ -87,11 +90,10 @@ const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({
});
export class ShareLinkManager {
constructor(viewer, {
onRestore,
isNavigationCurrent,
cancelOwnedNavigation,
} = {}) {
constructor(
viewer,
{ onRestore, isNavigationCurrent, cancelOwnedNavigation } = {},
) {
this.viewer = viewer;
this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void
this._debounceTimer = null;
@@ -135,17 +137,21 @@ export class ShareLinkManager {
this._destroyed = false;
this._restoreGeneration = 0;
this._activeCameraFlight = null;
this._isNavigationCurrent = typeof isNavigationCurrent === 'function'
? isNavigationCurrent
: () => true;
this._cancelOwnedNavigation = typeof cancelOwnedNavigation === 'function'
? cancelOwnedNavigation
: null;
this._isNavigationCurrent =
typeof isNavigationCurrent === 'function'
? isNavigationCurrent
: () => true;
this._cancelOwnedNavigation =
typeof cancelOwnedNavigation === 'function'
? cancelOwnedNavigation
: null;
// Listen for camera changes
this._removeCameraChanged = this.viewer.camera.changed.addEventListener(() => {
this._scheduleUpdate();
});
this._removeCameraChanged = this.viewer.camera.changed.addEventListener(
() => {
this._scheduleUpdate();
},
);
}
/**
@@ -192,10 +198,15 @@ export class ShareLinkManager {
sharpenIntensity: parseOr(params.get('si'), 49),
hudVariant: params.get('hud') || 'tactical',
hudVisible: params.get('hv') === '1',
detectionMode: restoredDetection.enabled ? restoredDetection.profile : 'OFF',
detectionMode: restoredDetection.enabled
? restoredDetection.profile
: 'OFF',
detectionDensity: restoredDetection.densityPct,
detectionAllocation: normalizeAllocationStrategy(params.get('da')),
detectionFadePct: Math.max(0, Math.min(40, Math.round(parseOr(params.get('kf'), 16)))),
detectionFadePct: Math.max(
0,
Math.min(40, Math.round(parseOr(params.get('kf'), 16))),
),
// Deliberately still 5 after the 2026-08-23 default moved to 3. Same rule
// as `scf` below: this is the PARSE fallback for a link that predates
// `ko`, and such a link was authored when 5 was what its author saw. Every
@@ -203,7 +214,10 @@ export class ShareLinkManager {
// the field — so nothing from the 5 % era depends on this number either
// way. The first-run default is a different question, answered in
// celestialRing.js.
detectionOutsideOpacityPct: Math.max(0, Math.min(100, Math.round(parseOr(params.get('ko'), 5)))),
detectionOutsideOpacityPct: Math.max(
0,
Math.min(100, Math.round(parseOr(params.get('ko'), 5))),
),
celestialRing: params.has('cr') ? params.get('cr') === '1' : false,
scopeEnabled: params.has('sc') ? params.get('sc') === '1' : true,
// Deliberately still 35 through both later default moves (0 on
@@ -216,7 +230,10 @@ export class ShareLinkManager {
// (`_scopeFeatherPct` in the constructor tracks the default: that one
// mirrors live state for the link this session generates, so it must match
// the mask, not the archive.)
scopeFeatherPct: Math.max(0, Math.min(100, Math.round(parseOr(params.get('scf'), 35)))),
scopeFeatherPct: Math.max(
0,
Math.min(100, Math.round(parseOr(params.get('scf'), 35))),
),
// Absent (or non-numeric) `sce` = adaptive (null), the default behavior;
// a value pins the terminus opacity percent, clamped into the SUPPORTED
// 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in
@@ -226,9 +243,10 @@ export class ShareLinkManager {
: null,
mapStack: params.get('map') || 'photoreal',
layerState: decodedLayerState,
layerStateInvalid: params.get('v') === '2'
&& params.has('l')
&& decodedLayerState === null,
layerStateInvalid:
params.get('v') === '2' &&
params.has('l') &&
decodedLayerState === null,
panelState: decodePanelStateParams(params),
sharedAtMs: decodeShareCreatedAtMs(params),
};
@@ -247,27 +265,40 @@ export class ShareLinkManager {
* Apply a parsed state to the viewer + style manager.
*/
async applyState(state, { applyCamera = true, navigationToken = null } = {}) {
if (this._destroyed || !state) return { succeeded: false, reason: 'unavailable' };
if (this._destroyed || !state)
return { succeeded: false, reason: 'unavailable' };
const view = {
destination: Cesium.Cartesian3.fromDegrees(state.lon, state.lat, state.alt),
destination: Cesium.Cartesian3.fromDegrees(
state.lon,
state.lat,
state.alt,
),
orientation: {
heading: Cesium.Math.toRadians(state.heading),
pitch: Cesium.Math.toRadians(state.pitch),
roll: Cesium.Math.toRadians(state.roll),
},
};
let cameraPromise = Promise.resolve({ status: applyCamera ? 'superseded' : 'skipped' });
let cameraPromise = Promise.resolve({
status: applyCamera ? 'superseded' : 'skipped',
});
if (applyCamera && this._isNavigationCurrent(navigationToken)) {
const restoreGeneration = ++this._restoreGeneration;
let settleCamera;
cameraPromise = new Promise((resolve) => { settleCamera = resolve; });
cameraPromise = new Promise((resolve) => {
settleCamera = resolve;
});
const releaseOwnedFlight = (status = 'cancelled') => {
if (this._activeCameraFlight?.restoreGeneration === restoreGeneration) {
this._activeCameraFlight = null;
}
settleCamera({ status });
};
this._activeCameraFlight = { restoreGeneration, navigationToken, settle: releaseOwnedFlight };
this._activeCameraFlight = {
restoreGeneration,
navigationToken,
settle: releaseOwnedFlight,
};
// Re-apply the final pose only while this share restoration still owns
// navigation. A later user or voice command wins over delayed restore.
this.viewer.camera.flyTo({
@@ -276,9 +307,9 @@ export class ShareLinkManager {
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
complete: () => {
if (
this._destroyed
|| restoreGeneration !== this._restoreGeneration
|| !this._isNavigationCurrent(navigationToken)
this._destroyed ||
restoreGeneration !== this._restoreGeneration ||
!this._isNavigationCurrent(navigationToken)
) {
releaseOwnedFlight('superseded');
return;
@@ -293,14 +324,17 @@ export class ShareLinkManager {
// Notify the style manager via callback
const reserved = state.restoreAuthority || null;
const visualCurrent = !reserved || reserved.visual === this._restoreAuthority.visual;
const visualCurrent =
!reserved || reserved.visual === this._restoreAuthority.visual;
const mapCurrent = !reserved || reserved.map === this._restoreAuthority.map;
let panelState = state.panelState;
if (reserved && panelState?.specs) {
panelState = {
specs: panelState.specs.filter((spec) => (
(reserved.panels?.get(spec.id) || 0) === (this._restoreAuthority.panels.get(spec.id) || 0)
)),
specs: panelState.specs.filter(
(spec) =>
(reserved.panels?.get(spec.id) || 0) ===
(this._restoreAuthority.panels.get(spec.id) || 0),
),
};
if (panelState.specs.length === 0) panelState = null;
}
@@ -317,9 +351,13 @@ export class ShareLinkManager {
hudVisible: visualCurrent ? state.hudVisible : undefined,
detectionMode: visualCurrent ? state.detectionMode : undefined,
detectionDensity: visualCurrent ? state.detectionDensity : undefined,
detectionAllocation: visualCurrent ? state.detectionAllocation : undefined,
detectionAllocation: visualCurrent
? state.detectionAllocation
: undefined,
detectionFadePct: visualCurrent ? state.detectionFadePct : undefined,
detectionOutsideOpacityPct: visualCurrent ? state.detectionOutsideOpacityPct : undefined,
detectionOutsideOpacityPct: visualCurrent
? state.detectionOutsideOpacityPct
: undefined,
celestialRing: visualCurrent ? state.celestialRing : undefined,
scopeEnabled: visualCurrent ? state.scopeEnabled : undefined,
scopeFeatherPct: visualCurrent ? state.scopeFeatherPct : undefined,
@@ -336,7 +374,11 @@ export class ShareLinkManager {
camera: camera.status,
visual: visualCurrent ? restoreStatus : 'superseded',
map: mapCurrent ? restoreStatus : 'superseded',
panels: panelState ? restoreStatus : (state.panelState ? 'superseded' : 'skipped'),
panels: panelState
? restoreStatus
: state.panelState
? 'superseded'
: 'skipped',
};
}
@@ -351,7 +393,10 @@ export class ShareLinkManager {
claimRestoreLane(lane, panelId = null) {
if (!this._initialRestorePending) return;
if (lane === 'panel' && panelId) {
this._restoreAuthority.panels.set(panelId, (this._restoreAuthority.panels.get(panelId) || 0) + 1);
this._restoreAuthority.panels.set(
panelId,
(this._restoreAuthority.panels.get(panelId) || 0) + 1,
);
} else if (lane === 'visual' || lane === 'map') {
this._restoreAuthority[lane] += 1;
}
@@ -369,7 +414,8 @@ export class ShareLinkManager {
/** Install the active visual preset parameter source used by URL generation. */
setStyleParamStateProvider(provider) {
this._styleParamStateProvider = typeof provider === 'function' ? provider : null;
this._styleParamStateProvider =
typeof provider === 'function' ? provider : null;
}
/** Called only when the durable layer preference model changes. */
@@ -384,7 +430,11 @@ export class ShareLinkManager {
}
_encodePanelStateParam(params, panelState) {
if (!panelState || !Array.isArray(panelState.specs) || panelState.specs.length === 0) {
if (
!panelState ||
!Array.isArray(panelState.specs) ||
panelState.specs.length === 0
) {
params.delete(SHARE_UI_STATE_PARAM);
return;
}
@@ -397,7 +447,8 @@ export class ShareLinkManager {
assignments.push(`${spec.token}.p.${state.pinned ? '1' : '0'}`);
}
}
if (assignments.length) params.set(SHARE_UI_STATE_PARAM, assignments.join('_'));
if (assignments.length)
params.set(SHARE_UI_STATE_PARAM, assignments.join('_'));
else params.delete(SHARE_UI_STATE_PARAM);
}
@@ -410,18 +461,30 @@ export class ShareLinkManager {
onToggleChange(bloom, sharpen, extras = {}) {
this._bloomEnabled = bloom;
this._sharpenEnabled = sharpen;
if (typeof extras.bloomIntensity === 'number') this._bloomIntensity = extras.bloomIntensity;
if (typeof extras.bloomVersion === 'number') this._bloomVersion = extras.bloomVersion;
if (typeof extras.sharpenIntensity === 'number') this._sharpenIntensity = extras.sharpenIntensity;
if (typeof extras.hudVariant === 'string') this._hudVariant = extras.hudVariant;
if (typeof extras.hudVisible === 'boolean') this._hudVisible = extras.hudVisible;
if (typeof extras.detectionMode === 'string') this._detectionMode = extras.detectionMode.toUpperCase();
if (typeof extras.detectionDensity === 'number') this._detectionDensity = extras.detectionDensity;
if (typeof extras.bloomIntensity === 'number')
this._bloomIntensity = extras.bloomIntensity;
if (typeof extras.bloomVersion === 'number')
this._bloomVersion = extras.bloomVersion;
if (typeof extras.sharpenIntensity === 'number')
this._sharpenIntensity = extras.sharpenIntensity;
if (typeof extras.hudVariant === 'string')
this._hudVariant = extras.hudVariant;
if (typeof extras.hudVisible === 'boolean')
this._hudVisible = extras.hudVisible;
if (typeof extras.detectionMode === 'string')
this._detectionMode = extras.detectionMode.toUpperCase();
if (typeof extras.detectionDensity === 'number')
this._detectionDensity = extras.detectionDensity;
if (typeof extras.detectionAllocation === 'string') {
this._detectionAllocation = normalizeAllocationStrategy(extras.detectionAllocation);
this._detectionAllocation = normalizeAllocationStrategy(
extras.detectionAllocation,
);
}
if (typeof extras.detectionFadePct === 'number') {
this._detectionFadePct = Math.max(0, Math.min(40, Math.round(extras.detectionFadePct)));
this._detectionFadePct = Math.max(
0,
Math.min(40, Math.round(extras.detectionFadePct)),
);
}
if (typeof extras.detectionOutsideOpacityPct === 'number') {
this._detectionOutsideOpacityPct = Math.max(
@@ -429,10 +492,15 @@ export class ShareLinkManager {
Math.min(100, Math.round(extras.detectionOutsideOpacityPct)),
);
}
if (typeof extras.celestialRingEnabled === 'boolean') this._celestialRingEnabled = extras.celestialRingEnabled;
if (typeof extras.scopeEnabled === 'boolean') this._scopeEnabled = extras.scopeEnabled;
if (typeof extras.celestialRingEnabled === 'boolean')
this._celestialRingEnabled = extras.celestialRingEnabled;
if (typeof extras.scopeEnabled === 'boolean')
this._scopeEnabled = extras.scopeEnabled;
if (typeof extras.scopeFeatherPct === 'number') {
this._scopeFeatherPct = Math.max(0, Math.min(100, Math.round(extras.scopeFeatherPct)));
this._scopeFeatherPct = Math.max(
0,
Math.min(100, Math.round(extras.scopeFeatherPct)),
);
}
if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null;
else if (typeof extras.scopeTerminusPct === 'number') {
@@ -482,9 +550,18 @@ export class ShareLinkManager {
params.set('lat', Cesium.Math.toDegrees(carto.latitude).toFixed(4));
params.set('lon', Cesium.Math.toDegrees(carto.longitude).toFixed(4));
params.set('alt', Math.round(carto.height).toString());
params.set('heading', Math.round(Cesium.Math.toDegrees(camera.heading)).toString());
params.set('pitch', Math.round(Cesium.Math.toDegrees(camera.pitch)).toString());
params.set('roll', Math.round(Cesium.Math.toDegrees(camera.roll)).toString());
params.set(
'heading',
Math.round(Cesium.Math.toDegrees(camera.heading)).toString(),
);
params.set(
'pitch',
Math.round(Cesium.Math.toDegrees(camera.pitch)).toString(),
);
params.set(
'roll',
Math.round(Cesium.Math.toDegrees(camera.roll)).toString(),
);
params.set('style', STYLE_TO_URL[this._currentStyle] || 'normal');
params.set('bloom', this._bloomEnabled ? '1' : '0');
params.set('sharpen', this._sharpenEnabled ? '1' : '0');
@@ -527,7 +604,10 @@ export class ShareLinkManager {
destroy() {
if (this._destroyed) return;
const activeFlight = this._activeCameraFlight;
if (activeFlight && this._isNavigationCurrent(activeFlight.navigationToken)) {
if (
activeFlight &&
this._isNavigationCurrent(activeFlight.navigationToken)
) {
this._cancelOwnedNavigation?.();
}
activeFlight?.settle?.('destroyed');
@@ -570,18 +650,22 @@ export function encodeStyleParamState(params, styleName, values) {
const clamped = Math.max(spec.min, Math.min(spec.max, numeric));
assignments.push(`${spec.token}.${Math.round(clamped * 100)}`);
}
if (assignments.length) params.set(SHARE_STYLE_PARAMS_PARAM, assignments.join('_'));
if (assignments.length)
params.set(SHARE_STYLE_PARAMS_PARAM, assignments.join('_'));
else params.delete(SHARE_STYLE_PARAMS_PARAM);
}
/** Decode allowlisted parameters for the selected visual preset. */
export function decodeStyleParamState(params, styleName) {
if (params.get('v') !== '2' || !params.has(SHARE_STYLE_PARAMS_PARAM)) return null;
if (params.get('v') !== '2' || !params.has(SHARE_STYLE_PARAMS_PARAM))
return null;
const registry = SHARE_STYLE_PARAM_REGISTRY[styleName];
if (!registry) return null;
const byToken = new Map(registry.map((spec) => [spec.token, spec]));
const decoded = {};
for (const assignment of String(params.get(SHARE_STYLE_PARAMS_PARAM) || '').split('_')) {
for (const assignment of String(
params.get(SHARE_STYLE_PARAMS_PARAM) || '',
).split('_')) {
const [token, scaledRaw, ...extra] = assignment.split('.');
if (extra.length || !/^-?\d+$/.test(scaledRaw || '')) continue;
const spec = byToken.get(token);
@@ -606,12 +690,17 @@ export function decodePanelStateParams(params) {
if (!spec || (field !== 'c' && field !== 'p')) continue;
if (value !== '0' && value !== '1') continue;
const bool = value === '1';
const current = stateById.get(spec.id) || { id: spec.id, collapsed: null, pinned: null };
const current = stateById.get(spec.id) || {
id: spec.id,
collapsed: null,
pinned: null,
};
if (field === 'c') current.collapsed = bool;
else if (field === 'p' && spec.pinnable) current.pinned = bool;
stateById.set(spec.id, current);
}
const specs = Array.from(stateById.values())
.filter((entry) => typeof entry.collapsed === 'boolean');
const specs = Array.from(stateById.values()).filter(
(entry) => typeof entry.collapsed === 'boolean',
);
return specs.length ? { specs } : null;
}
+1574 -626
View File
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -4,5 +4,8 @@ export * from './realtimeController.js';
/** Compose the standalone action runner with the voice controls. */
export function initGevVoiceCommands(options) {
return createVoiceCommands({ ...options, runner: createGevActionRunner(options) });
return createVoiceCommands({
...options,
runner: createGevActionRunner(options),
});
}
File diff suppressed because it is too large. Load diff
+13 -6
View File
@@ -122,7 +122,7 @@ export function isKnownVoiceTier(tier) {
export function mostExpensiveVoiceModel() {
// Rank by audio output — the dominant cost in a speech-to-speech session.
return Object.values(VOICE_MODELS).reduce((worst, entry) =>
entry.rates.audioOutput > worst.rates.audioOutput ? entry : worst
entry.rates.audioOutput > worst.rates.audioOutput ? entry : worst,
);
}
@@ -191,7 +191,10 @@ export const VOICE_COST_LIMIT_OFF = 'off';
*/
export function normalizeCostLimits(limits) {
const clean = (value, fallback) => {
if (typeof value === 'string' && value.trim().toLowerCase() === VOICE_COST_LIMIT_OFF) {
if (
typeof value === 'string' &&
value.trim().toLowerCase() === VOICE_COST_LIMIT_OFF
) {
return Infinity;
}
if (value === Infinity) return Infinity;
@@ -209,7 +212,8 @@ export function normalizeCostLimits(limits) {
/** Convert limits to a JSON-safe shape that round-trips a disabled threshold. */
export function serializeCostLimits(limits) {
const normalized = normalizeCostLimits(limits);
const encode = (value) => (Number.isFinite(value) ? value : VOICE_COST_LIMIT_OFF);
const encode = (value) =>
Number.isFinite(value) ? value : VOICE_COST_LIMIT_OFF;
return {
warnUsd: encode(normalized.warnUsd),
capUsd: encode(normalized.capUsd),
@@ -269,7 +273,7 @@ export function splitUsageTokens(usage) {
if (!cached && nonNegative(inDetails.cached_tokens) > 0) {
audioCached = Math.min(
nonNegative(inDetails.cached_tokens),
nonNegative(inDetails.audio_tokens)
nonNegative(inDetails.audio_tokens),
);
}
textIn = Math.max(0, nonNegative(inDetails.text_tokens) - textCached);
@@ -289,12 +293,15 @@ export function splitUsageTokens(usage) {
// to audio (priciest) so a partial payload over-estimates, never under.
const inputResidual = Math.max(
0,
inputTotal - (textIn + audioIn + imageIn + textCached + audioCached + imageCached)
inputTotal -
(textIn + audioIn + imageIn + textCached + audioCached + imageCached),
);
audioIn += inputResidual;
const textOut = outDetails ? nonNegative(outDetails.text_tokens) : 0;
let audioOut = outDetails ? nonNegative(outDetails.audio_tokens) : outputTotal;
let audioOut = outDetails
? nonNegative(outDetails.audio_tokens)
: outputTotal;
const outputResidual = Math.max(0, outputTotal - (textOut + audioOut));
audioOut += outputResidual;
+12 -3
View File
@@ -33,10 +33,13 @@ export function deriveWeatherEffectProfile(weather) {
const precipitation = Math.max(0, Number(weather.precipitationMm) || 0);
const wind = range01(weather.windKph, 4, 90);
const visibilityM = Number(weather.visibilityM);
const visibilityFog = Number.isFinite(visibilityM) ? range01(12000 - visibilityM, 0, 11500) : 0;
const visibilityFog = Number.isFinite(visibilityM)
? range01(12000 - visibilityM, 0, 11500)
: 0;
const drizzle = code >= 51 && code <= 57;
const rainCode = (code >= 61 && code <= 67) || (code >= 80 && code <= 82) || code >= 95;
const rainCode =
(code >= 61 && code <= 67) || (code >= 80 && code <= 82) || code >= 95;
const snowCode = (code >= 71 && code <= 77) || (code >= 85 && code <= 86);
const fogCode = code === 45 || code === 48;
const stormCode = code >= 95;
@@ -48,7 +51,13 @@ export function deriveWeatherEffectProfile(weather) {
const snow = snowCode ? Math.max(snowFloor, observedPrecip) : 0;
const fog = Math.max(fogCode ? 0.78 : 0, visibilityFog);
const storm = stormCode ? Math.max(0.55, observedPrecip) : 0;
const forcedCloud = stormCode ? 0.92 : (rain || snow) ? 0.7 : fogCode ? 0.55 : 0;
const forcedCloud = stormCode
? 0.92
: rain || snow
? 0.7
: fogCode
? 0.55
: 0;
const cloud = Math.max(cover, forcedCloud);
return {
+26 -13
View File
@@ -16,30 +16,41 @@ export function isValidWorldFocusTarget(detail) {
if (!detail || !WORLD_FOCUS_FRAMING[detail.kind]) return false;
if (!String(detail.id || '').trim()) return false;
const { position } = detail;
if (!position
|| !Number.isFinite(position.x)
|| !Number.isFinite(position.y)
|| !Number.isFinite(position.z)) return false;
if (
!position ||
!Number.isFinite(position.x) ||
!Number.isFinite(position.y) ||
!Number.isFinite(position.z)
)
return false;
// Vessel and fire targets are surface-anchored Earth positions. Merely
// finite coordinates near the ECEF origin cannot be flown to, and must be
// rejected before the camera policy releases a current follow owner.
const magnitude = Cesium.Cartesian3.magnitude(position);
return Number.isFinite(magnitude)
&& magnitude >= Cesium.Ellipsoid.WGS84.minimumRadius * 0.95;
return (
Number.isFinite(magnitude) &&
magnitude >= Cesium.Ellipsoid.WGS84.minimumRadius * 0.95
);
}
/** Announce a valid user-click focus request. */
export function requestWorldFocus(detail, eventTarget = globalThis.window) {
if (!isValidWorldFocusTarget(detail)) return false;
if (typeof eventTarget?.dispatchEvent !== 'function') return false;
eventTarget.dispatchEvent(new CustomEvent(WORLD_FOCUS_REQUEST_EVENT, { detail }));
eventTarget.dispatchEvent(
new CustomEvent(WORLD_FOCUS_REQUEST_EVENT, { detail }),
);
return true;
}
/** Register one listener and return an idempotent disposer. */
export function registerWorldFocusRequestListener(eventTarget, listener) {
if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener
|| typeof listener !== 'function') return () => {};
if (
!eventTarget?.addEventListener ||
!eventTarget?.removeEventListener ||
typeof listener !== 'function'
)
return () => {};
eventTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, listener);
let disposed = false;
return () => {
@@ -53,7 +64,8 @@ export function registerWorldFocusRequestListener(eventTarget, listener) {
export function routeWorldFocusRequest(event, runExplicitFocus, fly) {
const detail = event?.detail;
if (!isValidWorldFocusTarget(detail)) return false;
if (typeof runExplicitFocus !== 'function' || typeof fly !== 'function') return false;
if (typeof runExplicitFocus !== 'function' || typeof fly !== 'function')
return false;
return runExplicitFocus(detail, () => fly(detail));
}
@@ -63,9 +75,10 @@ export function flyToWorldTarget(viewer, target = {}) {
const framing = WORLD_FOCUS_FRAMING[target.kind];
if (!camera || !framing || !isValidWorldFocusTarget(target)) return false;
const heading = Number.isFinite(camera.heading) ? camera.heading : 0;
const duration = target.durationSec > 0
? target.durationSec
: WORLD_CLICK_FOCUS_DURATION_SEC;
const duration =
target.durationSec > 0
? target.durationSec
: WORLD_CLICK_FOCUS_DURATION_SEC;
camera.cancelFlight?.();
camera.flyToBoundingSphere(
new Cesium.BoundingSphere(target.position, framing.radiusM),