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
+8230 -2516

No files matched your search

+447 -97
View File
@@ -1,12 +1,20 @@
import { defaultGeospatial } from '../search/defaults.js'; import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; import {
import { isRateLimitedOutcome, resolveAnnotationTarget, sampleGroundHeight } from './annotationResolver.js'; holdContinuousRender,
releaseContinuousRender,
} from '../renderGovernor.js';
import {
isRateLimitedOutcome,
resolveAnnotationTarget,
sampleGroundHeight,
} from './annotationResolver.js';
import { ringCentroid } from './drawMode.js'; import { ringCentroid } from './drawMode.js';
// Dev convenience: expose the app's Cesium instance for console/preview probing // Dev convenience: expose the app's Cesium instance for console/preview probing
// (single shared module instance — avoids dual-Cesium state bugs when testing). // (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. * 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) — // 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 // 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. // 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 DEFAULT_TTL_MS = 22_000;
const FADE_MS = 1200; const FADE_MS = 1200;
// Hard ceiling on simultaneously-live marks. Protects against a runaway voice // 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 * retrying immediately. A thrown resolver counts as definitive — the pre-retry
* catch→point behavior. Exported for tests. * catch→point behavior. Exported for tests.
*/ */
export async function resolveOutlineWithRetry(resolveOutline, { export async function resolveOutlineWithRetry(
resolveOutline,
{
delaysMs = OUTLINE_RETRY_DELAYS_MS, delaysMs = OUTLINE_RETRY_DELAYS_MS,
isStale = () => false, isStale = () => false,
waitFn = wait, waitFn = wait,
} = {}) { } = {},
) {
let retriedRateLimit = false; let retriedRateLimit = false;
for (let attempt = 0; ; attempt += 1) { for (let attempt = 0; ; attempt += 1) {
let fp; let fp;
@@ -68,7 +85,9 @@ export async function resolveOutlineWithRetry(resolveOutline, {
// A throttle gets one deliberately spaced retry. If that retry is throttled // 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. // too, stop this mark's outline task instead of replaying the batch storm.
if (retriedRateLimit || attempt >= delaysMs.length) return undefined; 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])); await waitFn(Math.max(retryAfterMs, delaysMs[attempt]));
if (isStale()) return undefined; if (isStale()) return undefined;
retriedRateLimit = true; retriedRateLimit = true;
@@ -89,7 +108,9 @@ export async function resolveOutlineWithRetry(resolveOutline, {
* geometry is unknown. Exported for tests. * geometry is unknown. Exported for tests.
*/ */
export function normalizeTargetKey(target) { export function normalizeTargetKey(target) {
const raw = String(target ?? '').trim().toLowerCase(); const raw = String(target ?? '')
.trim()
.toLowerCase();
if (!raw) return null; if (!raw) return null;
const head = raw.split(',')[0].trim(); const head = raw.split(',')[0].trim();
return head || raw; return head || raw;
@@ -123,12 +144,22 @@ export function createAnnotationEngine({
function emitOutlineEvent(evt) { function emitOutlineEvent(evt) {
for (const listener of outlineListeners) { 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() { 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(); activeControllers.clear();
} }
@@ -174,7 +205,9 @@ export function createAnnotationEngine({
function rollbackRendererState(anno) { function rollbackRendererState(anno) {
try { try {
renderer.remove(anno); 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() { function ensureTicking() {
@@ -226,7 +259,15 @@ export function createAnnotationEngine({
* @returns {Promise<{ok, drawn, failed, ids, results}>} * @returns {Promise<{ok, drawn, failed, ids, results}>}
*/ */
async function annotate(requests, opts = {}) { 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]; const list = Array.isArray(requests) ? requests : [requests];
if (opts.clearPrevious) clear(); // bumps generation + aborts older pending work 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 // 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 // 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. // 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 { try {
for (let i = 0; i < list.length; i += 1) { 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. // stacked. De-dup runs BEFORE the cap so a re-narration still refreshes when full.
const dup = findDuplicate(anno); const dup = findDuplicate(anno);
if (dup) { if (dup) {
const labelChanged = String(dup.label || '').trim().toLowerCase() !== String(anno.label || '').trim().toLowerCase(); const labelChanged =
const colorChanged = (dup.color || 'primary') !== (anno.color || 'primary'); String(dup.label || '')
.trim()
.toLowerCase() !==
String(anno.label || '')
.trim()
.toLowerCase();
const colorChanged =
(dup.color || 'primary') !== (anno.color || 'primary');
if (labelChanged || colorChanged) { if (labelChanged || colorChanged) {
// Same place + shape, but a NEW label and/or color ("Marina" → "Marina District", // 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 // or a recolor) → replace in place so the latest caption/color wins (count
@@ -305,13 +357,26 @@ export function createAnnotationEngine({
ids.push(anno.id); ids.push(anno.id);
if (!firstAnchor) firstAnchor = anno; if (!firstAnchor) firstAnchor = anno;
if (typeof resolved.resolveOutline === 'function') { 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 { } else {
// Identical re-narration → refresh the existing mark's lifecycle in place (keep // 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). // 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.expiring = false;
dup.fadeStart = null; dup.fadeStart = null;
ids.push(dup.id); 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 // 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. // (transient Overpass miss), attach this call's deferred resolver to it.
if (!dup.ring && typeof resolved.resolveOutline === 'function') { 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 — // Same result builder as the fresh path (full metadata) but the existing id —
// route fallback / approximate honesty fields survive a re-narration. // 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; continue;
} }
@@ -350,12 +423,23 @@ export function createAnnotationEngine({
if (!firstAnchor) firstAnchor = anno; if (!firstAnchor) firstAnchor = anno;
// Progressive: the point is on the board; trace the outline behind the narration. // Progressive: the point is on the board; trace the outline behind the narration.
if (typeof resolved.resolveOutline === 'function') { if (typeof resolved.resolveOutline === 'function') {
startOutlineUpgrade(anno, resolved.resolveOutline, myGen, controller); startOutlineUpgrade(
anno,
resolved.resolveOutline,
myGen,
controller,
);
} }
results.push(okResult(anno, resolved, anno.id)); results.push(okResult(anno, resolved, anno.id));
} catch (error) { } catch (error) {
if (superseded()) break; 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 { } finally {
@@ -365,7 +449,14 @@ export function createAnnotationEngine({
// A clear/new-topic landed mid-flight: drop everything this call produced and // 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. // do not touch the renderer or camera — the board now belongs to a newer call.
if (superseded()) { 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) { if (ids.length) {
@@ -393,7 +484,8 @@ export function createAnnotationEngine({
if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer); if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);
if (type === 'route') { if (type === 'route') {
const points = Array.isArray(spec.points) ? spec.points : []; 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 resolvedPts = [];
const failed = []; const failed = [];
for (const pt of points) { for (const pt of points) {
@@ -425,7 +517,12 @@ export function createAnnotationEngine({
} }
// Real street-following route (OSM/OSRM), mode-aware. // Real street-following route (OSM/OSRM), mode-aware.
const mode = normalizeMode(spec.mode); 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) { if (routed) {
return { return {
path: routed.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })), 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 // 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. // a clearly-labeled direct line with great-circle distance and NO travel time.
let straight = 0; let straight = 0;
for (let i = 1; i < resolvedPts.length; i += 1) straight += greatCircleM(resolvedPts[i - 1], resolvedPts[i]); for (let i = 1; i < resolvedPts.length; i += 1)
return { path: resolvedPts, distanceM: straight, durationS: null, mode, source: resolvedPts[0].source, fallback: true }; straight += greatCircleM(resolvedPts[i - 1], resolvedPts[i]);
return {
path: resolvedPts,
distanceM: straight,
durationS: null,
mode,
source: resolvedPts[0].source,
fallback: true,
};
} }
if (type === 'arrow') { if (type === 'arrow') {
const from = await resolveTarget({ const from = await resolveTarget({
@@ -479,9 +584,15 @@ export function createAnnotationEngine({
err.failedTargets = failed; err.failedTargets = failed;
throw err; 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({ return resolveTarget({
placeSearch, placeSearch,
viewer, viewer,
@@ -491,7 +602,8 @@ export function createAnnotationEngine({
screenX: spec.screenX, screenX: spec.screenX,
screenY: spec.screenY, screenY: spec.screenY,
footprint: wantFootprint, 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, entityKind: ENTITY_KINDS.has(spec?.entityKind) ? spec.entityKind : null,
// The label often carries the ask's true shape when the target omits it // 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. // ("target: Texas State Capitol" + "label: Capitol grounds") — a resolver HINT only.
@@ -525,11 +637,15 @@ export function createAnnotationEngine({
} }
function drainOutlineQueue() { function drainOutlineQueue() {
while (activeOutlineUpgrades < OUTLINE_UPGRADE_CONCURRENCY && outlineQueue.length) { while (
activeOutlineUpgrades < OUTLINE_UPGRADE_CONCURRENCY &&
outlineQueue.length
) {
const task = outlineQueue.shift(); const task = outlineQueue.shift();
const stale = task.myGen !== generation const stale =
|| task.controller.signal.aborted task.myGen !== generation ||
|| !annotations.has(task.anno.id); task.controller.signal.aborted ||
!annotations.has(task.anno.id);
if (stale) { if (stale) {
if (task.anno._outlineTask === task) task.anno._outlineTask = null; if (task.anno._outlineTask === task) task.anno._outlineTask = null;
releaseController(task.controller); releaseController(task.controller);
@@ -549,7 +665,10 @@ export function createAnnotationEngine({
// proxy caches the late completion, so the retry is usually a warm cache hit. // proxy caches the late completion, so the retry is usually a warm cache hit.
fp = await resolveOutlineWithRetry(resolveOutline, { fp = await resolveOutlineWithRetry(resolveOutline, {
delaysMs: outlineRetryDelaysMs, 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 (myGen !== generation || controller.signal.aborted) return; // board superseded
if (!annotations.has(anno.id)) return; // mark replaced/removed while resolving 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 // 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. // a second representation of the same mark, never a distinct one.
for (const other of annotations.values()) { for (const other of annotations.values()) {
if (other.id === anno.id || other.type !== 'area' || other.pendingOutline) continue; if (
if (!other.anchor || Math.abs(other.anchor.lon - anno.anchor.lon) >= 5e-4 other.id === anno.id ||
|| Math.abs(other.anchor.lat - anno.anchor.lat) >= 5e-4) continue; other.type !== 'area' ||
if (Boolean(other.synthesized) !== Boolean(anno.synthesized)) continue; other.pendingOutline
if ((other.footprintKind || null) !== (anno.footprintKind || null)) continue; )
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; if (!ringsEqual(other.ring, anno.ring)) continue;
annotations.delete(other.id); annotations.delete(other.id);
renderer.remove(other); renderer.remove(other);
@@ -629,7 +759,12 @@ export function createAnnotationEngine({
// Route-specific signal so the voice layer can be honest about an OSRM outage: // 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. // fallback=true means a straight direct line, not a real route.
...(anno.type === '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. */ * degenerate geocode stacks on one point (several Capitol monuments) would erase each other. */
function findDuplicate(anno) { function findDuplicate(anno) {
if (!anno.anchor) return null; 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 // 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 // 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. // same-length rings/paths at the same anchor are never falsely merged.
const ringSame = ringsEqual; const ringSame = ringsEqual;
const pathSame = (a, b) => { 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; if (la !== lb) return false;
for (let i = 0; i < la; i++) if (!near(a[i], b[i])) return false; for (let i = 0; i < la; i++) if (!near(a[i], b[i])) return false;
return true; return true;
@@ -674,10 +814,12 @@ export function createAnnotationEngine({
// the identity; the target is only the stand-in while geometry is unknown. // 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 (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 { } else {
if (Boolean(ex.synthesized) !== Boolean(anno.synthesized)) continue; 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; if (!ringSame(ex.ring, anno.ring)) continue;
} }
} else if (anno.type === 'arrow') { } 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 // 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 // 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). // 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; if (!sameLabel) continue;
} }
return ex; return ex;
@@ -703,7 +851,11 @@ export function createAnnotationEngine({
const type = normalizeType(spec?.type); const type = normalizeType(spec?.type);
const id = `anno-${++_seq}`; const id = `anno-${++_seq}`;
const color = COLORS.has(spec?.color) ? spec.color : 'primary'; 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 now = performance.now();
const base = { const base = {
@@ -712,17 +864,27 @@ export function createAnnotationEngine({
color, color,
label, label,
createdAt: now, createdAt: now,
ttlMs: persist ? null : (Number(spec?.ttlMs) || DEFAULT_TTL_MS), ttlMs: persist ? null : Number(spec?.ttlMs) || DEFAULT_TTL_MS,
alpha: 0, // animate in alpha: 0, // animate in
bornAt: now, bornAt: now,
expiring: false, expiring: false,
}; };
if (type === 'route') { 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 { return {
...base, ...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], anchor: path[0],
to: null, to: null,
path, path,
@@ -738,8 +900,16 @@ export function createAnnotationEngine({
return { return {
...base, ...base,
label: appendDistance(base.label, resolved.distanceM), label: appendDistance(base.label, resolved.distanceM),
anchor: { lon: resolved.from.lon, lat: resolved.from.lat, height: resolved.from.height }, anchor: {
to: { lon: resolved.to.lon, lat: resolved.to.lat, height: resolved.to.height }, 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, ring: null,
}; };
} }
@@ -814,13 +984,17 @@ export function createAnnotationEngine({
if (m.to) points.push(m.to); if (m.to) points.push(m.to);
} }
if (!points.length || points.some((p) => isPointOnScreen(p))) return; 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 assistFlightUntil = performance.now() + 2600; // ~flight duration + settle
if (marks.length === 1) { if (marks.length === 1) {
frameAnnotation(marks[0]); frameAnnotation(marks[0]);
return; 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); const sphere = Cesium.BoundingSphere.fromPoints(cart);
viewer.camera.flyToBoundingSphere(sphere, { viewer.camera.flyToBoundingSphere(sphere, {
offset: new Cesium.HeadingPitchRange( offset: new Cesium.HeadingPitchRange(
@@ -841,9 +1015,20 @@ export function createAnnotationEngine({
try { try {
const camera = viewer.camera; const camera = viewer.camera;
const pos = Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0); const pos = Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0);
const cv = camera.frustum.computeCullingVolume(camera.position, camera.direction, camera.up); const cv = camera.frustum.computeCullingVolume(
if (cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) === Cesium.Intersect.OUTSIDE) return false; camera.position,
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, 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); return occluder.isPointVisible(pos);
} catch { } catch {
return true; return true;
@@ -853,15 +1038,24 @@ export function createAnnotationEngine({
function frameAnnotation(anno) { function frameAnnotation(anno) {
try { try {
const target = anno.to 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; : anno.anchor;
// No ring yet (progressive outline still resolving) → size the flight from the // 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 // 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. // 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( viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere( 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, range,
), ),
{ {
@@ -884,7 +1078,9 @@ export function createAnnotationEngine({
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
destroyed = true; destroyed = true;
try { clear(); } finally { try {
clear();
} finally {
if (tickHandle != null) cancelAnimationFrame(tickHandle); if (tickHandle != null) cancelAnimationFrame(tickHandle);
tickHandle = null; tickHandle = null;
outlineListeners.clear(); outlineListeners.clear();
@@ -912,12 +1108,36 @@ export function createAnnotationEngine({
* Lays down a small San Francisco "tour" the way the voice agent would. * Lays down a small San Francisco "tour" the way the voice agent would.
*/ */
async demo() { async demo() {
return annotate([ 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: 'highlight',
{ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina District, San Francisco', label: 'next to the Marina' }, target: 'Palace of Fine Arts, San Francisco',
], { flyTo: true, clearPrevious: true, persist: true }); 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() { async tour() {
clear(); 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 }; if (destroyed) return { ok: false };
await wait(3200); 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 }; if (destroyed) return { ok: false };
await wait(2600); 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 }; if (destroyed) return { ok: false };
await wait(2600); 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 }; if (destroyed) return { ok: false };
await wait(3200); 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 }; if (destroyed) return { ok: false };
await wait(2800); 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 }; if (destroyed) return { ok: false };
await wait(2600); await wait(2600);
await annotate([{ type: 'route', color: 'amber', label: 'Crissy Field shoreline', points: [ await annotate(
[
{
type: 'route',
color: 'amber',
label: 'Crissy Field shoreline',
points: [
{ target: 'Palace of Fine Arts, San Francisco' }, { target: 'Palace of Fine Arts, San Francisco' },
{ target: 'Crissy Field, San Francisco' }, { target: 'Crissy Field, San Francisco' },
{ target: 'Fort Point, San Francisco' }, { target: 'Fort Point, San Francisco' },
] }], { persist: true }); ],
},
],
{ persist: true },
);
return { ok: true, steps: 6 }; 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; if (destroyed) return;
try { try {
viewer.camera.flyTo({ viewer.camera.flyTo({
@@ -984,7 +1277,11 @@ function ringsEqual(a, b) {
const lb = b ? b.length : 0; const lb = b ? b.length : 0;
if (la !== lb) return false; if (la !== lb) return false;
for (let i = 0; i < la; i++) { 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; return true;
} }
@@ -1010,7 +1307,8 @@ function specTargets(spec) {
* arrow endpoint) when known; otherwise it derives them from the spec. * arrow endpoint) when known; otherwise it derives them from the spec.
*/ */
function failResult(spec, error, failedTargets) { function failResult(spec, error, failedTargets) {
const targets = (failedTargets && failedTargets.length) ? failedTargets : specTargets(spec); const targets =
failedTargets && failedTargets.length ? failedTargets : specTargets(spec);
return { return {
ok: false, ok: false,
label: spec?.label || spec?.target || targets[0] || null, label: spec?.label || spec?.target || targets[0] || null,
@@ -1022,7 +1320,8 @@ function failResult(spec, error, failedTargets) {
function normalizeType(type) { function normalizeType(type) {
const t = String(type || '').toLowerCase(); 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 === 'route' || t === 'path' || t === 'line') return 'route';
if (t === 'arrow' || t === 'vector' || t === 'connector') return 'arrow'; if (t === 'arrow' || t === 'vector' || t === 'connector') return 'arrow';
if (t === 'label' || t === 'callout' || t === 'text') return 'label'; if (t === 'label' || t === 'callout' || t === 'text') return 'label';
@@ -1036,7 +1335,8 @@ function computeAlpha(anno, now) {
if (anno.ttlMs == null && !anno.expiring) return fadeIn; if (anno.ttlMs == null && !anno.expiring) return fadeIn;
const fadeStart = anno.fadeStart != null const fadeStart =
anno.fadeStart != null
? anno.fadeStart ? anno.fadeStart
: anno.createdAt + (anno.ttlMs || 0); : anno.createdAt + (anno.ttlMs || 0);
if (now < fadeStart) return fadeIn; if (now < fadeStart) return fadeIn;
@@ -1052,7 +1352,10 @@ function pendingAnimation(anno, now) {
if (now - anno.bornAt < 260) return true; // fading in if (now - anno.bornAt < 260) return true; // fading in
if (anno.expiring) return true; // fading out if (anno.expiring) return true; // fading out
if (anno.ttlMs != null) { 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 now < fadeStart + FADE_MS; // TTL fade still ahead or in progress
} }
return false; // persistent + faded in → stable 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. */ /** A spec whose geometry was supplied by hand (drawTool.js) rather than by a name. */
function isManualSpec(spec, type) { function isManualSpec(spec, type) {
if (!spec || spec.manual !== true) return false; 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; 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. */ /** [lon, lat] pairs or {lon, lat} objects → [lon, lat] pairs, invalid entries dropped. */
function manualPairs(list) { function manualPairs(list) {
return (Array.isArray(list) ? 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)])) .map((p) =>
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180); 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 // height 0 is a placeholder, not a placement: the world renderer draws a
// route with clampToGround + CESIUM_3D_TILE classification, so the line is // route with clampToGround + CESIUM_3D_TILE classification, so the line is
// draped onto the photoreal surface whatever this number says. // 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'); if (pts.length < 2) throw new Error('a drawn line needs at least 2 points');
let distanceM = 0; let distanceM = 0;
for (let i = 1; i < pts.length; i += 1) distanceM += greatCircleM(pts[i - 1], pts[i]); for (let i = 1; i < pts.length; i += 1)
return { path: pts, distanceM, durationS: null, mode: 'manual', source: 'manual', fallback: false }; distanceM += greatCircleM(pts[i - 1], pts[i]);
return {
path: pts,
distanceM,
durationS: null,
mode: 'manual',
source: 'manual',
fallback: false,
};
} }
if (type === 'area') { if (type === 'area') {
const ring = manualPairs(spec.ring); 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 // Averaged through ringCentroid, which unwraps longitudes first: a ring
// straddling the antimeridian has coordinates like [179.999, -179.999], and // 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 // 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 lon = centre.lon;
const lat = centre.lat; const lat = centre.lat;
return { return {
lon, lat, height: sampleGroundHeight(viewer, lon, lat), lon,
ring, footprintKind: 'area', buildingHeight: null, synthesized: false, source: 'manual', lat,
height: sampleGroundHeight(viewer, lon, lat),
ring,
footprintKind: 'area',
buildingHeight: null,
synthesized: false,
source: 'manual',
}; };
} }
const lon = Number(spec.longitude); const lon = Number(spec.longitude);
const lat = Number(spec.latitude); 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. */ /** 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; if (ring.length < 2) return ring;
const [firstLon, firstLat] = ring[0]; const [firstLon, firstLat] = ring[0];
const [lastLon, lastLat] = ring[ring.length - 1]; 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) { function normalizeMode(m) {
const t = String(m || '').toLowerCase(); const t = String(m || '').toLowerCase();
if (t === 'car' || t === 'drive' || t === 'driving') return 'car'; 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'; return 'foot';
} }
/** Route failure remains an explicitly labelled direct line in the caller. */ /** Route failure remains an explicitly labelled direct line in the caller. */
async function fetchRoute(coordPairs, mode, signal, service) { async function fetchRoute(coordPairs, mode, signal, service) {
try { return await service.route?.(coordPairs, mode, { signal }) || null; } try {
catch { return null; } return (await service.route?.(coordPairs, mode, { signal })) || null;
} catch {
return null;
}
} }
function greatCircleM(a, b) { function greatCircleM(a, b) {
@@ -1188,7 +1536,9 @@ function greatCircleM(a, b) {
const dLon = toRad(b.lon - a.lon); const dLon = toRad(b.lon - a.lon);
const la1 = toRad(a.lat); const la1 = toRad(a.lat);
const la2 = toRad(b.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))); 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 { createAnnotationResolver } from './resolver.js';
import { applicationServices } from '../services/application.js'; import { applicationServices } from '../services/application.js';
const resolver = createAnnotationResolver({ boundarySource: applicationServices.boundaries }); const resolver = createAnnotationResolver({
export const { resolveAnnotationTarget, refineScope, isRateLimitedOutcome, isGroundsLikeAsk, selectFootprint, viewportBias, placesNearViewRecovery, resolveRegionRingForQuery, pickWorldFromScreen, sampleGroundHeight } = resolver; 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 }); entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
screen.add(entry.screenProxy); 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 // Drape the path on the tiles (world), caption it with a screen callout
// at the path midpoint. // at the path midpoint.
entry.worldProxy = liveProxy(anno, { label: null }); entry.worldProxy = liveProxy(anno, { label: null });
@@ -86,7 +90,11 @@ export function createHybridAnnotationRenderer(viewer) {
} else if (anno.label) { } else if (anno.label) {
screen.add(screenProxy); 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, * footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver,
* and voice tool wiring are identical to the other two branches. * 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. // World-space footprint draping; clamped marks can use the photoreal tiles.
if (tileset) { if (tileset) {
try { tileset.enableCollision = true; } catch { /* older tileset */ } try {
tileset.enableCollision = true;
} catch {
/* older tileset */
}
} }
const renderer = createHybridAnnotationRenderer(viewer); 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; window.__gevAnnotations = engine;
return engine; return engine;
} }
+241 -58
View File
@@ -51,16 +51,22 @@ function markScale(h) {
return 1 - t * (1 - MARK_SCALE_MIN); return 1 - t * (1 - MARK_SCALE_MIN);
} }
export function createScreenAnnotationRenderer(viewer, { export function createScreenAnnotationRenderer(
viewer,
{
overlayPaintRect = getOverlayPaintRect, overlayPaintRect = getOverlayPaintRect,
activeTrackedReadoutId = getActiveTrackedReadoutId, activeTrackedReadoutId = getActiveTrackedReadoutId,
} = {}) { } = {},
) {
injectStyles(); injectStyles();
const { layer, svg, defs } = buildOverlay(); const { layer, svg, defs } = buildOverlay();
document.body.appendChild(layer); document.body.appendChild(layer);
const scene = viewer.scene; 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 records = new Map(); // anno.id -> { anno, group, parts }
const scratch = new Cesium.Cartesian2(); const scratch = new Cesium.Cartesian2();
const scratchDir = new Cesium.Cartesian3(); const scratchDir = new Cesium.Cartesian3();
@@ -112,8 +118,13 @@ export function createScreenAnnotationRenderer(viewer, {
return cached.h; return cached.h;
} }
try { try {
if (scene.clampToHeightSupported && typeof scene.clampToHeight === 'function') { if (
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0)); scene.clampToHeightSupported &&
typeof scene.clampToHeight === 'function'
) {
const c = scene.clampToHeight(
Cesium.Cartesian3.fromDegrees(lon, lat, 0),
);
if (c) { if (c) {
const h = Cesium.Cartographic.fromCartesian(c).height; const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) { 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; const fallback = cached ? cached.h : 0;
heightCache.set(key, { h: fallback, settled: false, gen: projGen }); heightCache.set(key, { h: fallback, settled: false, gen: projGen });
trimHeightCache(); trimHeightCache();
@@ -140,7 +153,8 @@ export function createScreenAnnotationRenderer(viewer, {
// "draw-on" gesture, so they never masquerade as an authoritative boundary. // "draw-on" gesture, so they never masquerade as an authoritative boundary.
parts.poly = svgEl('polygon', { parts.poly = svgEl('polygon', {
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw', 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', 'fill-opacity': anno.synthesized ? '0.08' : '0.14',
'stroke-width': '2.5', 'stroke-width': '2.5',
...(anno.synthesized ? { 'stroke-dasharray': '9 7' } : {}), ...(anno.synthesized ? { 'stroke-dasharray': '9 7' } : {}),
@@ -151,25 +165,41 @@ export function createScreenAnnotationRenderer(viewer, {
if (parts.label) group.appendChild(parts.label.node); if (parts.label) group.appendChild(parts.label.node);
} else if (anno.type === 'arrow' && anno.to) { } else if (anno.type === 'arrow' && anno.to) {
parts.path = svgEl('path', { parts.path = svgEl('path', {
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c, class: 'gev-anno-arrow gev-draw',
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`, fill: 'none',
stroke: c,
'stroke-width': '3',
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
filter: 'url(#gev-sketch)', filter: 'url(#gev-sketch)',
}); });
ensureArrowMarker(defs, anno.color || 'primary', c); ensureArrowMarker(defs, anno.color || 'primary', c);
group.appendChild(parts.path); group.appendChild(parts.path);
parts.label = makeCallout(anno.label, c); parts.label = makeCallout(anno.label, c);
if (parts.label) group.appendChild(parts.label.node); 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. // Multi-waypoint path: a drawn-on polyline with a dot at each waypoint.
parts.poly = svgEl('polyline', { parts.poly = svgEl('polyline', {
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c, class: 'gev-anno-arrow gev-draw',
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`, fill: 'none',
stroke: c,
'stroke-width': '3',
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
filter: 'url(#gev-sketch)', filter: 'url(#gev-sketch)',
}); });
ensureArrowMarker(defs, anno.color || 'primary', c); ensureArrowMarker(defs, anno.color || 'primary', c);
group.appendChild(parts.poly); group.appendChild(parts.poly);
parts.dots = anno.path.map(() => { 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); group.appendChild(dot);
return dot; return dot;
}); });
@@ -178,14 +208,39 @@ export function createScreenAnnotationRenderer(viewer, {
} else { } else {
// pin / highlight / label — pulsing target rings + a marker dot + callout // pin / highlight / label — pulsing target rings + a marker dot + callout
if (anno.type !== 'label') { if (anno.type !== 'label') {
parts.ringOuter = svgEl('circle', { class: 'gev-anno-ring gev-pulse', fill: 'none', stroke: c, 'stroke-width': '2', r: '34' }); parts.ringOuter = svgEl('circle', {
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)' }); 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.ringOuter);
group.appendChild(parts.ringInner); 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); 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); group.appendChild(parts.leader);
parts.label = makeCallout(anno.label, c); parts.label = makeCallout(anno.label, c);
if (parts.label) group.appendChild(parts.label.node); 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 // any length. Guarded on the specific property so an unrelated transition
// (none here, but defensive) doesn't clear it early. // (none here, but defensive) doesn't clear it early.
for (const drawEl of group.querySelectorAll('.gev-draw')) { for (const drawEl of group.querySelectorAll('.gev-draw')) {
drawEl.addEventListener('transitionend', (e) => { drawEl.addEventListener(
'transitionend',
(e) => {
if (e.propertyName === 'stroke-dashoffset') { if (e.propertyName === 'stroke-dashoffset') {
drawEl.style.strokeDasharray = 'none'; drawEl.style.strokeDasharray = 'none';
} }
}, { once: true }); },
{ once: true },
);
} }
projectAll(); projectAll();
} catch (error) { } catch (error) {
// Hard, immediate unwind — a mark that never finished has nothing to // Hard, immediate unwind — a mark that never finished has nothing to
// fade out, and the caller (engine rollback) must find a clean board. // fade out, and the caller (engine rollback) must find a clean board.
records.delete(anno.id); records.delete(anno.id);
try { group.remove(); } catch { /* never inserted */ } try {
group.remove();
} catch {
/* never inserted */
}
if (records.size === 0) heightCache.clear(); if (records.size === 0) heightCache.clear();
throw error; throw error;
} }
@@ -269,13 +332,22 @@ export function createScreenAnnotationRenderer(viewer, {
// Anchor at the real surface height (cached + validated), so marks sit on // 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 // the ground/building instead of at sea level. The settle-once cache keeps
// it stable (no per-frame jitter that earlier broke nearby projections). // 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). // Reject points behind the camera (they produce wrapped/extreme coords).
Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir); 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. // Reject points beyond the globe horizon.
if (!occluder.isPointVisible(world)) return null; 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; if (!win || !Number.isFinite(win.x) || !Number.isFinite(win.y)) return null;
// Reject absurd off-screen projections (anchor not in view). // Reject absurd off-screen projections (anchor not in view).
const w = scene.canvas.clientWidth || scene.canvas.width; const w = scene.canvas.clientWidth || scene.canvas.width;
@@ -317,11 +389,16 @@ export function createScreenAnnotationRenderer(viewer, {
const ent = viewer.trackedEntity; const ent = viewer.trackedEntity;
const now = Cesium.JulianDate.now(); const now = Cesium.JulianDate.now();
const world = typeof ent?.gevDisplayPosition === 'function' const world =
typeof ent?.gevDisplayPosition === 'function'
? ent.gevDisplayPosition() ? ent.gevDisplayPosition()
: null; : null;
const win = world const win = world
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, _scratchTrackedWin) ? Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
world,
_scratchTrackedWin,
)
: null; : null;
// Billboard box — centered on the anchor, magnified by scaleByDistance. // Billboard box — centered on the anchor, magnified by scaleByDistance.
@@ -331,8 +408,13 @@ export function createScreenAnnotationRenderer(viewer, {
const baseH = bb.height?.getValue?.(now) ?? 28; const baseH = bb.height?.getValue?.(now) ?? 28;
let scale = 1; let scale = 1;
const sbd = bb.scaleByDistance?.getValue?.(now); const sbd = bb.scaleByDistance?.getValue?.(now);
if (sbd) scale = nearFarValue(sbd, Cesium.Cartesian3.distance(scene.camera.positionWC, world)); if (sbd)
const hw = (baseW * scale) / 2; const hh = (baseH * scale) / 2; 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 bbLeft = win.x - hw;
const bbRight = win.x + hw; const bbRight = win.x + hw;
const bbTop = win.y - hh; const bbTop = win.y - hh;
@@ -345,7 +427,12 @@ export function createScreenAnnotationRenderer(viewer, {
if (![left, right, top, bottom].every(Number.isFinite)) return null; if (![left, right, top, bottom].every(Number.isFinite)) return null;
const m = TRACKED_BBOX_MARGIN; 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() { function projectAll() {
@@ -357,7 +444,9 @@ export function createScreenAnnotationRenderer(viewer, {
const w = scene.canvas.clientWidth || scene.canvas.width; const w = scene.canvas.clientWidth || scene.canvas.width;
svg.setAttribute('viewBox', `0 0 ${w} ${h}`); svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
// Shrink point-marker geometry as the camera pulls back (declutter the blob). // 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()) { for (const rec of records.values()) {
const { anno, group, parts } = rec; const { anno, group, parts } = rec;
@@ -369,17 +458,22 @@ export function createScreenAnnotationRenderer(viewer, {
if (anno.type === 'area' && parts.poly) { if (anno.type === 'area' && parts.poly) {
const pts = []; 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) { for (const [lon, lat] of anno.ring) {
const p = project(lon, lat); const p = project(lon, lat);
if (!p) continue; if (!p) continue;
pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`); 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) { if (pts.length >= 3) {
parts.poly.setAttribute('points', pts.join(' ')); parts.poly.setAttribute('points', pts.join(' '));
group.style.display = ''; 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 { } else {
group.style.display = 'none'; group.style.display = 'none';
} }
@@ -388,16 +482,27 @@ export function createScreenAnnotationRenderer(viewer, {
const b = project(anno.to.lon, anno.to.lat); const b = project(anno.to.lon, anno.to.lat);
if (a && b) { if (a && b) {
// gentle arc so the connector reads as a drawn gesture, not a ruler line // 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 mx = (a.x + b.x) / 2;
const dx = b.x - a.x; const dy = b.y - a.y; 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 len = Math.hypot(dx, dy) || 1;
const bow = Math.min(60, len * 0.18); const bow = Math.min(60, len * 0.18);
const ctrlX = mx - (dy / len) * bow; const ctrlX = mx - (dy / len) * bow;
const ctrlY = my + (dx / 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 = ''; group.style.display = '';
// Anchor the label to the (stable) endpoint midpoint, lifted toward the bow. // 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 { } else {
group.style.display = 'none'; group.style.display = 'none';
} }
@@ -412,13 +517,22 @@ export function createScreenAnnotationRenderer(viewer, {
// dots at each resolvable waypoint; hide the rest // dots at each resolvable waypoint; hide the rest
parts.dots.forEach((dot, i) => { parts.dots.forEach((dot, i) => {
const p = projected[i]; const p = projected[i];
if (p) { dot.setAttribute('cx', p.x.toFixed(1)); dot.setAttribute('cy', p.y.toFixed(1)); dot.style.display = ''; } if (p) {
else { dot.style.display = 'none'; } 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 = ''; group.style.display = '';
// label at the middle waypoint // label at the middle waypoint
const mid = projected.filter(Boolean)[Math.floor(projected.filter(Boolean).length / 2)]; const mid =
if (parts.label && mid) positionCallout(parts.label, mid.x, mid.y - 12, true); projected.filter(Boolean)[
Math.floor(projected.filter(Boolean).length / 2)
];
if (parts.label && mid)
positionCallout(parts.label, mid.x, mid.y - 12, true);
} else { } else {
group.style.display = 'none'; group.style.display = 'none';
} }
@@ -427,16 +541,40 @@ export function createScreenAnnotationRenderer(viewer, {
if (p) { if (p) {
group.style.display = ''; group.style.display = '';
const baseDotR = anno.type === 'label' ? LABEL_DOT_R : DOT_R; 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.dot) {
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)); } parts.dot.setAttribute('cx', p.x.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)); } 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) { if (parts.label) {
// Keep the callout tucked near the (now smaller) reticle by scaling its offset. // 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); positionCallout(parts.label, lx, ly, false);
if (parts.leader) { if (parts.leader) {
parts.leader.setAttribute('x1', p.x.toFixed(1)); parts.leader.setAttribute('y1', p.y.toFixed(1)); parts.leader.setAttribute('x1', p.x.toFixed(1));
parts.leader.setAttribute('x2', lx.toFixed(1)); parts.leader.setAttribute('y2', (ly + parts.label.height).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 parts.label._leader = parts.leader; // so de-collision can re-point it
} }
} }
@@ -450,7 +588,12 @@ export function createScreenAnnotationRenderer(viewer, {
const placed = []; const placed = [];
for (const rec of records.values()) { for (const rec of records.values()) {
const cal = rec.parts.label; 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); placed.push(cal);
} }
} }
@@ -471,7 +614,11 @@ export function createScreenAnnotationRenderer(viewer, {
let overlapping = false; let overlapping = false;
if (trackedRect) { if (trackedRect) {
let bb = null; 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)) { if (bb && (bb.width !== 0 || bb.height !== 0)) {
// HYSTERESIS: a mark that's already hidden must clear the rect by // 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 // 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 // frame-to-frame (the pulsing reticle stroke + de-collided cards keep the bbox in
// motion), which was the source of the flicker. // motion), which was the source of the flicker.
const m = cur < 0.5 ? TRACKED_HYSTERESIS_PX : 0; const m = cur < 0.5 ? TRACKED_HYSTERESIS_PX : 0;
overlapping = bb.x <= trackedRect.right + m && bb.x + bb.width >= trackedRect.left - m overlapping =
&& bb.y <= trackedRect.bottom + m && bb.y + bb.height >= trackedRect.top - m; 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; 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 // (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.) // is the ONLY easing — previously the two fought and produced an opacity oscillation.)
if (rec._trackedFade !== 1) { 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.remove('gev-in');
rec.group.classList.add('gev-out'); rec.group.classList.add('gev-out');
const node = rec.group; const node = rec.group;
window.setTimeout(() => { try { node.remove(); } catch { /* gone */ } }, 360); window.setTimeout(() => {
try {
node.remove();
} catch {
/* gone */
}
}, 360);
records.delete(anno.id); records.delete(anno.id);
// Board emptied → drop accumulated height samples (natural reset point). // Board emptied → drop accumulated height samples (natural reset point).
if (records.size === 0) heightCache.clear(); if (records.size === 0) heightCache.clear();
@@ -513,8 +672,16 @@ export function createScreenAnnotationRenderer(viewer, {
} }
function destroy() { function destroy() {
try { scene.postRender.removeEventListener(onPostRender); } catch { /* torn down */ } try {
try { layer.remove(); } catch { /* gone */ } scene.postRender.removeEventListener(onPostRender);
} catch {
/* torn down */
}
try {
layer.remove();
} catch {
/* gone */
}
records.clear(); records.clear();
heightCache.clear(); heightCache.clear();
} }
@@ -562,7 +729,15 @@ function makeCallout(text, c) {
node.appendChild(rect); node.appendChild(rect);
node.appendChild(accent); node.appendChild(accent);
node.appendChild(t); 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 // size the card to the text once it is in the DOM
requestAnimationFrame(() => sizeCallout(callout)); requestAnimationFrame(() => sizeCallout(callout));
return callout; return callout;
@@ -581,7 +756,9 @@ function sizeCallout(callout) {
callout.width = w; callout.width = w;
callout.height = h; callout.height = h;
callout.sized = true; callout.sized = true;
} catch { /* not laid out yet */ } } catch {
/* not laid out yet */
}
} }
function positionCallout(callout, x, y, center) { function positionCallout(callout, x, y, center) {
@@ -589,7 +766,10 @@ function positionCallout(callout, x, y, center) {
const tx = center ? x - callout.width / 2 : x; const tx = center ? x - callout.width / 2 : x;
callout._x = tx; callout._x = tx;
callout._y = y; 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; const overlapY = a._y < b._y + b.height && a._y + a.height > b._y;
if (overlapX && overlapY) { if (overlapX && overlapY) {
a._y = b._y + b.height + 5; 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) { if (a._leader) {
a._leader.setAttribute('x2', a._x.toFixed(1)); a._leader.setAttribute('x2', a._x.toFixed(1));
a._leader.setAttribute('y2', a._y.toFixed(1)); a._leader.setAttribute('y2', a._y.toFixed(1));
+138 -44
View File
@@ -58,7 +58,9 @@ export function createWorldAnnotationRenderer(viewer) {
ensureFlowFabricRegistered(); ensureFlowFabricRegistered();
function colorFor(anno) { 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 // Target-ring radius (meters) scaled to camera height so it reads at any
@@ -86,7 +88,11 @@ export function createWorldAnnotationRenderer(viewer) {
const entities = []; const entities = [];
anno._entities = 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 // Single building → the PRIMARY highlight is an extruded CLASSIFICATION
// volume that tints the real photogrammetry mesh (dome, walls, roof — // volume that tints the real photogrammetry mesh (dome, walls, roof —
// everything inside the column) amber and pulses. A flat footprint // everything inside the column) amber and pulses. A flat footprint
@@ -94,29 +100,39 @@ export function createWorldAnnotationRenderer(viewer) {
// actual tiles. A faint wireframe cage rides on top as a secondary cue. // actual tiles. A faint wireframe cage rides on top as a secondary cue.
const buffered = bufferRing(anno.ring, 3); const buffered = bufferRing(anno.ring, 3);
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat()); const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
const groundH = sampleGroundOutside(viewer.scene, anno.ring) const groundH =
?? (Number.isFinite(anno.anchor.height) ? anno.anchor.height - (anno.buildingHeight || 25) : 0); sampleGroundOutside(viewer.scene, anno.ring) ??
(Number.isFinite(anno.anchor.height)
? anno.anchor.height - (anno.buildingHeight || 25)
: 0);
const baseH = groundH - 3; const baseH = groundH - 3;
// Tall headroom is free: classification only colors where tiles exist, so // Tall headroom is free: classification only colors where tiles exist, so
// empty air above the roof isn't tinted — this just guarantees we cover // empty air above the roof isn't tinted — this just guarantees we cover
// under-tagged building heights. // under-tagged building heights.
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24; const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
// 1) Classification volume — tints the mesh amber, pulsing. // 1) Classification volume — tints the mesh amber, pulsing.
entities.push(dataSource.entities.add({ entities.push(
dataSource.entities.add({
polygon: { polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions), hierarchy: new Cesium.PolygonHierarchy(positions),
height: baseH, height: baseH,
extrudedHeight: topH, extrudedHeight: topH,
perPositionHeight: false, perPositionHeight: false,
classificationType: CLASSIFY, classificationType: CLASSIFY,
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.45, pulse: true })), material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.45, pulse: true }),
),
}, },
})); }),
);
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate // 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
// the footprint so the extruded outline draws ~14 clean verticals, not one // the footprint so the extruded outline draws ~14 clean verticals, not one
// per (often 100+) ring vertex. // per (often 100+) ring vertex.
const cagePositions = Cesium.Cartesian3.fromDegreesArray(decimateRing(buffered, 14).flat()); const cagePositions = Cesium.Cartesian3.fromDegreesArray(
entities.push(dataSource.entities.add({ decimateRing(buffered, 14).flat(),
);
entities.push(
dataSource.entities.add({
polygon: { polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions), hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH, height: baseH,
@@ -126,61 +142,92 @@ export function createWorldAnnotationRenderer(viewer) {
outline: true, outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }), outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
}, },
})); }),
);
// 3) Crisp draped base outline so the footprint reads on the ground. // 3) Crisp draped base outline so the footprint reads on the ground.
entities.push(dataSource.entities.add({ entities.push(
dataSource.entities.add({
polyline: { polyline: {
positions, positions,
width: 3, width: 3,
material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.25, color: liveColor(anno, base, { alpha: 0.7 }) }), material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.25,
color: liveColor(anno, base, { alpha: 0.7 }),
}),
clampToGround: true, clampToGround: true,
classificationType: CLASSIFY, classificationType: CLASSIFY,
}, },
})); }),
);
if (anno.label) entities.push(labelMarker(anno, base, { point: false })); if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
} else if (anno.ring && anno.ring.length >= 3) { } else if (anno.ring && anno.ring.length >= 3) {
// Larger area (district / compound / park) → flat fill draped on the tiles // Larger area (district / compound / park) → flat fill draped on the tiles
// + a glowing outline. Draping is right here: you can't extrude a whole // + a glowing outline. Draping is right here: you can't extrude a whole
// neighbourhood, and the GIS overlay shows the boundary clearly. // neighbourhood, and the GIS overlay shows the boundary clearly.
const fillPositions = Cesium.Cartesian3.fromDegreesArray(anno.ring.flat()); const fillPositions = Cesium.Cartesian3.fromDegreesArray(
entities.push(dataSource.entities.add({ anno.ring.flat(),
);
entities.push(
dataSource.entities.add({
polygon: { polygon: {
hierarchy: new Cesium.PolygonHierarchy(fillPositions), hierarchy: new Cesium.PolygonHierarchy(fillPositions),
// Synthesized (approximate) areas get a fainter fill so they don't read as solid. // 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 })), material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, {
alpha: anno.synthesized ? 0.1 : 0.2,
pulse: true,
}),
),
classificationType: CLASSIFY, classificationType: CLASSIFY,
}, },
})); }),
entities.push(dataSource.entities.add({ );
entities.push(
dataSource.entities.add({
polyline: { polyline: {
positions: fillPositions, positions: fillPositions,
width: 6, width: 6,
// Synthesized → DASHED outline (signals "approximate, not an authoritative // Synthesized → DASHED outline (signals "approximate, not an authoritative
// boundary", research §8.4/§8.6); real footprints → solid glow. // boundary", research §8.4/§8.6); real footprints → solid glow.
material: anno.synthesized material: anno.synthesized
? new Cesium.PolylineDashMaterialProperty({ color: liveColor(anno, base, { alpha: 0.95 }), dashLength: 24 }) ? new Cesium.PolylineDashMaterialProperty({
: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.35, color: liveColor(anno, base, { alpha: 1 }) }), color: liveColor(anno, base, { alpha: 0.95 }),
dashLength: 24,
})
: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.35,
color: liveColor(anno, base, { alpha: 1 }),
}),
clampToGround: true, clampToGround: true,
classificationType: CLASSIFY, classificationType: CLASSIFY,
}, },
})); }),
);
if (anno.label) entities.push(labelMarker(anno, base, { point: false })); 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 // 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 // FLOW toward the destination (custom st.s + time material; works on the
// clamped/classified ground polyline). // clamped/classified ground polyline).
const positions = Cesium.Cartesian3.fromDegreesArray( const positions = Cesium.Cartesian3.fromDegreesArray(
anno.path.flatMap((p) => [p.lon, p.lat]), anno.path.flatMap((p) => [p.lon, p.lat]),
); );
entities.push(dataSource.entities.add({ entities.push(
dataSource.entities.add({
polyline: { polyline: {
positions, positions,
width: 9, width: 9,
material: new FlowMaterialProperty(PALETTE[anno.color] || PALETTE.primary), material: new FlowMaterialProperty(
PALETTE[anno.color] || PALETTE.primary,
),
clampToGround: true, clampToGround: true,
classificationType: CLASSIFY, classificationType: CLASSIFY,
}, },
})); }),
);
if (anno.label) entities.push(labelMarker(anno, base, { point: false })); if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
} else if (anno.type === 'arrow' && anno.to) { } else if (anno.type === 'arrow' && anno.to) {
// Connector draped across the ground from origin to destination. // 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.anchor.lon, anno.anchor.lat),
Cesium.Cartesian3.fromDegrees(anno.to.lon, anno.to.lat), Cesium.Cartesian3.fromDegrees(anno.to.lon, anno.to.lat),
]; ];
entities.push(dataSource.entities.add({ entities.push(
dataSource.entities.add({
polyline: { polyline: {
positions, positions,
width: 16, width: 16,
material: new Cesium.PolylineArrowMaterialProperty(liveColor(anno, base, { alpha: 0.95 })), material: new Cesium.PolylineArrowMaterialProperty(
liveColor(anno, base, { alpha: 0.95 }),
),
clampToGround: true, clampToGround: true,
classificationType: CLASSIFY, classificationType: CLASSIFY,
}, },
})); }),
);
if (anno.label) { if (anno.label) {
const mid = { const mid = {
lon: (anno.anchor.lon + anno.to.lon) / 2, lon: (anno.anchor.lon + anno.to.lon) / 2,
lat: (anno.anchor.lat + anno.to.lat) / 2, lat: (anno.anchor.lat + anno.to.lat) / 2,
}; };
entities.push(dataSource.entities.add({ entities.push(
dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat), position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
label: labelGraphic(anno, base), label: labelGraphic(anno, base),
})); }),
);
} }
} else { } else {
// pin / highlight / label — a camera-proportional target ring + a marker. // 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. // Radius scales with camera height so the ring reads at any altitude.
// semiMajor === semiMinor is required AND must hold every frame, so both // semiMajor === semiMinor is required AND must hold every frame, so both
// axes read the SAME ringRadius() (camera height is constant per frame). // axes read the SAME ringRadius() (camera height is constant per frame).
entities.push(dataSource.entities.add({ entities.push(
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat), dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(
anno.anchor.lon,
anno.anchor.lat,
),
ellipse: { ellipse: {
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false), semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false), semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.38, pulse: true })), material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.38, pulse: true }),
),
outline: false, outline: false,
classificationType: CLASSIFY, classificationType: CLASSIFY,
}, },
})); }),
);
} }
entities.push(labelMarker(anno, base, { point: true })); entities.push(labelMarker(anno, base, { point: true }));
} }
@@ -232,14 +292,16 @@ export function createWorldAnnotationRenderer(viewer) {
function labelMarker(anno, base, { point }) { function labelMarker(anno, base, { point }) {
return dataSource.entities.add({ return dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat), position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
point: point ? { point: point
? {
pixelSize: anno.type === 'label' ? 8 : 14, pixelSize: anno.type === 'label' ? 8 : 14,
color: liveColor(anno, base, { alpha: 1 }), color: liveColor(anno, base, { alpha: 1 }),
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }), outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
outlineWidth: 3, outlineWidth: 3,
heightReference: CLAMP, heightReference: CLAMP,
disableDepthTestDistance: Number.POSITIVE_INFINITY, disableDepthTestDistance: Number.POSITIVE_INFINITY,
} : undefined, }
: undefined,
label: anno.label ? labelGraphic(anno, base) : undefined, label: anno.label ? labelGraphic(anno, base) : undefined,
}); });
} }
@@ -256,7 +318,10 @@ export function createWorldAnnotationRenderer(viewer) {
pixelOffset: new Cesium.Cartesian2(0, -16), pixelOffset: new Cesium.Cartesian2(0, -16),
showBackground: true, showBackground: true,
backgroundColor: new Cesium.CallbackProperty( 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, false,
), ),
backgroundPadding: new Cesium.Cartesian2(8, 5), backgroundPadding: new Cesium.Cartesian2(8, 5),
@@ -357,8 +422,16 @@ export function FlowMaterialProperty(colorCss) {
this._definitionChanged = new Cesium.Event(); this._definitionChanged = new Cesium.Event();
} }
Object.defineProperties(FlowMaterialProperty.prototype, { Object.defineProperties(FlowMaterialProperty.prototype, {
isConstant: { get() { return false; } }, // re-evaluated each frame → it animates isConstant: {
definitionChanged: { get() { return this._definitionChanged; } }, get() {
return false;
},
}, // re-evaluated each frame → it animates
definitionChanged: {
get() {
return this._definitionChanged;
},
},
}); });
FlowMaterialProperty.prototype.getType = function getType() { FlowMaterialProperty.prototype.getType = function getType() {
return 'GevRouteFlow'; return 'GevRouteFlow';
@@ -394,10 +467,17 @@ function decimateRing(ring, n) {
* tiles under those points aren't loaded yet. * tiles under those points aren't loaded yet.
*/ */
function sampleGroundOutside(scene, ring) { 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 clon = 0;
let clat = 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; clon /= ring.length;
clat /= ring.length; clat /= ring.length;
const latS = 111320; const latS = 111320;
@@ -407,7 +487,16 @@ function sampleGroundOutside(scene, ring) {
maxR = Math.max(maxR, Math.hypot((lon - clon) * lonS, (lat - clat) * latS)); maxR = Math.max(maxR, Math.hypot((lon - clon) * lonS, (lat - clat) * latS));
} }
const out = maxR * 1.5 + 12; 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 = []; const samples = [];
for (const [dx, dy] of dirs) { for (const [dx, dy] of dirs) {
const lon = clon + (dx * out) / lonS; const lon = clon + (dx * out) / lonS;
@@ -418,7 +507,9 @@ function sampleGroundOutside(scene, ring) {
const h = Cesium.Cartographic.fromCartesian(c).height; const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h); if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
} }
} catch { /* tile not ready */ } } catch {
/* tile not ready */
}
} }
if (!samples.length) return null; if (!samples.length) return null;
samples.sort((a, b) => a - b); samples.sort((a, b) => a - b);
@@ -433,7 +524,10 @@ function sampleGroundOutside(scene, ring) {
function bufferRing(ring, meters) { function bufferRing(ring, meters) {
let clon = 0; let clon = 0;
let clat = 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; clon /= ring.length;
clat /= ring.length; clat /= ring.length;
const latScale = 111320; const latScale = 111320;
+460 -128
View File
File diff suppressed because it is too large. Load diff
+17 -6
View File
@@ -22,9 +22,11 @@ export function activateCctvCameraFromWorldClick(
) { ) {
if (!cameraId || typeof activate !== 'function') return false; if (!cameraId || typeof activate !== 'function') return false;
if (activate(cameraId) !== CCTV_ACTIVATION_RESULT.ACTIVATED) return false; if (activate(cameraId) !== CCTV_ACTIVATION_RESULT.ACTIVATED) return false;
eventTarget.dispatchEvent(new CustomEvent(CCTV_FOCUS_REQUEST_EVENT, { eventTarget.dispatchEvent(
new CustomEvent(CCTV_FOCUS_REQUEST_EVENT, {
detail: { cameraId }, detail: { cameraId },
})); }),
);
return true; return true;
} }
@@ -36,8 +38,12 @@ export function activateCctvCameraFromWorldClick(
* @returns {() => void} Listener disposer. * @returns {() => void} Listener disposer.
*/ */
export function registerCctvFocusRequestListener(eventTarget, listener) { export function registerCctvFocusRequestListener(eventTarget, listener) {
if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener if (
|| typeof listener !== 'function') return () => {}; !eventTarget?.addEventListener ||
!eventTarget?.removeEventListener ||
typeof listener !== 'function'
)
return () => {};
eventTarget.addEventListener(CCTV_FOCUS_REQUEST_EVENT, listener); eventTarget.addEventListener(CCTV_FOCUS_REQUEST_EVENT, listener);
let disposed = false; let disposed = false;
return () => { return () => {
@@ -57,11 +63,16 @@ export function registerCctvFocusRequestListener(eventTarget, listener) {
*/ */
export function routeCctvFocusRequest(event, runExplicitFocus, focusCamera) { export function routeCctvFocusRequest(event, runExplicitFocus, focusCamera) {
const cameraId = event?.detail?.cameraId; const cameraId = event?.detail?.cameraId;
if (typeof cameraId !== 'string' || !cameraId || typeof runExplicitFocus !== 'function') { if (
typeof cameraId !== 'string' ||
!cameraId ||
typeof runExplicitFocus !== 'function'
) {
return false; return false;
} }
return runExplicitFocus( return runExplicitFocus(
() => cameraId, () => 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. * floor through a band derived from the live keyhole radius.
*/ */
export function keyholeLabelAlpha(labelX, labelY, width, height) { 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. */ /** Compute keyhole opacity from geometry already cached by a hot render loop. */
export function keyholeLabelAlphaFromGeometry(labelX, labelY, geometry) { 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 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 (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); const progress = clamp((distance - geometry.radius) / feather, 0, 1);
return 1 - (1 - keyholeOutsideOpacity) * progress; return 1 - (1 - keyholeOutsideOpacity) * progress;
} }
@@ -131,7 +145,11 @@ export function isCelestialRingStyleSupported(styleName) {
* @param {number} lastAngle - Previous stable angle in radians. * @param {number} lastAngle - Previous stable angle in radians.
* @returns {{angle:number, opacity:number, stable:boolean}} * @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); const planeLength = Math.hypot(rightComponent, upComponent);
if (!Number.isFinite(planeLength) || planeLength < CELESTIAL_PLANE_EPSILON) { if (!Number.isFinite(planeLength) || planeLength < CELESTIAL_PLANE_EPSILON) {
return { return {
@@ -170,26 +188,42 @@ export function isFullGlobeInsideKeyhole(geometry, wasVisible = false) {
keyholeCenterY, keyholeCenterY,
keyholeRadius, keyholeRadius,
} = geometry || {}; } = geometry || {};
const values = [earthCenterX, earthCenterY, earthRadius, keyholeCenterX, keyholeCenterY, keyholeRadius]; const values = [
if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0) return false; earthCenterX,
const offset = Math.hypot(earthCenterX - keyholeCenterX, earthCenterY - keyholeCenterY); 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); 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. */ /** Return the Earth-disc radius in CSS pixels for a perspective camera. */
export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) { export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) {
const earthRadiusM = Cesium.Ellipsoid.WGS84.maximumRadius; const earthRadiusM = Cesium.Ellipsoid.WGS84.maximumRadius;
if ( if (
!Number.isFinite(cameraDistance) !Number.isFinite(cameraDistance) ||
|| cameraDistance <= earthRadiusM cameraDistance <= earthRadiusM ||
|| !(viewportHeight > 0) !(viewportHeight > 0) ||
|| !Number.isFinite(fovy) !Number.isFinite(fovy) ||
|| fovy <= 0 fovy <= 0 ||
|| fovy >= Math.PI fovy >= Math.PI
) return null; )
return null;
const angularRadius = Math.asin(clamp(earthRadiusM / cameraDistance, 0, 1)); 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; return Number.isFinite(radius) && radius > 0 ? radius : null;
} }
@@ -216,7 +250,11 @@ export function projectEarthDiscToViewport(
if (!camera || !scene || !(width > 0) || !(height > 0)) return null; if (!camera || !scene || !(width > 0) || !(height > 0)) return null;
const distance = Cesium.Cartesian3.magnitude(camera.positionWC); 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( const toCenter = Cesium.Cartesian3.negate(
camera.positionWC, camera.positionWC,
@@ -229,9 +267,14 @@ export function projectEarthDiscToViewport(
Cesium.Cartesian3.ZERO, Cesium.Cartesian3.ZERO,
scratchCenter, 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; if (!earthRadius) return null;
const keyhole = getKeyholeGeometry(width, height); 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(0.58, 'rgba(255, 235, 188, 0.027)');
glow.addColorStop(1, 'rgba(255, 242, 214, 0)'); glow.addColorStop(1, 'rgba(255, 242, 214, 0)');
ctx.fillStyle = glow; 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.lineCap = 'round';
ctx.shadowColor = 'rgba(255, 232, 171, 0.11)'; 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 ex = sx + Math.cos(rayAngle) * rayLength;
const ey = sy + Math.sin(rayAngle) * rayLength; const ey = sy + Math.sin(rayAngle) * rayLength;
const lineGradient = ctx.createLinearGradient(sx, sy, ex, ey); 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)'); lineGradient.addColorStop(1, 'rgba(255, 226, 156, 0)');
ctx.strokeStyle = lineGradient; ctx.strokeStyle = lineGradient;
ctx.lineWidth = i === 0 ? 1.8 : 0.9; ctx.lineWidth = i === 0 ? 1.8 : 0.9;
@@ -340,7 +391,8 @@ export class CelestialRing {
this.viewer = viewer; this.viewer = viewer;
this.enabled = !!enabled; this.enabled = !!enabled;
this.visible = false; this.visible = false;
this._onAutoDisable = typeof onAutoDisable === 'function' ? onAutoDisable : null; this._onAutoDisable =
typeof onAutoDisable === 'function' ? onAutoDisable : null;
this._focusInProgress = false; this._focusInProgress = false;
this._ephemerisDirty = true; this._ephemerisDirty = true;
this._ephemerisUpdateCount = 0; this._ephemerisUpdateCount = 0;
@@ -364,7 +416,9 @@ export class CelestialRing {
this._fixedMatrix = new Cesium.Matrix3(); this._fixedMatrix = new Cesium.Matrix3();
this._buildDOM(); 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 // Pre-existing staleness fix (perf wave 2 review): the ephemeris was
// sampled once per visible-enable from the FROZEN app clock, so the // sampled once per visible-enable from the FROZEN app clock, so the
// sun/moon markers aged with the app. Resample real wall time each // 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 = document.createElement('canvas');
this._sunCanvas.className = 'celestial-ring-canvas celestial-sun-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 = document.createElement('canvas');
this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-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 = 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._sunMarker.textContent = 'light_mode';
this._moonMarker = document.createElement('span'); 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._moonMarker.textContent = 'dark_mode';
this._root.append( this._root.append(
@@ -413,7 +475,7 @@ export class CelestialRing {
this._sunCanvas, this._sunCanvas,
this._moonCanvas, this._moonCanvas,
this._sunMarker, this._sunMarker,
this._moonMarker this._moonMarker,
); );
this.viewer.container.appendChild(this._root); this.viewer.container.appendChild(this._root);
} }
@@ -458,15 +520,24 @@ export class CelestialRing {
const height = canvas.clientHeight || canvas.height; const height = canvas.clientHeight || canvas.height;
const cartographic = this.viewer.camera.positionCartographic; const cartographic = this.viewer.camera.positionCartographic;
const fovy = this.viewer.camera.frustum?.fovy; 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; return false;
} }
const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius; 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 targetScreenRadius = keyholeRadius * FULL_GLOBE_RADIUS_RATIO;
const angularRadius = Math.atan( 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 distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius); const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
@@ -480,7 +551,7 @@ export class CelestialRing {
destination: Cesium.Cartesian3.fromRadians( destination: Cesium.Cartesian3.fromRadians(
cartographic.longitude, cartographic.longitude,
cartographic.latitude, cartographic.latitude,
altitude altitude,
), ),
orientation: { orientation: {
heading: this.viewer.camera.heading, heading: this.viewer.camera.heading,
@@ -500,7 +571,10 @@ export class CelestialRing {
/** Clear the backing canvas. */ /** Clear the backing canvas. */
_clear() { _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); if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
} }
this._ringOutline?.style.setProperty('display', 'none'); this._ringOutline?.style.setProperty('display', 'none');
@@ -512,12 +586,23 @@ export class CelestialRing {
/** Resize the canvas backing store while drawing in CSS pixels. */ /** Resize the canvas backing store while drawing in CSS pixels. */
_resize(width, height) { _resize(width, height) {
const nativeDpr = Math.max(1, window.devicePixelRatio || 1); const nativeDpr = Math.max(1, window.devicePixelRatio || 1);
const pixelsScale = Math.sqrt(CELESTIAL_MAX_BACKING_PIXELS / (width * height)); const pixelsScale = Math.sqrt(
const dimensionScale = CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height); CELESTIAL_MAX_BACKING_PIXELS / (width * height),
const dpr = Math.min(nativeDpr, CELESTIAL_MAX_DEVICE_PIXEL_RATIO, pixelsScale, dimensionScale); );
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 bw = Math.max(1, Math.round(width * dpr));
const bh = Math.max(1, Math.round(height * 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) { if (canvas.width !== bw || canvas.height !== bh) {
canvas.width = bw; canvas.width = bw;
canvas.height = bh; canvas.height = bh;
@@ -536,14 +621,25 @@ export class CelestialRing {
_updateEphemeris(time) { _updateEphemeris(time) {
if (!this._ephemerisDirty) return true; if (!this._ephemerisDirty) return true;
Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(time, this._sunInertial); Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(time, this._moonInertial); time,
const matrix = Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix) this._sunInertial,
|| Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix); );
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(
time,
this._moonInertial,
);
const matrix =
Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix) ||
Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix);
if (!matrix) return false; if (!matrix) return false;
Cesium.Matrix3.multiplyByVector(matrix, this._sunInertial, this._sunFixed); 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._sunFixed, this._sunFixed);
Cesium.Cartesian3.normalize(this._moonFixed, this._moonFixed); Cesium.Cartesian3.normalize(this._moonFixed, this._moonFixed);
this._ephemerisDirty = false; this._ephemerisDirty = false;
@@ -574,8 +670,14 @@ export class CelestialRing {
const cos = Math.cos(angle); const cos = Math.cos(angle);
const sin = Math.sin(angle); const sin = Math.sin(angle);
const margin = 14; const margin = 14;
const maxRadiusX = Math.abs(cos) > 1e-4 ? (cx - margin) / Math.abs(cos) : Number.POSITIVE_INFINITY; const maxRadiusX =
const maxRadiusY = Math.abs(sin) > 1e-4 ? (cy - margin) / Math.abs(sin) : Number.POSITIVE_INFINITY; 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); const markerRadius = Math.min(radius, maxRadiusX, maxRadiusY);
marker.style.left = `${cx + cos * markerRadius}px`; marker.style.left = `${cx + cos * markerRadius}px`;
marker.style.top = `${cy + sin * markerRadius}px`; marker.style.top = `${cy + sin * markerRadius}px`;
@@ -596,14 +698,24 @@ export class CelestialRing {
const sunKey = `${outlineKey}:${Math.round(rayInnerRadius)}`; const sunKey = `${outlineKey}:${Math.round(rayInnerRadius)}`;
if (sunKey !== this._sunRenderKey) { 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); drawSunRays(this._sunCtx, cx, cy, radius, rayInnerRadius, 0);
drawTaperedArc(this._sunCtx, cx, cy, radius, 0, '222, 190, 89', 0.56); drawTaperedArc(this._sunCtx, cx, cy, radius, 0, '222, 190, 89', 0.56);
this._sunRenderKey = sunKey; this._sunRenderKey = sunKey;
} }
if (outlineKey !== this._moonRenderKey) { 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); drawMoonHaze(this._moonCtx, cx, cy, radius, 0);
drawTaperedArc(this._moonCtx, cx, cy, radius, 0, '48, 201, 229', 0.42); drawTaperedArc(this._moonCtx, cx, cy, radius, 0, '48, 201, 229', 0.42);
this._moonRenderKey = outlineKey; this._moonRenderKey = outlineKey;
@@ -624,7 +736,9 @@ export class CelestialRing {
const disc = this._projectedEarthDisc(width, height); const disc = this._projectedEarthDisc(width, height);
const wasVisible = this.visible; const wasVisible = this.visible;
const nextVisible = disc ? isFullGlobeInsideKeyhole(disc, wasVisible) : false; const nextVisible = disc
? isFullGlobeInsideKeyhole(disc, wasVisible)
: false;
this.visible = nextVisible; this.visible = nextVisible;
this._root.classList.toggle('visible', nextVisible); this._root.classList.toggle('visible', nextVisible);
this._root.dataset.globeVisible = String(nextVisible); this._root.dataset.globeVisible = String(nextVisible);
@@ -645,12 +759,12 @@ export class CelestialRing {
const sunProjection = celestialScreenAngle( const sunProjection = celestialScreenAngle(
Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC), Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC),
Cesium.Cartesian3.dot(this._sunFixed, camera.upWC), Cesium.Cartesian3.dot(this._sunFixed, camera.upWC),
this._sunAngle this._sunAngle,
); );
const moonProjection = celestialScreenAngle( const moonProjection = celestialScreenAngle(
Cesium.Cartesian3.dot(this._moonFixed, camera.rightWC), Cesium.Cartesian3.dot(this._moonFixed, camera.rightWC),
Cesium.Cartesian3.dot(this._moonFixed, camera.upWC), Cesium.Cartesian3.dot(this._moonFixed, camera.upWC),
this._moonAngle this._moonAngle,
); );
if (sunProjection.stable) this._sunAngle = sunProjection.angle; if (sunProjection.stable) this._sunAngle = sunProjection.angle;
if (moonProjection.stable) this._moonAngle = moonProjection.angle; if (moonProjection.stable) this._moonAngle = moonProjection.angle;
@@ -660,7 +774,10 @@ export class CelestialRing {
const cx = width * 0.5; const cx = width * 0.5;
const cy = height * 0.5; const cy = height * 0.5;
const radius = disc.keyholeRadius - RING_INSET_PX; 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._renderEffectLayers(cx, cy, radius, rayInnerRadius);
this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`; this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`;
this._sunCanvas.style.opacity = String(this._sunOpacity); 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 // 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. // move the moon into a second radial lane so both bodies remain legible.
const markerRadius = radius - MARKER_INSET_PX; const markerRadius = radius - MARKER_INSET_PX;
const markersCollide = circularAngleDistance(this._sunAngle, this._moonAngle) const markersCollide =
< MARKER_COLLISION_ANGLE; circularAngleDistance(this._sunAngle, this._moonAngle) <
this._positionMarker(this._sunMarker, cx, cy, markerRadius, this._sunAngle, this._sunOpacity); MARKER_COLLISION_ANGLE;
this._positionMarker(
this._sunMarker,
cx,
cy,
markerRadius,
this._sunAngle,
this._sunOpacity,
);
this._positionMarker( this._positionMarker(
this._moonMarker, this._moonMarker,
cx, cx,
cy, cy,
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0), markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
this._moonAngle, this._moonAngle,
this._moonOpacity this._moonOpacity,
); );
this._debug = { this._debug = {
enabled: true, enabled: true,
@@ -698,7 +823,12 @@ export class CelestialRing {
/** Read-only geometry snapshot for browser QA. */ /** Read-only geometry snapshot for browser QA. */
getDebugState() { 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. */ /** Detach the render hook and remove all overlay DOM. */
+127 -41
View File
@@ -1,6 +1,9 @@
import { applicationServices } from './services/application.js'; import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium'; 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 WEATHER_REFRESH_MS = 5 * 60_000;
const CLOUD_FRAME_MS = 1000 / 12; const CLOUD_FRAME_MS = 1000 / 12;
@@ -117,28 +120,47 @@ function compileShader(gl, type, source) {
gl.shaderSource(shader, source); gl.shaderSource(shader, source);
gl.compileShader(shader); gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return 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); gl.deleteShader(shader);
throw new Error(error); throw new Error(error);
} }
function greatCircleM(a, b) { 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 latitudeA = Cesium.Math.toRadians(a.latitude);
const latitudeB = Cesium.Math.toRadians(b.latitude); const latitudeB = Cesium.Math.toRadians(b.latitude);
const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude); const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude);
const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude); const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude);
const haversine = Math.sin(latitudeDelta / 2) ** 2 const haversine =
+ Math.cos(latitudeA) * Math.cos(latitudeB) * Math.sin(longitudeDelta / 2) ** 2; Math.sin(latitudeDelta / 2) ** 2 +
return 6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)); 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. */ /** 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 (!hasWeather) return true;
if (!Number.isFinite(nowMs) || !Number.isFinite(fetchedAt)) return true; if (!Number.isFinite(nowMs) || !Number.isFinite(fetchedAt)) return true;
return nowMs - fetchedAt >= WEATHER_REFRESH_MS return (
|| greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M; nowMs - fetchedAt >= WEATHER_REFRESH_MS ||
greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M
);
} }
/** Returns the capped framebuffer size used by the cockpit cloud pass. */ /** Returns the capped framebuffer size used by the cockpit cloud pass. */
@@ -192,18 +214,24 @@ export class CockpitCloudEffectsController {
this.pending = null; this.pending = null;
this.abort = null; this.abort = null;
this.destroyed = false; 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.enabled = this.readEnabledPreference();
this.onResize = () => this.resize(); this.onResize = () => this.resize();
this.onCockpitMode = (event) => { 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(); if (active && this.enabled) this.start();
else this.stop(); else this.stop();
}; };
this.onEnabledChange = (event) => { this.onEnabledChange = (event) => {
const requested = event?.detail?.enabled; 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('resize', this.onResize);
window.addEventListener('gev:cockpit-mode-changed', this.onCockpitMode); window.addEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
@@ -222,9 +250,11 @@ export class CockpitCloudEffectsController {
} }
emitEnabledState() { emitEnabledState() {
window.dispatchEvent(new CustomEvent('gev:cockpit-weather-state', { window.dispatchEvent(
new CustomEvent('gev:cockpit-weather-state', {
detail: { enabled: this.enabled }, detail: { enabled: this.enabled },
})); }),
);
} }
setEnabled(enabled) { setEnabled(enabled) {
@@ -232,7 +262,9 @@ export class CockpitCloudEffectsController {
this.enabled = next; this.enabled = next;
try { try {
localStorage.setItem(WEATHER_ENABLED_STORAGE_KEY, next ? '1' : '0'); localStorage.setItem(WEATHER_ENABLED_STORAGE_KEY, next ? '1' : '0');
} catch { /* best effort */ } } catch {
/* best effort */
}
if (!next) { if (!next) {
this.stop(); this.stop();
} else if (document.body.classList.contains('cockpit-mode')) { } else if (document.body.classList.contains('cockpit-mode')) {
@@ -255,7 +287,11 @@ export class CockpitCloudEffectsController {
if (!gl) throw new Error('WebGL unavailable'); if (!gl) throw new Error('WebGL unavailable');
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER); 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(); const program = gl.createProgram();
gl.attachShader(program, vertexShader); gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader); gl.attachShader(program, fragmentShader);
@@ -263,15 +299,18 @@ export class CockpitCloudEffectsController {
gl.deleteShader(vertexShader); gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader); gl.deleteShader(fragmentShader);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { 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(); const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ gl.bufferData(
-1, -1, 1, -1, -1, 1, gl.ARRAY_BUFFER,
-1, 1, 1, -1, 1, 1, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
]), gl.STATIC_DRAW); gl.STATIC_DRAW,
);
const position = gl.getAttribLocation(program, 'position'); const position = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(position); gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
@@ -293,7 +332,8 @@ export class CockpitCloudEffectsController {
resize() { resize() {
if (!this.gl) return; if (!this.gl) return;
const size = cockpitCloudRenderSize(window.innerWidth, window.innerHeight); 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.width = size.width;
this.canvas.height = size.height; this.canvas.height = size.height;
this.gl.viewport(0, 0, size.width, size.height); this.gl.viewport(0, 0, size.width, size.height);
@@ -313,7 +353,13 @@ export class CockpitCloudEffectsController {
} }
start() { 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) { if (!this.gl) {
this.initializeRenderer(); this.initializeRenderer();
this.resize(); this.resize();
@@ -353,16 +399,21 @@ export class CockpitCloudEffectsController {
} }
async refresh() { async refresh() {
if (this.destroyed || !this.enabled || this.pending || this.suspended if (
|| !document.body.classList.contains('cockpit-mode')) { this.destroyed ||
!this.enabled ||
this.pending ||
this.suspended ||
!document.body.classList.contains('cockpit-mode')
) {
return this.pending; return this.pending;
} }
const point = this.cameraPoint(); const point = this.cameraPoint();
if (!point) return null; if (!point) return null;
if ( if (
this.weather this.weather &&
&& Date.now() - this.fetchedAt < WEATHER_REFRESH_MS Date.now() - this.fetchedAt < WEATHER_REFRESH_MS &&
&& greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M
) { ) {
this.applyWeather(this.weather, point.altitudeM); this.applyWeather(this.weather, point.altitudeM);
return this.weather; return this.weather;
@@ -370,11 +421,17 @@ export class CockpitCloudEffectsController {
this.abort?.abort(); this.abort?.abort();
this.abort = new AbortController(); 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; 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 (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.weather = payload.weather;
this.fetchedAt = Date.now(); this.fetchedAt = Date.now();
this.anchor = point; this.anchor = point;
@@ -391,7 +448,10 @@ export class CockpitCloudEffectsController {
return null; return null;
}) })
.finally(() => { .finally(() => {
if (this.abort === request) { this.pending = null; this.abort = null; } if (this.abort === request) {
this.pending = null;
this.abort = null;
}
}); });
return this.pending; return this.pending;
} }
@@ -399,12 +459,17 @@ export class CockpitCloudEffectsController {
applyWeather(weather, altitudeM) { applyWeather(weather, altitudeM) {
const profile = deriveWeatherEffectProfile(weather); const profile = deriveWeatherEffectProfile(weather);
const altitude = weatherAltitudeFactors(altitudeM); 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.windDirectionDeg = profile.windDirectionDeg || 0;
this.windStrength = profile.wind || 0; this.windStrength = profile.wind || 0;
this.canvas.dataset.cloudStrength = this.targetStrength.toFixed(3); this.canvas.dataset.cloudStrength = this.targetStrength.toFixed(3);
this.canvas.dataset.weatherCode = String(weather?.weatherCode ?? 'unknown'); 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; this.strength = this.targetStrength;
const visible = this.strength > 0.035; const visible = this.strength > 0.035;
this.canvas.classList.toggle('active', visible); this.canvas.classList.toggle('active', visible);
@@ -414,7 +479,11 @@ export class CockpitCloudEffectsController {
} }
tick(timeMs) { 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(); this.stop();
return; return;
} }
@@ -431,20 +500,27 @@ export class CockpitCloudEffectsController {
if (timeMs - this.lastRefreshCheckMs >= 1000) { if (timeMs - this.lastRefreshCheckMs >= 1000) {
this.lastRefreshCheckMs = timeMs; this.lastRefreshCheckMs = timeMs;
const point = this.cameraPoint(); const point = this.cameraPoint();
if (point && cockpitWeatherRefreshDue({ if (
point &&
cockpitWeatherRefreshDue({
nowMs: Date.now(), nowMs: Date.now(),
fetchedAt: this.fetchedAt, fetchedAt: this.fetchedAt,
anchor: this.anchor, anchor: this.anchor,
point, point,
hasWeather: Boolean(this.weather), hasWeather: Boolean(this.weather),
})) void this.refresh(); })
)
void this.refresh();
} }
const blend = this.reducedMotion ? 1 : 0.08; const blend = this.reducedMotion ? 1 : 0.08;
this.strength += (this.targetStrength - this.strength) * blend; this.strength += (this.targetStrength - this.strength) * blend;
const visible = this.strength > 0.035; const visible = this.strength > 0.035;
this.canvas.classList.toggle('active', visible); 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.lastFrameMs = timeMs;
this.render(timeMs / 1000); this.render(timeMs / 1000);
} else if (!visible) { } else if (!visible) {
@@ -468,9 +544,16 @@ export class CockpitCloudEffectsController {
const windRadians = Cesium.Math.toRadians(this.windDirectionDeg); const windRadians = Cesium.Math.toRadians(this.windDirectionDeg);
const windScale = 0.008 + this.windStrength * 0.022; const windScale = 0.008 + this.windStrength * 0.022;
gl.useProgram(this.program); 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.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( gl.uniform2f(
this.locations.wind, this.locations.wind,
Math.sin(windRadians) * windScale, Math.sin(windRadians) * windScale,
@@ -517,7 +600,10 @@ export class CockpitCloudEffectsController {
this.stop(); this.stop();
window.removeEventListener('resize', this.onResize); window.removeEventListener('resize', this.onResize);
window.removeEventListener('gev:cockpit-mode-changed', this.onCockpitMode); 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); if (this.program && this.gl) this.gl.deleteProgram(this.program);
this.canvas.remove(); this.canvas.remove();
} }
+32 -15
View File
@@ -8,7 +8,11 @@
*/ */
/** Force the prior aircraft layer to reacquire Cesium and durable tracking ownership. */ /** 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; if (!layer?.trackById || !id) return false;
layer.stopTracking?.({ origin }); layer.stopTracking?.({ origin });
return Boolean(layer.trackById(id, { origin })); return Boolean(layer.trackById(id, { origin }));
@@ -33,11 +37,13 @@ export function enterCockpitWithTracking({
rollbackTarget = undefined, rollbackTarget = undefined,
selectionOrigin = 'programmatic', selectionOrigin = 'programmatic',
}) { }) {
const currentTarget = aircraftTrackingTarget(cockpitView?.readAircraftInfo?.()); const currentTarget = aircraftTrackingTarget(
const restoreTarget = rollbackTarget === undefined ? currentTarget : rollbackTarget; cockpitView?.readAircraftInfo?.(),
const key = (target) => target?.layerId && target?.id );
? `${target.layerId}:${target.id}` const restoreTarget =
: null; rollbackTarget === undefined ? currentTarget : rollbackTarget;
const key = (target) =>
target?.layerId && target?.id ? `${target.layerId}:${target.id}` : null;
let activeLayer = currentLayer; let activeLayer = currentLayer;
let activeTarget = currentTarget; let activeTarget = currentTarget;
let entryError = null; let entryError = null;
@@ -49,8 +55,14 @@ export function enterCockpitWithTracking({
if (selectingTarget) { if (selectingTarget) {
activeLayer = selectedLayer; activeLayer = selectedLayer;
activeTarget = selectedTarget; activeTarget = selectedTarget;
if (!selectedLayer.trackById?.(selectedTarget.id, { origin: selectionOrigin })) { if (
entryError = new Error('Selected aircraft could not be tracked for Cockpit entry'); !selectedLayer.trackById?.(selectedTarget.id, {
origin: selectionOrigin,
})
) {
entryError = new Error(
'Selected aircraft could not be tracked for Cockpit entry',
);
} }
} }
if (!entryError) entered = Boolean(cockpitView.enter()); if (!entryError) entered = Boolean(cockpitView.enter());
@@ -66,12 +78,15 @@ export function enterCockpitWithTracking({
// programmatic rollback would fix the camera while leaving the failed // programmatic rollback would fix the camera while leaving the failed
// target durable in local/share state. // target durable in local/share state.
activeLayer?.stopTracking?.({ origin: selectionOrigin }); activeLayer?.stopTracking?.({ origin: selectionOrigin });
if (restoreTarget && !restoreAircraftTrackingOwner( if (
rollbackLayer, restoreTarget &&
restoreTarget.id, !restoreAircraftTrackingOwner(rollbackLayer, restoreTarget.id, {
{ origin: selectionOrigin }, origin: selectionOrigin,
)) { })
entryError ||= new Error('Prior aircraft tracking could not be restored'); ) {
entryError ||= new Error(
'Prior aircraft tracking could not be restored',
);
} }
} catch (error) { } catch (error) {
entryError ||= error instanceof Error ? error : new Error(String(error)); entryError ||= error instanceof Error ? error : new Error(String(error));
@@ -87,6 +102,8 @@ export function enterCockpitWithTracking({
return { return {
entered, 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 * @returns {?{mode: string, densityPct: ?number}} State to replay, or null when
* there is nothing to do. * there is nothing to do.
*/ */
export function contactsDetectionExitPlan(restore, current, styleOwnsDetection = false) { export function contactsDetectionExitPlan(
restore,
current,
styleOwnsDetection = false,
) {
if (!restore || styleOwnsDetection) return null; if (!restore || styleOwnsDetection) return null;
const saved = normalizeDetectionState(restore); const saved = normalizeDetectionState(restore);
const now = normalizeDetectionState(current); 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; return saved;
} }
@@ -119,7 +124,11 @@ export function applyContactsDetection({
if (plan.turnOn) applyPreset(); if (plan.turnOn) applyPreset();
return { restore: plan.restore, changed: plan.turnOn }; return { restore: plan.restore, changed: plan.turnOn };
} }
const plan = contactsDetectionExitPlan(restore, getState(), styleOwnsDetection); const plan = contactsDetectionExitPlan(
restore,
getState(),
styleOwnsDetection,
);
if (plan) restoreState(plan); if (plan) restoreState(plan);
return { restore: null, changed: Boolean(plan) }; return { restore: null, changed: Boolean(plan) };
} }
@@ -145,7 +154,9 @@ export function shareableDetectionState({ owned, liveMode, liveDensityPct }) {
if (!owned) return { mode: liveMode, densityPct: liveDensityPct }; if (!owned) return { mode: liveMode, densityPct: liveDensityPct };
return { return {
mode: owned.mode ?? liveMode, mode: owned.mode ?? liveMode,
densityPct: Number.isFinite(owned.densityPct) ? owned.densityPct : liveDensityPct, densityPct: Number.isFinite(owned.densityPct)
? owned.densityPct
: liveDensityPct,
}; };
} }
+119 -66
View File
@@ -1,5 +1,11 @@
const CONTEXT_DEPENDENCIES = Object.freeze({ 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']), 'space-missions': new Set(['rocket-launches', 'satellites']),
}); });
const CONTEXT_COMPANIONS = new Set(['radio']); 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. */ /** Preserve explicit layer intent that completes while a stale restore is queued. */
export function recordContextRestoreExplicitChange({ restoreState, change }) { export function recordContextRestoreExplicitChange({ restoreState, change }) {
if ( if (
!restoreState?.enabledLayerIds !restoreState?.enabledLayerIds ||
|| change?.type !== 'visibility' change?.type !== 'visibility' ||
|| !isExplicitUserIntentOrigin(change.origin, change.layerId) !isExplicitUserIntentOrigin(change.origin, change.layerId)
) return false; )
if (!restoreState.explicitLayerStates) restoreState.explicitLayerStates = new Map(); return false;
if (!restoreState.explicitLayerStates)
restoreState.explicitLayerStates = new Map();
restoreState.explicitLayerStates.set(change.layerId, Boolean(change.enabled)); restoreState.explicitLayerStates.set(change.layerId, Boolean(change.enabled));
if (change.enabled) restoreState.enabledLayerIds.add(change.layerId); if (change.enabled) restoreState.enabledLayerIds.add(change.layerId);
else restoreState.enabledLayerIds.delete(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. */ /** 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; if (restoreState?.cancelled) return null;
const entries = [...(restoreState?.explicitLayerStates || [])]; const entries = [...(restoreState?.explicitLayerStates || [])];
const results = await Promise.allSettled(entries.map(([layerId, enabled]) => ( const results = await Promise.allSettled(
entries.map(([layerId, enabled]) =>
setEnabled(layerId, enabled, { setEnabled(layerId, enabled, {
origin: 'context-intent-replay', origin: 'context-intent-replay',
...(notificationToken ? { notificationToken } : {}), ...(notificationToken ? { notificationToken } : {}),
}) }),
))); ),
const failedIndexes = results.flatMap((result, index) => ( );
result.status === 'rejected' || result.value === false ? [index] : [] const failedIndexes = results.flatMap((result, index) =>
)); result.status === 'rejected' || result.value === false ? [index] : [],
);
if (failedIndexes.length === 0) return null; if (failedIndexes.length === 0) return null;
const failedLayerIds = failedIndexes.map((index) => entries[index][0]); const failedLayerIds = failedIndexes.map((index) => entries[index][0]);
const rejected = failedIndexes const rejected = failedIndexes
.map((index) => results[index]) .map((index) => results[index])
.find((result) => result.status === 'rejected'); .find((result) => result.status === 'rejected');
const error = rejected?.reason instanceof Error const error =
rejected?.reason instanceof Error
? rejected.reason ? rejected.reason
: new Error(`Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`); : new Error(
error.failedLayerIds = [...new Set([ `Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`,
);
error.failedLayerIds = [
...new Set([
...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []), ...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []),
...failedLayerIds, ...failedLayerIds,
])]; ]),
];
return error; return error;
} }
@@ -54,18 +73,29 @@ export async function settleContextIntentReplay({ restoreState, setEnabled, noti
export function mergeContextTransitionErrors(primaryError, secondaryError) { export function mergeContextTransitionErrors(primaryError, secondaryError) {
if (!primaryError) return secondaryError || null; if (!primaryError) return secondaryError || null;
if (!secondaryError) return primaryError; if (!secondaryError) return primaryError;
primaryError.failedLayerIds = [...new Set([ primaryError.failedLayerIds = [
...(Array.isArray(primaryError.failedLayerIds) ? primaryError.failedLayerIds : []), ...new Set([
...(Array.isArray(secondaryError.failedLayerIds) ? secondaryError.failedLayerIds : []), ...(Array.isArray(primaryError.failedLayerIds)
])]; ? primaryError.failedLayerIds
: []),
...(Array.isArray(secondaryError.failedLayerIds)
? secondaryError.failedLayerIds
: []),
]),
];
return primaryError; return primaryError;
} }
/** Settle a user-facing Context action and convert every failure form to false. */ /** 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 { try {
const result = await operation(); 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; return result;
} catch (error) { } catch (error) {
try { try {
@@ -83,7 +113,10 @@ export async function settleUserFacingContextAction({ operation, onFailure, fals
* one into `userAdded` makes exit restoration re-enable the mode layer the * one into `userAdded` makes exit restoration re-enable the mode layer the
* user just turned off. * 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 * Leave a Context transaction: publish the settled coordination flag, then
@@ -108,7 +141,8 @@ export function settleContextModeChange(owner, changing = false) {
if (!owner) return; if (!owner) return;
const wasChanging = Boolean(owner._contextModeChanging); const wasChanging = Boolean(owner._contextModeChanging);
owner._contextModeChanging = Boolean(changing); 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. * @param {object|null} input.change DataLayerManager visibility notification.
* @returns {boolean} Whether the active Context bundle should be disabled. * @returns {boolean} Whether the active Context bundle should be disabled.
*/ */
export function shouldExitContextForLayerChange({ contextMode, globalContextEnabled, change }) { export function shouldExitContextForLayerChange({
contextMode,
globalContextEnabled,
change,
}) {
if ( if (
change?.type !== 'visibility' change?.type !== 'visibility' ||
|| !isExplicitUserIntentOrigin(change.origin, change.layerId) !isExplicitUserIntentOrigin(change.origin, change.layerId) ||
|| change.layerId === 'military-awareness' change.layerId === 'military-awareness' ||
|| CONTEXT_COMPANIONS.has(change.layerId) CONTEXT_COMPANIONS.has(change.layerId)
) { ) {
return false; return false;
} }
const active = Boolean(globalContextEnabled || contextMode); const active = Boolean(globalContextEnabled || contextMode);
if (!active) return false; 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; if (change.enabled) return false;
return Boolean(contextMode && dependencies.has(change.layerId)); return Boolean(contextMode && dependencies.has(change.layerId));
} }
@@ -181,10 +220,10 @@ export function contextAllowedLayerIds(contextMode) {
*/ */
export function shouldCaptureContextSession(change) { export function shouldCaptureContextSession(change) {
return Boolean( return Boolean(
['visibility-requested', 'visibility-will-change'].includes(change?.type) ['visibility-requested', 'visibility-will-change'].includes(change?.type) &&
&& isExplicitUserIntentOrigin(change.origin, change.layerId) isExplicitUserIntentOrigin(change.origin, change.layerId) &&
&& change.enabled change.enabled &&
&& CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId) CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId),
); );
} }
@@ -194,12 +233,12 @@ export function shouldCaptureContextSession(change) {
*/ */
export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) { export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
return Boolean( return Boolean(
clearInFlight clearInFlight &&
&& change?.type === 'visibility-requested' change?.type === 'visibility-requested' &&
&& change.layerId === 'rocket-launches' change.layerId === 'rocket-launches' &&
&& change.enabled === true change.enabled === true &&
&& isExplicitUserIntentOrigin(change.origin, change.layerId) isExplicitUserIntentOrigin(change.origin, change.layerId) &&
&& Number.isInteger(change.intentEpoch) Number.isInteger(change.intentEpoch),
); );
} }
@@ -218,11 +257,15 @@ export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
* @param {string|null} [input.effectiveContextMode] Committed or entering mode. * @param {string|null} [input.effectiveContextMode] Committed or entering mode.
* @returns {boolean} Whether the snapshot's userAdded set was modified. * @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 ( if (
!snapshot?.userAdded !snapshot?.userAdded ||
|| change?.type !== 'visibility' change?.type !== 'visibility' ||
|| !isExplicitUserIntentOrigin(change.origin, change.layerId) !isExplicitUserIntentOrigin(change.origin, change.layerId)
) { ) {
return false; return false;
} }
@@ -233,7 +276,11 @@ export function recordContextSessionUserChange({ snapshot, change, effectiveCont
snapshot.userAdded.add(change.layerId); snapshot.userAdded.add(change.layerId);
return true; 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); snapshot.userAdded.add(change.layerId);
if (isCompanion) snapshot.userRemoved?.delete(change.layerId); if (isCompanion) snapshot.userRemoved?.delete(change.layerId);
return true; return true;
@@ -276,11 +323,15 @@ export function contextSnapshotLayerIds(
* @param {string|null} [input.layerName] User-facing layer name. * @param {string|null} [input.layerName] User-facing layer name.
* @returns {string|null} Honest user-facing refusal reason, or null when allowed. * @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 ( if (
contextMode !== 'space-missions' contextMode !== 'space-missions' ||
|| change?.enabled !== true change?.enabled !== true ||
|| contextAllowedLayerIds('space-missions').has(change.layerId) contextAllowedLayerIds('space-missions').has(change.layerId)
) { ) {
return null; return null;
} }
@@ -297,20 +348,20 @@ export function contextLayerEnableBlockReason({ contextMode, change, layerName =
* @param {object|null} input.change Manager visibility notification. * @param {object|null} input.change Manager visibility notification.
* @returns {'ignore'|'replacement'|'restore'} Cancellation disposition. * @returns {'ignore'|'replacement'|'restore'} Cancellation disposition.
*/ */
export function spaceMissionEntryCancellationDisposition({ export function spaceMissionEntryCancellationDisposition({ change }) {
change,
}) {
if ( if (
change?.type !== 'visibility-cancelled' change?.type !== 'visibility-cancelled' ||
|| change.layerId !== 'rocket-launches' change.layerId !== 'rocket-launches' ||
|| change.enabled !== true change.enabled !== true
) return 'ignore'; )
return 'ignore';
if ( if (
change.cancellationReason === 'superseded' change.cancellationReason === 'superseded' &&
&& Number.isInteger(change.successorIntentEpoch) Number.isInteger(change.successorIntentEpoch) &&
&& change.successorIntentEpoch > change.intentEpoch change.successorIntentEpoch > change.intentEpoch &&
&& change.successorEnabled === true change.successorEnabled === true
) return 'replacement'; )
return 'replacement';
return 'restore'; return 'restore';
} }
@@ -348,10 +399,12 @@ export function cockpitEntryAllowed({
flightsEnabled, flightsEnabled,
militaryEnabled, militaryEnabled,
}) { }) {
return contextMode === 'flights' return (
&& !contextModeChanging contextMode === 'flights' &&
&& Boolean(flightsEnabled) !contextModeChanging &&
&& Boolean(militaryEnabled); Boolean(flightsEnabled) &&
Boolean(militaryEnabled)
);
} }
/** /**
+6 -2
View File
@@ -3,7 +3,9 @@
* Cesium retains ownership of showing and hiding its required credit lightbox. * Cesium retains ownership of showing and hiding its required credit lightbox.
*/ */
export function configureCreditKeyboardAccess(root = document) { 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 lightbox = root?.querySelector?.('.cesium-credit-lightbox');
const close = lightbox?.querySelector?.('.cesium-credit-lightbox-close'); const close = lightbox?.querySelector?.('.cesium-credit-lightbox-close');
if (!expand || !lightbox || !close) return false; 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. // 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 // Cancelling the armed release prevents that eventual keyup from also
// activating the attribution control. // activating the attribution control.
control.addEventListener('blur', () => { spacePressed = false; }); control.addEventListener('blur', () => {
spacePressed = false;
});
}; };
const closeAndRestoreFocus = () => { const closeAndRestoreFocus = () => {
expand.setAttribute('aria-expanded', 'false'); expand.setAttribute('aria-expanded', 'false');
+402 -55
View File
@@ -8,68 +8,385 @@
*/ */
const HELI = new Set([ const HELI = new Set([
'EC20', 'EC25', 'EC30', 'EC35', 'EC45', 'EC55', 'AS50', 'AS55', 'AS65', 'AS32', 'EC20',
'A109', 'A119', 'A139', 'A169', 'A189', 'B06', 'B06T', 'B407', 'B412', 'B427', 'EC25',
'B429', 'B430', 'B505', 'S76', 'S92', 'S61', 'S64', 'H60', 'H500', 'MD52', 'EC30',
'MD60', 'R22', 'R44', 'R66', 'EXEC', 'EXPL', 'GAZL', 'LYNX', 'NH90', 'PUMA', 'EC35',
'SCAV', 'UH1', 'B105', 'B212', 'B214', 'B222', 'AC', 'H47', 'H64', '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([ const QUAD = new Set([
'B741', 'B742', 'B743', 'B744', 'B748', 'B74S', 'B74R', 'B74D', 'A388', 'A342', 'B741',
'A343', 'A345', 'A346', 'A124', 'C5M', 'A225', 'IL96', 'B52', 'A140', 'B742',
'B743',
'B744',
'B748',
'B74S',
'B74R',
'B74D',
'A388',
'A342',
'A343',
'A345',
'A346',
'A124',
'C5M',
'A225',
'IL96',
'B52',
'A140',
]); ]);
const WIDE = new Set([ const WIDE = new Set([
'A306', 'A30B', 'A310', 'A332', 'A333', 'A338', 'A339', 'A359', 'A35K', 'B762', 'A306',
'B763', 'B764', 'B772', 'B77L', 'B773', 'B77W', 'B778', 'B779', 'B788', 'B789', 'A30B',
'B78X', 'MD11', 'IL86', 'DC10', 'L101', 'A337', 'B767', 'B777', 'B787', 'A310',
'C17', 'K35R', // military heavies that read as widebodies '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([ const TPROP = new Set([
'DH8A', 'DH8B', 'DH8C', 'DH8D', 'AT43', 'AT44', 'AT45', 'AT46', 'AT72', 'AT73', 'DH8A',
'AT75', 'AT76', 'SF34', 'SB20', 'SW3', 'SW4', 'E110', 'E120', 'C208', 'C212', 'DH8B',
'C408', 'PC12', 'B190', 'BE20', 'B350', 'B300', 'JS31', 'JS32', 'JS41', 'D228', 'DH8C',
'D328', 'F50', 'F27', 'ATP', 'TBM7', 'TBM8', 'TBM9', 'TBM0', 'PC6', 'C441', 'DH8D',
'C425', 'DHC6', 'DHC7', 'C130', 'AN12', 'AN26', 'AN32', 'SH36', 'CVLT', 'SAAB', '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', 'A400',
]); ]);
const GLIDER = new Set([ const GLIDER = new Set([
'DISC', 'DUOD', 'VENT', 'NIMB', 'NIM3', 'NIM4', 'JANS', 'ARCE', 'DG40', 'DG80', 'DISC',
'DG1T', 'DG30', 'DG50', 'LS3', 'LS4', 'LS6', 'LS7', 'LS8', 'STD3', 'G103', 'DUOD',
'G102', 'G104', 'PW5', 'PW6', 'L13', 'L23', 'L33', 'PIK', 'PEGA', 'KEST', 'VENT',
'TWIN', 'AS33', 'ASW', 'ASG', 'ASK', 'VENS', 'GLID', 'MOSQ', 'DIMO', '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([ const LIGHT = new Set([
'C150', 'C152', 'C162', 'C172', 'C72R', 'C175', 'C177', 'C180', 'C182', 'C185', 'C150',
'C188', 'C206', 'C207', 'C210', 'C310', 'C337', 'SR20', 'SR22', 'S22T', 'PA18', 'C152',
'PA24', 'PA28', 'P28A', 'P28B', 'P28R', 'PA32', 'P32R', 'PA34', 'PA38', 'PA44', 'C162',
'PA46', 'DA20', 'DA40', 'DA42', 'DA62', 'BE33', 'BE35', 'BE36', 'BE58', 'BE76', 'C172',
'BE19', 'BE23', 'BE24', 'M20P', 'M20T', 'AA1', 'AA5', 'GLAS', 'COL4', 'RV4', 'C72R',
'RV6', 'RV7', 'RV8', 'RV9', 'RV10', 'RV14', 'GA8', 'G115', 'BL8', 'CH7', '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 // GEV addition (2026-08-15 Hangar fleet): business jets — distinct class so the
// Citation II GLB + a slimmer glyph read apart from airliners. // Citation II GLB + a slimmer glyph read apart from airliners.
const BIZJET = new Set([ const BIZJET = new Set([
'C500', 'C501', 'C510', 'C525', 'C25A', 'C25B', 'C25C', 'C25M', 'C550', 'C500',
'C551', 'C560', 'C56X', 'C650', 'C680', 'C68A', 'C700', 'C750', 'C501',
'CL30', 'CL35', 'CL60', 'GLF2', 'GLF3', 'GLF4', 'GLF5', 'GLF6', 'GA5C', 'C510',
'GA6C', 'G150', 'G280', 'GL5T', 'GL7T', 'GLEX', 'C525',
'LJ23', 'LJ24', 'LJ25', 'LJ31', 'LJ35', 'LJ40', 'LJ45', 'LJ55', 'LJ60', 'C25A',
'LJ70', 'LJ75', 'FA10', 'FA20', 'FA50', 'FA7X', 'FA8X', 'F900', 'F2TH', 'C25B',
'H25A', 'H25B', 'H25C', 'HDJT', 'E50P', 'E55P', 'E545', 'E550', 'PC24', 'C25C',
'PRM1', 'BE40', 'ASTR', 'WW24', '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) '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 // GEV addition (2026-08-15 Hangar fleet): large UAVs — ICAO designators seen on
// ADS-B/adsb.lol for Predator/Reaper/Global Hawk-class airframes. // ADS-B/adsb.lol for Predator/Reaper/Global Hawk-class airframes.
const UAV = new Set([ 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. // GEV addition: fast jets, for the military layer's adsb.lol `t` codes.
const FASTJET = new Set([ const FASTJET = new Set([
'F16', 'F15', 'F18', 'FA18', 'F14', 'F22', 'F35', 'F4', 'F5', 'A10', 'AV8B', 'F16',
'TYPH', 'EUFI', 'RFAL', 'RAFL', 'GRIP', 'JAS39', 'TOR', 'MIR2', 'M2000', 'F15',
'SU27', 'SU30', 'SU33', 'SU34', 'SU35', 'SU57', 'MG29', 'MIG29', 'MG31', 'J20', 'F18',
'T38', 'HAWK', 'L39', 'M346', 'T7A', '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. */ /** OpenSky extended-states integer category → class. */
@@ -86,12 +403,20 @@ const OPENSKY_CATEGORY = {
/** ADS-B emitter-category string → class. */ /** ADS-B emitter-category string → class. */
const EMITTER_CATEGORY = { const EMITTER_CATEGORY = {
A1: 'light', A2: 'light', A3: 'airliner', A4: 'airliner', A1: 'light',
A5: 'widebody', A6: 'fastjet', A7: 'helicopter', B1: 'glider', A2: 'light',
A3: 'airliner',
A4: 'airliner',
A5: 'widebody',
A6: 'fastjet',
A7: 'helicopter',
B1: 'glider',
}; };
export function classifyAircraft({ typeCode, category } = {}) { export function classifyAircraft({ typeCode, category } = {}) {
const code = String(typeCode || '').trim().toUpperCase(); const code = String(typeCode || '')
.trim()
.toUpperCase();
if (code) { if (code) {
if (FASTJET.has(code)) return 'fastjet'; if (FASTJET.has(code)) return 'fastjet';
if (UAV.has(code)) return 'uav'; if (UAV.has(code)) return 'uav';
@@ -104,37 +429,59 @@ export function classifyAircraft({ typeCode, category } = {}) {
if (LIGHT.has(code)) return 'light'; if (LIGHT.has(code)) return 'light';
return 'airliner'; // known type code, not in a special set → default jet return 'airliner'; // known type code, not in a special set → default jet
} }
if (Number.isFinite(category) && OPENSKY_CATEGORY[category]) return OPENSKY_CATEGORY[category]; if (Number.isFinite(category) && OPENSKY_CATEGORY[category])
const cat = String(category || '').trim().toUpperCase(); return OPENSKY_CATEGORY[category];
const cat = String(category || '')
.trim()
.toUpperCase();
if (EMITTER_CATEGORY[cat]) return EMITTER_CATEGORY[cat]; if (EMITTER_CATEGORY[cat]) return EMITTER_CATEGORY[cat];
return 'airliner'; return 'airliner';
} }
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */ /** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
export const CLASS_SCALE_2D = { export const CLASS_SCALE_2D = {
light: 0.62, glider: 0.58, turboprop: 0.86, airliner: 1.0, light: 0.62,
widebody: 1.3, quadjet: 1.45, helicopter: 0.82, fastjet: 0.8, glider: 0.58,
bizjet: 0.72, uav: 0.75, 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 /** 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 * the single airplane.glb (a jet mesh at C172 scale reads wrong). Widen when
* real per-class models land in CLASS_MODEL_URL. */ * real per-class models land in CLASS_MODEL_URL. */
export const CLASS_SCALE_3D = { export const CLASS_SCALE_3D = {
light: 0.75, glider: 0.75, turboprop: 0.85, airliner: 1.0, light: 0.75,
widebody: 1.3, quadjet: 1.45, helicopter: 0.8, fastjet: 0.8, glider: 0.75,
bizjet: 0.8, uav: 0.8, 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. /** 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 * NOTE for future models: each asset may need its own heading offset (the
* shared GLB uses MODEL_HEADING_OFFSET_DEG = 180 in both layers). */ * shared GLB uses MODEL_HEADING_OFFSET_DEG = 180 in both layers). */
export const CLASS_MODEL_URL = { export const CLASS_MODEL_URL = {
light: '/models/airplane.glb', glider: '/models/airplane.glb', light: '/models/airplane.glb',
turboprop: '/models/airplane.glb', airliner: '/models/airplane.glb', glider: '/models/airplane.glb',
widebody: '/models/airplane.glb', quadjet: '/models/airplane.glb', turboprop: '/models/airplane.glb',
helicopter: '/models/airplane.glb', fastjet: '/models/airplane.glb', airliner: '/models/airplane.glb',
bizjet: '/models/airplane.glb', uav: '/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 — /** Real per-class GLBs (2026-08-15 Hangar fleet, owner picks; CC-BY 4.0 —
+7 -3
View File
@@ -35,9 +35,11 @@ const VIEW = 96;
const C = VIEW / 2; // 48 — glyph centre const C = VIEW / 2; // 48 — glyph centre
// Hairline dark edge (scaled for the 96 box: ~1.5u ≈ the old 0.5u at 32). // 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. // 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 // Softer white for translucent detail (prop discs, rotor disc) — still white so
// the tint multiplies cleanly; only the alpha differs. // the tint multiplies cleanly; only the alpha differs.
const DISC = 'fill="white" fill-opacity="0.5"'; const DISC = 'fill="white" fill-opacity="0.5"';
@@ -239,7 +241,9 @@ const BODIES = {
const _iconCache = new Map(); const _iconCache = new Map();
const _b64 = (s) => 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 × /** 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 * 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, modelRendering = false,
includeHidden = 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, startLimbRatio: 0.5,
scaleFloor: 0.45, scaleFloor: 0.45,
alphaFloor: 0.35, alphaFloor: 0.35,
combinedAlphaFloor: 0.20, combinedAlphaFloor: 0.2,
globeViewBlendStartM: 3_500_000, globeViewBlendStartM: 3_500_000,
globeViewBlendEndM: 4_500_000, globeViewBlendEndM: 4_500_000,
earthRadiusM: 6_378_137, earthRadiusM: 6_378_137,
@@ -36,10 +36,19 @@ function resolvedParams(overrides) {
return { ..._params, ...overrides }; return { ..._params, ...overrides };
} }
function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning, result) { function aircraftRecessionFactorsResolved(
if (!Number.isFinite(cameraDistanceM) || cameraDistanceM < 0 cameraDistanceM,
|| !Number.isFinite(cameraHeightM) || cameraHeightM <= 0 cameraHeightM,
|| cameraHeightM >= tuning.globeViewBlendEndM) { tuning,
result,
) {
if (
!Number.isFinite(cameraDistanceM) ||
cameraDistanceM < 0 ||
!Number.isFinite(cameraHeightM) ||
cameraHeightM <= 0 ||
cameraHeightM >= tuning.globeViewBlendEndM
) {
result.scale = 1; result.scale = 1;
result.alpha = 1; result.alpha = 1;
result.limbRatio = null; result.limbRatio = null;
@@ -60,11 +69,13 @@ function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning
return result; 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 t = clamp(rawT, 0, 1);
const limbEase = t * t * (3 - 2 * t); // smoothstep: zero slope at both band edges const limbEase = t * t * (3 - 2 * t); // smoothstep: zero slope at both band edges
const globeRawT = (cameraHeightM - tuning.globeViewBlendStartM) const globeRawT =
/ (tuning.globeViewBlendEndM - tuning.globeViewBlendStartM); (cameraHeightM - tuning.globeViewBlendStartM) /
(tuning.globeViewBlendEndM - tuning.globeViewBlendStartM);
const globeT = clamp(globeRawT, 0, 1); const globeT = clamp(globeRawT, 0, 1);
const globeEase = globeT * globeT * (3 - 2 * globeT); const globeEase = globeT * globeT * (3 - 2 * globeT);
const strength = 1 - globeEase; const strength = 1 - globeEase;
@@ -84,8 +95,10 @@ export function setAircraftRecessionParams(patch = {}) {
if (Number.isFinite(patch.startLimbRatio)) { if (Number.isFinite(patch.startLimbRatio)) {
next.startLimbRatio = clamp(patch.startLimbRatio, 0, 0.99); 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.scaleFloor))
if (Number.isFinite(patch.alphaFloor)) next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1); 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)) { if (Number.isFinite(patch.combinedAlphaFloor)) {
next.combinedAlphaFloor = clamp(patch.combinedAlphaFloor, 0.05, 1); next.combinedAlphaFloor = clamp(patch.combinedAlphaFloor, 0.05, 1);
} }
@@ -109,8 +122,10 @@ export function setAircraftRecessionParams(patch = {}) {
if (next.globeViewBlendStartM >= next.globeViewBlendEndM) { if (next.globeViewBlendStartM >= next.globeViewBlendEndM) {
next.globeViewBlendStartM = Math.max(1, next.globeViewBlendEndM - 1); next.globeViewBlendStartM = Math.max(1, next.globeViewBlendEndM - 1);
} }
if (Number.isFinite(patch.earthRadiusM)) next.earthRadiusM = Math.max(1, patch.earthRadiusM); if (Number.isFinite(patch.earthRadiusM))
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon); next.earthRadiusM = Math.max(1, patch.earthRadiusM);
if (Number.isFinite(patch.writeEpsilon))
next.writeEpsilon = Math.max(0, patch.writeEpsilon);
_params = next; _params = next;
return { ..._params }; return { ..._params };
} }
@@ -149,7 +164,10 @@ export function cameraLimbDistanceM(
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [params] * @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [params]
* @returns {{scale:number,alpha:number,limbRatio:number|null}} * @returns {{scale:number,alpha:number,limbRatio:number|null}}
*/ */
export function aircraftRecessionFactors({ cameraDistanceM, cameraHeightM }, params) { export function aircraftRecessionFactors(
{ cameraDistanceM, cameraHeightM },
params,
) {
const tuning = resolvedParams(params); const tuning = resolvedParams(params);
return aircraftRecessionFactorsResolved( return aircraftRecessionFactorsResolved(
cameraDistanceM, cameraDistanceM,
@@ -202,12 +220,17 @@ export function applyAircraftBillboardTreatment({
const alpha = baseAlpha * composedTreatment; const alpha = baseAlpha * composedTreatment;
let scaleWrites = 0; let scaleWrites = 0;
let alphaWrites = 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; billboard.scale = scale;
scaleWrites = 1; scaleWrites = 1;
} }
if (!Number.isFinite(billboard.color?.alpha) if (
|| Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon) { !Number.isFinite(billboard.color?.alpha) ||
Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon
) {
billboard.color = baseColor.withAlpha(alpha); billboard.color = baseColor.withAlpha(alpha);
alphaWrites = 1; alphaWrites = 1;
} }
@@ -228,11 +251,19 @@ export function applyAircraftBillboardTreatment({
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params] * @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
* @returns {number} One when a color write occurred, otherwise zero. * @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); const tuning = resolvedParams(params);
if (!model || !baseColor || !Number.isFinite(alpha)) return 0; if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
if (Number.isFinite(model.color?.alpha) if (
&& Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon) return 0; Number.isFinite(model.color?.alpha) &&
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon
)
return 0;
model.color = baseColor.withAlpha(alpha); model.color = baseColor.withAlpha(alpha);
return 1; return 1;
} }
+107 -37
View File
@@ -28,11 +28,39 @@ import { pointInRing } from './naturalEarthRegions.js';
/** Layers the engine understands, with the fields queries may reference. */ /** Layers the engine understands, with the fields queries may reference. */
export const ANALYST_LAYERS = { export const ANALYST_LAYERS = {
flights: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass'], flags: ['military', 'onGround'] }, flights: {
military: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'], flags: ['military', 'onGround'] }, numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
'ais-live-vessels': { numeric: ['speedKts', 'courseDeg'], text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'], flags: [] }, text: [
'local-firms': { numeric: ['frp'], text: ['confidence', 'satellite'], flags: [] }, 'callsign',
earthquakes: { numeric: ['magnitude', 'depthKm'], text: ['place'], flags: [] }, '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; const EARTH_R_KM = 6371;
@@ -42,8 +70,9 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
const d2r = Math.PI / 180; const d2r = Math.PI / 180;
const dLat = (lat2 - lat1) * d2r; const dLat = (lat2 - lat1) * d2r;
const dLon = (lon2 - lon1) * d2r; const dLon = (lon2 - lon1) * d2r;
const a = Math.sin(dLat / 2) ** 2 const a =
+ Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2; 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))); 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]; const got = r[field];
if (got === null || got === undefined) return false; if (got === null || got === undefined) return false;
switch (op) { switch (op) {
case 'gt': return Number(got) > Number(value); case 'gt':
case 'gte': return Number(got) >= Number(value); return Number(got) > Number(value);
case 'lt': return Number(got) < Number(value); case 'gte':
case 'lte': return Number(got) <= Number(value); return Number(got) >= Number(value);
case 'lt':
return Number(got) < Number(value);
case 'lte':
return Number(got) <= Number(value);
case 'eq': { 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(); return String(got).toLowerCase() === String(value).toLowerCase();
} }
case 'neq': return String(got).toLowerCase() !== String(value).toLowerCase(); case 'neq':
case 'contains': return String(got).toLowerCase().includes(String(value).toLowerCase()); return String(got).toLowerCase() !== String(value).toLowerCase();
default: return true; 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) { export function applyScope(records, scope, resolved) {
if (!scope || scope.kind === 'anywhere') return records; if (!scope || scope.kind === 'anywhere') return records;
if (scope.kind === 'region' && resolved?.ring) { if (scope.kind === 'region' && resolved?.ring) {
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon) return records.filter(
&& pointInRing(resolved.ring, r.lat, r.lon)); (r) =>
Number.isFinite(r.lat) &&
Number.isFinite(r.lon) &&
pointInRing(resolved.ring, r.lat, r.lon),
);
} }
if (scope.kind === 'radius' || scope.kind === 'view') { if (scope.kind === 'radius' || scope.kind === 'view') {
const c = resolved?.center; const c = resolved?.center;
const km = resolved?.km; const km = resolved?.km;
if (!c || !Number.isFinite(km)) return records; if (!c || !Number.isFinite(km)) return records;
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon) return records.filter(
&& haversineKm(c.lat, c.lon, r.lat, r.lon) <= km); (r) =>
Number.isFinite(r.lat) &&
Number.isFinite(r.lon) &&
haversineKm(c.lat, c.lon, r.lat, r.lon) <= km,
);
} }
return records; return records;
} }
@@ -108,9 +153,12 @@ export function createAnalystEngine(providers) {
let lastResult = null; let lastResult = null;
async function query(spec = {}) { async function query(spec = {}) {
const layers = (spec.followUp && lastResult) const layers =
spec.followUp && lastResult
? null // follow-up: re-filter the remembered set, no re-snapshot ? null // follow-up: re-filter the remembered set, no re-snapshot
: (Array.isArray(spec.layers) && spec.layers.length ? spec.layers : ['flights']); : Array.isArray(spec.layers) && spec.layers.length
? spec.layers
: ['flights'];
// 1) Source records // 1) Source records
let records; let records;
@@ -164,20 +212,23 @@ export function createAnalystEngine(providers) {
// camera made the two disagree — a parked, high-altitude camera answered // camera made the two disagree — a parked, high-altitude camera answered
// "46 within 250 km" while the panel showed a far larger contact-centred // "46 within 250 km" while the panel showed a far larger contact-centred
// count. Same question, two numbers. // count. Same question, two numbers.
const explicitCenter = scope.center && Number.isFinite(scope.center.lat) const explicitCenter =
? scope.center scope.center && Number.isFinite(scope.center.lat) ? scope.center : null;
: null; const subject = explicitCenter
const subject = explicitCenter ? null : providers.getContextSubject?.() || null; ? null
: providers.getContextSubject?.() || null;
const view = providers.getViewContext(); const view = providers.getViewContext();
// Only a subject that actually SUPPLIED the centre may name it. A // Only a subject that actually SUPPLIED the centre may name it. A
// subject present but without usable coordinates silently fell back to // subject present but without usable coordinates silently fell back to
// the camera while the label still read "within 250 km of <contact>" — // the camera while the label still read "within 250 km of <contact>" —
// a count centred on one place, reported as centred on another, with // a count centred on one place, reported as centred on another, with
// nothing in the payload to show which. // nothing in the payload to show which.
const subjectCenter = Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon) const subjectCenter =
Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
? { lat: subject.lat, lon: subject.lon } ? { lat: subject.lat, lon: subject.lon }
: null; : null;
const center = explicitCenter || subjectCenter || { lat: view.lat, lon: view.lon }; const center = explicitCenter ||
subjectCenter || { lat: view.lat, lon: view.lon };
resolvedScope = { center, km: Number(scope.km) || 100 }; resolvedScope = { center, km: Number(scope.km) || 100 };
if (subjectCenter) resolvedScope.centeredOn = subject.label || null; if (subjectCenter) resolvedScope.centeredOn = subject.label || null;
scopeNote = resolvedScope.centeredOn scopeNote = resolvedScope.centeredOn
@@ -188,7 +239,10 @@ export function createAnalystEngine(providers) {
: `within ${resolvedScope.km} km`; : `within ${resolvedScope.km} km`;
} else if (scope.kind === 'view') { } else if (scope.kind === 'view') {
const view = providers.getViewContext(); 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`; scopeNote = `view:${Math.round(view.viewRadiusKm)}km`;
scopeLabel = 'in view'; scopeLabel = 'in view';
} else { } else {
@@ -205,15 +259,22 @@ export function createAnalystEngine(providers) {
if (sortBy === 'distance') { if (sortBy === 'distance') {
const ref = resolvedScope?.center || providers.getViewContext(); const ref = resolvedScope?.center || providers.getViewContext();
for (const it of items) { for (const it of items) {
it.distanceKm = (Number.isFinite(it.lat) && Number.isFinite(it.lon)) it.distanceKm =
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) / 10 : null; Number.isFinite(it.lat) && Number.isFinite(it.lon)
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) /
10
: null;
} }
} }
if (sortBy) { if (sortBy) {
const dir = spec.sortDir === 'asc' ? 1 : -1; const dir = spec.sortDir === 'asc' ? 1 : -1;
items.sort((a, b) => (Number(a[sortBy]) - Number(b[sortBy])) * dir items.sort(
|| String(a.id).localeCompare(String(b.id))); (a, b) =>
if (sortBy === 'distance') items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9)); (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 limit = Math.max(1, Math.min(50, Number(spec.limit) || 10));
const top = items.slice(0, limit); const top = items.slice(0, limit);
@@ -223,7 +284,10 @@ export function createAnalystEngine(providers) {
count: items.length, count: items.length,
items: top, items: top,
truncated: items.length > top.length, truncated: items.length > top.length,
summary: summarize(items, sortBy && sortBy !== 'distance' ? sortBy : null), summary: summarize(
items,
sortBy && sortBy !== 'distance' ? sortBy : null,
),
scopeLabel, scopeLabel,
coverage: { coverage: {
layersQueried, layersQueried,
@@ -233,7 +297,9 @@ export function createAnalystEngine(providers) {
}, },
// Surfaced so the narration can name the centre it measured from rather // Surfaced so the narration can name the centre it measured from rather
// than implying a view-centred answer. // than implying a view-centred answer.
...(resolvedScope?.centeredOn ? { centeredOn: resolvedScope.centeredOn } : {}), ...(resolvedScope?.centeredOn
? { centeredOn: resolvedScope.centeredOn }
: {}),
}; };
lastResult = { items, coverage: result.coverage }; lastResult = { items, coverage: result.coverage };
return result; return result;
@@ -241,7 +307,11 @@ export function createAnalystEngine(providers) {
return { return {
query, query,
reset() { lastResult = null; }, reset() {
hasMemory() { return Boolean(lastResult); }, 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 } = {}) { export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
if (pinned) return Number.isFinite(sy); if (pinned) return Number.isFinite(sy);
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0) return false; if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0)
const safeTop = Math.min(CCTV_CARD_SAFE_TOP_MAX_PX, viewH * CCTV_CARD_SAFE_TOP_RATIO); return false;
const safeTop = Math.min(
CCTV_CARD_SAFE_TOP_MAX_PX,
viewH * CCTV_CARD_SAFE_TOP_RATIO,
);
return sy >= safeTop; return sy >= safeTop;
} }
@@ -126,11 +130,24 @@ export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
* @param {number} [options.limit] * @param {number} [options.limit]
* @returns {string[]} Accepted ids, nearest-first. * @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 : []) const valid = (Array.isArray(candidates) ? candidates : [])
.filter((c) => c && typeof c.id === 'string' && c.id .filter(
&& Number.isFinite(c.sx) && Number.isFinite(c.sy)) (c) =>
.sort((a, b) => (a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) || a.id.localeCompare(b.id)); 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 minSq = minSepPx * minSepPx;
const accepted = []; const accepted = [];
const acceptedIds = []; const acceptedIds = [];
@@ -140,7 +157,10 @@ export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX
for (let i = 0; i < accepted.length; i++) { for (let i = 0; i < accepted.length; i++) {
const dx = candidate.sx - accepted[i].sx; const dx = candidate.sx - accepted[i].sx;
const dy = candidate.sy - accepted[i].sy; 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; if (!clear) continue;
accepted.push(candidate); accepted.push(candidate);
@@ -165,15 +185,26 @@ export function cardScaleForAltitude(cameraHeightM) {
const h = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : 0; 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_FULL_M) return { scale: 1, alpha: 1 };
if (h <= CCTV_CARD_SCALE_MID_M) { 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 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 }; 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 t = Math.min(
const scale = CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t; 1,
if (h >= CCTV_CARD_FADE_END_M) return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 }; (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 }; 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 }; return { scale, alpha };
} }
@@ -192,17 +223,23 @@ export function cardScaleForAltitude(cameraHeightM) {
* @param {number} [input.sinceLastLaunchMs] - Ms since the last fetch launch. * @param {number} [input.sinceLastLaunchMs] - Ms since the last fetch launch.
* @returns {{mode:('burst'|'steady'), launch:boolean}} * @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) { if (coldFill) {
return { return {
mode: 'burst', mode: 'burst',
launch: inFlight < CCTV_CARD_FETCH_BURST_LIMIT launch:
&& sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS, inFlight < CCTV_CARD_FETCH_BURST_LIMIT &&
sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
}; };
} }
return { return {
mode: 'steady', 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) { export function applyFrameResult(prev, result, nowMs) {
const base = prev || createFrameSlot(); const base = prev || createFrameSlot();
if (result?.ok && result.frame) { 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 { return {
frame: base.frame, frame: base.frame,
@@ -278,11 +320,17 @@ export function frameFetchDue(slot, refreshMs, nowMs) {
* @param {number} [cap] * @param {number} [cap]
* @returns {string[]} Ids to drop from the cache. * @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 keep = new Set(keepIds);
const spare = (Array.isArray(slots) ? slots : []) const spare = (Array.isArray(slots) ? slots : [])
.filter((slot) => slot && typeof slot.id === 'string' && !keep.has(slot.id)) .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); const capacity = Math.max(0, cap - keep.size);
return spare.slice(capacity).map((slot) => slot.id); return spare.slice(capacity).map((slot) => slot.id);
} }
+165 -36
View File
@@ -74,7 +74,11 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
const toPlane = Cesium.Cartesian3.subtract(planeOrigin, rayOrigin, scratchW); const toPlane = Cesium.Cartesian3.subtract(planeOrigin, rayOrigin, scratchW);
const s = Cesium.Cartesian3.dot(toPlane, planeNormal) / denom; const s = Cesium.Cartesian3.dot(toPlane, planeNormal) / denom;
if (s < 0) return null; 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); 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) { export function ringAngle(hitPoint, center, basisA, basisB) {
const v = Cesium.Cartesian3.subtract(hitPoint, center, scratchW); 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). */ /** a*sa + b*sb (fresh Cartesian3). */
function combine2(a, sa, b, sb) { 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()); const t = Cesium.Cartesian3.multiplyByScalar(b, sb, new Cesium.Cartesian3());
return Cesium.Cartesian3.add(out, t, out); return Cesium.Cartesian3.add(out, t, out);
} }
@@ -165,7 +176,12 @@ function combine2(a, sa, b, sb) {
function viewAxesFor(headingDeg, pitchDeg, axes) { function viewAxesFor(headingDeg, pitchDeg, axes) {
const h = toRadians(headingDeg); const h = toRadians(headingDeg);
const p = toRadians(pitchDeg); 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 right = combine2(axes.east, Math.cos(h), axes.north, -Math.sin(h));
const view = combine2(forwardHoriz, Math.cos(p), axes.up, Math.sin(p)); const view = combine2(forwardHoriz, Math.cos(p), axes.up, Math.sin(p));
return { forwardHoriz, right, view }; return { forwardHoriz, right, view };
@@ -176,7 +192,12 @@ function ringPositions(center, radius, basisA, basisB) {
const positions = []; const positions = [];
for (let i = 0; i <= RING_SEGMENTS; i++) { for (let i = 0; i <= RING_SEGMENTS; i++) {
const angle = (i / RING_SEGMENTS) * 2 * Math.PI; 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)); positions.push(Cesium.Cartesian3.add(center, offset, offset));
} }
return positions; return positions;
@@ -202,7 +223,12 @@ function ringPositions(center, radius, basisA, basisB) {
* destroy: function(): void, isDragging: function(): boolean, * destroy: function(): void, isDragging: function(): boolean,
* isEnabled: function(): boolean}} * isEnabled: function(): boolean}}
*/ */
export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, endPatch }) { export function createCalibrationGizmo({
viewer,
getActiveRecord,
applyPatch,
endPatch,
}) {
let enabled = false; let enabled = false;
let drag = null; // { part, startCal, basePose, refs... } let drag = null; // { part, startCal, basePose, refs... }
let hoveredId = null; let hoveredId = null;
@@ -225,7 +251,8 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
function polylinePart(part, color, width, arrow = false) { function polylinePart(part, color, width, arrow = false) {
addEntity(part, { addEntity(part, {
...(arrow ? { ...(arrow
? {
position: Cesium.Cartesian3.ZERO, position: Cesium.Cartesian3.ZERO,
point: { point: {
pixelSize: 14, pixelSize: 14,
@@ -234,11 +261,14 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
outlineWidth: 2, outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY, disableDepthTestDistance: Number.POSITIVE_INFINITY,
}, },
} : {}), }
: {}),
polyline: { polyline: {
positions: [], positions: [],
width, 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 // Standard gizmo convention: parts behind geometry stay clearly
// visible, just dimmed (a street-level mount buries half the heading // visible, just dimmed (a street-level mount buries half the heading
// ring in sloped photogrammetry tiles — smoke-tested 2026-07-05). // 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 mount = positions.mount;
const axes = enuAxes(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 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; const arrowLen = radius * ARROW_LENGTH_FACTOR;
entities.get('ring-heading').polyline.positions = ringPositions(mount, radius, axes.north, axes.east); entities.get('ring-heading').polyline.positions = ringPositions(
entities.get('ring-pitch').polyline.positions = ringPositions(mount, radius, forwardHoriz, axes.up); mount,
const arrowTo = (dir) => [mount, Cesium.Cartesian3.add( radius,
mount, Cesium.Cartesian3.multiplyByScalar(dir, arrowLen, new Cesium.Cartesian3()), new Cesium.Cartesian3() 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 eastArrow = arrowTo(axes.east);
const northArrow = arrowTo(axes.north); const northArrow = arrowTo(axes.north);
const upArrow = arrowTo(axes.up); 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').polyline.positions = upArrow;
entities.get('move-up').position = upArrow[1]; entities.get('move-up').position = upArrow[1];
entities.get('handle-range').position = positions.capCenter; 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-l').position = Cesium.Cartesian3.midpoint(
entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint(positions.tr, positions.br, new Cesium.Cartesian3()); 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; for (const entity of entities.values()) entity.show = true;
} }
@@ -343,8 +407,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
return null; return null;
} }
if (typeof window !== 'undefined' && window.__gevGizmoDebug) { if (typeof window !== 'undefined' && window.__gevGizmoDebug) {
console.debug('[CCTV:gizmo] drillPick @', windowPosition?.x, windowPosition?.y, '→', console.debug(
JSON.stringify(results.map((r) => String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name)))); '[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) { for (const picked of results) {
const part = gizmoPartFrom(picked); const part = gizmoPartFrom(picked);
@@ -379,7 +452,8 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
for (const [name, entity] of entities.entries()) { for (const [name, entity] of entities.entries()) {
if (!entity.point) continue; if (!entity.point) continue;
const hot = hoveredId === `${GIZMO_ID_PREFIX}${name}`; 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' : ''); setCursor(hoveredId ? 'grab' : '');
} }
@@ -397,7 +471,11 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
const mount = Cesium.Cartesian3.clone(positions.mount); const mount = Cesium.Cartesian3.clone(positions.mount);
const capCenter = Cesium.Cartesian3.clone(positions.capCenter); const capCenter = Cesium.Cartesian3.clone(positions.capCenter);
const axes = enuAxes(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 startCal = { ...camera.calibration }; const startCal = { ...camera.calibration };
const basePose = { ...camera.basePose }; const basePose = { ...camera.basePose };
const state = { const state = {
@@ -405,7 +483,15 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
// mid-drag active-camera switch (voice select / auto-hop) onto a // mid-drag active-camera switch (voice select / auto-hop) onto a
// different camera — its basePose makes the captured offsets wrong. // different camera — its basePose makes the captured offsets wrong.
record, record,
part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view, part,
startCal,
basePose,
mount,
capCenter,
axes,
forwardHoriz,
right,
view,
effectiveRangeM: geometry.rangeM, effectiveRangeM: geometry.rangeM,
startAngle: null, startAngle: null,
startParam: null, startParam: null,
@@ -419,8 +505,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right); const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
if (!hit) return false; if (!hit) return false;
state.startAngle = ringAngle(hit, mount, forwardHoriz, axes.up); // 0 = level, + = up state.startAngle = ringAngle(hit, mount, forwardHoriz, axes.up); // 0 = level, + = up
} else if (part === 'move-east' || part === 'move-north' || part === 'move-up') { } else if (
const dir = part === 'move-east' ? axes.east : part === 'move-north' ? axes.north : axes.up; 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; state.axisDir = dir;
const t = closestParamOnAxis(ray.origin, ray.direction, mount, dir); const t = closestParamOnAxis(ray.origin, ray.direction, mount, dir);
if (t === null) return false; if (t === null) return false;
@@ -448,26 +543,49 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
function dragPatch(windowPosition) { function dragPatch(windowPosition) {
const ray = pickRay(windowPosition); const ray = pickRay(windowPosition);
if (!ray || !drag) return null; 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') { if (part === 'ring-heading') {
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, axes.up); const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, axes.up);
if (!hit) return null; if (!hit) return null;
const angle = ringAngle(hit, mount, axes.north, axes.east); 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') { if (part === 'ring-pitch') {
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right); const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
if (!hit) return null; if (!hit) return null;
const angle = ringAngle(hit, mount, forwardHoriz, axes.up); 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') { 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; if (t === null) return null;
const delta = t - drag.startParam; const delta = t - drag.startParam;
if (part === 'move-east') return { offsetEastM: startCal.offsetEastM + delta }; if (part === 'move-east')
if (part === 'move-north') return { offsetNorthM: startCal.offsetNorthM + delta }; return { offsetEastM: startCal.offsetEastM + delta };
if (part === 'move-north')
return { offsetNorthM: startCal.offsetNorthM + delta };
return { heightM: startCal.heightM + delta }; return { heightM: startCal.heightM + delta };
} }
if (part === 'handle-range') { if (part === 'handle-range') {
@@ -478,11 +596,16 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
if (part === 'handle-fov-l' || part === 'handle-fov-r') { if (part === 'handle-fov-l' || part === 'handle-fov-r') {
const hit = rayPlaneIntersect(ray.origin, ray.direction, capCenter, view); const hit = rayPlaneIntersect(ray.origin, ray.direction, capCenter, view);
if (!hit) return null; if (!hit) return null;
const lateral = Math.abs(Cesium.Cartesian3.dot( const lateral = Math.abs(
Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()), right Cesium.Cartesian3.dot(
)); Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()),
right,
),
);
const halfW = Math.max(0.5, lateral); 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 { fovDeg: fovDeg - basePose.fovDeg };
} }
return null; return null;
@@ -531,7 +654,13 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
return; return;
} }
const patch = dragPatch(event.endPosition); 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); if (patch) applyPatch(patch, drag.record);
return; 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}} * @returns {{fill: Cesium.Color, fillActive: Cesium.Color, line: Cesium.Color, lineActive: Cesium.Color}}
*/ */
export function viewshedColors(hueDeg) { export function viewshedColors(hueDeg) {
const hue = ((Number(hueDeg) % 360) + 360) % 360 / 360; const hue = (((Number(hueDeg) % 360) + 360) % 360) / 360;
return { return {
fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE), fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
fillActive: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE), 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}} * @returns {{positions: Float64Array, indices: Uint16Array}}
*/ */
export function frustumVolumeGeometryData(positions) { 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); const flat = new Float64Array(15);
pts.forEach((p, i) => { pts.forEach((p, i) => {
flat[i * 3] = p.x; 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). // apex=0, tl=1, tr=2, br=3, bl=4 — 4 side faces + far cap (2 triangles).
const indices = new Uint16Array([ const indices = new Uint16Array([
0, 1, 2, 0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1, 1, 2, 3, 1, 3, 4,
0, 2, 3,
0, 3, 4,
0, 4, 1,
1, 2, 3,
1, 3, 4,
]); ]);
return { positions: flat, indices }; return { positions: flat, indices };
} }
+1 -2
View File
@@ -21,7 +21,7 @@ export function nextCockpitNearContacts(
) { ) {
const previous = previousIds instanceof Set ? previousIds : new Set(); const previous = previousIds instanceof Set ? previousIds : new Set();
const addSq = Math.max(0, Number(addDistanceM) || 0) ** 2; 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(); const next = new Set();
for (const [id, distanceSq] of distancesSquared || []) { for (const [id, distanceSq] of distancesSquared || []) {
@@ -32,4 +32,3 @@ export function nextCockpitNearContacts(
return next; 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. * @returns {string} Canonical form, or '' when there is nothing to compare.
*/ */
export function canonicalizeContactId(value) { 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. * @param {string} [params.registration] Contact's registration, if any.
* @returns {number} A {@link CONTACT_MATCH_TIER} value; `NONE` when no match. * @returns {number} A {@link CONTACT_MATCH_TIER} value; `NONE` when no match.
*/ */
export function rankContactMatch({ query, hex = '', callsign = '', registration = '' }) { export function rankContactMatch({
const rawQuery = String(query ?? '').trim().toLowerCase(); query,
hex = '',
callsign = '',
registration = '',
}) {
const rawQuery = String(query ?? '')
.trim()
.toLowerCase();
if (!rawQuery) return CONTACT_MATCH_TIER.NONE; 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 // Callsigns carry no separators, so they compare raw — canonicalizing them
// would silently widen the long-standing callsign contract. // 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 canonicalQuery = canonicalizeContactId(rawQuery);
const canonicalRegistration = canonicalizeContactId(registration); const canonicalRegistration = canonicalizeContactId(registration);
@@ -74,11 +91,13 @@ export function rankContactMatch({ query, hex = '', callsign = '', registration
return CONTACT_MATCH_TIER.REGISTRATION_EXACT; 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)) { if (canonicalQuery && canonicalRegistration.startsWith(canonicalQuery)) {
return CONTACT_MATCH_TIER.REGISTRATION_PREFIX; 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)) { if (canonicalQuery && canonicalRegistration.includes(canonicalQuery)) {
return CONTACT_MATCH_TIER.REGISTRATION_SUBSTRING; return CONTACT_MATCH_TIER.REGISTRATION_SUBSTRING;
} }
+21 -9
View File
@@ -48,7 +48,9 @@ export function selectEntityContext(entity) {
store.selectedEntityId = contextId; store.selectedEntityId = contextId;
store.selectedAt = Date.now(); store.selectedAt = Date.now();
const record = store.entities.get(contextId); 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; return record;
} }
@@ -78,7 +80,8 @@ export function selectTrackedSubjectContext(metadata) {
const store = getContextStore(); const store = getContextStore();
const id = String(metadata.id); const id = String(metadata.id);
for (const [key, record] of store.entities) { 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. // Reuse the existing carrier so a per-poll refresh does not churn identity.
const carrier = store.entities.get(id)?.entity || { __gevContextId: id }; 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 * 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. * 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(); const store = getContextStore();
if (!store.selectedEntityId) return; if (!store.selectedEntityId) return;
const record = store.entities.get(store.selectedEntityId); const record = store.entities.get(store.selectedEntityId);
if (record?.layerId === layerId) { if (record?.layerId === layerId) {
store.selectedEntityId = null; store.selectedEntityId = null;
store.selectedAt = null; store.selectedAt = null;
window.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', { window.dispatchEvent(
new CustomEvent('gev:entity-selection-cleared', {
detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' }, detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' },
})); }),
);
} }
} }
@@ -166,16 +174,19 @@ export function clearSelectedEntityContextForLayer(layerId, { evicted = false }
export function removeEntityContextsForLayer(layerId, { retainIds } = {}) { export function removeEntityContextsForLayer(layerId, { retainIds } = {}) {
const store = getContextStore(); const store = getContextStore();
for (const [id, record] of store.entities) { 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)) { if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) {
store.selectedEntityId = null; store.selectedEntityId = null;
store.selectedAt = null; store.selectedAt = null;
// A viewport refresh dropped the record out from under the selection — // A viewport refresh dropped the record out from under the selection —
// the user did not deselect anything. // the user did not deselect anything.
window.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', { window.dispatchEvent(
new CustomEvent('gev:entity-selection-cleared', {
detail: { layerId, reason: 'evicted' }, detail: { layerId, reason: 'evicted' },
})); }),
);
} }
} }
@@ -183,6 +194,7 @@ export function isContextRecordActive(record, dataManager = null) {
if (!record) return false; if (!record) return false;
if (record.entity?.show === false) return false; if (record.entity?.show === false) return false;
if (record.dataSource && record.dataSource.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; return true;
} }
+3 -3
View File
@@ -185,8 +185,7 @@ export const DATA_CREDITS = [
}, },
{ {
key: 'gtfs-rt', key: 'gtfs-rt',
html: html: 'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
}, },
{ {
key: 'radio-browser', key: 'radio-browser',
@@ -299,7 +298,8 @@ export const NATURAL_EARTH_CREDIT = {
* @returns {{ key: string, html: string }} * @returns {{ key: string, html: string }}
*/ */
export function transitFeedCredit(feed) { export function transitFeedCredit(feed) {
const escape = (text) => String(text) const escape = (text) =>
String(text)
.replaceAll('&', '&amp;') .replaceAll('&', '&amp;')
.replaceAll('<', '&lt;') .replaceAll('<', '&lt;')
.replaceAll('>', '&gt;') .replaceAll('>', '&gt;')
+205 -56
View File
@@ -279,7 +279,10 @@ let _cockpitModeListener = null;
*/ */
export function initDetection(viewer, layers, onModeChange) { export function initDetection(viewer, layers, onModeChange) {
if (_cockpitModeListener && typeof window !== 'undefined') { if (_cockpitModeListener && typeof window !== 'undefined') {
window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener); window.removeEventListener(
'gev:cockpit-mode-changed',
_cockpitModeListener,
);
} }
_hostLane?.unregister?.(); _hostLane?.unregister?.();
_calloutLane?.unregister?.(); _calloutLane?.unregister?.();
@@ -323,7 +326,10 @@ export function initDetection(viewer, layers, onModeChange) {
/** Release the host lane and all retained detection runtime state. */ /** Release the host lane and all retained detection runtime state. */
export function destroyDetection() { export function destroyDetection() {
if (_cockpitModeListener && typeof window !== 'undefined') { if (_cockpitModeListener && typeof window !== 'undefined') {
window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener); window.removeEventListener(
'gev:cockpit-mode-changed',
_cockpitModeListener,
);
} }
_cockpitModeListener = null; _cockpitModeListener = null;
_cockpitActive = false; _cockpitActive = false;
@@ -397,7 +403,8 @@ export function setMode(modeLabel) {
_mode = MODE_OFF; _mode = MODE_OFF;
} else { } else {
const requestedDensity = defaultDensityForProfile(target); const requestedDensity = defaultDensityForProfile(target);
if (profileForDensity(_densityPct) !== target) _densityPct = requestedDensity; if (profileForDensity(_densityPct) !== target)
_densityPct = requestedDensity;
_mode = _modeForDensity(); _mode = _modeForDensity();
_lastNonOffMode = _mode; _lastNonOffMode = _mode;
} }
@@ -462,7 +469,10 @@ export function isDetectionSuspended() {
*/ */
export function setDetectionTuning(options = {}) { export function setDetectionTuning(options = {}) {
let modeChanged = false; 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); const nextDensity = canonicalizeDensity(options.densityPct);
if (nextDensity !== _densityPct) { if (nextDensity !== _densityPct) {
_densityPct = nextDensity; _densityPct = nextDensity;
@@ -477,7 +487,10 @@ export function setDetectionTuning(options = {}) {
} }
} }
if (options.allocationStrategy != null) { if (options.allocationStrategy != null) {
const nextStrategy = normalizeAllocationStrategy(options.allocationStrategy, _allocationStrategy); const nextStrategy = normalizeAllocationStrategy(
options.allocationStrategy,
_allocationStrategy,
);
if (nextStrategy !== _allocationStrategy) { if (nextStrategy !== _allocationStrategy) {
_allocationStrategy = nextStrategy; _allocationStrategy = nextStrategy;
_labelSolveDirty = true; _labelSolveDirty = true;
@@ -526,7 +539,9 @@ export function getDetectionDiagnostics() {
const result = JSON.parse(JSON.stringify(_lastDiagnostics)); const result = JSON.parse(JSON.stringify(_lastDiagnostics));
// Readback only: expose painted plates so pixel QA can exclude occluded // Readback only: expose painted plates so pixel QA can exclude occluded
// sprites/strokes. Nothing is copied on the production paint path. // 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; return result;
} }
@@ -535,19 +550,30 @@ function _publishDiagnostics() {
const dataset = _hostCanvas.dataset; const dataset = _hostCanvas.dataset;
dataset.profile = _lastDiagnostics.profile || 'OFF'; dataset.profile = _lastDiagnostics.profile || 'OFF';
dataset.densityPct = String(_lastDiagnostics.densityPct ?? _densityPct); dataset.densityPct = String(_lastDiagnostics.densityPct ?? _densityPct);
dataset.allocationStrategy = _lastDiagnostics.allocationStrategy || _allocationStrategy; dataset.allocationStrategy =
_lastDiagnostics.allocationStrategy || _allocationStrategy;
dataset.solveRevision = String(_lastDiagnostics.solveRevision ?? 0); 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.labelsByLayer = JSON.stringify(_lastDiagnostics.labelsByLayer || {});
dataset.entitlementByLayer = JSON.stringify(_lastDiagnostics.entitlementByLayer || {}); dataset.entitlementByLayer = JSON.stringify(
_lastDiagnostics.entitlementByLayer || {},
);
dataset.labeledKeys = JSON.stringify(_lastDiagnostics.labeledKeys || []); dataset.labeledKeys = JSON.stringify(_lastDiagnostics.labeledKeys || []);
dataset.labelAlphaByKey = JSON.stringify(_lastDiagnostics.labelAlphaByKey || {}); dataset.labelAlphaByKey = JSON.stringify(
dataset.bracketOpacityCounts = JSON.stringify(_lastDiagnostics.bracketOpacityCounts || {}); _lastDiagnostics.labelAlphaByKey || {},
);
dataset.bracketOpacityCounts = JSON.stringify(
_lastDiagnostics.bracketOpacityCounts || {},
);
dataset.keyholeRadius = String(_lastDiagnostics.keyholeRadius ?? 0); dataset.keyholeRadius = String(_lastDiagnostics.keyholeRadius ?? 0);
dataset.keyholeFeatherPx = String(_lastDiagnostics.keyholeFeatherPx ?? 0); dataset.keyholeFeatherPx = String(_lastDiagnostics.keyholeFeatherPx ?? 0);
dataset.demandByLayer = JSON.stringify(_lastDiagnostics.demandByLayer || {}); dataset.demandByLayer = JSON.stringify(_lastDiagnostics.demandByLayer || {});
dataset.cohortByLayer = JSON.stringify(_lastDiagnostics.cohortByLayer || {}); 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.observationCount = String(_lastDiagnostics.observationCount ?? 0);
dataset.selectedCount = String(_lastDiagnostics.selectedCount ?? 0); dataset.selectedCount = String(_lastDiagnostics.selectedCount ?? 0);
dataset.fadingCount = String(_lastDiagnostics.fadingCount ?? 0); dataset.fadingCount = String(_lastDiagnostics.fadingCount ?? 0);
@@ -632,7 +658,11 @@ function _applyModeState() {
_labelArbiter.clear(); _labelArbiter.clear();
_lastLabelSolveAt = 0; _lastLabelSolveAt = 0;
_labelSolveDirty = true; _labelSolveDirty = true;
_lastSolveSnapshot = { demandByLayer: {}, cohortByLayer: {}, cohortCount: 0 }; _lastSolveSnapshot = {
demandByLayer: {},
cohortByLayer: {},
cohortCount: 0,
};
} else { } else {
_enableTime = _nowMs(); // restart the subtle fade-in on (re)activation _enableTime = _nowMs(); // restart the subtle fade-in on (re)activation
_syncSurfaceVisibility(); _syncSurfaceVisibility();
@@ -665,8 +695,10 @@ function _shouldPaintDetectionLane(frame) {
}); });
if (decision.skip) { if (decision.skip) {
_throttleSkipCount++; _throttleSkipCount++;
if (_lastDiagnostics) _lastDiagnostics.throttleSkipCount = _throttleSkipCount; if (_lastDiagnostics)
if (decision.requestFollowUp) governorRequestRender('detection-paint-skipped'); _lastDiagnostics.throttleSkipCount = _throttleSkipCount;
if (decision.requestFollowUp)
governorRequestRender('detection-paint-skipped');
return false; return false;
} }
return true; return true;
@@ -687,8 +719,13 @@ function _paintDetectionLane(frame) {
_labelSolveDirty = true; _labelSolveDirty = true;
} }
const start = performance.now(); const start = performance.now();
const result = _drawOverlay(frame) const result = _drawOverlay(frame) || {
|| { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false }; didSolve: false,
solveMs: 0,
fadingCount: 0,
animatingCount: 0,
solvePending: false,
};
_lastRenderMs = performance.now() - start; _lastRenderMs = performance.now() - start;
_lastSolveMs = result.solveMs || 0; _lastSolveMs = result.solveMs || 0;
_lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs); _lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs);
@@ -705,7 +742,8 @@ function _paintDetectionLane(frame) {
// exactly what makes it safe where the old blanket hold was not. On a parked // 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 // scene with nothing animating it asks for nothing, and the governor stays
// idle. See `detectionRenderDemand.js`. // idle. See `detectionRenderDemand.js`.
if (detectionNeedsFollowUpFrame({ if (
detectionNeedsFollowUpFrame({
active: _mode !== MODE_OFF && !_suspended, active: _mode !== MODE_OFF && !_suspended,
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here // 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 // is what dropped the terminal frame of a fade (paint at 219 ms, policy at
@@ -715,7 +753,8 @@ function _paintDetectionLane(frame) {
fadeMs: FADE_MS, fadeMs: FADE_MS,
animatingLabelCount: result.animatingCount || 0, animatingLabelCount: result.animatingCount || 0,
solvePending: result.solvePending === true, solvePending: result.solvePending === true,
})) { })
) {
governorRequestRender('detection-animation'); governorRequestRender('detection-animation');
} }
} }
@@ -804,7 +843,8 @@ function _drawModeBanner(visibleCount, sampledCount) {
if (!_debugBanner) return; if (!_debugBanner) return;
const mode = MODE_LABELS[_mode]; const mode = MODE_LABELS[_mode];
const paused = _suspended ? ` PAUSED:${_suspendReason || 'transition'}` : ''; 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}`; const text = `${mode} VIS:${visibleCount} SRC:${sampledCount} DENS:${_densityPct}% ${allocation} ${_lastRenderMs.toFixed(1)}ms${paused}`;
_ctx.fillStyle = _theme.labelBg; _ctx.fillStyle = _theme.labelBg;
_ctx.fillRect(12, 12, Math.max(180, _ctx.measureText(text).width + 12), 16); _ctx.fillRect(12, 12, Math.max(180, _ctx.measureText(text).width + 12), 16);
@@ -900,16 +940,27 @@ function _buildLabelPlacements(
const placements = []; const placements = [];
for (const placement of raw) { for (const placement of raw) {
const { cardX, cardY } = placement; 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; continue;
} }
const cardRect = { x: cardX, y: cardY, w: card.w, h: card.h }; const cardRect = { x: cardX, y: cardY, w: card.w, h: card.h };
if (rectIntersectsAny(cardRect, occlusionRects)) continue; if (rectIntersectsAny(cardRect, occlusionRects)) continue;
const leadToX = placement.leadToSide.endsWith('E') ? cardX + card.w : cardX; 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 centerX = cardX + card.w * 0.5;
const centerY = cardY + card.h * 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; if (radialAlpha <= 0) continue;
placements.push({ placements.push({
...placement, ...placement,
@@ -940,18 +991,37 @@ function _buildLabelPlacements(
*/ */
function _stashCallout(entry, acquireFade, keyhole) { function _stashCallout(entry, acquireFade, keyhole) {
const { candidate, placement, temporalAlpha } = entry; 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; const alpha = acquireFade * temporalAlpha * radialAlpha;
if (alpha <= 0.001) return; if (alpha <= 0.001) return;
let row = _calloutPool[_calloutCount]; let row = _calloutPool[_calloutCount];
if (!row) { if (!row) {
row = { row = {
x: 0, y: 0, w: 0, h: 0, x: 0,
primaryX: 0, microX: 0, baseline: 0, y: 0,
leadFromX: 0, leadFromY: 0, leadToX: 0, leadToY: 0, w: 0,
plate: '', plateScale: 1, accent: '', label: '', primary: '', micro: '', h: 0,
font: FONT, microFont: MICRO_FONT, alpha: 1, 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; _calloutPool[_calloutCount] = row;
} }
@@ -996,7 +1066,13 @@ function _paintCalloutLane(frame) {
if (!ctx) return; if (!ctx) return;
for (const bands of _transitBracketPaths.values()) { for (const bands of _transitBracketPaths.values()) {
for (const entry of bands) { 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'; ctx.textAlign = 'left';
@@ -1021,7 +1097,14 @@ function _plateScaleForBackdrop(skyFactor) {
} }
/** Materialize one bounded rich-callout candidate for the existing arbiter. */ /** 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 primary = obj._candidatePrimary;
const micro = obj._candidateMicro; const micro = obj._candidateMicro;
const card = measureTrackLabel(primary, micro, _charWidth); const card = measureTrackLabel(primary, micro, _charWidth);
@@ -1042,7 +1125,10 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
for (const placement of placements) { for (const placement of placements) {
centerDistance = Math.min( centerDistance = Math.min(
centerDistance, 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); 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 // Costed here rather than in the object sweep: the sweep walks every
// observation in view, while this runs only for callouts that actually // observation in view, while this runs only for callouts that actually
// placed — a budgeted handful per frame. // placed — a budgeted handful per frame.
plateScale: _plateScaleForBackdrop(skyBackdropFactor(cameraPosition, obj.position)), plateScale: _plateScaleForBackdrop(
skyBackdropFactor(cameraPosition, obj.position),
),
primary, primary,
micro, micro,
hasMicro: card.hasMicro, hasMicro: card.hasMicro,
@@ -1079,7 +1167,13 @@ function _drawOverlay(frame) {
_transitBracketPaths.clear(); _transitBracketPaths.clear();
const { width, height } = frame; const { width, height } = frame;
if (!_ctx || width <= 0 || height <= 0) { 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 // 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. // animation's final state is the same frame that ends its demand.
const now = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(); const now = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
const bracketPresentationOpacity = detectionBracketOpacity(_cockpitActive); 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 calloutOcclusionRects = frame.uiRects;
const selectedIdentities = shouldSolve const selectedIdentities = shouldSolve
? _labelArbiter.liveIdentities({ includeFading: false, now }) ? _labelArbiter.liveIdentities({ includeFading: false, now })
: null; : null;
const renderIdentities = _labelArbiter.liveIdentities({ includeFading: true, now }); const renderIdentities = _labelArbiter.liveIdentities({
includeFading: true,
now,
});
const objects = _collectDetectableObjects(); const objects = _collectDetectableObjects();
const sampledCount = objects.length; const sampledCount = objects.length;
if (objects.length === 0) { if (objects.length === 0) {
@@ -1116,7 +1214,9 @@ function _drawOverlay(frame) {
profile: MODE_LABELS[_mode], profile: MODE_LABELS[_mode],
densityPct: _densityPct, densityPct: _densityPct,
allocationStrategy: _allocationStrategy, allocationStrategy: _allocationStrategy,
viewScale: viewScaleForAltitude(_viewer?.camera?.positionCartographic?.height), viewScale: viewScaleForAltitude(
_viewer?.camera?.positionCartographic?.height,
),
candidateCount: 0, candidateCount: 0,
observationCount: 0, observationCount: 0,
visibleCount: 0, visibleCount: 0,
@@ -1144,7 +1244,13 @@ function _drawOverlay(frame) {
// whole mechanism exists to remove. // whole mechanism exists to remove.
_lastLabelSolveAt = now; _lastLabelSolveAt = now;
_labelSolveDirty = false; _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 // 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 colorFor = (key) => (tiers && tiers[key]) || _theme.line;
const bracketPaths = new Map(); const bracketPaths = new Map();
const pathFor = (map, color, alpha) => { 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); let bands = map.get(color);
if (!bands) { if (!bands) {
bands = new Array(BRACKET_ALPHA_STEPS + 1); bands = new Array(BRACKET_ALPHA_STEPS + 1);
@@ -1217,8 +1326,10 @@ function _drawOverlay(frame) {
const clipW = vp3 * px + vp7 * py + vp11 * pz + vp15; const clipW = vp3 * px + vp7 * py + vp11 * pz + vp15;
if (clipW <= 0) continue; if (clipW <= 0) continue;
const invW = 1 / clipW; const invW = 1 / clipW;
const sx = ((vp0 * px + vp4 * py + vp8 * pz + vp12) * invW * 0.5 + 0.5) * width; const sx =
const sy = (0.5 - (vp1 * px + vp5 * py + vp9 * pz + vp13) * invW * 0.5) * height; ((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 // Tracked objects (skipLabel) get larger boxes. AIR reticles scale with the
// plane's on-screen size (same scaleByDistance curve as the billboards) so // plane's on-screen size (same scaleByDistance curve as the billboards) so
@@ -1229,7 +1340,10 @@ function _drawOverlay(frame) {
if (obj.type === 'AIR') { if (obj.type === 'AIR') {
const bscale = nearFarScale( const bscale = nearFarScale(
Cesium.Cartesian3.distance(camPos, obj.position), 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); halfW = _clamp((isTracked ? 14 : 9) * bscale, 7, 48);
halfH = _clamp((isTracked ? 11 : 7) * bscale, 5, 38); halfH = _clamp((isTracked ? 11 : 7) * bscale, 5, 38);
@@ -1241,18 +1355,34 @@ function _drawOverlay(frame) {
halfH = _mode === MODE_DENSE ? (isTracked ? 22 : 7) : 10; halfH = _mode === MODE_DENSE ? (isTracked ? 22 : 7) : 10;
} }
// Viewport bounds check with padding // 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. // 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. // The bracket follows the same linear radial keyhole fade as its callout.
const color = colorFor(resolveTier(obj)); const color = colorFor(resolveTier(obj));
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole); const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
const bracketAlpha = detectionBracketAlpha(obj.type, keyholeAlpha, keyholeOutsideOpacity); const bracketAlpha = detectionBracketAlpha(
obj.type,
keyholeAlpha,
keyholeOutsideOpacity,
);
if (bracketAlpha > 0) { if (bracketAlpha > 0) {
const transit = obj.tier?.startsWith('transit_'); 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++; visibleCount++;
if (obj.type === 'AIR') aircraftBracketSectors[detectionHorizontalSector(sx, width)]++; if (obj.type === 'AIR')
aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
if (bracketAlpha >= 1) bracketOpacityCounts.full++; if (bracketAlpha >= 1) bracketOpacityCounts.full++;
else bracketOpacityCounts.partial++; else bracketOpacityCounts.partial++;
} else { } else {
@@ -1286,8 +1416,10 @@ function _drawOverlay(frame) {
if (shouldSolve && keyholeAlpha > 0) { if (shouldSolve && keyholeAlpha > 0) {
demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1); demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1);
if (!cohortBuilders.has(layerId)) cohortBuilders.set(layerId, new BoundedCohort(256)); if (!cohortBuilders.has(layerId))
const incumbent = selectedIdentities?.get(layerId)?.has(sourceId) || false; cohortBuilders.set(layerId, new BoundedCohort(256));
const incumbent =
selectedIdentities?.get(layerId)?.has(sourceId) || false;
cohortBuilders.get(layerId).consider(obj, incumbent); cohortBuilders.get(layerId).consider(obj, incumbent);
} }
@@ -1301,13 +1433,17 @@ function _drawOverlay(frame) {
calloutOcclusionRects, calloutOcclusionRects,
camPos, 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 altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
const collectiveBudget = labelBudgetFor(altitude, _densityPct); 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 didSolve = false;
let solveMs = 0; let solveMs = 0;
if (shouldSolve) { if (shouldSolve) {
@@ -1320,7 +1456,9 @@ function _drawOverlay(frame) {
const solveCandidates = []; const solveCandidates = [];
const cohortByLayer = {}; const cohortByLayer = {};
for (const [layerId, builder] of cohortBuilders) { 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) { for (const obj of cohort) {
const key = _detectionKey(layerId, obj._cohortSourceId); const key = _detectionKey(layerId, obj._cohortSourceId);
let candidate = candidateMap.get(key); let candidate = candidateMap.get(key);
@@ -1338,7 +1476,9 @@ function _drawOverlay(frame) {
} }
if (candidate) solveCandidates.push(candidate); 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(); const solveStarted = performance.now();
_labelArbiter.solve(solveCandidates, { _labelArbiter.solve(solveCandidates, {
@@ -1419,14 +1559,17 @@ function _drawOverlay(frame) {
labelsByLayer: arbiterDiagnostics.labelsByLayer || {}, labelsByLayer: arbiterDiagnostics.labelsByLayer || {},
entitlementByLayer: arbiterDiagnostics.quotas || {}, entitlementByLayer: arbiterDiagnostics.quotas || {},
labeledKeys: Array.from(_labelArbiter.selectedKeys), labeledKeys: Array.from(_labelArbiter.selectedKeys),
labelAlphaByKey: Object.fromEntries(renderEntries.map((entry) => [ labelAlphaByKey: Object.fromEntries(
renderEntries.map((entry) => [
entry.candidate.key, entry.candidate.key,
entry.temporalAlpha * keyholeLabelAlphaFromGeometry( entry.temporalAlpha *
keyholeLabelAlphaFromGeometry(
entry.placement.centerX, entry.placement.centerX,
entry.placement.centerY, entry.placement.centerY,
keyhole, keyhole,
), ),
])), ]),
),
solveRevision: _labelArbiter.solveRevision, solveRevision: _labelArbiter.solveRevision,
keyholeRadius: keyhole.radius, keyholeRadius: keyhole.radius,
keyholeFeatherPx: keyhole.featherPx, keyholeFeatherPx: keyhole.featherPx,
@@ -1451,5 +1594,11 @@ function _drawOverlay(frame) {
_drawModeBanner(visibleCount, sampledCount); _drawModeBanner(visibleCount, sampledCount);
// `_labelSolveDirty` surviving a paint means the solve was owed and did not // `_labelSolveDirty` surviving a paint means the solve was owed and did not
// run — the frame that carried the request cannot be the last one. // 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. */ /** Negative means a is a better deterministic contender than b. */
export function compareCohortContenders(a, 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; 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; 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; if (hashDelta) return hashDelta;
return String(a?._cohortSourceId).localeCompare(String(b?._cohortSourceId)); return String(a?._cohortSourceId).localeCompare(String(b?._cohortSourceId));
} }
@@ -39,8 +42,14 @@ function isWorse(a, b) {
*/ */
export class BoundedCohort { export class BoundedCohort {
constructor(maxSize = DEFAULT_MAX_COHORT, hardMax = DEFAULT_MAX_COHORT) { constructor(maxSize = DEFAULT_MAX_COHORT, hardMax = DEFAULT_MAX_COHORT) {
const ceiling = Math.max(1, Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT)); const ceiling = Math.max(
this.maxSize = Math.max(1, Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT))); 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.incumbents = [];
this.heap = []; this.heap = [];
} }
@@ -62,11 +71,20 @@ export class BoundedCohort {
} }
values(limit = this.maxSize) { values(limit = this.maxSize) {
const cap = Math.max(0, Math.min(this.maxSize, Math.floor(Number(limit) || 0))); const cap = Math.max(
const incumbents = this.incumbents.slice().sort(compareCohortContenders).slice(0, cap); 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); const remaining = Math.max(0, cap - incumbents.length);
if (remaining === 0) return incumbents; 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); return incumbents.concat(contenders);
} }
@@ -75,7 +93,10 @@ export class BoundedCohort {
while (current > 0) { while (current > 0) {
const parent = Math.floor((current - 1) / 2); const parent = Math.floor((current - 1) / 2);
if (!isWorse(this.heap[current], this.heap[parent])) break; 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; current = parent;
} }
} }
@@ -86,10 +107,18 @@ export class BoundedCohort {
const left = current * 2 + 1; const left = current * 2 + 1;
const right = left + 1; const right = left + 1;
let worst = current; let worst = current;
if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst])) worst = left; if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst]))
if (right < this.heap.length && isWorse(this.heap[right], this.heap[worst])) worst = right; worst = left;
if (
right < this.heap.length &&
isWorse(this.heap[right], this.heap[worst])
)
worst = right;
if (worst === current) return; 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; current = worst;
} }
} }
+15 -8
View File
@@ -211,11 +211,14 @@ export function measureTrackLabel(primary, micro, charWidth) {
*/ */
export function rectIntersectsAny(rect, obstacles, padding = 0) { export function rectIntersectsAny(rect, obstacles, padding = 0) {
if (!rect || !Array.isArray(obstacles)) return false; if (!rect || !Array.isArray(obstacles)) return false;
return obstacles.some((obstacle) => obstacle return obstacles.some(
&& rect.x < obstacle.x + obstacle.w + padding (obstacle) =>
&& rect.x + rect.w + padding > obstacle.x obstacle &&
&& rect.y < obstacle.y + obstacle.h + padding rect.x < obstacle.x + obstacle.w + padding &&
&& rect.y + rect.h + padding > obstacle.y); 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); return nearValue + t * (farValue - nearValue);
} }
/** Transit keeps literal mode colour on the normal-composite surface. */ /** Transit keeps literal mode colour on the normal-composite surface. */
export function paintTransitBracket(ctx, path, color, alpha = 1) { export function paintTransitBracket(ctx, path, color, alpha = 1) {
ctx.save?.(); 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. */ /** Centre 1.25 px strokes on pixels, so their cores are fully covered. */
export function appendTransitBracket(sink, sx, sy, halfW, halfH) { export function appendTransitBracket(sink, sx, sy, halfW, halfH) {
appendCornerBracket(sink, Math.floor(sx) + 0.5, Math.floor(sy) + 0.5, appendCornerBracket(
Math.round(halfW), Math.round(halfH)); 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 DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]);
export const ALLOCATION_ELASTIC = 'ELASTIC'; export const ALLOCATION_ELASTIC = 'ELASTIC';
export const ALLOCATION_WEIGHTED = 'WEIGHTED'; 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 // The keyhole's default 1% outside opacity is appropriate for text, but it
// made side aircraft brackets effectively disappear while the same contacts // made side aircraft brackets effectively disappear while the same contacts
@@ -71,10 +74,16 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
: AIRCRAFT_BRACKET_FLOOR_ANCHOR; : AIRCRAFT_BRACKET_FLOOR_ANCHOR;
if (outside <= 0) return 0; if (outside <= 0) return 0;
if (outside <= AIRCRAFT_BRACKET_FLOOR_ANCHOR) { 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); const progress =
return AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * 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. * the default, so a caller that does not know it reproduces the shipped look.
* @returns {number} Paint alpha, 0-1. * @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)); const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0));
if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha; if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha;
return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha); return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha);
@@ -97,7 +110,7 @@ export function detectionHorizontalSector(screenX, viewportWidth) {
const x = Number(screenX); const x = Number(screenX);
if (!(width > 0) || !Number.isFinite(x)) return 'front'; if (!(width > 0) || !Number.isFinite(x)) return 'front';
if (x < width / 3) return 'left'; if (x < width / 3) return 'left';
if (x > width * 2 / 3) return 'right'; if (x > (width * 2) / 3) return 'right';
return 'front'; return 'front';
} }
@@ -140,19 +153,29 @@ export function defaultDensityForProfile(profile) {
/** Normalize current and legacy profile labels. OFF remains an enabled-state value. */ /** Normalize current and legacy profile labels. OFF remains an enabled-state value. */
export function normalizeProfile(profile) { export function normalizeProfile(profile) {
const raw = String(profile || '').trim().toUpperCase(); const raw = String(profile || '')
.trim()
.toUpperCase();
if (raw === 'OFF') return 'OFF'; if (raw === 'OFF') return 'OFF';
if (raw === 'SPARSE' || raw === 'SURVEY') return 'SPARSE'; if (raw === 'SPARSE' || raw === 'SURVEY') return 'SPARSE';
if (raw === 'BALANCED' || raw === 'NORMAL') return 'BALANCED'; 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; return null;
} }
/** Normalize the user-selectable layer allocation strategy. */ /** Normalize the user-selectable layer allocation strategy. */
export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELASTIC) { export function normalizeAllocationStrategy(
const raw = String(strategy || '').trim().toUpperCase(); strategy,
fallback = ALLOCATION_ELASTIC,
) {
const raw = String(strategy || '')
.trim()
.toUpperCase();
if (ALLOCATION_STRATEGIES.includes(raw)) return raw; if (ALLOCATION_STRATEGIES.includes(raw)) return raw;
const normalizedFallback = String(fallback || '').trim().toUpperCase(); const normalizedFallback = String(fallback || '')
.trim()
.toUpperCase();
return ALLOCATION_STRATEGIES.includes(normalizedFallback) return ALLOCATION_STRATEGIES.includes(normalizedFallback)
? normalizedFallback ? normalizedFallback
: ALLOCATION_ELASTIC; : ALLOCATION_ELASTIC;
@@ -160,7 +183,9 @@ export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELAS
/** Classify camera altitude into the shared label-budget view scale. */ /** Classify camera altitude into the shared label-budget view scale. */
export function viewScaleForAltitude(altitudeM) { 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 < 1200) return 'street';
if (altitude < 4500) return 'city'; if (altitude < 4500) return 'city';
if (altitude < 20000) return 'metro'; 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. * density was below 75; legacy Sparse remains inside the Sparse band.
*/ */
export function migrateDetectionState(mode, densityPct, fallbackDensity = 50) { export function migrateDetectionState(mode, densityPct, fallbackDensity = 50) {
const rawMode = String(mode || '').trim().toUpperCase(); const rawMode = String(mode || '')
.trim()
.toUpperCase();
const normalized = normalizeProfile(rawMode); const normalized = normalizeProfile(rawMode);
if (normalized === 'OFF') { if (normalized === 'OFF') {
const supplied = Number.isFinite(Number(densityPct)) ? densityPct : fallbackDensity; const supplied = Number.isFinite(Number(densityPct))
? densityPct
: fallbackDensity;
const stop = canonicalizeDensity(supplied, 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') { if (rawMode === 'PANOPTIC' || rawMode === 'GOD' || rawMode === 'ON') {
const density = Number.isFinite(Number(densityPct)) ? Math.max(75, Number(densityPct)) : 75; const density = Number.isFinite(Number(densityPct))
return { enabled: true, profile: 'DENSE', densityPct: canonicalizeDensity(density) }; ? Math.max(75, Number(densityPct))
: 75;
return {
enabled: true,
profile: 'DENSE',
densityPct: canonicalizeDensity(density),
};
} }
if (rawMode === 'SPARSE' || rawMode === 'SURVEY') { 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); const stop = canonicalizeDensity(density, 25);
return { enabled: true, profile: 'SPARSE', densityPct: stop }; return { enabled: true, profile: 'SPARSE', densityPct: stop };
} }
if (normalized === 'BALANCED' || normalized === 'DENSE') { if (normalized === 'BALANCED' || normalized === 'DENSE') {
const supplied = Number.isFinite(Number(densityPct)) ? canonicalizeDensity(densityPct) : defaultDensityForProfile(normalized); const supplied = Number.isFinite(Number(densityPct))
const stop = profileForDensity(supplied) === normalized ? supplied : defaultDensityForProfile(normalized); ? canonicalizeDensity(densityPct)
: defaultDensityForProfile(normalized);
const stop =
profileForDensity(supplied) === normalized
? supplied
: defaultDensityForProfile(normalized);
return { enabled: true, profile: normalized, densityPct: stop }; return { enabled: true, profile: normalized, densityPct: stop };
} }
const stop = canonicalizeDensity(densityPct, fallbackDensity); const stop = canonicalizeDensity(densityPct, fallbackDensity);
-1
View File
@@ -10,4 +10,3 @@ export const COCKPIT_BRACKET_OPACITY = 0.45;
export function detectionBracketOpacity(cockpitActive) { export function detectionBracketOpacity(cockpitActive) {
return cockpitActive === true ? COCKPIT_BRACKET_OPACITY : 1; return cockpitActive === true ? COCKPIT_BRACKET_OPACITY : 1;
} }
+4 -3
View File
@@ -158,9 +158,10 @@ export function detectionPaintSkipDecision({
frameCount, frameCount,
thresholdMs = DETECTION_PAINT_SKIP_THRESHOLD_MS, thresholdMs = DETECTION_PAINT_SKIP_THRESHOLD_MS,
} = {}) { } = {}) {
const skip = !layoutChanged const skip =
&& Number(lastPaintMs) > thresholdMs !layoutChanged &&
&& Number(frameCount) % 2 !== 0; Number(lastPaintMs) > thresholdMs &&
Number(frameCount) % 2 !== 0;
return { skip, requestFollowUp: skip }; return { skip, requestFollowUp: skip };
} }
+3 -1
View File
@@ -24,7 +24,9 @@
* @returns {number} Heading in degrees [0..360), or NaN if unrecognized. * @returns {number} Heading in degrees [0..360), or NaN if unrecognized.
*/ */
export function directionToHeading(value, allowBare = false) { export function directionToHeading(value, allowBare = false) {
const text = String(value || '').trim().toUpperCase(); const text = String(value || '')
.trim()
.toUpperCase();
if (!text) return NaN; if (!text) return NaN;
// Explicit travel/intercardinal forms — safe on free-form text (a street // Explicit travel/intercardinal forms — safe on free-form text (a street
// name almost never contains "NORTHBOUND" or a lone "NB" token). // 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 hhmm = String(time ?? '0000').padStart(4, '0');
const hours = Number(hhmm.slice(0, 2)); const hours = Number(hhmm.slice(0, 2));
const minutes = Number(hhmm.slice(2, 4)); const minutes = Number(hhmm.slice(2, 4));
const valid = Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day) const valid =
&& Number.isFinite(hours) && Number.isFinite(minutes); 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; const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
cache.set(key, ms); cache.set(key, ms);
return ms; return ms;
+11 -5
View File
@@ -12,10 +12,14 @@ export const FIRMS_OVERLAY_SOURCE_ID = 'firms';
export function fireDetectionKey(fire) { export function fireDetectionKey(fire) {
const lat = Number.isFinite(fire?.lat) ? fire.lat.toFixed(4) : 'x'; const lat = Number.isFinite(fire?.lat) ? fire.lat.toFixed(4) : 'x';
const lon = Number.isFinite(fire?.lon) ? fire.lon.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 acq =
const source = satelliteShortName(fire?.satellite) Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? String(fire.acqMs) : '0';
|| String(fire?.sensor || '').trim().toUpperCase() const source =
|| 'x'; satelliteShortName(fire?.satellite) ||
String(fire?.sensor || '')
.trim()
.toUpperCase() ||
'x';
return `firms:${lat}:${lon}:${acq}:${source}`; return `firms:${lat}:${lon}:${acq}:${source}`;
} }
@@ -37,7 +41,9 @@ export function accentForSeverity(stopName) {
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */ /** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
export function satelliteShortName(satellite) { 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 === 'N20' || s === 'NOAA-20') return 'N20';
if (s === 'N21' || s === 'NOAA-21') return 'N21'; if (s === 'N21' || s === 'NOAA-21') return 'N21';
if (s === 'N' || s === 'NPP' || s === 'SUOMI NPP') return 'SNPP'; 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; if (!Array.isArray(values) || values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b); const sorted = [...values].sort((a, b) => a - b);
const mid = sorted.length >> 1; 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. */ /** 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 roadCount = Array.isArray(roads) ? roads.length : 0;
const matches = new Array(roadCount).fill(null); const matches = new Array(roadCount).fill(null);
const empty = { matches, matchedCount: 0, candidateCount: 0 }; 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; return empty;
} }
// Local equirectangular projection anchored at the first flow coordinate — // Local equirectangular projection anchored at the first flow coordinate —
// over a ≤0.05° fetch box the distortion is negligible. // 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; if (!anchor) return empty;
const [refLon, refLat] = anchor.coords[0]; const [refLon, refLat] = anchor.coords[0];
const mPerDegLon = M_PER_DEG_LAT * Math.cos((refLat * Math.PI) / 180); 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) ── // ── 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}>>} */ /** @type {Map<string, Array<{ax:number,ay:number,bx:number,by:number,bearing:number,level:number,closure:boolean}>>} */
const grid = new Map(); 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) { for (const flow of flowSegments) {
const coords = flow?.coords; const coords = flow?.coords;
@@ -173,7 +182,8 @@ export function matchFlowToRoads(roads, flowSegments) {
for (let i = 0; i < coords.length; i++) { for (let i = 0; i < coords.length; i++) {
xs[i] = projX(coords[i][0]); xs[i] = projX(coords[i][0]);
ys[i] = projY(coords[i][1]); 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]; const totalLen = cum[coords.length - 1];
if (!(totalLen > 0)) continue; if (!(totalLen > 0)) continue;
@@ -188,10 +198,14 @@ export function matchFlowToRoads(roads, flowSegments) {
// cross-street flow lines meet at intersections. // cross-street flow lines meet at intersections.
const target = (totalLen * (s + 0.5)) / ROAD_SAMPLES; const target = (totalLen * (s + 0.5)) / ROAD_SAMPLES;
while (cursor < coords.length - 1 && cum[cursor] < target) cursor++; 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 px = xs[cursor - 1] + (xs[cursor] - xs[cursor - 1]) * segT;
const py = ys[cursor - 1] + (ys[cursor] - ys[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. // 3×3 cell probe around the sample.
const cx = Math.floor(px / CELL_SIZE_M); 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); const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
if (d2 > radius2) continue; if (d2 > radius2) continue;
hadCandidate = true; // within radius, bearing not yet checked 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) { if (d2 <= bestDist2) {
bestDist2 = d2; bestDist2 = d2;
best = seg; best = seg;
@@ -221,7 +239,9 @@ export function matchFlowToRoads(roads, flowSegments) {
} }
if (hadCandidate) candidateCount++; 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 }; matches[r] = { level: median(matchedLevels), closure: matchedClosure };
matchedCount++; matchedCount++;
} }
+104 -40
View File
@@ -59,24 +59,35 @@ function focusTargetEmphasisResolved(
previouslyFarther, previouslyFarther,
) { ) {
if (!target?.screenRect || !spriteScreenPosition) return 1; 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 // Published rects already contain their publication-time padding. The
// delta keeps direct pure-function calls and runtime A/B overrides honest. // delta keeps direct pure-function calls and runtime A/B overrides honest.
// The ambient extent makes overlap mean visual overlap, not center-point // The ambient extent makes overlap mean visual overlap, not center-point
// containment: a class-sized icon whose edge intrudes into the focus rect // containment: a class-sized icon whose edge intrudes into the focus rect
// yields even when its center remains just outside it. // 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 hysteresis = previouslyCompeting ? tuning.hysteresisPx : 0;
const expansionX = paddingDelta + hysteresis const expansionX =
+ Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0); paddingDelta +
const expansionY = paddingDelta + hysteresis hysteresis +
+ Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0); 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 rect = target.screenRect;
const inside = spriteScreenPosition.x >= rect.left - expansionX const inside =
&& spriteScreenPosition.x <= rect.right + expansionX spriteScreenPosition.x >= rect.left - expansionX &&
&& spriteScreenPosition.y >= rect.top - expansionY spriteScreenPosition.x <= rect.right + expansionX &&
&& spriteScreenPosition.y <= rect.bottom + expansionY; spriteScreenPosition.y >= rect.top - expansionY &&
spriteScreenPosition.y <= rect.bottom + expansionY;
if (!inside) return 1; if (!inside) return 1;
const floor = clamp(tuning.dimFloor, 0.01, 1); const floor = clamp(tuning.dimFloor, 0.01, 1);
@@ -88,24 +99,33 @@ function focusTargetEmphasisResolved(
const fartherThreshold = Number.isFinite(target.cameraDistance) const fartherThreshold = Number.isFinite(target.cameraDistance)
? target.cameraDistance + (previouslyFarther ? -distanceBand : distanceBand) ? target.cameraDistance + (previouslyFarther ? -distanceBand : distanceBand)
: Number.NaN; : Number.NaN;
const farther = !Number.isFinite(spriteCameraDistance) const farther =
|| !Number.isFinite(fartherThreshold) !Number.isFinite(spriteCameraDistance) ||
|| spriteCameraDistance >= fartherThreshold; !Number.isFinite(fartherThreshold) ||
spriteCameraDistance >= fartherThreshold;
if (farther) return floor; if (farther) return floor;
if (tuning.nearerBehavior === 'partial') return floor + (1 - floor) * 0.5; if (tuning.nearerBehavior === 'partial') return floor + (1 - floor) * 0.5;
return 1; return 1;
} }
function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) { 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); const to = clamp(Number.isFinite(target) ? target : 1, tuning.dimFloor, 1);
if (Math.abs(to - from) < Number.EPSILON) return to; if (Math.abs(to - from) < Number.EPSILON) return to;
const duration = to < from ? tuning.attackMs : tuning.releaseMs; const duration = to < from ? tuning.attackMs : tuning.releaseMs;
if (duration <= 0) return to; 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 // Cubic ease-out reaches the exact endpoint at the configured duration and
// stays continuous when a moving contact reverses direction mid-transition. // 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); return clamp(from + (to - from) * eased, tuning.dimFloor, 1);
} }
@@ -118,18 +138,24 @@ function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
*/ */
export function setFocusDeemphasisParams(patch = {}) { export function setFocusDeemphasisParams(patch = {}) {
const next = { ..._params }; const next = { ..._params };
if (Number.isFinite(patch.paddingPx)) next.paddingPx = Math.max(0, patch.paddingPx); if (Number.isFinite(patch.paddingPx))
if (Number.isFinite(patch.dimFloor)) next.dimFloor = clamp(patch.dimFloor, 0.01, 1); 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)) { if (['allow', 'dim', 'partial'].includes(patch.nearerBehavior)) {
next.nearerBehavior = 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)) { if (Number.isFinite(patch.distanceHysteresisRatio)) {
next.distanceHysteresisRatio = clamp(patch.distanceHysteresisRatio, 0, 0.5); next.distanceHysteresisRatio = clamp(patch.distanceHysteresisRatio, 0, 0.5);
} }
if (Number.isFinite(patch.attackMs)) next.attackMs = Math.max(0, patch.attackMs); if (Number.isFinite(patch.attackMs))
if (Number.isFinite(patch.releaseMs)) next.releaseMs = Math.max(0, patch.releaseMs); next.attackMs = Math.max(0, patch.attackMs);
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon); 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; _params = next;
return { ..._params }; return { ..._params };
} }
@@ -171,11 +197,22 @@ export function publishFocusTargetFromCachedPosition({
heightPx = 24, heightPx = 24,
params, params,
}) { }) {
if (!ownerLayer || id === null || id === undefined || !scene || !camera || !displayPosition) { if (
!ownerLayer ||
id === null ||
id === undefined ||
!scene ||
!camera ||
!displayPosition
) {
clearFocusTarget(ownerLayer, id); clearFocusTarget(ownerLayer, id);
return null; 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)) { if (!screen || !Number.isFinite(screen.x) || !Number.isFinite(screen.y)) {
clearFocusTarget(ownerLayer, id); clearFocusTarget(ownerLayer, id);
return null; return null;
@@ -195,12 +232,19 @@ export function publishFocusTargetFromCachedPosition({
bottom: screen.y + halfHeight + padding, bottom: screen.y + halfHeight + padding,
}, },
paddingPx: padding, paddingPx: padding,
cameraDistance: Cesium.Cartesian3.distance(camera.positionWC, displayPosition), cameraDistance: Cesium.Cartesian3.distance(
camera.positionWC,
displayPosition,
),
frameNumber: scene.frameState?.frameNumber ?? -1, frameNumber: scene.frameState?.frameNumber ?? -1,
}; };
if (appeared) { if (appeared) {
for (const listener of _focusAppearListeners) { 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; return _focusTarget;
@@ -311,7 +355,9 @@ export function smoothFocusEmphasis(current, target, elapsedMs, params) {
* every needed property before the next call; never retain it as a snapshot. * every needed property before the next call; never retain it as a snapshot.
* @returns {{factor:number, changed:boolean, transitioning:boolean,active:boolean,desired:number}} * @returns {{factor:number, changed:boolean, transitioning:boolean,active:boolean,desired:number}}
*/ */
export function advanceSpriteFocus(sprite, { export function advanceSpriteFocus(
sprite,
{
screenPosition, screenPosition,
cameraDistance, cameraDistance,
nowMs, nowMs,
@@ -319,7 +365,8 @@ export function advanceSpriteFocus(sprite, {
params, params,
spriteHalfWidthPx = 0, spriteHalfWidthPx = 0,
spriteHalfHeightPx = spriteHalfWidthPx, spriteHalfHeightPx = spriteHalfWidthPx,
}) { },
) {
const tuning = resolvedParams(params); const tuning = resolvedParams(params);
let state = _spriteStates.get(sprite); let state = _spriteStates.get(sprite);
if (!state) { if (!state) {
@@ -369,12 +416,18 @@ export function advanceSpriteFocus(sprite, {
state.desired = desired; state.desired = desired;
} }
const elapsedMs = Math.max(0, nowMs - state.transitionStartMs); 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; const changed = Math.abs(next - state.factor) > tuning.writeEpsilon;
state.factor = next; state.factor = next;
state.desired = desired; state.desired = desired;
state.wasFarther = tuning.nearerBehavior !== 'dim' state.wasFarther =
&& Math.abs(desired - tuning.dimFloor) <= tuning.writeEpsilon; tuning.nearerBehavior !== 'dim' &&
Math.abs(desired - tuning.dimFloor) <= tuning.writeEpsilon;
const transitioning = Math.abs(next - desired) > tuning.writeEpsilon; const transitioning = Math.abs(next - desired) > tuning.writeEpsilon;
const active = Math.abs(next - 1) > tuning.writeEpsilon; const active = Math.abs(next - 1) > tuning.writeEpsilon;
@@ -426,10 +479,16 @@ export function advanceProjectedSpriteFocus(
_advanceResult.desired = 1; _advanceResult.desired = 1;
return _advanceResult; return _advanceResult;
} }
const screen = position && scene const screen =
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, _scratchScreen) position && scene
? Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
position,
_scratchScreen,
)
: null; : null;
const cameraDistance = position && camera?.positionWC const cameraDistance =
position && camera?.positionWC
? Cesium.Cartesian3.distance(camera.positionWC, position) ? Cesium.Cartesian3.distance(camera.positionWC, position)
: Number.NaN; : Number.NaN;
return advanceSpriteFocus(sprite, { return advanceSpriteFocus(sprite, {
@@ -451,8 +510,10 @@ export function advanceProjectedSpriteFocus(
* @returns {boolean} * @returns {boolean}
*/ */
export function focusPassIsNeeded(target, activeCount) { export function focusPassIsNeeded(target, activeCount) {
return target !== null && target !== undefined return (
|| (Number.isFinite(activeCount) && activeCount > 0); (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; if (!scalar || !Number.isFinite(cameraDistanceM)) return 1;
const nearSq = scalar.near * scalar.near; const nearSq = scalar.near * scalar.near;
const farSq = scalar.far * scalar.far; const farSq = scalar.far * scalar.far;
if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq) return 1; if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq)
const rawT = ((cameraDistanceM * cameraDistanceM) - nearSq) / (farSq - nearSq); return 1;
const rawT = (cameraDistanceM * cameraDistanceM - nearSq) / (farSq - nearSq);
const t = Math.pow(clamp(rawT, 0, 1), 0.2); const t = Math.pow(clamp(rawT, 0, 1), 0.2);
return scalar.nearValue + (scalar.farValue - scalar.nearValue) * t; return scalar.nearValue + (scalar.farValue - scalar.nearValue) * t;
} }
@@ -507,6 +569,8 @@ export function forgetSpriteFocus(sprite) {
*/ */
export function focusAlphaNeedsWrite(currentAlpha, nextAlpha, params) { export function focusAlphaNeedsWrite(currentAlpha, nextAlpha, params) {
const tuning = resolvedParams(params); const tuning = resolvedParams(params);
return !Number.isFinite(currentAlpha) return (
|| Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon; !Number.isFinite(currentAlpha) ||
Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon
);
} }
+1 -1
View File
@@ -51,7 +51,7 @@ export function geoidHeight(latDeg, lonDeg) {
if (!egm96Module) { if (!egm96Module) {
throw new Error( throw new Error(
'geoid.js: geoidHeight() called before ensureGeoidReady() resolved — ' + 'geoid.js: geoidHeight() called before ensureGeoidReady() resolved — ' +
'await ensureGeoidReady() first.' 'await ensureGeoidReady() first.',
); );
} }
return egm96Module.meanSeaLevel(latDeg, lonDeg); return egm96Module.meanSeaLevel(latDeg, lonDeg);
+29 -1
View File
@@ -1,4 +1,32 @@
import { createGroundFloor } from '../services/groundFloor.js'; import { createGroundFloor } from '../services/groundFloor.js';
import * as terrain from './terrainHeights.js'; import * as terrain from './terrainHeights.js';
const groundFloor = createGroundFloor({ terrain }); 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 { createGroundSnap as createSnap } from '../services/groundSnap.js';
import * as groundFloor from './groundFloor.js'; import * as groundFloor from './groundFloor.js';
/** Compatibility constructor using the default floor cache. */ /** 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({ export function findNextIssPass({
satrec, latDeg, lonDeg, fromMs, satrec,
minElevDeg = 10, horizonHours = 24, coarseStepSec = 30, fineStepSec = 5, latDeg,
lonDeg,
fromMs,
minElevDeg = 10,
horizonHours = 24,
coarseStepSec = 30,
fineStepSec = 5,
}) { }) {
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90; const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
const horizonMs = fromMs + horizonHours * 3600_000; 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. // pass, that's still "the next pass" for a voice answer — accept it.
let hit = null; let hit = null;
for (let t = fromMs; t <= horizonMs; t += coarse) { 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; if (hit == null) return null;
// Refine rise: walk back in fine steps to the first sample ≥ threshold. // Refine rise: walk back in fine steps to the first sample ≥ threshold.
let riseMs = hit; 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. // Walk forward through the pass tracking the peak until we drop below.
let maxElevDeg = -90; let maxElevDeg = -90;
@@ -56,11 +66,20 @@ export function findNextIssPass({
while (t <= horizonMs) { while (t <= horizonMs) {
const e = elev(t); const e = elev(t);
if (e < minElevDeg && t > riseMs) break; if (e < minElevDeg && t > riseMs) break;
if (e > maxElevDeg) { maxElevDeg = e; maxElevMs = t; } if (e > maxElevDeg) {
maxElevDeg = e;
maxElevMs = t;
}
t += fine; t += fine;
} }
const setMs = t; const setMs = t;
const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg); 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,
};
} }
+161 -58
View File
@@ -16,11 +16,15 @@ function clampInt(value, min, max) {
} }
function demandEntries(demandByLayer) { function demandEntries(demandByLayer) {
const entries = demandByLayer instanceof Map const entries =
demandByLayer instanceof Map
? Array.from(demandByLayer.entries()) ? Array.from(demandByLayer.entries())
: Object.entries(demandByLayer || {}); : Object.entries(demandByLayer || {});
return entries 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) .filter(([, demand]) => demand > 0)
.sort(([a], [b]) => a.localeCompare(b)); .sort(([a], [b]) => a.localeCompare(b));
} }
@@ -104,7 +108,9 @@ function allocateLayerQuotasInto(
weighted.length = idCount; weighted.length = idCount;
priorityOrder.length = idCount; priorityOrder.length = idCount;
remainders.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; let remaining = capacity;
if (capacity >= idCount) { if (capacity >= idCount) {
@@ -115,7 +121,7 @@ function allocateLayerQuotasInto(
let apportioned = 0; let apportioned = 0;
for (let i = 0; i < idCount; i++) { for (let i = 0; i < idCount; i++) {
const entry = weighted[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 room = entry.count - (quotas.get(entry.layerId) || 0);
const whole = Math.min(room, Math.floor(exact)); const whole = Math.min(room, Math.floor(exact));
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole); quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
@@ -123,9 +129,12 @@ function allocateLayerQuotasInto(
entry.fraction = exact - Math.floor(exact); entry.fraction = exact - Math.floor(exact);
} }
remaining -= apportioned; remaining -= apportioned;
remainders.sort((a, b) => b.fraction - a.fraction remainders.sort(
|| b.weight - a.weight (a, b) =>
|| a.layerId.localeCompare(b.layerId)); b.fraction - a.fraction ||
b.weight - a.weight ||
a.layerId.localeCompare(b.layerId),
);
for (let i = 0; i < idCount && remaining > 0; i++) { for (let i = 0; i < idCount && remaining > 0; i++) {
const entry = remainders[i]; const entry = remainders[i];
const current = quotas.get(entry.layerId) || 0; const current = quotas.get(entry.layerId) || 0;
@@ -156,7 +165,12 @@ function allocateLayerQuotasInto(
* Allocate a collective capacity across non-empty layers. Both strategies are * Allocate a collective capacity across non-empty layers. Both strategies are
* work-conserving and deterministic; unused entitlement is always borrowed. * 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 entries = demandEntries(demandByLayer);
const totalDemand = entries.reduce((sum, [, demand]) => sum + demand, 0); const totalDemand = entries.reduce((sum, [, demand]) => sum + demand, 0);
const cap = clampInt(capacity, 0, totalDemand); const cap = clampInt(capacity, 0, totalDemand);
@@ -198,15 +212,24 @@ export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATI
if (remaining > 0 && totalWeight > 0) { if (remaining > 0 && totalWeight > 0) {
let apportioned = 0; let apportioned = 0;
for (const entry of weighted) { 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 room = entry.count - (quotas.get(entry.layerId) || 0);
const whole = Math.min(room, Math.floor(exact)); const whole = Math.min(room, Math.floor(exact));
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole); quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
apportioned += 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; 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) { for (const entry of remainders) {
if (remaining <= 0) break; if (remaining <= 0) break;
const current = quotas.get(entry.layerId) || 0; const current = quotas.get(entry.layerId) || 0;
@@ -220,20 +243,24 @@ export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATI
} }
function rectIsFinite(rect) { function rectIsFinite(rect) {
return rect return (
&& Number.isFinite(rect.x) rect &&
&& Number.isFinite(rect.y) Number.isFinite(rect.x) &&
&& Number.isFinite(rect.w) Number.isFinite(rect.y) &&
&& Number.isFinite(rect.h) Number.isFinite(rect.w) &&
&& rect.w > 0 Number.isFinite(rect.h) &&
&& rect.h > 0; rect.w > 0 &&
rect.h > 0
);
} }
function overlaps(a, b, padding = 4) { function overlaps(a, b, padding = 4) {
return a.x < b.x + b.w + padding return (
&& a.x + a.w + padding > b.x a.x < b.x + b.w + padding &&
&& a.y < b.y + b.h + padding a.x + a.w + padding > b.x &&
&& a.y + a.h + padding > b.y; a.y < b.y + b.h + padding &&
a.y + a.h + padding > b.y
);
} }
const CELL_ORIGIN = 8192; const CELL_ORIGIN = 8192;
@@ -271,8 +298,10 @@ class SpatialHash {
* never hide a real collision. * never hide a real collision.
*/ */
_cellKey(x, y) { _cellKey(x, y) {
const cx = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, x)) + CELL_ORIGIN; const cx =
const cy = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, y)) + CELL_ORIGIN; 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 // Both coordinates occupy 14 bits. Bit-packing forces the lookup key to a
// Smi; arithmetic multiplication materializes a boxed double at each // Smi; arithmetic multiplication materializes a boxed double at each
// spatial-hash get/add on this hot solve path. // 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 // 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 // per-frame rebuild, so it must be free to lose its slot the moment something
// better wants it. Plain incumbency ordering below still applies. // better wants it. Plain incumbency ordering below still applies.
const aPinned = !!aState?.selected && a.stateless !== true && now - aState.selectedAt < MIN_LIFETIME_MS; const aPinned =
const bPinned = !!bState?.selected && b.stateless !== true && now - bState.selectedAt < MIN_LIFETIME_MS; !!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; if (aPinned !== bPinned) return aPinned ? -1 : 1;
const aIncumbent = !!aState?.selected; const aIncumbent = !!aState?.selected;
const bIncumbent = !!bState?.selected; const bIncumbent = !!bState?.selected;
@@ -346,12 +381,20 @@ function candidateCompare(a, b, states, now) {
function cacheCandidateAnchorScalars(candidate) { function cacheCandidateAnchorScalars(candidate) {
let anchorX = Number.NaN; let anchorX = Number.NaN;
let anchorY = 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; anchorX = candidate.screenX;
anchorY = candidate.screenY; anchorY = candidate.screenY;
} else { } else {
const placement = Array.isArray(candidate?.placements) ? candidate.placements[0] : null; const placement = Array.isArray(candidate?.placements)
if (Number.isFinite(placement?.leadFromX) && Number.isFinite(placement?.leadFromY)) { ? candidate.placements[0]
: null;
if (
Number.isFinite(placement?.leadFromX) &&
Number.isFinite(placement?.leadFromY)
) {
anchorX = placement.leadFromX; anchorX = placement.leadFromX;
anchorY = placement.leadFromY; anchorY = placement.leadFromY;
} else if (rectIsFinite(placement?.rect)) { } else if (rectIsFinite(placement?.rect)) {
@@ -401,7 +444,16 @@ class SpatialCandidateQueue {
* stay unboxed. Few-placement candidates already blocked by the * stay unboxed. Few-placement candidates already blocked by the
* monotonically growing collision field are dismissed before anchor scans. * 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.candidates = candidates;
this.count = count; this.count = count;
this.anchorCount = anchorCount; this.anchorCount = anchorCount;
@@ -529,10 +581,13 @@ class SpatialCandidateQueue {
const rectY = placementYs[rectIndex]; const rectY = placementYs[rectIndex];
const rectW = placementWs[rectIndex]; const rectW = placementWs[rectIndex];
const rectH = placementHs[rectIndex]; const rectH = placementHs[rectIndex];
if (rectX < anchorRectX + anchorRectW + 4 if (
&& rectX + rectW + 4 > anchorRectX rectX < anchorRectX + anchorRectW + 4 &&
&& rectY < anchorRectY + anchorRectH + 4 rectX + rectW + 4 > anchorRectX &&
&& rectY + rectH + 4 > anchorRectY) freeMask &= ~bit; rectY < anchorRectY + anchorRectH + 4 &&
rectY + rectH + 4 > anchorRectY
)
freeMask &= ~bit;
} }
placementMasks[i] = freeMask; placementMasks[i] = freeMask;
if (freeMask === 0 && this._dismissBlockedKey(i)) continue; if (freeMask === 0 && this._dismissBlockedKey(i)) continue;
@@ -558,7 +613,8 @@ class SpatialCandidateQueue {
bestIndex = i; bestIndex = i;
continue; 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) {
if (priorityDelta > 0) { if (priorityDelta > 0) {
best = candidate; best = candidate;
@@ -621,7 +677,8 @@ function firstFreePlacement(candidate, stickyCorner, spatial) {
if (stickyCorner) { if (stickyCorner) {
for (let i = 0; i < placements.length; i++) { for (let i = 0; i < placements.length; i++) {
const placement = placements[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; if (!spatial.collides(placement.rect)) return placement;
} }
} }
@@ -790,7 +847,13 @@ export class LabelArbiter {
let candidateCount = 0; let candidateCount = 0;
for (let i = 0; i < source.length; i++) { for (let i = 0; i < source.length; i++) {
const candidate = source[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); cacheCandidateAnchorScalars(candidate);
candidateList[candidateCount++] = candidate; candidateList[candidateCount++] = candidate;
let bucket = byLayer.get(candidate.layerId); let bucket = byLayer.get(candidate.layerId);
@@ -815,13 +878,15 @@ export class LabelArbiter {
}); });
} else if (options.demandByLayer != null) { } else if (options.demandByLayer != null) {
const supplied = demandEntries(options.demandByLayer); 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 // A supplied demand map describes the full lightweight field while the
// candidate list may be only a bounded materialized cohort. Keep defensive // candidate list may be only a bounded materialized cohort. Keep defensive
// support for candidate layers omitted by an incomplete caller map. // support for candidate layers omitted by an incomplete caller map.
byLayer.forEach((bucket, layerId) => { 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; const activeLayers = this._activeLayersScratch;
@@ -857,14 +922,20 @@ export class LabelArbiter {
let sameActiveLayers = activeCount === this.lastActiveLayers.length; let sameActiveLayers = activeCount === this.lastActiveLayers.length;
for (let i = 0; sameActiveLayers && i < activeCount; i++) { 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 spatialQueueBuildCount = 0;
let spatialQueueNextCount = 0; let spatialQueueNextCount = 0;
const attempt = (candidate, allowOverlapFallback = false) => { 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); attemptStamps.set(candidate.key, stamp);
const previous = this.states.get(candidate.key); const previous = this.states.get(candidate.key);
const stateless = candidate.stateless === true; const stateless = candidate.stateless === true;
@@ -872,10 +943,12 @@ export class LabelArbiter {
// very next solve, as their stateless originals did) and carry no sticky // very next solve, as their stateless originals did) and carry no sticky
// corner, so above/below is re-decided from geometry every solve instead // corner, so above/below is re-decided from geometry every solve instead
// of being pinned by whichever side happened to be free once. // 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; const sticky = stateless ? undefined : previous?.corner;
let placement = firstFreePlacement(candidate, sticky, spatial); let placement = firstFreePlacement(candidate, sticky, spatial);
if (!placement && allowOverlapFallback) placement = firstOrderedPlacement(candidate, sticky); if (!placement && allowOverlapFallback)
placement = firstOrderedPlacement(candidate, sticky);
if (!placement) return false; if (!placement) return false;
spatial.add(placement.rect); spatial.add(placement.rect);
selectStamps.set(candidate.key, stamp); selectStamps.set(candidate.key, stamp);
@@ -953,7 +1026,8 @@ export class LabelArbiter {
} }
let retainedIncumbents = 0; let retainedIncumbents = 0;
for (let i = 0; i < selectedCount; i++) { 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(); const exitingStates = this._refreshStateList();
@@ -964,7 +1038,9 @@ export class LabelArbiter {
state.exitStartedAt = now; state.exitStartedAt = now;
// Stateless entries may be re-selected immediately; the cooldown exists // Stateless entries may be re-selected immediately; the cooldown exists
// to damp thrash for sources that shipped with hysteresis. // 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; this._stateListDirty = true;
} }
if (labelsByLayer) { 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(); const livingStates = this._refreshStateList();
for (let i = 0; i < livingStates.length; i++) { for (let i = 0; i < livingStates.length; i++) {
const state = livingStates[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.states.delete(state.key);
this._stateListDirty = true; this._stateListDirty = true;
} }
@@ -1026,13 +1106,15 @@ export class LabelArbiter {
}); });
for (let i = 0; i < droppedCount; i++) this.selectedKeys.delete(dropped[i]); for (let i = 0; i < droppedCount; i++) this.selectedKeys.delete(dropped[i]);
dropped.length = droppedCount; 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; const previousActiveLayers = this.lastActiveLayers;
this.lastActiveLayers = activeLayers; this.lastActiveLayers = activeLayers;
this._activeLayersScratch = previousActiveLayers; this._activeLayersScratch = previousActiveLayers;
this.solveRevision++; this.solveRevision++;
this.lastDiagnostics = collectDiagnostics ? { this.lastDiagnostics = collectDiagnostics
? {
strategy, strategy,
capacity, capacity,
selectedCount, selectedCount,
@@ -1044,7 +1126,8 @@ export class LabelArbiter {
spatialQueueNextCount, spatialQueueNextCount,
eligibleIncumbents, eligibleIncumbents,
retainedIncumbents, retainedIncumbents,
} : null; }
: null;
selectedCandidates.length = selectedCount; selectedCandidates.length = selectedCount;
selectedPlacements.length = selectedCount; selectedPlacements.length = selectedCount;
pruneStampMap(attemptStamps, candidateCount); pruneStampMap(attemptStamps, candidateCount);
@@ -1057,9 +1140,15 @@ export class LabelArbiter {
* A caller-owned output array enables allocation-free per-frame rendering. * A caller-owned output array enables allocation-free per-frame rendering.
*/ */
renderEntries(currentCandidates, now = Date.now(), out = []) { renderEntries(currentCandidates, now = Date.now(), out = []) {
const current = currentCandidates instanceof Map const current =
currentCandidates instanceof Map
? currentCandidates ? currentCandidates
: new Map((currentCandidates || []).map((candidate) => [candidate.key, candidate])); : new Map(
(currentCandidates || []).map((candidate) => [
candidate.key,
candidate,
]),
);
const states = this._refreshStateList(); const states = this._refreshStateList();
let outIndex = 0; let outIndex = 0;
for (let i = 0; i < states.length; i++) { 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. // fully painted this solve or gone. No enter ramp, no exit tail.
temporalAlpha = state.selected ? 1 : 0; temporalAlpha = state.selected ? 1 : 0;
} else if (state.selected) { } 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 { } 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; if (temporalAlpha <= 0) continue;
const candidate = current.get(state.key) || state.lastCandidate; const candidate = current.get(state.key) || state.lastCandidate;
if (!candidate) continue; 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; if (!placement) continue;
const entry = out[outIndex] || (out[outIndex] = {}); const entry = out[outIndex] || (out[outIndex] = {});
entry.candidate = candidate; entry.candidate = candidate;
@@ -1100,11 +1198,16 @@ export class LabelArbiter {
for (const state of this.states.values()) { for (const state of this.states.values()) {
if (!state.selected) { if (!state.selected) {
if (!includeFading) continue; 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; const candidate = state.lastCandidate;
if (!candidate?.layerId || candidate.sourceId == null) continue; 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); grouped.get(candidate.layerId).add(candidate.sourceId);
} }
return grouped; return grouped;
+388 -129
View File
@@ -42,7 +42,9 @@ const RADIO_FILTER_CODES = Object.freeze({
other: 'o', other: 'o',
}); });
const RADIO_CODE_FILTERS = Object.freeze( const RADIO_CODE_FILTERS = Object.freeze(
Object.fromEntries(Object.entries(RADIO_FILTER_CODES).map(([key, value]) => [value, key])), Object.fromEntries(
Object.entries(RADIO_FILTER_CODES).map(([key, value]) => [value, key]),
),
); );
function normalizeBoolean(value) { function normalizeBoolean(value) {
@@ -54,7 +56,9 @@ function normalizeEnum(values, value) {
} }
export function normalizeRadioFilter(value) { export function normalizeRadioFilter(value) {
const normalized = String(value || '').trim().toLowerCase(); const normalized = String(value || '')
.trim()
.toLowerCase();
if (Object.hasOwn(RADIO_FILTER_CODES, normalized)) return normalized; if (Object.hasOwn(RADIO_FILTER_CODES, normalized)) return normalized;
if (/^genre:[a-z0-9][a-z0-9 &-]{0,31}$/.test(normalized)) return normalized; if (/^genre:[a-z0-9][a-z0-9 &-]{0,31}$/.test(normalized)) return normalized;
return null; return null;
@@ -65,8 +69,10 @@ function encodeRadioFilter(value) {
} }
function decodeRadioFilter(value) { function decodeRadioFilter(value) {
if (Object.hasOwn(RADIO_CODE_FILTERS, value)) return RADIO_CODE_FILTERS[value]; if (Object.hasOwn(RADIO_CODE_FILTERS, value))
if (/^g-[a-z0-9][a-z0-9 &-]{0,31}$/.test(value)) return `genre:${value.slice(2)}`; return RADIO_CODE_FILTERS[value];
if (/^g-[a-z0-9][a-z0-9 &-]{0,31}$/.test(value))
return `genre:${value.slice(2)}`;
return null; return null;
} }
@@ -76,7 +82,12 @@ function normalizeVolume(value) {
return Math.round(Math.max(0, Math.min(1, numeric)) * 100) / 100; return Math.round(Math.max(0, Math.min(1, numeric)) * 100) / 100;
} }
function booleanOption(key, token, defaultValue, { absentValue = defaultValue } = {}) { function booleanOption(
key,
token,
defaultValue,
{ absentValue = defaultValue } = {},
) {
return Object.freeze({ return Object.freeze({
key, key,
token, token,
@@ -109,15 +120,20 @@ function booleanOption(key, token, defaultValue, { absentValue = defaultValue }
* first option in THIS codec to need it — flipped to default-ON on 2026-08-22.) * first option in THIS codec to need it — flipped to default-ON on 2026-08-22.)
*/ */
function absentTokenValue(spec) { function absentTokenValue(spec) {
return Object.hasOwn(spec, 'absentValue') ? spec.absentValue : spec.defaultValue; return Object.hasOwn(spec, 'absentValue')
? spec.absentValue
: spec.defaultValue;
} }
function trackingIdOption(key, token, defaultValue = null) { function trackingIdOption(key, token, defaultValue = null) {
const bounded = (candidate) => { const bounded = (candidate) => {
if (candidate === null || candidate === undefined) return null; if (candidate === null || candidate === undefined) return null;
const raw = typeof candidate === 'number' && Number.isFinite(candidate) const raw =
typeof candidate === 'number' && Number.isFinite(candidate)
? String(candidate) ? String(candidate)
: (typeof candidate === 'string' ? candidate : null); : typeof candidate === 'string'
? candidate
: null;
if (raw === null) return null; if (raw === null) return null;
const normalized = raw.trim().toLowerCase(); const normalized = raw.trim().toLowerCase();
return TRACKING_ID_GRAMMAR.test(normalized) ? normalized : null; return TRACKING_ID_GRAMMAR.test(normalized) ? normalized : null;
@@ -138,18 +154,24 @@ function stringOption(key, token, defaultValue) {
token, token,
defaultValue, defaultValue,
normalize: (value) => { normalize: (value) => {
if (typeof value === 'number' && Number.isFinite(value)) return String(value).trim().toLowerCase(); if (typeof value === 'number' && Number.isFinite(value))
return String(value).trim().toLowerCase();
if (typeof value !== 'string') return null; if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase(); const normalized = value.trim().toLowerCase();
return normalized ? normalized : null; return normalized ? normalized : null;
}, },
encode: (value) => String(value), encode: (value) => String(value),
decode: (value) => (typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null), decode: (value) =>
typeof value === 'string' && value.trim()
? value.trim().toLowerCase()
: null,
}); });
} }
function enumOption(key, token, defaultValue, values, codes) { function enumOption(key, token, defaultValue, values, codes) {
const reverse = Object.fromEntries(Object.entries(codes).map(([name, code]) => [code, name])); const reverse = Object.fromEntries(
Object.entries(codes).map(([name, code]) => [code, name]),
);
return Object.freeze({ return Object.freeze({
key, key,
token, token,
@@ -166,7 +188,8 @@ function integerOption(key, token, defaultValue) {
token, token,
defaultValue, defaultValue,
normalize: (value) => { normalize: (value) => {
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value; if (typeof value === 'number' && Number.isInteger(value) && value > 0)
return value;
const candidate = typeof value === 'string' ? value.trim() : ''; const candidate = typeof value === 'string' ? value.trim() : '';
const parsed = Number(candidate); const parsed = Number(candidate);
if (!candidate || !Number.isInteger(parsed) || parsed <= 0) return null; if (!candidate || !Number.isInteger(parsed) || parsed <= 0) return null;
@@ -211,7 +234,10 @@ const OPTION_GROUPS = Object.freeze({
trackingIdOption('selectedMilitaryTrackingId', 'u', null), trackingIdOption('selectedMilitaryTrackingId', 'u', null),
]), ]),
satellites: Object.freeze([ satellites: Object.freeze([
enumOption('catalog', 'c', 'core', ['core', 'dense'], { core: 'c', dense: 'd' }), enumOption('catalog', 'c', 'core', ['core', 'dense'], {
core: 'c',
dense: 'd',
}),
integerOption('selectedSatTrackingId', 't', null), integerOption('selectedSatTrackingId', 't', null),
]), ]),
cctv: Object.freeze([ cctv: Object.freeze([
@@ -238,7 +264,8 @@ const OPTION_GROUPS = Object.freeze({
defaultValue: 0.8, defaultValue: 0.8,
normalize: normalizeVolume, normalize: normalizeVolume,
encode: (value) => String(Math.round(value * 100)), encode: (value) => String(Math.round(value * 100)),
decode: (value) => (/^\d{1,3}$/.test(value) ? normalizeVolume(Number(value) / 100) : null), decode: (value) =>
/^\d{1,3}$/.test(value) ? normalizeVolume(Number(value) / 100) : null,
}), }),
]), ]),
}); });
@@ -275,48 +302,113 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
* owns stable URL ordering. * owns stable URL ordering.
*/ */
export const LAYER_STATE_REGISTRY = Object.freeze([ export const LAYER_STATE_REGISTRY = Object.freeze([
Object.freeze({ id: 'ais-live-vessels', token: 'a', disposition: 'enabled-only' }), Object.freeze({
Object.freeze({ id: 'alpr-cameras', token: 'p', disposition: 'enabled-only' }), id: 'ais-live-vessels',
token: 'a',
disposition: 'enabled-only',
}),
Object.freeze({
id: 'alpr-cameras',
token: 'p',
disposition: 'enabled-only',
}),
Object.freeze({ id: 'bikeshare', token: 'b', disposition: 'enabled-only' }), Object.freeze({ id: 'bikeshare', token: 'b', disposition: 'enabled-only' }),
Object.freeze({ id: 'cctv', token: 'c', disposition: 'enabled+options', optionOwner: 'cctv' }), Object.freeze({
id: 'cctv',
token: 'c',
disposition: 'enabled+options',
optionOwner: 'cctv',
}),
Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }), Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }),
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }), Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
Object.freeze({ id: 'flights', token: 'f', disposition: 'enabled+options', optionOwner: 'flights' }), Object.freeze({
id: 'flights',
token: 'f',
disposition: 'enabled+options',
optionOwner: 'flights',
}),
Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }), Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }),
Object.freeze({ id: 'local-datacenters', token: 'd', disposition: 'enabled-only' }), Object.freeze({
id: 'local-datacenters',
token: 'd',
disposition: 'enabled-only',
}),
Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }), Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }),
Object.freeze({ id: 'military', token: 'm', disposition: 'enabled+mirrored-options', optionOwner: 'flights' }), Object.freeze({
Object.freeze({ id: 'military-awareness', token: 'g', disposition: 'enabled-only' }), id: 'military',
Object.freeze({ id: 'military-installations', token: 'i', disposition: 'enabled-only' }), token: 'm',
Object.freeze({ id: 'radio', token: 'r', disposition: 'enabled+options', optionOwner: 'radio' }), disposition: 'enabled+mirrored-options',
Object.freeze({ id: 'rocket-launches', token: 'x', disposition: 'enabled-only' }), optionOwner: 'flights',
Object.freeze({ id: 'satellites', token: 's', disposition: 'enabled+options', optionOwner: 'satellites' }), }),
Object.freeze({ id: 'telegeography-submarine-cables', token: 'u', disposition: 'enabled-only' }), Object.freeze({
id: 'military-awareness',
token: 'g',
disposition: 'enabled-only',
}),
Object.freeze({
id: 'military-installations',
token: 'i',
disposition: 'enabled-only',
}),
Object.freeze({
id: 'radio',
token: 'r',
disposition: 'enabled+options',
optionOwner: 'radio',
}),
Object.freeze({
id: 'rocket-launches',
token: 'x',
disposition: 'enabled-only',
}),
Object.freeze({
id: 'satellites',
token: 's',
disposition: 'enabled+options',
optionOwner: 'satellites',
}),
Object.freeze({
id: 'telegeography-submarine-cables',
token: 'u',
disposition: 'enabled-only',
}),
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }), Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }), Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }),
]); ]);
export const REGISTERED_LAYER_IDS = Object.freeze(LAYER_STATE_REGISTRY.map((entry) => entry.id)); export const REGISTERED_LAYER_IDS = Object.freeze(
LAYER_STATE_REGISTRY.map((entry) => entry.id),
);
const REGISTRY_BY_ID = new Map(LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry])); const REGISTRY_BY_ID = new Map(
const REGISTRY_BY_TOKEN = new Map(LAYER_STATE_REGISTRY.map((entry) => [entry.token, entry])); LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]),
const OPTION_OWNER_IDS = Object.freeze([...new Set( );
const REGISTRY_BY_TOKEN = new Map(
LAYER_STATE_REGISTRY.map((entry) => [entry.token, entry]),
);
const OPTION_OWNER_IDS = Object.freeze([
...new Set(
LAYER_STATE_REGISTRY.map((entry) => entry.optionOwner).filter(Boolean), LAYER_STATE_REGISTRY.map((entry) => entry.optionOwner).filter(Boolean),
)]); ),
]);
function optionSpecs(ownerId) { function optionSpecs(ownerId) {
return OPTION_GROUPS[ownerId] || []; return OPTION_GROUPS[ownerId] || [];
} }
function defaultsForOwner(ownerId) { function defaultsForOwner(ownerId) {
return Object.fromEntries(optionSpecs(ownerId).map((spec) => [spec.key, spec.defaultValue])); return Object.fromEntries(
optionSpecs(ownerId).map((spec) => [spec.key, spec.defaultValue]),
);
} }
function normalizeOwnerOptions(ownerId, candidate = {}) { function normalizeOwnerOptions(ownerId, candidate = {}) {
const input = candidate && typeof candidate === 'object' ? candidate : {}; const input = candidate && typeof candidate === 'object' ? candidate : {};
const normalized = {}; const normalized = {};
for (const spec of optionSpecs(ownerId)) { for (const spec of optionSpecs(ownerId)) {
const value = Object.hasOwn(input, spec.key) ? spec.normalize(input[spec.key]) : null; const value = Object.hasOwn(input, spec.key)
? spec.normalize(input[spec.key])
: null;
normalized[spec.key] = value === null ? spec.defaultValue : value; normalized[spec.key] = value === null ? spec.defaultValue : value;
} }
return normalized; return normalized;
@@ -335,12 +427,17 @@ export function validateLayerStateRegistry(registry = LAYER_STATE_REGISTRY) {
const ids = new Set(); const ids = new Set();
const tokens = new Set(); const tokens = new Set();
for (const entry of registry) { for (const entry of registry) {
if (!entry || typeof entry.id !== 'string' || !entry.id) throw new Error('Layer-state entry missing id'); if (!entry || typeof entry.id !== 'string' || !entry.id)
if (!/^[a-z0-9-]+$/.test(entry.id)) throw new Error(`Invalid layer-state id: ${entry.id}`); throw new Error('Layer-state entry missing id');
if (ids.has(entry.id)) throw new Error(`Duplicate layer-state id: ${entry.id}`); if (!/^[a-z0-9-]+$/.test(entry.id))
throw new Error(`Invalid layer-state id: ${entry.id}`);
if (ids.has(entry.id))
throw new Error(`Duplicate layer-state id: ${entry.id}`);
ids.add(entry.id); ids.add(entry.id);
if (!/^[a-z0-9]$/.test(entry.token || '')) throw new Error(`Invalid layer-state token: ${entry.id}`); if (!/^[a-z0-9]$/.test(entry.token || ''))
if (tokens.has(entry.token)) throw new Error(`Duplicate layer-state token: ${entry.token}`); throw new Error(`Invalid layer-state token: ${entry.id}`);
if (tokens.has(entry.token))
throw new Error(`Duplicate layer-state token: ${entry.token}`);
tokens.add(entry.token); tokens.add(entry.token);
if (!VALID_DISPOSITIONS.has(entry.disposition)) { if (!VALID_DISPOSITIONS.has(entry.disposition)) {
throw new Error(`Invalid layer-state disposition: ${entry.id}`); throw new Error(`Invalid layer-state disposition: ${entry.id}`);
@@ -363,10 +460,9 @@ export function createDefaultLayerState() {
return { return {
version: LAYER_STATE_VERSION, version: LAYER_STATE_VERSION,
enabledLayerIds: [], enabledLayerIds: [],
options: Object.fromEntries(OPTION_OWNER_IDS.map((ownerId) => [ options: Object.fromEntries(
ownerId, OPTION_OWNER_IDS.map((ownerId) => [ownerId, defaultsForOwner(ownerId)]),
defaultsForOwner(ownerId), ),
])),
}; };
} }
@@ -374,20 +470,28 @@ export function createDefaultLayerState() {
export function normalizeLayerState(candidate) { export function normalizeLayerState(candidate) {
const input = candidate && typeof candidate === 'object' ? candidate : {}; const input = candidate && typeof candidate === 'object' ? candidate : {};
const requestedEnabled = new Set( const requestedEnabled = new Set(
Array.isArray(input.enabledLayerIds) ? input.enabledLayerIds.map(String) : [], Array.isArray(input.enabledLayerIds)
? input.enabledLayerIds.map(String)
: [],
);
const enabledLayerIds = REGISTERED_LAYER_IDS.filter((id) =>
requestedEnabled.has(id),
); );
const enabledLayerIds = REGISTERED_LAYER_IDS.filter((id) => requestedEnabled.has(id));
const enabled = new Set(enabledLayerIds); const enabled = new Set(enabledLayerIds);
const options = Object.fromEntries(OPTION_OWNER_IDS.map((ownerId) => [ const options = Object.fromEntries(
OPTION_OWNER_IDS.map((ownerId) => [
ownerId, ownerId,
normalizeOwnerOptions(ownerId, input.options?.[ownerId]), normalizeOwnerOptions(ownerId, input.options?.[ownerId]),
])); ]),
);
// A selected entity cannot outlive an explicitly disabled owner layer. // A selected entity cannot outlive an explicitly disabled owner layer.
// Keeping these IDs would resurrect tracking when that layer is enabled // Keeping these IDs would resurrect tracking when that layer is enabled
// later, even though OFF was newer explicit intent. // later, even though OFF was newer explicit intent.
if (!enabled.has('flights')) options.flights.selectedFlightsTrackingId = null; if (!enabled.has('flights')) options.flights.selectedFlightsTrackingId = null;
if (!enabled.has('military')) options.flights.selectedMilitaryTrackingId = null; if (!enabled.has('military'))
if (!enabled.has('satellites')) options.satellites.selectedSatTrackingId = null; options.flights.selectedMilitaryTrackingId = null;
if (!enabled.has('satellites'))
options.satellites.selectedSatTrackingId = null;
// The codec has no cross-family recency field, so multiple tracking IDs are // The codec has no cross-family recency field, so multiple tracking IDs are
// ambiguous rather than an ordered handoff. Fail closed instead of letting // ambiguous rather than an ordered handoff. Fail closed instead of letting
// asynchronous feed arrival decide which tracker and camera owner wins. // asynchronous feed arrival decide which tracker and camera owner wins.
@@ -414,7 +518,10 @@ export function cloneLayerState(state) {
...normalized, ...normalized,
enabledLayerIds: [...normalized.enabledLayerIds], enabledLayerIds: [...normalized.enabledLayerIds],
options: Object.fromEntries( options: Object.fromEntries(
Object.entries(normalized.options).map(([id, options]) => [id, { ...options }]), Object.entries(normalized.options).map(([id, options]) => [
id,
{ ...options },
]),
), ),
}; };
} }
@@ -423,10 +530,12 @@ export function cloneLayerState(state) {
export function encodeLayerStateParams(params, state) { export function encodeLayerStateParams(params, state) {
const normalized = normalizeLayerState(state); const normalized = normalizeLayerState(state);
const enabled = new Set(normalized.enabledLayerIds); const enabled = new Set(normalized.enabledLayerIds);
params.set('l', LAYER_STATE_REGISTRY params.set(
.filter((entry) => enabled.has(entry.id)) 'l',
LAYER_STATE_REGISTRY.filter((entry) => enabled.has(entry.id))
.map((entry) => entry.token) .map((entry) => entry.token)
.join('.')); .join('.'),
);
const encodedOptions = []; const encodedOptions = [];
for (const ownerId of OPTION_OWNER_IDS) { for (const ownerId of OPTION_OWNER_IDS) {
const ownerEntry = REGISTRY_BY_ID.get(ownerId); const ownerEntry = REGISTRY_BY_ID.get(ownerId);
@@ -436,7 +545,9 @@ export function encodeLayerStateParams(params, state) {
// current default — those are the same thing for every option whose // current default — those are the same thing for every option whose
// default never moved, and deliberately different for one whose did. // default never moved, and deliberately different for one whose did.
if (ownerOptions[spec.key] === absentTokenValue(spec)) continue; if (ownerOptions[spec.key] === absentTokenValue(spec)) continue;
encodedOptions.push(`${ownerEntry.token}.${spec.token}.${spec.encode(ownerOptions[spec.key])}`); encodedOptions.push(
`${ownerEntry.token}.${spec.token}.${spec.encode(ownerOptions[spec.key])}`,
);
} }
} }
if (encodedOptions.length) params.set('lo', encodedOptions.join('_')); if (encodedOptions.length) params.set('lo', encodedOptions.join('_'));
@@ -446,7 +557,8 @@ export function encodeLayerStateParams(params, state) {
/** Decode v2 fields. Null means that the layer payload is absent. */ /** Decode v2 fields. Null means that the layer payload is absent. */
export function decodeLayerStateParams(params) { export function decodeLayerStateParams(params) {
if (params.get('v') !== String(LAYER_STATE_VERSION) || !params.has('l')) return null; if (params.get('v') !== String(LAYER_STATE_VERSION) || !params.has('l'))
return null;
const rawLayers = String(params.get('l') || ''); const rawLayers = String(params.get('l') || '');
const rawOptionsField = String(params.get('lo') || ''); const rawOptionsField = String(params.get('lo') || '');
// Fail closed on an oversized payload rather than decoding a truncated one. // Fail closed on an oversized payload rather than decoding a truncated one.
@@ -457,16 +569,21 @@ export function decodeLayerStateParams(params) {
// set containing an unknown member rejects the complete layer payload so a // set containing an unknown member rejects the complete layer payload so a
// typo or future token cannot silently become an authoritative empty set. // typo or future token cannot silently become an authoritative empty set.
if (layerTokens.some((token) => !REGISTRY_BY_TOKEN.has(token))) return null; if (layerTokens.some((token) => !REGISTRY_BY_TOKEN.has(token))) return null;
const enabledLayerIds = layerTokens.map((token) => REGISTRY_BY_TOKEN.get(token).id); const enabledLayerIds = layerTokens.map(
(token) => REGISTRY_BY_TOKEN.get(token).id,
);
const rawOptions = {}; const rawOptions = {};
for (const assignment of rawOptionsField.split('_')) { for (const assignment of rawOptionsField.split('_')) {
if (!assignment) continue; if (!assignment) continue;
const [layerToken, optionToken, encodedValue, ...extra] = assignment.split('.'); const [layerToken, optionToken, encodedValue, ...extra] =
assignment.split('.');
if (extra.length) continue; if (extra.length) continue;
const entry = REGISTRY_BY_TOKEN.get(layerToken); const entry = REGISTRY_BY_TOKEN.get(layerToken);
const ownerId = entry?.optionOwner || null; const ownerId = entry?.optionOwner || null;
if (!ownerId) continue; if (!ownerId) continue;
const spec = optionSpecs(ownerId).find((candidate) => candidate.token === optionToken); const spec = optionSpecs(ownerId).find(
(candidate) => candidate.token === optionToken,
);
if (!spec) continue; if (!spec) continue;
const decoded = spec.decode(encodedValue); const decoded = spec.decode(encodedValue);
if (decoded === null) continue; if (decoded === null) continue;
@@ -480,7 +597,8 @@ export function decodeLayerStateParams(params) {
// saying "whatever the default happens to be today". See `absentTokenValue`. // saying "whatever the default happens to be today". See `absentTokenValue`.
for (const ownerId of OPTION_OWNER_IDS) { for (const ownerId of OPTION_OWNER_IDS) {
for (const spec of optionSpecs(ownerId)) { for (const spec of optionSpecs(ownerId)) {
if (rawOptions[ownerId] && Object.hasOwn(rawOptions[ownerId], spec.key)) continue; if (rawOptions[ownerId] && Object.hasOwn(rawOptions[ownerId], spec.key))
continue;
if (!rawOptions[ownerId]) rawOptions[ownerId] = {}; if (!rawOptions[ownerId]) rawOptions[ownerId] = {};
rawOptions[ownerId][spec.key] = absentTokenValue(spec); rawOptions[ownerId][spec.key] = absentTokenValue(spec);
} }
@@ -502,8 +620,12 @@ export function parseStoredLayerState(raw) {
if (typeof raw !== 'string' || !raw) return null; if (typeof raw !== 'string' || !raw) return null;
try { try {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (parsed?.v !== LAYER_STATE_VERSION || !Array.isArray(parsed.l)) return null; if (parsed?.v !== LAYER_STATE_VERSION || !Array.isArray(parsed.l))
return normalizeLayerState({ enabledLayerIds: parsed.l, options: parsed.o }); return null;
return normalizeLayerState({
enabledLayerIds: parsed.l,
options: parsed.o,
});
} catch { } catch {
return null; return null;
} }
@@ -517,7 +639,11 @@ export function layerOptionsForRestore(state, layerId) {
} }
function safeStorage() { function safeStorage() {
try { return globalThis.localStorage || null; } catch { return null; } try {
return globalThis.localStorage || null;
} catch {
return null;
}
} }
function currentLayerOutcome(dataManager, layerId) { function currentLayerOutcome(dataManager, layerId) {
@@ -534,7 +660,10 @@ function currentLayerOutcome(dataManager, layerId) {
* choreography, and coordinates passive post-registration restoration. * choreography, and coordinates passive post-registration restoration.
*/ */
export class LayerStateCoordinator { export class LayerStateCoordinator {
constructor(dataManager, shareLinkManager, { constructor(
dataManager,
shareLinkManager,
{
storage = safeStorage(), storage = safeStorage(),
restoreGate = null, restoreGate = null,
onDurableStateChange = null, onDurableStateChange = null,
@@ -544,9 +673,12 @@ export class LayerStateCoordinator {
// without sleeping out a 90 s expiry. // without sleeping out a 90 s expiry.
setTimer = (fn, ms) => setTimeout(fn, ms), setTimer = (fn, ms) => setTimeout(fn, ms),
clearTimer = (handle) => clearTimeout(handle), clearTimer = (handle) => clearTimeout(handle),
} = {}) { } = {},
) {
if (!dataManager?.registrationsFinalized) { if (!dataManager?.registrationsFinalized) {
throw new Error('Layer state requires finalized data-layer registrations'); throw new Error(
'Layer state requires finalized data-layer registrations',
);
} }
this.dataManager = dataManager; this.dataManager = dataManager;
this.shareLinkManager = shareLinkManager || null; this.shareLinkManager = shareLinkManager || null;
@@ -566,23 +698,41 @@ export class LayerStateCoordinator {
this._trackingRestoreGeneration = 0; this._trackingRestoreGeneration = 0;
this._pendingTrackingTimer = null; this._pendingTrackingTimer = null;
this._pendingTrackingContext = null; this._pendingTrackingContext = null;
this._unsubscribe = this.dataManager.subscribe((change) => this._handleManagerChange(change)); this._unsubscribe = this.dataManager.subscribe((change) =>
this._unsubscribeVisibilityRequests = this.dataManager.subscribeVisibilityRequests( this._handleManagerChange(change),
(change) => this._handleVisibilityRequest(change), );
this._unsubscribeVisibilityRequests =
this.dataManager.subscribeVisibilityRequests((change) =>
this._handleVisibilityRequest(change),
); );
this.restorePromise = Promise.resolve([]); this.restorePromise = Promise.resolve([]);
this.lastRestoreResults = []; this.lastRestoreResults = [];
} }
start({ shareLayerState = null, allowLocalState = true, shareCreatedAtMs = null } = {}) { start({
if (this._destroyed) throw new Error('Layer-state coordinator is destroyed'); shareLayerState = null,
let selected = shareLayerState ? normalizeLayerState(shareLayerState) : null; allowLocalState = true,
shareCreatedAtMs = null,
} = {}) {
if (this._destroyed)
throw new Error('Layer-state coordinator is destroyed');
let selected = shareLayerState
? normalizeLayerState(shareLayerState)
: null;
if (selected) { if (selected) {
this._source = 'share'; this._source = 'share';
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null; this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs)
? shareCreatedAtMs
: null;
} else if (allowLocalState) { } else if (allowLocalState) {
let stored = null; let stored = null;
try { stored = parseStoredLayerState(this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY)); } catch { /* best effort */ } try {
stored = parseStoredLayerState(
this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY),
);
} catch {
/* best effort */
}
if (stored) { if (stored) {
selected = stored; selected = stored;
this._source = 'local'; this._source = 'local';
@@ -594,12 +744,16 @@ export class LayerStateCoordinator {
this._source = 'legacy-share'; this._source = 'legacy-share';
} }
this._durableState = selected || createDefaultLayerState(); this._durableState = selected || createDefaultLayerState();
this.shareLinkManager?.setLayerStateProvider?.(() => this.getDurableState()); this.shareLinkManager?.setLayerStateProvider?.(() =>
this.getDurableState(),
);
this.shareLinkManager?.onLayerStateChange?.(); this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState(); this._notifyDurableState();
if (!selected) return this.restorePromise; if (!selected) return this.restorePromise;
this.restorePromise = this._restoreSelectedState( this.restorePromise = this._restoreSelectedState(
this._source === 'share' ? LAYER_RESTORE_ORIGINS.share : LAYER_RESTORE_ORIGINS.local, this._source === 'share'
? LAYER_RESTORE_ORIGINS.share
: LAYER_RESTORE_ORIGINS.local,
); );
return this.restorePromise; return this.restorePromise;
} }
@@ -613,12 +767,18 @@ export class LayerStateCoordinator {
} }
_notifyDurableState() { _notifyDurableState() {
try { this.onDurableStateChange?.(this.getDurableState()); } catch { /* UI sync is best effort */ } try {
this.onDurableStateChange?.(this.getDurableState());
} catch {
/* UI sync is best effort */
}
} }
_handleVisibilityRequest(change) { _handleVisibilityRequest(change) {
if (!isExplicitLayerStateOrigin(change?.origin)) return; if (!isExplicitLayerStateOrigin(change?.origin)) return;
this._restoreControllers.get(change.layerId)?.abort('superseded-by-explicit-visibility'); this._restoreControllers
.get(change.layerId)
?.abort('superseded-by-explicit-visibility');
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) { if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
this._revokePendingTrackingWatch('superseded-by-explicit-visibility'); this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
} }
@@ -626,7 +786,8 @@ export class LayerStateCoordinator {
/** Revoke every passive restore before explicit navigation can be reclaimed. */ /** Revoke every passive restore before explicit navigation can be reclaimed. */
cancelPendingRestores(reason = 'superseded-by-explicit-navigation') { cancelPendingRestores(reason = 'superseded-by-explicit-navigation') {
for (const controller of this._restoreControllers.values()) controller.abort(reason); for (const controller of this._restoreControllers.values())
controller.abort(reason);
this._revokePendingTrackingWatch(reason); this._revokePendingTrackingWatch(reason);
} }
@@ -634,9 +795,10 @@ export class LayerStateCoordinator {
* Revoke a pending shared Follow. Physical navigation may also clear only * Revoke a pending shared Follow. Physical navigation may also clear only
* the exact passive selection, without writing recipient preferences. * the exact passive selection, without writing recipient preferences.
*/ */
cancelPendingShareTracking(reason = 'superseded-by-explicit-navigation', { cancelPendingShareTracking(
clearSelection = false, reason = 'superseded-by-explicit-navigation',
} = {}) { { clearSelection = false } = {},
) {
this._revokePendingTrackingWatch(reason); this._revokePendingTrackingWatch(reason);
if (!clearSelection) return false; if (!clearSelection) return false;
const selected = this._selectedShareTrackingTarget(); const selected = this._selectedShareTrackingTarget();
@@ -649,8 +811,10 @@ export class LayerStateCoordinator {
// option request may replace passive share options, but it must not abort // option request may replace passive share options, but it must not abort
// the same layer's visibility lifecycle. // the same layer's visibility lifecycle.
if (change.type === 'params-requested') { if (change.type === 'params-requested') {
if (isExplicitLayerStateOrigin(change.origin) if (
&& SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) { isExplicitLayerStateOrigin(change.origin) &&
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
) {
this._revokePendingTrackingWatch('superseded-by-explicit-params'); this._revokePendingTrackingWatch('superseded-by-explicit-params');
} }
return; return;
@@ -658,21 +822,28 @@ export class LayerStateCoordinator {
// A layer that goes away takes its latch with it, at ANY origin — a // A layer that goes away takes its latch with it, at ANY origin — a
// programmatic disable or teardown never reaches the explicit-intent path // programmatic disable or teardown never reaches the explicit-intent path
// below, so revoke here before that early return. // below, so revoke here before that early return.
if (change.type === 'visibility' if (
&& change.enabled === false change.type === 'visibility' &&
&& SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) { change.enabled === false &&
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
) {
this._revokePendingTrackingWatch('owner-layer-disabled'); this._revokePendingTrackingWatch('owner-layer-disabled');
} }
if (!isExplicitLayerStateOrigin(change.origin)) return; if (!isExplicitLayerStateOrigin(change.origin)) return;
if (change.type === 'visibility') { if (change.type === 'visibility') {
this._restoreControllers.get(change.layerId)?.abort('superseded-by-explicit-visibility'); this._restoreControllers
.get(change.layerId)
?.abort('superseded-by-explicit-visibility');
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) { if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
this._revokePendingTrackingWatch('superseded-by-explicit-visibility'); this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
} }
const enabled = new Set(this._durableState.enabledLayerIds); const enabled = new Set(this._durableState.enabledLayerIds);
if (change.enabled) enabled.add(change.layerId); if (change.enabled) enabled.add(change.layerId);
else enabled.delete(change.layerId); else enabled.delete(change.layerId);
this._commitExplicit({ ...this._durableState, enabledLayerIds: [...enabled] }); this._commitExplicit({
...this._durableState,
enabledLayerIds: [...enabled],
});
return; return;
} }
if (change.type !== 'params') return; if (change.type !== 'params') return;
@@ -681,7 +852,8 @@ export class LayerStateCoordinator {
const ownerId = entry.optionOwner; const ownerId = entry.optionOwner;
const nextOwnerOptions = { ...this._durableState.options[ownerId] }; const nextOwnerOptions = { ...this._durableState.options[ownerId] };
const requestedParams = change.requestedParams || {}; const requestedParams = change.requestedParams || {};
const trackingOptionKey = TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null; const trackingOptionKey =
TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null;
let changed = false; let changed = false;
for (const spec of optionSpecs(ownerId)) { for (const spec of optionSpecs(ownerId)) {
// Only persist keys present in this explicit request. getLayerParams() // Only persist keys present in this explicit request. getLayerParams()
@@ -691,7 +863,8 @@ export class LayerStateCoordinator {
// in that family, so its wider live value (active ID or null) must replace // in that family, so its wider live value (active ID or null) must replace
// the formerly durable pending ID instead of allowing reload resurrection. // the formerly durable pending ID instead of allowing reload resurrection.
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key); const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
const implicitTrackingSync = !explicitlyRequested && spec.key === trackingOptionKey; const implicitTrackingSync =
!explicitlyRequested && spec.key === trackingOptionKey;
if (!explicitlyRequested && !implicitTrackingSync) continue; if (!explicitlyRequested && !implicitTrackingSync) continue;
const value = spec.normalize(change.params[spec.key]); const value = spec.normalize(change.params[spec.key]);
if (value === null) { if (value === null) {
@@ -700,7 +873,8 @@ export class LayerStateCoordinator {
changed = true; changed = true;
continue; continue;
} }
if (spec.defaultValue !== null || change.params[spec.key] !== null) continue; if (spec.defaultValue !== null || change.params[spec.key] !== null)
continue;
} }
nextOwnerOptions[spec.key] = value; nextOwnerOptions[spec.key] = value;
changed = true; changed = true;
@@ -719,14 +893,18 @@ export class LayerStateCoordinator {
if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) { if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) {
this.storage?.setItem?.(LAYER_STATE_STORAGE_KEY, serialized); this.storage?.setItem?.(LAYER_STATE_STORAGE_KEY, serialized);
} }
} catch { /* storage can be unavailable or quota-limited */ } } catch {
/* storage can be unavailable or quota-limited */
}
this.shareLinkManager?.onLayerStateChange?.(); this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState(); this._notifyDurableState();
} }
async _waitForRestoreGate() { async _waitForRestoreGate() {
if (!this.restoreGate) return; if (!this.restoreGate) return;
await (typeof this.restoreGate === 'function' ? this.restoreGate() : this.restoreGate); await (typeof this.restoreGate === 'function'
? this.restoreGate()
: this.restoreGate);
} }
async _restoreSelectedState(origin) { async _restoreSelectedState(origin) {
@@ -736,12 +914,15 @@ export class LayerStateCoordinator {
try { try {
await this._waitForRestoreGate(); await this._waitForRestoreGate();
const enabled = new Set(this._durableState.enabledLayerIds); const enabled = new Set(this._durableState.enabledLayerIds);
const settled = await Promise.allSettled(LAYER_STATE_REGISTRY.map(async (entry) => { const settled = await Promise.allSettled(
LAYER_STATE_REGISTRY.map(async (entry) => {
const controller = this._restoreControllers.get(entry.id); const controller = this._restoreControllers.get(entry.id);
const targetEnabled = enabled.has(entry.id); const targetEnabled = enabled.has(entry.id);
const options = layerOptionsForRestore(this._durableState, entry.id); const options = layerOptionsForRestore(this._durableState, entry.id);
if (origin === LAYER_RESTORE_ORIGINS.share && options) { if (origin === LAYER_RESTORE_ORIGINS.share && options) {
for (const trackingKey of Object.values(TRACKING_OPTION_KEY_BY_LAYER)) { for (const trackingKey of Object.values(
TRACKING_OPTION_KEY_BY_LAYER,
)) {
delete options[trackingKey]; delete options[trackingKey];
} }
} }
@@ -762,18 +943,29 @@ export class LayerStateCoordinator {
// Reserve passive option state before any asynchronous lifecycle work. // Reserve passive option state before any asynchronous lifecycle work.
// A later explicit params intent then wins on its own lane without // A later explicit params intent then wins on its own lane without
// cancelling or being overwritten by the visibility restore. // cancelling or being overwritten by the visibility restore.
const paramsSucceeded = !options || Object.keys(options).length === 0 const paramsSucceeded =
|| this.dataManager.setLayerParams(entry.id, options, { origin }); !options ||
return this.dataManager.restoreLayerState(entry.id, { Object.keys(options).length === 0 ||
this.dataManager.setLayerParams(entry.id, options, { origin });
return this.dataManager
.restoreLayerState(
entry.id,
{
enabled: targetEnabled, enabled: targetEnabled,
params: null, params: null,
}, { origin, signal: controller.signal }).then((result) => ({ },
{ origin, signal: controller.signal },
)
.then((result) => ({
...result, ...result,
appliedOptions: paramsSucceeded && options ? options : {}, appliedOptions: paramsSucceeded && options ? options : {},
errorClass: paramsSucceeded ? result.errorClass : 'ParamsRejected', errorClass: paramsSucceeded
? result.errorClass
: 'ParamsRejected',
succeeded: paramsSucceeded && result.succeeded, succeeded: paramsSucceeded && result.succeeded,
})); }));
})); }),
);
this.lastRestoreResults = settled.map((result, index) => { this.lastRestoreResults = settled.map((result, index) => {
if (result.status === 'fulfilled') return result.value; if (result.status === 'fulfilled') return result.value;
const entry = LAYER_STATE_REGISTRY[index]; const entry = LAYER_STATE_REGISTRY[index];
@@ -800,8 +992,11 @@ export class LayerStateCoordinator {
_selectedShareTrackingTarget() { _selectedShareTrackingTarget() {
if (this._source !== 'share') return null; if (this._source !== 'share') return null;
for (const [layerId, policy] of Object.entries(SHARE_TRACKING_RESTORE_POLICIES)) { for (const [layerId, policy] of Object.entries(
const targetId = this._durableState.options?.[policy.optionOwner]?.[policy.optionKey]; SHARE_TRACKING_RESTORE_POLICIES,
)) {
const targetId =
this._durableState.options?.[policy.optionOwner]?.[policy.optionKey];
if (targetId !== null && targetId !== undefined && targetId !== '') { if (targetId !== null && targetId !== undefined && targetId !== '') {
return { layerId, targetId, ...policy }; return { layerId, targetId, ...policy };
} }
@@ -810,7 +1005,8 @@ export class LayerStateCoordinator {
} }
_passivelyClearTrackingSelection(selected) { _passivelyClearTrackingSelection(selected) {
const current = this._durableState.options?.[selected.optionOwner]?.[selected.optionKey]; const current =
this._durableState.options?.[selected.optionOwner]?.[selected.optionKey];
if (String(current) !== String(selected.targetId)) return false; if (String(current) !== String(selected.targetId)) return false;
const ownerOptions = { const ownerOptions = {
...this._durableState.options[selected.optionOwner], ...this._durableState.options[selected.optionOwner],
@@ -850,13 +1046,17 @@ export class LayerStateCoordinator {
_trackingTargetLatched(selected) { _trackingTargetLatched(selected) {
const params = this.dataManager.getLayerParams?.(selected.layerId); const params = this.dataManager.getLayerParams?.(selected.layerId);
const active = params?.[selected.optionKey]; const active = params?.[selected.optionKey];
return active !== null && active !== undefined return (
&& String(active) === String(selected.targetId); active !== null &&
active !== undefined &&
String(active) === String(selected.targetId)
);
} }
/** Stop watching a pending shared subject without deciding its fate. */ /** Stop watching a pending shared subject without deciding its fate. */
_cancelPendingTrackingWatch() { _cancelPendingTrackingWatch() {
if (this._pendingTrackingTimer !== null) this.clearTimer(this._pendingTrackingTimer); if (this._pendingTrackingTimer !== null)
this.clearTimer(this._pendingTrackingTimer);
this._pendingTrackingTimer = null; this._pendingTrackingTimer = null;
const pending = this._pendingTrackingContext; const pending = this._pendingTrackingContext;
if (pending?.signal && pending.abortHandler) { if (pending?.signal && pending.abortHandler) {
@@ -867,7 +1067,11 @@ export class LayerStateCoordinator {
/** Publish a share-follow lifecycle update without allowing UI errors to own state. */ /** Publish a share-follow lifecycle update without allowing UI errors to own state. */
_publishTrackingRestoreStatus(status) { _publishTrackingRestoreStatus(status) {
try { this.onTrackingRestoreStatus?.(status); } catch { /* status UI is best effort */ } try {
this.onTrackingRestoreStatus?.(status);
} catch {
/* status UI is best effort */
}
} }
/** /**
@@ -927,15 +1131,20 @@ export class LayerStateCoordinator {
let armed = false; let armed = false;
let armError = null; let armError = null;
try { try {
armed = await this.dataManager.setLayerParams?.( armed =
(await this.dataManager.setLayerParams?.(
selected.layerId, selected.layerId,
{ [selected.optionKey]: selected.targetId }, { [selected.optionKey]: selected.targetId },
{ origin: LAYER_RESTORE_ORIGINS.share }, { origin: LAYER_RESTORE_ORIGINS.share },
) === true; )) === true;
} catch (error) { } catch (error) {
armError = error; armError = error;
} }
if (this._destroyed || generation !== this._trackingRestoreGeneration || signal?.aborted) { if (
this._destroyed ||
generation !== this._trackingRestoreGeneration ||
signal?.aborted
) {
if (armed) { if (armed) {
this.dataManager.cancelPendingLayerRestore?.(selected.layerId, { this.dataManager.cancelPendingLayerRestore?.(selected.layerId, {
origin: LAYER_RESTORE_ORIGINS.share, origin: LAYER_RESTORE_ORIGINS.share,
@@ -957,7 +1166,9 @@ export class LayerStateCoordinator {
...selected, ...selected,
status: 'source-unavailable', status: 'source-unavailable',
classification: 'source-unavailable', classification: 'source-unavailable',
reason: String(armError?.message || armError || 'tracking restore latch rejected'), reason: String(
armError?.message || armError || 'tracking restore latch rejected',
),
cleared: this._passivelyClearTrackingSelection(selected), cleared: this._passivelyClearTrackingSelection(selected),
}; };
this._publishTrackingRestoreStatus(terminal); this._publishTrackingRestoreStatus(terminal);
@@ -972,7 +1183,8 @@ export class LayerStateCoordinator {
}; };
const poll = () => { const poll = () => {
this._pendingTrackingTimer = null; this._pendingTrackingTimer = null;
if (this._destroyed || generation !== this._trackingRestoreGeneration) return; if (this._destroyed || generation !== this._trackingRestoreGeneration)
return;
// The layer that owns the latch may have gone away since the last tick // The layer that owns the latch may have gone away since the last tick
// (disable, teardown, replacement). There is nothing left attempting this // (disable, teardown, replacement). There is nothing left attempting this
// restore, so abandon it silently rather than announcing a verdict. // restore, so abandon it silently rather than announcing a verdict.
@@ -981,23 +1193,39 @@ export class LayerStateCoordinator {
return; return;
} }
if (this._trackingTargetLatched(selected)) { if (this._trackingTargetLatched(selected)) {
settle({ ...probe, ...selected, status: 'found', classification: 'followed', cleared: false }); settle({
...probe,
...selected,
status: 'found',
classification: 'followed',
cleared: false,
});
return; return;
} }
if (this.now() >= deadline) { if (this.now() >= deadline) {
// The window really has elapsed — only now does the verdict apply. // The window really has elapsed — only now does the verdict apply.
const classification = probe.status === 'missing' const classification =
probe.status === 'missing'
? this._classifyMissingTrackingTarget(selected, absentAtMs) ? this._classifyMissingTrackingTarget(selected, absentAtMs)
: 'source-unavailable'; : 'source-unavailable';
const cleared = this._passivelyClearTrackingSelection(selected); const cleared = this._passivelyClearTrackingSelection(selected);
settle({ ...probe, ...selected, classification, cleared }); settle({ ...probe, ...selected, classification, cleared });
return; return;
} }
this._pendingTrackingTimer = this.setTimer(poll, PENDING_TRACKING_POLL_MS); this._pendingTrackingTimer = this.setTimer(
poll,
PENDING_TRACKING_POLL_MS,
);
this._pendingTrackingTimer?.unref?.(); this._pendingTrackingTimer?.unref?.();
}; };
this._cancelPendingTrackingWatch(); this._cancelPendingTrackingWatch();
const pending = { ...probe, ...selected, status: 'pending', classification: 'pending', cleared: false }; const pending = {
...probe,
...selected,
status: 'pending',
classification: 'pending',
cleared: false,
};
const pendingContext = { const pendingContext = {
generation, generation,
selected, selected,
@@ -1010,7 +1238,9 @@ export class LayerStateCoordinator {
if (this._pendingTrackingContext?.generation !== generation) return; if (this._pendingTrackingContext?.generation !== generation) return;
this._revokePendingTrackingWatch(signal.reason || 'aborted'); this._revokePendingTrackingWatch(signal.reason || 'aborted');
}; };
signal.addEventListener('abort', pendingContext.abortHandler, { once: true }); signal.addEventListener('abort', pendingContext.abortHandler, {
once: true,
});
} }
this._pendingTrackingContext = pendingContext; this._pendingTrackingContext = pendingContext;
this._publishTrackingRestoreStatus(pending); this._publishTrackingRestoreStatus(pending);
@@ -1025,7 +1255,8 @@ export class LayerStateCoordinator {
*/ */
async restoreShareTrackingSelection({ signal = null } = {}) { async restoreShareTrackingSelection({ signal = null } = {}) {
const selected = this._selectedShareTrackingTarget(); const selected = this._selectedShareTrackingTarget();
if (!selected || this._destroyed) return { status: 'skipped', reason: 'no-shared-target' }; if (!selected || this._destroyed)
return { status: 'skipped', reason: 'no-shared-target' };
this._revokePendingTrackingWatch('superseded-by-newer-restore'); this._revokePendingTrackingWatch('superseded-by-newer-restore');
const controller = new AbortController(); const controller = new AbortController();
const combinedSignal = signal const combinedSignal = signal
@@ -1042,31 +1273,58 @@ export class LayerStateCoordinator {
); );
} catch (error) { } catch (error) {
result = combinedSignal.aborted result = combinedSignal.aborted
? { status: 'cancelled', reason: String(combinedSignal.reason || 'aborted') } ? {
: { status: 'source-unavailable', reason: String(error?.message || error) }; status: 'cancelled',
reason: String(combinedSignal.reason || 'aborted'),
} }
if (generation !== this._trackingRestoreGeneration || this._destroyed || combinedSignal.aborted) { : {
if (this._trackingRestoreController === controller) this._trackingRestoreController = null; status: 'source-unavailable',
return { ...result, status: 'cancelled', reason: String(combinedSignal.reason || 'superseded') }; reason: String(error?.message || error),
};
} }
if (this._trackingRestoreController === controller) this._trackingRestoreController = null; if (
generation !== this._trackingRestoreGeneration ||
this._destroyed ||
combinedSignal.aborted
) {
if (this._trackingRestoreController === controller)
this._trackingRestoreController = null;
return {
...result,
status: 'cancelled',
reason: String(combinedSignal.reason || 'superseded'),
};
}
if (this._trackingRestoreController === controller)
this._trackingRestoreController = null;
if (result.status === 'found') { if (result.status === 'found') {
const terminal = { ...result, ...selected, classification: 'followed', cleared: false }; const terminal = {
...result,
...selected,
classification: 'followed',
cleared: false,
};
this._publishTrackingRestoreStatus(terminal); this._publishTrackingRestoreStatus(terminal);
return terminal; return terminal;
} }
if (['cancelled', 'superseded', 'destroyed'].includes(result.status)) return result; if (['cancelled', 'superseded', 'destroyed'].includes(result.status))
return result;
// A subject that is simply not here YET is not a subject that is gone. Hold // A subject that is simply not here YET is not a subject that is gone. Hold
// it on the layer's own deferred-restore latch for its source-specific // it on the layer's own deferred-restore latch for its source-specific
// window before any verdict is reached or shown. `unsupported` layers have // window before any verdict is reached or shown. `unsupported` layers have
// no latch to arm, so they still decide immediately. // no latch to arm, so they still decide immediately.
if (result.status === 'missing' || result.status === 'source-unavailable') { if (result.status === 'missing' || result.status === 'source-unavailable') {
return this._beginPendingTrackingRestore(selected, result, combinedSignal); return this._beginPendingTrackingRestore(
selected,
result,
combinedSignal,
);
} }
const classification = result.status === 'missing' const classification =
result.status === 'missing'
? this._classifyMissingTrackingTarget(selected) ? this._classifyMissingTrackingTarget(selected)
: 'source-unavailable'; : 'source-unavailable';
const cleared = this._passivelyClearTrackingSelection(selected); const cleared = this._passivelyClearTrackingSelection(selected);
@@ -1078,7 +1336,8 @@ export class LayerStateCoordinator {
destroy() { destroy() {
if (this._destroyed) return; if (this._destroyed) return;
this._destroyed = true; this._destroyed = true;
for (const controller of this._restoreControllers.values()) controller.abort('coordinator-destroyed'); for (const controller of this._restoreControllers.values())
controller.abort('coordinator-destroyed');
this._restoreControllers.clear(); this._restoreControllers.clear();
this._revokePendingTrackingWatch('coordinator-destroyed'); this._revokePendingTrackingWatch('coordinator-destroyed');
this._trackingRestoreController = null; this._trackingRestoreController = null;
+8 -3
View File
@@ -11,8 +11,10 @@ export * from './localGeojsonCore.js';
export function createLocalGeoJsonLayer(options) { export function createLocalGeoJsonLayer(options) {
return createLayer(options, { return createLayer(options, {
...localGeoJsonServices, ...localGeoJsonServices,
overlayHost: options.overlayHost === undefined overlayHost:
? localGeoJsonServices.overlayHost : options.overlayHost, options.overlayHost === undefined
? localGeoJsonServices.overlayHost
: options.overlayHost,
}); });
} }
@@ -20,6 +22,9 @@ export function createLocalGeoJsonLayer(options) {
export function createLocalInfrastructureOverlayPublisher(options) { export function createLocalInfrastructureOverlayPublisher(options) {
return createPublisher({ return createPublisher({
...options, ...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', source: 'NASA FIRMS · LIVE',
}); });
export default [ export default [datacenters, dams, submarineCablesLayer, fires];
datacenters,
dams,
submarineCablesLayer,
fires,
];
+4 -1
View File
@@ -1,4 +1,7 @@
import { createMeshFloorSampler } from '../services/meshFloorSampler.js'; import { createMeshFloorSampler } from '../services/meshFloorSampler.js';
import * as groundFloor from './groundFloor.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; export const { sampleMeshFloorCells, cachedGroundFloor } = meshFloorSampler;
+71 -16
View File
@@ -18,10 +18,13 @@ export const AWARENESS_RELATIONSHIP = Object.freeze({
* @param {{initialRadiusM?: number, maxRadiusM?: number}} [options] Bounds. * @param {{initialRadiusM?: number, maxRadiusM?: number}} [options] Bounds.
* @returns {{candidate: T, radiusM: number}|null} First match and radius used. * @returns {{candidate: T, radiusM: number}|null} First match and radius used.
*/ */
export function findByDoublingRadius(search, { export function findByDoublingRadius(
search,
{
initialRadiusM = AWARENESS_RADIUS_M, initialRadiusM = AWARENESS_RADIUS_M,
maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M, maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
} = {}) { } = {},
) {
if (typeof search !== 'function') return null; if (typeof search !== 'function') return null;
if (!Number.isFinite(initialRadiusM) || initialRadiusM <= 0) return null; if (!Number.isFinite(initialRadiusM) || initialRadiusM <= 0) return null;
if (!Number.isFinite(maxRadiusM) || maxRadiusM < initialRadiusM) return null; if (!Number.isFinite(maxRadiusM) || maxRadiusM < initialRadiusM) return null;
@@ -39,13 +42,22 @@ export function findByDoublingRadius(search, {
/** @param {number} meters @returns {string} */ /** @param {number} meters @returns {string} */
export function formatAwarenessDistance(meters) { export function formatAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—'; 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. */ /** Return a stable visible label without leaking missing values into UI text. */
export function formatAwarenessLabel(value) { export function formatAwarenessLabel(value) {
if (value && typeof value === 'object') { 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); const label = formatAwarenessLabel(candidate);
if (label !== '—') return label; 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 * negative finding. `UNKNOWN` is an input-data state, never an out-of-range
* claim in this MVP. * claim in this MVP.
*/ */
export function summarizeAwarenessCohort(items, { available = true, stale = false, limit = 3 } = {}) { export function summarizeAwarenessCohort(
if (!available) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed unavailable' }; items,
if (stale) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed stale' }; { 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 : []) const normalized = (Array.isArray(items) ? items : [])
.filter((item) => Number.isFinite(item?.distanceM ?? item?.distance) .filter(
&& (item.distanceM ?? item.distance) >= 0) (item) =>
Number.isFinite(item?.distanceM ?? item?.distance) &&
(item.distanceM ?? item.distance) >= 0,
)
.map((item) => ({ ...item, distanceM: item.distanceM ?? item.distance })) .map((item) => ({ ...item, distanceM: item.distanceM ?? item.distance }))
.sort((a, b) => a.distanceM - b.distanceM); .sort((a, b) => a.distanceM - b.distanceM);
return { return {
relationship: normalized.length ? AWARENESS_RELATIONSHIP.NEARBY : AWARENESS_RELATIONSHIP.UNKNOWN, relationship: normalized.length
? AWARENESS_RELATIONSHIP.NEARBY
: AWARENESS_RELATIONSHIP.UNKNOWN,
count: normalized.length, count: normalized.length,
nearest: normalized.slice(0, limit), 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. * @param {Iterable<string>} [visitedKeys] Previously visited layer/id keys.
* @returns {Array<{layerId: string, id: string, item: Object}>} Selectable targets. * @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 visited = new Set(visitedKeys);
const targets = []; const targets = [];
for (const cohort of Array.isArray(cohorts) ? cohorts : []) { for (const cohort of Array.isArray(cohorts) ? cohorts : []) {
const layerId = cohort?.id; 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) const sourceItems = Array.isArray(cohort?.summary?.navigationNearest)
? 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) { for (const item of sourceItems) {
const id = item?.icao24 || item?.mmsi || item?.id; const id = item?.icao24 || item?.mmsi || item?.id;
if (!id) continue; if (!id) continue;
const key = `${layerId}:${id}`; const key = `${layerId}:${id}`;
if (key === `${subject?.layerId}:${subject?.id}`) continue; 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) => { 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. // reached vessels, installations, or the other flight layer at all.
const visitedOrder = Number(a.visited) - Number(b.visited); const visitedOrder = Number(a.visited) - Number(b.visited);
if (visitedOrder) return visitedOrder; 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; if (subjectOrder) return subjectOrder;
return (a.item.distanceM ?? Infinity) - (b.item.distanceM ?? Infinity); 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. * @returns {string} Display label, never empty.
*/ */
export function humanizeInstallationClass(klass) { 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]; if (LABEL_BY_CLASS[key]) return LABEL_BY_CLASS[key];
const words = key.replaceAll('_', ' ').trim(); 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) { function finiteLatitude(value) {
@@ -54,7 +58,8 @@ function finiteLongitude(value) {
function pointFrom(element) { function pointFrom(element) {
const lat = Number(element?.lat ?? element?.center?.lat); const lat = Number(element?.lat ?? element?.center?.lat);
const longitude = Number(element?.lon ?? element?.center?.lon); 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). // Bounds midpoint fallback (field test 2026-08-28, Warendorf: nothing drawn).
// The proxy asks for `out center tags geom`, but Overpass takes the LAST // 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. // recovers all of them without touching the query or the footprint path.
const bounds = element?.bounds; const bounds = element?.bounds;
// JSON nulls, booleans, and empty strings must not coerce to a false 0,0 site. // JSON nulls, booleans, and empty strings must not coerce to a false 0,0 site.
if (![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon] if (
.every((value) => typeof value === 'number' && Number.isFinite(value))) return null; ![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon].every(
(value) => typeof value === 'number' && Number.isFinite(value),
)
)
return null;
const south = Number(bounds?.minlat); const south = Number(bounds?.minlat);
const west = Number(bounds?.minlon); const west = Number(bounds?.minlon);
const north = Number(bounds?.maxlat); const north = Number(bounds?.maxlat);
@@ -79,17 +88,28 @@ function pointFrom(element) {
// A single installation footprint is never wider than the request bbox cap // A single installation footprint is never wider than the request bbox cap
// (10°): an ascending box that spans the antimeridian (minlon -179, maxlon // (10°): an ascending box that spans the antimeridian (minlon -179, maxlon
// 179) passes the ordering check yet midpoints to longitude 0. // 179) passes the ordering check yet midpoints to longitude 0.
if (finiteLatitude(south) && finiteLatitude(north) if (
&& finiteLongitude(west) && finiteLongitude(east) finiteLatitude(south) &&
&& south <= north && west <= east finiteLatitude(north) &&
&& north - south <= 10 && east - west <= 10) { finiteLongitude(west) &&
finiteLongitude(east) &&
south <= north &&
west <= east &&
north - south <= 10 &&
east - west <= 10
) {
return { latitude: (south + north) / 2, longitude: (west + east) / 2 }; return { latitude: (south + north) / 2, longitude: (west + east) / 2 };
} }
return null; return null;
} }
function footprintFrom(element) { 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 = []; const points = [];
for (const point of element.geometry) { for (const point of element.geometry) {
const latitude = Number(point?.lat); const latitude = Number(point?.lat);
@@ -106,21 +126,30 @@ function footprintFrom(element) {
* @param {string} [retrievedAt] * @param {string} [retrievedAt]
* @returns {{records: Array, droppedCount: number}} * @returns {{records: Array, droppedCount: number}}
*/ */
export function normalizeMilitaryInstallations(payload, retrievedAt = new Date().toISOString()) { export function normalizeMilitaryInstallations(
payload,
retrievedAt = new Date().toISOString(),
) {
const records = []; const records = [];
const ids = new Set(); const ids = new Set();
let droppedCount = 0; 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 type = String(element?.type || '');
const osmId = Number(element?.id); 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; droppedCount += 1;
continue; continue;
} }
const id = `osm:${type}:${osmId}`; const id = `osm:${type}:${osmId}`;
const tags = element?.tags || {}; const tags = element?.tags || {};
const klass = CLASS_BY_MILITARY_TAG[String(tags.military || '').toLowerCase()] const klass =
|| (tags.landuse === 'military' ? 'military_land' : null); CLASS_BY_MILITARY_TAG[String(tags.military || '').toLowerCase()] ||
(tags.landuse === 'military' ? 'military_land' : null);
const point = pointFrom(element); const point = pointFrom(element);
if (!klass || !point || ids.has(id)) { if (!klass || !point || ids.has(id)) {
droppedCount += 1; droppedCount += 1;
@@ -134,7 +163,9 @@ export function normalizeMilitaryInstallations(payload, retrievedAt = new Date()
// geometry, while a footprint-less way/relation has unknown extent. // geometry, while a footprint-less way/relation has unknown extent.
osmType: type, osmType: type,
class: klass, class: klass,
name: String(tags.name || tags['name:en'] || '').trim() || humanizeInstallationClass(klass), name:
String(tags.name || tags['name:en'] || '').trim() ||
humanizeInstallationClass(klass),
...point, ...point,
footprint: footprintFrom(element), footprint: footprintFrom(element),
sources: [{ name: 'OpenStreetMap', id: `${type}/${osmId}`, retrievedAt }], sources: [{ name: 'OpenStreetMap', id: `${type}/${osmId}`, retrievedAt }],
@@ -152,6 +183,14 @@ export function isValidInstallationBoundingBox(value) {
const west = Number(box.west); const west = Number(box.west);
const north = Number(box.north); const north = Number(box.north);
const east = Number(box.east); const east = Number(box.east);
return finiteLatitude(south) && finiteLatitude(north) && finiteLongitude(west) && finiteLongitude(east) return (
&& south < north && west < east && north - south <= 10 && east - west <= 10; 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 * 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. */ * the aircraft it is supposed to be attached to. */
const AXIS_CORRECTION = Cesium.Matrix4.multiplyTransformation( 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 _anchorScratch = new Cesium.Cartesian3();
const _chainScratch = new Cesium.Matrix4(); const _chainScratch = new Cesium.Matrix4();
@@ -124,7 +126,11 @@ export function modelAnchorWorld(model, nativeAnchor, result) {
components || Cesium.Matrix4.IDENTITY, components || Cesium.Matrix4.IDENTITY,
_chainScratch, _chainScratch,
); );
Cesium.Matrix4.multiplyTransformation(_chainScratch, AXIS_CORRECTION, _chainScratch); Cesium.Matrix4.multiplyTransformation(
_chainScratch,
AXIS_CORRECTION,
_chainScratch,
);
return Cesium.Matrix4.multiplyByPoint(_chainScratch, _anchorScratch, result); return Cesium.Matrix4.multiplyByPoint(_chainScratch, _anchorScratch, result);
} }
@@ -220,9 +226,17 @@ export function trailHeadStart(start, anchor, center, radiusM, result) {
// a real trail. // a real trail.
if (!anchor || !center || !result) return start; if (!anchor || !center || !result) return start;
if (!Number.isFinite(radiusM) || radiusM <= 0) 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; 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 // 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 // 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. // 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 x = (nativeCenter?.[0] || 0) * safeScale;
const y = (nativeCenter?.[1] || 0) * safeScale; const y = (nativeCenter?.[1] || 0) * safeScale;
const z = (nativeCenter?.[2] || 0) * safeScale; const z = (nativeCenter?.[2] || 0) * safeScale;
result.x = modelMatrix[0] * x + modelMatrix[4] * y + modelMatrix[8] * z + modelMatrix[12]; result.x =
result.y = modelMatrix[1] * x + modelMatrix[5] * y + modelMatrix[9] * z + modelMatrix[13]; modelMatrix[0] * x +
result.z = modelMatrix[2] * x + modelMatrix[6] * y + modelMatrix[10] * z + modelMatrix[14]; 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; return result;
} }
+102 -27
View File
@@ -29,10 +29,14 @@ export function norm360(deg) {
export function liftRepeatedGroundFix(newest, candidatePosition, onGround) { export function liftRepeatedGroundFix(newest, candidatePosition, onGround) {
if (!onGround || !newest?.position || !candidatePosition) return false; if (!onGround || !newest?.position || !candidatePosition) return false;
const stored = Cesium.Cartographic.fromCartesian( const stored = Cesium.Cartographic.fromCartesian(
newest.position, Cesium.Ellipsoid.WGS84, _mmCarto, newest.position,
Cesium.Ellipsoid.WGS84,
_mmCarto,
); );
const candidate = Cesium.Cartographic.fromCartesian( const candidate = Cesium.Cartographic.fromCartesian(
candidatePosition, Cesium.Ellipsoid.WGS84, _mmCandidateCarto, candidatePosition,
Cesium.Ellipsoid.WGS84,
_mmCandidateCarto,
); );
if (!stored || !candidate || candidate.height <= stored.height) return false; if (!stored || !candidate || candidate.height <= stored.height) return false;
Cesium.Cartesian3.fromRadians( Cesium.Cartesian3.fromRadians(
@@ -62,7 +66,11 @@ export function courseBetweenCartesians(from, to, minChordM = 25) {
const dy = to.y - from.y; const dy = to.y - from.y;
const dz = to.z - from.z; const dz = to.z - from.z;
if (dx * dx + dy * dy + dz * dz < minChordM * minChordM) return null; 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; if (!carto) return null;
const sLat = Math.sin(carto.latitude); const sLat = Math.sin(carto.latitude);
const cLat = Math.cos(carto.latitude); const cLat = Math.cos(carto.latitude);
@@ -110,7 +118,10 @@ export function speedRamp(speedMps) {
if (!Number.isFinite(speedMps)) return 1; if (!Number.isFinite(speedMps)) return 1;
if (speedMps <= COURSE_TRACK_ONLY_MPS) return 0; if (speedMps <= COURSE_TRACK_ONLY_MPS) return 0;
if (speedMps >= COURSE_CHORD_ONLY_MPS) return 1; 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 /** Shortest-arc angle lerp: from → to by t (t=0 → from, t=1 → to). Also the
@@ -141,10 +152,16 @@ export function displayedKinematics({
reportedTrackDeg, reportedTrackDeg,
} = {}) { } = {}) {
return { return {
speedMps: Number.isFinite(derivedSpeedMps) ? Math.max(0, derivedSpeedMps) speedMps: Number.isFinite(derivedSpeedMps)
: (Number.isFinite(reportedSpeedMps) ? Math.max(0, reportedSpeedMps) : null), ? Math.max(0, derivedSpeedMps)
trackDeg: Number.isFinite(derivedTrackDeg) ? derivedTrackDeg : Number.isFinite(reportedSpeedMps)
: (Number.isFinite(reportedTrackDeg) ? reportedTrackDeg : null), ? Math.max(0, reportedSpeedMps)
: null,
trackDeg: Number.isFinite(derivedTrackDeg)
? derivedTrackDeg
: Number.isFinite(reportedTrackDeg)
? reportedTrackDeg
: null,
}; };
} }
@@ -167,10 +184,17 @@ export function staleCoastLimitSeconds({
maximumSec = 300, maximumSec = 300,
} = {}) { } = {}) {
const floor = Math.max(0, Number.isFinite(minimumSec) ? minimumSec : 60); const floor = Math.max(0, Number.isFinite(minimumSec) ? minimumSec : 60);
const ceiling = Math.max(floor, Number.isFinite(maximumSec) ? maximumSec : 300); const ceiling = Math.max(
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(lastContactEpochMs)) return floor; floor,
Number.isFinite(maximumSec) ? maximumSec : 300,
);
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(lastContactEpochMs))
return floor;
const contactLeadSec = Math.max(0, (lastContactEpochMs - fixEpochMs) / 1000); 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)); 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 * is GPS-vector noise, not a turn (samples without a speed keep the legacy
* behavior: no way to tell, so they still count). * 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; if (!samples || samples.length < 2) return 0;
let sum = 0; let sum = 0;
let n = 0; let n = 0;
for (let i = 1; i < samples.length; i++) { for (let i = 1; i < samples.length; i++) {
const dt = samples[i].tSec - samples[i - 1].tSec; const dt = samples[i].tSec - samples[i - 1].tSec;
if (dt < 2 || dt > 120) continue; 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) { if (minSpeedMps > 0) {
const v0 = samples[i - 1].speedMps; const v0 = samples[i - 1].speedMps;
const v1 = samples[i].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; sum += norm180(samples[i].trackDeg - samples[i - 1].trackDeg) / dt;
n += 1; 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. * @param {number} dtSec - Seconds ahead; 0 or negative returns the start.
* @returns {{lat: number, lon: number}} Projected coordinate, degrees. * @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(lat) || !Number.isFinite(lon)) return { lat, lon };
if (!Number.isFinite(courseDeg) || !Number.isFinite(speedMps) || !Number.isFinite(dtSec) if (
|| speedMps <= 0 || dtSec <= 0) { !Number.isFinite(courseDeg) ||
!Number.isFinite(speedMps) ||
!Number.isFinite(dtSec) ||
speedMps <= 0 ||
dtSec <= 0
) {
return { lat, lon }; 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. // Guard the pole: cos(lat) → 0 would send the longitude to infinity.
const cosLat = Math.max(Math.cos(lat * DEG), 1e-6); const cosLat = Math.max(Math.cos(lat * DEG), 1e-6);
return { return {
@@ -320,8 +375,15 @@ export function projectGroundArcLatLon(lat, lon, courseDeg, speedMps, turnRateDp
* @returns {Array<{lat: number, lon: number}>} Polyline, display position first. * @returns {Array<{lat: number, lon: number}>} Polyline, display position first.
*/ */
export function corridorPathLatLon({ export function corridorPathLatLon({
extrapolating, displayLat, displayLon, courseDeg, speedMps, turnRateDps = 0, extrapolating,
fixLat, fixLon, lookaheadSec, displayLat,
displayLon,
courseDeg,
speedMps,
turnRateDps = 0,
fixLat,
fixLon,
lookaheadSec,
}) { }) {
if (!Number.isFinite(displayLat) || !Number.isFinite(displayLon)) return []; if (!Number.isFinite(displayLat) || !Number.isFinite(displayLon)) return [];
const start = { lat: displayLat, lon: displayLon }; 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 n = Math.max(1, Math.ceil(arcLengthM / CORRIDOR_SAMPLE_SPACING_M));
const out = [start]; const out = [start];
for (let i = 1; i <= n; i++) { for (let i = 1; i <= n; i++) {
out.push(projectGroundArcLatLon( out.push(
displayLat, displayLon, courseDeg, speed, turnRateDps, cappedSec * (i / n), projectGroundArcLatLon(
)); displayLat,
displayLon,
courseDeg,
speed,
turnRateDps,
cappedSec * (i / n),
),
);
} }
return out; return out;
} }
@@ -385,13 +454,17 @@ const _forwardFixOffset = new Cesium.Cartesian3();
* velocity: number, track: number}|null} Forward synthetic fix, or null. * velocity: number, track: number}|null} Forward synthetic fix, or null.
*/ */
export function synthesizeForwardKinematicsFix(newest, next = {}) { 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) const startEpochMs = Number.isFinite(newest.epochMs)
? newest.epochMs ? newest.epochMs
: Cesium.JulianDate.toDate(newest.time).getTime(); : 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 previousTrack = Number.isFinite(newest.track) ? newest.track : 0;
const dtSec = (next.epochMs - startEpochMs) / 1000; const dtSec = (next.epochMs - startEpochMs) / 1000;
arcOffsetEnu( arcOffsetEnu(
@@ -421,7 +494,9 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
time: Cesium.JulianDate.fromDate(new Date(next.epochMs)), time: Cesium.JulianDate.fromDate(new Date(next.epochMs)),
epochMs: next.epochMs, epochMs: next.epochMs,
position, 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, 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 (id === null || id === undefined) return undefined;
if (typeof id === 'object') { if (typeof id === 'object') {
// AIS vessel record (id object with .mmsi) or Cesium Entity (.id string) // 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; if (typeof id.id === 'string' || typeof id.id === 'number') return id.id;
return undefined; return undefined;
} }
@@ -40,7 +41,7 @@ export function resolvePickId(picked) {
}; };
let id = unwrap(picked.id); let id = unwrap(picked.id);
if (id === undefined) id = unwrap(picked.primitive?.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;
} }
/** /**
+20 -11
View File
@@ -1,6 +1,7 @@
const RADIO_COUNTRY_MAX_LENGTH = 80; 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 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 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 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,10 +9,14 @@ 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 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 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 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 ISO_ALPHA_2_SET = new Set(ISO_ALPHA_2_CODES);
const ENGLISH_REGION_NAMES = typeof Intl?.DisplayNames === 'function' const ENGLISH_REGION_NAMES =
typeof Intl?.DisplayNames === 'function'
? new Intl.DisplayNames(['en'], { type: 'region' }) ? new Intl.DisplayNames(['en'], { type: 'region' })
: null; : null;
@@ -29,7 +34,8 @@ function countryKey(value) {
const COUNTRY_NAME_TO_CODE = new Map(); const COUNTRY_NAME_TO_CODE = new Map();
for (const code of ISO_ALPHA_2_CODES) { for (const code of ISO_ALPHA_2_CODES) {
const displayName = ENGLISH_REGION_NAMES?.of(code); 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({ for (const [name, code] of Object.entries({
@@ -71,7 +77,8 @@ for (const [name, code] of Object.entries({
'east timor': 'TL', 'east timor': 'TL',
'cabo verde': 'CV', 'cabo verde': 'CV',
vatican: 'VA', vatican: 'VA',
})) COUNTRY_NAME_TO_CODE.set(countryKey(name), code); }))
COUNTRY_NAME_TO_CODE.set(countryKey(name), code);
function canonicalCountryName(code) { function canonicalCountryName(code) {
return ENGLISH_REGION_NAMES?.of(code) || 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, ' '); const trimmed = value.normalize('NFKC').trim().replace(/\s+/g, ' ');
if ( if (
!trimmed !trimmed ||
|| trimmed.length > RADIO_COUNTRY_MAX_LENGTH trimmed.length > RADIO_COUNTRY_MAX_LENGTH ||
|| /[\u0000-\u001f\u007f]/.test(trimmed) /[\u0000-\u001f\u007f]/.test(trimmed) ||
|| !/^[\p{L}\p{M}.&'’()\-\s]+$/u.test(trimmed) !/^[\p{L}\p{M}.&'’()\-\s]+$/u.test(trimmed)
) return Object.freeze({ valid: false, empty: false, code: '', name: '' }); )
return Object.freeze({ valid: false, empty: false, code: '', name: '' });
const possibleCode = trimmed.toUpperCase(); const possibleCode = trimmed.toUpperCase();
const code = ISO_ALPHA_2_SET.has(possibleCode) const code = ISO_ALPHA_2_SET.has(possibleCode)
? possibleCode ? possibleCode
: COUNTRY_NAME_TO_CODE.get(countryKey(trimmed)) || ''; : 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({ return Object.freeze({
valid: true, valid: true,
empty: false, empty: false,
+2 -1
View File
@@ -2,6 +2,7 @@ import { applicationServices } from '../services/application.js';
export * from './regionalModel.js'; export * from './regionalModel.js';
/** Fetch a bounded regional brief through the same-origin dev/preview proxy. */ /** Fetch a bounded regional brief through the same-origin dev/preview proxy. */
export async function fetchRegionalBrief(latitude, longitude, { signal } = {}) { 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 }); return applicationServices.regional.getBrief(latitude, longitude, { signal });
} }
+50 -18
View File
@@ -1,13 +1,18 @@
const MAX_ARTICLES = 5; const MAX_ARTICLES = 5;
function cleanText(value, maxLength = 180) { 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) { function safeHttpUrl(value) {
try { try {
const parsed = new URL(String(value || '')); 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 { } catch {
return null; return null;
} }
@@ -17,14 +22,27 @@ function safeHttpUrl(value) {
export function normalizeRegionalPlace(payload) { export function normalizeRegionalPlace(payload) {
const address = payload?.address || {}; const address = payload?.address || {};
const locality = cleanText( const locality = cleanText(
address.city || address.town || address.village || address.municipality address.city ||
|| address.hamlet || address.county, address.town ||
address.village ||
address.municipality ||
address.hamlet ||
address.county,
90,
);
const region = cleanText(
address.state || address.region || address.county,
90, 90,
); );
const region = cleanText(address.state || address.region || address.county, 90);
const country = cleanText(address.country, 90); const country = cleanText(address.country, 90);
const label = [locality, region].filter((value, index, values) => value && values.indexOf(value) === index) const label =
.join(', ') || country || cleanText(payload?.display_name, 120); [locality, region]
.filter(
(value, index, values) => value && values.indexOf(value) === index,
)
.join(', ') ||
country ||
cleanText(payload?.display_name, 120);
if (!label) return null; if (!label) return null;
return { return {
label, label,
@@ -51,11 +69,16 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate); const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate);
const publishedAt = compactDate 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` ? `${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({ articles.push({
title, title,
url, url,
domain: cleanText(row?.domain || new URL(url).hostname.replace(/^www\./, ''), 80), domain: cleanText(
row?.domain || new URL(url).hostname.replace(/^www\./, ''),
80,
),
publishedAt, publishedAt,
sourceCountry: cleanText(row?.sourcecountry, 60) || null, 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, // 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. // 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) const observedRaw =
typeof current.time === 'string' &&
!/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time)
? `${current.time}Z` ? `${current.time}Z`
: current.time; : current.time;
return { 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), temperatureC: numberOrNull(current.temperature_2m),
apparentTemperatureC: numberOrNull(current.apparent_temperature), apparentTemperatureC: numberOrNull(current.apparent_temperature),
precipitationMm: numberOrNull(current.precipitation), 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. */ /** Great-circle distance used to avoid refetching a regional brief every animation frame. */
export function regionalDistanceM(from, to) { 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; return Infinity;
} }
const phi1 = from.latitude * Math.PI / 180; const phi1 = (from.latitude * Math.PI) / 180;
const phi2 = to.latitude * Math.PI / 180; const phi2 = (to.latitude * Math.PI) / 180;
const deltaPhi = (to.latitude - from.latitude) * Math.PI / 180; const deltaPhi = ((to.latitude - from.latitude) * Math.PI) / 180;
const deltaLambda = (to.longitude - from.longitude) * Math.PI / 180; const deltaLambda = ((to.longitude - from.longitude) * Math.PI) / 180;
const a = Math.sin(deltaPhi / 2) ** 2 const a =
+ Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2; 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)); 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 * 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). * 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)) { if (onGround && Number.isFinite(surfaceM)) {
return surfaceM; return surfaceM;
} }
@@ -89,7 +95,12 @@ export function reuseGroundedSurfaceM(currentM, previousM) {
* @param {number|null|undefined} params.geoidN - Geoid undulation N here. * @param {number|null|undefined} params.geoidN - Geoid undulation N here.
* @returns {number|null} The geoid guess, or null when it must not be used. * @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(geoAltM) || Number.isFinite(baroAltM)) return null;
if (Number.isFinite(priorRenderM)) return null; if (Number.isFinite(priorRenderM)) return null;
return Number.isFinite(geoidN) ? geoidN : null; return Number.isFinite(geoidN) ? geoidN : null;
+9 -3
View File
@@ -34,11 +34,14 @@ export const RETRY_COOLDOWN_MAX_MS = 300_000;
* @param {() => number} [options.now] - Clock seam for tests. * @param {() => number} [options.now] - Clock seam for tests.
* @returns {() => Promise<T>} Memoized loader; retries once the cooldown elapses. * @returns {() => Promise<T>} Memoized loader; retries once the cooldown elapses.
*/ */
export function createRetryableLoader(load, { export function createRetryableLoader(
load,
{
cooldownMs = RETRY_COOLDOWN_MS, cooldownMs = RETRY_COOLDOWN_MS,
maxCooldownMs = RETRY_COOLDOWN_MAX_MS, maxCooldownMs = RETRY_COOLDOWN_MAX_MS,
now = () => Date.now(), now = () => Date.now(),
} = {}) { } = {},
) {
/** @type {Promise<T>|null} */ /** @type {Promise<T>|null} */
let inflight = null; let inflight = null;
/** @type {*} Last rejection, replayed to callers inside the cooldown. */ /** @type {*} Last rejection, replayed to callers inside the cooldown. */
@@ -51,7 +54,10 @@ export function createRetryableLoader(load, {
return function loadOnce() { return function loadOnce() {
if (inflight) return inflight; if (inflight) return inflight;
if (hasFailure) { 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 // Replay the recorded reason rather than re-running a loader that is
// probably still broken. Callers see identical behavior either way. // probably still broken. Callers see identical behavior either way.
if (now() - failedAt < wait) return Promise.reject(failure); if (now() - failedAt < wait) return Promise.reject(failure);
+42 -10
View File
@@ -19,7 +19,8 @@ export function greatCircleKm(lat1, lon1, lat2, lon2) {
const p2 = lat2 * D2R; const p2 = lat2 * D2R;
const dp = (lat2 - lat1) * D2R; const dp = (lat2 - lat1) * D2R;
const dl = (lon2 - lon1) * 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)); 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 p2 = lat2 * D2R;
const dl = (lon2 - lon1) * D2R; const dl = (lon2 - lon1) * D2R;
const y = Math.sin(dl) * Math.cos(p2); 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); return Math.atan2(y, x);
} }
@@ -55,16 +57,35 @@ const LOCAL_AIRPORT_KM = 150;
* @param {{lat:number|null, lon:number|null}|null} [p.destination] * @param {{lat:number|null, lon:number|null}|null} [p.destination]
* @returns {boolean} false ONLY when the route is confidently wrong. * @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 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 if (!haveO && !haveD) return true; // no coordinates — cannot judge, do not hide
// (a) Geographic consistency: near an endpoint, or roughly on the path. // (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)); let geomOk = (haveO && near(origin)) || (haveD && near(destination));
if (!geomOk && haveO && haveD) { 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) { } else if (!geomOk) {
geomOk = true; // only one endpoint known and not near — cannot judge, allow 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). // (b) Vertical-trend consistency for low traffic (observer-free adaptation).
if ( if (
Number.isFinite(altitudeM) && altitudeM < LOW_ALT_M && Number.isFinite(altitudeM) &&
Number.isFinite(verticalRateMps) && Math.abs(verticalRateMps) > VERT_TREND_MPS altitudeM < LOW_ALT_M &&
Number.isFinite(verticalRateMps) &&
Math.abs(verticalRateMps) > VERT_TREND_MPS
) { ) {
if (verticalRateMps > 0) { 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 { } 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; return true;
+19 -4
View File
@@ -69,7 +69,13 @@ export const SATELLITE_CLASSES = Object.freeze({
}); });
/** Legend/report order for the classes above. */ /** 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 * CelesTrak group tag → { class, subtype }. Subtype names the specific
@@ -150,8 +156,11 @@ export function satelliteClassLabel(group, { isIss = false } = {}) {
export function tallySatelliteClasses(entries) { export function tallySatelliteClasses(entries) {
const counts = Object.create(null); const counts = Object.create(null);
for (const entry of entries || []) { for (const entry of entries || []) {
const descriptor = entry && typeof entry === 'object' ? entry : { group: entry }; const descriptor =
const { klass } = satelliteClassOf(descriptor.group, { isIss: descriptor.isIss }); entry && typeof entry === 'object' ? entry : { group: entry };
const { klass } = satelliteClassOf(descriptor.group, {
isIss: descriptor.isIss,
});
counts[klass] = (counts[klass] || 0) + 1; counts[klass] = (counts[klass] || 0) + 1;
} }
return counts; return counts;
@@ -171,7 +180,13 @@ export function satelliteClassLegend(counts) {
const count = counts?.[klass]; const count = counts?.[klass];
if (!(count > 0)) continue; if (!(count > 0)) continue;
const spec = SATELLITE_CLASSES[klass]; 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; return result;
} }
+2 -1
View File
@@ -42,7 +42,8 @@ const MAX_PICK_MAGNITUDE_M = 1_000_000_000;
export function isPickedWorldPosition(position) { export function isPickedWorldPosition(position) {
if (!position) return false; if (!position) return false;
const { x, y, z } = position; 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); const magnitude = Math.hypot(x, y, z);
return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M; 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; if (!viewer || viewer.isDestroyed?.()) return;
const scene = viewer.scene; const scene = viewer.scene;
const primitives = scene?.primitives; 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) { for (const layerId of SPRITE_LAYER_ORDER) {
const collection = _collections.get(layerId); const collection = _collections.get(layerId);
@@ -71,6 +72,7 @@ export function restoreSpriteOrderOnEnable(
viewer, viewer,
restore = restoreSpriteOrder, restore = restoreSpriteOrder,
) { ) {
if (!SPRITE_LAYER_ORDER.includes(layerId) || typeof restore !== 'function') return; if (!SPRITE_LAYER_ORDER.includes(layerId) || typeof restore !== 'function')
return;
restore(viewer); restore(viewer);
} }
+8 -2
View File
@@ -1,4 +1,10 @@
import { createTerrainHeights } from '../services/terrainHeights.js'; import { createTerrainHeights } from '../services/terrainHeights.js';
import { applicationServices } from '../services/application.js'; import { applicationServices } from '../services/application.js';
const terrainHeights = createTerrainHeights({ source: applicationServices.terrain }); const terrainHeights = createTerrainHeights({
export const { cachedEllipsoidalGround, cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrainHeights; 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. */ /** @param {*} id Raw contact id. @returns {string} Normalized registry key. */
function key(id) { function key(id) {
return String(id ?? '').trim().toLowerCase(); return String(id ?? '')
.trim()
.toLowerCase();
} }
/** /**
+35 -17
View File
@@ -26,17 +26,23 @@ export function trackedModelScaleForPixelCap({
maximumPixelSize, maximumPixelSize,
}) { }) {
if ( if (
!Number.isFinite(baseScale) || baseScale <= 0 !Number.isFinite(baseScale) ||
|| !Number.isFinite(nativeRadiusM) || nativeRadiusM <= 0 baseScale <= 0 ||
|| !Number.isFinite(rangeM) || rangeM <= 0 !Number.isFinite(nativeRadiusM) ||
|| !Number.isFinite(viewportHeightPx) || viewportHeightPx <= 0 nativeRadiusM <= 0 ||
|| !Number.isFinite(fovyRad) || fovyRad <= 0 !Number.isFinite(rangeM) ||
|| !Number.isFinite(maximumPixelSize) || maximumPixelSize <= 0 rangeM <= 0 ||
) return baseScale; !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 focalLengthPx = viewportHeightPx / (2 * Math.tan(fovyRad / 2));
const projectedDiameterPx = ( const projectedDiameterPx =
2 * nativeRadiusM * baseScale * focalLengthPx (2 * nativeRadiusM * baseScale * focalLengthPx) / rangeM;
) / rangeM;
if (projectedDiameterPx <= maximumPixelSize) return baseScale; if (projectedDiameterPx <= maximumPixelSize) return baseScale;
return baseScale * (maximumPixelSize / projectedDiameterPx); return baseScale * (maximumPixelSize / projectedDiameterPx);
} }
@@ -99,17 +105,25 @@ export function clampTrackedCameraPosition(
previousPosition, previousPosition,
minimumRangeM = MIN_TRACKED_RANGE_M, minimumRangeM = MIN_TRACKED_RANGE_M,
) { ) {
const crossedOrigin = Cesium.Cartesian3.dot(camera.position, previousPosition) <= 0; const crossedOrigin =
const forwardDistance = -Cesium.Cartesian3.dot(camera.position, camera.direction); Cesium.Cartesian3.dot(camera.position, previousPosition) <= 0;
const forwardDistance = -Cesium.Cartesian3.dot(
camera.position,
camera.direction,
);
const rangeSquared = Cesium.Cartesian3.magnitudeSquared(camera.position); const rangeSquared = Cesium.Cartesian3.magnitudeSquared(camera.position);
if (crossedOrigin) { if (crossedOrigin) {
Cesium.Cartesian3.normalize(previousPosition, camera.position); Cesium.Cartesian3.normalize(previousPosition, camera.position);
Cesium.Cartesian3.multiplyByScalar(camera.position, minimumRangeM, camera.position); Cesium.Cartesian3.multiplyByScalar(
camera.position,
minimumRangeM,
camera.position,
);
return true; return true;
} }
if ( if (
forwardDistance < minimumRangeM forwardDistance < minimumRangeM ||
|| rangeSquared < minimumRangeM * minimumRangeM rangeSquared < minimumRangeM * minimumRangeM
) { ) {
Cesium.Cartesian3.multiplyByScalar( Cesium.Cartesian3.multiplyByScalar(
camera.direction, camera.direction,
@@ -192,10 +206,14 @@ export function applyTrackedCameraFrame(viewer, entity, viewFrom) {
Cesium.Ellipsoid.WGS84, Cesium.Ellipsoid.WGS84,
trackedTransform, trackedTransform,
); );
const offset = typeof viewFrom.getValue === 'function' const offset =
typeof viewFrom.getValue === 'function'
? viewFrom.getValue(viewer.clock.currentTime, resolvedViewFrom) ? viewFrom.getValue(viewer.clock.currentTime, resolvedViewFrom)
: Cesium.Cartesian3.clone(viewFrom, resolvedViewFrom); : Cesium.Cartesian3.clone(viewFrom, resolvedViewFrom);
if (!offset || Cesium.Cartesian3.magnitudeSquared(offset) < Cesium.Math.EPSILON12) { if (
!offset ||
Cesium.Cartesian3.magnitudeSquared(offset) < Cesium.Math.EPSILON12
) {
stop(); stop();
return; return;
} }
+4 -1
View File
@@ -76,5 +76,8 @@ export const TRACKED_MODEL_EXIT_ALT_M =
*/ */
export function trackedModelZoomActive(cameraHeightM, wasActive = false) { export function trackedModelZoomActive(cameraHeightM, wasActive = false) {
if (!Number.isFinite(cameraHeightM)) return 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)
);
} }
+54 -16
View File
@@ -49,15 +49,22 @@ let _overlayHost = DEFAULT_TRACKED_OVERLAY_HOST;
* @param {string} accent Source-owned accent color. * @param {string} accent Source-owned accent color.
* @returns {{title:string,details:string[],accent:string}} * @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(); const raw = String(text || '').trim();
if (!raw) return { title: '', details: [], accent }; if (!raw) return { title: '', details: [], accent };
let lines; let lines;
if (raw.includes('\n')) { 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 { } else {
const parts = raw.split(' · '); const parts = raw.split(' · ');
lines = parts.length > 1 lines =
parts.length > 1
? [parts[0].trim(), parts.slice(1).join(' · ').trim()] ? [parts[0].trim(), parts.slice(1).join(' · ').trim()]
: [raw]; : [raw];
} }
@@ -101,7 +108,9 @@ export function cachedTrackedVisualPosition(entity) {
try { try {
const visual = entity.gevVisualPosition(); const visual = entity.gevVisualPosition();
if (visual) return visual; if (visual) return visual;
} catch { /* fall through to the display position */ } } catch {
/* fall through to the display position */
}
} }
return cachedTrackedDisplayPosition(entity); return cachedTrackedDisplayPosition(entity);
} }
@@ -140,7 +149,9 @@ export function createTrackedOverlayEntry(entity) {
collisionGroup: 'ambient-card', collisionGroup: 'ambient-card',
priority: Number.MAX_SAFE_INTEGER, priority: Number.MAX_SAFE_INTEGER,
title, 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, accent: model.accent || WORLD_OVERLAY_STYLE.accent,
cardStyle: model.cardStyle, cardStyle: model.cardStyle,
selected: model.selected === true, selected: model.selected === true,
@@ -149,9 +160,14 @@ export function createTrackedOverlayEntry(entity) {
leaderAnimationStartedAt: model.leaderAnimationStartedAt, leaderAnimationStartedAt: model.leaderAnimationStartedAt,
leaderDrawRatio: model.leaderDrawRatio, leaderDrawRatio: model.leaderDrawRatio,
anchorRadiusPx: Number.isFinite(Number(model.anchorRadiusPx)) anchorRadiusPx: Number.isFinite(Number(model.anchorRadiusPx))
? Math.max(0, Number(model.anchorRadiusPx)) : 10, ? Math.max(0, Number(model.anchorRadiusPx))
anchorRadiusScale: Object.prototype.hasOwnProperty.call(model, 'anchorRadiusScale') : 10,
? model.anchorRadiusScale : TRACKED_BILLBOARD_SCALE, anchorRadiusScale: Object.prototype.hasOwnProperty.call(
model,
'anchorRadiusScale',
)
? model.anchorRadiusScale
: TRACKED_BILLBOARD_SCALE,
minAnchorGapPx: 16, minAnchorGapPx: 16,
anchorGapPaddingPx: 10, anchorGapPaddingPx: 10,
verticalOnly: true, verticalOnly: true,
@@ -203,7 +219,10 @@ export function getActiveTrackedReadoutId() {
} }
/** Static-context layers whose click selection publishes the readout card. */ /** 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 * Initialize the model bridge and selection listeners. No render listener is
@@ -215,7 +234,8 @@ export function initTrackedReadout(viewer) {
if (_viewer) destroyTrackedReadout(); if (_viewer) destroyTrackedReadout();
_viewer = viewer; _viewer = viewer;
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true); _overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true);
_trackedEntityChangedRemove = viewer.trackedEntityChanged?.addEventListener?.(() => { _trackedEntityChangedRemove =
viewer.trackedEntityChanged?.addEventListener?.(() => {
if (viewer.trackedEntity) _selectedContext = null; if (viewer.trackedEntity) _selectedContext = null;
syncActiveEntity(); syncActiveEntity();
}) || null; }) || null;
@@ -232,7 +252,10 @@ export function initTrackedReadout(viewer) {
} }
}; };
_contextClearedHandler = (event) => { _contextClearedHandler = (event) => {
if (!_selectedContext || event.detail?.layerId === _selectedContext.layerId) { if (
!_selectedContext ||
event.detail?.layerId === _selectedContext.layerId
) {
_selectedContext = null; _selectedContext = null;
syncActiveEntity(); syncActiveEntity();
} }
@@ -242,8 +265,14 @@ export function initTrackedReadout(viewer) {
if (!_viewer?.trackedEntity) clearTrackedSource(); if (!_viewer?.trackedEntity) clearTrackedSource();
}; };
window.addEventListener('gev:entity-selected', _contextSelectedHandler); window.addEventListener('gev:entity-selected', _contextSelectedHandler);
window.addEventListener('gev:entity-selection-cleared', _contextClearedHandler); window.addEventListener(
window.addEventListener('gev:awareness-subject-selected', _aircraftSelectedHandler); 'gev:entity-selection-cleared',
_contextClearedHandler,
);
window.addEventListener(
'gev:awareness-subject-selected',
_aircraftSelectedHandler,
);
syncActiveEntity(); syncActiveEntity();
// Boot-verification contract: the track regression harness asserts this // Boot-verification contract: the track regression harness asserts this
// exact line at init (dropped by the host migration; restored). // exact line at init (dropped by the host migration; restored).
@@ -254,9 +283,18 @@ export function initTrackedReadout(viewer) {
export function destroyTrackedReadout() { export function destroyTrackedReadout() {
_trackedEntityChangedRemove?.(); _trackedEntityChangedRemove?.();
_trackedEntityChangedRemove = null; _trackedEntityChangedRemove = null;
if (_contextSelectedHandler) window.removeEventListener('gev:entity-selected', _contextSelectedHandler); if (_contextSelectedHandler)
if (_contextClearedHandler) window.removeEventListener('gev:entity-selection-cleared', _contextClearedHandler); window.removeEventListener('gev:entity-selected', _contextSelectedHandler);
if (_aircraftSelectedHandler) window.removeEventListener('gev:awareness-subject-selected', _aircraftSelectedHandler); if (_contextClearedHandler)
window.removeEventListener(
'gev:entity-selection-cleared',
_contextClearedHandler,
);
if (_aircraftSelectedHandler)
window.removeEventListener(
'gev:awareness-subject-selected',
_aircraftSelectedHandler,
);
_contextSelectedHandler = null; _contextSelectedHandler = null;
_contextClearedHandler = null; _contextClearedHandler = null;
_aircraftSelectedHandler = null; _aircraftSelectedHandler = null;
+10 -3
View File
@@ -28,8 +28,10 @@ export function isTrackingClickGesture(gesture = {}) {
const durationMs = Number.isFinite(gesture.durationMs) const durationMs = Number.isFinite(gesture.durationMs)
? Math.max(0, gesture.durationMs) ? Math.max(0, gesture.durationMs)
: Number.POSITIVE_INFINITY; : Number.POSITIVE_INFINITY;
return isTrackingSelectionGesture(gesture) return (
&& durationMs <= MAX_TRACKING_CLICK_DURATION_MS; isTrackingSelectionGesture(gesture) &&
durationMs <= MAX_TRACKING_CLICK_DURATION_MS
);
} }
/** /**
@@ -53,7 +55,12 @@ export function bindTrackingClickGesture(handler, onClick, options = {}) {
let completedGesture = null; let completedGesture = null;
const appendTravel = (position) => { 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) { if (previousPosition) {
travelPx += Math.hypot( travelPx += Math.hypot(
position.x - previousPosition.x, position.x - previousPosition.x,
+17 -7
View File
@@ -33,8 +33,8 @@ export function greatCircleKm(lat1, lon1, lat2, lon2) {
const p2 = toRad(lat2); const p2 = toRad(lat2);
const dp = toRad(lat2 - lat1); const dp = toRad(lat2 - lat1);
const dl = toRad(lon2 - lon1); const dl = toRad(lon2 - lon1);
const a = Math.sin(dp / 2) ** 2 const a =
+ Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2; 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))); 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 p2 = toRad(lat2);
const dl = toRad(lon2 - lon1); const dl = toRad(lon2 - lon1);
const y = Math.sin(dl) * Math.cos(p2); 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); return Math.atan2(y, x);
} }
@@ -68,11 +69,14 @@ function destinationPoint(lat, lon, bearingRad, distKm) {
const p1 = toRad(lat); const p1 = toRad(lat);
const l1 = toRad(lon); const l1 = toRad(lon);
const p2 = Math.asin( const p2 = Math.asin(
Math.sin(p1) * Math.cos(delta) + Math.cos(p1) * Math.sin(delta) * Math.cos(bearingRad) Math.sin(p1) * Math.cos(delta) +
Math.cos(p1) * Math.sin(delta) * Math.cos(bearingRad),
); );
const l2 = l1 + Math.atan2( const l2 =
l1 +
Math.atan2(
Math.sin(bearingRad) * Math.sin(delta) * Math.cos(p1), Math.sin(bearingRad) * Math.sin(delta) * Math.cos(p1),
Math.cos(delta) - Math.sin(p1) * Math.sin(p2) Math.cos(delta) - Math.sin(p1) * Math.sin(p2),
); );
// Normalize longitude to [-180, 180) // Normalize longitude to [-180, 180)
const lonDeg = ((toDeg(l2) + 540) % 360) - 180; const lonDeg = ((toDeg(l2) + 540) % 360) - 180;
@@ -100,7 +104,13 @@ function destinationPoint(lat, lon, bearingRad, distKm) {
* @returns {{lat:number, lon:number, source:'hit'|'nadir'|'pulled'}} * @returns {{lat:number, lon:number, source:'hit'|'nadir'|'pulled'}}
* The fetch center and which rule produced it. * 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)) { if (!Number.isFinite(hitLat) || !Number.isFinite(hitLon)) {
return { lat: nadirLat, lon: nadirLon, source: 'nadir' }; return { lat: nadirLat, lon: nadirLon, source: 'nadir' };
} }
+27 -6
View File
@@ -49,13 +49,33 @@ const PROFILE_BY_STYLE = {
*/ */
const DOT_STYLE = { const DOT_STYLE = {
mono: { mono: {
jam: { rgba: [255, 255, 255, 0.95], sizeDelta: 3, outline: { rgba: [0, 0, 0, 0.9], width: 2 } }, jam: {
slow: { rgba: [255, 255, 255, 0.9], sizeDelta: 1, outline: { rgba: [0, 0, 0, 0.85], width: 1 } }, rgba: [255, 255, 255, 0.95],
free: { rgba: [255, 255, 255, 0.85], sizeDelta: 0, outline: { rgba: [0, 0, 0, 0.8], width: 1 } }, 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: { crt: {
jam: { rgba: [255, 59, 48, 0.95], sizeDelta: 3, outline: { rgba: [0, 0, 0, 0.9], width: 2 } }, jam: {
slow: { rgba: [255, 179, 0, 0.92], sizeDelta: 2, outline: { rgba: [0, 0, 0, 0.85], width: 1 } }, 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 }, 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'). * @returns {string|null} Tier key, or null (no override → stock 'vehicle').
*/ */
export function trafficBucketTier(bucket) { 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'; if (bucket === 'sim') return 'veh_nodata';
return null; return null;
} }
+3 -2
View File
@@ -33,13 +33,14 @@ const GAP_SPREAD_M = 6;
*/ */
export function queuePlatoons(totalLen, count, rng = Math.random) { export function queuePlatoons(totalLen, count, rng = Math.random) {
const platoons = []; 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; let placed = 0;
while (placed < count) { while (placed < count) {
const platoonSize = Math.min( const platoonSize = Math.min(
count - placed, count - placed,
PLATOON_MIN + Math.floor(rng() * PLATOON_SPREAD) PLATOON_MIN + Math.floor(rng() * PLATOON_SPREAD),
); );
const anchor = rng() * totalLen; const anchor = rng() * totalLen;
const platoon = []; 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 // plane being tracked. Claiming the 'gev-trail:' id namespace makes
// isOwnedByOtherLayer() true for every layer — clicking a trail is a no-op // isOwnedByOtherLayer() true for every layer — clicking a trail is a no-op
// everywhere. Registered once at module load; the predicate is pure. // 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). */ /** @type {number} Uniquifier for trail entity ids (Cesium requires unique entity ids). */
let _trailSeq = 0; let _trailSeq = 0;
@@ -99,7 +101,12 @@ export function createTrail(viewer, { color, width = 2.5 }) {
for (const position of Array.isArray(cartesians) ? cartesians : []) { for (const position of Array.isArray(cartesians) ? cartesians : []) {
if (!position) continue; if (!position) continue;
const last = positions[positions.length - 1]; 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); positions.push(position);
} }
current = positions.length >= 2 ? positions : []; current = positions.length >= 2 ? positions : [];
@@ -122,7 +129,11 @@ export function createTrail(viewer, { color, width = 2.5 }) {
destroyed = true; destroyed = true;
current = []; current = [];
if (entity && viewer && !viewer.isDestroyed()) { if (entity && viewer && !viewer.isDestroyed()) {
try { viewer.entities.remove(entity); } catch { /* torn down */ } try {
viewer.entities.remove(entity);
} catch {
/* torn down */
}
} }
entity = null; entity = null;
}, },
+51 -12
View File
@@ -20,21 +20,48 @@ export const VESSEL_CARD_FADE_DISTANCE_M = 5_000_000;
*/ */
const TYPE_STYLES = [ const TYPE_STYLES = [
{ pattern: /tanker/i, css: '#ffb347', accent: '255, 179, 71' }, { 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: /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 DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
const NUMERIC_TYPE_SPECIALS = { const NUMERIC_TYPE_SPECIALS = {
30: 'FISHING', 31: 'TOWING', 32: 'TOWING', 33: 'DREDGER', 34: 'DIVE OPS', 30: 'FISHING',
35: 'MILITARY', 36: 'SAILING', 37: 'PLEASURE', 31: 'TOWING',
50: 'PILOT', 51: 'SAR', 52: 'TUG', 53: 'PORT TENDER', 54: 'ANTI-POLLUTION', 32: 'TOWING',
55: 'LAW ENFORCE', 58: 'MEDICAL', 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 = { 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. * @param {number} [rowLimit=900] Configured source row ceiling.
* @returns {number} * @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 w = Number(width);
const h = Number(height); const h = Number(height);
const requested = Number(rowLimit); const requested = Number(rowLimit);
if (!(w > 0) || !(h > 0) || !(requested > 0)) return 0; 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); const gridCapacity =
return Math.min(VESSEL_OVERLAY_MAX_COHORT, Math.floor(requested), 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. * @param {number} [fadeDistance=5000000] Ambient distance-fade endpoint.
* @returns {Object} * @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 selected = card?.selected === true;
const rawGap = Number(card?.gapPx) || 10; const rawGap = Number(card?.gapPx) || 10;
const gapPx = Math.max(12, rawGap + 8); const gapPx = Math.max(12, rawGap + 8);
+56 -24
View File
@@ -143,7 +143,9 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
function resolveStore(kind, injected) { function resolveStore(kind, injected) {
if (injected !== undefined) return injected; if (injected !== undefined) return injected;
try { try {
return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage; return kind === 'session'
? globalThis.sessionStorage
: globalThis.localStorage;
} catch { } catch {
// Privacy-restricted storage should not make first launch silent. // Privacy-restricted storage should not make first launch silent.
return null; return null;
@@ -211,8 +213,13 @@ export function shouldShowFirstRun({
if (params.get('welcome') === '0') return false; if (params.get('welcome') === '0') return false;
// The demo/support escape hatch outranks both suppressions on purpose. // The demo/support escape hatch outranks both suppressions on purpose.
if (params.get('welcome') === '1') return true; if (params.get('welcome') === '1') return true;
if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false; if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')
if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false; return false;
if (
readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) ===
'dismissed'
)
return false;
return true; return true;
} }
@@ -253,7 +260,10 @@ export function rememberFirstRunSessionDismissed(sessionStorageRef) {
* @param {() => Promise<any>} deps.flyToGlobe * @param {() => Promise<any>} deps.flyToGlobe
* @returns {Promise<{ok: boolean, choice: string, result?: object, failedLayerIds?: string[]}>} * @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]; const mission = FIRST_RUN_MISSIONS[choice];
if (!mission) return { ok: false, choice }; if (!mission) return { ok: false, choice };
if (mission.kind === 'none') return { ok: true, choice }; if (mission.kind === 'none') return { ok: true, choice };
@@ -267,15 +277,19 @@ export async function runFirstRunChoice(choice, { setContextMode, setLayerEnable
const flight = Promise.resolve() const flight = Promise.resolve()
.then(() => flyToGlobe()) .then(() => flyToGlobe())
.catch(() => null); .catch(() => null);
const outcomes = await Promise.all(mission.layerIds.map(async (layerId) => { const outcomes = await Promise.all(
mission.layerIds.map(async (layerId) => {
try { try {
return { layerId, ok: (await setLayerEnabled(layerId)) !== false }; return { layerId, ok: (await setLayerEnabled(layerId)) !== false };
} catch { } catch {
return { layerId, ok: false }; return { layerId, ok: false };
} }
})); }),
);
await flight; 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 }; return { ok: failedLayerIds.length === 0, choice, failedLayerIds };
} }
@@ -325,20 +339,25 @@ export function initFirstRunExperience({
if (!root || root.dataset.initialized === 'true') return null; if (!root || root.dataset.initialized === 'true') return null;
root.dataset.initialized = 'true'; root.dataset.initialized = 'true';
if (!shouldShowFirstRun({ if (
!shouldShowFirstRun({
hasShareState: styleManager?.hasShareState, hasShareState: styleManager?.hasShareState,
storage, storage,
sessionStorageRef, sessionStorageRef,
location, location,
})) { })
) {
root.remove(); root.remove();
return null; return null;
} }
// The tile name is owner-switchable from one constant, so paint it from the // 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. // module rather than trusting the markup to have been edited to match.
const environmentalTitle = root.querySelector('[data-first-run-environmental-title]'); const environmentalTitle = root.querySelector(
if (environmentalTitle) environmentalTitle.textContent = environmentalLabel().title; '[data-first-run-environmental-title]',
);
if (environmentalTitle)
environmentalTitle.textContent = environmentalLabel().title;
const status = root.querySelector('[data-first-run-status]'); const status = root.querySelector('[data-first-run-status]');
const suppressBox = root.querySelector('[data-first-run-suppress]'); 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 * 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. * then covers the overlays that leave the box intact and simply sit on top.
*/ */
const isTopmost = () => root.isConnected const isTopmost = () =>
&& root.classList.contains('visible') root.isConnected &&
&& root.getClientRects().length > 0 root.classList.contains('visible') &&
&& !coveredByOverlay(); root.getClientRects().length > 0 &&
!coveredByOverlay();
const dismiss = ({ restoreFocus = true } = {}) => { const dismiss = ({ restoreFocus = true } = {}) => {
if (closing) return; if (closing) return;
@@ -416,10 +436,13 @@ export function initFirstRunExperience({
root.setAttribute('aria-busy', String(next)); root.setAttribute('aria-busy', String(next));
// aria-disabled, not `disabled`: disabling the focused button drops focus to // aria-disabled, not `disabled`: disabling the focused button drops focus to
// <body> mid-flight and strands a keyboard visitor outside the launcher. // <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 (!status) return;
if (next) status.textContent = FIRST_RUN_MISSIONS[choice]?.busyText || 'Working…'; if (next)
else if (status.dataset.sticky !== 'true') status.textContent = defaultStatus; status.textContent = FIRST_RUN_MISSIONS[choice]?.busyText || 'Working…';
else if (status.dataset.sticky !== 'true')
status.textContent = defaultStatus;
}; };
const onChoice = async (event) => { const onChoice = async (event) => {
@@ -438,13 +461,16 @@ export function initFirstRunExperience({
// does not decide panel chrome. This first-run click is an explicit // does not decide panel chrome. This first-run click is an explicit
// visual choice, so reveal the result exactly as the visible // visual choice, so reveal the result exactly as the visible
// Contacts / Space Missions tabs do. // Contacts / Space Missions tabs do.
styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true }); styleManager.setPanelCollapsed?.('global-context-panel', false, {
explicit: true,
});
} }
return result; return result;
}, },
// `origin: 'user'` on purpose: a mission tile is a real person choosing // `origin: 'user'` on purpose: a mission tile is a real person choosing
// these layers, so it persists exactly as clicking those rows would. // 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(), flyToGlobe: () => styleManager.resetToGlobeView(),
}); });
} catch (error) { } catch (error) {
@@ -460,7 +486,8 @@ export function initFirstRunExperience({
const failed = outcome?.failedLayerIds?.length const failed = outcome?.failedLayerIds?.length
? outcome.failedLayerIds ? outcome.failedLayerIds
: outcome?.result?.failedLayerIds; : outcome?.result?.failedLayerIds;
const detail = Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : ''; const detail =
Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
if (status) { if (status) {
status.dataset.sticky = 'true'; status.dataset.sticky = 'true';
status.textContent = `Could not open that mission${detail}. Retry or explore manually.`; 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 (box) box.checked = !wanted;
if (!status) return; if (!status) return;
status.dataset.sticky = 'true'; 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({ const keyboard = createSurfaceKeyboard({
@@ -585,13 +613,17 @@ export function initFirstRunExperience({
const onViewportResize = () => syncScrollAffordance(); const onViewportResize = () => syncScrollAffordance();
globalThis.addEventListener?.('resize', onViewportResize); globalThis.addEventListener?.('resize', onViewportResize);
const surfaceObserver = typeof globalThis.MutationObserver === 'function' const surfaceObserver =
typeof globalThis.MutationObserver === 'function'
? new globalThis.MutationObserver(syncToExclusiveSurfaces) ? new globalThis.MutationObserver(syncToExclusiveSurfaces)
: null; : null;
// Attributes only, no subtree: this is a class watch on one element, so it // 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. // costs nothing per frame and never asks the render governor for a frame.
if (documentRef.body) { if (documentRef.body) {
surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] }); surfaceObserver?.observe(documentRef.body, {
attributes: true,
attributeFilter: ['class'],
});
} }
syncToExclusiveSurfaces(); syncToExclusiveSurfaces();
+92 -27
View File
@@ -18,16 +18,36 @@ import * as Cesium from 'cesium';
import { forward as toMGRS } from 'mgrs'; import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './locations.js'; import { CITY_POIS } from './locations.js';
import { composeLocalityTag } from './hudLocality.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 { getBasemapLabelContext } from './voice/gevActions.js';
import { isHudSummaryUnconfigured } from './hudSummaryResponse.js'; import { isHudSummaryUnconfigured } from './hudSummaryResponse.js';
/** Color palettes keyed by shader mode; applied as CSS custom properties. */ /** Color palettes keyed by shader mode; applied as CSS custom properties. */
const HUD_COLORS = { 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)' }, surveillance: {
thermal: { main: 'rgba(255, 255, 255, 0.7)', glow: 'rgba(255, 255, 255, 0.4)', border: 'rgba(255, 255, 255, 0.15)' }, main: 'rgba(51, 255, 51, 0.8)',
retro: { main: 'rgba(255, 170, 0, 0.8)', glow: 'rgba(255, 170, 0, 0.5)', border: 'rgba(255, 170, 0, 0.2)' }, glow: 'rgba(51, 255, 51, 0.5)',
_default: { main: 'rgba(0, 255, 255, 0.6)', glow: 'rgba(0, 255, 255, 0.4)', border: 'rgba(0, 255, 255, 0.15)' }, 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. */ /** 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; const HUD_GEOID_CELL_DEG = 0.01;
/** Flattened list of all city POIs for nearest-point lookups. */ /** Flattened list of all city POIs for nearest-point lookups. */
const NEARBY_POINTS = Object.values(CITY_POIS) const NEARBY_POINTS = Object.values(CITY_POIS).flatMap((city) =>
.flatMap((city) => city.pois.map((poi) => ({ city.pois.map((poi) => ({
city: city.name, city: city.name,
poi: poi.name, poi: poi.name,
lat: poi.lat, lat: poi.lat,
lon: poi.lon, lon: poi.lon,
}))); })),
);
/** /**
* Full-screen intelligence HUD overlay rendered on top of the Cesium canvas. * 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 * @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for
* camera telemetry and coordinate derivation. * 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.summaryService = summaryService;
this.summaryPolicy = summaryPolicy; this.summaryPolicy = summaryPolicy;
this.basemapContext = basemapContext; this.basemapContext = basemapContext;
@@ -227,7 +256,8 @@ export class IntelHUD {
this._recBlinkInterval = setInterval(() => { this._recBlinkInterval = setInterval(() => {
this._recBlinkState = !this._recBlinkState; this._recBlinkState = !this._recBlinkState;
const dot = document.getElementById('hud-rec-dot'); 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); }, 800);
// Camera-derived data — 4 updates/second (250ms) // Camera-derived data — 4 updates/second (250ms)
@@ -269,8 +299,12 @@ export class IntelHUD {
if (!this._geoidRequested) { if (!this._geoidRequested) {
this._geoidRequested = true; this._geoidRequested = true;
ensureGeoidReady() ensureGeoidReady()
.then(() => { this._geoidReady = true; }) .then(() => {
.catch(() => { /* readout falls back to the uncorrected height */ }); this._geoidReady = true;
})
.catch(() => {
/* readout falls back to the uncorrected height */
});
} }
return null; return null;
} }
@@ -331,9 +365,13 @@ export class IntelHUD {
// Equation (GIQE) simplified form: NIIRS = 10.25 - 3.32 * log10(GSD_inches). // Equation (GIQE) simplified form: NIIRS = 10.25 - 3.32 * log10(GSD_inches).
const gsd = Math.max(0.01, altM * 0.000375); const gsd = Math.max(0.01, altM * 0.000375);
const gsdInches = gsd * 39.37; 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'); 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 // Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
// ellipsoidal camera height, which reads far below zero wherever the geoid // 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 geoidN = this._geoidUndulationM(latDeg, lonDeg);
const altMslM = ellipsoidalToMslDisplayM(altM, geoidN); const altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
const sunEl = this._estimateSunElevation(latDeg, lonDeg); 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 // Collection timestamp
const collEl = document.getElementById('hud-coll'); const collEl = document.getElementById('hud-coll');
@@ -435,7 +474,10 @@ export class IntelHUD {
if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S'; if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S';
else dir = decimal >= 0 ? 'E' : 'W'; 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}`; return `${degStr}°${String(min).padStart(2, '0')}'${String(sec).padStart(5, '0')}"${dir}`;
} }
@@ -457,12 +499,20 @@ export class IntelHUD {
const solarNoon = 12; const solarNoon = 12;
const hourAngle = (hours - solarNoon) * 15; const hourAngle = (hours - solarNoon) * 15;
// Solar declination approximation (~23.45 deg amplitude sinusoidal over the year) // 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 latRad = Cesium.Math.toRadians(lat);
const decRad = Cesium.Math.toRadians(declination); const decRad = Cesium.Math.toRadians(declination);
const haRad = Cesium.Math.toRadians(hourAngle); const haRad = Cesium.Math.toRadians(hourAngle);
// Standard formula: sin(el) = sin(lat)*sin(dec) + cos(lat)*cos(dec)*cos(ha) // 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 // 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)))); return Cesium.Math.toDegrees(Math.asin(Math.max(-1, Math.min(1, sinEl))));
} }
@@ -489,8 +539,10 @@ export class IntelHUD {
_regionLabel(lat, lon) { _regionLabel(lat, lon) {
if (lat > 72) return 'ARCTIC'; if (lat > 72) return 'ARCTIC';
if (lat < -60) return 'ANTARCTIC'; if (lat < -60) return 'ANTARCTIC';
if (lat >= 5 && lat <= 83 && lon >= -170 && lon <= -50) return 'NORTH AMERICA'; if (lat >= 5 && lat <= 83 && lon >= -170 && lon <= -50)
if (lat >= -60 && lat <= 15 && lon >= -90 && lon <= -30) return 'SOUTH AMERICA'; 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 >= 34 && lat <= 72 && lon >= -25 && lon <= 45) return 'EUROPE';
if (lat >= -35 && lat <= 38 && lon >= -20 && lon <= 55) return 'AFRICA'; if (lat >= -35 && lat <= 38 && lon >= -20 && lon <= 55) return 'AFRICA';
if (lat >= 5 && lat <= 80 && lon >= 45 && lon <= 180) return 'ASIA'; if (lat >= 5 && lat <= 80 && lon >= 45 && lon <= 180) return 'ASIA';
@@ -519,7 +571,10 @@ export class IntelHUD {
const latSpan = Math.abs(north - south); const latSpan = Math.abs(north - south);
// 111 km/deg is the approximate surface distance per degree of latitude; // 111 km/deg is the approximate surface distance per degree of latitude;
// longitude distance is scaled by cos(lat) to account for meridian convergence. // 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); const heightKm = Math.max(0, latSpan * 111);
return { widthKm, heightKm }; return { widthKm, heightKm };
} }
@@ -537,8 +592,9 @@ export class IntelHUD {
const toRad = (deg) => Cesium.Math.toRadians(deg); const toRad = (deg) => Cesium.Math.toRadians(deg);
const dLat = toRad(lat2 - lat1); const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1); const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 const a =
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; 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)); return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
} }
@@ -583,7 +639,8 @@ export class IntelHUD {
// together, so they must never disagree. The view band above deliberately // together, so they must never disagree. The view band above deliberately
// keeps the ellipsoidal height: its thresholds were tuned against it. // keeps the ellipsoidal height: its thresholds were tuned against it.
const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM; const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM;
const altTag = altDisplayM >= 1000 const altTag =
altDisplayM >= 1000
? `${(altDisplayM / 1000).toFixed(1)}KM` ? `${(altDisplayM / 1000).toFixed(1)}KM`
: `${Math.round(altDisplayM)}M`; : `${Math.round(altDisplayM)}M`;
const winTag = window const winTag = window
@@ -665,7 +722,9 @@ export class IntelHUD {
this._summaryRequest = controller; this._summaryRequest = controller;
try { try {
this.summaryPolicy.onRequest?.(); 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; const data = response.data;
if (revision !== this._summaryRevision) return; if (revision !== this._summaryRevision) return;
if (isHudSummaryUnconfigured(response.status, data)) { if (isHudSummaryUnconfigured(response.status, data)) {
@@ -701,8 +760,14 @@ export class IntelHUD {
} }
async _summaryContext() { async _summaryContext() {
const labels = await getBasemapLabelContext(this.viewer, this.placeSearch, this.basemapContext); const labels = await getBasemapLabelContext(
const enabledLayers = this._dataManager?.getAll?.() this.viewer,
this.placeSearch,
this.basemapContext,
);
const enabledLayers =
this._dataManager
?.getAll?.()
?.filter((layer) => layer.enabled) ?.filter((layer) => layer.enabled)
.map((layer) => layer.name) || []; .map((layer) => layer.name) || [];
return { return {
+10 -7
View File
@@ -23,13 +23,16 @@ export function keylessHudSummaryResponse(apiKey) {
/** Return true only for the deliberate, successful no-key capability response. */ /** Return true only for the deliberate, successful no-key capability response. */
export function isHudSummaryUnconfigured(status, data) { export function isHudSummaryUnconfigured(status, data) {
const keys = data !== null && typeof data === 'object' && !Array.isArray(data) const keys =
data !== null && typeof data === 'object' && !Array.isArray(data)
? Object.keys(data) ? Object.keys(data)
: []; : [];
return keys.length === 4 return (
&& status === 200 keys.length === 4 &&
&& data?.configured === false status === 200 &&
&& data?.code === HUD_SUMMARY_UNCONFIGURED_CODE data?.configured === false &&
&& data?.error === null data?.code === HUD_SUMMARY_UNCONFIGURED_CODE &&
&& data?.summary === null; data?.error === null &&
data?.summary === null
);
} }
+68 -25
View File
@@ -19,7 +19,9 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
/** Chip label — pure, exported for tests. */ /** Chip label — pure, exported for tests. */
export function keySetupChipLabel(status) { export function keySetupChipLabel(status) {
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0)); 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'); const tier = documentRef.createElement('span');
tier.className = 'key-setup-tier'; tier.className = 'key-setup-tier';
tier.textContent = TIER_DOTS[key.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); head.append(led, title, tier);
if (key.clientExposed) { if (key.clientExposed) {
const exposed = documentRef.createElement('span'); const exposed = documentRef.createElement('span');
exposed.className = 'key-setup-exposed'; exposed.className = 'key-setup-exposed';
exposed.textContent = 'browser-side'; 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); head.append(exposed);
} }
if (external) { if (external) {
@@ -93,7 +99,8 @@ function buildRow(documentRef, key) {
const badge = documentRef.createElement('span'); const badge = documentRef.createElement('span');
badge.className = 'key-setup-external'; badge.className = 'key-setup-external';
badge.textContent = 'configured externally'; 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); head.append(badge);
} }
const get = documentRef.createElement('a'); 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 * 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). * 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 chip = documentRef?.getElementById?.('key-setup-chip');
const root = documentRef?.getElementById?.('key-setup'); const root = documentRef?.getElementById?.('key-setup');
if (!chip || !root || root.dataset.initialized === 'true') return null; if (!chip || !root || root.dataset.initialized === 'true') return null;
@@ -161,13 +172,19 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
chip.remove(); chip.remove();
root.remove(); root.remove();
}; };
if (signal?.aborted) { destroy(); return null; } if (signal?.aborted) {
destroy();
return null;
}
signal?.addEventListener('abort', destroy, { once: true }); signal?.addEventListener('abort', destroy, { once: true });
const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis); const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis);
let status = null; let status = null;
try { 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)); if (!response.ok) throw new Error(String(response.status));
status = await response.json(); status = await response.json();
if (disposed) return null; if (disposed) return null;
@@ -196,12 +213,14 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
chip.hidden = status.setCount >= status.total; chip.hidden = status.setCount >= status.total;
if (!rowsHost) return; if (!rowsHost) return;
rowsHost.textContent = ''; 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 const visible = () =>
&& root.classList.contains('visible') root.isConnected &&
&& root.getClientRects().length > 0; root.classList.contains('visible') &&
root.getClientRects().length > 0;
const keyboard = createSurfaceKeyboard({ const keyboard = createSurfaceKeyboard({
root, root,
@@ -226,22 +245,29 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
if (!open) return; if (!open) return;
open = false; open = false;
root.classList.remove('visible'); root.classList.remove('visible');
const hide = () => { if (!open) root.hidden = true; }; const hide = () => {
if (!open) root.hidden = true;
};
root.addEventListener('transitionend', hide, { once: true }); root.addEventListener('transitionend', hide, { once: true });
globalThis.setTimeout?.(hide, 400); globalThis.setTimeout?.(hide, 400);
if (statusLine) statusLine.textContent = defaultStatusText; if (statusLine) statusLine.textContent = defaultStatusText;
keyboard.deactivate({ restoreFocus: true }); 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' const storeLabel = () =>
status?.store === 'pinokio-environment'
? 'your app configuration' ? 'your app configuration'
: 'your local .env'); : 'your local .env';
const submitUpdates = async (updates, doneVerb) => { const submitUpdates = async (updates, doneVerb) => {
if (disposed || busy) return; 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; busy = true;
applyButton?.setAttribute('aria-disabled', 'true'); applyButton?.setAttribute('aria-disabled', 'true');
say('Saving…'); say('Saving…');
@@ -258,13 +284,17 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
say(payload.error || `Save failed (${response.status}).`); say(payload.error || `Save failed (${response.status}).`);
return; 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); render(payload.status);
if (googleWasUnset && payload.saved?.includes('GOOGLE_MAPS_API_KEY')) { if (googleWasUnset && payload.saved?.includes('GOOGLE_MAPS_API_KEY')) {
const strip = () => { const strip = () => {
try { try {
const next = stripKeylessBasemapFromHash(globalThis.location?.hash?.slice(1) || ''); const next = stripKeylessBasemapFromHash(
if (next !== null) globalThis.history?.replaceState?.(null, '', `#${next}`); globalThis.location?.hash?.slice(1) || '',
);
if (next !== null)
globalThis.history?.replaceState?.(null, '', `#${next}`);
} catch { } catch {
// Continuity is a nicety, never a blocker. // Continuity is a nicety, never a blocker.
} }
@@ -272,9 +302,14 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
strip(); strip();
// The live share writer may re-serialize the still-OSM stack before // The live share writer may re-serialize the still-OSM stack before
// the restart's reload lands, so strip again at the door. // 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) { } catch (error) {
say(`Save failed: ${error?.message || error}`); say(`Save failed: ${error?.message || error}`);
} finally { } finally {
@@ -287,7 +322,10 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
if (disposed || busy) return; if (disposed || busy) return;
const inputs = [...root.querySelectorAll('input[data-env-var]')]; const inputs = [...root.querySelectorAll('input[data-env-var]')];
const updates = collectKeyUpdates( 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) { if (!Object.keys(updates).length) {
say('Paste at least one key first.'); 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 // Removal is destructive and — behind a framing defense that should already
// stop it — a clickjack target. A confirm turns a single aligned click into // stop it — a clickjack target. A confirm turns a single aligned click into
// a deliberate two-step the lure cannot pre-satisfy. // a deliberate two-step the lure cannot pre-satisfy.
const ok = typeof globalThis.confirm !== 'function' const ok =
|| globalThis.confirm('Remove this key from your saved configuration?'); typeof globalThis.confirm !== 'function' ||
globalThis.confirm('Remove this key from your saved configuration?');
if (!ok) return; if (!ok) return;
void submitUpdates( void submitUpdates(
Object.fromEntries(envVars.map((name) => [name, null])), 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 // Re-entry for a fully-keyed setup, demos, and support: ?setup=1 opens the
// dialog even though the chip has retired. // dialog even though the chip has retired.
try { try {
if (new URLSearchParams(globalThis.location?.search || '').get('setup') === '1') openDialog(); if (
new URLSearchParams(globalThis.location?.search || '').get('setup') ===
'1'
)
openDialog();
} catch { } catch {
// An unparsable location never blocks init. // An unparsable location never blocks init.
} }
+15 -3
View File
@@ -1336,7 +1336,12 @@ function buildingPitch(bounds) {
return -32; return -32;
} }
async function resolveBuildingBounds(lat, lon, query, boundaries = applicationServices.boundaries) { async function resolveBuildingBounds(
lat,
lon,
query,
boundaries = applicationServices.boundaries,
) {
const overpassQuery = ` const overpassQuery = `
[out:json][timeout:10]; [out:json][timeout:10];
( (
@@ -1352,8 +1357,15 @@ async function resolveBuildingBounds(lat, lon, query, boundaries = applicationSe
const controller = new AbortController(); const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 6000); const timeout = window.setTimeout(() => controller.abort(), 6000);
try { try {
const elements = await boundaries.query(overpassQuery, { signal: controller.signal }); const elements = await boundaries.query(overpassQuery, {
return selectBuildingBounds(Array.isArray(elements) ? elements : [], lat, lon, query); signal: controller.signal,
});
return selectBuildingBounds(
Array.isArray(elements) ? elements : [],
lat,
lon,
query,
);
} catch { } catch {
return null; return null;
} finally { } 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. * @param {number} [maxOffset=MAX_GAZE_SVG_UNITS] - Maximum SVG-space translation.
* @returns {{x:number, y:number}} * @returns {{x:number, y:number}}
*/ */
export function calculateLogoGaze(clientX, clientY, rect, maxOffset = MAX_GAZE_SVG_UNITS) { export function calculateLogoGaze(
const values = [clientX, clientY, rect?.left, rect?.top, rect?.width, rect?.height, maxOffset]; clientX,
if (!values.every(Number.isFinite) || rect.width <= 0 || rect.height <= 0 || maxOffset < 0) { 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 }; 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 }; if (distance === 0) return { x: 0, y: 0 };
const strength = Math.min(distance / FULL_GAZE_DISTANCE_PX, 1); 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 }; return { x: dx * scale, y: dy * scale };
} }
@@ -43,7 +61,9 @@ export function initLogoGaze(root = document) {
const logos = [...root.querySelectorAll('[data-logo-gaze]')]; const logos = [...root.querySelectorAll('[data-logo-gaze]')];
if (!logos.length) return () => {}; 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) => ({ const states = logos.map((element) => ({
element, element,
parts: [], parts: [],
@@ -68,7 +88,10 @@ export function initLogoGaze(root = document) {
const deltaX = state.targetX - state.currentX; const deltaX = state.targetX - state.currentX;
const deltaY = state.targetY - state.currentY; 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.currentX = state.targetX;
state.currentY = state.targetY; state.currentY = state.targetY;
} else { } else {
@@ -94,7 +117,10 @@ export function initLogoGaze(root = document) {
const markup = await response.text(); const markup = await response.text();
if (disposed) return; 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; if (svgDocument.querySelector('parsererror')) return;
const sourceSvg = svgDocument.documentElement; const sourceSvg = svgDocument.documentElement;
@@ -122,7 +148,11 @@ export function initLogoGaze(root = document) {
const onPointerMove = (event) => { const onPointerMove = (event) => {
if (reducedMotion || event.pointerType === 'touch') return; if (reducedMotion || event.pointerType === 'touch') return;
for (const state of states) { 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.targetX = gaze.x;
state.targetY = gaze.y; state.targetY = gaze.y;
} }
+10 -4
View File
@@ -30,12 +30,14 @@ export function announceNavigationAuthority(
{ eventTarget = globalThis.window, cancelPendingSelection = true } = {}, { eventTarget = globalThis.window, cancelPendingSelection = true } = {},
) { ) {
if (typeof eventTarget?.dispatchEvent !== 'function') return false; if (typeof eventTarget?.dispatchEvent !== 'function') return false;
eventTarget.dispatchEvent(new CustomEvent(NAVIGATION_AUTHORITY_EVENT, { eventTarget.dispatchEvent(
new CustomEvent(NAVIGATION_AUTHORITY_EVENT, {
detail: { detail: {
reason: String(reason || 'layer-focus'), reason: String(reason || 'layer-focus'),
cancelPendingSelection: Boolean(cancelPendingSelection), cancelPendingSelection: Boolean(cancelPendingSelection),
}, },
})); }),
);
return true; return true;
} }
@@ -46,8 +48,12 @@ export function announceNavigationAuthority(
* @returns {() => void} Idempotent disposer. * @returns {() => void} Idempotent disposer.
*/ */
export function registerNavigationAuthorityListener(eventTarget, listener) { export function registerNavigationAuthorityListener(eventTarget, listener) {
if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener if (
|| typeof listener !== 'function') return () => {}; !eventTarget?.addEventListener ||
!eventTarget?.removeEventListener ||
typeof listener !== 'function'
)
return () => {};
eventTarget.addEventListener(NAVIGATION_AUTHORITY_EVENT, listener); eventTarget.addEventListener(NAVIGATION_AUTHORITY_EVENT, listener);
let disposed = false; let disposed = false;
return () => { return () => {
+5 -2
View File
@@ -1,5 +1,8 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { holdContinuousRender, releaseContinuousRender } from './renderGovernor.js'; import {
holdContinuousRender,
releaseContinuousRender,
} from './renderGovernor.js';
/** /**
* OrbitController — smooth orbit around a target point. * OrbitController — smooth orbit around a target point.
@@ -54,7 +57,7 @@ export class OrbitController {
const hpr = new Cesium.HeadingPitchRange( const hpr = new Cesium.HeadingPitchRange(
this.angle, this.angle,
Cesium.Math.toRadians(this.pitch), Cesium.Math.toRadians(this.pitch),
this.radius this.radius,
); );
this.viewer.camera.lookAt(this.target, hpr); this.viewer.camera.lookAt(this.target, hpr);
}); });
File diff suppressed because it is too large. Load diff
+246 -74
View File
@@ -49,22 +49,34 @@ export function clearWorldOverlayTextMeasureCache() {
/** Install the font-loading invalidation hooks once, when the API exists. */ /** Install the font-loading invalidation hooks once, when the API exists. */
export function installWorldOverlayFontInvalidation() { export function installWorldOverlayFontInvalidation() {
if (_fontInvalidationInstalled || typeof document === 'undefined' || !document.fonts) return; if (
_fontInvalidationInstalled ||
typeof document === 'undefined' ||
!document.fonts
)
return;
_fontInvalidationInstalled = true; _fontInvalidationInstalled = true;
_observedFontSet = document.fonts; _observedFontSet = document.fonts;
const generation = ++_fontInvalidationGeneration; const generation = ++_fontInvalidationGeneration;
Promise.resolve(document.fonts.ready) Promise.resolve(document.fonts.ready)
.then(() => { .then(() => {
if (generation === _fontInvalidationGeneration) clearWorldOverlayTextMeasureCache(); if (generation === _fontInvalidationGeneration)
clearWorldOverlayTextMeasureCache();
}) })
.catch(() => {}); .catch(() => {});
_observedFontSet.addEventListener?.('loadingdone', clearWorldOverlayTextMeasureCache); _observedFontSet.addEventListener?.(
'loadingdone',
clearWorldOverlayTextMeasureCache,
);
} }
/** Remove font hooks and cached measurements when the overlay host is destroyed. */ /** Remove font hooks and cached measurements when the overlay host is destroyed. */
export function destroyWorldOverlayDraw() { export function destroyWorldOverlayDraw() {
_fontInvalidationGeneration++; _fontInvalidationGeneration++;
_observedFontSet?.removeEventListener?.('loadingdone', clearWorldOverlayTextMeasureCache); _observedFontSet?.removeEventListener?.(
'loadingdone',
clearWorldOverlayTextMeasureCache,
);
_observedFontSet = null; _observedFontSet = null;
_fontInvalidationInstalled = false; _fontInvalidationInstalled = false;
clearWorldOverlayTextMeasureCache(); clearWorldOverlayTextMeasureCache();
@@ -95,7 +107,8 @@ export function measureWorldOverlayText(ctx, text, font) {
} }
fontCache.set(normalizedText, { width, usedAt: ++_textMeasureClock }); fontCache.set(normalizedText, { width, usedAt: ++_textMeasureClock });
_textMeasureCacheSize++; _textMeasureCacheSize++;
if (_textMeasureCacheSize > TEXT_MEASURE_CACHE_LIMIT) evictOldestTextMeasureEntry(); if (_textMeasureCacheSize > TEXT_MEASURE_CACHE_LIMIT)
evictOldestTextMeasureEntry();
return width; return width;
} }
@@ -113,7 +126,14 @@ export function getWorldOverlayTextMeasureCacheSize() {
* @param {number} h * @param {number} h
* @param {number} radius * @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') { if (typeof path.roundRect === 'function') {
path.roundRect(x, y, w, h, radius); path.roundRect(x, y, w, h, radius);
return; return;
@@ -137,11 +157,14 @@ export function roundedRectPath(path, x, y, w, h, radius = WORLD_OVERLAY_STYLE.r
* @param {number} [options.fadeStartRatio] * @param {number} [options.fadeStartRatio]
* @returns {number} * @returns {number}
*/ */
export function distanceFade(distanceM, { export function distanceFade(
distanceM,
{
minDistance = 0, minDistance = 0,
maxDistance = Number.POSITIVE_INFINITY, maxDistance = Number.POSITIVE_INFINITY,
fadeStartRatio = 0.7, fadeStartRatio = 0.7,
} = {}) { } = {},
) {
if (!Number.isFinite(distanceM)) return 0; if (!Number.isFinite(distanceM)) return 0;
const min = Number.isFinite(minDistance) ? Math.max(0, minDistance) : 0; const min = Number.isFinite(minDistance) ? Math.max(0, minDistance) : 0;
if (distanceM < min) return 0; if (distanceM < min) return 0;
@@ -192,17 +215,27 @@ export function altitudeScale(altitudeM, curve = null) {
const end = Number(curve.end); const end = Number(curve.end);
const midValue = Number(curve.midValue); const midValue = Number(curve.midValue);
const endValue = Number(curve.endValue); const endValue = Number(curve.endValue);
if (!Number.isFinite(fullEnd) || !Number.isFinite(midEnd) || !Number.isFinite(end) if (
|| !Number.isFinite(midValue) || !Number.isFinite(endValue)) return 1; !Number.isFinite(fullEnd) ||
!Number.isFinite(midEnd) ||
!Number.isFinite(end) ||
!Number.isFinite(midValue) ||
!Number.isFinite(endValue)
)
return 1;
if (altitudeM <= fullEnd) return 1; if (altitudeM <= fullEnd) return 1;
if (altitudeM <= midEnd) { if (altitudeM <= midEnd) {
const span = Math.max(1, midEnd - fullEnd); const span = Math.max(1, midEnd - fullEnd);
let progress = Math.max(0, Math.min(1, (altitudeM - fullEnd) / span)); 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; return 1 + (midValue - 1) * progress;
} }
if (altitudeM >= end) return endValue; 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; return midValue + (endValue - midValue) * progress;
} }
@@ -215,11 +248,14 @@ export function altitudeScale(altitudeM, curve = null) {
* @param {number} [options.fadeEnd] * @param {number} [options.fadeEnd]
* @returns {number} * @returns {number}
*/ */
export function altitudeFade(altitudeM, { export function altitudeFade(
altitudeM,
{
minAltitude = Number.NEGATIVE_INFINITY, minAltitude = Number.NEGATIVE_INFINITY,
fadeStart = Number.POSITIVE_INFINITY, fadeStart = Number.POSITIVE_INFINITY,
fadeEnd = Number.POSITIVE_INFINITY, fadeEnd = Number.POSITIVE_INFINITY,
} = {}) { } = {},
) {
if (!Number.isFinite(altitudeM)) return 1; if (!Number.isFinite(altitudeM)) return 1;
if (Number.isFinite(minAltitude) && altitudeM < minAltitude) return 0; if (Number.isFinite(minAltitude) && altitudeM < minAltitude) return 0;
if (!Number.isFinite(fadeEnd)) return 1; if (!Number.isFinite(fadeEnd)) return 1;
@@ -238,24 +274,32 @@ export function combinedOverlayAlpha({
altitudeFade: altitudeAlpha = 1, altitudeFade: altitudeAlpha = 1,
keyholeEdgeFade = 1, keyholeEdgeFade = 1,
} = {}) { } = {}) {
return clampUnit(sourceAlpha) return (
* clampUnit(temporalFade) clampUnit(sourceAlpha) *
* clampUnit(distanceAlpha) clampUnit(temporalFade) *
* clampUnit(altitudeAlpha) clampUnit(distanceAlpha) *
* clampUnit(keyholeEdgeFade); clampUnit(altitudeAlpha) *
clampUnit(keyholeEdgeFade)
);
} }
function clampUnit(value) { 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) { function trackDisplayText(entry) {
const title = entry?.title || ''; const title = entry?.title || '';
const detail = Array.isArray(entry?.details) ? entry.details[0] || '' : ''; 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._overlayTrackDisplayTitle = title;
entry._overlayTrackDisplayDetail = detail; entry._overlayTrackDisplayDetail = detail;
entry._overlayTrackDisplayText = title && detail ? `${title} · ${detail}` : title || detail; entry._overlayTrackDisplayText =
title && detail ? `${title} · ${detail}` : title || detail;
} }
return entry._overlayTrackDisplayText || ''; 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}} * @returns {{w:number,h:number,padX:number,padY:number,titleH:number,lineH:number,thumbW:number,thumbH:number}}
*/ */
export function measureOverlayEntry(ctx, entry, out = {}) { 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 details = Array.isArray(entry?.details) ? entry.details : [];
const selected = variant === 'selected'; const selected = variant === 'selected';
const tracked = variant === 'tracked'; const tracked = variant === 'tracked';
const tactical = entry?.cardStyle === 'tactical'; const tactical = entry?.cardStyle === 'tactical';
const titleFont = tracked const titleFont = tracked
? WORLD_OVERLAY_STYLE.fontTrackedTitle ? WORLD_OVERLAY_STYLE.fontTrackedTitle
: selected ? WORLD_OVERLAY_STYLE.fontSelected : WORLD_OVERLAY_STYLE.fontTitle; : selected
let titleWidth = measureWorldOverlayText(ctx, entry?.title || '', ? WORLD_OVERLAY_STYLE.fontSelected
variant === 'label' ? WORLD_OVERLAY_STYLE.fontLabel : titleFont); : WORLD_OVERLAY_STYLE.fontTitle;
let titleWidth = measureWorldOverlayText(
ctx,
entry?.title || '',
variant === 'label' ? WORLD_OVERLAY_STYLE.fontLabel : titleFont,
);
if (variant === 'track' && details[0]) { if (variant === 'track' && details[0]) {
titleWidth = measureWorldOverlayText( titleWidth = measureWorldOverlayText(
ctx, ctx,
@@ -292,16 +343,38 @@ export function measureOverlayEntry(ctx, entry, out = {}) {
measureWorldOverlayText( measureWorldOverlayText(
ctx, ctx,
details[i], 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.padX = tracked
out.padY = tracked ? 9 : selected ? 8 : variant === 'label' || variant === 'track' ? 4 : 6; ? 13
: selected
? 12
: variant === 'label' || variant === 'track'
? 6
: 9;
out.padY = tracked
? 9
: selected
? 8
: variant === 'label' || variant === 'track'
? 4
: 6;
out.titleH = tactical out.titleH = tactical
? (selected ? 14 : 12) ? selected
: tracked ? 13 : selected ? 15 : variant === 'label' || variant === 'track' ? 11 : 13; ? 14
: 12
: tracked
? 13
: selected
? 15
: variant === 'label' || variant === 'track'
? 11
: 13;
out.lineH = tracked ? 17 : selected ? 15 : 13; out.lineH = tracked ? 17 : selected ? 15 : 13;
out.thumbW = 0; out.thumbW = 0;
out.thumbH = 0; out.thumbH = 0;
@@ -325,7 +398,17 @@ export function measureOverlayEntry(ctx, entry, out = {}) {
return 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 || {}; const placement = out || {};
placement.corner = corner; placement.corner = corner;
placement.rect ||= {}; 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 // pushed the card sideways off its anchor, which reads as "the card isn't
// attached to that camera". // attached to that camera".
placement.leadToX = anchorX; 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 { } else {
placement.leadFromX = anchorX; placement.leadFromX = anchorX;
placement.leadFromY = anchorY; placement.leadFromY = anchorY;
placement.leadToX = corner === 'left' ? placement.rect.x + w : placement.rect.x; placement.leadToX =
placement.leadToY = Math.max(placement.rect.y, Math.min(anchorY, placement.rect.y + h)); 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; return placement;
} }
@@ -373,7 +461,10 @@ const VERTICAL_PLACEMENT_ORDERS = Object.freeze({
}); });
function clampPlacementCoordinate(value, size, viewportSize, margin = 4) { 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,7 +485,8 @@ function clampPlacementCoordinate(value, size, viewportSize, margin = 4) {
* @param {Array<object>} [out] * @param {Array<object>} [out]
* @returns {Array<object>} * @returns {Array<object>}
*/ */
export function placementVariants({ export function placementVariants(
{
anchorX, anchorX,
anchorY, anchorY,
width, width,
@@ -406,7 +498,9 @@ export function placementVariants({
leaderOffset = 0, leaderOffset = 0,
verticalOnly = false, verticalOnly = false,
viewportMargin = 4, viewportMargin = 4,
}, out = []) { },
out = [],
) {
const automatic = anchorY - gap - height >= 4 ? 'above' : 'below'; const automatic = anchorY - gap - height >= 4 ? 'above' : 'below';
const orders = verticalOnly ? VERTICAL_PLACEMENT_ORDERS : PLACEMENT_ORDERS; const orders = verticalOnly ? VERTICAL_PLACEMENT_ORDERS : PLACEMENT_ORDERS;
const order = orders[preferred] || orders[automatic]; const order = orders[preferred] || orders[automatic];
@@ -437,7 +531,9 @@ export function placementVariants({
height, height,
anchorX, anchorX,
anchorY, anchorY,
corner === 'above' || corner === 'left' ? negativeLeaderOffset : positiveLeaderOffset, corner === 'above' || corner === 'left'
? negativeLeaderOffset
: positiveLeaderOffset,
); );
} }
out.length = order.length; out.length = order.length;
@@ -454,9 +550,15 @@ function drawLeader(ctx, placement, accent) {
if (placement.leaderOffset === 0) { if (placement.leaderOffset === 0) {
ctx.moveTo(placement.leadFromX, placement.leadFromY); ctx.moveTo(placement.leadFromX, placement.leadFromY);
} else if (placement.corner === 'above' || placement.corner === 'below') { } 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 { } else {
ctx.moveTo(placement.leadFromX + placement.leaderOffset, placement.leadFromY); ctx.moveTo(
placement.leadFromX + placement.leaderOffset,
placement.leadFromY,
);
} }
ctx.lineTo(placement.leadToX, placement.leadToY); ctx.lineTo(placement.leadToX, placement.leadToY);
ctx.stroke(); ctx.stroke();
@@ -468,9 +570,13 @@ function drawCardChrome(ctx, entry, placement, selected = false) {
drawLeader(ctx, placement, accent); drawLeader(ctx, placement, accent);
ctx.beginPath(); ctx.beginPath();
roundedRectPath(ctx, x, y, w, h); 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.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.lineWidth = selected ? 1.25 : 1;
ctx.stroke(); ctx.stroke();
ctx.fillStyle = accent; ctx.fillStyle = accent;
@@ -482,7 +588,9 @@ function drawCardText(ctx, entry, placement, selected = false, topOffset = 0) {
const x = placement.rect.x + (selected ? 12 : 9); const x = placement.rect.x + (selected ? 12 : 9);
let y = placement.rect.y + (selected ? 8 : 6) + topOffset; let y = placement.rect.y + (selected ? 8 : 6) + topOffset;
ctx.fillStyle = WORLD_OVERLAY_STYLE.title; 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.textBaseline = 'top';
ctx.fillText(String(entry.title || ''), x, y); ctx.fillText(String(entry.title || ''), x, y);
y += selected ? 15 : 13; y += selected ? 15 : 13;
@@ -497,7 +605,8 @@ function drawCardText(ctx, entry, placement, selected = false, topOffset = 0) {
function colorWithAlpha(color, alpha) { function colorWithAlpha(color, alpha) {
const text = String(color || WORLD_OVERLAY_STYLE.accent).trim(); 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})$/); 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); const hex = text.match(/^#([0-9a-f]{6})$/i);
if (hex) { if (hex) {
const value = Number.parseInt(hex[1], 16); 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; 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()) { export function leaderRevealProgress(
const linear = Math.min(1, animationLinearProgress(entry, timestamp) / leaderDrawRatio(entry)); entry,
timestamp = globalThis.performance?.now?.() ?? Date.now(),
) {
const linear = Math.min(
1,
animationLinearProgress(entry, timestamp) / leaderDrawRatio(entry),
);
return 1 - Math.pow(1 - linear, 3); 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; if (!(Math.max(0, Number(entry?.leaderAnimationMs) || 0) > 0)) return 1;
const start = leaderDrawRatio(entry); const start = leaderDrawRatio(entry);
const linear = Math.max(0, Math.min(1, const linear = Math.max(
(animationLinearProgress(entry, timestamp) - start) / Math.max(0.1, 1 - start))); 0,
Math.min(
1,
(animationLinearProgress(entry, timestamp) - start) /
Math.max(0.1, 1 - start),
),
);
return linear * linear * (3 - 2 * linear); return linear * linear * (3 - 2 * linear);
} }
function drawTacticalLeader(ctx, entry, placement, accent, timestamp) { function drawTacticalLeader(ctx, entry, placement, accent, timestamp) {
ctx.strokeStyle = accent; 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.lineJoin = 'round';
ctx.lineCap = 'round'; ctx.lineCap = 'round';
ctx.beginPath(); ctx.beginPath();
if (entry.leaderStyle !== 'elbow' if (
|| (placement.corner !== 'above' && placement.corner !== 'below')) { entry.leaderStyle !== 'elbow' ||
(placement.corner !== 'above' && placement.corner !== 'below')
) {
if (placement.leaderOffset === 0) { if (placement.leaderOffset === 0) {
ctx.moveTo(placement.leadFromX, placement.leadFromY); ctx.moveTo(placement.leadFromX, placement.leadFromY);
} else if (placement.corner === 'above' || placement.corner === 'below') { } 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 { } else {
ctx.moveTo(placement.leadFromX + placement.leaderOffset, placement.leadFromY); ctx.moveTo(
placement.leadFromX + placement.leaderOffset,
placement.leadFromY,
);
} }
ctx.lineTo(placement.leadToX, placement.leadToY); ctx.lineTo(placement.leadToX, placement.leadToY);
ctx.stroke(); ctx.stroke();
@@ -575,7 +708,9 @@ function drawTacticalLeader(ctx, entry, placement, accent, timestamp) {
const anchorY = placement.leadFromY; const anchorY = placement.leadFromY;
const horizontalLength = Math.abs(anchorX - cardX); const horizontalLength = Math.abs(anchorX - cardX);
const verticalLength = Math.abs(anchorY - cardY); const verticalLength = Math.abs(anchorY - cardY);
let remaining = leaderRevealProgress(entry, timestamp) * (horizontalLength + verticalLength); let remaining =
leaderRevealProgress(entry, timestamp) *
(horizontalLength + verticalLength);
ctx.moveTo(anchorX, anchorY); ctx.moveTo(anchorX, anchorY);
if (remaining <= horizontalLength) { if (remaining <= horizontalLength) {
const direction = Math.sign(cardX - anchorX) || -1; const direction = Math.sign(cardX - anchorX) || -1;
@@ -584,7 +719,10 @@ function drawTacticalLeader(ctx, entry, placement, accent, timestamp) {
ctx.lineTo(cardX, anchorY); ctx.lineTo(cardX, anchorY);
remaining -= horizontalLength; remaining -= horizontalLength;
const direction = Math.sign(cardY - anchorY) || -1; 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(); ctx.stroke();
} }
@@ -600,9 +738,15 @@ export function paintTacticalCard(ctx, entry, placement, alpha = 1) {
ctx.save(); ctx.save();
ctx.globalAlpha = alpha; ctx.globalAlpha = alpha;
drawTacticalLeader(ctx, entry, placement, entry.leaderStyle === 'elbow' drawTacticalLeader(
ctx,
entry,
placement,
entry.leaderStyle === 'elbow'
? colorWithAlpha(entry.accent, 0.95) ? colorWithAlpha(entry.accent, 0.95)
: accentColors.leader, animationTimestamp); : accentColors.leader,
animationTimestamp,
);
ctx.globalAlpha = alpha * tacticalCardRevealAlpha(entry, animationTimestamp); ctx.globalAlpha = alpha * tacticalCardRevealAlpha(entry, animationTimestamp);
@@ -625,13 +769,19 @@ export function paintTacticalCard(ctx, entry, placement, alpha = 1) {
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic'; ctx.textBaseline = 'alphabetic';
ctx.fillStyle = WORLD_OVERLAY_STYLE.title; 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; const titleBaseline = y + layout.padY + layout.titleH - 2;
ctx.fillText(String(entry.title || ''), x + layout.padX, titleBaseline); ctx.fillText(String(entry.title || ''), x + layout.padX, titleBaseline);
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail; ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
ctx.font = WORLD_OVERLAY_STYLE.fontDetail; ctx.font = WORLD_OVERLAY_STYLE.fontDetail;
for (let i = 0; i < details.length; i++) { 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(); ctx.restore();
return placement.rect; return placement.rect;
@@ -645,7 +795,11 @@ export function paintLabel(ctx, entry, placement, alpha = 1) {
ctx.fillStyle = WORLD_OVERLAY_STYLE.title; ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
ctx.font = WORLD_OVERLAY_STYLE.fontLabel; ctx.font = WORLD_OVERLAY_STYLE.fontLabel;
ctx.textBaseline = 'top'; 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(); ctx.restore();
return placement.rect; return placement.rect;
} }
@@ -690,7 +844,11 @@ export function paintThumbnail(ctx, entry, placement, alpha = 1) {
ctx.save(); ctx.save();
ctx.globalAlpha = alpha; ctx.globalAlpha = alpha;
drawLeader(ctx, placement, entry.thumbnailLeaderColor || WORLD_OVERLAY_STYLE.leader); drawLeader(
ctx,
placement,
entry.thumbnailLeaderColor || WORLD_OVERLAY_STYLE.leader,
);
ctx.beginPath(); ctx.beginPath();
roundedRectPath(ctx, x, y, w, h, Number(entry.thumbnailRadius) || 4); roundedRectPath(ctx, x, y, w, h, Number(entry.thumbnailRadius) || 4);
ctx.fillStyle = entry.thumbnailBackground || WORLD_OVERLAY_STYLE.background; ctx.fillStyle = entry.thumbnailBackground || WORLD_OVERLAY_STYLE.background;
@@ -711,13 +869,17 @@ export function paintThumbnail(ctx, entry, placement, alpha = 1) {
if (ruleHeight > 0) { if (ruleHeight > 0) {
ctx.beginPath(); ctx.beginPath();
roundedRectPath(ctx, x, y, w, ruleHeight, Math.min(1, ruleHeight)); 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(); ctx.fill();
} }
const titleChars = Math.max(0, Math.floor(Number(entry.thumbnailTitleChars) || 0)); const titleChars = Math.max(
const title = entry._overlayThumbnailTitle 0,
|| String(entry.title || '').toUpperCase(); Math.floor(Number(entry.thumbnailTitleChars) || 0),
);
const title =
entry._overlayThumbnailTitle || String(entry.title || '').toUpperCase();
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic'; ctx.textBaseline = 'alphabetic';
ctx.fillStyle = entry.thumbnailTitleColor || WORLD_OVERLAY_STYLE.title; 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.fillStyle = WORLD_OVERLAY_STYLE.detail;
ctx.font = WORLD_OVERLAY_STYLE.fontTrackedDetail; ctx.font = WORLD_OVERLAY_STYLE.fontTrackedDetail;
for (let i = 0; i < details.length; i++) { 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(); ctx.restore();
return placement.rect; return placement.rect;
@@ -842,7 +1008,8 @@ export function paintDetectionCallout(ctx, callout, alpha = 1) {
ctx.globalAlpha = alpha; ctx.globalAlpha = alpha;
ctx.fillStyle = callout.label; ctx.fillStyle = callout.label;
ctx.font = callout.font; 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) { if (callout.micro) {
ctx.globalAlpha = alpha * 0.8; ctx.globalAlpha = alpha * 0.8;
ctx.font = callout.microFont; ctx.font = callout.microFont;
@@ -852,11 +1019,16 @@ export function paintDetectionCallout(ctx, callout, alpha = 1) {
/** Dispatch a normalized entry to its pure variant painter. */ /** Dispatch a normalized entry to its pure variant painter. */
export function paintOverlayEntry(ctx, entry, placement, alpha = 1) { export function paintOverlayEntry(ctx, entry, placement, alpha = 1) {
if (entry.cardStyle === 'tactical') return paintTacticalCard(ctx, entry, placement, alpha); if (entry.cardStyle === 'tactical')
if (entry.variant === 'tracked') return paintTracked(ctx, entry, placement, alpha); return paintTacticalCard(ctx, entry, placement, alpha);
if (entry.selected || entry.variant === 'selected') return paintSelected(ctx, entry, placement, alpha); if (entry.variant === 'tracked')
if (entry.variant === 'thumbnail') return paintThumbnail(ctx, entry, placement, alpha); 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 === '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); return paintLabel(ctx, entry, placement, alpha);
} }
+2 -1
View File
@@ -61,7 +61,8 @@ function applyMode() {
* @returns {void} * @returns {void}
*/ */
export function installRenderGovernor(viewer) { 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; _viewer = viewer;
_installed = true; _installed = true;
// Never let Cesium re-render on simulation-time deltas behind our back — // 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, restoring = false,
}) { }) {
return Boolean( return Boolean(
GLOBAL_CONTEXT_EXPLICIT_ACTIONS.has(action) GLOBAL_CONTEXT_EXPLICIT_ACTIONS.has(action) &&
&& explicitUserAction explicitUserAction &&
&& succeeded succeeded &&
&& !restoring !restoring,
); );
} }
+209 -62
View File
@@ -60,7 +60,10 @@ function normalizeLayerEntry(entry) {
if (entry && typeof entry === 'object') { if (entry && typeof entry === 'object') {
return { return {
enabled: !!entry.enabled, 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 }; return { enabled: !!entry };
@@ -75,13 +78,18 @@ function normalizeLayerEntry(entry) {
* @param {number} [options.fallbackIntensity] - Default intensity if not stored * @param {number} [options.fallbackIntensity] - Default intensity if not stored
* @returns {{ enabled: boolean, intensity: number, version: number }} * @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. // Determine which bloom scale the stored value was encoded under.
// Older projects (version < PROJECT_VERSION) used scale version 1. // Older projects (version < PROJECT_VERSION) used scale version 1.
const explicitVersion = Number(rawBloom.version); const explicitVersion = Number(rawBloom.version);
const bloomVersion = Number.isFinite(explicitVersion) const bloomVersion = Number.isFinite(explicitVersion)
? explicitVersion ? explicitVersion
: (projectVersion >= PROJECT_VERSION ? BLOOM_SCALE_VERSION : 1); : projectVersion >= PROJECT_VERSION
? BLOOM_SCALE_VERSION
: 1;
const rawIntensity = Number.isFinite(Number(rawBloom.intensity)) const rawIntensity = Number.isFinite(Number(rawBloom.intensity))
? Number(rawBloom.intensity) ? Number(rawBloom.intensity)
@@ -104,7 +112,8 @@ function normalizeBloomState(rawBloom = {}, { projectVersion = PROJECT_VERSION,
function recipeToScene(recipe) { function recipeToScene(recipe) {
const post = recipe.post || {}; const post = recipe.post || {};
const ui = recipe.ui || {}; const ui = recipe.ui || {};
const styleParams = post.styleParams && typeof post.styleParams === 'object' const styleParams =
post.styleParams && typeof post.styleParams === 'object'
? deepClone(post.styleParams) ? deepClone(post.styleParams)
: {}; : {};
@@ -138,13 +147,15 @@ function recipeToScene(recipe) {
style: recipe.style || 'normal', style: recipe.style || 'normal',
bloom: { bloom: {
enabled: typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom, enabled: typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom,
intensity: typeof post.bloom === 'number' intensity:
typeof post.bloom === 'number'
? decodeBloomIntensity(post.bloom, 1) ? decodeBloomIntensity(post.bloom, 1)
: BLOOM_INTENSITY_DEFAULT, : BLOOM_INTENSITY_DEFAULT,
version: BLOOM_SCALE_VERSION, version: BLOOM_SCALE_VERSION,
}, },
sharpen: { sharpen: {
enabled: typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen, enabled:
typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen,
intensity: 65, intensity: 65,
}, },
hud: { hud: {
@@ -190,7 +201,11 @@ function createDefaultProject() {
* @param {number} [options.projectVersion] - Schema version of the enclosing project * @param {number} [options.projectVersion] - Schema version of the enclosing project
* @returns {Object} Fully normalized shot * @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 camera = rawShot?.camera || {};
const visual = rawShot?.visual || {}; const visual = rawShot?.visual || {};
const bloom = visual.bloom || {}; const bloom = visual.bloom || {};
@@ -201,7 +216,10 @@ function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION }
return { return {
id: rawShot?.id || uid('shot'), id: rawShot?.id || uid('shot'),
title: rawShot?.title || `Shot ${index + 1}`, 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), holdSec: Math.max(0, Number(rawShot?.holdSec) || 0),
camera: { camera: {
lat: Number(camera.lat) || 0, lat: Number(camera.lat) || 0,
@@ -213,10 +231,21 @@ function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION }
}, },
visual: { visual: {
style: visual.style || 'normal', style: visual.style || 'normal',
bloom: normalizeBloomState(bloom, { projectVersion, fallbackIntensity: 50 }), bloom: normalizeBloomState(bloom, {
projectVersion,
fallbackIntensity: 50,
}),
sharpen: { sharpen: {
enabled: !!sharpen.enabled, 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: { hud: {
visible: typeof hud.visible === 'boolean' ? hud.visible : true, visible: typeof hud.visible === 'boolean' ? hud.visible : true,
@@ -224,12 +253,26 @@ function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION }
}, },
detection: { detection: {
mode: typeof detection.mode === 'string' ? detection.mode : 'OFF', 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( 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 * @returns {Object} Fully normalized project at the current PROJECT_VERSION
*/ */
function normalizeProject(rawProject) { function normalizeProject(rawProject) {
if (!rawProject || typeof rawProject !== 'object') return createDefaultProject(); if (!rawProject || typeof rawProject !== 'object')
return createDefaultProject();
const projectVersion = Number.isFinite(Number(rawProject.version)) const projectVersion = Number.isFinite(Number(rawProject.version))
? Number(rawProject.version) ? Number(rawProject.version)
: 1; : 1;
@@ -250,7 +294,9 @@ function normalizeProject(rawProject) {
const scenes = scenesRaw const scenes = scenesRaw
.map((scene, sceneIdx) => { .map((scene, sceneIdx) => {
const shotsRaw = Array.isArray(scene?.shots) ? scene.shots : []; 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 { return {
id: scene?.id || uid('scene'), id: scene?.id || uid('scene'),
title: scene?.title || `Scene ${sceneIdx + 1}`, title: scene?.title || `Scene ${sceneIdx + 1}`,
@@ -316,12 +362,21 @@ export class SceneDirector {
this._selectedSceneId = this._project.scenes[0]?.id || null; this._selectedSceneId = this._project.scenes[0]?.id || null;
this._selectedShotId = this._project.scenes[0]?.shots[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._presentation = {
this._state = createStateChannel(() => ({ ...this.getPlaybackStatus(), ...this._presentation, hasRun: !!this._lastRunJson })); status: 'Ready',
progress: 0,
runtime: '',
playbackActive: false,
keyboardEnabled: false,
};
this._state = createStateChannel(() => ({
...this.getPlaybackStatus(),
...this._presentation,
hasRun: !!this._lastRunJson,
}));
this._initUI(); this._initUI();
} }
_trackWork(promise) { _trackWork(promise) {
this._pendingWork ||= new Set(); this._pendingWork ||= new Set();
this._pendingWork.add(promise); this._pendingWork.add(promise);
@@ -372,7 +427,10 @@ export class SceneDirector {
// Private browsing / block-all-cookies / quota-exceeded throws here. The // Private browsing / block-all-cookies / quota-exceeded throws here. The
// in-memory project stays usable this session, but persistence failed — // in-memory project stays usable this session, but persistence failed —
// tell the user instead of crashing the caller (M11). // 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(); this._toastStorageError();
} }
} }
@@ -391,17 +449,30 @@ export class SceneDirector {
toast.classList.remove('visible'); toast.classList.remove('visible');
}, 2600); }, 2600);
} }
} catch { /* toast is best-effort */ } } catch {
/* toast is best-effort */
}
} }
/** Immutable playback snapshots and completed editing actions. */ /** 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)) { _shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) {
if (type === 'shot-loaded') this._presentation.status = `Loaded: ${scene.title} / ${shot.title}`; if (type === 'shot-loaded')
this._publish({ type, sceneId: scene.id, sceneTitle: scene.title, shot, index }); 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._selectedShotId = this._getSelectedScene()?.shots[0]?.id || null;
this._renderShotList(); 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) => { renameShot: (sceneId, shotId, title) => {
const { scene, shot } = this._getShot(sceneId, shotId); const { scene, shot } = this._getShot(sceneId, shotId);
if (!shot) return; if (!shot) return;
@@ -450,7 +524,9 @@ export class SceneDirector {
/** Rebuild the scene dropdown options and sync the selected value. */ /** Rebuild the scene dropdown options and sync the selected value. */
_renderSceneSelect() { _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._selectedSceneId = this._project.scenes[0]?.id || null;
} }
this._publish({ type: 'scene-options-changed' }); this._publish({ type: 'scene-options-changed' });
@@ -463,7 +539,10 @@ export class SceneDirector {
*/ */
_renderShotList() { _renderShotList() {
const scene = this._getSelectedScene(); 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._selectedShotId = scene.shots[0].id;
} }
this._publish({ type: 'shots-changed' }); this._publish({ type: 'shots-changed' });
@@ -474,7 +553,11 @@ export class SceneDirector {
* @returns {Object|null} The scene, or null if no valid selection * @returns {Object|null} The scene, or null if no valid selection
*/ */
_getSelectedScene() { _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(); const scene = this._getSelectedScene();
if (!scene) return; 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 // Restore default recipes if the user deleted all scenes
if (!this._project.scenes.length) { if (!this._project.scenes.length) {
this._project = createDefaultProject(); this._project = createDefaultProject();
@@ -553,7 +638,8 @@ export class SceneDirector {
return; return;
} }
const shot = normalizeShot({ const shot = normalizeShot(
{
id: uid('shot'), id: uid('shot'),
title: `Shot ${scene.shots.length + 1}`, title: `Shot ${scene.shots.length + 1}`,
durationSec: DEFAULT_SHOT_DURATION_SEC, durationSec: DEFAULT_SHOT_DURATION_SEC,
@@ -561,7 +647,9 @@ export class SceneDirector {
camera, camera,
visual: this.styleManager.getVisualState(), visual: this.styleManager.getVisualState(),
layers: this._captureLayerStates(), layers: this._captureLayerStates(),
}, scene.shots.length); },
scene.shots.length,
);
scene.shots.push(shot); scene.shots.push(shot);
this._selectedShotId = shot.id; this._selectedShotId = shot.id;
@@ -628,7 +716,8 @@ export class SceneDirector {
* @param {number} [options.flyDuration=2.2] - Camera flight duration in seconds * @param {number} [options.flyDuration=2.2] - Camera flight duration in seconds
*/ */
loadShot(sceneId, shotId, options) { 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)); return this._trackWork(this._loadShot(sceneId, shotId, options));
} }
@@ -652,7 +741,9 @@ export class SceneDirector {
this._renderSceneSelect(); this._renderSceneSelect();
this._renderShotList(); this._renderShotList();
await this.styleManager.applyVisualState(shot.visual, { isCurrent: () => !token.cancelled }); await this.styleManager.applyVisualState(shot.visual, {
isCurrent: () => !token.cancelled,
});
if (token.cancelled) return; if (token.cancelled) return;
await this._applyLayerStates(shot.layers || {}, token); await this._applyLayerStates(shot.layers || {}, token);
if (token.cancelled) return; if (token.cancelled) return;
@@ -693,8 +784,12 @@ export class SceneDirector {
*/ */
_claimCameraOwnership() { _claimCameraOwnership() {
// Older/headless style managers may predate the facade — proceed then. // Older/headless style managers may predate the facade — proceed then.
if (typeof this.styleManager?.runImmediateNavigation !== 'function') return true; if (typeof this.styleManager?.runImmediateNavigation !== 'function')
const claimed = this.styleManager.runImmediateNavigation('scene', () => true); return true;
const claimed = this.styleManager.runImmediateNavigation(
'scene',
() => true,
);
if (claimed === false) { if (claimed === false) {
this._updateStatus('Camera unavailable — exit cockpit first'); this._updateStatus('Camera unavailable — exit cockpit first');
return false; return false;
@@ -712,7 +807,10 @@ export class SceneDirector {
if (!this._project.scenes.length) return []; if (!this._project.scenes.length) return [];
// Rotate the scene list so startSceneId comes first // 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 const ordered = single
? this._project.scenes.slice(startIdx, startIdx + 1) ? this._project.scenes.slice(startIdx, startIdx + 1)
: [ : [
@@ -748,12 +846,17 @@ export class SceneDirector {
* @returns {{id: string, title: string, shots: number}|null} * @returns {{id: string, title: string, shots: number}|null}
*/ */
findSceneByQuery(query) { findSceneByQuery(query) {
const q = String(query ?? '').trim().toLowerCase(); const q = String(query ?? '')
.trim()
.toLowerCase();
if (!q) return null; if (!q) return null;
const scene = this._project.scenes.find((item) => item.id === query) const scene =
|| this._project.scenes.find((item) => item.title.toLowerCase() === q) this._project.scenes.find((item) => item.id === query) ||
|| this._project.scenes.find((item) => item.title.toLowerCase().includes(q)); this._project.scenes.find((item) => item.title.toLowerCase() === q) ||
return scene ? { id: scene.id, title: scene.title, shots: scene.shots.length } : null; 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, running: this._running,
selectedSceneId: this._selectedSceneId, selectedSceneId: this._selectedSceneId,
selectedShotId: this._selectedShotId, selectedShotId: this._selectedShotId,
elapsedMs: this._activeRun ? Math.max(0, Date.now() - Date.parse(this._activeRun.startedAt)) : null, elapsedMs: this._activeRun
estimatedDurationMs: this._activeRun ? Math.round(this._activeRun.estimatedDurationSec * 1000) : null, ? 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, sceneCount: this._project.scenes.length,
}; };
} }
@@ -792,14 +899,18 @@ export class SceneDirector {
* @returns {Promise<{started: boolean, reason?: string, shots?: number}>} * @returns {Promise<{started: boolean, reason?: string, shots?: number}>}
*/ */
startScene(sceneId, options) { 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)); return this._trackWork(this._startScene(sceneId, options));
} }
async _startScene(sceneId, { single = false } = {}) { async _startScene(sceneId, { single = false } = {}) {
if (this._running) return { started: false, reason: 'already-running' }; 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) { if (!queue.length) {
this._updateStatus('No shots to run'); this._updateStatus('No shots to run');
return { started: false, reason: 'no-shots' }; return { started: false, reason: 'no-shots' };
@@ -872,7 +983,9 @@ export class SceneDirector {
this._renderSceneSelect(); this._renderSceneSelect();
this._renderShotList(); 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._updateRuntime(`${scene.title} · ${shot.title}`);
this._logEvent('shot_start', { this._logEvent('shot_start', {
@@ -897,7 +1010,11 @@ export class SceneDirector {
if (token.cancelled) break; if (token.cancelled) break;
await this._applyLayerStates(shot.layers || {}, token); await this._applyLayerStates(shot.layers || {}, token);
if (token.cancelled) break; 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; if (token.cancelled) break;
// Hold on the final frame before transitioning to the next shot // Hold on the final frame before transitioning to the next shot
await this._sleep((shot.holdSec || 0) * 1000, token); await this._sleep((shot.holdSec || 0) * 1000, token);
@@ -917,7 +1034,9 @@ export class SceneDirector {
} }
} catch (error) { } catch (error) {
this._updateStatus(`Error: ${error.message || 'run failed'}`); 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 { } finally {
this._finishRun(); this._finishRun();
} }
@@ -930,13 +1049,17 @@ export class SceneDirector {
async runNextScene() { async runNextScene() {
if (this._destroyed || this._running) return; 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; if (!queue.length) return;
// Find the shot after the current selection, wrapping to the start // Find the shot after the current selection, wrapping to the start
let next = queue[0]; let next = queue[0];
if (this._selectedShotId) { 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]; if (idx >= 0) next = queue[(idx + 1) % queue.length];
} }
@@ -1064,13 +1187,24 @@ export class SceneDirector {
if (token?.cancelled) return abort(); if (token?.cancelled) return abort();
const signal = token?.signal; const signal = token?.signal;
const registered = new Set(this.dataManager.getAll().map((layer) => layer.id)); const registered = new Set(
for (const { id, enabled, params } of sceneLayerPlan(targetStates, registered)) { this.dataManager.getAll().map((layer) => layer.id),
const settled = await this.dataManager.setEnabled(id, enabled, signal ? { signal } : undefined); );
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 (token?.cancelled) return abort();
if (settled === false) { if (settled === false) {
refused.push(id); refused.push(id);
console.warn(`[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`); console.warn(
`[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`,
);
continue; continue;
} }
applied.push(id); applied.push(id);
@@ -1105,7 +1239,8 @@ export class SceneDirector {
*/ */
async _exitIsolatingContextMode() { async _exitIsolatingContextMode() {
// Older/headless style managers may predate the Context facade. // 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; if (typeof this.styleManager?.setContextMode !== 'function') return false;
const state = this.styleManager.getContextModeState() || {}; const state = this.styleManager.getContextModeState() || {};
@@ -1115,9 +1250,17 @@ export class SceneDirector {
const result = await this.styleManager.setContextMode('off'); const result = await this.styleManager.setContextMode('off');
if (result && result.ok === false) { if (result && result.ok === false) {
console.warn(`[Scenes] Could not exit ${mode}:`, result.error || 'unknown reason'); console.warn(
this._updateStatus(`Could not exit ${mode} — scene layers may be refused`); `[Scenes] Could not exit ${mode}:`,
this._logEvent('context_mode_exit_failed', { mode, error: result.error || null }); 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; return false;
} }
this._logEvent('context_mode_exited', { mode }); this._logEvent('context_mode_exited', { mode });
@@ -1140,11 +1283,14 @@ export class SceneDirector {
// a free-text search the shot has already flown away from. // a free-text search the shot has already flown away from.
this.styleManager?.clearSearchedLocation?.(); 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( const destination = Cesium.Cartesian3.fromDegrees(
cameraState.lon, cameraState.lon,
cameraState.lat, cameraState.lat,
cameraState.alt cameraState.alt,
); );
await new Promise((resolve) => { await new Promise((resolve) => {
@@ -1244,7 +1390,9 @@ export class SceneDirector {
* Editing controls are disabled during a run; stop is disabled when idle. * Editing controls are disabled during a run; stop is disabled when idle.
* @param {boolean} isRunning * @param {boolean} isRunning
*/ */
_setButtons(isRunning) { this._publish({ type: 'buttons-changed', running: isRunning }); } _setButtons(isRunning) {
this._publish({ type: 'buttons-changed', running: isRunning });
}
_setPlaybackActive(active) { _setPlaybackActive(active) {
this._presentation.playbackActive = active; this._presentation.playbackActive = active;
@@ -1297,5 +1445,4 @@ export class SceneDirector {
payload: payload || null, payload: payload || null,
}); });
} }
} }
+240 -24
View File
@@ -22,11 +22,56 @@ export const SCENE_RECIPES = [
detectionMode: 'OFF', detectionMode: 'OFF',
}, },
cameraPath: [ 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: 20.0,
{ lat: 35.0, lon: 139.0, alt: 4200000, heading: 22, pitch: -46, roll: 0, duration: 5, hold: 1 }, lon: -30.0,
{ lat: 37.6, lon: -122.4, alt: 1700000, heading: 8, pitch: -40, roll: 0, duration: 5, hold: 1 }, alt: 19000000,
{ lat: 0.0, lon: -20.0, alt: 12000000, heading: -10, pitch: -70, roll: 0, duration: 4, hold: 0 }, 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: [ 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: 28.0,
{ lat: 35.7, lon: 139.7, alt: 6500000, heading: 24, pitch: -64, roll: 0, duration: 5, hold: 1 }, lon: -82.0,
{ lat: 48.8, lon: 2.3, alt: 3000000, heading: 32, pitch: -58, roll: 0, duration: 5, hold: 1 }, alt: 22000000,
{ lat: 30.0, lon: -25.0, alt: 15000000, heading: -20, pitch: -78, roll: 0, duration: 4, hold: 0 }, 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: [ 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: 37.0,
{ lat: -36.8, lon: 174.7, alt: 2600000, heading: -12, pitch: -50, roll: 0, duration: 5, hold: 1 }, lon: -121.0,
{ lat: 38.0, lon: -10.0, alt: 9000000, heading: 12, pitch: -70, roll: 0, duration: 4, hold: 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: [ 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,
{ lat: 40.73, lon: -74.0, alt: 260000, heading: 102, pitch: -33, roll: 0, duration: 4, hold: 1 }, lon: -74.0,
{ lat: 34.05, lon: -118.24, alt: 1200000, heading: 45, pitch: -42, roll: 0, duration: 5, hold: 1 }, alt: 1500000,
{ lat: 51.5, lon: -0.12, alt: 1200000, heading: 22, pitch: -44, roll: 0, duration: 5, hold: 0 }, 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: [ 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,
{ lat: 35.68, lon: 139.76, alt: 3800000, heading: 18, pitch: -52, roll: 0, duration: 5, hold: 1 }, lon: 139.76,
{ lat: 20.0, lon: 110.0, alt: 9500000, heading: 8, pitch: -66, roll: 0, duration: 5, hold: 1 }, alt: 280000,
{ lat: 5.0, lon: 30.0, alt: 18000000, heading: -8, pitch: -78, roll: 0, duration: 6, hold: 0 }, 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,
},
], ],
}, },
]; ];
+15 -6
View File
@@ -80,7 +80,8 @@ export const SCENE_SELECTION_PARAM_PATTERN =
*/ */
export function stripSceneTrackingParams(params) { export function stripSceneTrackingParams(params) {
if (!params || typeof params !== 'object') return undefined; 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 = {}; const kept = {};
for (const [key, value] of Object.entries(params)) { 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) { export function sceneRequiresContextModeExit(contextMode) {
if (!contextMode) return false; if (!contextMode) return false;
return contextLayerEnableBlockReason({ return (
contextLayerEnableBlockReason({
contextMode, contextMode,
change: { layerId: SCENE_EXCLUSIVITY_PROBE_LAYER_ID, enabled: true }, change: { layerId: SCENE_EXCLUSIVITY_PROBE_LAYER_ID, enabled: true },
}) !== null; }) !== null
);
} }
/** /**
@@ -144,9 +147,12 @@ export function sceneRequiresContextModeExit(contextMode) {
* @returns {Array<{ id: string, enabled: boolean, params: Object|undefined }>} * @returns {Array<{ id: string, enabled: boolean, params: Object|undefined }>}
*/ */
export function sceneLayerPlan(targetStates, registeredIds) { export function sceneLayerPlan(targetStates, registeredIds) {
const known = registeredIds instanceof Set const known =
registeredIds instanceof Set
? registeredIds ? registeredIds
: (registeredIds ? new Set(registeredIds) : null); : registeredIds
? new Set(registeredIds)
: null;
const plan = []; const plan = [];
for (const [id, target] of Object.entries(targetStates || {})) { for (const [id, target] of Object.entries(targetStates || {})) {
@@ -154,7 +160,10 @@ export function sceneLayerPlan(targetStates, registeredIds) {
plan.push({ plan.push({
id, id,
enabled: !!(target && target.enabled), enabled: !!(target && target.enabled),
params: target && target.params ? stripSceneTrackingParams(target.params) : undefined, params:
target && target.params
? stripSceneTrackingParams(target.params)
: undefined,
}); });
} }
return plan; 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_FAR_M) return SCOPE_OUTSIDE_ALPHA;
if (h <= SCOPE_TERMINUS_NEAR_M) return SCOPE_TERMINUS_ALPHA_NEAR; 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. // 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); 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, // 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, // which would ride straight into the rgba() string. The quantum is 3-decimal,
// so 3 decimals is lossless here. // 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 // boolean — is ABSENT, not zero. (Number(null) === 0 would otherwise pin the
// floor on every adaptive write.) // floor on every adaptive write.)
const raw = typeof value === 'string' ? value.trim() : value; 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); const pct = Number(raw);
if (!Number.isFinite(pct)) return null; 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} * @returns {void}
*/ */
function withCoalescedPaint(fn) { function withCoalescedPaint(fn) {
if (_coalescingPaint) { fn(); return; } // already inside a scope if (_coalescingPaint) {
fn();
return;
} // already inside a scope
_coalescingPaint = true; _coalescingPaint = true;
_paintDirty = false; _paintDirty = false;
try { try {
@@ -218,7 +233,9 @@ function withCoalescedPaint(fn) {
/** @returns {number} Quantized terminus the CURRENT camera + override imply. */ /** @returns {number} Quantized terminus the CURRENT camera + override imply. */
function currentTerminusTarget() { function currentTerminusTarget() {
return quantizeScopeTerminusAlpha( return quantizeScopeTerminusAlpha(
_terminusOverride == null ? scopeTerminusAlpha(currentCameraHeightM()) : _terminusOverride, _terminusOverride == null
? scopeTerminusAlpha(currentCameraHeightM())
: _terminusOverride,
); );
} }
@@ -228,7 +245,10 @@ export function setScopeTerminusOverride(alpha) {
} else { } else {
// Same supported band as the `sce` hash key: a sub-globe-scale terminus is // 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. // 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 // Re-resolve immediately against the live camera so the override is visible
// without waiting for the next sample. // without waiting for the next sample.
@@ -256,7 +276,9 @@ export function getScopeTerminusRepaintCount() {
} }
/** Backing-store scale actually used by the last draw(). */ /** 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); return Math.min(2, Number(ratio) || 1);
} }
@@ -272,7 +294,8 @@ export function scopeMaskDevicePixelRatio(ratio = (typeof window !== 'undefined'
* @returns {void} * @returns {void}
*/ */
function watchDevicePixelRatio() { function watchDevicePixelRatio() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
return;
teardownDevicePixelRatioWatch(); teardownDevicePixelRatioWatch();
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
let query; let query;
@@ -292,14 +315,18 @@ function watchDevicePixelRatio() {
draw(); draw();
}); });
}; };
if (typeof query.addEventListener === 'function') query.addEventListener('change', _dprListener, { once: true }); if (typeof query.addEventListener === 'function')
else if (typeof query.addListener === 'function') query.addListener(_dprListener); query.addEventListener('change', _dprListener, { once: true });
else if (typeof query.addListener === 'function')
query.addListener(_dprListener);
} }
function teardownDevicePixelRatioWatch() { function teardownDevicePixelRatioWatch() {
if (_dprQuery && _dprListener) { if (_dprQuery && _dprListener) {
if (typeof _dprQuery.removeEventListener === 'function') _dprQuery.removeEventListener('change', _dprListener); if (typeof _dprQuery.removeEventListener === 'function')
else if (typeof _dprQuery.removeListener === 'function') _dprQuery.removeListener(_dprListener); _dprQuery.removeEventListener('change', _dprListener);
else if (typeof _dprQuery.removeListener === 'function')
_dprQuery.removeListener(_dprListener);
} }
_dprQuery = null; _dprQuery = null;
_dprListener = null; _dprListener = null;
@@ -326,13 +353,18 @@ export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) {
centerY: keyhole.centerY, centerY: keyhole.centerY,
innerR: Math.max(0, keyhole.radius - half), innerR: Math.max(0, keyhole.radius - half),
outerR: keyhole.radius + half, outerR: keyhole.radius + half,
maxR: Math.hypot(Math.max(keyhole.centerX, width - keyhole.centerX), maxR: Math.hypot(
Math.max(keyhole.centerY, height - keyhole.centerY)), Math.max(keyhole.centerX, width - keyhole.centerX),
Math.max(keyhole.centerY, height - keyhole.centerY),
),
}; };
} }
function draw() { 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; if (!_canvas || !_container) return;
// SCOPE OFF is the cheapest state, not a painted one: bail out BEFORE the // 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 // backing-store resize + clear (a full-viewport allocation) that used to run
@@ -377,8 +409,12 @@ function draw() {
return; return;
} }
const gradient = ctx.createRadialGradient( const gradient = ctx.createRadialGradient(
geo.centerX, geo.centerY, geo.innerR, geo.centerX,
geo.centerX, geo.centerY, geo.outerR, geo.centerY,
geo.innerR,
geo.centerX,
geo.centerY,
geo.outerR,
); );
gradient.addColorStop(0, `rgba(${r},${g},${b},0)`); gradient.addColorStop(0, `rgba(${r},${g},${b},0)`);
gradient.addColorStop(1, `rgba(${r},${g},${b},${_terminusAlpha})`); 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. // throttle bookkeeping, no quantize — the listener is a single compare.
// setScopeMaskEnabled(true) re-syncs the alpha it skipped. // setScopeMaskEnabled(true) re-syncs the alpha it skipped.
if (!_enabled) return; 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; if (now - _lastTerminusSampleMs < SCOPE_TERMINUS_SAMPLE_MS) return;
_lastTerminusSampleMs = now; _lastTerminusSampleMs = now;
updateScopeTerminusForHeight(currentCameraHeightM()); updateScopeTerminusForHeight(currentCameraHeightM());
@@ -449,8 +486,14 @@ function watchCameraHeight(viewer) {
} }
function teardownCameraHeightWatch() { function teardownCameraHeightWatch() {
if (_cameraSampleRemover) { _cameraSampleRemover(); _cameraSampleRemover = null; } if (_cameraSampleRemover) {
if (_cameraMoveEndRemover) { _cameraMoveEndRemover(); _cameraMoveEndRemover = null; } _cameraSampleRemover();
_cameraSampleRemover = null;
}
if (_cameraMoveEndRemover) {
_cameraMoveEndRemover();
_cameraMoveEndRemover = null;
}
_lastTerminusSampleMs = -Infinity; _lastTerminusSampleMs = -Infinity;
} }
+151 -62
View File
@@ -5,7 +5,10 @@ import {
normalizeAllocationStrategy, normalizeAllocationStrategy,
} from './data/detectionPolicy.js'; } from './data/detectionPolicy.js';
import { clampScopeTerminusPct } from './scopeMask.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 * 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 }, { id: 'param-slider-panel', token: 'm', pinnable: false },
]); ]);
const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze(new Map( const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze(
SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry]), new Map(SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry])),
)); );
const URL_TO_STYLE = Object.fromEntries( 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({ const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({
@@ -87,11 +90,10 @@ const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({
}); });
export class ShareLinkManager { export class ShareLinkManager {
constructor(viewer, { constructor(
onRestore, viewer,
isNavigationCurrent, { onRestore, isNavigationCurrent, cancelOwnedNavigation } = {},
cancelOwnedNavigation, ) {
} = {}) {
this.viewer = viewer; this.viewer = viewer;
this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void
this._debounceTimer = null; this._debounceTimer = null;
@@ -135,17 +137,21 @@ export class ShareLinkManager {
this._destroyed = false; this._destroyed = false;
this._restoreGeneration = 0; this._restoreGeneration = 0;
this._activeCameraFlight = null; this._activeCameraFlight = null;
this._isNavigationCurrent = typeof isNavigationCurrent === 'function' this._isNavigationCurrent =
typeof isNavigationCurrent === 'function'
? isNavigationCurrent ? isNavigationCurrent
: () => true; : () => true;
this._cancelOwnedNavigation = typeof cancelOwnedNavigation === 'function' this._cancelOwnedNavigation =
typeof cancelOwnedNavigation === 'function'
? cancelOwnedNavigation ? cancelOwnedNavigation
: null; : null;
// Listen for camera changes // Listen for camera changes
this._removeCameraChanged = this.viewer.camera.changed.addEventListener(() => { this._removeCameraChanged = this.viewer.camera.changed.addEventListener(
() => {
this._scheduleUpdate(); this._scheduleUpdate();
}); },
);
} }
/** /**
@@ -192,10 +198,15 @@ export class ShareLinkManager {
sharpenIntensity: parseOr(params.get('si'), 49), sharpenIntensity: parseOr(params.get('si'), 49),
hudVariant: params.get('hud') || 'tactical', hudVariant: params.get('hud') || 'tactical',
hudVisible: params.get('hv') === '1', hudVisible: params.get('hv') === '1',
detectionMode: restoredDetection.enabled ? restoredDetection.profile : 'OFF', detectionMode: restoredDetection.enabled
? restoredDetection.profile
: 'OFF',
detectionDensity: restoredDetection.densityPct, detectionDensity: restoredDetection.densityPct,
detectionAllocation: normalizeAllocationStrategy(params.get('da')), 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 // 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 // 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 // `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 // the field — so nothing from the 5 % era depends on this number either
// way. The first-run default is a different question, answered in // way. The first-run default is a different question, answered in
// celestialRing.js. // 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, celestialRing: params.has('cr') ? params.get('cr') === '1' : false,
scopeEnabled: params.has('sc') ? params.get('sc') === '1' : true, scopeEnabled: params.has('sc') ? params.get('sc') === '1' : true,
// Deliberately still 35 through both later default moves (0 on // 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 // (`_scopeFeatherPct` in the constructor tracks the default: that one
// mirrors live state for the link this session generates, so it must match // mirrors live state for the link this session generates, so it must match
// the mask, not the archive.) // 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; // Absent (or non-numeric) `sce` = adaptive (null), the default behavior;
// a value pins the terminus opacity percent, clamped into the SUPPORTED // 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 // 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in
@@ -226,9 +243,10 @@ export class ShareLinkManager {
: null, : null,
mapStack: params.get('map') || 'photoreal', mapStack: params.get('map') || 'photoreal',
layerState: decodedLayerState, layerState: decodedLayerState,
layerStateInvalid: params.get('v') === '2' layerStateInvalid:
&& params.has('l') params.get('v') === '2' &&
&& decodedLayerState === null, params.has('l') &&
decodedLayerState === null,
panelState: decodePanelStateParams(params), panelState: decodePanelStateParams(params),
sharedAtMs: decodeShareCreatedAtMs(params), sharedAtMs: decodeShareCreatedAtMs(params),
}; };
@@ -247,27 +265,40 @@ export class ShareLinkManager {
* Apply a parsed state to the viewer + style manager. * Apply a parsed state to the viewer + style manager.
*/ */
async applyState(state, { applyCamera = true, navigationToken = null } = {}) { 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 = { const view = {
destination: Cesium.Cartesian3.fromDegrees(state.lon, state.lat, state.alt), destination: Cesium.Cartesian3.fromDegrees(
state.lon,
state.lat,
state.alt,
),
orientation: { orientation: {
heading: Cesium.Math.toRadians(state.heading), heading: Cesium.Math.toRadians(state.heading),
pitch: Cesium.Math.toRadians(state.pitch), pitch: Cesium.Math.toRadians(state.pitch),
roll: Cesium.Math.toRadians(state.roll), 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)) { if (applyCamera && this._isNavigationCurrent(navigationToken)) {
const restoreGeneration = ++this._restoreGeneration; const restoreGeneration = ++this._restoreGeneration;
let settleCamera; let settleCamera;
cameraPromise = new Promise((resolve) => { settleCamera = resolve; }); cameraPromise = new Promise((resolve) => {
settleCamera = resolve;
});
const releaseOwnedFlight = (status = 'cancelled') => { const releaseOwnedFlight = (status = 'cancelled') => {
if (this._activeCameraFlight?.restoreGeneration === restoreGeneration) { if (this._activeCameraFlight?.restoreGeneration === restoreGeneration) {
this._activeCameraFlight = null; this._activeCameraFlight = null;
} }
settleCamera({ status }); 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 // Re-apply the final pose only while this share restoration still owns
// navigation. A later user or voice command wins over delayed restore. // navigation. A later user or voice command wins over delayed restore.
this.viewer.camera.flyTo({ this.viewer.camera.flyTo({
@@ -276,9 +307,9 @@ export class ShareLinkManager {
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
complete: () => { complete: () => {
if ( if (
this._destroyed this._destroyed ||
|| restoreGeneration !== this._restoreGeneration restoreGeneration !== this._restoreGeneration ||
|| !this._isNavigationCurrent(navigationToken) !this._isNavigationCurrent(navigationToken)
) { ) {
releaseOwnedFlight('superseded'); releaseOwnedFlight('superseded');
return; return;
@@ -293,14 +324,17 @@ export class ShareLinkManager {
// Notify the style manager via callback // Notify the style manager via callback
const reserved = state.restoreAuthority || null; 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; const mapCurrent = !reserved || reserved.map === this._restoreAuthority.map;
let panelState = state.panelState; let panelState = state.panelState;
if (reserved && panelState?.specs) { if (reserved && panelState?.specs) {
panelState = { panelState = {
specs: panelState.specs.filter((spec) => ( specs: panelState.specs.filter(
(reserved.panels?.get(spec.id) || 0) === (this._restoreAuthority.panels.get(spec.id) || 0) (spec) =>
)), (reserved.panels?.get(spec.id) || 0) ===
(this._restoreAuthority.panels.get(spec.id) || 0),
),
}; };
if (panelState.specs.length === 0) panelState = null; if (panelState.specs.length === 0) panelState = null;
} }
@@ -317,9 +351,13 @@ export class ShareLinkManager {
hudVisible: visualCurrent ? state.hudVisible : undefined, hudVisible: visualCurrent ? state.hudVisible : undefined,
detectionMode: visualCurrent ? state.detectionMode : undefined, detectionMode: visualCurrent ? state.detectionMode : undefined,
detectionDensity: visualCurrent ? state.detectionDensity : undefined, detectionDensity: visualCurrent ? state.detectionDensity : undefined,
detectionAllocation: visualCurrent ? state.detectionAllocation : undefined, detectionAllocation: visualCurrent
? state.detectionAllocation
: undefined,
detectionFadePct: visualCurrent ? state.detectionFadePct : undefined, detectionFadePct: visualCurrent ? state.detectionFadePct : undefined,
detectionOutsideOpacityPct: visualCurrent ? state.detectionOutsideOpacityPct : undefined, detectionOutsideOpacityPct: visualCurrent
? state.detectionOutsideOpacityPct
: undefined,
celestialRing: visualCurrent ? state.celestialRing : undefined, celestialRing: visualCurrent ? state.celestialRing : undefined,
scopeEnabled: visualCurrent ? state.scopeEnabled : undefined, scopeEnabled: visualCurrent ? state.scopeEnabled : undefined,
scopeFeatherPct: visualCurrent ? state.scopeFeatherPct : undefined, scopeFeatherPct: visualCurrent ? state.scopeFeatherPct : undefined,
@@ -336,7 +374,11 @@ export class ShareLinkManager {
camera: camera.status, camera: camera.status,
visual: visualCurrent ? restoreStatus : 'superseded', visual: visualCurrent ? restoreStatus : 'superseded',
map: mapCurrent ? 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) { claimRestoreLane(lane, panelId = null) {
if (!this._initialRestorePending) return; if (!this._initialRestorePending) return;
if (lane === 'panel' && panelId) { 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') { } else if (lane === 'visual' || lane === 'map') {
this._restoreAuthority[lane] += 1; this._restoreAuthority[lane] += 1;
} }
@@ -369,7 +414,8 @@ export class ShareLinkManager {
/** Install the active visual preset parameter source used by URL generation. */ /** Install the active visual preset parameter source used by URL generation. */
setStyleParamStateProvider(provider) { 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. */ /** Called only when the durable layer preference model changes. */
@@ -384,7 +430,11 @@ export class ShareLinkManager {
} }
_encodePanelStateParam(params, panelState) { _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); params.delete(SHARE_UI_STATE_PARAM);
return; return;
} }
@@ -397,7 +447,8 @@ export class ShareLinkManager {
assignments.push(`${spec.token}.p.${state.pinned ? '1' : '0'}`); 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); else params.delete(SHARE_UI_STATE_PARAM);
} }
@@ -410,18 +461,30 @@ export class ShareLinkManager {
onToggleChange(bloom, sharpen, extras = {}) { onToggleChange(bloom, sharpen, extras = {}) {
this._bloomEnabled = bloom; this._bloomEnabled = bloom;
this._sharpenEnabled = sharpen; this._sharpenEnabled = sharpen;
if (typeof extras.bloomIntensity === 'number') this._bloomIntensity = extras.bloomIntensity; if (typeof extras.bloomIntensity === 'number')
if (typeof extras.bloomVersion === 'number') this._bloomVersion = extras.bloomVersion; this._bloomIntensity = extras.bloomIntensity;
if (typeof extras.sharpenIntensity === 'number') this._sharpenIntensity = extras.sharpenIntensity; if (typeof extras.bloomVersion === 'number')
if (typeof extras.hudVariant === 'string') this._hudVariant = extras.hudVariant; this._bloomVersion = extras.bloomVersion;
if (typeof extras.hudVisible === 'boolean') this._hudVisible = extras.hudVisible; if (typeof extras.sharpenIntensity === 'number')
if (typeof extras.detectionMode === 'string') this._detectionMode = extras.detectionMode.toUpperCase(); this._sharpenIntensity = extras.sharpenIntensity;
if (typeof extras.detectionDensity === 'number') this._detectionDensity = extras.detectionDensity; 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') { if (typeof extras.detectionAllocation === 'string') {
this._detectionAllocation = normalizeAllocationStrategy(extras.detectionAllocation); this._detectionAllocation = normalizeAllocationStrategy(
extras.detectionAllocation,
);
} }
if (typeof extras.detectionFadePct === 'number') { 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') { if (typeof extras.detectionOutsideOpacityPct === 'number') {
this._detectionOutsideOpacityPct = Math.max( this._detectionOutsideOpacityPct = Math.max(
@@ -429,10 +492,15 @@ export class ShareLinkManager {
Math.min(100, Math.round(extras.detectionOutsideOpacityPct)), Math.min(100, Math.round(extras.detectionOutsideOpacityPct)),
); );
} }
if (typeof extras.celestialRingEnabled === 'boolean') this._celestialRingEnabled = extras.celestialRingEnabled; if (typeof extras.celestialRingEnabled === 'boolean')
if (typeof extras.scopeEnabled === 'boolean') this._scopeEnabled = extras.scopeEnabled; this._celestialRingEnabled = extras.celestialRingEnabled;
if (typeof extras.scopeEnabled === 'boolean')
this._scopeEnabled = extras.scopeEnabled;
if (typeof extras.scopeFeatherPct === 'number') { 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; if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null;
else if (typeof extras.scopeTerminusPct === 'number') { 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('lat', Cesium.Math.toDegrees(carto.latitude).toFixed(4));
params.set('lon', Cesium.Math.toDegrees(carto.longitude).toFixed(4)); params.set('lon', Cesium.Math.toDegrees(carto.longitude).toFixed(4));
params.set('alt', Math.round(carto.height).toString()); params.set('alt', Math.round(carto.height).toString());
params.set('heading', Math.round(Cesium.Math.toDegrees(camera.heading)).toString()); params.set(
params.set('pitch', Math.round(Cesium.Math.toDegrees(camera.pitch)).toString()); 'heading',
params.set('roll', Math.round(Cesium.Math.toDegrees(camera.roll)).toString()); 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('style', STYLE_TO_URL[this._currentStyle] || 'normal');
params.set('bloom', this._bloomEnabled ? '1' : '0'); params.set('bloom', this._bloomEnabled ? '1' : '0');
params.set('sharpen', this._sharpenEnabled ? '1' : '0'); params.set('sharpen', this._sharpenEnabled ? '1' : '0');
@@ -527,7 +604,10 @@ export class ShareLinkManager {
destroy() { destroy() {
if (this._destroyed) return; if (this._destroyed) return;
const activeFlight = this._activeCameraFlight; const activeFlight = this._activeCameraFlight;
if (activeFlight && this._isNavigationCurrent(activeFlight.navigationToken)) { if (
activeFlight &&
this._isNavigationCurrent(activeFlight.navigationToken)
) {
this._cancelOwnedNavigation?.(); this._cancelOwnedNavigation?.();
} }
activeFlight?.settle?.('destroyed'); activeFlight?.settle?.('destroyed');
@@ -570,18 +650,22 @@ export function encodeStyleParamState(params, styleName, values) {
const clamped = Math.max(spec.min, Math.min(spec.max, numeric)); const clamped = Math.max(spec.min, Math.min(spec.max, numeric));
assignments.push(`${spec.token}.${Math.round(clamped * 100)}`); 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); else params.delete(SHARE_STYLE_PARAMS_PARAM);
} }
/** Decode allowlisted parameters for the selected visual preset. */ /** Decode allowlisted parameters for the selected visual preset. */
export function decodeStyleParamState(params, styleName) { 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]; const registry = SHARE_STYLE_PARAM_REGISTRY[styleName];
if (!registry) return null; if (!registry) return null;
const byToken = new Map(registry.map((spec) => [spec.token, spec])); const byToken = new Map(registry.map((spec) => [spec.token, spec]));
const decoded = {}; 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('.'); const [token, scaledRaw, ...extra] = assignment.split('.');
if (extra.length || !/^-?\d+$/.test(scaledRaw || '')) continue; if (extra.length || !/^-?\d+$/.test(scaledRaw || '')) continue;
const spec = byToken.get(token); const spec = byToken.get(token);
@@ -606,12 +690,17 @@ export function decodePanelStateParams(params) {
if (!spec || (field !== 'c' && field !== 'p')) continue; if (!spec || (field !== 'c' && field !== 'p')) continue;
if (value !== '0' && value !== '1') continue; if (value !== '0' && value !== '1') continue;
const bool = value === '1'; 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; if (field === 'c') current.collapsed = bool;
else if (field === 'p' && spec.pinnable) current.pinned = bool; else if (field === 'p' && spec.pinnable) current.pinned = bool;
stateById.set(spec.id, current); stateById.set(spec.id, current);
} }
const specs = Array.from(stateById.values()) const specs = Array.from(stateById.values()).filter(
.filter((entry) => typeof entry.collapsed === 'boolean'); (entry) => typeof entry.collapsed === 'boolean',
);
return specs.length ? { specs } : null; return specs.length ? { specs } : null;
} }
+1370 -422
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. */ /** Compose the standalone action runner with the voice controls. */
export function initGevVoiceCommands(options) { 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() { export function mostExpensiveVoiceModel() {
// Rank by audio output — the dominant cost in a speech-to-speech session. // Rank by audio output — the dominant cost in a speech-to-speech session.
return Object.values(VOICE_MODELS).reduce((worst, entry) => 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) { export function normalizeCostLimits(limits) {
const clean = (value, fallback) => { 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; return Infinity;
} }
if (value === Infinity) 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. */ /** Convert limits to a JSON-safe shape that round-trips a disabled threshold. */
export function serializeCostLimits(limits) { export function serializeCostLimits(limits) {
const normalized = normalizeCostLimits(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 { return {
warnUsd: encode(normalized.warnUsd), warnUsd: encode(normalized.warnUsd),
capUsd: encode(normalized.capUsd), capUsd: encode(normalized.capUsd),
@@ -269,7 +273,7 @@ export function splitUsageTokens(usage) {
if (!cached && nonNegative(inDetails.cached_tokens) > 0) { if (!cached && nonNegative(inDetails.cached_tokens) > 0) {
audioCached = Math.min( audioCached = Math.min(
nonNegative(inDetails.cached_tokens), nonNegative(inDetails.cached_tokens),
nonNegative(inDetails.audio_tokens) nonNegative(inDetails.audio_tokens),
); );
} }
textIn = Math.max(0, nonNegative(inDetails.text_tokens) - textCached); 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. // to audio (priciest) so a partial payload over-estimates, never under.
const inputResidual = Math.max( const inputResidual = Math.max(
0, 0,
inputTotal - (textIn + audioIn + imageIn + textCached + audioCached + imageCached) inputTotal -
(textIn + audioIn + imageIn + textCached + audioCached + imageCached),
); );
audioIn += inputResidual; audioIn += inputResidual;
const textOut = outDetails ? nonNegative(outDetails.text_tokens) : 0; 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)); const outputResidual = Math.max(0, outputTotal - (textOut + audioOut));
audioOut += outputResidual; audioOut += outputResidual;
+12 -3
View File
@@ -33,10 +33,13 @@ export function deriveWeatherEffectProfile(weather) {
const precipitation = Math.max(0, Number(weather.precipitationMm) || 0); const precipitation = Math.max(0, Number(weather.precipitationMm) || 0);
const wind = range01(weather.windKph, 4, 90); const wind = range01(weather.windKph, 4, 90);
const visibilityM = Number(weather.visibilityM); 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 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 snowCode = (code >= 71 && code <= 77) || (code >= 85 && code <= 86);
const fogCode = code === 45 || code === 48; const fogCode = code === 45 || code === 48;
const stormCode = code >= 95; const stormCode = code >= 95;
@@ -48,7 +51,13 @@ export function deriveWeatherEffectProfile(weather) {
const snow = snowCode ? Math.max(snowFloor, observedPrecip) : 0; const snow = snowCode ? Math.max(snowFloor, observedPrecip) : 0;
const fog = Math.max(fogCode ? 0.78 : 0, visibilityFog); const fog = Math.max(fogCode ? 0.78 : 0, visibilityFog);
const storm = stormCode ? Math.max(0.55, observedPrecip) : 0; 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); const cloud = Math.max(cover, forcedCloud);
return { return {
+24 -11
View File
@@ -16,30 +16,41 @@ export function isValidWorldFocusTarget(detail) {
if (!detail || !WORLD_FOCUS_FRAMING[detail.kind]) return false; if (!detail || !WORLD_FOCUS_FRAMING[detail.kind]) return false;
if (!String(detail.id || '').trim()) return false; if (!String(detail.id || '').trim()) return false;
const { position } = detail; const { position } = detail;
if (!position if (
|| !Number.isFinite(position.x) !position ||
|| !Number.isFinite(position.y) !Number.isFinite(position.x) ||
|| !Number.isFinite(position.z)) return false; !Number.isFinite(position.y) ||
!Number.isFinite(position.z)
)
return false;
// Vessel and fire targets are surface-anchored Earth positions. Merely // Vessel and fire targets are surface-anchored Earth positions. Merely
// finite coordinates near the ECEF origin cannot be flown to, and must be // finite coordinates near the ECEF origin cannot be flown to, and must be
// rejected before the camera policy releases a current follow owner. // rejected before the camera policy releases a current follow owner.
const magnitude = Cesium.Cartesian3.magnitude(position); const magnitude = Cesium.Cartesian3.magnitude(position);
return Number.isFinite(magnitude) return (
&& magnitude >= Cesium.Ellipsoid.WGS84.minimumRadius * 0.95; Number.isFinite(magnitude) &&
magnitude >= Cesium.Ellipsoid.WGS84.minimumRadius * 0.95
);
} }
/** Announce a valid user-click focus request. */ /** Announce a valid user-click focus request. */
export function requestWorldFocus(detail, eventTarget = globalThis.window) { export function requestWorldFocus(detail, eventTarget = globalThis.window) {
if (!isValidWorldFocusTarget(detail)) return false; if (!isValidWorldFocusTarget(detail)) return false;
if (typeof eventTarget?.dispatchEvent !== 'function') 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; return true;
} }
/** Register one listener and return an idempotent disposer. */ /** Register one listener and return an idempotent disposer. */
export function registerWorldFocusRequestListener(eventTarget, listener) { export function registerWorldFocusRequestListener(eventTarget, listener) {
if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener if (
|| typeof listener !== 'function') return () => {}; !eventTarget?.addEventListener ||
!eventTarget?.removeEventListener ||
typeof listener !== 'function'
)
return () => {};
eventTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, listener); eventTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, listener);
let disposed = false; let disposed = false;
return () => { return () => {
@@ -53,7 +64,8 @@ export function registerWorldFocusRequestListener(eventTarget, listener) {
export function routeWorldFocusRequest(event, runExplicitFocus, fly) { export function routeWorldFocusRequest(event, runExplicitFocus, fly) {
const detail = event?.detail; const detail = event?.detail;
if (!isValidWorldFocusTarget(detail)) return false; 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)); return runExplicitFocus(detail, () => fly(detail));
} }
@@ -63,7 +75,8 @@ export function flyToWorldTarget(viewer, target = {}) {
const framing = WORLD_FOCUS_FRAMING[target.kind]; const framing = WORLD_FOCUS_FRAMING[target.kind];
if (!camera || !framing || !isValidWorldFocusTarget(target)) return false; if (!camera || !framing || !isValidWorldFocusTarget(target)) return false;
const heading = Number.isFinite(camera.heading) ? camera.heading : 0; const heading = Number.isFinite(camera.heading) ? camera.heading : 0;
const duration = target.durationSec > 0 const duration =
target.durationSec > 0
? target.durationSec ? target.durationSec
: WORLD_CLICK_FOCUS_DURATION_SEC; : WORLD_CLICK_FOCUS_DURATION_SEC;
camera.cancelFlight?.(); camera.cancelFlight?.();