mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
style: format remaining owned runtime modules
This commit is contained in:
1 parent
ae99843c14
commit
2aca7aecbb
94 files changed
+8865
-3151
No files matched your search
+455
-105
@@ -1,12 +1,20 @@
|
||||
import { defaultGeospatial } from '../search/defaults.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js';
|
||||
import { isRateLimitedOutcome, resolveAnnotationTarget, sampleGroundHeight } from './annotationResolver.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
} from '../renderGovernor.js';
|
||||
import {
|
||||
isRateLimitedOutcome,
|
||||
resolveAnnotationTarget,
|
||||
sampleGroundHeight,
|
||||
} from './annotationResolver.js';
|
||||
import { ringCentroid } from './drawMode.js';
|
||||
|
||||
// Dev convenience: expose the app's Cesium instance for console/preview probing
|
||||
// (single shared module instance — avoids dual-Cesium state bugs when testing).
|
||||
if (typeof window !== 'undefined' && !window.__CESIUM__) window.__CESIUM__ = Cesium;
|
||||
if (typeof window !== 'undefined' && !window.__CESIUM__)
|
||||
window.__CESIUM__ = Cesium;
|
||||
|
||||
/**
|
||||
* Annotation engine — the voice agent's "whiteboard" over the 3D world.
|
||||
@@ -25,7 +33,13 @@ const COLORS = new Set(['primary', 'amber', 'cyan', 'green', 'red']);
|
||||
// Entity FACTS the voice model may attach to an annotation (annotate_map.entityKind) —
|
||||
// what kind of thing the target IS, routing the resolver (e.g. point_feature keeps a
|
||||
// monument point-first). Unknown values are dropped, never guessed.
|
||||
const ENTITY_KINDS = new Set(['building', 'compound', 'district', 'street', 'point_feature']);
|
||||
const ENTITY_KINDS = new Set([
|
||||
'building',
|
||||
'compound',
|
||||
'district',
|
||||
'street',
|
||||
'point_feature',
|
||||
]);
|
||||
const DEFAULT_TTL_MS = 22_000;
|
||||
const FADE_MS = 1200;
|
||||
// Hard ceiling on simultaneously-live marks. Protects against a runaway voice
|
||||
@@ -51,11 +65,14 @@ const OUTLINE_UPGRADE_CONCURRENCY = 2;
|
||||
* retrying immediately. A thrown resolver counts as definitive — the pre-retry
|
||||
* catch→point behavior. Exported for tests.
|
||||
*/
|
||||
export async function resolveOutlineWithRetry(resolveOutline, {
|
||||
delaysMs = OUTLINE_RETRY_DELAYS_MS,
|
||||
isStale = () => false,
|
||||
waitFn = wait,
|
||||
} = {}) {
|
||||
export async function resolveOutlineWithRetry(
|
||||
resolveOutline,
|
||||
{
|
||||
delaysMs = OUTLINE_RETRY_DELAYS_MS,
|
||||
isStale = () => false,
|
||||
waitFn = wait,
|
||||
} = {},
|
||||
) {
|
||||
let retriedRateLimit = false;
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
let fp;
|
||||
@@ -68,7 +85,9 @@ export async function resolveOutlineWithRetry(resolveOutline, {
|
||||
// A throttle gets one deliberately spaced retry. If that retry is throttled
|
||||
// too, stop this mark's outline task instead of replaying the batch storm.
|
||||
if (retriedRateLimit || attempt >= delaysMs.length) return undefined;
|
||||
const retryAfterMs = Number.isFinite(fp.retryAfterMs) ? fp.retryAfterMs : 0;
|
||||
const retryAfterMs = Number.isFinite(fp.retryAfterMs)
|
||||
? fp.retryAfterMs
|
||||
: 0;
|
||||
await waitFn(Math.max(retryAfterMs, delaysMs[attempt]));
|
||||
if (isStale()) return undefined;
|
||||
retriedRateLimit = true;
|
||||
@@ -89,7 +108,9 @@ export async function resolveOutlineWithRetry(resolveOutline, {
|
||||
* geometry is unknown. Exported for tests.
|
||||
*/
|
||||
export function normalizeTargetKey(target) {
|
||||
const raw = String(target ?? '').trim().toLowerCase();
|
||||
const raw = String(target ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!raw) return null;
|
||||
const head = raw.split(',')[0].trim();
|
||||
return head || raw;
|
||||
@@ -123,12 +144,22 @@ export function createAnnotationEngine({
|
||||
|
||||
function emitOutlineEvent(evt) {
|
||||
for (const listener of outlineListeners) {
|
||||
try { listener(evt); } catch { /* a listener error must never break the upgrade */ }
|
||||
try {
|
||||
listener(evt);
|
||||
} catch {
|
||||
/* a listener error must never break the upgrade */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function abortPending() {
|
||||
for (const c of activeControllers) { try { c.abort(); } catch { /* no-op */ } }
|
||||
for (const c of activeControllers) {
|
||||
try {
|
||||
c.abort();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
activeControllers.clear();
|
||||
}
|
||||
|
||||
@@ -174,7 +205,9 @@ export function createAnnotationEngine({
|
||||
function rollbackRendererState(anno) {
|
||||
try {
|
||||
renderer.remove(anno);
|
||||
} catch { /* the renderer is already in a bad way; the original error wins */ }
|
||||
} catch {
|
||||
/* the renderer is already in a bad way; the original error wins */
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTicking() {
|
||||
@@ -226,7 +259,15 @@ export function createAnnotationEngine({
|
||||
* @returns {Promise<{ok, drawn, failed, ids, results}>}
|
||||
*/
|
||||
async function annotate(requests, opts = {}) {
|
||||
if (destroyed) return { ok: false, drawn: 0, failed: 0, ids: [], results: [], error: 'destroyed' };
|
||||
if (destroyed)
|
||||
return {
|
||||
ok: false,
|
||||
drawn: 0,
|
||||
failed: 0,
|
||||
ids: [],
|
||||
results: [],
|
||||
error: 'destroyed',
|
||||
};
|
||||
const list = Array.isArray(requests) ? requests : [requests];
|
||||
if (opts.clearPrevious) clear(); // bumps generation + aborts older pending work
|
||||
|
||||
@@ -247,7 +288,11 @@ export function createAnnotationEngine({
|
||||
// allSettled gives per-item error isolation (one failed item never aborts the batch); the
|
||||
// mutation pass below then runs in ORDER, so de-dup, the synchronous live-cap check, and output
|
||||
// order are all preserved exactly as the old serial loop had them.
|
||||
const settled = await Promise.allSettled(list.map((spec) => resolveSpec(spec, controller.signal, opts.flyTo === true)));
|
||||
const settled = await Promise.allSettled(
|
||||
list.map((spec) =>
|
||||
resolveSpec(spec, controller.signal, opts.flyTo === true),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
@@ -270,8 +315,15 @@ export function createAnnotationEngine({
|
||||
// stacked. De-dup runs BEFORE the cap so a re-narration still refreshes when full.
|
||||
const dup = findDuplicate(anno);
|
||||
if (dup) {
|
||||
const labelChanged = String(dup.label || '').trim().toLowerCase() !== String(anno.label || '').trim().toLowerCase();
|
||||
const colorChanged = (dup.color || 'primary') !== (anno.color || 'primary');
|
||||
const labelChanged =
|
||||
String(dup.label || '')
|
||||
.trim()
|
||||
.toLowerCase() !==
|
||||
String(anno.label || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const colorChanged =
|
||||
(dup.color || 'primary') !== (anno.color || 'primary');
|
||||
if (labelChanged || colorChanged) {
|
||||
// Same place + shape, but a NEW label and/or color ("Marina" → "Marina District",
|
||||
// or a recolor) → replace in place so the latest caption/color wins (count
|
||||
@@ -305,13 +357,26 @@ export function createAnnotationEngine({
|
||||
ids.push(anno.id);
|
||||
if (!firstAnchor) firstAnchor = anno;
|
||||
if (typeof resolved.resolveOutline === 'function') {
|
||||
startOutlineUpgrade(anno, resolved.resolveOutline, myGen, controller);
|
||||
startOutlineUpgrade(
|
||||
anno,
|
||||
resolved.resolveOutline,
|
||||
myGen,
|
||||
controller,
|
||||
);
|
||||
}
|
||||
results.push({ ...okResult(anno, resolved, anno.id), duplicate: true });
|
||||
results.push({
|
||||
...okResult(anno, resolved, anno.id),
|
||||
duplicate: true,
|
||||
});
|
||||
} else {
|
||||
// Identical re-narration → refresh the existing mark's lifecycle in place (keep
|
||||
// it visible whether persistent or TTL-based, and revive a fading-out one).
|
||||
if (persist) { dup.ttlMs = null; } else { dup.createdAt = performance.now(); dup.ttlMs = anno.ttlMs; }
|
||||
if (persist) {
|
||||
dup.ttlMs = null;
|
||||
} else {
|
||||
dup.createdAt = performance.now();
|
||||
dup.ttlMs = anno.ttlMs;
|
||||
}
|
||||
dup.expiring = false;
|
||||
dup.fadeStart = null;
|
||||
ids.push(dup.id);
|
||||
@@ -319,11 +384,19 @@ export function createAnnotationEngine({
|
||||
// A re-narration is also a RETRY: if the existing mark never got its outline
|
||||
// (transient Overpass miss), attach this call's deferred resolver to it.
|
||||
if (!dup.ring && typeof resolved.resolveOutline === 'function') {
|
||||
startOutlineUpgrade(dup, resolved.resolveOutline, myGen, controller);
|
||||
startOutlineUpgrade(
|
||||
dup,
|
||||
resolved.resolveOutline,
|
||||
myGen,
|
||||
controller,
|
||||
);
|
||||
}
|
||||
// Same result builder as the fresh path (full metadata) but the existing id —
|
||||
// route fallback / approximate honesty fields survive a re-narration.
|
||||
results.push({ ...okResult(anno, resolved, dup.id), duplicate: true });
|
||||
results.push({
|
||||
...okResult(anno, resolved, dup.id),
|
||||
duplicate: true,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -350,12 +423,23 @@ export function createAnnotationEngine({
|
||||
if (!firstAnchor) firstAnchor = anno;
|
||||
// Progressive: the point is on the board; trace the outline behind the narration.
|
||||
if (typeof resolved.resolveOutline === 'function') {
|
||||
startOutlineUpgrade(anno, resolved.resolveOutline, myGen, controller);
|
||||
startOutlineUpgrade(
|
||||
anno,
|
||||
resolved.resolveOutline,
|
||||
myGen,
|
||||
controller,
|
||||
);
|
||||
}
|
||||
results.push(okResult(anno, resolved, anno.id));
|
||||
} catch (error) {
|
||||
if (superseded()) break;
|
||||
results.push(failResult(spec, error?.message || 'annotation failed', Array.isArray(error?.failedTargets) ? error.failedTargets : null));
|
||||
results.push(
|
||||
failResult(
|
||||
spec,
|
||||
error?.message || 'annotation failed',
|
||||
Array.isArray(error?.failedTargets) ? error.failedTargets : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -365,7 +449,14 @@ export function createAnnotationEngine({
|
||||
// A clear/new-topic landed mid-flight: drop everything this call produced and
|
||||
// do not touch the renderer or camera — the board now belongs to a newer call.
|
||||
if (superseded()) {
|
||||
return { ok: false, drawn: 0, failed: results.length, ids: [], results, aborted: true };
|
||||
return {
|
||||
ok: false,
|
||||
drawn: 0,
|
||||
failed: results.length,
|
||||
ids: [],
|
||||
results,
|
||||
aborted: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (ids.length) {
|
||||
@@ -393,7 +484,8 @@ export function createAnnotationEngine({
|
||||
if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);
|
||||
if (type === 'route') {
|
||||
const points = Array.isArray(spec.points) ? spec.points : [];
|
||||
if (points.length < 2) throw new Error('a route needs at least 2 waypoints');
|
||||
if (points.length < 2)
|
||||
throw new Error('a route needs at least 2 waypoints');
|
||||
const resolvedPts = [];
|
||||
const failed = [];
|
||||
for (const pt of points) {
|
||||
@@ -425,7 +517,12 @@ export function createAnnotationEngine({
|
||||
}
|
||||
// Real street-following route (OSM/OSRM), mode-aware.
|
||||
const mode = normalizeMode(spec.mode);
|
||||
const routed = await fetchRoute(resolvedPts.map((p) => [p.lon, p.lat]), mode, signal, placeSearch);
|
||||
const routed = await fetchRoute(
|
||||
resolvedPts.map((p) => [p.lon, p.lat]),
|
||||
mode,
|
||||
signal,
|
||||
placeSearch,
|
||||
);
|
||||
if (routed) {
|
||||
return {
|
||||
path: routed.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
||||
@@ -439,8 +536,16 @@ export function createAnnotationEngine({
|
||||
// Routing unavailable: do NOT pass off straight segments as a real route. Draw
|
||||
// a clearly-labeled direct line with great-circle distance and NO travel time.
|
||||
let straight = 0;
|
||||
for (let i = 1; i < resolvedPts.length; i += 1) straight += greatCircleM(resolvedPts[i - 1], resolvedPts[i]);
|
||||
return { path: resolvedPts, distanceM: straight, durationS: null, mode, source: resolvedPts[0].source, fallback: true };
|
||||
for (let i = 1; i < resolvedPts.length; i += 1)
|
||||
straight += greatCircleM(resolvedPts[i - 1], resolvedPts[i]);
|
||||
return {
|
||||
path: resolvedPts,
|
||||
distanceM: straight,
|
||||
durationS: null,
|
||||
mode,
|
||||
source: resolvedPts[0].source,
|
||||
fallback: true,
|
||||
};
|
||||
}
|
||||
if (type === 'arrow') {
|
||||
const from = await resolveTarget({
|
||||
@@ -479,9 +584,15 @@ export function createAnnotationEngine({
|
||||
err.failedTargets = failed;
|
||||
throw err;
|
||||
}
|
||||
return { from, to, distanceM: greatCircleM(from, to), source: from.source };
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
distanceM: greatCircleM(from, to),
|
||||
source: from.source,
|
||||
};
|
||||
}
|
||||
const wantFootprint = type === 'area' ? spec.footprint !== false : Boolean(spec.footprint);
|
||||
const wantFootprint =
|
||||
type === 'area' ? spec.footprint !== false : Boolean(spec.footprint);
|
||||
return resolveTarget({
|
||||
placeSearch,
|
||||
viewer,
|
||||
@@ -491,7 +602,8 @@ export function createAnnotationEngine({
|
||||
screenX: spec.screenX,
|
||||
screenY: spec.screenY,
|
||||
footprint: wantFootprint,
|
||||
intent: spec.intent === 'around_the_thing' ? 'around_the_thing' : 'the_thing',
|
||||
intent:
|
||||
spec.intent === 'around_the_thing' ? 'around_the_thing' : 'the_thing',
|
||||
entityKind: ENTITY_KINDS.has(spec?.entityKind) ? spec.entityKind : null,
|
||||
// The label often carries the ask's true shape when the target omits it
|
||||
// ("target: Texas State Capitol" + "label: Capitol grounds") — a resolver HINT only.
|
||||
@@ -525,11 +637,15 @@ export function createAnnotationEngine({
|
||||
}
|
||||
|
||||
function drainOutlineQueue() {
|
||||
while (activeOutlineUpgrades < OUTLINE_UPGRADE_CONCURRENCY && outlineQueue.length) {
|
||||
while (
|
||||
activeOutlineUpgrades < OUTLINE_UPGRADE_CONCURRENCY &&
|
||||
outlineQueue.length
|
||||
) {
|
||||
const task = outlineQueue.shift();
|
||||
const stale = task.myGen !== generation
|
||||
|| task.controller.signal.aborted
|
||||
|| !annotations.has(task.anno.id);
|
||||
const stale =
|
||||
task.myGen !== generation ||
|
||||
task.controller.signal.aborted ||
|
||||
!annotations.has(task.anno.id);
|
||||
if (stale) {
|
||||
if (task.anno._outlineTask === task) task.anno._outlineTask = null;
|
||||
releaseController(task.controller);
|
||||
@@ -549,7 +665,10 @@ export function createAnnotationEngine({
|
||||
// proxy caches the late completion, so the retry is usually a warm cache hit.
|
||||
fp = await resolveOutlineWithRetry(resolveOutline, {
|
||||
delaysMs: outlineRetryDelaysMs,
|
||||
isStale: () => myGen !== generation || controller.signal.aborted || !annotations.has(anno.id),
|
||||
isStale: () =>
|
||||
myGen !== generation ||
|
||||
controller.signal.aborted ||
|
||||
!annotations.has(anno.id),
|
||||
});
|
||||
if (myGen !== generation || controller.signal.aborted) return; // board superseded
|
||||
if (!annotations.has(anno.id)) return; // mark replaced/removed while resolving
|
||||
@@ -577,11 +696,22 @@ export function createAnnotationEngine({
|
||||
// wins, exactly like the add-time replace path). Not an auto-clear — it removes
|
||||
// a second representation of the same mark, never a distinct one.
|
||||
for (const other of annotations.values()) {
|
||||
if (other.id === anno.id || other.type !== 'area' || other.pendingOutline) continue;
|
||||
if (!other.anchor || Math.abs(other.anchor.lon - anno.anchor.lon) >= 5e-4
|
||||
|| Math.abs(other.anchor.lat - anno.anchor.lat) >= 5e-4) continue;
|
||||
if (Boolean(other.synthesized) !== Boolean(anno.synthesized)) continue;
|
||||
if ((other.footprintKind || null) !== (anno.footprintKind || null)) continue;
|
||||
if (
|
||||
other.id === anno.id ||
|
||||
other.type !== 'area' ||
|
||||
other.pendingOutline
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
!other.anchor ||
|
||||
Math.abs(other.anchor.lon - anno.anchor.lon) >= 5e-4 ||
|
||||
Math.abs(other.anchor.lat - anno.anchor.lat) >= 5e-4
|
||||
)
|
||||
continue;
|
||||
if (Boolean(other.synthesized) !== Boolean(anno.synthesized))
|
||||
continue;
|
||||
if ((other.footprintKind || null) !== (anno.footprintKind || null))
|
||||
continue;
|
||||
if (!ringsEqual(other.ring, anno.ring)) continue;
|
||||
annotations.delete(other.id);
|
||||
renderer.remove(other);
|
||||
@@ -629,7 +759,12 @@ export function createAnnotationEngine({
|
||||
// Route-specific signal so the voice layer can be honest about an OSRM outage:
|
||||
// fallback=true means a straight direct line, not a real route.
|
||||
...(anno.type === 'route'
|
||||
? { fallback: Boolean(anno.fallback), mode: anno.mode, distanceM: anno.distanceM, durationS: anno.durationS }
|
||||
? {
|
||||
fallback: Boolean(anno.fallback),
|
||||
mode: anno.mode,
|
||||
distanceM: anno.distanceM,
|
||||
durationS: anno.durationS,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -643,13 +778,18 @@ export function createAnnotationEngine({
|
||||
* degenerate geocode stacks on one point (several Capitol monuments) would erase each other. */
|
||||
function findDuplicate(anno) {
|
||||
if (!anno.anchor) return null;
|
||||
const near = (a, b) => a && b && Math.abs(a.lon - b.lon) < 5e-4 && Math.abs(a.lat - b.lat) < 5e-4;
|
||||
const near = (a, b) =>
|
||||
a &&
|
||||
b &&
|
||||
Math.abs(a.lon - b.lon) < 5e-4 &&
|
||||
Math.abs(a.lat - b.lat) < 5e-4;
|
||||
// FULL per-vertex geometry comparison (the live cap is only 120 and this runs solely for
|
||||
// candidates that already matched type+label+anchor, so it's cheap) — so two DIFFERENT
|
||||
// same-length rings/paths at the same anchor are never falsely merged.
|
||||
const ringSame = ringsEqual;
|
||||
const pathSame = (a, b) => {
|
||||
const la = a ? a.length : 0; const lb = b ? b.length : 0;
|
||||
const la = a ? a.length : 0;
|
||||
const lb = b ? b.length : 0;
|
||||
if (la !== lb) return false;
|
||||
for (let i = 0; i < la; i++) if (!near(a[i], b[i])) return false;
|
||||
return true;
|
||||
@@ -674,10 +814,12 @@ export function createAnnotationEngine({
|
||||
// the identity; the target is only the stand-in while geometry is unknown.
|
||||
if (!anno.targetKey || !ex.targetKey) continue;
|
||||
if (anno.targetKey !== ex.targetKey) continue;
|
||||
if ((ex.intentKey || 'thing') !== (anno.intentKey || 'thing')) continue;
|
||||
if ((ex.intentKey || 'thing') !== (anno.intentKey || 'thing'))
|
||||
continue;
|
||||
} else {
|
||||
if (Boolean(ex.synthesized) !== Boolean(anno.synthesized)) continue;
|
||||
if ((ex.footprintKind || null) !== (anno.footprintKind || null)) continue;
|
||||
if ((ex.footprintKind || null) !== (anno.footprintKind || null))
|
||||
continue;
|
||||
if (!ringSame(ex.ring, anno.ring)) continue;
|
||||
}
|
||||
} else if (anno.type === 'arrow') {
|
||||
@@ -691,7 +833,13 @@ export function createAnnotationEngine({
|
||||
// point (e.g. several Texas Capitol monuments all geocoding to the dome centroid) — NOT a
|
||||
// duplicate. Only collapse a genuine re-narration of the SAME point (same label); distinct
|
||||
// labels stay as separate marks (their callouts de-collide in the renderer).
|
||||
const sameLabel = String(ex.label || '').trim().toLowerCase() === String(anno.label || '').trim().toLowerCase();
|
||||
const sameLabel =
|
||||
String(ex.label || '')
|
||||
.trim()
|
||||
.toLowerCase() ===
|
||||
String(anno.label || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!sameLabel) continue;
|
||||
}
|
||||
return ex;
|
||||
@@ -703,7 +851,11 @@ export function createAnnotationEngine({
|
||||
const type = normalizeType(spec?.type);
|
||||
const id = `anno-${++_seq}`;
|
||||
const color = COLORS.has(spec?.color) ? spec.color : 'primary';
|
||||
const label = cleanLabel(spec?.label) || resolved?.label || resolved?.from?.label || null;
|
||||
const label =
|
||||
cleanLabel(spec?.label) ||
|
||||
resolved?.label ||
|
||||
resolved?.from?.label ||
|
||||
null;
|
||||
const now = performance.now();
|
||||
|
||||
const base = {
|
||||
@@ -712,17 +864,27 @@ export function createAnnotationEngine({
|
||||
color,
|
||||
label,
|
||||
createdAt: now,
|
||||
ttlMs: persist ? null : (Number(spec?.ttlMs) || DEFAULT_TTL_MS),
|
||||
ttlMs: persist ? null : Number(spec?.ttlMs) || DEFAULT_TTL_MS,
|
||||
alpha: 0, // animate in
|
||||
bornAt: now,
|
||||
expiring: false,
|
||||
};
|
||||
|
||||
if (type === 'route') {
|
||||
const path = resolved.path.map((p) => ({ lon: p.lon, lat: p.lat, height: p.height }));
|
||||
const path = resolved.path.map((p) => ({
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
height: p.height,
|
||||
}));
|
||||
return {
|
||||
...base,
|
||||
label: composeRouteLabel(base.label, resolved.distanceM, resolved.durationS, resolved.mode, resolved.fallback),
|
||||
label: composeRouteLabel(
|
||||
base.label,
|
||||
resolved.distanceM,
|
||||
resolved.durationS,
|
||||
resolved.mode,
|
||||
resolved.fallback,
|
||||
),
|
||||
anchor: path[0],
|
||||
to: null,
|
||||
path,
|
||||
@@ -738,8 +900,16 @@ export function createAnnotationEngine({
|
||||
return {
|
||||
...base,
|
||||
label: appendDistance(base.label, resolved.distanceM),
|
||||
anchor: { lon: resolved.from.lon, lat: resolved.from.lat, height: resolved.from.height },
|
||||
to: { lon: resolved.to.lon, lat: resolved.to.lat, height: resolved.to.height },
|
||||
anchor: {
|
||||
lon: resolved.from.lon,
|
||||
lat: resolved.from.lat,
|
||||
height: resolved.from.height,
|
||||
},
|
||||
to: {
|
||||
lon: resolved.to.lon,
|
||||
lat: resolved.to.lat,
|
||||
height: resolved.to.height,
|
||||
},
|
||||
ring: null,
|
||||
};
|
||||
}
|
||||
@@ -814,13 +984,17 @@ export function createAnnotationEngine({
|
||||
if (m.to) points.push(m.to);
|
||||
}
|
||||
if (!points.length || points.some((p) => isPointOnScreen(p))) return;
|
||||
console.log(`[Annotations] auto-framing ${marks.length} off-screen mark(s)`);
|
||||
console.log(
|
||||
`[Annotations] auto-framing ${marks.length} off-screen mark(s)`,
|
||||
);
|
||||
assistFlightUntil = performance.now() + 2600; // ~flight duration + settle
|
||||
if (marks.length === 1) {
|
||||
frameAnnotation(marks[0]);
|
||||
return;
|
||||
}
|
||||
const cart = points.map((p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0));
|
||||
const cart = points.map((p) =>
|
||||
Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0),
|
||||
);
|
||||
const sphere = Cesium.BoundingSphere.fromPoints(cart);
|
||||
viewer.camera.flyToBoundingSphere(sphere, {
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
@@ -841,9 +1015,20 @@ export function createAnnotationEngine({
|
||||
try {
|
||||
const camera = viewer.camera;
|
||||
const pos = Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0);
|
||||
const cv = camera.frustum.computeCullingVolume(camera.position, camera.direction, camera.up);
|
||||
if (cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) === Cesium.Intersect.OUTSIDE) return false;
|
||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, camera.position);
|
||||
const cv = camera.frustum.computeCullingVolume(
|
||||
camera.position,
|
||||
camera.direction,
|
||||
camera.up,
|
||||
);
|
||||
if (
|
||||
cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) ===
|
||||
Cesium.Intersect.OUTSIDE
|
||||
)
|
||||
return false;
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
camera.position,
|
||||
);
|
||||
return occluder.isPointVisible(pos);
|
||||
} catch {
|
||||
return true;
|
||||
@@ -853,15 +1038,24 @@ export function createAnnotationEngine({
|
||||
function frameAnnotation(anno) {
|
||||
try {
|
||||
const target = anno.to
|
||||
? { lon: (anno.anchor.lon + anno.to.lon) / 2, lat: (anno.anchor.lat + anno.to.lat) / 2 }
|
||||
? {
|
||||
lon: (anno.anchor.lon + anno.to.lon) / 2,
|
||||
lat: (anno.anchor.lat + anno.to.lat) / 2,
|
||||
}
|
||||
: anno.anchor;
|
||||
// No ring yet (progressive outline still resolving) → size the flight from the
|
||||
// Places viewport box when we have one, so a big compound isn't framed at
|
||||
// building scale while its outline is traced. Never re-fly when the ring lands.
|
||||
const range = anno.ring ? ringRange(anno.ring) : (viewportRange(anno.viewport) || 600);
|
||||
const range = anno.ring
|
||||
? ringRange(anno.ring)
|
||||
: viewportRange(anno.viewport) || 600;
|
||||
viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
Cesium.Cartesian3.fromDegrees(target.lon, target.lat, anno.anchor.height || 0),
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
target.lon,
|
||||
target.lat,
|
||||
anno.anchor.height || 0,
|
||||
),
|
||||
range,
|
||||
),
|
||||
{
|
||||
@@ -884,7 +1078,9 @@ export function createAnnotationEngine({
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
try { clear(); } finally {
|
||||
try {
|
||||
clear();
|
||||
} finally {
|
||||
if (tickHandle != null) cancelAnimationFrame(tickHandle);
|
||||
tickHandle = null;
|
||||
outlineListeners.clear();
|
||||
@@ -912,12 +1108,36 @@ export function createAnnotationEngine({
|
||||
* Lays down a small San Francisco "tour" the way the voice agent would.
|
||||
*/
|
||||
async demo() {
|
||||
return annotate([
|
||||
{ type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' },
|
||||
{ type: 'area', target: 'Presidio of San Francisco', label: 'The Presidio (former Army base)', color: 'green', footprint: true },
|
||||
{ type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'cyan' },
|
||||
{ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina District, San Francisco', label: 'next to the Marina' },
|
||||
], { flyTo: true, clearPrevious: true, persist: true });
|
||||
return annotate(
|
||||
[
|
||||
{
|
||||
type: 'highlight',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
label: 'Palace of Fine Arts',
|
||||
color: 'amber',
|
||||
},
|
||||
{
|
||||
type: 'area',
|
||||
target: 'Presidio of San Francisco',
|
||||
label: 'The Presidio (former Army base)',
|
||||
color: 'green',
|
||||
footprint: true,
|
||||
},
|
||||
{
|
||||
type: 'pin',
|
||||
target: 'Letterman Digital Arts Center, San Francisco',
|
||||
label: 'ILM / Lucasfilm',
|
||||
color: 'cyan',
|
||||
},
|
||||
{
|
||||
type: 'arrow',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
toTarget: 'Marina District, San Francisco',
|
||||
label: 'next to the Marina',
|
||||
},
|
||||
],
|
||||
{ flyTo: true, clearPrevious: true, persist: true },
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -927,34 +1147,107 @@ export function createAnnotationEngine({
|
||||
*/
|
||||
async tour() {
|
||||
clear();
|
||||
flyTo({ lon: -122.4486, lat: 37.7960, height: 520, heading: 0, pitch: -26, duration: 3 });
|
||||
flyTo({
|
||||
lon: -122.4486,
|
||||
lat: 37.796,
|
||||
height: 520,
|
||||
heading: 0,
|
||||
pitch: -26,
|
||||
duration: 3,
|
||||
});
|
||||
if (destroyed) return { ok: false };
|
||||
await wait(3200);
|
||||
await annotate([{ type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' }], { persist: true });
|
||||
await annotate(
|
||||
[
|
||||
{
|
||||
type: 'highlight',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
label: 'Palace of Fine Arts',
|
||||
color: 'amber',
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
if (destroyed) return { ok: false };
|
||||
await wait(2600);
|
||||
await annotate([{ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina Green, San Francisco', label: 'next to the Marina', color: 'cyan' }], { persist: true });
|
||||
await annotate(
|
||||
[
|
||||
{
|
||||
type: 'arrow',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
toTarget: 'Marina Green, San Francisco',
|
||||
label: 'next to the Marina',
|
||||
color: 'cyan',
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
if (destroyed) return { ok: false };
|
||||
await wait(2600);
|
||||
flyTo({ lon: -122.4545, lat: 37.7880, height: 1500, heading: 18, pitch: -32, duration: 3 });
|
||||
flyTo({
|
||||
lon: -122.4545,
|
||||
lat: 37.788,
|
||||
height: 1500,
|
||||
heading: 18,
|
||||
pitch: -32,
|
||||
duration: 3,
|
||||
});
|
||||
if (destroyed) return { ok: false };
|
||||
await wait(3200);
|
||||
await annotate([{ type: 'area', target: 'Presidio of San Francisco', label: 'The Presidio — a former Army base', color: 'green', footprint: true }], { persist: true });
|
||||
await annotate(
|
||||
[
|
||||
{
|
||||
type: 'area',
|
||||
target: 'Presidio of San Francisco',
|
||||
label: 'The Presidio — a former Army base',
|
||||
color: 'green',
|
||||
footprint: true,
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
if (destroyed) return { ok: false };
|
||||
await wait(2800);
|
||||
await annotate([{ type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'red' }], { persist: true });
|
||||
await annotate(
|
||||
[
|
||||
{
|
||||
type: 'pin',
|
||||
target: 'Letterman Digital Arts Center, San Francisco',
|
||||
label: 'ILM / Lucasfilm',
|
||||
color: 'red',
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
if (destroyed) return { ok: false };
|
||||
await wait(2600);
|
||||
await annotate([{ type: 'route', color: 'amber', label: 'Crissy Field shoreline', points: [
|
||||
{ target: 'Palace of Fine Arts, San Francisco' },
|
||||
{ target: 'Crissy Field, San Francisco' },
|
||||
{ target: 'Fort Point, San Francisco' },
|
||||
] }], { persist: true });
|
||||
await annotate(
|
||||
[
|
||||
{
|
||||
type: 'route',
|
||||
color: 'amber',
|
||||
label: 'Crissy Field shoreline',
|
||||
points: [
|
||||
{ target: 'Palace of Fine Arts, San Francisco' },
|
||||
{ target: 'Crissy Field, San Francisco' },
|
||||
{ target: 'Fort Point, San Francisco' },
|
||||
],
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
return { ok: true, steps: 6 };
|
||||
},
|
||||
};
|
||||
|
||||
function flyTo({ lon, lat, height, heading = 0, pitch = -30, duration = 2.5 }) {
|
||||
function flyTo({
|
||||
lon,
|
||||
lat,
|
||||
height,
|
||||
heading = 0,
|
||||
pitch = -30,
|
||||
duration = 2.5,
|
||||
}) {
|
||||
if (destroyed) return;
|
||||
try {
|
||||
viewer.camera.flyTo({
|
||||
@@ -984,7 +1277,11 @@ function ringsEqual(a, b) {
|
||||
const lb = b ? b.length : 0;
|
||||
if (la !== lb) return false;
|
||||
for (let i = 0; i < la; i++) {
|
||||
if (Math.abs(a[i][0] - b[i][0]) >= 1e-5 || Math.abs(a[i][1] - b[i][1]) >= 1e-5) return false;
|
||||
if (
|
||||
Math.abs(a[i][0] - b[i][0]) >= 1e-5 ||
|
||||
Math.abs(a[i][1] - b[i][1]) >= 1e-5
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1010,7 +1307,8 @@ function specTargets(spec) {
|
||||
* arrow endpoint) when known; otherwise it derives them from the spec.
|
||||
*/
|
||||
function failResult(spec, error, failedTargets) {
|
||||
const targets = (failedTargets && failedTargets.length) ? failedTargets : specTargets(spec);
|
||||
const targets =
|
||||
failedTargets && failedTargets.length ? failedTargets : specTargets(spec);
|
||||
return {
|
||||
ok: false,
|
||||
label: spec?.label || spec?.target || targets[0] || null,
|
||||
@@ -1022,7 +1320,8 @@ function failResult(spec, error, failedTargets) {
|
||||
|
||||
function normalizeType(type) {
|
||||
const t = String(type || '').toLowerCase();
|
||||
if (t === 'area' || t === 'polygon' || t === 'outline' || t === 'compound') return 'area';
|
||||
if (t === 'area' || t === 'polygon' || t === 'outline' || t === 'compound')
|
||||
return 'area';
|
||||
if (t === 'route' || t === 'path' || t === 'line') return 'route';
|
||||
if (t === 'arrow' || t === 'vector' || t === 'connector') return 'arrow';
|
||||
if (t === 'label' || t === 'callout' || t === 'text') return 'label';
|
||||
@@ -1036,9 +1335,10 @@ function computeAlpha(anno, now) {
|
||||
|
||||
if (anno.ttlMs == null && !anno.expiring) return fadeIn;
|
||||
|
||||
const fadeStart = anno.fadeStart != null
|
||||
? anno.fadeStart
|
||||
: anno.createdAt + (anno.ttlMs || 0);
|
||||
const fadeStart =
|
||||
anno.fadeStart != null
|
||||
? anno.fadeStart
|
||||
: anno.createdAt + (anno.ttlMs || 0);
|
||||
if (now < fadeStart) return fadeIn;
|
||||
const out = 1 - (now - fadeStart) / FADE_MS;
|
||||
if (out <= 0) anno.expiring = true;
|
||||
@@ -1052,7 +1352,10 @@ function pendingAnimation(anno, now) {
|
||||
if (now - anno.bornAt < 260) return true; // fading in
|
||||
if (anno.expiring) return true; // fading out
|
||||
if (anno.ttlMs != null) {
|
||||
const fadeStart = anno.fadeStart != null ? anno.fadeStart : anno.createdAt + (anno.ttlMs || 0);
|
||||
const fadeStart =
|
||||
anno.fadeStart != null
|
||||
? anno.fadeStart
|
||||
: anno.createdAt + (anno.ttlMs || 0);
|
||||
return now < fadeStart + FADE_MS; // TTL fade still ahead or in progress
|
||||
}
|
||||
return false; // persistent + faded in → stable
|
||||
@@ -1110,16 +1413,30 @@ function round5(n) {
|
||||
/** A spec whose geometry was supplied by hand (drawTool.js) rather than by a name. */
|
||||
function isManualSpec(spec, type) {
|
||||
if (!spec || spec.manual !== true) return false;
|
||||
if (type === 'route') return Array.isArray(spec.path) && spec.path.length >= 2;
|
||||
if (type === 'route')
|
||||
return Array.isArray(spec.path) && spec.path.length >= 2;
|
||||
if (type === 'area') return Array.isArray(spec.ring) && spec.ring.length >= 3;
|
||||
return Number.isFinite(Number(spec.latitude)) && Number.isFinite(Number(spec.longitude));
|
||||
return (
|
||||
Number.isFinite(Number(spec.latitude)) &&
|
||||
Number.isFinite(Number(spec.longitude))
|
||||
);
|
||||
}
|
||||
|
||||
/** [lon, lat] pairs or {lon, lat} objects → [lon, lat] pairs, invalid entries dropped. */
|
||||
function manualPairs(list) {
|
||||
return (Array.isArray(list) ? list : [])
|
||||
.map((p) => (Array.isArray(p) ? [Number(p[0]), Number(p[1])] : [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)]))
|
||||
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180);
|
||||
.map((p) =>
|
||||
Array.isArray(p)
|
||||
? [Number(p[0]), Number(p[1])]
|
||||
: [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)],
|
||||
)
|
||||
.filter(
|
||||
([lon, lat]) =>
|
||||
Number.isFinite(lon) &&
|
||||
Number.isFinite(lat) &&
|
||||
Math.abs(lat) <= 90 &&
|
||||
Math.abs(lon) <= 180,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1132,15 +1449,28 @@ function resolveManualSpec(spec, type, viewer) {
|
||||
// height 0 is a placeholder, not a placement: the world renderer draws a
|
||||
// route with clampToGround + CESIUM_3D_TILE classification, so the line is
|
||||
// draped onto the photoreal surface whatever this number says.
|
||||
const pts = manualPairs(spec.path).map(([lon, lat]) => ({ lon, lat, height: 0 }));
|
||||
const pts = manualPairs(spec.path).map(([lon, lat]) => ({
|
||||
lon,
|
||||
lat,
|
||||
height: 0,
|
||||
}));
|
||||
if (pts.length < 2) throw new Error('a drawn line needs at least 2 points');
|
||||
let distanceM = 0;
|
||||
for (let i = 1; i < pts.length; i += 1) distanceM += greatCircleM(pts[i - 1], pts[i]);
|
||||
return { path: pts, distanceM, durationS: null, mode: 'manual', source: 'manual', fallback: false };
|
||||
for (let i = 1; i < pts.length; i += 1)
|
||||
distanceM += greatCircleM(pts[i - 1], pts[i]);
|
||||
return {
|
||||
path: pts,
|
||||
distanceM,
|
||||
durationS: null,
|
||||
mode: 'manual',
|
||||
source: 'manual',
|
||||
fallback: false,
|
||||
};
|
||||
}
|
||||
if (type === 'area') {
|
||||
const ring = manualPairs(spec.ring);
|
||||
if (ring.length < 3) throw new Error('a drawn area needs at least 3 points');
|
||||
if (ring.length < 3)
|
||||
throw new Error('a drawn area needs at least 3 points');
|
||||
// Averaged through ringCentroid, which unwraps longitudes first: a ring
|
||||
// straddling the antimeridian has coordinates like [179.999, -179.999], and
|
||||
// a raw mean of those puts the anchor on the Greenwich meridian, half a
|
||||
@@ -1151,13 +1481,25 @@ function resolveManualSpec(spec, type, viewer) {
|
||||
const lon = centre.lon;
|
||||
const lat = centre.lat;
|
||||
return {
|
||||
lon, lat, height: sampleGroundHeight(viewer, lon, lat),
|
||||
ring, footprintKind: 'area', buildingHeight: null, synthesized: false, source: 'manual',
|
||||
lon,
|
||||
lat,
|
||||
height: sampleGroundHeight(viewer, lon, lat),
|
||||
ring,
|
||||
footprintKind: 'area',
|
||||
buildingHeight: null,
|
||||
synthesized: false,
|
||||
source: 'manual',
|
||||
};
|
||||
}
|
||||
const lon = Number(spec.longitude);
|
||||
const lat = Number(spec.latitude);
|
||||
return { lon, lat, height: sampleGroundHeight(viewer, lon, lat), ring: null, source: 'manual' };
|
||||
return {
|
||||
lon,
|
||||
lat,
|
||||
height: sampleGroundHeight(viewer, lon, lat),
|
||||
ring: null,
|
||||
source: 'manual',
|
||||
};
|
||||
}
|
||||
|
||||
/** The ring's distinct positions: a closing vertex that repeats the first is dropped. */
|
||||
@@ -1165,20 +1507,26 @@ function closedRingWithoutRepeat(ring) {
|
||||
if (ring.length < 2) return ring;
|
||||
const [firstLon, firstLat] = ring[0];
|
||||
const [lastLon, lastLat] = ring[ring.length - 1];
|
||||
return firstLon === lastLon && firstLat === lastLat ? ring.slice(0, -1) : ring;
|
||||
return firstLon === lastLon && firstLat === lastLat
|
||||
? ring.slice(0, -1)
|
||||
: ring;
|
||||
}
|
||||
|
||||
function normalizeMode(m) {
|
||||
const t = String(m || '').toLowerCase();
|
||||
if (t === 'car' || t === 'drive' || t === 'driving') return 'car';
|
||||
if (t === 'bike' || t === 'cycle' || t === 'cycling' || t === 'bicycle') return 'bike';
|
||||
if (t === 'bike' || t === 'cycle' || t === 'cycling' || t === 'bicycle')
|
||||
return 'bike';
|
||||
return 'foot';
|
||||
}
|
||||
|
||||
/** Route failure remains an explicitly labelled direct line in the caller. */
|
||||
async function fetchRoute(coordPairs, mode, signal, service) {
|
||||
try { return await service.route?.(coordPairs, mode, { signal }) || null; }
|
||||
catch { return null; }
|
||||
try {
|
||||
return (await service.route?.(coordPairs, mode, { signal })) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function greatCircleM(a, b) {
|
||||
@@ -1188,7 +1536,9 @@ function greatCircleM(a, b) {
|
||||
const dLon = toRad(b.lon - a.lon);
|
||||
const la1 = toRad(a.lat);
|
||||
const la2 = toRad(b.lat);
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
import { applicationServices } from '../services/application.js';
|
||||
const resolver = createAnnotationResolver({ boundarySource: applicationServices.boundaries });
|
||||
export const { resolveAnnotationTarget, refineScope, isRateLimitedOutcome, isGroundsLikeAsk, selectFootprint, viewportBias, placesNearViewRecovery, resolveRegionRingForQuery, pickWorldFromScreen, sampleGroundHeight } = resolver;
|
||||
const resolver = createAnnotationResolver({
|
||||
boundarySource: applicationServices.boundaries,
|
||||
});
|
||||
export const {
|
||||
resolveAnnotationTarget,
|
||||
refineScope,
|
||||
isRateLimitedOutcome,
|
||||
isGroundsLikeAsk,
|
||||
selectFootprint,
|
||||
viewportBias,
|
||||
placesNearViewRecovery,
|
||||
resolveRegionRingForQuery,
|
||||
pickWorldFromScreen,
|
||||
sampleGroundHeight,
|
||||
} = resolver;
|
||||
@@ -42,7 +42,11 @@ export function createHybridAnnotationRenderer(viewer) {
|
||||
entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
|
||||
screen.add(entry.screenProxy);
|
||||
}
|
||||
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
Array.isArray(anno.path) &&
|
||||
anno.path.length >= 2
|
||||
) {
|
||||
// Drape the path on the tiles (world), caption it with a screen callout
|
||||
// at the path midpoint.
|
||||
entry.worldProxy = liveProxy(anno, { label: null });
|
||||
@@ -86,7 +90,11 @@ export function createHybridAnnotationRenderer(viewer) {
|
||||
} else if (anno.label) {
|
||||
screen.add(screenProxy);
|
||||
}
|
||||
entry.screenProxy = entry.screenProxy ? screenProxy : (anno.label ? screenProxy : null);
|
||||
entry.screenProxy = entry.screenProxy
|
||||
? screenProxy
|
||||
: anno.label
|
||||
? screenProxy
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,13 +10,27 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
||||
* footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver,
|
||||
* and voice tool wiring are identical to the other two branches.
|
||||
*/
|
||||
export function initAnnotations({ viewer, tileset = null, placeSearch, resolver }) {
|
||||
export function initAnnotations({
|
||||
viewer,
|
||||
tileset = null,
|
||||
placeSearch,
|
||||
resolver,
|
||||
}) {
|
||||
// World-space footprint draping; clamped marks can use the photoreal tiles.
|
||||
if (tileset) {
|
||||
try { tileset.enableCollision = true; } catch { /* older tileset */ }
|
||||
try {
|
||||
tileset.enableCollision = true;
|
||||
} catch {
|
||||
/* older tileset */
|
||||
}
|
||||
}
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const engine = createAnnotationEngine({ viewer, renderer, placeSearch, resolveTarget: resolver?.resolveAnnotationTarget });
|
||||
const engine = createAnnotationEngine({
|
||||
viewer,
|
||||
renderer,
|
||||
placeSearch,
|
||||
resolveTarget: resolver?.resolveAnnotationTarget,
|
||||
});
|
||||
window.__gevAnnotations = engine;
|
||||
return engine;
|
||||
}
|
||||
@@ -37,9 +37,9 @@ const PALETTE = {
|
||||
// Altitude-scaled marker geometry. Zoomed in (≤ NEAR_H) reticles are full-size; pulling
|
||||
// back shrinks them toward small recon dots (MIN floor) so many world-anchored marks no
|
||||
// longer collapse into an overlapping blob when their anchors project close together.
|
||||
const MARK_SCALE_NEAR_H = 800; // m: at/below this, full-size reticles
|
||||
const MARK_SCALE_FAR_H = 6000; // m: at/above this, minimum size
|
||||
const MARK_SCALE_MIN = 0.32; // floor so dots never vanish
|
||||
const MARK_SCALE_NEAR_H = 800; // m: at/below this, full-size reticles
|
||||
const MARK_SCALE_FAR_H = 6000; // m: at/above this, minimum size
|
||||
const MARK_SCALE_MIN = 0.32; // floor so dots never vanish
|
||||
const RING_OUTER_R = 34;
|
||||
const RING_INNER_R = 18;
|
||||
const DOT_R = 5;
|
||||
@@ -51,16 +51,22 @@ function markScale(h) {
|
||||
return 1 - t * (1 - MARK_SCALE_MIN);
|
||||
}
|
||||
|
||||
export function createScreenAnnotationRenderer(viewer, {
|
||||
overlayPaintRect = getOverlayPaintRect,
|
||||
activeTrackedReadoutId = getActiveTrackedReadoutId,
|
||||
} = {}) {
|
||||
export function createScreenAnnotationRenderer(
|
||||
viewer,
|
||||
{
|
||||
overlayPaintRect = getOverlayPaintRect,
|
||||
activeTrackedReadoutId = getActiveTrackedReadoutId,
|
||||
} = {},
|
||||
) {
|
||||
injectStyles();
|
||||
const { layer, svg, defs } = buildOverlay();
|
||||
document.body.appendChild(layer);
|
||||
|
||||
const scene = viewer.scene;
|
||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, scene.camera.positionWC);
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
scene.camera.positionWC,
|
||||
);
|
||||
const records = new Map(); // anno.id -> { anno, group, parts }
|
||||
const scratch = new Cesium.Cartesian2();
|
||||
const scratchDir = new Cesium.Cartesian3();
|
||||
@@ -112,8 +118,13 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
return cached.h;
|
||||
}
|
||||
try {
|
||||
if (scene.clampToHeightSupported && typeof scene.clampToHeight === 'function') {
|
||||
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
|
||||
if (
|
||||
scene.clampToHeightSupported &&
|
||||
typeof scene.clampToHeight === 'function'
|
||||
) {
|
||||
const c = scene.clampToHeight(
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat, 0),
|
||||
);
|
||||
if (c) {
|
||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
||||
if (Number.isFinite(h) && h > -430 && h < 9000) {
|
||||
@@ -123,7 +134,9 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* tiles not ready */ }
|
||||
} catch {
|
||||
/* tiles not ready */
|
||||
}
|
||||
const fallback = cached ? cached.h : 0;
|
||||
heightCache.set(key, { h: fallback, settled: false, gen: projGen });
|
||||
trimHeightCache();
|
||||
@@ -140,7 +153,8 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
// "draw-on" gesture, so they never masquerade as an authoritative boundary.
|
||||
parts.poly = svgEl('polygon', {
|
||||
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw',
|
||||
fill: c, stroke: c,
|
||||
fill: c,
|
||||
stroke: c,
|
||||
'fill-opacity': anno.synthesized ? '0.08' : '0.14',
|
||||
'stroke-width': '2.5',
|
||||
...(anno.synthesized ? { 'stroke-dasharray': '9 7' } : {}),
|
||||
@@ -151,25 +165,41 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
parts.path = svgEl('path', {
|
||||
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c,
|
||||
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||
class: 'gev-anno-arrow gev-draw',
|
||||
fill: 'none',
|
||||
stroke: c,
|
||||
'stroke-width': '3',
|
||||
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
||||
group.appendChild(parts.path);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
Array.isArray(anno.path) &&
|
||||
anno.path.length >= 2
|
||||
) {
|
||||
// Multi-waypoint path: a drawn-on polyline with a dot at each waypoint.
|
||||
parts.poly = svgEl('polyline', {
|
||||
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c,
|
||||
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||
class: 'gev-anno-arrow gev-draw',
|
||||
fill: 'none',
|
||||
stroke: c,
|
||||
'stroke-width': '3',
|
||||
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
||||
group.appendChild(parts.poly);
|
||||
parts.dots = anno.path.map(() => {
|
||||
const dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: '4' });
|
||||
const dot = svgEl('circle', {
|
||||
class: 'gev-anno-dot',
|
||||
fill: c,
|
||||
stroke: '#06121c',
|
||||
'stroke-width': '2',
|
||||
r: '4',
|
||||
});
|
||||
group.appendChild(dot);
|
||||
return dot;
|
||||
});
|
||||
@@ -178,14 +208,39 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
} else {
|
||||
// pin / highlight / label — pulsing target rings + a marker dot + callout
|
||||
if (anno.type !== 'label') {
|
||||
parts.ringOuter = svgEl('circle', { class: 'gev-anno-ring gev-pulse', fill: 'none', stroke: c, 'stroke-width': '2', r: '34' });
|
||||
parts.ringInner = svgEl('circle', { class: 'gev-anno-ring', fill: c, 'fill-opacity': '0.12', stroke: c, 'stroke-width': '2.5', r: '18', filter: 'url(#gev-sketch)' });
|
||||
parts.ringOuter = svgEl('circle', {
|
||||
class: 'gev-anno-ring gev-pulse',
|
||||
fill: 'none',
|
||||
stroke: c,
|
||||
'stroke-width': '2',
|
||||
r: '34',
|
||||
});
|
||||
parts.ringInner = svgEl('circle', {
|
||||
class: 'gev-anno-ring',
|
||||
fill: c,
|
||||
'fill-opacity': '0.12',
|
||||
stroke: c,
|
||||
'stroke-width': '2.5',
|
||||
r: '18',
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
group.appendChild(parts.ringOuter);
|
||||
group.appendChild(parts.ringInner);
|
||||
}
|
||||
parts.dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: anno.type === 'label' ? '4' : '5' });
|
||||
parts.dot = svgEl('circle', {
|
||||
class: 'gev-anno-dot',
|
||||
fill: c,
|
||||
stroke: '#06121c',
|
||||
'stroke-width': '2',
|
||||
r: anno.type === 'label' ? '4' : '5',
|
||||
});
|
||||
group.appendChild(parts.dot);
|
||||
parts.leader = svgEl('line', { class: 'gev-anno-leader', stroke: c, 'stroke-width': '1.5', 'stroke-opacity': '0.7' });
|
||||
parts.leader = svgEl('line', {
|
||||
class: 'gev-anno-leader',
|
||||
stroke: c,
|
||||
'stroke-width': '1.5',
|
||||
'stroke-opacity': '0.7',
|
||||
});
|
||||
group.appendChild(parts.leader);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
@@ -209,18 +264,26 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
// any length. Guarded on the specific property so an unrelated transition
|
||||
// (none here, but defensive) doesn't clear it early.
|
||||
for (const drawEl of group.querySelectorAll('.gev-draw')) {
|
||||
drawEl.addEventListener('transitionend', (e) => {
|
||||
if (e.propertyName === 'stroke-dashoffset') {
|
||||
drawEl.style.strokeDasharray = 'none';
|
||||
}
|
||||
}, { once: true });
|
||||
drawEl.addEventListener(
|
||||
'transitionend',
|
||||
(e) => {
|
||||
if (e.propertyName === 'stroke-dashoffset') {
|
||||
drawEl.style.strokeDasharray = 'none';
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
projectAll();
|
||||
} catch (error) {
|
||||
// Hard, immediate unwind — a mark that never finished has nothing to
|
||||
// fade out, and the caller (engine rollback) must find a clean board.
|
||||
records.delete(anno.id);
|
||||
try { group.remove(); } catch { /* never inserted */ }
|
||||
try {
|
||||
group.remove();
|
||||
} catch {
|
||||
/* never inserted */
|
||||
}
|
||||
if (records.size === 0) heightCache.clear();
|
||||
throw error;
|
||||
}
|
||||
@@ -269,13 +332,22 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
// Anchor at the real surface height (cached + validated), so marks sit on
|
||||
// the ground/building instead of at sea level. The settle-once cache keeps
|
||||
// it stable (no per-frame jitter that earlier broke nearby projections).
|
||||
const world = Cesium.Cartesian3.fromDegrees(lon, lat, groundHeight(lon, lat));
|
||||
const world = Cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
groundHeight(lon, lat),
|
||||
);
|
||||
// Reject points behind the camera (they produce wrapped/extreme coords).
|
||||
Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir);
|
||||
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0) return null;
|
||||
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0)
|
||||
return null;
|
||||
// Reject points beyond the globe horizon.
|
||||
if (!occluder.isPointVisible(world)) return null;
|
||||
const win = Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, scratch);
|
||||
const win = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
world,
|
||||
scratch,
|
||||
);
|
||||
if (!win || !Number.isFinite(win.x) || !Number.isFinite(win.y)) return null;
|
||||
// Reject absurd off-screen projections (anchor not in view).
|
||||
const w = scene.canvas.clientWidth || scene.canvas.width;
|
||||
@@ -291,9 +363,9 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
// tracked subject's screen FOOTPRINT is faded. The complete card footprint comes
|
||||
// from the host's ACTUAL painted rectangle after final layout; the billboard extent
|
||||
// is unioned for aircraft/satellites that also own a native tracked graphic.
|
||||
const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint
|
||||
const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1)
|
||||
const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop
|
||||
const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint
|
||||
const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1)
|
||||
const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop
|
||||
const _scratchTrackedWin = new Cesium.Cartesian2();
|
||||
|
||||
// Evaluate a NearFarScalar (billboard scaleByDistance) at a camera distance.
|
||||
@@ -317,11 +389,16 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
|
||||
const ent = viewer.trackedEntity;
|
||||
const now = Cesium.JulianDate.now();
|
||||
const world = typeof ent?.gevDisplayPosition === 'function'
|
||||
? ent.gevDisplayPosition()
|
||||
: null;
|
||||
const world =
|
||||
typeof ent?.gevDisplayPosition === 'function'
|
||||
? ent.gevDisplayPosition()
|
||||
: null;
|
||||
const win = world
|
||||
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, _scratchTrackedWin)
|
||||
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
world,
|
||||
_scratchTrackedWin,
|
||||
)
|
||||
: null;
|
||||
|
||||
// Billboard box — centered on the anchor, magnified by scaleByDistance.
|
||||
@@ -331,8 +408,13 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
const baseH = bb.height?.getValue?.(now) ?? 28;
|
||||
let scale = 1;
|
||||
const sbd = bb.scaleByDistance?.getValue?.(now);
|
||||
if (sbd) scale = nearFarValue(sbd, Cesium.Cartesian3.distance(scene.camera.positionWC, world));
|
||||
const hw = (baseW * scale) / 2; const hh = (baseH * scale) / 2;
|
||||
if (sbd)
|
||||
scale = nearFarValue(
|
||||
sbd,
|
||||
Cesium.Cartesian3.distance(scene.camera.positionWC, world),
|
||||
);
|
||||
const hw = (baseW * scale) / 2;
|
||||
const hh = (baseH * scale) / 2;
|
||||
const bbLeft = win.x - hw;
|
||||
const bbRight = win.x + hw;
|
||||
const bbTop = win.y - hh;
|
||||
@@ -345,7 +427,12 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
if (![left, right, top, bottom].every(Number.isFinite)) return null;
|
||||
|
||||
const m = TRACKED_BBOX_MARGIN;
|
||||
return { left: left - m, right: right + m, top: top - m, bottom: bottom + m };
|
||||
return {
|
||||
left: left - m,
|
||||
right: right + m,
|
||||
top: top - m,
|
||||
bottom: bottom + m,
|
||||
};
|
||||
}
|
||||
|
||||
function projectAll() {
|
||||
@@ -357,7 +444,9 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
const w = scene.canvas.clientWidth || scene.canvas.width;
|
||||
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
||||
// Shrink point-marker geometry as the camera pulls back (declutter the blob).
|
||||
const mScale = markScale(viewer.camera.positionCartographic?.height ?? 1000);
|
||||
const mScale = markScale(
|
||||
viewer.camera.positionCartographic?.height ?? 1000,
|
||||
);
|
||||
|
||||
for (const rec of records.values()) {
|
||||
const { anno, group, parts } = rec;
|
||||
@@ -369,17 +458,22 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
|
||||
if (anno.type === 'area' && parts.poly) {
|
||||
const pts = [];
|
||||
let cx = 0; let cy = 0; let n = 0;
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
let n = 0;
|
||||
for (const [lon, lat] of anno.ring) {
|
||||
const p = project(lon, lat);
|
||||
if (!p) continue;
|
||||
pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
|
||||
cx += p.x; cy += p.y; n++;
|
||||
cx += p.x;
|
||||
cy += p.y;
|
||||
n++;
|
||||
}
|
||||
if (pts.length >= 3) {
|
||||
parts.poly.setAttribute('points', pts.join(' '));
|
||||
group.style.display = '';
|
||||
if (parts.label) positionCallout(parts.label, cx / n, cy / n - 6, true);
|
||||
if (parts.label)
|
||||
positionCallout(parts.label, cx / n, cy / n - 6, true);
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
}
|
||||
@@ -388,16 +482,27 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
const b = project(anno.to.lon, anno.to.lat);
|
||||
if (a && b) {
|
||||
// gentle arc so the connector reads as a drawn gesture, not a ruler line
|
||||
const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2;
|
||||
const dx = b.x - a.x; const dy = b.y - a.y;
|
||||
const mx = (a.x + b.x) / 2;
|
||||
const my = (a.y + b.y) / 2;
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const bow = Math.min(60, len * 0.18);
|
||||
const ctrlX = mx - (dy / len) * bow;
|
||||
const ctrlY = my + (dx / len) * bow;
|
||||
parts.path.setAttribute('d', `M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`);
|
||||
parts.path.setAttribute(
|
||||
'd',
|
||||
`M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`,
|
||||
);
|
||||
group.style.display = '';
|
||||
// Anchor the label to the (stable) endpoint midpoint, lifted toward the bow.
|
||||
if (parts.label) positionCallout(parts.label, mx + (ctrlX - mx) * 0.5, my + (ctrlY - my) * 0.5 - 8, true);
|
||||
if (parts.label)
|
||||
positionCallout(
|
||||
parts.label,
|
||||
mx + (ctrlX - mx) * 0.5,
|
||||
my + (ctrlY - my) * 0.5 - 8,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
}
|
||||
@@ -412,13 +517,22 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
// dots at each resolvable waypoint; hide the rest
|
||||
parts.dots.forEach((dot, i) => {
|
||||
const p = projected[i];
|
||||
if (p) { dot.setAttribute('cx', p.x.toFixed(1)); dot.setAttribute('cy', p.y.toFixed(1)); dot.style.display = ''; }
|
||||
else { dot.style.display = 'none'; }
|
||||
if (p) {
|
||||
dot.setAttribute('cx', p.x.toFixed(1));
|
||||
dot.setAttribute('cy', p.y.toFixed(1));
|
||||
dot.style.display = '';
|
||||
} else {
|
||||
dot.style.display = 'none';
|
||||
}
|
||||
});
|
||||
group.style.display = '';
|
||||
// label at the middle waypoint
|
||||
const mid = projected.filter(Boolean)[Math.floor(projected.filter(Boolean).length / 2)];
|
||||
if (parts.label && mid) positionCallout(parts.label, mid.x, mid.y - 12, true);
|
||||
const mid =
|
||||
projected.filter(Boolean)[
|
||||
Math.floor(projected.filter(Boolean).length / 2)
|
||||
];
|
||||
if (parts.label && mid)
|
||||
positionCallout(parts.label, mid.x, mid.y - 12, true);
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
}
|
||||
@@ -427,16 +541,40 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
if (p) {
|
||||
group.style.display = '';
|
||||
const baseDotR = anno.type === 'label' ? LABEL_DOT_R : DOT_R;
|
||||
if (parts.dot) { parts.dot.setAttribute('cx', p.x.toFixed(1)); parts.dot.setAttribute('cy', p.y.toFixed(1)); parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1)); }
|
||||
if (parts.ringOuter) { parts.ringOuter.setAttribute('cx', p.x.toFixed(1)); parts.ringOuter.setAttribute('cy', p.y.toFixed(1)); parts.ringOuter.setAttribute('r', (RING_OUTER_R * mScale).toFixed(1)); }
|
||||
if (parts.ringInner) { parts.ringInner.setAttribute('cx', p.x.toFixed(1)); parts.ringInner.setAttribute('cy', p.y.toFixed(1)); parts.ringInner.setAttribute('r', (RING_INNER_R * mScale).toFixed(1)); }
|
||||
if (parts.dot) {
|
||||
parts.dot.setAttribute('cx', p.x.toFixed(1));
|
||||
parts.dot.setAttribute('cy', p.y.toFixed(1));
|
||||
parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1));
|
||||
}
|
||||
if (parts.ringOuter) {
|
||||
parts.ringOuter.setAttribute('cx', p.x.toFixed(1));
|
||||
parts.ringOuter.setAttribute('cy', p.y.toFixed(1));
|
||||
parts.ringOuter.setAttribute(
|
||||
'r',
|
||||
(RING_OUTER_R * mScale).toFixed(1),
|
||||
);
|
||||
}
|
||||
if (parts.ringInner) {
|
||||
parts.ringInner.setAttribute('cx', p.x.toFixed(1));
|
||||
parts.ringInner.setAttribute('cy', p.y.toFixed(1));
|
||||
parts.ringInner.setAttribute(
|
||||
'r',
|
||||
(RING_INNER_R * mScale).toFixed(1),
|
||||
);
|
||||
}
|
||||
if (parts.label) {
|
||||
// Keep the callout tucked near the (now smaller) reticle by scaling its offset.
|
||||
const lx = p.x + 18 * mScale; const ly = p.y - 34 * mScale;
|
||||
const lx = p.x + 18 * mScale;
|
||||
const ly = p.y - 34 * mScale;
|
||||
positionCallout(parts.label, lx, ly, false);
|
||||
if (parts.leader) {
|
||||
parts.leader.setAttribute('x1', p.x.toFixed(1)); parts.leader.setAttribute('y1', p.y.toFixed(1));
|
||||
parts.leader.setAttribute('x2', lx.toFixed(1)); parts.leader.setAttribute('y2', (ly + parts.label.height).toFixed(1));
|
||||
parts.leader.setAttribute('x1', p.x.toFixed(1));
|
||||
parts.leader.setAttribute('y1', p.y.toFixed(1));
|
||||
parts.leader.setAttribute('x2', lx.toFixed(1));
|
||||
parts.leader.setAttribute(
|
||||
'y2',
|
||||
(ly + parts.label.height).toFixed(1),
|
||||
);
|
||||
parts.label._leader = parts.leader; // so de-collision can re-point it
|
||||
}
|
||||
}
|
||||
@@ -450,7 +588,12 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
const placed = [];
|
||||
for (const rec of records.values()) {
|
||||
const cal = rec.parts.label;
|
||||
if (cal && cal.sized && rec.group.style.display !== 'none' && Number.isFinite(cal._x)) {
|
||||
if (
|
||||
cal &&
|
||||
cal.sized &&
|
||||
rec.group.style.display !== 'none' &&
|
||||
Number.isFinite(cal._x)
|
||||
) {
|
||||
placed.push(cal);
|
||||
}
|
||||
}
|
||||
@@ -471,7 +614,11 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
let overlapping = false;
|
||||
if (trackedRect) {
|
||||
let bb = null;
|
||||
try { bb = group.getBBox(); } catch { bb = null; }
|
||||
try {
|
||||
bb = group.getBBox();
|
||||
} catch {
|
||||
bb = null;
|
||||
}
|
||||
if (bb && (bb.width !== 0 || bb.height !== 0)) {
|
||||
// HYSTERESIS: a mark that's already hidden must clear the rect by
|
||||
// TRACKED_HYSTERESIS_PX before it starts showing again; one that's visible fades
|
||||
@@ -479,8 +626,11 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
// frame-to-frame (the pulsing reticle stroke + de-collided cards keep the bbox in
|
||||
// motion), which was the source of the flicker.
|
||||
const m = cur < 0.5 ? TRACKED_HYSTERESIS_PX : 0;
|
||||
overlapping = bb.x <= trackedRect.right + m && bb.x + bb.width >= trackedRect.left - m
|
||||
&& bb.y <= trackedRect.bottom + m && bb.y + bb.height >= trackedRect.top - m;
|
||||
overlapping =
|
||||
bb.x <= trackedRect.right + m &&
|
||||
bb.x + bb.width >= trackedRect.left - m &&
|
||||
bb.y <= trackedRect.bottom + m &&
|
||||
bb.y + bb.height >= trackedRect.top - m;
|
||||
}
|
||||
}
|
||||
const target = overlapping ? 0 : 1;
|
||||
@@ -490,7 +640,10 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
// (The CSS `transition: opacity` was removed from .gev-anno so this per-frame JS ease
|
||||
// is the ONLY easing — previously the two fought and produced an opacity oscillation.)
|
||||
if (rec._trackedFade !== 1) {
|
||||
group.setAttribute('opacity', String((anno.alpha ?? 1) * rec._trackedFade));
|
||||
group.setAttribute(
|
||||
'opacity',
|
||||
String((anno.alpha ?? 1) * rec._trackedFade),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -501,7 +654,13 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
rec.group.classList.remove('gev-in');
|
||||
rec.group.classList.add('gev-out');
|
||||
const node = rec.group;
|
||||
window.setTimeout(() => { try { node.remove(); } catch { /* gone */ } }, 360);
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
node.remove();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}, 360);
|
||||
records.delete(anno.id);
|
||||
// Board emptied → drop accumulated height samples (natural reset point).
|
||||
if (records.size === 0) heightCache.clear();
|
||||
@@ -513,8 +672,16 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
try { scene.postRender.removeEventListener(onPostRender); } catch { /* torn down */ }
|
||||
try { layer.remove(); } catch { /* gone */ }
|
||||
try {
|
||||
scene.postRender.removeEventListener(onPostRender);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
try {
|
||||
layer.remove();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
records.clear();
|
||||
heightCache.clear();
|
||||
}
|
||||
@@ -562,7 +729,15 @@ function makeCallout(text, c) {
|
||||
node.appendChild(rect);
|
||||
node.appendChild(accent);
|
||||
node.appendChild(t);
|
||||
const callout = { node, rect, text: t, accent, width: 0, height: 26, sized: false };
|
||||
const callout = {
|
||||
node,
|
||||
rect,
|
||||
text: t,
|
||||
accent,
|
||||
width: 0,
|
||||
height: 26,
|
||||
sized: false,
|
||||
};
|
||||
// size the card to the text once it is in the DOM
|
||||
requestAnimationFrame(() => sizeCallout(callout));
|
||||
return callout;
|
||||
@@ -581,7 +756,9 @@ function sizeCallout(callout) {
|
||||
callout.width = w;
|
||||
callout.height = h;
|
||||
callout.sized = true;
|
||||
} catch { /* not laid out yet */ }
|
||||
} catch {
|
||||
/* not laid out yet */
|
||||
}
|
||||
}
|
||||
|
||||
function positionCallout(callout, x, y, center) {
|
||||
@@ -589,7 +766,10 @@ function positionCallout(callout, x, y, center) {
|
||||
const tx = center ? x - callout.width / 2 : x;
|
||||
callout._x = tx;
|
||||
callout._y = y;
|
||||
callout.node.setAttribute('transform', `translate(${tx.toFixed(1)}, ${y.toFixed(1)})`);
|
||||
callout.node.setAttribute(
|
||||
'transform',
|
||||
`translate(${tx.toFixed(1)}, ${y.toFixed(1)})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -607,7 +787,10 @@ function decollideCallouts(callouts) {
|
||||
const overlapY = a._y < b._y + b.height && a._y + a.height > b._y;
|
||||
if (overlapX && overlapY) {
|
||||
a._y = b._y + b.height + 5;
|
||||
a.node.setAttribute('transform', `translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`);
|
||||
a.node.setAttribute(
|
||||
'transform',
|
||||
`translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`,
|
||||
);
|
||||
if (a._leader) {
|
||||
a._leader.setAttribute('x2', a._x.toFixed(1));
|
||||
a._leader.setAttribute('y2', a._y.toFixed(1));
|
||||
|
||||
@@ -58,7 +58,9 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
ensureFlowFabricRegistered();
|
||||
|
||||
function colorFor(anno) {
|
||||
return Cesium.Color.fromCssColorString(PALETTE[anno.color] || PALETTE.primary);
|
||||
return Cesium.Color.fromCssColorString(
|
||||
PALETTE[anno.color] || PALETTE.primary,
|
||||
);
|
||||
}
|
||||
|
||||
// Target-ring radius (meters) scaled to camera height so it reads at any
|
||||
@@ -86,7 +88,11 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
const entities = [];
|
||||
anno._entities = entities;
|
||||
|
||||
if (anno.ring && anno.ring.length >= 3 && anno.footprintKind === 'building') {
|
||||
if (
|
||||
anno.ring &&
|
||||
anno.ring.length >= 3 &&
|
||||
anno.footprintKind === 'building'
|
||||
) {
|
||||
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION
|
||||
// volume that tints the real photogrammetry mesh (dome, walls, roof —
|
||||
// everything inside the column) amber and pulses. A flat footprint
|
||||
@@ -94,93 +100,134 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
// actual tiles. A faint wireframe cage rides on top as a secondary cue.
|
||||
const buffered = bufferRing(anno.ring, 3);
|
||||
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
|
||||
const groundH = sampleGroundOutside(viewer.scene, anno.ring)
|
||||
?? (Number.isFinite(anno.anchor.height) ? anno.anchor.height - (anno.buildingHeight || 25) : 0);
|
||||
const groundH =
|
||||
sampleGroundOutside(viewer.scene, anno.ring) ??
|
||||
(Number.isFinite(anno.anchor.height)
|
||||
? anno.anchor.height - (anno.buildingHeight || 25)
|
||||
: 0);
|
||||
const baseH = groundH - 3;
|
||||
// Tall headroom is free: classification only colors where tiles exist, so
|
||||
// empty air above the roof isn't tinted — this just guarantees we cover
|
||||
// under-tagged building heights.
|
||||
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
|
||||
// 1) Classification volume — tints the mesh amber, pulsing.
|
||||
entities.push(dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(positions),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
classificationType: CLASSIFY,
|
||||
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.45, pulse: true })),
|
||||
},
|
||||
}));
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(positions),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
classificationType: CLASSIFY,
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
liveColor(anno, base, { alpha: 0.45, pulse: true }),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
|
||||
// the footprint so the extruded outline draws ~14 clean verticals, not one
|
||||
// per (often 100+) ring vertex.
|
||||
const cagePositions = Cesium.Cartesian3.fromDegreesArray(decimateRing(buffered, 14).flat());
|
||||
entities.push(dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
fill: false,
|
||||
outline: true,
|
||||
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
|
||||
},
|
||||
}));
|
||||
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
|
||||
decimateRing(buffered, 14).flat(),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
fill: false,
|
||||
outline: true,
|
||||
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
// 3) Crisp draped base outline so the footprint reads on the ground.
|
||||
entities.push(dataSource.entities.add({
|
||||
polyline: {
|
||||
positions,
|
||||
width: 3,
|
||||
material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.25, color: liveColor(anno, base, { alpha: 0.7 }) }),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions,
|
||||
width: 3,
|
||||
material: new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.25,
|
||||
color: liveColor(anno, base, { alpha: 0.7 }),
|
||||
}),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
} else if (anno.ring && anno.ring.length >= 3) {
|
||||
// Larger area (district / compound / park) → flat fill draped on the tiles
|
||||
// + a glowing outline. Draping is right here: you can't extrude a whole
|
||||
// neighbourhood, and the GIS overlay shows the boundary clearly.
|
||||
const fillPositions = Cesium.Cartesian3.fromDegreesArray(anno.ring.flat());
|
||||
entities.push(dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
|
||||
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
||||
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: anno.synthesized ? 0.10 : 0.20, pulse: true })),
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
entities.push(dataSource.entities.add({
|
||||
polyline: {
|
||||
positions: fillPositions,
|
||||
width: 6,
|
||||
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
||||
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
||||
material: anno.synthesized
|
||||
? new Cesium.PolylineDashMaterialProperty({ color: liveColor(anno, base, { alpha: 0.95 }), dashLength: 24 })
|
||||
: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.35, color: liveColor(anno, base, { alpha: 1 }) }),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
const fillPositions = Cesium.Cartesian3.fromDegreesArray(
|
||||
anno.ring.flat(),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
|
||||
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
liveColor(anno, base, {
|
||||
alpha: anno.synthesized ? 0.1 : 0.2,
|
||||
pulse: true,
|
||||
}),
|
||||
),
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions: fillPositions,
|
||||
width: 6,
|
||||
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
||||
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
||||
material: anno.synthesized
|
||||
? new Cesium.PolylineDashMaterialProperty({
|
||||
color: liveColor(anno, base, { alpha: 0.95 }),
|
||||
dashLength: 24,
|
||||
})
|
||||
: new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.35,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
}),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
Array.isArray(anno.path) &&
|
||||
anno.path.length >= 2
|
||||
) {
|
||||
// A real path (street-following) draped on the 3D tiles, with dashes that
|
||||
// FLOW toward the destination (custom st.s + time material; works on the
|
||||
// clamped/classified ground polyline).
|
||||
const positions = Cesium.Cartesian3.fromDegreesArray(
|
||||
anno.path.flatMap((p) => [p.lon, p.lat]),
|
||||
);
|
||||
entities.push(dataSource.entities.add({
|
||||
polyline: {
|
||||
positions,
|
||||
width: 9,
|
||||
material: new FlowMaterialProperty(PALETTE[anno.color] || PALETTE.primary),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions,
|
||||
width: 9,
|
||||
material: new FlowMaterialProperty(
|
||||
PALETTE[anno.color] || PALETTE.primary,
|
||||
),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
// Connector draped across the ground from origin to destination.
|
||||
@@ -188,24 +235,30 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
||||
Cesium.Cartesian3.fromDegrees(anno.to.lon, anno.to.lat),
|
||||
];
|
||||
entities.push(dataSource.entities.add({
|
||||
polyline: {
|
||||
positions,
|
||||
width: 16,
|
||||
material: new Cesium.PolylineArrowMaterialProperty(liveColor(anno, base, { alpha: 0.95 })),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions,
|
||||
width: 16,
|
||||
material: new Cesium.PolylineArrowMaterialProperty(
|
||||
liveColor(anno, base, { alpha: 0.95 }),
|
||||
),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) {
|
||||
const mid = {
|
||||
lon: (anno.anchor.lon + anno.to.lon) / 2,
|
||||
lat: (anno.anchor.lat + anno.to.lat) / 2,
|
||||
};
|
||||
entities.push(dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
|
||||
label: labelGraphic(anno, base),
|
||||
}));
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
|
||||
label: labelGraphic(anno, base),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// pin / highlight / label — a camera-proportional target ring + a marker.
|
||||
@@ -213,16 +266,23 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
// Radius scales with camera height so the ring reads at any altitude.
|
||||
// semiMajor === semiMinor is required AND must hold every frame, so both
|
||||
// axes read the SAME ringRadius() (camera height is constant per frame).
|
||||
entities.push(dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
||||
ellipse: {
|
||||
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
|
||||
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
|
||||
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.38, pulse: true })),
|
||||
outline: false,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
anno.anchor.lon,
|
||||
anno.anchor.lat,
|
||||
),
|
||||
ellipse: {
|
||||
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
|
||||
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
liveColor(anno, base, { alpha: 0.38, pulse: true }),
|
||||
),
|
||||
outline: false,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
entities.push(labelMarker(anno, base, { point: true }));
|
||||
}
|
||||
@@ -232,14 +292,16 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
function labelMarker(anno, base, { point }) {
|
||||
return dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
||||
point: point ? {
|
||||
pixelSize: anno.type === 'label' ? 8 : 14,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
||||
outlineWidth: 3,
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
} : undefined,
|
||||
point: point
|
||||
? {
|
||||
pixelSize: anno.type === 'label' ? 8 : 14,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
||||
outlineWidth: 3,
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
}
|
||||
: undefined,
|
||||
label: anno.label ? labelGraphic(anno, base) : undefined,
|
||||
});
|
||||
}
|
||||
@@ -256,7 +318,10 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
pixelOffset: new Cesium.Cartesian2(0, -16),
|
||||
showBackground: true,
|
||||
backgroundColor: new Cesium.CallbackProperty(
|
||||
() => Cesium.Color.fromCssColorString('#0b1622').withAlpha(0.72 * (anno.alpha ?? 1)),
|
||||
() =>
|
||||
Cesium.Color.fromCssColorString('#0b1622').withAlpha(
|
||||
0.72 * (anno.alpha ?? 1),
|
||||
),
|
||||
false,
|
||||
),
|
||||
backgroundPadding: new Cesium.Cartesian2(8, 5),
|
||||
@@ -357,8 +422,16 @@ export function FlowMaterialProperty(colorCss) {
|
||||
this._definitionChanged = new Cesium.Event();
|
||||
}
|
||||
Object.defineProperties(FlowMaterialProperty.prototype, {
|
||||
isConstant: { get() { return false; } }, // re-evaluated each frame → it animates
|
||||
definitionChanged: { get() { return this._definitionChanged; } },
|
||||
isConstant: {
|
||||
get() {
|
||||
return false;
|
||||
},
|
||||
}, // re-evaluated each frame → it animates
|
||||
definitionChanged: {
|
||||
get() {
|
||||
return this._definitionChanged;
|
||||
},
|
||||
},
|
||||
});
|
||||
FlowMaterialProperty.prototype.getType = function getType() {
|
||||
return 'GevRouteFlow';
|
||||
@@ -394,10 +467,17 @@ function decimateRing(ring, n) {
|
||||
* tiles under those points aren't loaded yet.
|
||||
*/
|
||||
function sampleGroundOutside(scene, ring) {
|
||||
if (!scene?.clampToHeightSupported || typeof scene.clampToHeight !== 'function') return null;
|
||||
if (
|
||||
!scene?.clampToHeightSupported ||
|
||||
typeof scene.clampToHeight !== 'function'
|
||||
)
|
||||
return null;
|
||||
let clon = 0;
|
||||
let clat = 0;
|
||||
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
|
||||
for (const [lon, lat] of ring) {
|
||||
clon += lon;
|
||||
clat += lat;
|
||||
}
|
||||
clon /= ring.length;
|
||||
clat /= ring.length;
|
||||
const latS = 111320;
|
||||
@@ -407,7 +487,16 @@ function sampleGroundOutside(scene, ring) {
|
||||
maxR = Math.max(maxR, Math.hypot((lon - clon) * lonS, (lat - clat) * latS));
|
||||
}
|
||||
const out = maxR * 1.5 + 12;
|
||||
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7, 0.7], [-0.7, -0.7], [0.7, -0.7], [-0.7, 0.7]];
|
||||
const dirs = [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1],
|
||||
[0.7, 0.7],
|
||||
[-0.7, -0.7],
|
||||
[0.7, -0.7],
|
||||
[-0.7, 0.7],
|
||||
];
|
||||
const samples = [];
|
||||
for (const [dx, dy] of dirs) {
|
||||
const lon = clon + (dx * out) / lonS;
|
||||
@@ -418,7 +507,9 @@ function sampleGroundOutside(scene, ring) {
|
||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
||||
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
|
||||
}
|
||||
} catch { /* tile not ready */ }
|
||||
} catch {
|
||||
/* tile not ready */
|
||||
}
|
||||
}
|
||||
if (!samples.length) return null;
|
||||
samples.sort((a, b) => a - b);
|
||||
@@ -433,7 +524,10 @@ function sampleGroundOutside(scene, ring) {
|
||||
function bufferRing(ring, meters) {
|
||||
let clon = 0;
|
||||
let clat = 0;
|
||||
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
|
||||
for (const [lon, lat] of ring) {
|
||||
clon += lon;
|
||||
clat += lat;
|
||||
}
|
||||
clon /= ring.length;
|
||||
clat /= ring.length;
|
||||
const latScale = 111320;
|
||||
|
||||
+473
-141
File diff suppressed because it is too large.
Load diff
+18
-7
@@ -22,9 +22,11 @@ export function activateCctvCameraFromWorldClick(
|
||||
) {
|
||||
if (!cameraId || typeof activate !== 'function') return false;
|
||||
if (activate(cameraId) !== CCTV_ACTIVATION_RESULT.ACTIVATED) return false;
|
||||
eventTarget.dispatchEvent(new CustomEvent(CCTV_FOCUS_REQUEST_EVENT, {
|
||||
detail: { cameraId },
|
||||
}));
|
||||
eventTarget.dispatchEvent(
|
||||
new CustomEvent(CCTV_FOCUS_REQUEST_EVENT, {
|
||||
detail: { cameraId },
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -36,8 +38,12 @@ export function activateCctvCameraFromWorldClick(
|
||||
* @returns {() => void} Listener disposer.
|
||||
*/
|
||||
export function registerCctvFocusRequestListener(eventTarget, listener) {
|
||||
if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener
|
||||
|| typeof listener !== 'function') return () => {};
|
||||
if (
|
||||
!eventTarget?.addEventListener ||
|
||||
!eventTarget?.removeEventListener ||
|
||||
typeof listener !== 'function'
|
||||
)
|
||||
return () => {};
|
||||
eventTarget.addEventListener(CCTV_FOCUS_REQUEST_EVENT, listener);
|
||||
let disposed = false;
|
||||
return () => {
|
||||
@@ -57,11 +63,16 @@ export function registerCctvFocusRequestListener(eventTarget, listener) {
|
||||
*/
|
||||
export function routeCctvFocusRequest(event, runExplicitFocus, focusCamera) {
|
||||
const cameraId = event?.detail?.cameraId;
|
||||
if (typeof cameraId !== 'string' || !cameraId || typeof runExplicitFocus !== 'function') {
|
||||
if (
|
||||
typeof cameraId !== 'string' ||
|
||||
!cameraId ||
|
||||
typeof runExplicitFocus !== 'function'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return runExplicitFocus(
|
||||
() => cameraId,
|
||||
(selectedId) => focusCamera(selectedId, CCTV_WORLD_CLICK_FOCUS_DURATION_SEC),
|
||||
(selectedId) =>
|
||||
focusCamera(selectedId, CCTV_WORLD_CLICK_FOCUS_DURATION_SEC),
|
||||
);
|
||||
}
|
||||
+186
-56
@@ -90,16 +90,30 @@ export function getKeyholeGeometry(width, height) {
|
||||
* floor through a band derived from the live keyhole radius.
|
||||
*/
|
||||
export function keyholeLabelAlpha(labelX, labelY, width, height) {
|
||||
return keyholeLabelAlphaFromGeometry(labelX, labelY, getKeyholeGeometry(width, height));
|
||||
return keyholeLabelAlphaFromGeometry(
|
||||
labelX,
|
||||
labelY,
|
||||
getKeyholeGeometry(width, height),
|
||||
);
|
||||
}
|
||||
|
||||
/** Compute keyhole opacity from geometry already cached by a hot render loop. */
|
||||
export function keyholeLabelAlphaFromGeometry(labelX, labelY, geometry) {
|
||||
if (!geometry || !(geometry.radius > 0) || !Number.isFinite(labelX) || !Number.isFinite(labelY)) return 0;
|
||||
if (
|
||||
!geometry ||
|
||||
!(geometry.radius > 0) ||
|
||||
!Number.isFinite(labelX) ||
|
||||
!Number.isFinite(labelY)
|
||||
)
|
||||
return 0;
|
||||
const feather = geometry.featherPx;
|
||||
const distance = Math.hypot(labelX - geometry.centerX, labelY - geometry.centerY);
|
||||
const distance = Math.hypot(
|
||||
labelX - geometry.centerX,
|
||||
labelY - geometry.centerY,
|
||||
);
|
||||
if (distance <= geometry.radius) return 1;
|
||||
if (!(feather > 0) || distance >= geometry.radius + feather) return keyholeOutsideOpacity;
|
||||
if (!(feather > 0) || distance >= geometry.radius + feather)
|
||||
return keyholeOutsideOpacity;
|
||||
const progress = clamp((distance - geometry.radius) / feather, 0, 1);
|
||||
return 1 - (1 - keyholeOutsideOpacity) * progress;
|
||||
}
|
||||
@@ -131,7 +145,11 @@ export function isCelestialRingStyleSupported(styleName) {
|
||||
* @param {number} lastAngle - Previous stable angle in radians.
|
||||
* @returns {{angle:number, opacity:number, stable:boolean}}
|
||||
*/
|
||||
export function celestialScreenAngle(rightComponent, upComponent, lastAngle = 0) {
|
||||
export function celestialScreenAngle(
|
||||
rightComponent,
|
||||
upComponent,
|
||||
lastAngle = 0,
|
||||
) {
|
||||
const planeLength = Math.hypot(rightComponent, upComponent);
|
||||
if (!Number.isFinite(planeLength) || planeLength < CELESTIAL_PLANE_EPSILON) {
|
||||
return {
|
||||
@@ -170,26 +188,42 @@ export function isFullGlobeInsideKeyhole(geometry, wasVisible = false) {
|
||||
keyholeCenterY,
|
||||
keyholeRadius,
|
||||
} = geometry || {};
|
||||
const values = [earthCenterX, earthCenterY, earthRadius, keyholeCenterX, keyholeCenterY, keyholeRadius];
|
||||
if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0) return false;
|
||||
const offset = Math.hypot(earthCenterX - keyholeCenterX, earthCenterY - keyholeCenterY);
|
||||
const values = [
|
||||
earthCenterX,
|
||||
earthCenterY,
|
||||
earthRadius,
|
||||
keyholeCenterX,
|
||||
keyholeCenterY,
|
||||
keyholeRadius,
|
||||
];
|
||||
if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0)
|
||||
return false;
|
||||
const offset = Math.hypot(
|
||||
earthCenterX - keyholeCenterX,
|
||||
earthCenterY - keyholeCenterY,
|
||||
);
|
||||
const clearance = keyholeRadius - (offset + earthRadius);
|
||||
return clearance >= (wasVisible ? GLOBE_EXIT_CLEARANCE_PX : GLOBE_ENTER_CLEARANCE_PX);
|
||||
return (
|
||||
clearance >=
|
||||
(wasVisible ? GLOBE_EXIT_CLEARANCE_PX : GLOBE_ENTER_CLEARANCE_PX)
|
||||
);
|
||||
}
|
||||
|
||||
/** Return the Earth-disc radius in CSS pixels for a perspective camera. */
|
||||
export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) {
|
||||
const earthRadiusM = Cesium.Ellipsoid.WGS84.maximumRadius;
|
||||
if (
|
||||
!Number.isFinite(cameraDistance)
|
||||
|| cameraDistance <= earthRadiusM
|
||||
|| !(viewportHeight > 0)
|
||||
|| !Number.isFinite(fovy)
|
||||
|| fovy <= 0
|
||||
|| fovy >= Math.PI
|
||||
) return null;
|
||||
!Number.isFinite(cameraDistance) ||
|
||||
cameraDistance <= earthRadiusM ||
|
||||
!(viewportHeight > 0) ||
|
||||
!Number.isFinite(fovy) ||
|
||||
fovy <= 0 ||
|
||||
fovy >= Math.PI
|
||||
)
|
||||
return null;
|
||||
const angularRadius = Math.asin(clamp(earthRadiusM / cameraDistance, 0, 1));
|
||||
const radius = (viewportHeight * 0.5) * Math.tan(angularRadius) / Math.tan(fovy * 0.5);
|
||||
const radius =
|
||||
(viewportHeight * 0.5 * Math.tan(angularRadius)) / Math.tan(fovy * 0.5);
|
||||
return Number.isFinite(radius) && radius > 0 ? radius : null;
|
||||
}
|
||||
|
||||
@@ -216,7 +250,11 @@ export function projectEarthDiscToViewport(
|
||||
if (!camera || !scene || !(width > 0) || !(height > 0)) return null;
|
||||
|
||||
const distance = Cesium.Cartesian3.magnitude(camera.positionWC);
|
||||
if (!Number.isFinite(distance) || distance <= Cesium.Ellipsoid.WGS84.maximumRadius) return null;
|
||||
if (
|
||||
!Number.isFinite(distance) ||
|
||||
distance <= Cesium.Ellipsoid.WGS84.maximumRadius
|
||||
)
|
||||
return null;
|
||||
|
||||
const toCenter = Cesium.Cartesian3.negate(
|
||||
camera.positionWC,
|
||||
@@ -229,9 +267,14 @@ export function projectEarthDiscToViewport(
|
||||
Cesium.Cartesian3.ZERO,
|
||||
scratchCenter,
|
||||
);
|
||||
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) return null;
|
||||
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y))
|
||||
return null;
|
||||
|
||||
const earthRadius = earthDiscScreenRadius(distance, height, camera.frustum?.fovy);
|
||||
const earthRadius = earthDiscScreenRadius(
|
||||
distance,
|
||||
height,
|
||||
camera.frustum?.fovy,
|
||||
);
|
||||
if (!earthRadius) return null;
|
||||
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
@@ -286,7 +329,12 @@ function drawSunRays(ctx, cx, cy, radius, innerRadius, angle) {
|
||||
glow.addColorStop(0.58, 'rgba(255, 235, 188, 0.027)');
|
||||
glow.addColorStop(1, 'rgba(255, 242, 214, 0)');
|
||||
ctx.fillStyle = glow;
|
||||
ctx.fillRect(cx - radius - 4, cy - radius - 4, (radius + 4) * 2, (radius + 4) * 2);
|
||||
ctx.fillRect(
|
||||
cx - radius - 4,
|
||||
cy - radius - 4,
|
||||
(radius + 4) * 2,
|
||||
(radius + 4) * 2,
|
||||
);
|
||||
|
||||
ctx.lineCap = 'round';
|
||||
ctx.shadowColor = 'rgba(255, 232, 171, 0.11)';
|
||||
@@ -299,7 +347,10 @@ function drawSunRays(ctx, cx, cy, radius, innerRadius, angle) {
|
||||
const ex = sx + Math.cos(rayAngle) * rayLength;
|
||||
const ey = sy + Math.sin(rayAngle) * rayLength;
|
||||
const lineGradient = ctx.createLinearGradient(sx, sy, ex, ey);
|
||||
lineGradient.addColorStop(0, `rgba(255, 230, 166, ${0.075 - Math.abs(i) * 0.011})`);
|
||||
lineGradient.addColorStop(
|
||||
0,
|
||||
`rgba(255, 230, 166, ${0.075 - Math.abs(i) * 0.011})`,
|
||||
);
|
||||
lineGradient.addColorStop(1, 'rgba(255, 226, 156, 0)');
|
||||
ctx.strokeStyle = lineGradient;
|
||||
ctx.lineWidth = i === 0 ? 1.8 : 0.9;
|
||||
@@ -340,7 +391,8 @@ export class CelestialRing {
|
||||
this.viewer = viewer;
|
||||
this.enabled = !!enabled;
|
||||
this.visible = false;
|
||||
this._onAutoDisable = typeof onAutoDisable === 'function' ? onAutoDisable : null;
|
||||
this._onAutoDisable =
|
||||
typeof onAutoDisable === 'function' ? onAutoDisable : null;
|
||||
this._focusInProgress = false;
|
||||
this._ephemerisDirty = true;
|
||||
this._ephemerisUpdateCount = 0;
|
||||
@@ -364,7 +416,9 @@ export class CelestialRing {
|
||||
this._fixedMatrix = new Cesium.Matrix3();
|
||||
|
||||
this._buildDOM();
|
||||
this._removePostRender = viewer.scene.postRender.addEventListener(() => this._draw());
|
||||
this._removePostRender = viewer.scene.postRender.addEventListener(() =>
|
||||
this._draw(),
|
||||
);
|
||||
// Pre-existing staleness fix (perf wave 2 review): the ephemeris was
|
||||
// sampled once per visible-enable from the FROZEN app clock, so the
|
||||
// sun/moon markers aged with the app. Resample real wall time each
|
||||
@@ -394,18 +448,26 @@ export class CelestialRing {
|
||||
|
||||
this._sunCanvas = document.createElement('canvas');
|
||||
this._sunCanvas.className = 'celestial-ring-canvas celestial-sun-canvas';
|
||||
this._sunCtx = this._sunCanvas.getContext('2d', { alpha: true, desynchronized: true });
|
||||
this._sunCtx = this._sunCanvas.getContext('2d', {
|
||||
alpha: true,
|
||||
desynchronized: true,
|
||||
});
|
||||
|
||||
this._moonCanvas = document.createElement('canvas');
|
||||
this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-canvas';
|
||||
this._moonCtx = this._moonCanvas.getContext('2d', { alpha: true, desynchronized: true });
|
||||
this._moonCtx = this._moonCanvas.getContext('2d', {
|
||||
alpha: true,
|
||||
desynchronized: true,
|
||||
});
|
||||
|
||||
this._sunMarker = document.createElement('span');
|
||||
this._sunMarker.className = 'celestial-marker celestial-sun material-symbols-outlined';
|
||||
this._sunMarker.className =
|
||||
'celestial-marker celestial-sun material-symbols-outlined';
|
||||
this._sunMarker.textContent = 'light_mode';
|
||||
|
||||
this._moonMarker = document.createElement('span');
|
||||
this._moonMarker.className = 'celestial-marker celestial-moon material-symbols-outlined';
|
||||
this._moonMarker.className =
|
||||
'celestial-marker celestial-moon material-symbols-outlined';
|
||||
this._moonMarker.textContent = 'dark_mode';
|
||||
|
||||
this._root.append(
|
||||
@@ -413,7 +475,7 @@ export class CelestialRing {
|
||||
this._sunCanvas,
|
||||
this._moonCanvas,
|
||||
this._sunMarker,
|
||||
this._moonMarker
|
||||
this._moonMarker,
|
||||
);
|
||||
this.viewer.container.appendChild(this._root);
|
||||
}
|
||||
@@ -458,15 +520,24 @@ export class CelestialRing {
|
||||
const height = canvas.clientHeight || canvas.height;
|
||||
const cartographic = this.viewer.camera.positionCartographic;
|
||||
const fovy = this.viewer.camera.frustum?.fovy;
|
||||
if (!(height > 0) || !cartographic || !Number.isFinite(fovy) || fovy <= 0 || fovy >= Math.PI) {
|
||||
if (
|
||||
!(height > 0) ||
|
||||
!cartographic ||
|
||||
!Number.isFinite(fovy) ||
|
||||
fovy <= 0 ||
|
||||
fovy >= Math.PI
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius;
|
||||
const keyholeRadius = getKeyholeGeometry(canvas.clientWidth || canvas.width, height).radius;
|
||||
const keyholeRadius = getKeyholeGeometry(
|
||||
canvas.clientWidth || canvas.width,
|
||||
height,
|
||||
).radius;
|
||||
const targetScreenRadius = keyholeRadius * FULL_GLOBE_RADIUS_RATIO;
|
||||
const angularRadius = Math.atan(
|
||||
(targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5)
|
||||
(targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5),
|
||||
);
|
||||
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
|
||||
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
|
||||
@@ -480,7 +551,7 @@ export class CelestialRing {
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
cartographic.longitude,
|
||||
cartographic.latitude,
|
||||
altitude
|
||||
altitude,
|
||||
),
|
||||
orientation: {
|
||||
heading: this.viewer.camera.heading,
|
||||
@@ -500,7 +571,10 @@ export class CelestialRing {
|
||||
|
||||
/** Clear the backing canvas. */
|
||||
_clear() {
|
||||
for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) {
|
||||
for (const [canvas, ctx] of [
|
||||
[this._sunCanvas, this._sunCtx],
|
||||
[this._moonCanvas, this._moonCtx],
|
||||
]) {
|
||||
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
this._ringOutline?.style.setProperty('display', 'none');
|
||||
@@ -512,12 +586,23 @@ export class CelestialRing {
|
||||
/** Resize the canvas backing store while drawing in CSS pixels. */
|
||||
_resize(width, height) {
|
||||
const nativeDpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
const pixelsScale = Math.sqrt(CELESTIAL_MAX_BACKING_PIXELS / (width * height));
|
||||
const dimensionScale = CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height);
|
||||
const dpr = Math.min(nativeDpr, CELESTIAL_MAX_DEVICE_PIXEL_RATIO, pixelsScale, dimensionScale);
|
||||
const pixelsScale = Math.sqrt(
|
||||
CELESTIAL_MAX_BACKING_PIXELS / (width * height),
|
||||
);
|
||||
const dimensionScale =
|
||||
CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height);
|
||||
const dpr = Math.min(
|
||||
nativeDpr,
|
||||
CELESTIAL_MAX_DEVICE_PIXEL_RATIO,
|
||||
pixelsScale,
|
||||
dimensionScale,
|
||||
);
|
||||
const bw = Math.max(1, Math.round(width * dpr));
|
||||
const bh = Math.max(1, Math.round(height * dpr));
|
||||
for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) {
|
||||
for (const [canvas, ctx] of [
|
||||
[this._sunCanvas, this._sunCtx],
|
||||
[this._moonCanvas, this._moonCtx],
|
||||
]) {
|
||||
if (canvas.width !== bw || canvas.height !== bh) {
|
||||
canvas.width = bw;
|
||||
canvas.height = bh;
|
||||
@@ -536,14 +621,25 @@ export class CelestialRing {
|
||||
_updateEphemeris(time) {
|
||||
if (!this._ephemerisDirty) return true;
|
||||
|
||||
Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(time, this._sunInertial);
|
||||
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(time, this._moonInertial);
|
||||
const matrix = Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix)
|
||||
|| Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix);
|
||||
Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(
|
||||
time,
|
||||
this._sunInertial,
|
||||
);
|
||||
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(
|
||||
time,
|
||||
this._moonInertial,
|
||||
);
|
||||
const matrix =
|
||||
Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix) ||
|
||||
Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix);
|
||||
if (!matrix) return false;
|
||||
|
||||
Cesium.Matrix3.multiplyByVector(matrix, this._sunInertial, this._sunFixed);
|
||||
Cesium.Matrix3.multiplyByVector(matrix, this._moonInertial, this._moonFixed);
|
||||
Cesium.Matrix3.multiplyByVector(
|
||||
matrix,
|
||||
this._moonInertial,
|
||||
this._moonFixed,
|
||||
);
|
||||
Cesium.Cartesian3.normalize(this._sunFixed, this._sunFixed);
|
||||
Cesium.Cartesian3.normalize(this._moonFixed, this._moonFixed);
|
||||
this._ephemerisDirty = false;
|
||||
@@ -574,8 +670,14 @@ export class CelestialRing {
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const margin = 14;
|
||||
const maxRadiusX = Math.abs(cos) > 1e-4 ? (cx - margin) / Math.abs(cos) : Number.POSITIVE_INFINITY;
|
||||
const maxRadiusY = Math.abs(sin) > 1e-4 ? (cy - margin) / Math.abs(sin) : Number.POSITIVE_INFINITY;
|
||||
const maxRadiusX =
|
||||
Math.abs(cos) > 1e-4
|
||||
? (cx - margin) / Math.abs(cos)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const maxRadiusY =
|
||||
Math.abs(sin) > 1e-4
|
||||
? (cy - margin) / Math.abs(sin)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const markerRadius = Math.min(radius, maxRadiusX, maxRadiusY);
|
||||
marker.style.left = `${cx + cos * markerRadius}px`;
|
||||
marker.style.top = `${cy + sin * markerRadius}px`;
|
||||
@@ -596,14 +698,24 @@ export class CelestialRing {
|
||||
|
||||
const sunKey = `${outlineKey}:${Math.round(rayInnerRadius)}`;
|
||||
if (sunKey !== this._sunRenderKey) {
|
||||
this._sunCtx.clearRect(0, 0, this._sunCanvas.width, this._sunCanvas.height);
|
||||
this._sunCtx.clearRect(
|
||||
0,
|
||||
0,
|
||||
this._sunCanvas.width,
|
||||
this._sunCanvas.height,
|
||||
);
|
||||
drawSunRays(this._sunCtx, cx, cy, radius, rayInnerRadius, 0);
|
||||
drawTaperedArc(this._sunCtx, cx, cy, radius, 0, '222, 190, 89', 0.56);
|
||||
this._sunRenderKey = sunKey;
|
||||
}
|
||||
|
||||
if (outlineKey !== this._moonRenderKey) {
|
||||
this._moonCtx.clearRect(0, 0, this._moonCanvas.width, this._moonCanvas.height);
|
||||
this._moonCtx.clearRect(
|
||||
0,
|
||||
0,
|
||||
this._moonCanvas.width,
|
||||
this._moonCanvas.height,
|
||||
);
|
||||
drawMoonHaze(this._moonCtx, cx, cy, radius, 0);
|
||||
drawTaperedArc(this._moonCtx, cx, cy, radius, 0, '48, 201, 229', 0.42);
|
||||
this._moonRenderKey = outlineKey;
|
||||
@@ -624,7 +736,9 @@ export class CelestialRing {
|
||||
|
||||
const disc = this._projectedEarthDisc(width, height);
|
||||
const wasVisible = this.visible;
|
||||
const nextVisible = disc ? isFullGlobeInsideKeyhole(disc, wasVisible) : false;
|
||||
const nextVisible = disc
|
||||
? isFullGlobeInsideKeyhole(disc, wasVisible)
|
||||
: false;
|
||||
this.visible = nextVisible;
|
||||
this._root.classList.toggle('visible', nextVisible);
|
||||
this._root.dataset.globeVisible = String(nextVisible);
|
||||
@@ -645,12 +759,12 @@ export class CelestialRing {
|
||||
const sunProjection = celestialScreenAngle(
|
||||
Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC),
|
||||
Cesium.Cartesian3.dot(this._sunFixed, camera.upWC),
|
||||
this._sunAngle
|
||||
this._sunAngle,
|
||||
);
|
||||
const moonProjection = celestialScreenAngle(
|
||||
Cesium.Cartesian3.dot(this._moonFixed, camera.rightWC),
|
||||
Cesium.Cartesian3.dot(this._moonFixed, camera.upWC),
|
||||
this._moonAngle
|
||||
this._moonAngle,
|
||||
);
|
||||
if (sunProjection.stable) this._sunAngle = sunProjection.angle;
|
||||
if (moonProjection.stable) this._moonAngle = moonProjection.angle;
|
||||
@@ -660,7 +774,10 @@ export class CelestialRing {
|
||||
const cx = width * 0.5;
|
||||
const cy = height * 0.5;
|
||||
const radius = disc.keyholeRadius - RING_INSET_PX;
|
||||
const rayInnerRadius = Math.min(radius - 12, disc.earthRadius + Math.max(38, radius * 0.065));
|
||||
const rayInnerRadius = Math.min(
|
||||
radius - 12,
|
||||
disc.earthRadius + Math.max(38, radius * 0.065),
|
||||
);
|
||||
this._renderEffectLayers(cx, cy, radius, rayInnerRadius);
|
||||
this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`;
|
||||
this._sunCanvas.style.opacity = String(this._sunOpacity);
|
||||
@@ -671,16 +788,24 @@ export class CelestialRing {
|
||||
// stroke. Near conjunction (such as a new moon), keep the true bearings but
|
||||
// move the moon into a second radial lane so both bodies remain legible.
|
||||
const markerRadius = radius - MARKER_INSET_PX;
|
||||
const markersCollide = circularAngleDistance(this._sunAngle, this._moonAngle)
|
||||
< MARKER_COLLISION_ANGLE;
|
||||
this._positionMarker(this._sunMarker, cx, cy, markerRadius, this._sunAngle, this._sunOpacity);
|
||||
const markersCollide =
|
||||
circularAngleDistance(this._sunAngle, this._moonAngle) <
|
||||
MARKER_COLLISION_ANGLE;
|
||||
this._positionMarker(
|
||||
this._sunMarker,
|
||||
cx,
|
||||
cy,
|
||||
markerRadius,
|
||||
this._sunAngle,
|
||||
this._sunOpacity,
|
||||
);
|
||||
this._positionMarker(
|
||||
this._moonMarker,
|
||||
cx,
|
||||
cy,
|
||||
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
|
||||
this._moonAngle,
|
||||
this._moonOpacity
|
||||
this._moonOpacity,
|
||||
);
|
||||
this._debug = {
|
||||
enabled: true,
|
||||
@@ -698,7 +823,12 @@ export class CelestialRing {
|
||||
|
||||
/** Read-only geometry snapshot for browser QA. */
|
||||
getDebugState() {
|
||||
return this._debug ? { ...this._debug, disc: this._debug.disc ? { ...this._debug.disc } : null } : null;
|
||||
return this._debug
|
||||
? {
|
||||
...this._debug,
|
||||
disc: this._debug.disc ? { ...this._debug.disc } : null,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Detach the render hook and remove all overlay DOM. */
|
||||
|
||||
+133
-47
@@ -1,6 +1,9 @@
|
||||
import { applicationServices } from './services/application.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { deriveWeatherEffectProfile, weatherAltitudeFactors } from './weatherEffectsMath.js';
|
||||
import {
|
||||
deriveWeatherEffectProfile,
|
||||
weatherAltitudeFactors,
|
||||
} from './weatherEffectsMath.js';
|
||||
|
||||
const WEATHER_REFRESH_MS = 5 * 60_000;
|
||||
const CLOUD_FRAME_MS = 1000 / 12;
|
||||
@@ -117,28 +120,47 @@ function compileShader(gl, type, source) {
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
|
||||
const error = gl.getShaderInfoLog(shader) || 'Unknown shader compilation error';
|
||||
const error =
|
||||
gl.getShaderInfoLog(shader) || 'Unknown shader compilation error';
|
||||
gl.deleteShader(shader);
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
function greatCircleM(a, b) {
|
||||
if (![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(Number.isFinite)) return Infinity;
|
||||
if (
|
||||
![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(
|
||||
Number.isFinite,
|
||||
)
|
||||
)
|
||||
return Infinity;
|
||||
const latitudeA = Cesium.Math.toRadians(a.latitude);
|
||||
const latitudeB = Cesium.Math.toRadians(b.latitude);
|
||||
const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude);
|
||||
const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude);
|
||||
const haversine = Math.sin(latitudeDelta / 2) ** 2
|
||||
+ Math.cos(latitudeA) * Math.cos(latitudeB) * Math.sin(longitudeDelta / 2) ** 2;
|
||||
return 6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine));
|
||||
const haversine =
|
||||
Math.sin(latitudeDelta / 2) ** 2 +
|
||||
Math.cos(latitudeA) *
|
||||
Math.cos(latitudeB) *
|
||||
Math.sin(longitudeDelta / 2) ** 2;
|
||||
return (
|
||||
6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine))
|
||||
);
|
||||
}
|
||||
|
||||
/** Return whether cockpit weather must refresh for elapsed time or movement. */
|
||||
export function cockpitWeatherRefreshDue({ nowMs, fetchedAt, anchor, point, hasWeather }) {
|
||||
export function cockpitWeatherRefreshDue({
|
||||
nowMs,
|
||||
fetchedAt,
|
||||
anchor,
|
||||
point,
|
||||
hasWeather,
|
||||
}) {
|
||||
if (!hasWeather) return true;
|
||||
if (!Number.isFinite(nowMs) || !Number.isFinite(fetchedAt)) return true;
|
||||
return nowMs - fetchedAt >= WEATHER_REFRESH_MS
|
||||
|| greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M;
|
||||
return (
|
||||
nowMs - fetchedAt >= WEATHER_REFRESH_MS ||
|
||||
greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the capped framebuffer size used by the cockpit cloud pass. */
|
||||
@@ -192,18 +214,24 @@ export class CockpitCloudEffectsController {
|
||||
this.pending = null;
|
||||
this.abort = null;
|
||||
this.destroyed = false;
|
||||
this.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
this.reducedMotion = window.matchMedia(
|
||||
'(prefers-reduced-motion: reduce)',
|
||||
).matches;
|
||||
this.enabled = this.readEnabledPreference();
|
||||
|
||||
this.onResize = () => this.resize();
|
||||
this.onCockpitMode = (event) => {
|
||||
const active = event?.detail?.active ?? document.body.classList.contains('cockpit-mode');
|
||||
const active =
|
||||
event?.detail?.active ??
|
||||
document.body.classList.contains('cockpit-mode');
|
||||
if (active && this.enabled) this.start();
|
||||
else this.stop();
|
||||
};
|
||||
this.onEnabledChange = (event) => {
|
||||
const requested = event?.detail?.enabled;
|
||||
this.setEnabled(typeof requested === 'boolean' ? requested : !this.enabled);
|
||||
this.setEnabled(
|
||||
typeof requested === 'boolean' ? requested : !this.enabled,
|
||||
);
|
||||
};
|
||||
window.addEventListener('resize', this.onResize);
|
||||
window.addEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
|
||||
@@ -222,9 +250,11 @@ export class CockpitCloudEffectsController {
|
||||
}
|
||||
|
||||
emitEnabledState() {
|
||||
window.dispatchEvent(new CustomEvent('gev:cockpit-weather-state', {
|
||||
detail: { enabled: this.enabled },
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:cockpit-weather-state', {
|
||||
detail: { enabled: this.enabled },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setEnabled(enabled) {
|
||||
@@ -232,7 +262,9 @@ export class CockpitCloudEffectsController {
|
||||
this.enabled = next;
|
||||
try {
|
||||
localStorage.setItem(WEATHER_ENABLED_STORAGE_KEY, next ? '1' : '0');
|
||||
} catch { /* best effort */ }
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
if (!next) {
|
||||
this.stop();
|
||||
} else if (document.body.classList.contains('cockpit-mode')) {
|
||||
@@ -255,7 +287,11 @@ export class CockpitCloudEffectsController {
|
||||
if (!gl) throw new Error('WebGL unavailable');
|
||||
|
||||
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
|
||||
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
|
||||
const fragmentShader = compileShader(
|
||||
gl,
|
||||
gl.FRAGMENT_SHADER,
|
||||
FRAGMENT_SHADER,
|
||||
);
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, vertexShader);
|
||||
gl.attachShader(program, fragmentShader);
|
||||
@@ -263,15 +299,18 @@ export class CockpitCloudEffectsController {
|
||||
gl.deleteShader(vertexShader);
|
||||
gl.deleteShader(fragmentShader);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
throw new Error(gl.getProgramInfoLog(program) || 'Cloud shader link failed');
|
||||
throw new Error(
|
||||
gl.getProgramInfoLog(program) || 'Cloud shader link failed',
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||||
-1, -1, 1, -1, -1, 1,
|
||||
-1, 1, 1, -1, 1, 1,
|
||||
]), gl.STATIC_DRAW);
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
|
||||
gl.STATIC_DRAW,
|
||||
);
|
||||
const position = gl.getAttribLocation(program, 'position');
|
||||
gl.enableVertexAttribArray(position);
|
||||
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
|
||||
@@ -293,7 +332,8 @@ export class CockpitCloudEffectsController {
|
||||
resize() {
|
||||
if (!this.gl) return;
|
||||
const size = cockpitCloudRenderSize(window.innerWidth, window.innerHeight);
|
||||
if (this.canvas.width === size.width && this.canvas.height === size.height) return;
|
||||
if (this.canvas.width === size.width && this.canvas.height === size.height)
|
||||
return;
|
||||
this.canvas.width = size.width;
|
||||
this.canvas.height = size.height;
|
||||
this.gl.viewport(0, 0, size.width, size.height);
|
||||
@@ -313,7 +353,13 @@ export class CockpitCloudEffectsController {
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.destroyed || !this.enabled || this.frame !== null || this.suspended) return;
|
||||
if (
|
||||
this.destroyed ||
|
||||
!this.enabled ||
|
||||
this.frame !== null ||
|
||||
this.suspended
|
||||
)
|
||||
return;
|
||||
if (!this.gl) {
|
||||
this.initializeRenderer();
|
||||
this.resize();
|
||||
@@ -353,16 +399,21 @@ export class CockpitCloudEffectsController {
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (this.destroyed || !this.enabled || this.pending || this.suspended
|
||||
|| !document.body.classList.contains('cockpit-mode')) {
|
||||
if (
|
||||
this.destroyed ||
|
||||
!this.enabled ||
|
||||
this.pending ||
|
||||
this.suspended ||
|
||||
!document.body.classList.contains('cockpit-mode')
|
||||
) {
|
||||
return this.pending;
|
||||
}
|
||||
const point = this.cameraPoint();
|
||||
if (!point) return null;
|
||||
if (
|
||||
this.weather
|
||||
&& Date.now() - this.fetchedAt < WEATHER_REFRESH_MS
|
||||
&& greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M
|
||||
this.weather &&
|
||||
Date.now() - this.fetchedAt < WEATHER_REFRESH_MS &&
|
||||
greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M
|
||||
) {
|
||||
this.applyWeather(this.weather, point.altitudeM);
|
||||
return this.weather;
|
||||
@@ -370,11 +421,17 @@ export class CockpitCloudEffectsController {
|
||||
|
||||
this.abort?.abort();
|
||||
this.abort = new AbortController();
|
||||
const pending = this.weatherService.getConditions(point.latitude, point.longitude, { signal: this.abort.signal });
|
||||
const pending = this.weatherService.getConditions(
|
||||
point.latitude,
|
||||
point.longitude,
|
||||
{ signal: this.abort.signal },
|
||||
);
|
||||
const request = this.abort;
|
||||
this.pending = pending.then(async (payload) => {
|
||||
this.pending = pending
|
||||
.then(async (payload) => {
|
||||
if (this.abort !== request || request.signal.aborted) return null;
|
||||
if (!payload?.weather) throw new Error('Cloud weather observation unavailable');
|
||||
if (!payload?.weather)
|
||||
throw new Error('Cloud weather observation unavailable');
|
||||
this.weather = payload.weather;
|
||||
this.fetchedAt = Date.now();
|
||||
this.anchor = point;
|
||||
@@ -391,7 +448,10 @@ export class CockpitCloudEffectsController {
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.abort === request) { this.pending = null; this.abort = null; }
|
||||
if (this.abort === request) {
|
||||
this.pending = null;
|
||||
this.abort = null;
|
||||
}
|
||||
});
|
||||
return this.pending;
|
||||
}
|
||||
@@ -399,12 +459,17 @@ export class CockpitCloudEffectsController {
|
||||
applyWeather(weather, altitudeM) {
|
||||
const profile = deriveWeatherEffectProfile(weather);
|
||||
const altitude = weatherAltitudeFactors(altitudeM);
|
||||
this.targetStrength = profile.available ? profile.cloud * altitude.cloud : 0;
|
||||
this.targetStrength = profile.available
|
||||
? profile.cloud * altitude.cloud
|
||||
: 0;
|
||||
this.windDirectionDeg = profile.windDirectionDeg || 0;
|
||||
this.windStrength = profile.wind || 0;
|
||||
this.canvas.dataset.cloudStrength = this.targetStrength.toFixed(3);
|
||||
this.canvas.dataset.weatherCode = String(weather?.weatherCode ?? 'unknown');
|
||||
if (this.reducedMotion && document.body.classList.contains('cockpit-mode')) {
|
||||
if (
|
||||
this.reducedMotion &&
|
||||
document.body.classList.contains('cockpit-mode')
|
||||
) {
|
||||
this.strength = this.targetStrength;
|
||||
const visible = this.strength > 0.035;
|
||||
this.canvas.classList.toggle('active', visible);
|
||||
@@ -414,7 +479,11 @@ export class CockpitCloudEffectsController {
|
||||
}
|
||||
|
||||
tick(timeMs) {
|
||||
if (this.destroyed || !this.enabled || !document.body.classList.contains('cockpit-mode')) {
|
||||
if (
|
||||
this.destroyed ||
|
||||
!this.enabled ||
|
||||
!document.body.classList.contains('cockpit-mode')
|
||||
) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
@@ -431,20 +500,27 @@ export class CockpitCloudEffectsController {
|
||||
if (timeMs - this.lastRefreshCheckMs >= 1000) {
|
||||
this.lastRefreshCheckMs = timeMs;
|
||||
const point = this.cameraPoint();
|
||||
if (point && cockpitWeatherRefreshDue({
|
||||
nowMs: Date.now(),
|
||||
fetchedAt: this.fetchedAt,
|
||||
anchor: this.anchor,
|
||||
point,
|
||||
hasWeather: Boolean(this.weather),
|
||||
})) void this.refresh();
|
||||
if (
|
||||
point &&
|
||||
cockpitWeatherRefreshDue({
|
||||
nowMs: Date.now(),
|
||||
fetchedAt: this.fetchedAt,
|
||||
anchor: this.anchor,
|
||||
point,
|
||||
hasWeather: Boolean(this.weather),
|
||||
})
|
||||
)
|
||||
void this.refresh();
|
||||
}
|
||||
|
||||
const blend = this.reducedMotion ? 1 : 0.08;
|
||||
this.strength += (this.targetStrength - this.strength) * blend;
|
||||
const visible = this.strength > 0.035;
|
||||
this.canvas.classList.toggle('active', visible);
|
||||
if (visible && (this.reducedMotion || timeMs - this.lastFrameMs >= CLOUD_FRAME_MS)) {
|
||||
if (
|
||||
visible &&
|
||||
(this.reducedMotion || timeMs - this.lastFrameMs >= CLOUD_FRAME_MS)
|
||||
) {
|
||||
this.lastFrameMs = timeMs;
|
||||
this.render(timeMs / 1000);
|
||||
} else if (!visible) {
|
||||
@@ -468,9 +544,16 @@ export class CockpitCloudEffectsController {
|
||||
const windRadians = Cesium.Math.toRadians(this.windDirectionDeg);
|
||||
const windScale = 0.008 + this.windStrength * 0.022;
|
||||
gl.useProgram(this.program);
|
||||
gl.uniform2f(this.locations.resolution, this.canvas.width, this.canvas.height);
|
||||
gl.uniform2f(
|
||||
this.locations.resolution,
|
||||
this.canvas.width,
|
||||
this.canvas.height,
|
||||
);
|
||||
gl.uniform1f(this.locations.time, timeSec);
|
||||
gl.uniform1f(this.locations.strength, Math.min(1, Math.max(0, this.strength)));
|
||||
gl.uniform1f(
|
||||
this.locations.strength,
|
||||
Math.min(1, Math.max(0, this.strength)),
|
||||
);
|
||||
gl.uniform2f(
|
||||
this.locations.wind,
|
||||
Math.sin(windRadians) * windScale,
|
||||
@@ -517,7 +600,10 @@ export class CockpitCloudEffectsController {
|
||||
this.stop();
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
window.removeEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
|
||||
window.removeEventListener('gev:cockpit-weather-toggle', this.onEnabledChange);
|
||||
window.removeEventListener(
|
||||
'gev:cockpit-weather-toggle',
|
||||
this.onEnabledChange,
|
||||
);
|
||||
if (this.program && this.gl) this.gl.deleteProgram(this.program);
|
||||
this.canvas.remove();
|
||||
}
|
||||
|
||||
+32
-15
@@ -8,7 +8,11 @@
|
||||
*/
|
||||
|
||||
/** Force the prior aircraft layer to reacquire Cesium and durable tracking ownership. */
|
||||
export function restoreAircraftTrackingOwner(layer, id, { origin = 'programmatic' } = {}) {
|
||||
export function restoreAircraftTrackingOwner(
|
||||
layer,
|
||||
id,
|
||||
{ origin = 'programmatic' } = {},
|
||||
) {
|
||||
if (!layer?.trackById || !id) return false;
|
||||
layer.stopTracking?.({ origin });
|
||||
return Boolean(layer.trackById(id, { origin }));
|
||||
@@ -33,11 +37,13 @@ export function enterCockpitWithTracking({
|
||||
rollbackTarget = undefined,
|
||||
selectionOrigin = 'programmatic',
|
||||
}) {
|
||||
const currentTarget = aircraftTrackingTarget(cockpitView?.readAircraftInfo?.());
|
||||
const restoreTarget = rollbackTarget === undefined ? currentTarget : rollbackTarget;
|
||||
const key = (target) => target?.layerId && target?.id
|
||||
? `${target.layerId}:${target.id}`
|
||||
: null;
|
||||
const currentTarget = aircraftTrackingTarget(
|
||||
cockpitView?.readAircraftInfo?.(),
|
||||
);
|
||||
const restoreTarget =
|
||||
rollbackTarget === undefined ? currentTarget : rollbackTarget;
|
||||
const key = (target) =>
|
||||
target?.layerId && target?.id ? `${target.layerId}:${target.id}` : null;
|
||||
let activeLayer = currentLayer;
|
||||
let activeTarget = currentTarget;
|
||||
let entryError = null;
|
||||
@@ -49,8 +55,14 @@ export function enterCockpitWithTracking({
|
||||
if (selectingTarget) {
|
||||
activeLayer = selectedLayer;
|
||||
activeTarget = selectedTarget;
|
||||
if (!selectedLayer.trackById?.(selectedTarget.id, { origin: selectionOrigin })) {
|
||||
entryError = new Error('Selected aircraft could not be tracked for Cockpit entry');
|
||||
if (
|
||||
!selectedLayer.trackById?.(selectedTarget.id, {
|
||||
origin: selectionOrigin,
|
||||
})
|
||||
) {
|
||||
entryError = new Error(
|
||||
'Selected aircraft could not be tracked for Cockpit entry',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!entryError) entered = Boolean(cockpitView.enter());
|
||||
@@ -66,12 +78,15 @@ export function enterCockpitWithTracking({
|
||||
// programmatic rollback would fix the camera while leaving the failed
|
||||
// target durable in local/share state.
|
||||
activeLayer?.stopTracking?.({ origin: selectionOrigin });
|
||||
if (restoreTarget && !restoreAircraftTrackingOwner(
|
||||
rollbackLayer,
|
||||
restoreTarget.id,
|
||||
{ origin: selectionOrigin },
|
||||
)) {
|
||||
entryError ||= new Error('Prior aircraft tracking could not be restored');
|
||||
if (
|
||||
restoreTarget &&
|
||||
!restoreAircraftTrackingOwner(rollbackLayer, restoreTarget.id, {
|
||||
origin: selectionOrigin,
|
||||
})
|
||||
) {
|
||||
entryError ||= new Error(
|
||||
'Prior aircraft tracking could not be restored',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
entryError ||= error instanceof Error ? error : new Error(String(error));
|
||||
@@ -87,6 +102,8 @@ export function enterCockpitWithTracking({
|
||||
|
||||
return {
|
||||
entered,
|
||||
error: entered ? null : entryError?.message || 'Cockpit entry was unavailable',
|
||||
error: entered
|
||||
? null
|
||||
: entryError?.message || 'Cockpit entry was unavailable',
|
||||
};
|
||||
}
|
||||
@@ -75,11 +75,16 @@ export function contactsDetectionEnterPlan(current, restore) {
|
||||
* @returns {?{mode: string, densityPct: ?number}} State to replay, or null when
|
||||
* there is nothing to do.
|
||||
*/
|
||||
export function contactsDetectionExitPlan(restore, current, styleOwnsDetection = false) {
|
||||
export function contactsDetectionExitPlan(
|
||||
restore,
|
||||
current,
|
||||
styleOwnsDetection = false,
|
||||
) {
|
||||
if (!restore || styleOwnsDetection) return null;
|
||||
const saved = normalizeDetectionState(restore);
|
||||
const now = normalizeDetectionState(current);
|
||||
if (saved.mode === now.mode && saved.densityPct === now.densityPct) return null;
|
||||
if (saved.mode === now.mode && saved.densityPct === now.densityPct)
|
||||
return null;
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -119,7 +124,11 @@ export function applyContactsDetection({
|
||||
if (plan.turnOn) applyPreset();
|
||||
return { restore: plan.restore, changed: plan.turnOn };
|
||||
}
|
||||
const plan = contactsDetectionExitPlan(restore, getState(), styleOwnsDetection);
|
||||
const plan = contactsDetectionExitPlan(
|
||||
restore,
|
||||
getState(),
|
||||
styleOwnsDetection,
|
||||
);
|
||||
if (plan) restoreState(plan);
|
||||
return { restore: null, changed: Boolean(plan) };
|
||||
}
|
||||
@@ -145,7 +154,9 @@ export function shareableDetectionState({ owned, liveMode, liveDensityPct }) {
|
||||
if (!owned) return { mode: liveMode, densityPct: liveDensityPct };
|
||||
return {
|
||||
mode: owned.mode ?? liveMode,
|
||||
densityPct: Number.isFinite(owned.densityPct) ? owned.densityPct : liveDensityPct,
|
||||
densityPct: Number.isFinite(owned.densityPct)
|
||||
? owned.densityPct
|
||||
: liveDensityPct,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+125
-72
@@ -1,5 +1,11 @@
|
||||
const CONTEXT_DEPENDENCIES = Object.freeze({
|
||||
flights: new Set(['military-awareness', 'flights', 'military', 'ais-live-vessels', 'military-installations']),
|
||||
flights: new Set([
|
||||
'military-awareness',
|
||||
'flights',
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
]),
|
||||
'space-missions': new Set(['rocket-launches', 'satellites']),
|
||||
});
|
||||
const CONTEXT_COMPANIONS = new Set(['radio']);
|
||||
@@ -11,11 +17,13 @@ export function isExplicitUserIntentOrigin(origin, layerId = null) {
|
||||
/** Preserve explicit layer intent that completes while a stale restore is queued. */
|
||||
export function recordContextRestoreExplicitChange({ restoreState, change }) {
|
||||
if (
|
||||
!restoreState?.enabledLayerIds
|
||||
|| change?.type !== 'visibility'
|
||||
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
) return false;
|
||||
if (!restoreState.explicitLayerStates) restoreState.explicitLayerStates = new Map();
|
||||
!restoreState?.enabledLayerIds ||
|
||||
change?.type !== 'visibility' ||
|
||||
!isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
)
|
||||
return false;
|
||||
if (!restoreState.explicitLayerStates)
|
||||
restoreState.explicitLayerStates = new Map();
|
||||
restoreState.explicitLayerStates.set(change.layerId, Boolean(change.enabled));
|
||||
if (change.enabled) restoreState.enabledLayerIds.add(change.layerId);
|
||||
else restoreState.enabledLayerIds.delete(change.layerId);
|
||||
@@ -23,30 +31,41 @@ export function recordContextRestoreExplicitChange({ restoreState, change }) {
|
||||
}
|
||||
|
||||
/** Settle every explicit Context-restore replay without losing semantic failures. */
|
||||
export async function settleContextIntentReplay({ restoreState, setEnabled, notificationToken = null }) {
|
||||
export async function settleContextIntentReplay({
|
||||
restoreState,
|
||||
setEnabled,
|
||||
notificationToken = null,
|
||||
}) {
|
||||
if (restoreState?.cancelled) return null;
|
||||
const entries = [...(restoreState?.explicitLayerStates || [])];
|
||||
const results = await Promise.allSettled(entries.map(([layerId, enabled]) => (
|
||||
setEnabled(layerId, enabled, {
|
||||
origin: 'context-intent-replay',
|
||||
...(notificationToken ? { notificationToken } : {}),
|
||||
})
|
||||
)));
|
||||
const failedIndexes = results.flatMap((result, index) => (
|
||||
result.status === 'rejected' || result.value === false ? [index] : []
|
||||
));
|
||||
const results = await Promise.allSettled(
|
||||
entries.map(([layerId, enabled]) =>
|
||||
setEnabled(layerId, enabled, {
|
||||
origin: 'context-intent-replay',
|
||||
...(notificationToken ? { notificationToken } : {}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const failedIndexes = results.flatMap((result, index) =>
|
||||
result.status === 'rejected' || result.value === false ? [index] : [],
|
||||
);
|
||||
if (failedIndexes.length === 0) return null;
|
||||
const failedLayerIds = failedIndexes.map((index) => entries[index][0]);
|
||||
const rejected = failedIndexes
|
||||
.map((index) => results[index])
|
||||
.find((result) => result.status === 'rejected');
|
||||
const error = rejected?.reason instanceof Error
|
||||
? rejected.reason
|
||||
: new Error(`Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`);
|
||||
error.failedLayerIds = [...new Set([
|
||||
...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []),
|
||||
...failedLayerIds,
|
||||
])];
|
||||
const error =
|
||||
rejected?.reason instanceof Error
|
||||
? rejected.reason
|
||||
: new Error(
|
||||
`Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`,
|
||||
);
|
||||
error.failedLayerIds = [
|
||||
...new Set([
|
||||
...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []),
|
||||
...failedLayerIds,
|
||||
]),
|
||||
];
|
||||
return error;
|
||||
}
|
||||
|
||||
@@ -54,18 +73,29 @@ export async function settleContextIntentReplay({ restoreState, setEnabled, noti
|
||||
export function mergeContextTransitionErrors(primaryError, secondaryError) {
|
||||
if (!primaryError) return secondaryError || null;
|
||||
if (!secondaryError) return primaryError;
|
||||
primaryError.failedLayerIds = [...new Set([
|
||||
...(Array.isArray(primaryError.failedLayerIds) ? primaryError.failedLayerIds : []),
|
||||
...(Array.isArray(secondaryError.failedLayerIds) ? secondaryError.failedLayerIds : []),
|
||||
])];
|
||||
primaryError.failedLayerIds = [
|
||||
...new Set([
|
||||
...(Array.isArray(primaryError.failedLayerIds)
|
||||
? primaryError.failedLayerIds
|
||||
: []),
|
||||
...(Array.isArray(secondaryError.failedLayerIds)
|
||||
? secondaryError.failedLayerIds
|
||||
: []),
|
||||
]),
|
||||
];
|
||||
return primaryError;
|
||||
}
|
||||
|
||||
/** Settle a user-facing Context action and convert every failure form to false. */
|
||||
export async function settleUserFacingContextAction({ operation, onFailure, falseIsFailure = true }) {
|
||||
export async function settleUserFacingContextAction({
|
||||
operation,
|
||||
onFailure,
|
||||
falseIsFailure = true,
|
||||
}) {
|
||||
try {
|
||||
const result = await operation();
|
||||
if (falseIsFailure && result === false) throw new Error('Context transition did not complete');
|
||||
if (falseIsFailure && result === false)
|
||||
throw new Error('Context transition did not complete');
|
||||
return result;
|
||||
} catch (error) {
|
||||
try {
|
||||
@@ -83,7 +113,10 @@ export async function settleUserFacingContextAction({ operation, onFailure, fals
|
||||
* one into `userAdded` makes exit restoration re-enable the mode layer the
|
||||
* user just turned off.
|
||||
*/
|
||||
export const CONTEXT_ENTRY_LAYER_IDS = Object.freeze(['military-awareness', 'rocket-launches']);
|
||||
export const CONTEXT_ENTRY_LAYER_IDS = Object.freeze([
|
||||
'military-awareness',
|
||||
'rocket-launches',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Leave a Context transaction: publish the settled coordination flag, then
|
||||
@@ -108,7 +141,8 @@ export function settleContextModeChange(owner, changing = false) {
|
||||
if (!owner) return;
|
||||
const wasChanging = Boolean(owner._contextModeChanging);
|
||||
owner._contextModeChanging = Boolean(changing);
|
||||
if (wasChanging && !owner._contextModeChanging) owner._syncContextModeButtons?.();
|
||||
if (wasChanging && !owner._contextModeChanging)
|
||||
owner._syncContextModeButtons?.();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,19 +176,24 @@ export async function runWithContextModeChanging(owner, operation) {
|
||||
* @param {object|null} input.change DataLayerManager visibility notification.
|
||||
* @returns {boolean} Whether the active Context bundle should be disabled.
|
||||
*/
|
||||
export function shouldExitContextForLayerChange({ contextMode, globalContextEnabled, change }) {
|
||||
export function shouldExitContextForLayerChange({
|
||||
contextMode,
|
||||
globalContextEnabled,
|
||||
change,
|
||||
}) {
|
||||
if (
|
||||
change?.type !== 'visibility'
|
||||
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
|| change.layerId === 'military-awareness'
|
||||
|| CONTEXT_COMPANIONS.has(change.layerId)
|
||||
change?.type !== 'visibility' ||
|
||||
!isExplicitUserIntentOrigin(change.origin, change.layerId) ||
|
||||
change.layerId === 'military-awareness' ||
|
||||
CONTEXT_COMPANIONS.has(change.layerId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const active = Boolean(globalContextEnabled || contextMode);
|
||||
if (!active) return false;
|
||||
|
||||
const dependencies = CONTEXT_DEPENDENCIES[contextMode] || new Set(['military-awareness']);
|
||||
const dependencies =
|
||||
CONTEXT_DEPENDENCIES[contextMode] || new Set(['military-awareness']);
|
||||
if (change.enabled) return false;
|
||||
return Boolean(contextMode && dependencies.has(change.layerId));
|
||||
}
|
||||
@@ -181,10 +220,10 @@ export function contextAllowedLayerIds(contextMode) {
|
||||
*/
|
||||
export function shouldCaptureContextSession(change) {
|
||||
return Boolean(
|
||||
['visibility-requested', 'visibility-will-change'].includes(change?.type)
|
||||
&& isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
&& change.enabled
|
||||
&& CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId)
|
||||
['visibility-requested', 'visibility-will-change'].includes(change?.type) &&
|
||||
isExplicitUserIntentOrigin(change.origin, change.layerId) &&
|
||||
change.enabled &&
|
||||
CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -194,12 +233,12 @@ export function shouldCaptureContextSession(change) {
|
||||
*/
|
||||
export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
|
||||
return Boolean(
|
||||
clearInFlight
|
||||
&& change?.type === 'visibility-requested'
|
||||
&& change.layerId === 'rocket-launches'
|
||||
&& change.enabled === true
|
||||
&& isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
&& Number.isInteger(change.intentEpoch)
|
||||
clearInFlight &&
|
||||
change?.type === 'visibility-requested' &&
|
||||
change.layerId === 'rocket-launches' &&
|
||||
change.enabled === true &&
|
||||
isExplicitUserIntentOrigin(change.origin, change.layerId) &&
|
||||
Number.isInteger(change.intentEpoch),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,11 +257,15 @@ export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
|
||||
* @param {string|null} [input.effectiveContextMode] Committed or entering mode.
|
||||
* @returns {boolean} Whether the snapshot's userAdded set was modified.
|
||||
*/
|
||||
export function recordContextSessionUserChange({ snapshot, change, effectiveContextMode = null }) {
|
||||
export function recordContextSessionUserChange({
|
||||
snapshot,
|
||||
change,
|
||||
effectiveContextMode = null,
|
||||
}) {
|
||||
if (
|
||||
!snapshot?.userAdded
|
||||
|| change?.type !== 'visibility'
|
||||
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
!snapshot?.userAdded ||
|
||||
change?.type !== 'visibility' ||
|
||||
!isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -233,7 +276,11 @@ export function recordContextSessionUserChange({ snapshot, change, effectiveCont
|
||||
snapshot.userAdded.add(change.layerId);
|
||||
return true;
|
||||
}
|
||||
if (contextAllowedLayerIds(effectiveContextMode).has(change.layerId) && !isCompanion) return false;
|
||||
if (
|
||||
contextAllowedLayerIds(effectiveContextMode).has(change.layerId) &&
|
||||
!isCompanion
|
||||
)
|
||||
return false;
|
||||
snapshot.userAdded.add(change.layerId);
|
||||
if (isCompanion) snapshot.userRemoved?.delete(change.layerId);
|
||||
return true;
|
||||
@@ -276,11 +323,15 @@ export function contextSnapshotLayerIds(
|
||||
* @param {string|null} [input.layerName] User-facing layer name.
|
||||
* @returns {string|null} Honest user-facing refusal reason, or null when allowed.
|
||||
*/
|
||||
export function contextLayerEnableBlockReason({ contextMode, change, layerName = null }) {
|
||||
export function contextLayerEnableBlockReason({
|
||||
contextMode,
|
||||
change,
|
||||
layerName = null,
|
||||
}) {
|
||||
if (
|
||||
contextMode !== 'space-missions'
|
||||
|| change?.enabled !== true
|
||||
|| contextAllowedLayerIds('space-missions').has(change.layerId)
|
||||
contextMode !== 'space-missions' ||
|
||||
change?.enabled !== true ||
|
||||
contextAllowedLayerIds('space-missions').has(change.layerId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -297,20 +348,20 @@ export function contextLayerEnableBlockReason({ contextMode, change, layerName =
|
||||
* @param {object|null} input.change Manager visibility notification.
|
||||
* @returns {'ignore'|'replacement'|'restore'} Cancellation disposition.
|
||||
*/
|
||||
export function spaceMissionEntryCancellationDisposition({
|
||||
change,
|
||||
}) {
|
||||
export function spaceMissionEntryCancellationDisposition({ change }) {
|
||||
if (
|
||||
change?.type !== 'visibility-cancelled'
|
||||
|| change.layerId !== 'rocket-launches'
|
||||
|| change.enabled !== true
|
||||
) return 'ignore';
|
||||
change?.type !== 'visibility-cancelled' ||
|
||||
change.layerId !== 'rocket-launches' ||
|
||||
change.enabled !== true
|
||||
)
|
||||
return 'ignore';
|
||||
if (
|
||||
change.cancellationReason === 'superseded'
|
||||
&& Number.isInteger(change.successorIntentEpoch)
|
||||
&& change.successorIntentEpoch > change.intentEpoch
|
||||
&& change.successorEnabled === true
|
||||
) return 'replacement';
|
||||
change.cancellationReason === 'superseded' &&
|
||||
Number.isInteger(change.successorIntentEpoch) &&
|
||||
change.successorIntentEpoch > change.intentEpoch &&
|
||||
change.successorEnabled === true
|
||||
)
|
||||
return 'replacement';
|
||||
return 'restore';
|
||||
}
|
||||
|
||||
@@ -348,10 +399,12 @@ export function cockpitEntryAllowed({
|
||||
flightsEnabled,
|
||||
militaryEnabled,
|
||||
}) {
|
||||
return contextMode === 'flights'
|
||||
&& !contextModeChanging
|
||||
&& Boolean(flightsEnabled)
|
||||
&& Boolean(militaryEnabled);
|
||||
return (
|
||||
contextMode === 'flights' &&
|
||||
!contextModeChanging &&
|
||||
Boolean(flightsEnabled) &&
|
||||
Boolean(militaryEnabled)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* Cesium retains ownership of showing and hiding its required credit lightbox.
|
||||
*/
|
||||
export function configureCreditKeyboardAccess(root = document) {
|
||||
const expand = root?.querySelector?.('#cesium-credits .cesium-credit-expand-link');
|
||||
const expand = root?.querySelector?.(
|
||||
'#cesium-credits .cesium-credit-expand-link',
|
||||
);
|
||||
const lightbox = root?.querySelector?.('.cesium-credit-lightbox');
|
||||
const close = lightbox?.querySelector?.('.cesium-credit-lightbox-close');
|
||||
if (!expand || !lightbox || !close) return false;
|
||||
@@ -44,7 +46,9 @@ export function configureCreditKeyboardAccess(root = document) {
|
||||
// A long Space hold may hand control to push-to-talk and move focus away.
|
||||
// Cancelling the armed release prevents that eventual keyup from also
|
||||
// activating the attribution control.
|
||||
control.addEventListener('blur', () => { spacePressed = false; });
|
||||
control.addEventListener('blur', () => {
|
||||
spacePressed = false;
|
||||
});
|
||||
};
|
||||
const closeAndRestoreFocus = () => {
|
||||
expand.setAttribute('aria-expanded', 'false');
|
||||
|
||||
+414
-67
@@ -8,90 +8,415 @@
|
||||
*/
|
||||
|
||||
const HELI = new Set([
|
||||
'EC20', 'EC25', 'EC30', 'EC35', 'EC45', 'EC55', 'AS50', 'AS55', 'AS65', 'AS32',
|
||||
'A109', 'A119', 'A139', 'A169', 'A189', 'B06', 'B06T', 'B407', 'B412', 'B427',
|
||||
'B429', 'B430', 'B505', 'S76', 'S92', 'S61', 'S64', 'H60', 'H500', 'MD52',
|
||||
'MD60', 'R22', 'R44', 'R66', 'EXEC', 'EXPL', 'GAZL', 'LYNX', 'NH90', 'PUMA',
|
||||
'SCAV', 'UH1', 'B105', 'B212', 'B214', 'B222', 'AC', 'H47', 'H64',
|
||||
'EC20',
|
||||
'EC25',
|
||||
'EC30',
|
||||
'EC35',
|
||||
'EC45',
|
||||
'EC55',
|
||||
'AS50',
|
||||
'AS55',
|
||||
'AS65',
|
||||
'AS32',
|
||||
'A109',
|
||||
'A119',
|
||||
'A139',
|
||||
'A169',
|
||||
'A189',
|
||||
'B06',
|
||||
'B06T',
|
||||
'B407',
|
||||
'B412',
|
||||
'B427',
|
||||
'B429',
|
||||
'B430',
|
||||
'B505',
|
||||
'S76',
|
||||
'S92',
|
||||
'S61',
|
||||
'S64',
|
||||
'H60',
|
||||
'H500',
|
||||
'MD52',
|
||||
'MD60',
|
||||
'R22',
|
||||
'R44',
|
||||
'R66',
|
||||
'EXEC',
|
||||
'EXPL',
|
||||
'GAZL',
|
||||
'LYNX',
|
||||
'NH90',
|
||||
'PUMA',
|
||||
'SCAV',
|
||||
'UH1',
|
||||
'B105',
|
||||
'B212',
|
||||
'B214',
|
||||
'B222',
|
||||
'AC',
|
||||
'H47',
|
||||
'H64',
|
||||
]);
|
||||
const QUAD = new Set([
|
||||
'B741', 'B742', 'B743', 'B744', 'B748', 'B74S', 'B74R', 'B74D', 'A388', 'A342',
|
||||
'A343', 'A345', 'A346', 'A124', 'C5M', 'A225', 'IL96', 'B52', 'A140',
|
||||
'B741',
|
||||
'B742',
|
||||
'B743',
|
||||
'B744',
|
||||
'B748',
|
||||
'B74S',
|
||||
'B74R',
|
||||
'B74D',
|
||||
'A388',
|
||||
'A342',
|
||||
'A343',
|
||||
'A345',
|
||||
'A346',
|
||||
'A124',
|
||||
'C5M',
|
||||
'A225',
|
||||
'IL96',
|
||||
'B52',
|
||||
'A140',
|
||||
]);
|
||||
const WIDE = new Set([
|
||||
'A306', 'A30B', 'A310', 'A332', 'A333', 'A338', 'A339', 'A359', 'A35K', 'B762',
|
||||
'B763', 'B764', 'B772', 'B77L', 'B773', 'B77W', 'B778', 'B779', 'B788', 'B789',
|
||||
'B78X', 'MD11', 'IL86', 'DC10', 'L101', 'A337', 'B767', 'B777', 'B787',
|
||||
'C17', 'K35R', // military heavies that read as widebodies
|
||||
'A306',
|
||||
'A30B',
|
||||
'A310',
|
||||
'A332',
|
||||
'A333',
|
||||
'A338',
|
||||
'A339',
|
||||
'A359',
|
||||
'A35K',
|
||||
'B762',
|
||||
'B763',
|
||||
'B764',
|
||||
'B772',
|
||||
'B77L',
|
||||
'B773',
|
||||
'B77W',
|
||||
'B778',
|
||||
'B779',
|
||||
'B788',
|
||||
'B789',
|
||||
'B78X',
|
||||
'MD11',
|
||||
'IL86',
|
||||
'DC10',
|
||||
'L101',
|
||||
'A337',
|
||||
'B767',
|
||||
'B777',
|
||||
'B787',
|
||||
'C17',
|
||||
'K35R', // military heavies that read as widebodies
|
||||
]);
|
||||
const TPROP = new Set([
|
||||
'DH8A', 'DH8B', 'DH8C', 'DH8D', 'AT43', 'AT44', 'AT45', 'AT46', 'AT72', 'AT73',
|
||||
'AT75', 'AT76', 'SF34', 'SB20', 'SW3', 'SW4', 'E110', 'E120', 'C208', 'C212',
|
||||
'C408', 'PC12', 'B190', 'BE20', 'B350', 'B300', 'JS31', 'JS32', 'JS41', 'D228',
|
||||
'D328', 'F50', 'F27', 'ATP', 'TBM7', 'TBM8', 'TBM9', 'TBM0', 'PC6', 'C441',
|
||||
'C425', 'DHC6', 'DHC7', 'C130', 'AN12', 'AN26', 'AN32', 'SH36', 'CVLT', 'SAAB',
|
||||
'DH8A',
|
||||
'DH8B',
|
||||
'DH8C',
|
||||
'DH8D',
|
||||
'AT43',
|
||||
'AT44',
|
||||
'AT45',
|
||||
'AT46',
|
||||
'AT72',
|
||||
'AT73',
|
||||
'AT75',
|
||||
'AT76',
|
||||
'SF34',
|
||||
'SB20',
|
||||
'SW3',
|
||||
'SW4',
|
||||
'E110',
|
||||
'E120',
|
||||
'C208',
|
||||
'C212',
|
||||
'C408',
|
||||
'PC12',
|
||||
'B190',
|
||||
'BE20',
|
||||
'B350',
|
||||
'B300',
|
||||
'JS31',
|
||||
'JS32',
|
||||
'JS41',
|
||||
'D228',
|
||||
'D328',
|
||||
'F50',
|
||||
'F27',
|
||||
'ATP',
|
||||
'TBM7',
|
||||
'TBM8',
|
||||
'TBM9',
|
||||
'TBM0',
|
||||
'PC6',
|
||||
'C441',
|
||||
'C425',
|
||||
'DHC6',
|
||||
'DHC7',
|
||||
'C130',
|
||||
'AN12',
|
||||
'AN26',
|
||||
'AN32',
|
||||
'SH36',
|
||||
'CVLT',
|
||||
'SAAB',
|
||||
'A400',
|
||||
]);
|
||||
const GLIDER = new Set([
|
||||
'DISC', 'DUOD', 'VENT', 'NIMB', 'NIM3', 'NIM4', 'JANS', 'ARCE', 'DG40', 'DG80',
|
||||
'DG1T', 'DG30', 'DG50', 'LS3', 'LS4', 'LS6', 'LS7', 'LS8', 'STD3', 'G103',
|
||||
'G102', 'G104', 'PW5', 'PW6', 'L13', 'L23', 'L33', 'PIK', 'PEGA', 'KEST',
|
||||
'TWIN', 'AS33', 'ASW', 'ASG', 'ASK', 'VENS', 'GLID', 'MOSQ', 'DIMO',
|
||||
'DISC',
|
||||
'DUOD',
|
||||
'VENT',
|
||||
'NIMB',
|
||||
'NIM3',
|
||||
'NIM4',
|
||||
'JANS',
|
||||
'ARCE',
|
||||
'DG40',
|
||||
'DG80',
|
||||
'DG1T',
|
||||
'DG30',
|
||||
'DG50',
|
||||
'LS3',
|
||||
'LS4',
|
||||
'LS6',
|
||||
'LS7',
|
||||
'LS8',
|
||||
'STD3',
|
||||
'G103',
|
||||
'G102',
|
||||
'G104',
|
||||
'PW5',
|
||||
'PW6',
|
||||
'L13',
|
||||
'L23',
|
||||
'L33',
|
||||
'PIK',
|
||||
'PEGA',
|
||||
'KEST',
|
||||
'TWIN',
|
||||
'AS33',
|
||||
'ASW',
|
||||
'ASG',
|
||||
'ASK',
|
||||
'VENS',
|
||||
'GLID',
|
||||
'MOSQ',
|
||||
'DIMO',
|
||||
]);
|
||||
const LIGHT = new Set([
|
||||
'C150', 'C152', 'C162', 'C172', 'C72R', 'C175', 'C177', 'C180', 'C182', 'C185',
|
||||
'C188', 'C206', 'C207', 'C210', 'C310', 'C337', 'SR20', 'SR22', 'S22T', 'PA18',
|
||||
'PA24', 'PA28', 'P28A', 'P28B', 'P28R', 'PA32', 'P32R', 'PA34', 'PA38', 'PA44',
|
||||
'PA46', 'DA20', 'DA40', 'DA42', 'DA62', 'BE33', 'BE35', 'BE36', 'BE58', 'BE76',
|
||||
'BE19', 'BE23', 'BE24', 'M20P', 'M20T', 'AA1', 'AA5', 'GLAS', 'COL4', 'RV4',
|
||||
'RV6', 'RV7', 'RV8', 'RV9', 'RV10', 'RV14', 'GA8', 'G115', 'BL8', 'CH7',
|
||||
'C150',
|
||||
'C152',
|
||||
'C162',
|
||||
'C172',
|
||||
'C72R',
|
||||
'C175',
|
||||
'C177',
|
||||
'C180',
|
||||
'C182',
|
||||
'C185',
|
||||
'C188',
|
||||
'C206',
|
||||
'C207',
|
||||
'C210',
|
||||
'C310',
|
||||
'C337',
|
||||
'SR20',
|
||||
'SR22',
|
||||
'S22T',
|
||||
'PA18',
|
||||
'PA24',
|
||||
'PA28',
|
||||
'P28A',
|
||||
'P28B',
|
||||
'P28R',
|
||||
'PA32',
|
||||
'P32R',
|
||||
'PA34',
|
||||
'PA38',
|
||||
'PA44',
|
||||
'PA46',
|
||||
'DA20',
|
||||
'DA40',
|
||||
'DA42',
|
||||
'DA62',
|
||||
'BE33',
|
||||
'BE35',
|
||||
'BE36',
|
||||
'BE58',
|
||||
'BE76',
|
||||
'BE19',
|
||||
'BE23',
|
||||
'BE24',
|
||||
'M20P',
|
||||
'M20T',
|
||||
'AA1',
|
||||
'AA5',
|
||||
'GLAS',
|
||||
'COL4',
|
||||
'RV4',
|
||||
'RV6',
|
||||
'RV7',
|
||||
'RV8',
|
||||
'RV9',
|
||||
'RV10',
|
||||
'RV14',
|
||||
'GA8',
|
||||
'G115',
|
||||
'BL8',
|
||||
'CH7',
|
||||
]);
|
||||
// GEV addition (2026-08-15 Hangar fleet): business jets — distinct class so the
|
||||
// Citation II GLB + a slimmer glyph read apart from airliners.
|
||||
const BIZJET = new Set([
|
||||
'C500', 'C501', 'C510', 'C525', 'C25A', 'C25B', 'C25C', 'C25M', 'C550',
|
||||
'C551', 'C560', 'C56X', 'C650', 'C680', 'C68A', 'C700', 'C750',
|
||||
'CL30', 'CL35', 'CL60', 'GLF2', 'GLF3', 'GLF4', 'GLF5', 'GLF6', 'GA5C',
|
||||
'GA6C', 'G150', 'G280', 'GL5T', 'GL7T', 'GLEX',
|
||||
'LJ23', 'LJ24', 'LJ25', 'LJ31', 'LJ35', 'LJ40', 'LJ45', 'LJ55', 'LJ60',
|
||||
'LJ70', 'LJ75', 'FA10', 'FA20', 'FA50', 'FA7X', 'FA8X', 'F900', 'F2TH',
|
||||
'H25A', 'H25B', 'H25C', 'HDJT', 'E50P', 'E55P', 'E545', 'E550', 'PC24',
|
||||
'PRM1', 'BE40', 'ASTR', 'WW24',
|
||||
'C500',
|
||||
'C501',
|
||||
'C510',
|
||||
'C525',
|
||||
'C25A',
|
||||
'C25B',
|
||||
'C25C',
|
||||
'C25M',
|
||||
'C550',
|
||||
'C551',
|
||||
'C560',
|
||||
'C56X',
|
||||
'C650',
|
||||
'C680',
|
||||
'C68A',
|
||||
'C700',
|
||||
'C750',
|
||||
'CL30',
|
||||
'CL35',
|
||||
'CL60',
|
||||
'GLF2',
|
||||
'GLF3',
|
||||
'GLF4',
|
||||
'GLF5',
|
||||
'GLF6',
|
||||
'GA5C',
|
||||
'GA6C',
|
||||
'G150',
|
||||
'G280',
|
||||
'GL5T',
|
||||
'GL7T',
|
||||
'GLEX',
|
||||
'LJ23',
|
||||
'LJ24',
|
||||
'LJ25',
|
||||
'LJ31',
|
||||
'LJ35',
|
||||
'LJ40',
|
||||
'LJ45',
|
||||
'LJ55',
|
||||
'LJ60',
|
||||
'LJ70',
|
||||
'LJ75',
|
||||
'FA10',
|
||||
'FA20',
|
||||
'FA50',
|
||||
'FA7X',
|
||||
'FA8X',
|
||||
'F900',
|
||||
'F2TH',
|
||||
'H25A',
|
||||
'H25B',
|
||||
'H25C',
|
||||
'HDJT',
|
||||
'E50P',
|
||||
'E55P',
|
||||
'E545',
|
||||
'E550',
|
||||
'PC24',
|
||||
'PRM1',
|
||||
'BE40',
|
||||
'ASTR',
|
||||
'WW24',
|
||||
'SF50', // Cirrus Vision Jet — a JET (was mis-set in LIGHT before the bizjet class existed)
|
||||
]);
|
||||
// GEV addition (2026-08-15 Hangar fleet): large UAVs — ICAO designators seen on
|
||||
// ADS-B/adsb.lol for Predator/Reaper/Global Hawk-class airframes.
|
||||
const UAV = new Set([
|
||||
'Q1', 'Q4', 'Q9', 'MQ1', 'MQ4', 'MQ9', 'RQ4', 'TB2', 'SHDW', 'HERN',
|
||||
'Q1',
|
||||
'Q4',
|
||||
'Q9',
|
||||
'MQ1',
|
||||
'MQ4',
|
||||
'MQ9',
|
||||
'RQ4',
|
||||
'TB2',
|
||||
'SHDW',
|
||||
'HERN',
|
||||
]);
|
||||
// GEV addition: fast jets, for the military layer's adsb.lol `t` codes.
|
||||
const FASTJET = new Set([
|
||||
'F16', 'F15', 'F18', 'FA18', 'F14', 'F22', 'F35', 'F4', 'F5', 'A10', 'AV8B',
|
||||
'TYPH', 'EUFI', 'RFAL', 'RAFL', 'GRIP', 'JAS39', 'TOR', 'MIR2', 'M2000',
|
||||
'SU27', 'SU30', 'SU33', 'SU34', 'SU35', 'SU57', 'MG29', 'MIG29', 'MG31', 'J20',
|
||||
'T38', 'HAWK', 'L39', 'M346', 'T7A',
|
||||
'F16',
|
||||
'F15',
|
||||
'F18',
|
||||
'FA18',
|
||||
'F14',
|
||||
'F22',
|
||||
'F35',
|
||||
'F4',
|
||||
'F5',
|
||||
'A10',
|
||||
'AV8B',
|
||||
'TYPH',
|
||||
'EUFI',
|
||||
'RFAL',
|
||||
'RAFL',
|
||||
'GRIP',
|
||||
'JAS39',
|
||||
'TOR',
|
||||
'MIR2',
|
||||
'M2000',
|
||||
'SU27',
|
||||
'SU30',
|
||||
'SU33',
|
||||
'SU34',
|
||||
'SU35',
|
||||
'SU57',
|
||||
'MG29',
|
||||
'MIG29',
|
||||
'MG31',
|
||||
'J20',
|
||||
'T38',
|
||||
'HAWK',
|
||||
'L39',
|
||||
'M346',
|
||||
'T7A',
|
||||
]);
|
||||
|
||||
/** OpenSky extended-states integer category → class. */
|
||||
const OPENSKY_CATEGORY = {
|
||||
2: 'light', // Light (< 15 500 lbs)
|
||||
3: 'airliner', // Small
|
||||
4: 'airliner', // Large
|
||||
5: 'airliner', // High-vortex large (B757)
|
||||
6: 'widebody', // Heavy
|
||||
7: 'fastjet', // High performance (>5g, >400 kt)
|
||||
2: 'light', // Light (< 15 500 lbs)
|
||||
3: 'airliner', // Small
|
||||
4: 'airliner', // Large
|
||||
5: 'airliner', // High-vortex large (B757)
|
||||
6: 'widebody', // Heavy
|
||||
7: 'fastjet', // High performance (>5g, >400 kt)
|
||||
8: 'helicopter', // Rotorcraft
|
||||
9: 'glider',
|
||||
};
|
||||
|
||||
/** ADS-B emitter-category string → class. */
|
||||
const EMITTER_CATEGORY = {
|
||||
A1: 'light', A2: 'light', A3: 'airliner', A4: 'airliner',
|
||||
A5: 'widebody', A6: 'fastjet', A7: 'helicopter', B1: 'glider',
|
||||
A1: 'light',
|
||||
A2: 'light',
|
||||
A3: 'airliner',
|
||||
A4: 'airliner',
|
||||
A5: 'widebody',
|
||||
A6: 'fastjet',
|
||||
A7: 'helicopter',
|
||||
B1: 'glider',
|
||||
};
|
||||
|
||||
export function classifyAircraft({ typeCode, category } = {}) {
|
||||
const code = String(typeCode || '').trim().toUpperCase();
|
||||
const code = String(typeCode || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (code) {
|
||||
if (FASTJET.has(code)) return 'fastjet';
|
||||
if (UAV.has(code)) return 'uav';
|
||||
@@ -104,37 +429,59 @@ export function classifyAircraft({ typeCode, category } = {}) {
|
||||
if (LIGHT.has(code)) return 'light';
|
||||
return 'airliner'; // known type code, not in a special set → default jet
|
||||
}
|
||||
if (Number.isFinite(category) && OPENSKY_CATEGORY[category]) return OPENSKY_CATEGORY[category];
|
||||
const cat = String(category || '').trim().toUpperCase();
|
||||
if (Number.isFinite(category) && OPENSKY_CATEGORY[category])
|
||||
return OPENSKY_CATEGORY[category];
|
||||
const cat = String(category || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (EMITTER_CATEGORY[cat]) return EMITTER_CATEGORY[cat];
|
||||
return 'airliner';
|
||||
}
|
||||
|
||||
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
|
||||
export const CLASS_SCALE_2D = {
|
||||
light: 0.62, glider: 0.58, turboprop: 0.86, airliner: 1.0,
|
||||
widebody: 1.3, quadjet: 1.45, helicopter: 0.82, fastjet: 0.8,
|
||||
bizjet: 0.72, uav: 0.75,
|
||||
light: 0.62,
|
||||
glider: 0.58,
|
||||
turboprop: 0.86,
|
||||
airliner: 1.0,
|
||||
widebody: 1.3,
|
||||
quadjet: 1.45,
|
||||
helicopter: 0.82,
|
||||
fastjet: 0.8,
|
||||
bizjet: 0.72,
|
||||
uav: 0.75,
|
||||
};
|
||||
|
||||
/** 3D model scale multipliers — clamped [0.75, 1.45] while every class shares
|
||||
* the single airplane.glb (a jet mesh at C172 scale reads wrong). Widen when
|
||||
* real per-class models land in CLASS_MODEL_URL. */
|
||||
export const CLASS_SCALE_3D = {
|
||||
light: 0.75, glider: 0.75, turboprop: 0.85, airliner: 1.0,
|
||||
widebody: 1.3, quadjet: 1.45, helicopter: 0.8, fastjet: 0.8,
|
||||
bizjet: 0.8, uav: 0.8,
|
||||
light: 0.75,
|
||||
glider: 0.75,
|
||||
turboprop: 0.85,
|
||||
airliner: 1.0,
|
||||
widebody: 1.3,
|
||||
quadjet: 1.45,
|
||||
helicopter: 0.8,
|
||||
fastjet: 0.8,
|
||||
bizjet: 0.8,
|
||||
uav: 0.8,
|
||||
};
|
||||
|
||||
/** Per-class glTF — all the shared airplane today; drop real assets in here.
|
||||
* NOTE for future models: each asset may need its own heading offset (the
|
||||
* shared GLB uses MODEL_HEADING_OFFSET_DEG = 180 in both layers). */
|
||||
export const CLASS_MODEL_URL = {
|
||||
light: '/models/airplane.glb', glider: '/models/airplane.glb',
|
||||
turboprop: '/models/airplane.glb', airliner: '/models/airplane.glb',
|
||||
widebody: '/models/airplane.glb', quadjet: '/models/airplane.glb',
|
||||
helicopter: '/models/airplane.glb', fastjet: '/models/airplane.glb',
|
||||
bizjet: '/models/airplane.glb', uav: '/models/airplane.glb',
|
||||
light: '/models/airplane.glb',
|
||||
glider: '/models/airplane.glb',
|
||||
turboprop: '/models/airplane.glb',
|
||||
airliner: '/models/airplane.glb',
|
||||
widebody: '/models/airplane.glb',
|
||||
quadjet: '/models/airplane.glb',
|
||||
helicopter: '/models/airplane.glb',
|
||||
fastjet: '/models/airplane.glb',
|
||||
bizjet: '/models/airplane.glb',
|
||||
uav: '/models/airplane.glb',
|
||||
};
|
||||
|
||||
/** Real per-class GLBs (2026-08-15 Hangar fleet, owner picks; CC-BY 4.0 —
|
||||
@@ -148,10 +495,10 @@ export const CLASS_MODEL_URL = {
|
||||
* pinned by modelScale.test.mjs. Classes NOT listed (airliner, quadjet,
|
||||
* glider, fastjet) still render the shared airplane.glb via CLASS_MODEL_URL. */
|
||||
export const CLASS_MODEL_REAL = {
|
||||
helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 },
|
||||
light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 },
|
||||
bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 },
|
||||
uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 },
|
||||
widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 },
|
||||
turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 },
|
||||
helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 },
|
||||
light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 },
|
||||
bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 },
|
||||
uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 },
|
||||
widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 },
|
||||
turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 },
|
||||
};
|
||||
@@ -35,9 +35,11 @@ const VIEW = 96;
|
||||
const C = VIEW / 2; // 48 — glyph centre
|
||||
|
||||
// Hairline dark edge (scaled for the 96 box: ~1.5u ≈ the old 0.5u at 32).
|
||||
const STROKE = 'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"';
|
||||
const STROKE =
|
||||
'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"';
|
||||
// Heavier edge for the chart-symbol ("bold") glyphs adopted 2026-08-15.
|
||||
const STROKE_BOLD = 'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"';
|
||||
const STROKE_BOLD =
|
||||
'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"';
|
||||
// Softer white for translucent detail (prop discs, rotor disc) — still white so
|
||||
// the tint multiplies cleanly; only the alpha differs.
|
||||
const DISC = 'fill="white" fill-opacity="0.5"';
|
||||
@@ -239,7 +241,9 @@ const BODIES = {
|
||||
const _iconCache = new Map();
|
||||
|
||||
const _b64 = (s) =>
|
||||
typeof btoa === 'function' ? btoa(s) : Buffer.from(s, 'utf8').toString('base64');
|
||||
typeof btoa === 'function'
|
||||
? btoa(s)
|
||||
: Buffer.from(s, 'utf8').toString('base64');
|
||||
|
||||
/** Fleet raster: billboards render at ~40–58 DEVICE px (width 20–24 CSS ×
|
||||
* Retina × class scale). Cesium's billboard atlas has no mipmaps, so a big
|
||||
|
||||
@@ -11,5 +11,7 @@ export function aircraftIncludedInNearby({
|
||||
modelRendering = false,
|
||||
includeHidden = false,
|
||||
} = {}) {
|
||||
return Boolean(includeHidden || isTracked || billboardShown || modelRendering);
|
||||
return Boolean(
|
||||
includeHidden || isTracked || billboardShown || modelRendering,
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
|
||||
startLimbRatio: 0.5,
|
||||
scaleFloor: 0.45,
|
||||
alphaFloor: 0.35,
|
||||
combinedAlphaFloor: 0.20,
|
||||
combinedAlphaFloor: 0.2,
|
||||
globeViewBlendStartM: 3_500_000,
|
||||
globeViewBlendEndM: 4_500_000,
|
||||
earthRadiusM: 6_378_137,
|
||||
@@ -36,10 +36,19 @@ function resolvedParams(overrides) {
|
||||
return { ..._params, ...overrides };
|
||||
}
|
||||
|
||||
function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning, result) {
|
||||
if (!Number.isFinite(cameraDistanceM) || cameraDistanceM < 0
|
||||
|| !Number.isFinite(cameraHeightM) || cameraHeightM <= 0
|
||||
|| cameraHeightM >= tuning.globeViewBlendEndM) {
|
||||
function aircraftRecessionFactorsResolved(
|
||||
cameraDistanceM,
|
||||
cameraHeightM,
|
||||
tuning,
|
||||
result,
|
||||
) {
|
||||
if (
|
||||
!Number.isFinite(cameraDistanceM) ||
|
||||
cameraDistanceM < 0 ||
|
||||
!Number.isFinite(cameraHeightM) ||
|
||||
cameraHeightM <= 0 ||
|
||||
cameraHeightM >= tuning.globeViewBlendEndM
|
||||
) {
|
||||
result.scale = 1;
|
||||
result.alpha = 1;
|
||||
result.limbRatio = null;
|
||||
@@ -60,11 +69,13 @@ function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning
|
||||
return result;
|
||||
}
|
||||
|
||||
const rawT = (limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio);
|
||||
const rawT =
|
||||
(limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio);
|
||||
const t = clamp(rawT, 0, 1);
|
||||
const limbEase = t * t * (3 - 2 * t); // smoothstep: zero slope at both band edges
|
||||
const globeRawT = (cameraHeightM - tuning.globeViewBlendStartM)
|
||||
/ (tuning.globeViewBlendEndM - tuning.globeViewBlendStartM);
|
||||
const globeRawT =
|
||||
(cameraHeightM - tuning.globeViewBlendStartM) /
|
||||
(tuning.globeViewBlendEndM - tuning.globeViewBlendStartM);
|
||||
const globeT = clamp(globeRawT, 0, 1);
|
||||
const globeEase = globeT * globeT * (3 - 2 * globeT);
|
||||
const strength = 1 - globeEase;
|
||||
@@ -84,8 +95,10 @@ export function setAircraftRecessionParams(patch = {}) {
|
||||
if (Number.isFinite(patch.startLimbRatio)) {
|
||||
next.startLimbRatio = clamp(patch.startLimbRatio, 0, 0.99);
|
||||
}
|
||||
if (Number.isFinite(patch.scaleFloor)) next.scaleFloor = clamp(patch.scaleFloor, 0.05, 1);
|
||||
if (Number.isFinite(patch.alphaFloor)) next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1);
|
||||
if (Number.isFinite(patch.scaleFloor))
|
||||
next.scaleFloor = clamp(patch.scaleFloor, 0.05, 1);
|
||||
if (Number.isFinite(patch.alphaFloor))
|
||||
next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1);
|
||||
if (Number.isFinite(patch.combinedAlphaFloor)) {
|
||||
next.combinedAlphaFloor = clamp(patch.combinedAlphaFloor, 0.05, 1);
|
||||
}
|
||||
@@ -109,8 +122,10 @@ export function setAircraftRecessionParams(patch = {}) {
|
||||
if (next.globeViewBlendStartM >= next.globeViewBlendEndM) {
|
||||
next.globeViewBlendStartM = Math.max(1, next.globeViewBlendEndM - 1);
|
||||
}
|
||||
if (Number.isFinite(patch.earthRadiusM)) next.earthRadiusM = Math.max(1, patch.earthRadiusM);
|
||||
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon);
|
||||
if (Number.isFinite(patch.earthRadiusM))
|
||||
next.earthRadiusM = Math.max(1, patch.earthRadiusM);
|
||||
if (Number.isFinite(patch.writeEpsilon))
|
||||
next.writeEpsilon = Math.max(0, patch.writeEpsilon);
|
||||
_params = next;
|
||||
return { ..._params };
|
||||
}
|
||||
@@ -149,7 +164,10 @@ export function cameraLimbDistanceM(
|
||||
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [params]
|
||||
* @returns {{scale:number,alpha:number,limbRatio:number|null}}
|
||||
*/
|
||||
export function aircraftRecessionFactors({ cameraDistanceM, cameraHeightM }, params) {
|
||||
export function aircraftRecessionFactors(
|
||||
{ cameraDistanceM, cameraHeightM },
|
||||
params,
|
||||
) {
|
||||
const tuning = resolvedParams(params);
|
||||
return aircraftRecessionFactorsResolved(
|
||||
cameraDistanceM,
|
||||
@@ -202,12 +220,17 @@ export function applyAircraftBillboardTreatment({
|
||||
const alpha = baseAlpha * composedTreatment;
|
||||
let scaleWrites = 0;
|
||||
let alphaWrites = 0;
|
||||
if (!Number.isFinite(billboard.scale) || Math.abs(billboard.scale - scale) > tuning.writeEpsilon) {
|
||||
if (
|
||||
!Number.isFinite(billboard.scale) ||
|
||||
Math.abs(billboard.scale - scale) > tuning.writeEpsilon
|
||||
) {
|
||||
billboard.scale = scale;
|
||||
scaleWrites = 1;
|
||||
}
|
||||
if (!Number.isFinite(billboard.color?.alpha)
|
||||
|| Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon) {
|
||||
if (
|
||||
!Number.isFinite(billboard.color?.alpha) ||
|
||||
Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon
|
||||
) {
|
||||
billboard.color = baseColor.withAlpha(alpha);
|
||||
alphaWrites = 1;
|
||||
}
|
||||
@@ -228,11 +251,19 @@ export function applyAircraftBillboardTreatment({
|
||||
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
|
||||
* @returns {number} One when a color write occurred, otherwise zero.
|
||||
*/
|
||||
export function applyAircraftModelTreatment({ model, baseColor, alpha, params }) {
|
||||
export function applyAircraftModelTreatment({
|
||||
model,
|
||||
baseColor,
|
||||
alpha,
|
||||
params,
|
||||
}) {
|
||||
const tuning = resolvedParams(params);
|
||||
if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
|
||||
if (Number.isFinite(model.color?.alpha)
|
||||
&& Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon) return 0;
|
||||
if (
|
||||
Number.isFinite(model.color?.alpha) &&
|
||||
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon
|
||||
)
|
||||
return 0;
|
||||
model.color = baseColor.withAlpha(alpha);
|
||||
return 1;
|
||||
}
|
||||
+110
-40
@@ -28,11 +28,39 @@ import { pointInRing } from './naturalEarthRegions.js';
|
||||
|
||||
/** Layers the engine understands, with the fields queries may reference. */
|
||||
export const ANALYST_LAYERS = {
|
||||
flights: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass'], flags: ['military', 'onGround'] },
|
||||
military: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'], flags: ['military', 'onGround'] },
|
||||
'ais-live-vessels': { numeric: ['speedKts', 'courseDeg'], text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'], flags: [] },
|
||||
'local-firms': { numeric: ['frp'], text: ['confidence', 'satellite'], flags: [] },
|
||||
earthquakes: { numeric: ['magnitude', 'depthKm'], text: ['place'], flags: [] },
|
||||
flights: {
|
||||
numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
|
||||
text: [
|
||||
'callsign',
|
||||
'icao24',
|
||||
'originCountry',
|
||||
'operator',
|
||||
'routeOrigin',
|
||||
'routeDestination',
|
||||
'aircraftClass',
|
||||
],
|
||||
flags: ['military', 'onGround'],
|
||||
},
|
||||
military: {
|
||||
numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
|
||||
text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'],
|
||||
flags: ['military', 'onGround'],
|
||||
},
|
||||
'ais-live-vessels': {
|
||||
numeric: ['speedKts', 'courseDeg'],
|
||||
text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'],
|
||||
flags: [],
|
||||
},
|
||||
'local-firms': {
|
||||
numeric: ['frp'],
|
||||
text: ['confidence', 'satellite'],
|
||||
flags: [],
|
||||
},
|
||||
earthquakes: {
|
||||
numeric: ['magnitude', 'depthKm'],
|
||||
text: ['place'],
|
||||
flags: [],
|
||||
},
|
||||
};
|
||||
|
||||
const EARTH_R_KM = 6371;
|
||||
@@ -42,8 +70,9 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const d2r = Math.PI / 180;
|
||||
const dLat = (lat2 - lat1) * d2r;
|
||||
const dLon = (lon2 - lon1) * d2r;
|
||||
const a = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
@@ -55,17 +84,25 @@ export function applyFilter(records, filter) {
|
||||
const got = r[field];
|
||||
if (got === null || got === undefined) return false;
|
||||
switch (op) {
|
||||
case 'gt': return Number(got) > Number(value);
|
||||
case 'gte': return Number(got) >= Number(value);
|
||||
case 'lt': return Number(got) < Number(value);
|
||||
case 'lte': return Number(got) <= Number(value);
|
||||
case 'gt':
|
||||
return Number(got) > Number(value);
|
||||
case 'gte':
|
||||
return Number(got) >= Number(value);
|
||||
case 'lt':
|
||||
return Number(got) < Number(value);
|
||||
case 'lte':
|
||||
return Number(got) <= Number(value);
|
||||
case 'eq': {
|
||||
if (typeof got === 'boolean' || typeof value === 'boolean') return Boolean(got) === Boolean(value);
|
||||
if (typeof got === 'boolean' || typeof value === 'boolean')
|
||||
return Boolean(got) === Boolean(value);
|
||||
return String(got).toLowerCase() === String(value).toLowerCase();
|
||||
}
|
||||
case 'neq': return String(got).toLowerCase() !== String(value).toLowerCase();
|
||||
case 'contains': return String(got).toLowerCase().includes(String(value).toLowerCase());
|
||||
default: return true;
|
||||
case 'neq':
|
||||
return String(got).toLowerCase() !== String(value).toLowerCase();
|
||||
case 'contains':
|
||||
return String(got).toLowerCase().includes(String(value).toLowerCase());
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -74,15 +111,23 @@ export function applyFilter(records, filter) {
|
||||
export function applyScope(records, scope, resolved) {
|
||||
if (!scope || scope.kind === 'anywhere') return records;
|
||||
if (scope.kind === 'region' && resolved?.ring) {
|
||||
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon)
|
||||
&& pointInRing(resolved.ring, r.lat, r.lon));
|
||||
return records.filter(
|
||||
(r) =>
|
||||
Number.isFinite(r.lat) &&
|
||||
Number.isFinite(r.lon) &&
|
||||
pointInRing(resolved.ring, r.lat, r.lon),
|
||||
);
|
||||
}
|
||||
if (scope.kind === 'radius' || scope.kind === 'view') {
|
||||
const c = resolved?.center;
|
||||
const km = resolved?.km;
|
||||
if (!c || !Number.isFinite(km)) return records;
|
||||
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon)
|
||||
&& haversineKm(c.lat, c.lon, r.lat, r.lon) <= km);
|
||||
return records.filter(
|
||||
(r) =>
|
||||
Number.isFinite(r.lat) &&
|
||||
Number.isFinite(r.lon) &&
|
||||
haversineKm(c.lat, c.lon, r.lat, r.lon) <= km,
|
||||
);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
@@ -108,9 +153,12 @@ export function createAnalystEngine(providers) {
|
||||
let lastResult = null;
|
||||
|
||||
async function query(spec = {}) {
|
||||
const layers = (spec.followUp && lastResult)
|
||||
? null // follow-up: re-filter the remembered set, no re-snapshot
|
||||
: (Array.isArray(spec.layers) && spec.layers.length ? spec.layers : ['flights']);
|
||||
const layers =
|
||||
spec.followUp && lastResult
|
||||
? null // follow-up: re-filter the remembered set, no re-snapshot
|
||||
: Array.isArray(spec.layers) && spec.layers.length
|
||||
? spec.layers
|
||||
: ['flights'];
|
||||
|
||||
// 1) Source records
|
||||
let records;
|
||||
@@ -164,20 +212,23 @@ export function createAnalystEngine(providers) {
|
||||
// camera made the two disagree — a parked, high-altitude camera answered
|
||||
// "46 within 250 km" while the panel showed a far larger contact-centred
|
||||
// count. Same question, two numbers.
|
||||
const explicitCenter = scope.center && Number.isFinite(scope.center.lat)
|
||||
? scope.center
|
||||
: null;
|
||||
const subject = explicitCenter ? null : providers.getContextSubject?.() || null;
|
||||
const explicitCenter =
|
||||
scope.center && Number.isFinite(scope.center.lat) ? scope.center : null;
|
||||
const subject = explicitCenter
|
||||
? null
|
||||
: providers.getContextSubject?.() || null;
|
||||
const view = providers.getViewContext();
|
||||
// Only a subject that actually SUPPLIED the centre may name it. A
|
||||
// subject present but without usable coordinates silently fell back to
|
||||
// the camera while the label still read "within 250 km of <contact>" —
|
||||
// a count centred on one place, reported as centred on another, with
|
||||
// nothing in the payload to show which.
|
||||
const subjectCenter = Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
|
||||
? { lat: subject.lat, lon: subject.lon }
|
||||
: null;
|
||||
const center = explicitCenter || subjectCenter || { lat: view.lat, lon: view.lon };
|
||||
const subjectCenter =
|
||||
Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
|
||||
? { lat: subject.lat, lon: subject.lon }
|
||||
: null;
|
||||
const center = explicitCenter ||
|
||||
subjectCenter || { lat: view.lat, lon: view.lon };
|
||||
resolvedScope = { center, km: Number(scope.km) || 100 };
|
||||
if (subjectCenter) resolvedScope.centeredOn = subject.label || null;
|
||||
scopeNote = resolvedScope.centeredOn
|
||||
@@ -188,7 +239,10 @@ export function createAnalystEngine(providers) {
|
||||
: `within ${resolvedScope.km} km`;
|
||||
} else if (scope.kind === 'view') {
|
||||
const view = providers.getViewContext();
|
||||
resolvedScope = { center: { lat: view.lat, lon: view.lon }, km: view.viewRadiusKm };
|
||||
resolvedScope = {
|
||||
center: { lat: view.lat, lon: view.lon },
|
||||
km: view.viewRadiusKm,
|
||||
};
|
||||
scopeNote = `view:${Math.round(view.viewRadiusKm)}km`;
|
||||
scopeLabel = 'in view';
|
||||
} else {
|
||||
@@ -205,15 +259,22 @@ export function createAnalystEngine(providers) {
|
||||
if (sortBy === 'distance') {
|
||||
const ref = resolvedScope?.center || providers.getViewContext();
|
||||
for (const it of items) {
|
||||
it.distanceKm = (Number.isFinite(it.lat) && Number.isFinite(it.lon))
|
||||
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) / 10 : null;
|
||||
it.distanceKm =
|
||||
Number.isFinite(it.lat) && Number.isFinite(it.lon)
|
||||
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) /
|
||||
10
|
||||
: null;
|
||||
}
|
||||
}
|
||||
if (sortBy) {
|
||||
const dir = spec.sortDir === 'asc' ? 1 : -1;
|
||||
items.sort((a, b) => (Number(a[sortBy]) - Number(b[sortBy])) * dir
|
||||
|| String(a.id).localeCompare(String(b.id)));
|
||||
if (sortBy === 'distance') items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9));
|
||||
items.sort(
|
||||
(a, b) =>
|
||||
(Number(a[sortBy]) - Number(b[sortBy])) * dir ||
|
||||
String(a.id).localeCompare(String(b.id)),
|
||||
);
|
||||
if (sortBy === 'distance')
|
||||
items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9));
|
||||
}
|
||||
const limit = Math.max(1, Math.min(50, Number(spec.limit) || 10));
|
||||
const top = items.slice(0, limit);
|
||||
@@ -223,7 +284,10 @@ export function createAnalystEngine(providers) {
|
||||
count: items.length,
|
||||
items: top,
|
||||
truncated: items.length > top.length,
|
||||
summary: summarize(items, sortBy && sortBy !== 'distance' ? sortBy : null),
|
||||
summary: summarize(
|
||||
items,
|
||||
sortBy && sortBy !== 'distance' ? sortBy : null,
|
||||
),
|
||||
scopeLabel,
|
||||
coverage: {
|
||||
layersQueried,
|
||||
@@ -233,7 +297,9 @@ export function createAnalystEngine(providers) {
|
||||
},
|
||||
// Surfaced so the narration can name the centre it measured from rather
|
||||
// than implying a view-centred answer.
|
||||
...(resolvedScope?.centeredOn ? { centeredOn: resolvedScope.centeredOn } : {}),
|
||||
...(resolvedScope?.centeredOn
|
||||
? { centeredOn: resolvedScope.centeredOn }
|
||||
: {}),
|
||||
};
|
||||
lastResult = { items, coverage: result.coverage };
|
||||
return result;
|
||||
@@ -241,7 +307,11 @@ export function createAnalystEngine(providers) {
|
||||
|
||||
return {
|
||||
query,
|
||||
reset() { lastResult = null; },
|
||||
hasMemory() { return Boolean(lastResult); },
|
||||
reset() {
|
||||
lastResult = null;
|
||||
},
|
||||
hasMemory() {
|
||||
return Boolean(lastResult);
|
||||
},
|
||||
};
|
||||
}
|
||||
+67
-19
@@ -110,8 +110,12 @@ export const CCTV_THUMBNAIL_ALTITUDE_SCALE = Object.freeze({
|
||||
*/
|
||||
export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
|
||||
if (pinned) return Number.isFinite(sy);
|
||||
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0) return false;
|
||||
const safeTop = Math.min(CCTV_CARD_SAFE_TOP_MAX_PX, viewH * CCTV_CARD_SAFE_TOP_RATIO);
|
||||
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0)
|
||||
return false;
|
||||
const safeTop = Math.min(
|
||||
CCTV_CARD_SAFE_TOP_MAX_PX,
|
||||
viewH * CCTV_CARD_SAFE_TOP_RATIO,
|
||||
);
|
||||
return sy >= safeTop;
|
||||
}
|
||||
|
||||
@@ -126,11 +130,24 @@ export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
|
||||
* @param {number} [options.limit]
|
||||
* @returns {string[]} Accepted ids, nearest-first.
|
||||
*/
|
||||
export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {}) {
|
||||
export function declutterCctvCards(
|
||||
candidates,
|
||||
{ minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {},
|
||||
) {
|
||||
const valid = (Array.isArray(candidates) ? candidates : [])
|
||||
.filter((c) => c && typeof c.id === 'string' && c.id
|
||||
&& Number.isFinite(c.sx) && Number.isFinite(c.sy))
|
||||
.sort((a, b) => (a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) || a.id.localeCompare(b.id));
|
||||
.filter(
|
||||
(c) =>
|
||||
c &&
|
||||
typeof c.id === 'string' &&
|
||||
c.id &&
|
||||
Number.isFinite(c.sx) &&
|
||||
Number.isFinite(c.sy),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
const minSq = minSepPx * minSepPx;
|
||||
const accepted = [];
|
||||
const acceptedIds = [];
|
||||
@@ -140,7 +157,10 @@ export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX
|
||||
for (let i = 0; i < accepted.length; i++) {
|
||||
const dx = candidate.sx - accepted[i].sx;
|
||||
const dy = candidate.sy - accepted[i].sy;
|
||||
if (dx * dx + dy * dy < minSq) { clear = false; break; }
|
||||
if (dx * dx + dy * dy < minSq) {
|
||||
clear = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!clear) continue;
|
||||
accepted.push(candidate);
|
||||
@@ -165,15 +185,26 @@ export function cardScaleForAltitude(cameraHeightM) {
|
||||
const h = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : 0;
|
||||
if (h <= CCTV_CARD_SCALE_FULL_M) return { scale: 1, alpha: 1 };
|
||||
if (h <= CCTV_CARD_SCALE_MID_M) {
|
||||
const t = (h - CCTV_CARD_SCALE_FULL_M) / (CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M);
|
||||
const t =
|
||||
(h - CCTV_CARD_SCALE_FULL_M) /
|
||||
(CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M);
|
||||
const s = t * t * (3 - 2 * t); // smoothstep — no visible kink at either end
|
||||
return { scale: 1 + (CCTV_CARD_SCALE_AT_MID - 1) * s, alpha: 1 };
|
||||
}
|
||||
const t = Math.min(1, (h - CCTV_CARD_SCALE_MID_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M));
|
||||
const scale = CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t;
|
||||
if (h >= CCTV_CARD_FADE_END_M) return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 };
|
||||
const t = Math.min(
|
||||
1,
|
||||
(h - CCTV_CARD_SCALE_MID_M) /
|
||||
(CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M),
|
||||
);
|
||||
const scale =
|
||||
CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t;
|
||||
if (h >= CCTV_CARD_FADE_END_M)
|
||||
return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 };
|
||||
if (h <= CCTV_CARD_FADE_START_M) return { scale, alpha: 1 };
|
||||
const alpha = 1 - (h - CCTV_CARD_FADE_START_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
|
||||
const alpha =
|
||||
1 -
|
||||
(h - CCTV_CARD_FADE_START_M) /
|
||||
(CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
|
||||
return { scale, alpha };
|
||||
}
|
||||
|
||||
@@ -192,17 +223,23 @@ export function cardScaleForAltitude(cameraHeightM) {
|
||||
* @param {number} [input.sinceLastLaunchMs] - Ms since the last fetch launch.
|
||||
* @returns {{mode:('burst'|'steady'), launch:boolean}}
|
||||
*/
|
||||
export function cardFetchPolicy({ coldFill = false, inFlight = 0, sinceLastLaunchMs = Infinity } = {}) {
|
||||
export function cardFetchPolicy({
|
||||
coldFill = false,
|
||||
inFlight = 0,
|
||||
sinceLastLaunchMs = Infinity,
|
||||
} = {}) {
|
||||
if (coldFill) {
|
||||
return {
|
||||
mode: 'burst',
|
||||
launch: inFlight < CCTV_CARD_FETCH_BURST_LIMIT
|
||||
&& sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
|
||||
launch:
|
||||
inFlight < CCTV_CARD_FETCH_BURST_LIMIT &&
|
||||
sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: 'steady',
|
||||
launch: inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
|
||||
launch:
|
||||
inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,7 +267,12 @@ export function createFrameSlot() {
|
||||
export function applyFrameResult(prev, result, nowMs) {
|
||||
const base = prev || createFrameSlot();
|
||||
if (result?.ok && result.frame) {
|
||||
return { frame: result.frame, stamp: nowMs, failCount: 0, lastAttemptAt: nowMs };
|
||||
return {
|
||||
frame: result.frame,
|
||||
stamp: nowMs,
|
||||
failCount: 0,
|
||||
lastAttemptAt: nowMs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
frame: base.frame,
|
||||
@@ -278,11 +320,17 @@ export function frameFetchDue(slot, refreshMs, nowMs) {
|
||||
* @param {number} [cap]
|
||||
* @returns {string[]} Ids to drop from the cache.
|
||||
*/
|
||||
export function planFrameCachePrune(slots, keepIds, cap = CCTV_FRAME_CACHE_MAX) {
|
||||
export function planFrameCachePrune(
|
||||
slots,
|
||||
keepIds,
|
||||
cap = CCTV_FRAME_CACHE_MAX,
|
||||
) {
|
||||
const keep = new Set(keepIds);
|
||||
const spare = (Array.isArray(slots) ? slots : [])
|
||||
.filter((slot) => slot && typeof slot.id === 'string' && !keep.has(slot.id))
|
||||
.sort((a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id));
|
||||
.sort(
|
||||
(a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id),
|
||||
);
|
||||
const capacity = Math.max(0, cap - keep.size);
|
||||
return spare.slice(capacity).map((slot) => slot.id);
|
||||
}
|
||||
|
||||
+173
-44
@@ -74,7 +74,11 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
|
||||
const toPlane = Cesium.Cartesian3.subtract(planeOrigin, rayOrigin, scratchW);
|
||||
const s = Cesium.Cartesian3.dot(toPlane, planeNormal) / denom;
|
||||
if (s < 0) return null;
|
||||
const hit = Cesium.Cartesian3.multiplyByScalar(rayDir, s, new Cesium.Cartesian3());
|
||||
const hit = Cesium.Cartesian3.multiplyByScalar(
|
||||
rayDir,
|
||||
s,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
return Cesium.Cartesian3.add(rayOrigin, hit, hit);
|
||||
}
|
||||
|
||||
@@ -89,7 +93,10 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
|
||||
*/
|
||||
export function ringAngle(hitPoint, center, basisA, basisB) {
|
||||
const v = Cesium.Cartesian3.subtract(hitPoint, center, scratchW);
|
||||
return Math.atan2(Cesium.Cartesian3.dot(v, basisB), Cesium.Cartesian3.dot(v, basisA));
|
||||
return Math.atan2(
|
||||
Cesium.Cartesian3.dot(v, basisB),
|
||||
Cesium.Cartesian3.dot(v, basisA),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,7 +157,11 @@ function enuAxes(position) {
|
||||
|
||||
/** a*sa + b*sb (fresh Cartesian3). */
|
||||
function combine2(a, sa, b, sb) {
|
||||
const out = Cesium.Cartesian3.multiplyByScalar(a, sa, new Cesium.Cartesian3());
|
||||
const out = Cesium.Cartesian3.multiplyByScalar(
|
||||
a,
|
||||
sa,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const t = Cesium.Cartesian3.multiplyByScalar(b, sb, new Cesium.Cartesian3());
|
||||
return Cesium.Cartesian3.add(out, t, out);
|
||||
}
|
||||
@@ -165,7 +176,12 @@ function combine2(a, sa, b, sb) {
|
||||
function viewAxesFor(headingDeg, pitchDeg, axes) {
|
||||
const h = toRadians(headingDeg);
|
||||
const p = toRadians(pitchDeg);
|
||||
const forwardHoriz = combine2(axes.east, Math.sin(h), axes.north, Math.cos(h));
|
||||
const forwardHoriz = combine2(
|
||||
axes.east,
|
||||
Math.sin(h),
|
||||
axes.north,
|
||||
Math.cos(h),
|
||||
);
|
||||
const right = combine2(axes.east, Math.cos(h), axes.north, -Math.sin(h));
|
||||
const view = combine2(forwardHoriz, Math.cos(p), axes.up, Math.sin(p));
|
||||
return { forwardHoriz, right, view };
|
||||
@@ -176,7 +192,12 @@ function ringPositions(center, radius, basisA, basisB) {
|
||||
const positions = [];
|
||||
for (let i = 0; i <= RING_SEGMENTS; i++) {
|
||||
const angle = (i / RING_SEGMENTS) * 2 * Math.PI;
|
||||
const offset = combine2(basisA, Math.cos(angle) * radius, basisB, Math.sin(angle) * radius);
|
||||
const offset = combine2(
|
||||
basisA,
|
||||
Math.cos(angle) * radius,
|
||||
basisB,
|
||||
Math.sin(angle) * radius,
|
||||
);
|
||||
positions.push(Cesium.Cartesian3.add(center, offset, offset));
|
||||
}
|
||||
return positions;
|
||||
@@ -202,7 +223,12 @@ function ringPositions(center, radius, basisA, basisB) {
|
||||
* destroy: function(): void, isDragging: function(): boolean,
|
||||
* isEnabled: function(): boolean}}
|
||||
*/
|
||||
export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, endPatch }) {
|
||||
export function createCalibrationGizmo({
|
||||
viewer,
|
||||
getActiveRecord,
|
||||
applyPatch,
|
||||
endPatch,
|
||||
}) {
|
||||
let enabled = false;
|
||||
let drag = null; // { part, startCal, basePose, refs... }
|
||||
let hoveredId = null;
|
||||
@@ -225,20 +251,24 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
|
||||
function polylinePart(part, color, width, arrow = false) {
|
||||
addEntity(part, {
|
||||
...(arrow ? {
|
||||
position: Cesium.Cartesian3.ZERO,
|
||||
point: {
|
||||
pixelSize: 14,
|
||||
color,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.7),
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
} : {}),
|
||||
...(arrow
|
||||
? {
|
||||
position: Cesium.Cartesian3.ZERO,
|
||||
point: {
|
||||
pixelSize: 14,
|
||||
color,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.7),
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
polyline: {
|
||||
positions: [],
|
||||
width,
|
||||
material: arrow ? new Cesium.PolylineArrowMaterialProperty(color) : color.withAlpha(0.9),
|
||||
material: arrow
|
||||
? new Cesium.PolylineArrowMaterialProperty(color)
|
||||
: color.withAlpha(0.9),
|
||||
// Standard gizmo convention: parts behind geometry stay clearly
|
||||
// visible, just dimmed (a street-level mount buries half the heading
|
||||
// ring in sloped photogrammetry tiles — smoke-tested 2026-07-05).
|
||||
@@ -286,16 +316,42 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
}
|
||||
const mount = positions.mount;
|
||||
const axes = enuAxes(mount);
|
||||
const { forwardHoriz, right, view } = viewAxesFor(camera.headingDeg, camera.pitchDeg, axes);
|
||||
const { forwardHoriz, right, view } = viewAxesFor(
|
||||
camera.headingDeg,
|
||||
camera.pitchDeg,
|
||||
axes,
|
||||
);
|
||||
const rangeM = Math.max(1, Number(camera.rangeM) || 1);
|
||||
const radius = Math.min(RING_RADIUS_MAX_M, Math.max(RING_RADIUS_MIN_M, rangeM * RING_RADIUS_FACTOR));
|
||||
const radius = Math.min(
|
||||
RING_RADIUS_MAX_M,
|
||||
Math.max(RING_RADIUS_MIN_M, rangeM * RING_RADIUS_FACTOR),
|
||||
);
|
||||
const arrowLen = radius * ARROW_LENGTH_FACTOR;
|
||||
|
||||
entities.get('ring-heading').polyline.positions = ringPositions(mount, radius, axes.north, axes.east);
|
||||
entities.get('ring-pitch').polyline.positions = ringPositions(mount, radius, forwardHoriz, axes.up);
|
||||
const arrowTo = (dir) => [mount, Cesium.Cartesian3.add(
|
||||
mount, Cesium.Cartesian3.multiplyByScalar(dir, arrowLen, new Cesium.Cartesian3()), new Cesium.Cartesian3()
|
||||
)];
|
||||
entities.get('ring-heading').polyline.positions = ringPositions(
|
||||
mount,
|
||||
radius,
|
||||
axes.north,
|
||||
axes.east,
|
||||
);
|
||||
entities.get('ring-pitch').polyline.positions = ringPositions(
|
||||
mount,
|
||||
radius,
|
||||
forwardHoriz,
|
||||
axes.up,
|
||||
);
|
||||
const arrowTo = (dir) => [
|
||||
mount,
|
||||
Cesium.Cartesian3.add(
|
||||
mount,
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
dir,
|
||||
arrowLen,
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
];
|
||||
const eastArrow = arrowTo(axes.east);
|
||||
const northArrow = arrowTo(axes.north);
|
||||
const upArrow = arrowTo(axes.up);
|
||||
@@ -306,8 +362,16 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
entities.get('move-up').polyline.positions = upArrow;
|
||||
entities.get('move-up').position = upArrow[1];
|
||||
entities.get('handle-range').position = positions.capCenter;
|
||||
entities.get('handle-fov-l').position = Cesium.Cartesian3.midpoint(positions.tl, positions.bl, new Cesium.Cartesian3());
|
||||
entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint(positions.tr, positions.br, new Cesium.Cartesian3());
|
||||
entities.get('handle-fov-l').position = Cesium.Cartesian3.midpoint(
|
||||
positions.tl,
|
||||
positions.bl,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint(
|
||||
positions.tr,
|
||||
positions.br,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
for (const entity of entities.values()) entity.show = true;
|
||||
}
|
||||
|
||||
@@ -343,8 +407,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
return null;
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.__gevGizmoDebug) {
|
||||
console.debug('[CCTV:gizmo] drillPick @', windowPosition?.x, windowPosition?.y, '→',
|
||||
JSON.stringify(results.map((r) => String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name))));
|
||||
console.debug(
|
||||
'[CCTV:gizmo] drillPick @',
|
||||
windowPosition?.x,
|
||||
windowPosition?.y,
|
||||
'→',
|
||||
JSON.stringify(
|
||||
results.map((r) =>
|
||||
String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const picked of results) {
|
||||
const part = gizmoPartFrom(picked);
|
||||
@@ -379,7 +452,8 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
for (const [name, entity] of entities.entries()) {
|
||||
if (!entity.point) continue;
|
||||
const hot = hoveredId === `${GIZMO_ID_PREFIX}${name}`;
|
||||
entity.point.pixelSize = (name === 'handle-range' ? 13 : 10) + (hot ? 4 : 0);
|
||||
entity.point.pixelSize =
|
||||
(name === 'handle-range' ? 13 : 10) + (hot ? 4 : 0);
|
||||
}
|
||||
setCursor(hoveredId ? 'grab' : '');
|
||||
}
|
||||
@@ -397,7 +471,11 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
const mount = Cesium.Cartesian3.clone(positions.mount);
|
||||
const capCenter = Cesium.Cartesian3.clone(positions.capCenter);
|
||||
const axes = enuAxes(mount);
|
||||
const { forwardHoriz, right, view } = viewAxesFor(camera.headingDeg, camera.pitchDeg, axes);
|
||||
const { forwardHoriz, right, view } = viewAxesFor(
|
||||
camera.headingDeg,
|
||||
camera.pitchDeg,
|
||||
axes,
|
||||
);
|
||||
const startCal = { ...camera.calibration };
|
||||
const basePose = { ...camera.basePose };
|
||||
const state = {
|
||||
@@ -405,7 +483,15 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
// mid-drag active-camera switch (voice select / auto-hop) onto a
|
||||
// different camera — its basePose makes the captured offsets wrong.
|
||||
record,
|
||||
part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view,
|
||||
part,
|
||||
startCal,
|
||||
basePose,
|
||||
mount,
|
||||
capCenter,
|
||||
axes,
|
||||
forwardHoriz,
|
||||
right,
|
||||
view,
|
||||
effectiveRangeM: geometry.rangeM,
|
||||
startAngle: null,
|
||||
startParam: null,
|
||||
@@ -419,8 +505,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
|
||||
if (!hit) return false;
|
||||
state.startAngle = ringAngle(hit, mount, forwardHoriz, axes.up); // 0 = level, + = up
|
||||
} else if (part === 'move-east' || part === 'move-north' || part === 'move-up') {
|
||||
const dir = part === 'move-east' ? axes.east : part === 'move-north' ? axes.north : axes.up;
|
||||
} else if (
|
||||
part === 'move-east' ||
|
||||
part === 'move-north' ||
|
||||
part === 'move-up'
|
||||
) {
|
||||
const dir =
|
||||
part === 'move-east'
|
||||
? axes.east
|
||||
: part === 'move-north'
|
||||
? axes.north
|
||||
: axes.up;
|
||||
state.axisDir = dir;
|
||||
const t = closestParamOnAxis(ray.origin, ray.direction, mount, dir);
|
||||
if (t === null) return false;
|
||||
@@ -448,26 +543,49 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
function dragPatch(windowPosition) {
|
||||
const ray = pickRay(windowPosition);
|
||||
if (!ray || !drag) return null;
|
||||
const { part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view } = drag;
|
||||
const {
|
||||
part,
|
||||
startCal,
|
||||
basePose,
|
||||
mount,
|
||||
capCenter,
|
||||
axes,
|
||||
forwardHoriz,
|
||||
right,
|
||||
view,
|
||||
} = drag;
|
||||
|
||||
if (part === 'ring-heading') {
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, axes.up);
|
||||
if (!hit) return null;
|
||||
const angle = ringAngle(hit, mount, axes.north, axes.east);
|
||||
return { headingDeg: startCal.headingDeg + toDeg(signedAngleDelta(drag.startAngle, angle)) };
|
||||
return {
|
||||
headingDeg:
|
||||
startCal.headingDeg + toDeg(signedAngleDelta(drag.startAngle, angle)),
|
||||
};
|
||||
}
|
||||
if (part === 'ring-pitch') {
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
|
||||
if (!hit) return null;
|
||||
const angle = ringAngle(hit, mount, forwardHoriz, axes.up);
|
||||
return { pitchDeg: startCal.pitchDeg + toDeg(signedAngleDelta(drag.startAngle, angle)) };
|
||||
return {
|
||||
pitchDeg:
|
||||
startCal.pitchDeg + toDeg(signedAngleDelta(drag.startAngle, angle)),
|
||||
};
|
||||
}
|
||||
if (part === 'move-east' || part === 'move-north' || part === 'move-up') {
|
||||
const t = closestParamOnAxis(ray.origin, ray.direction, mount, drag.axisDir);
|
||||
const t = closestParamOnAxis(
|
||||
ray.origin,
|
||||
ray.direction,
|
||||
mount,
|
||||
drag.axisDir,
|
||||
);
|
||||
if (t === null) return null;
|
||||
const delta = t - drag.startParam;
|
||||
if (part === 'move-east') return { offsetEastM: startCal.offsetEastM + delta };
|
||||
if (part === 'move-north') return { offsetNorthM: startCal.offsetNorthM + delta };
|
||||
if (part === 'move-east')
|
||||
return { offsetEastM: startCal.offsetEastM + delta };
|
||||
if (part === 'move-north')
|
||||
return { offsetNorthM: startCal.offsetNorthM + delta };
|
||||
return { heightM: startCal.heightM + delta };
|
||||
}
|
||||
if (part === 'handle-range') {
|
||||
@@ -478,11 +596,16 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
if (part === 'handle-fov-l' || part === 'handle-fov-r') {
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, capCenter, view);
|
||||
if (!hit) return null;
|
||||
const lateral = Math.abs(Cesium.Cartesian3.dot(
|
||||
Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()), right
|
||||
));
|
||||
const lateral = Math.abs(
|
||||
Cesium.Cartesian3.dot(
|
||||
Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()),
|
||||
right,
|
||||
),
|
||||
);
|
||||
const halfW = Math.max(0.5, lateral);
|
||||
const fovDeg = toDeg(2 * Math.atan(halfW / Math.max(1, drag.effectiveRangeM)));
|
||||
const fovDeg = toDeg(
|
||||
2 * Math.atan(halfW / Math.max(1, drag.effectiveRangeM)),
|
||||
);
|
||||
return { fovDeg: fovDeg - basePose.fovDeg };
|
||||
}
|
||||
return null;
|
||||
@@ -531,7 +654,13 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
return;
|
||||
}
|
||||
const patch = dragPatch(event.endPosition);
|
||||
debugLog('dragPatch', drag.part, event.endPosition, '→', patch ? JSON.stringify(patch) : null);
|
||||
debugLog(
|
||||
'dragPatch',
|
||||
drag.part,
|
||||
event.endPosition,
|
||||
'→',
|
||||
patch ? JSON.stringify(patch) : null,
|
||||
);
|
||||
if (patch) applyPatch(patch, drag.record);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function cameraHue(index) {
|
||||
* @returns {{fill: Cesium.Color, fillActive: Cesium.Color, line: Cesium.Color, lineActive: Cesium.Color}}
|
||||
*/
|
||||
export function viewshedColors(hueDeg) {
|
||||
const hue = ((Number(hueDeg) % 360) + 360) % 360 / 360;
|
||||
const hue = (((Number(hueDeg) % 360) + 360) % 360) / 360;
|
||||
return {
|
||||
fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
|
||||
fillActive: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE),
|
||||
@@ -66,7 +66,13 @@ export function viewshedColors(hueDeg) {
|
||||
* @returns {{positions: Float64Array, indices: Uint16Array}}
|
||||
*/
|
||||
export function frustumVolumeGeometryData(positions) {
|
||||
const pts = [positions.mount, positions.tl, positions.tr, positions.br, positions.bl];
|
||||
const pts = [
|
||||
positions.mount,
|
||||
positions.tl,
|
||||
positions.tr,
|
||||
positions.br,
|
||||
positions.bl,
|
||||
];
|
||||
const flat = new Float64Array(15);
|
||||
pts.forEach((p, i) => {
|
||||
flat[i * 3] = p.x;
|
||||
@@ -75,12 +81,7 @@ export function frustumVolumeGeometryData(positions) {
|
||||
});
|
||||
// apex=0, tl=1, tr=2, br=3, bl=4 — 4 side faces + far cap (2 triangles).
|
||||
const indices = new Uint16Array([
|
||||
0, 1, 2,
|
||||
0, 2, 3,
|
||||
0, 3, 4,
|
||||
0, 4, 1,
|
||||
1, 2, 3,
|
||||
1, 3, 4,
|
||||
0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1, 1, 2, 3, 1, 3, 4,
|
||||
]);
|
||||
return { positions: flat, indices };
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function nextCockpitNearContacts(
|
||||
) {
|
||||
const previous = previousIds instanceof Set ? previousIds : new Set();
|
||||
const addSq = Math.max(0, Number(addDistanceM) || 0) ** 2;
|
||||
const keepSq = Math.max(addSq, (Math.max(0, Number(keepDistanceM) || 0) ** 2));
|
||||
const keepSq = Math.max(addSq, Math.max(0, Number(keepDistanceM) || 0) ** 2);
|
||||
const next = new Set();
|
||||
|
||||
for (const [id, distanceSq] of distancesSquared || []) {
|
||||
@@ -32,4 +32,3 @@ export function nextCockpitNearContacts(
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,9 @@ export const CONTACT_MATCH_TIER = Object.freeze({
|
||||
* @returns {string} Canonical form, or '' when there is nothing to compare.
|
||||
*/
|
||||
export function canonicalizeContactId(value) {
|
||||
return String(value ?? '').replace(/[^a-z0-9]/gi, '').toUpperCase();
|
||||
return String(value ?? '')
|
||||
.replace(/[^a-z0-9]/gi, '')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,16 +59,31 @@ export function canonicalizeContactId(value) {
|
||||
* @param {string} [params.registration] Contact's registration, if any.
|
||||
* @returns {number} A {@link CONTACT_MATCH_TIER} value; `NONE` when no match.
|
||||
*/
|
||||
export function rankContactMatch({ query, hex = '', callsign = '', registration = '' }) {
|
||||
const rawQuery = String(query ?? '').trim().toLowerCase();
|
||||
export function rankContactMatch({
|
||||
query,
|
||||
hex = '',
|
||||
callsign = '',
|
||||
registration = '',
|
||||
}) {
|
||||
const rawQuery = String(query ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!rawQuery) return CONTACT_MATCH_TIER.NONE;
|
||||
|
||||
if (String(hex ?? '').trim().toLowerCase() === rawQuery) return CONTACT_MATCH_TIER.HEX_EXACT;
|
||||
if (
|
||||
String(hex ?? '')
|
||||
.trim()
|
||||
.toLowerCase() === rawQuery
|
||||
)
|
||||
return CONTACT_MATCH_TIER.HEX_EXACT;
|
||||
|
||||
const callsignText = String(callsign ?? '').trim().toLowerCase();
|
||||
const callsignText = String(callsign ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
// Callsigns carry no separators, so they compare raw — canonicalizing them
|
||||
// would silently widen the long-standing callsign contract.
|
||||
if (callsignText && callsignText === rawQuery) return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
|
||||
if (callsignText && callsignText === rawQuery)
|
||||
return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
|
||||
|
||||
const canonicalQuery = canonicalizeContactId(rawQuery);
|
||||
const canonicalRegistration = canonicalizeContactId(registration);
|
||||
@@ -74,11 +91,13 @@ export function rankContactMatch({ query, hex = '', callsign = '', registration
|
||||
return CONTACT_MATCH_TIER.REGISTRATION_EXACT;
|
||||
}
|
||||
|
||||
if (callsignText && callsignText.startsWith(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
|
||||
if (callsignText && callsignText.startsWith(rawQuery))
|
||||
return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
|
||||
if (canonicalQuery && canonicalRegistration.startsWith(canonicalQuery)) {
|
||||
return CONTACT_MATCH_TIER.REGISTRATION_PREFIX;
|
||||
}
|
||||
if (callsignText && callsignText.includes(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
|
||||
if (callsignText && callsignText.includes(rawQuery))
|
||||
return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
|
||||
if (canonicalQuery && canonicalRegistration.includes(canonicalQuery)) {
|
||||
return CONTACT_MATCH_TIER.REGISTRATION_SUBSTRING;
|
||||
}
|
||||
|
||||
+23
-11
@@ -48,7 +48,9 @@ export function selectEntityContext(entity) {
|
||||
store.selectedEntityId = contextId;
|
||||
store.selectedAt = Date.now();
|
||||
const record = store.entities.get(contextId);
|
||||
window.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: record }));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:entity-selected', { detail: record }),
|
||||
);
|
||||
return record;
|
||||
}
|
||||
|
||||
@@ -78,7 +80,8 @@ export function selectTrackedSubjectContext(metadata) {
|
||||
const store = getContextStore();
|
||||
const id = String(metadata.id);
|
||||
for (const [key, record] of store.entities) {
|
||||
if (record?.layerId === metadata.layerId && key !== id) store.entities.delete(key);
|
||||
if (record?.layerId === metadata.layerId && key !== id)
|
||||
store.entities.delete(key);
|
||||
}
|
||||
// Reuse the existing carrier so a per-poll refresh does not churn identity.
|
||||
const carrier = store.entities.get(id)?.entity || { __gevContextId: id };
|
||||
@@ -146,16 +149,21 @@ export function getSelectedEntityContext({ dataManager = null } = {}) {
|
||||
* rather than being deselected. Readouts that stay on screen hold their
|
||||
* last-known values for an eviction and only tear down on a deliberate clear.
|
||||
*/
|
||||
export function clearSelectedEntityContextForLayer(layerId, { evicted = false } = {}) {
|
||||
export function clearSelectedEntityContextForLayer(
|
||||
layerId,
|
||||
{ evicted = false } = {},
|
||||
) {
|
||||
const store = getContextStore();
|
||||
if (!store.selectedEntityId) return;
|
||||
const record = store.entities.get(store.selectedEntityId);
|
||||
if (record?.layerId === layerId) {
|
||||
store.selectedEntityId = null;
|
||||
store.selectedAt = null;
|
||||
window.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' },
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,16 +174,19 @@ export function clearSelectedEntityContextForLayer(layerId, { evicted = false }
|
||||
export function removeEntityContextsForLayer(layerId, { retainIds } = {}) {
|
||||
const store = getContextStore();
|
||||
for (const [id, record] of store.entities) {
|
||||
if (record?.layerId === layerId && !retainIds?.has(id)) store.entities.delete(id);
|
||||
if (record?.layerId === layerId && !retainIds?.has(id))
|
||||
store.entities.delete(id);
|
||||
}
|
||||
if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) {
|
||||
store.selectedEntityId = null;
|
||||
store.selectedAt = null;
|
||||
// A viewport refresh dropped the record out from under the selection —
|
||||
// the user did not deselect anything.
|
||||
window.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: 'evicted' },
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: 'evicted' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +194,7 @@ export function isContextRecordActive(record, dataManager = null) {
|
||||
if (!record) return false;
|
||||
if (record.entity?.show === false) return false;
|
||||
if (record.dataSource && record.dataSource.show === false) return false;
|
||||
if (dataManager && record.layerId && !dataManager.isEnabled(record.layerId)) return false;
|
||||
if (dataManager && record.layerId && !dataManager.isEnabled(record.layerId))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
@@ -185,8 +185,7 @@ export const DATA_CREDITS = [
|
||||
},
|
||||
{
|
||||
key: 'gtfs-rt',
|
||||
html:
|
||||
'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
|
||||
html: 'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
|
||||
},
|
||||
{
|
||||
key: 'radio-browser',
|
||||
@@ -299,11 +298,12 @@ export const NATURAL_EARTH_CREDIT = {
|
||||
* @returns {{ key: string, html: string }}
|
||||
*/
|
||||
export function transitFeedCredit(feed) {
|
||||
const escape = (text) => String(text)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
const escape = (text) =>
|
||||
String(text)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
return {
|
||||
key: `transit-${feed.id}`,
|
||||
html:
|
||||
|
||||
+219
-70
@@ -279,7 +279,10 @@ let _cockpitModeListener = null;
|
||||
*/
|
||||
export function initDetection(viewer, layers, onModeChange) {
|
||||
if (_cockpitModeListener && typeof window !== 'undefined') {
|
||||
window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener);
|
||||
window.removeEventListener(
|
||||
'gev:cockpit-mode-changed',
|
||||
_cockpitModeListener,
|
||||
);
|
||||
}
|
||||
_hostLane?.unregister?.();
|
||||
_calloutLane?.unregister?.();
|
||||
@@ -323,7 +326,10 @@ export function initDetection(viewer, layers, onModeChange) {
|
||||
/** Release the host lane and all retained detection runtime state. */
|
||||
export function destroyDetection() {
|
||||
if (_cockpitModeListener && typeof window !== 'undefined') {
|
||||
window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener);
|
||||
window.removeEventListener(
|
||||
'gev:cockpit-mode-changed',
|
||||
_cockpitModeListener,
|
||||
);
|
||||
}
|
||||
_cockpitModeListener = null;
|
||||
_cockpitActive = false;
|
||||
@@ -397,7 +403,8 @@ export function setMode(modeLabel) {
|
||||
_mode = MODE_OFF;
|
||||
} else {
|
||||
const requestedDensity = defaultDensityForProfile(target);
|
||||
if (profileForDensity(_densityPct) !== target) _densityPct = requestedDensity;
|
||||
if (profileForDensity(_densityPct) !== target)
|
||||
_densityPct = requestedDensity;
|
||||
_mode = _modeForDensity();
|
||||
_lastNonOffMode = _mode;
|
||||
}
|
||||
@@ -462,7 +469,10 @@ export function isDetectionSuspended() {
|
||||
*/
|
||||
export function setDetectionTuning(options = {}) {
|
||||
let modeChanged = false;
|
||||
if (typeof options.densityPct === 'number' && Number.isFinite(options.densityPct)) {
|
||||
if (
|
||||
typeof options.densityPct === 'number' &&
|
||||
Number.isFinite(options.densityPct)
|
||||
) {
|
||||
const nextDensity = canonicalizeDensity(options.densityPct);
|
||||
if (nextDensity !== _densityPct) {
|
||||
_densityPct = nextDensity;
|
||||
@@ -477,7 +487,10 @@ export function setDetectionTuning(options = {}) {
|
||||
}
|
||||
}
|
||||
if (options.allocationStrategy != null) {
|
||||
const nextStrategy = normalizeAllocationStrategy(options.allocationStrategy, _allocationStrategy);
|
||||
const nextStrategy = normalizeAllocationStrategy(
|
||||
options.allocationStrategy,
|
||||
_allocationStrategy,
|
||||
);
|
||||
if (nextStrategy !== _allocationStrategy) {
|
||||
_allocationStrategy = nextStrategy;
|
||||
_labelSolveDirty = true;
|
||||
@@ -526,7 +539,9 @@ export function getDetectionDiagnostics() {
|
||||
const result = JSON.parse(JSON.stringify(_lastDiagnostics));
|
||||
// Readback only: expose painted plates so pixel QA can exclude occluded
|
||||
// sprites/strokes. Nothing is copied on the production paint path.
|
||||
result.calloutRects = _calloutPool.slice(0, _calloutCount).map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
|
||||
result.calloutRects = _calloutPool
|
||||
.slice(0, _calloutCount)
|
||||
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -535,19 +550,30 @@ function _publishDiagnostics() {
|
||||
const dataset = _hostCanvas.dataset;
|
||||
dataset.profile = _lastDiagnostics.profile || 'OFF';
|
||||
dataset.densityPct = String(_lastDiagnostics.densityPct ?? _densityPct);
|
||||
dataset.allocationStrategy = _lastDiagnostics.allocationStrategy || _allocationStrategy;
|
||||
dataset.allocationStrategy =
|
||||
_lastDiagnostics.allocationStrategy || _allocationStrategy;
|
||||
dataset.solveRevision = String(_lastDiagnostics.solveRevision ?? 0);
|
||||
dataset.collectiveLabelBudget = String(_lastDiagnostics.collectiveLabelBudget ?? 0);
|
||||
dataset.collectiveLabelBudget = String(
|
||||
_lastDiagnostics.collectiveLabelBudget ?? 0,
|
||||
);
|
||||
dataset.labelsByLayer = JSON.stringify(_lastDiagnostics.labelsByLayer || {});
|
||||
dataset.entitlementByLayer = JSON.stringify(_lastDiagnostics.entitlementByLayer || {});
|
||||
dataset.entitlementByLayer = JSON.stringify(
|
||||
_lastDiagnostics.entitlementByLayer || {},
|
||||
);
|
||||
dataset.labeledKeys = JSON.stringify(_lastDiagnostics.labeledKeys || []);
|
||||
dataset.labelAlphaByKey = JSON.stringify(_lastDiagnostics.labelAlphaByKey || {});
|
||||
dataset.bracketOpacityCounts = JSON.stringify(_lastDiagnostics.bracketOpacityCounts || {});
|
||||
dataset.labelAlphaByKey = JSON.stringify(
|
||||
_lastDiagnostics.labelAlphaByKey || {},
|
||||
);
|
||||
dataset.bracketOpacityCounts = JSON.stringify(
|
||||
_lastDiagnostics.bracketOpacityCounts || {},
|
||||
);
|
||||
dataset.keyholeRadius = String(_lastDiagnostics.keyholeRadius ?? 0);
|
||||
dataset.keyholeFeatherPx = String(_lastDiagnostics.keyholeFeatherPx ?? 0);
|
||||
dataset.demandByLayer = JSON.stringify(_lastDiagnostics.demandByLayer || {});
|
||||
dataset.cohortByLayer = JSON.stringify(_lastDiagnostics.cohortByLayer || {});
|
||||
dataset.placementBuildCount = String(_lastDiagnostics.placementBuildCount ?? 0);
|
||||
dataset.placementBuildCount = String(
|
||||
_lastDiagnostics.placementBuildCount ?? 0,
|
||||
);
|
||||
dataset.observationCount = String(_lastDiagnostics.observationCount ?? 0);
|
||||
dataset.selectedCount = String(_lastDiagnostics.selectedCount ?? 0);
|
||||
dataset.fadingCount = String(_lastDiagnostics.fadingCount ?? 0);
|
||||
@@ -632,7 +658,11 @@ function _applyModeState() {
|
||||
_labelArbiter.clear();
|
||||
_lastLabelSolveAt = 0;
|
||||
_labelSolveDirty = true;
|
||||
_lastSolveSnapshot = { demandByLayer: {}, cohortByLayer: {}, cohortCount: 0 };
|
||||
_lastSolveSnapshot = {
|
||||
demandByLayer: {},
|
||||
cohortByLayer: {},
|
||||
cohortCount: 0,
|
||||
};
|
||||
} else {
|
||||
_enableTime = _nowMs(); // restart the subtle fade-in on (re)activation
|
||||
_syncSurfaceVisibility();
|
||||
@@ -665,8 +695,10 @@ function _shouldPaintDetectionLane(frame) {
|
||||
});
|
||||
if (decision.skip) {
|
||||
_throttleSkipCount++;
|
||||
if (_lastDiagnostics) _lastDiagnostics.throttleSkipCount = _throttleSkipCount;
|
||||
if (decision.requestFollowUp) governorRequestRender('detection-paint-skipped');
|
||||
if (_lastDiagnostics)
|
||||
_lastDiagnostics.throttleSkipCount = _throttleSkipCount;
|
||||
if (decision.requestFollowUp)
|
||||
governorRequestRender('detection-paint-skipped');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -687,8 +719,13 @@ function _paintDetectionLane(frame) {
|
||||
_labelSolveDirty = true;
|
||||
}
|
||||
const start = performance.now();
|
||||
const result = _drawOverlay(frame)
|
||||
|| { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
|
||||
const result = _drawOverlay(frame) || {
|
||||
didSolve: false,
|
||||
solveMs: 0,
|
||||
fadingCount: 0,
|
||||
animatingCount: 0,
|
||||
solvePending: false,
|
||||
};
|
||||
_lastRenderMs = performance.now() - start;
|
||||
_lastSolveMs = result.solveMs || 0;
|
||||
_lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs);
|
||||
@@ -705,17 +742,19 @@ function _paintDetectionLane(frame) {
|
||||
// exactly what makes it safe where the old blanket hold was not. On a parked
|
||||
// scene with nothing animating it asks for nothing, and the governor stays
|
||||
// idle. See `detectionRenderDemand.js`.
|
||||
if (detectionNeedsFollowUpFrame({
|
||||
active: _mode !== MODE_OFF && !_suspended,
|
||||
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here
|
||||
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at
|
||||
// 220 ms, and the settled alpha never painted).
|
||||
nowMs: Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(),
|
||||
enabledAtMs: _enableTime,
|
||||
fadeMs: FADE_MS,
|
||||
animatingLabelCount: result.animatingCount || 0,
|
||||
solvePending: result.solvePending === true,
|
||||
})) {
|
||||
if (
|
||||
detectionNeedsFollowUpFrame({
|
||||
active: _mode !== MODE_OFF && !_suspended,
|
||||
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here
|
||||
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at
|
||||
// 220 ms, and the settled alpha never painted).
|
||||
nowMs: Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(),
|
||||
enabledAtMs: _enableTime,
|
||||
fadeMs: FADE_MS,
|
||||
animatingLabelCount: result.animatingCount || 0,
|
||||
solvePending: result.solvePending === true,
|
||||
})
|
||||
) {
|
||||
governorRequestRender('detection-animation');
|
||||
}
|
||||
}
|
||||
@@ -804,7 +843,8 @@ function _drawModeBanner(visibleCount, sampledCount) {
|
||||
if (!_debugBanner) return;
|
||||
const mode = MODE_LABELS[_mode];
|
||||
const paused = _suspended ? ` PAUSED:${_suspendReason || 'transition'}` : '';
|
||||
const allocation = _allocationStrategy === ALLOCATION_ELASTIC ? 'ELASTIC' : 'WEIGHTED';
|
||||
const allocation =
|
||||
_allocationStrategy === ALLOCATION_ELASTIC ? 'ELASTIC' : 'WEIGHTED';
|
||||
const text = `${mode} VIS:${visibleCount} SRC:${sampledCount} DENS:${_densityPct}% ${allocation} ${_lastRenderMs.toFixed(1)}ms${paused}`;
|
||||
_ctx.fillStyle = _theme.labelBg;
|
||||
_ctx.fillRect(12, 12, Math.max(180, _ctx.measureText(text).width + 12), 16);
|
||||
@@ -900,16 +940,27 @@ function _buildLabelPlacements(
|
||||
const placements = [];
|
||||
for (const placement of raw) {
|
||||
const { cardX, cardY } = placement;
|
||||
if (cardX < margin || cardY < margin || cardX + card.w > width - margin || cardY + card.h > height - margin) {
|
||||
if (
|
||||
cardX < margin ||
|
||||
cardY < margin ||
|
||||
cardX + card.w > width - margin ||
|
||||
cardY + card.h > height - margin
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const cardRect = { x: cardX, y: cardY, w: card.w, h: card.h };
|
||||
if (rectIntersectsAny(cardRect, occlusionRects)) continue;
|
||||
const leadToX = placement.leadToSide.endsWith('E') ? cardX + card.w : cardX;
|
||||
const leadToY = placement.leadToSide.startsWith('S') ? cardY + card.h : cardY;
|
||||
const leadToY = placement.leadToSide.startsWith('S')
|
||||
? cardY + card.h
|
||||
: cardY;
|
||||
const centerX = cardX + card.w * 0.5;
|
||||
const centerY = cardY + card.h * 0.5;
|
||||
const radialAlpha = keyholeLabelAlphaFromGeometry(centerX, centerY, keyhole);
|
||||
const radialAlpha = keyholeLabelAlphaFromGeometry(
|
||||
centerX,
|
||||
centerY,
|
||||
keyhole,
|
||||
);
|
||||
if (radialAlpha <= 0) continue;
|
||||
placements.push({
|
||||
...placement,
|
||||
@@ -940,18 +991,37 @@ function _buildLabelPlacements(
|
||||
*/
|
||||
function _stashCallout(entry, acquireFade, keyhole) {
|
||||
const { candidate, placement, temporalAlpha } = entry;
|
||||
const radialAlpha = keyholeLabelAlphaFromGeometry(placement.centerX, placement.centerY, keyhole);
|
||||
const radialAlpha = keyholeLabelAlphaFromGeometry(
|
||||
placement.centerX,
|
||||
placement.centerY,
|
||||
keyhole,
|
||||
);
|
||||
const alpha = acquireFade * temporalAlpha * radialAlpha;
|
||||
if (alpha <= 0.001) return;
|
||||
|
||||
let row = _calloutPool[_calloutCount];
|
||||
if (!row) {
|
||||
row = {
|
||||
x: 0, y: 0, w: 0, h: 0,
|
||||
primaryX: 0, microX: 0, baseline: 0,
|
||||
leadFromX: 0, leadFromY: 0, leadToX: 0, leadToY: 0,
|
||||
plate: '', plateScale: 1, accent: '', label: '', primary: '', micro: '',
|
||||
font: FONT, microFont: MICRO_FONT, alpha: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 0,
|
||||
h: 0,
|
||||
primaryX: 0,
|
||||
microX: 0,
|
||||
baseline: 0,
|
||||
leadFromX: 0,
|
||||
leadFromY: 0,
|
||||
leadToX: 0,
|
||||
leadToY: 0,
|
||||
plate: '',
|
||||
plateScale: 1,
|
||||
accent: '',
|
||||
label: '',
|
||||
primary: '',
|
||||
micro: '',
|
||||
font: FONT,
|
||||
microFont: MICRO_FONT,
|
||||
alpha: 1,
|
||||
};
|
||||
_calloutPool[_calloutCount] = row;
|
||||
}
|
||||
@@ -996,7 +1066,13 @@ function _paintCalloutLane(frame) {
|
||||
if (!ctx) return;
|
||||
for (const bands of _transitBracketPaths.values()) {
|
||||
for (const entry of bands) {
|
||||
if (entry) paintTransitBracket(ctx, entry.path, entry.color, entry.alpha * _transitBracketAlpha);
|
||||
if (entry)
|
||||
paintTransitBracket(
|
||||
ctx,
|
||||
entry.path,
|
||||
entry.color,
|
||||
entry.alpha * _transitBracketAlpha,
|
||||
);
|
||||
}
|
||||
}
|
||||
ctx.textAlign = 'left';
|
||||
@@ -1021,7 +1097,14 @@ function _plateScaleForBackdrop(skyFactor) {
|
||||
}
|
||||
|
||||
/** Materialize one bounded rich-callout candidate for the existing arbiter. */
|
||||
function _materializeCandidate(obj, width, height, keyhole, occlusionRects, cameraPosition) {
|
||||
function _materializeCandidate(
|
||||
obj,
|
||||
width,
|
||||
height,
|
||||
keyhole,
|
||||
occlusionRects,
|
||||
cameraPosition,
|
||||
) {
|
||||
const primary = obj._candidatePrimary;
|
||||
const micro = obj._candidateMicro;
|
||||
const card = measureTrackLabel(primary, micro, _charWidth);
|
||||
@@ -1042,7 +1125,10 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
|
||||
for (const placement of placements) {
|
||||
centerDistance = Math.min(
|
||||
centerDistance,
|
||||
Math.hypot(placement.centerX - keyhole.centerX, placement.centerY - keyhole.centerY),
|
||||
Math.hypot(
|
||||
placement.centerX - keyhole.centerX,
|
||||
placement.centerY - keyhole.centerY,
|
||||
),
|
||||
);
|
||||
keyholeAlpha = Math.max(keyholeAlpha, placement.keyholeAlpha);
|
||||
}
|
||||
@@ -1063,7 +1149,9 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
|
||||
// Costed here rather than in the object sweep: the sweep walks every
|
||||
// observation in view, while this runs only for callouts that actually
|
||||
// placed — a budgeted handful per frame.
|
||||
plateScale: _plateScaleForBackdrop(skyBackdropFactor(cameraPosition, obj.position)),
|
||||
plateScale: _plateScaleForBackdrop(
|
||||
skyBackdropFactor(cameraPosition, obj.position),
|
||||
),
|
||||
primary,
|
||||
micro,
|
||||
hasMicro: card.hasMicro,
|
||||
@@ -1079,7 +1167,13 @@ function _drawOverlay(frame) {
|
||||
_transitBracketPaths.clear();
|
||||
const { width, height } = frame;
|
||||
if (!_ctx || width <= 0 || height <= 0) {
|
||||
return { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
|
||||
return {
|
||||
didSolve: false,
|
||||
solveMs: 0,
|
||||
fadingCount: 0,
|
||||
animatingCount: 0,
|
||||
solvePending: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ONE timestamp per frame, monotonic, shared with the demand policy below and
|
||||
@@ -1087,12 +1181,16 @@ function _drawOverlay(frame) {
|
||||
// animation's final state is the same frame that ends its demand.
|
||||
const now = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
|
||||
const bracketPresentationOpacity = detectionBracketOpacity(_cockpitActive);
|
||||
const shouldSolve = _labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
|
||||
const shouldSolve =
|
||||
_labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
|
||||
const calloutOcclusionRects = frame.uiRects;
|
||||
const selectedIdentities = shouldSolve
|
||||
? _labelArbiter.liveIdentities({ includeFading: false, now })
|
||||
: null;
|
||||
const renderIdentities = _labelArbiter.liveIdentities({ includeFading: true, now });
|
||||
const renderIdentities = _labelArbiter.liveIdentities({
|
||||
includeFading: true,
|
||||
now,
|
||||
});
|
||||
const objects = _collectDetectableObjects();
|
||||
const sampledCount = objects.length;
|
||||
if (objects.length === 0) {
|
||||
@@ -1116,7 +1214,9 @@ function _drawOverlay(frame) {
|
||||
profile: MODE_LABELS[_mode],
|
||||
densityPct: _densityPct,
|
||||
allocationStrategy: _allocationStrategy,
|
||||
viewScale: viewScaleForAltitude(_viewer?.camera?.positionCartographic?.height),
|
||||
viewScale: viewScaleForAltitude(
|
||||
_viewer?.camera?.positionCartographic?.height,
|
||||
),
|
||||
candidateCount: 0,
|
||||
observationCount: 0,
|
||||
visibleCount: 0,
|
||||
@@ -1144,7 +1244,13 @@ function _drawOverlay(frame) {
|
||||
// whole mechanism exists to remove.
|
||||
_lastLabelSolveAt = now;
|
||||
_labelSolveDirty = false;
|
||||
return { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
|
||||
return {
|
||||
didSolve: false,
|
||||
solveMs: 0,
|
||||
fadingCount: 0,
|
||||
animatingCount: 0,
|
||||
solvePending: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Horizon culling, keyhole geometry, and camera transforms are shared with
|
||||
@@ -1179,7 +1285,10 @@ function _drawOverlay(frame) {
|
||||
const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
|
||||
const bracketPaths = new Map();
|
||||
const pathFor = (map, color, alpha) => {
|
||||
const band = Math.max(1, Math.min(BRACKET_ALPHA_STEPS, Math.ceil(alpha * BRACKET_ALPHA_STEPS)));
|
||||
const band = Math.max(
|
||||
1,
|
||||
Math.min(BRACKET_ALPHA_STEPS, Math.ceil(alpha * BRACKET_ALPHA_STEPS)),
|
||||
);
|
||||
let bands = map.get(color);
|
||||
if (!bands) {
|
||||
bands = new Array(BRACKET_ALPHA_STEPS + 1);
|
||||
@@ -1217,8 +1326,10 @@ function _drawOverlay(frame) {
|
||||
const clipW = vp3 * px + vp7 * py + vp11 * pz + vp15;
|
||||
if (clipW <= 0) continue;
|
||||
const invW = 1 / clipW;
|
||||
const sx = ((vp0 * px + vp4 * py + vp8 * pz + vp12) * invW * 0.5 + 0.5) * width;
|
||||
const sy = (0.5 - (vp1 * px + vp5 * py + vp9 * pz + vp13) * invW * 0.5) * height;
|
||||
const sx =
|
||||
((vp0 * px + vp4 * py + vp8 * pz + vp12) * invW * 0.5 + 0.5) * width;
|
||||
const sy =
|
||||
(0.5 - (vp1 * px + vp5 * py + vp9 * pz + vp13) * invW * 0.5) * height;
|
||||
|
||||
// Tracked objects (skipLabel) get larger boxes. AIR reticles scale with the
|
||||
// plane's on-screen size (same scaleByDistance curve as the billboards) so
|
||||
@@ -1229,7 +1340,10 @@ function _drawOverlay(frame) {
|
||||
if (obj.type === 'AIR') {
|
||||
const bscale = nearFarScale(
|
||||
Cesium.Cartesian3.distance(camPos, obj.position),
|
||||
BILL_NEAR, BILL_NEAR_SCALE, BILL_FAR, BILL_FAR_SCALE,
|
||||
BILL_NEAR,
|
||||
BILL_NEAR_SCALE,
|
||||
BILL_FAR,
|
||||
BILL_FAR_SCALE,
|
||||
);
|
||||
halfW = _clamp((isTracked ? 14 : 9) * bscale, 7, 48);
|
||||
halfH = _clamp((isTracked ? 11 : 7) * bscale, 5, 38);
|
||||
@@ -1241,18 +1355,34 @@ function _drawOverlay(frame) {
|
||||
halfH = _mode === MODE_DENSE ? (isTracked ? 22 : 7) : 10;
|
||||
}
|
||||
// Viewport bounds check with padding
|
||||
if (sx < -halfW || sx > width + halfW || sy < -halfH || sy > height + halfH) continue;
|
||||
if (sx < -halfW || sx > width + halfW || sy < -halfH || sy > height + halfH)
|
||||
continue;
|
||||
|
||||
// Tier color drives the bracket, accent bar, and leader line for this object.
|
||||
// The bracket follows the same linear radial keyhole fade as its callout.
|
||||
const color = colorFor(resolveTier(obj));
|
||||
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
|
||||
const bracketAlpha = detectionBracketAlpha(obj.type, keyholeAlpha, keyholeOutsideOpacity);
|
||||
const bracketAlpha = detectionBracketAlpha(
|
||||
obj.type,
|
||||
keyholeAlpha,
|
||||
keyholeOutsideOpacity,
|
||||
);
|
||||
if (bracketAlpha > 0) {
|
||||
const transit = obj.tier?.startsWith('transit_');
|
||||
(transit ? appendTransitBracket : appendCornerBracket)(pathFor(transit ? _transitBracketPaths : bracketPaths, color, bracketAlpha), sx, sy, halfW, halfH);
|
||||
(transit ? appendTransitBracket : appendCornerBracket)(
|
||||
pathFor(
|
||||
transit ? _transitBracketPaths : bracketPaths,
|
||||
color,
|
||||
bracketAlpha,
|
||||
),
|
||||
sx,
|
||||
sy,
|
||||
halfW,
|
||||
halfH,
|
||||
);
|
||||
visibleCount++;
|
||||
if (obj.type === 'AIR') aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
|
||||
if (obj.type === 'AIR')
|
||||
aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
|
||||
if (bracketAlpha >= 1) bracketOpacityCounts.full++;
|
||||
else bracketOpacityCounts.partial++;
|
||||
} else {
|
||||
@@ -1286,8 +1416,10 @@ function _drawOverlay(frame) {
|
||||
|
||||
if (shouldSolve && keyholeAlpha > 0) {
|
||||
demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1);
|
||||
if (!cohortBuilders.has(layerId)) cohortBuilders.set(layerId, new BoundedCohort(256));
|
||||
const incumbent = selectedIdentities?.get(layerId)?.has(sourceId) || false;
|
||||
if (!cohortBuilders.has(layerId))
|
||||
cohortBuilders.set(layerId, new BoundedCohort(256));
|
||||
const incumbent =
|
||||
selectedIdentities?.get(layerId)?.has(sourceId) || false;
|
||||
cohortBuilders.get(layerId).consider(obj, incumbent);
|
||||
}
|
||||
|
||||
@@ -1301,13 +1433,17 @@ function _drawOverlay(frame) {
|
||||
calloutOcclusionRects,
|
||||
camPos,
|
||||
);
|
||||
if (candidate && !candidateMap.has(candidate.key)) candidateMap.set(candidate.key, candidate);
|
||||
if (candidate && !candidateMap.has(candidate.key))
|
||||
candidateMap.set(candidate.key, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
|
||||
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
|
||||
const ambientBudget = Math.max(0, collectiveBudget - Math.min(collectiveBudget, protectedVisibleCount));
|
||||
const ambientBudget = Math.max(
|
||||
0,
|
||||
collectiveBudget - Math.min(collectiveBudget, protectedVisibleCount),
|
||||
);
|
||||
let didSolve = false;
|
||||
let solveMs = 0;
|
||||
if (shouldSolve) {
|
||||
@@ -1320,7 +1456,9 @@ function _drawOverlay(frame) {
|
||||
const solveCandidates = [];
|
||||
const cohortByLayer = {};
|
||||
for (const [layerId, builder] of cohortBuilders) {
|
||||
const cohort = builder.values(cohortCapForQuota(quotas.get(layerId) || 0));
|
||||
const cohort = builder.values(
|
||||
cohortCapForQuota(quotas.get(layerId) || 0),
|
||||
);
|
||||
for (const obj of cohort) {
|
||||
const key = _detectionKey(layerId, obj._cohortSourceId);
|
||||
let candidate = candidateMap.get(key);
|
||||
@@ -1338,7 +1476,9 @@ function _drawOverlay(frame) {
|
||||
}
|
||||
if (candidate) solveCandidates.push(candidate);
|
||||
}
|
||||
cohortByLayer[layerId] = solveCandidates.filter((candidate) => candidate.layerId === layerId).length;
|
||||
cohortByLayer[layerId] = solveCandidates.filter(
|
||||
(candidate) => candidate.layerId === layerId,
|
||||
).length;
|
||||
}
|
||||
const solveStarted = performance.now();
|
||||
_labelArbiter.solve(solveCandidates, {
|
||||
@@ -1419,14 +1559,17 @@ function _drawOverlay(frame) {
|
||||
labelsByLayer: arbiterDiagnostics.labelsByLayer || {},
|
||||
entitlementByLayer: arbiterDiagnostics.quotas || {},
|
||||
labeledKeys: Array.from(_labelArbiter.selectedKeys),
|
||||
labelAlphaByKey: Object.fromEntries(renderEntries.map((entry) => [
|
||||
entry.candidate.key,
|
||||
entry.temporalAlpha * keyholeLabelAlphaFromGeometry(
|
||||
entry.placement.centerX,
|
||||
entry.placement.centerY,
|
||||
keyhole,
|
||||
),
|
||||
])),
|
||||
labelAlphaByKey: Object.fromEntries(
|
||||
renderEntries.map((entry) => [
|
||||
entry.candidate.key,
|
||||
entry.temporalAlpha *
|
||||
keyholeLabelAlphaFromGeometry(
|
||||
entry.placement.centerX,
|
||||
entry.placement.centerY,
|
||||
keyhole,
|
||||
),
|
||||
]),
|
||||
),
|
||||
solveRevision: _labelArbiter.solveRevision,
|
||||
keyholeRadius: keyhole.radius,
|
||||
keyholeFeatherPx: keyhole.featherPx,
|
||||
@@ -1451,5 +1594,11 @@ function _drawOverlay(frame) {
|
||||
_drawModeBanner(visibleCount, sampledCount);
|
||||
// `_labelSolveDirty` surviving a paint means the solve was owed and did not
|
||||
// run — the frame that carried the request cannot be the last one.
|
||||
return { didSolve, solveMs, fadingCount, animatingCount, solvePending: _labelSolveDirty };
|
||||
return {
|
||||
didSolve,
|
||||
solveMs,
|
||||
fadingCount,
|
||||
animatingCount,
|
||||
solvePending: _labelSolveDirty,
|
||||
};
|
||||
}
|
||||
+41
-12
@@ -19,11 +19,14 @@ export function stableIdentityHash(layerId, sourceId) {
|
||||
|
||||
/** Negative means a is a better deterministic contender than b. */
|
||||
export function compareCohortContenders(a, b) {
|
||||
const priorityDelta = (Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
|
||||
const priorityDelta =
|
||||
(Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
|
||||
if (priorityDelta) return priorityDelta;
|
||||
const bandDelta = (Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
|
||||
const bandDelta =
|
||||
(Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
|
||||
if (bandDelta) return bandDelta;
|
||||
const hashDelta = (Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
|
||||
const hashDelta =
|
||||
(Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
|
||||
if (hashDelta) return hashDelta;
|
||||
return String(a?._cohortSourceId).localeCompare(String(b?._cohortSourceId));
|
||||
}
|
||||
@@ -39,8 +42,14 @@ function isWorse(a, b) {
|
||||
*/
|
||||
export class BoundedCohort {
|
||||
constructor(maxSize = DEFAULT_MAX_COHORT, hardMax = DEFAULT_MAX_COHORT) {
|
||||
const ceiling = Math.max(1, Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT));
|
||||
this.maxSize = Math.max(1, Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT)));
|
||||
const ceiling = Math.max(
|
||||
1,
|
||||
Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT),
|
||||
);
|
||||
this.maxSize = Math.max(
|
||||
1,
|
||||
Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT)),
|
||||
);
|
||||
this.incumbents = [];
|
||||
this.heap = [];
|
||||
}
|
||||
@@ -62,11 +71,20 @@ export class BoundedCohort {
|
||||
}
|
||||
|
||||
values(limit = this.maxSize) {
|
||||
const cap = Math.max(0, Math.min(this.maxSize, Math.floor(Number(limit) || 0)));
|
||||
const incumbents = this.incumbents.slice().sort(compareCohortContenders).slice(0, cap);
|
||||
const cap = Math.max(
|
||||
0,
|
||||
Math.min(this.maxSize, Math.floor(Number(limit) || 0)),
|
||||
);
|
||||
const incumbents = this.incumbents
|
||||
.slice()
|
||||
.sort(compareCohortContenders)
|
||||
.slice(0, cap);
|
||||
const remaining = Math.max(0, cap - incumbents.length);
|
||||
if (remaining === 0) return incumbents;
|
||||
const contenders = this.heap.slice().sort(compareCohortContenders).slice(0, remaining);
|
||||
const contenders = this.heap
|
||||
.slice()
|
||||
.sort(compareCohortContenders)
|
||||
.slice(0, remaining);
|
||||
return incumbents.concat(contenders);
|
||||
}
|
||||
|
||||
@@ -75,7 +93,10 @@ export class BoundedCohort {
|
||||
while (current > 0) {
|
||||
const parent = Math.floor((current - 1) / 2);
|
||||
if (!isWorse(this.heap[current], this.heap[parent])) break;
|
||||
[this.heap[current], this.heap[parent]] = [this.heap[parent], this.heap[current]];
|
||||
[this.heap[current], this.heap[parent]] = [
|
||||
this.heap[parent],
|
||||
this.heap[current],
|
||||
];
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
@@ -86,10 +107,18 @@ export class BoundedCohort {
|
||||
const left = current * 2 + 1;
|
||||
const right = left + 1;
|
||||
let worst = current;
|
||||
if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst])) worst = left;
|
||||
if (right < this.heap.length && isWorse(this.heap[right], this.heap[worst])) worst = right;
|
||||
if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst]))
|
||||
worst = left;
|
||||
if (
|
||||
right < this.heap.length &&
|
||||
isWorse(this.heap[right], this.heap[worst])
|
||||
)
|
||||
worst = right;
|
||||
if (worst === current) return;
|
||||
[this.heap[current], this.heap[worst]] = [this.heap[worst], this.heap[current]];
|
||||
[this.heap[current], this.heap[worst]] = [
|
||||
this.heap[worst],
|
||||
this.heap[current],
|
||||
];
|
||||
current = worst;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,11 +211,14 @@ export function measureTrackLabel(primary, micro, charWidth) {
|
||||
*/
|
||||
export function rectIntersectsAny(rect, obstacles, padding = 0) {
|
||||
if (!rect || !Array.isArray(obstacles)) return false;
|
||||
return obstacles.some((obstacle) => obstacle
|
||||
&& rect.x < obstacle.x + obstacle.w + padding
|
||||
&& rect.x + rect.w + padding > obstacle.x
|
||||
&& rect.y < obstacle.y + obstacle.h + padding
|
||||
&& rect.y + rect.h + padding > obstacle.y);
|
||||
return obstacles.some(
|
||||
(obstacle) =>
|
||||
obstacle &&
|
||||
rect.x < obstacle.x + obstacle.w + padding &&
|
||||
rect.x + rect.w + padding > obstacle.x &&
|
||||
rect.y < obstacle.y + obstacle.h + padding &&
|
||||
rect.y + rect.h + padding > obstacle.y,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,7 +241,6 @@ export function nearFarScale(distance, near, nearValue, far, farValue) {
|
||||
return nearValue + t * (farValue - nearValue);
|
||||
}
|
||||
|
||||
|
||||
/** Transit keeps literal mode colour on the normal-composite surface. */
|
||||
export function paintTransitBracket(ctx, path, color, alpha = 1) {
|
||||
ctx.save?.();
|
||||
@@ -255,6 +257,11 @@ export function paintTransitBracket(ctx, path, color, alpha = 1) {
|
||||
|
||||
/** Centre 1.25 px strokes on pixels, so their cores are fully covered. */
|
||||
export function appendTransitBracket(sink, sx, sy, halfW, halfH) {
|
||||
appendCornerBracket(sink, Math.floor(sx) + 0.5, Math.floor(sy) + 0.5,
|
||||
Math.round(halfW), Math.round(halfH));
|
||||
appendCornerBracket(
|
||||
sink,
|
||||
Math.floor(sx) + 0.5,
|
||||
Math.floor(sy) + 0.5,
|
||||
Math.round(halfW),
|
||||
Math.round(halfH),
|
||||
);
|
||||
}
|
||||
+66
-20
@@ -8,7 +8,10 @@
|
||||
export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]);
|
||||
export const ALLOCATION_ELASTIC = 'ELASTIC';
|
||||
export const ALLOCATION_WEIGHTED = 'WEIGHTED';
|
||||
export const ALLOCATION_STRATEGIES = Object.freeze([ALLOCATION_ELASTIC, ALLOCATION_WEIGHTED]);
|
||||
export const ALLOCATION_STRATEGIES = Object.freeze([
|
||||
ALLOCATION_ELASTIC,
|
||||
ALLOCATION_WEIGHTED,
|
||||
]);
|
||||
|
||||
// The keyhole's default 1% outside opacity is appropriate for text, but it
|
||||
// made side aircraft brackets effectively disappear while the same contacts
|
||||
@@ -71,10 +74,16 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
|
||||
: AIRCRAFT_BRACKET_FLOOR_ANCHOR;
|
||||
if (outside <= 0) return 0;
|
||||
if (outside <= AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
|
||||
return AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR);
|
||||
return (
|
||||
AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR)
|
||||
);
|
||||
}
|
||||
const progress = (outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) / (1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR);
|
||||
return AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress;
|
||||
const progress =
|
||||
(outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) /
|
||||
(1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR);
|
||||
return (
|
||||
AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +94,11 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
|
||||
* the default, so a caller that does not know it reproduces the shipped look.
|
||||
* @returns {number} Paint alpha, 0-1.
|
||||
*/
|
||||
export function detectionBracketAlpha(type, keyholeAlpha, outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
|
||||
export function detectionBracketAlpha(
|
||||
type,
|
||||
keyholeAlpha,
|
||||
outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR,
|
||||
) {
|
||||
const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0));
|
||||
if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha;
|
||||
return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha);
|
||||
@@ -97,7 +110,7 @@ export function detectionHorizontalSector(screenX, viewportWidth) {
|
||||
const x = Number(screenX);
|
||||
if (!(width > 0) || !Number.isFinite(x)) return 'front';
|
||||
if (x < width / 3) return 'left';
|
||||
if (x > width * 2 / 3) return 'right';
|
||||
if (x > (width * 2) / 3) return 'right';
|
||||
return 'front';
|
||||
}
|
||||
|
||||
@@ -140,19 +153,29 @@ export function defaultDensityForProfile(profile) {
|
||||
|
||||
/** Normalize current and legacy profile labels. OFF remains an enabled-state value. */
|
||||
export function normalizeProfile(profile) {
|
||||
const raw = String(profile || '').trim().toUpperCase();
|
||||
const raw = String(profile || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (raw === 'OFF') return 'OFF';
|
||||
if (raw === 'SPARSE' || raw === 'SURVEY') return 'SPARSE';
|
||||
if (raw === 'BALANCED' || raw === 'NORMAL') return 'BALANCED';
|
||||
if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON') return 'DENSE';
|
||||
if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON')
|
||||
return 'DENSE';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Normalize the user-selectable layer allocation strategy. */
|
||||
export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELASTIC) {
|
||||
const raw = String(strategy || '').trim().toUpperCase();
|
||||
export function normalizeAllocationStrategy(
|
||||
strategy,
|
||||
fallback = ALLOCATION_ELASTIC,
|
||||
) {
|
||||
const raw = String(strategy || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (ALLOCATION_STRATEGIES.includes(raw)) return raw;
|
||||
const normalizedFallback = String(fallback || '').trim().toUpperCase();
|
||||
const normalizedFallback = String(fallback || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
return ALLOCATION_STRATEGIES.includes(normalizedFallback)
|
||||
? normalizedFallback
|
||||
: ALLOCATION_ELASTIC;
|
||||
@@ -160,7 +183,9 @@ export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELAS
|
||||
|
||||
/** Classify camera altitude into the shared label-budget view scale. */
|
||||
export function viewScaleForAltitude(altitudeM) {
|
||||
const altitude = Number.isFinite(Number(altitudeM)) ? Math.max(0, Number(altitudeM)) : 1e9;
|
||||
const altitude = Number.isFinite(Number(altitudeM))
|
||||
? Math.max(0, Number(altitudeM))
|
||||
: 1e9;
|
||||
if (altitude < 1200) return 'street';
|
||||
if (altitude < 4500) return 'city';
|
||||
if (altitude < 20000) return 'metro';
|
||||
@@ -181,25 +206,46 @@ export function labelBudgetFor(altitudeM, densityPct) {
|
||||
* density was below 75; legacy Sparse remains inside the Sparse band.
|
||||
*/
|
||||
export function migrateDetectionState(mode, densityPct, fallbackDensity = 50) {
|
||||
const rawMode = String(mode || '').trim().toUpperCase();
|
||||
const rawMode = String(mode || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const normalized = normalizeProfile(rawMode);
|
||||
if (normalized === 'OFF') {
|
||||
const supplied = Number.isFinite(Number(densityPct)) ? densityPct : fallbackDensity;
|
||||
const supplied = Number.isFinite(Number(densityPct))
|
||||
? densityPct
|
||||
: fallbackDensity;
|
||||
const stop = canonicalizeDensity(supplied, fallbackDensity);
|
||||
return { enabled: false, profile: profileForDensity(stop), densityPct: stop };
|
||||
return {
|
||||
enabled: false,
|
||||
profile: profileForDensity(stop),
|
||||
densityPct: stop,
|
||||
};
|
||||
}
|
||||
if (rawMode === 'PANOPTIC' || rawMode === 'GOD' || rawMode === 'ON') {
|
||||
const density = Number.isFinite(Number(densityPct)) ? Math.max(75, Number(densityPct)) : 75;
|
||||
return { enabled: true, profile: 'DENSE', densityPct: canonicalizeDensity(density) };
|
||||
const density = Number.isFinite(Number(densityPct))
|
||||
? Math.max(75, Number(densityPct))
|
||||
: 75;
|
||||
return {
|
||||
enabled: true,
|
||||
profile: 'DENSE',
|
||||
densityPct: canonicalizeDensity(density),
|
||||
};
|
||||
}
|
||||
if (rawMode === 'SPARSE' || rawMode === 'SURVEY') {
|
||||
const density = Number.isFinite(Number(densityPct)) ? Math.min(25, Number(densityPct)) : 25;
|
||||
const density = Number.isFinite(Number(densityPct))
|
||||
? Math.min(25, Number(densityPct))
|
||||
: 25;
|
||||
const stop = canonicalizeDensity(density, 25);
|
||||
return { enabled: true, profile: 'SPARSE', densityPct: stop };
|
||||
}
|
||||
if (normalized === 'BALANCED' || normalized === 'DENSE') {
|
||||
const supplied = Number.isFinite(Number(densityPct)) ? canonicalizeDensity(densityPct) : defaultDensityForProfile(normalized);
|
||||
const stop = profileForDensity(supplied) === normalized ? supplied : defaultDensityForProfile(normalized);
|
||||
const supplied = Number.isFinite(Number(densityPct))
|
||||
? canonicalizeDensity(densityPct)
|
||||
: defaultDensityForProfile(normalized);
|
||||
const stop =
|
||||
profileForDensity(supplied) === normalized
|
||||
? supplied
|
||||
: defaultDensityForProfile(normalized);
|
||||
return { enabled: true, profile: normalized, densityPct: stop };
|
||||
}
|
||||
const stop = canonicalizeDensity(densityPct, fallbackDensity);
|
||||
|
||||
@@ -10,4 +10,3 @@ export const COCKPIT_BRACKET_OPACITY = 0.45;
|
||||
export function detectionBracketOpacity(cockpitActive) {
|
||||
return cockpitActive === true ? COCKPIT_BRACKET_OPACITY : 1;
|
||||
}
|
||||
|
||||
@@ -158,9 +158,10 @@ export function detectionPaintSkipDecision({
|
||||
frameCount,
|
||||
thresholdMs = DETECTION_PAINT_SKIP_THRESHOLD_MS,
|
||||
} = {}) {
|
||||
const skip = !layoutChanged
|
||||
&& Number(lastPaintMs) > thresholdMs
|
||||
&& Number(frameCount) % 2 !== 0;
|
||||
const skip =
|
||||
!layoutChanged &&
|
||||
Number(lastPaintMs) > thresholdMs &&
|
||||
Number(frameCount) % 2 !== 0;
|
||||
return { skip, requestFollowUp: skip };
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
* @returns {number} Heading in degrees [0..360), or NaN if unrecognized.
|
||||
*/
|
||||
export function directionToHeading(value, allowBare = false) {
|
||||
const text = String(value || '').trim().toUpperCase();
|
||||
const text = String(value || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!text) return NaN;
|
||||
// Explicit travel/intercardinal forms — safe on free-form text (a street
|
||||
// name almost never contains "NORTHBOUND" or a lone "NB" token).
|
||||
|
||||
@@ -79,8 +79,12 @@ export function parseAcquisitionMs(date, time, cache = new Map()) {
|
||||
const hhmm = String(time ?? '0000').padStart(4, '0');
|
||||
const hours = Number(hhmm.slice(0, 2));
|
||||
const minutes = Number(hhmm.slice(2, 4));
|
||||
const valid = Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day)
|
||||
&& Number.isFinite(hours) && Number.isFinite(minutes);
|
||||
const valid =
|
||||
Number.isFinite(year) &&
|
||||
Number.isFinite(month) &&
|
||||
Number.isFinite(day) &&
|
||||
Number.isFinite(hours) &&
|
||||
Number.isFinite(minutes);
|
||||
const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
|
||||
cache.set(key, ms);
|
||||
return ms;
|
||||
|
||||
+11
-5
@@ -12,10 +12,14 @@ export const FIRMS_OVERLAY_SOURCE_ID = 'firms';
|
||||
export function fireDetectionKey(fire) {
|
||||
const lat = Number.isFinite(fire?.lat) ? fire.lat.toFixed(4) : 'x';
|
||||
const lon = Number.isFinite(fire?.lon) ? fire.lon.toFixed(4) : 'x';
|
||||
const acq = Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? String(fire.acqMs) : '0';
|
||||
const source = satelliteShortName(fire?.satellite)
|
||||
|| String(fire?.sensor || '').trim().toUpperCase()
|
||||
|| 'x';
|
||||
const acq =
|
||||
Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? String(fire.acqMs) : '0';
|
||||
const source =
|
||||
satelliteShortName(fire?.satellite) ||
|
||||
String(fire?.sensor || '')
|
||||
.trim()
|
||||
.toUpperCase() ||
|
||||
'x';
|
||||
return `firms:${lat}:${lon}:${acq}:${source}`;
|
||||
}
|
||||
|
||||
@@ -37,7 +41,9 @@ export function accentForSeverity(stopName) {
|
||||
|
||||
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
|
||||
export function satelliteShortName(satellite) {
|
||||
const s = String(satellite || '').trim().toUpperCase();
|
||||
const s = String(satellite || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (s === 'N20' || s === 'NOAA-20') return 'N20';
|
||||
if (s === 'N21' || s === 'NOAA-21') return 'N21';
|
||||
if (s === 'N' || s === 'NPP' || s === 'SUOMI NPP') return 'SNPP';
|
||||
|
||||
+29
-9
@@ -45,7 +45,9 @@ export function median(values) {
|
||||
if (!Array.isArray(values) || values.length === 0) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = sorted.length >> 1;
|
||||
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
return sorted.length % 2 === 1
|
||||
? sorted[mid]
|
||||
: (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
/** Bearing (degrees, 0 = north, clockwise) of a projected dx/dy vector. */
|
||||
@@ -98,13 +100,19 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
const roadCount = Array.isArray(roads) ? roads.length : 0;
|
||||
const matches = new Array(roadCount).fill(null);
|
||||
const empty = { matches, matchedCount: 0, candidateCount: 0 };
|
||||
if (roadCount === 0 || !Array.isArray(flowSegments) || flowSegments.length === 0) {
|
||||
if (
|
||||
roadCount === 0 ||
|
||||
!Array.isArray(flowSegments) ||
|
||||
flowSegments.length === 0
|
||||
) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
// Local equirectangular projection anchored at the first flow coordinate —
|
||||
// over a ≤0.05° fetch box the distortion is negligible.
|
||||
const anchor = flowSegments.find((f) => Array.isArray(f?.coords) && f.coords.length >= 2);
|
||||
const anchor = flowSegments.find(
|
||||
(f) => Array.isArray(f?.coords) && f.coords.length >= 2,
|
||||
);
|
||||
if (!anchor) return empty;
|
||||
const [refLon, refLat] = anchor.coords[0];
|
||||
const mPerDegLon = M_PER_DEG_LAT * Math.cos((refLat * Math.PI) / 180);
|
||||
@@ -114,7 +122,8 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
// ── 1. Spatial hash of flow segments (midpoint-keyed 100 m cells) ──
|
||||
/** @type {Map<string, Array<{ax:number,ay:number,bx:number,by:number,bearing:number,level:number,closure:boolean}>>} */
|
||||
const grid = new Map();
|
||||
const cellOf = (x, y) => `${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
|
||||
const cellOf = (x, y) =>
|
||||
`${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
|
||||
|
||||
for (const flow of flowSegments) {
|
||||
const coords = flow?.coords;
|
||||
@@ -173,7 +182,8 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
for (let i = 0; i < coords.length; i++) {
|
||||
xs[i] = projX(coords[i][0]);
|
||||
ys[i] = projY(coords[i][1]);
|
||||
if (i > 0) cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
|
||||
if (i > 0)
|
||||
cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
|
||||
}
|
||||
const totalLen = cum[coords.length - 1];
|
||||
if (!(totalLen > 0)) continue;
|
||||
@@ -188,10 +198,14 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
// cross-street flow lines meet at intersections.
|
||||
const target = (totalLen * (s + 0.5)) / ROAD_SAMPLES;
|
||||
while (cursor < coords.length - 1 && cum[cursor] < target) cursor++;
|
||||
const segT = (target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
|
||||
const segT =
|
||||
(target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
|
||||
const px = xs[cursor - 1] + (xs[cursor] - xs[cursor - 1]) * segT;
|
||||
const py = ys[cursor - 1] + (ys[cursor] - ys[cursor - 1]) * segT;
|
||||
const sampleBearing = bearingDeg(xs[cursor] - xs[cursor - 1], ys[cursor] - ys[cursor - 1]);
|
||||
const sampleBearing = bearingDeg(
|
||||
xs[cursor] - xs[cursor - 1],
|
||||
ys[cursor] - ys[cursor - 1],
|
||||
);
|
||||
|
||||
// 3×3 cell probe around the sample.
|
||||
const cx = Math.floor(px / CELL_SIZE_M);
|
||||
@@ -206,7 +220,11 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
|
||||
if (d2 > radius2) continue;
|
||||
hadCandidate = true; // within radius, bearing not yet checked
|
||||
if (bearingDiffDeg(seg.bearing, sampleBearing) >= BEARING_TOLERANCE_DEG) continue;
|
||||
if (
|
||||
bearingDiffDeg(seg.bearing, sampleBearing) >=
|
||||
BEARING_TOLERANCE_DEG
|
||||
)
|
||||
continue;
|
||||
if (d2 <= bestDist2) {
|
||||
bestDist2 = d2;
|
||||
best = seg;
|
||||
@@ -221,7 +239,9 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
}
|
||||
|
||||
if (hadCandidate) candidateCount++;
|
||||
if (matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)) {
|
||||
if (
|
||||
matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)
|
||||
) {
|
||||
matches[r] = { level: median(matchedLevels), closure: matchedClosure };
|
||||
matchedCount++;
|
||||
}
|
||||
|
||||
+114
-50
@@ -59,24 +59,35 @@ function focusTargetEmphasisResolved(
|
||||
previouslyFarther,
|
||||
) {
|
||||
if (!target?.screenRect || !spriteScreenPosition) return 1;
|
||||
if (!Number.isFinite(spriteScreenPosition.x) || !Number.isFinite(spriteScreenPosition.y)) return 1;
|
||||
if (
|
||||
!Number.isFinite(spriteScreenPosition.x) ||
|
||||
!Number.isFinite(spriteScreenPosition.y)
|
||||
)
|
||||
return 1;
|
||||
|
||||
// Published rects already contain their publication-time padding. The
|
||||
// delta keeps direct pure-function calls and runtime A/B overrides honest.
|
||||
// The ambient extent makes overlap mean visual overlap, not center-point
|
||||
// containment: a class-sized icon whose edge intrudes into the focus rect
|
||||
// yields even when its center remains just outside it.
|
||||
const paddingDelta = tuning.paddingPx - (Number.isFinite(target.paddingPx) ? target.paddingPx : 0);
|
||||
const paddingDelta =
|
||||
tuning.paddingPx -
|
||||
(Number.isFinite(target.paddingPx) ? target.paddingPx : 0);
|
||||
const hysteresis = previouslyCompeting ? tuning.hysteresisPx : 0;
|
||||
const expansionX = paddingDelta + hysteresis
|
||||
+ Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0);
|
||||
const expansionY = paddingDelta + hysteresis
|
||||
+ Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0);
|
||||
const expansionX =
|
||||
paddingDelta +
|
||||
hysteresis +
|
||||
Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0);
|
||||
const expansionY =
|
||||
paddingDelta +
|
||||
hysteresis +
|
||||
Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0);
|
||||
const rect = target.screenRect;
|
||||
const inside = spriteScreenPosition.x >= rect.left - expansionX
|
||||
&& spriteScreenPosition.x <= rect.right + expansionX
|
||||
&& spriteScreenPosition.y >= rect.top - expansionY
|
||||
&& spriteScreenPosition.y <= rect.bottom + expansionY;
|
||||
const inside =
|
||||
spriteScreenPosition.x >= rect.left - expansionX &&
|
||||
spriteScreenPosition.x <= rect.right + expansionX &&
|
||||
spriteScreenPosition.y >= rect.top - expansionY &&
|
||||
spriteScreenPosition.y <= rect.bottom + expansionY;
|
||||
if (!inside) return 1;
|
||||
|
||||
const floor = clamp(tuning.dimFloor, 0.01, 1);
|
||||
@@ -88,24 +99,33 @@ function focusTargetEmphasisResolved(
|
||||
const fartherThreshold = Number.isFinite(target.cameraDistance)
|
||||
? target.cameraDistance + (previouslyFarther ? -distanceBand : distanceBand)
|
||||
: Number.NaN;
|
||||
const farther = !Number.isFinite(spriteCameraDistance)
|
||||
|| !Number.isFinite(fartherThreshold)
|
||||
|| spriteCameraDistance >= fartherThreshold;
|
||||
const farther =
|
||||
!Number.isFinite(spriteCameraDistance) ||
|
||||
!Number.isFinite(fartherThreshold) ||
|
||||
spriteCameraDistance >= fartherThreshold;
|
||||
if (farther) return floor;
|
||||
if (tuning.nearerBehavior === 'partial') return floor + (1 - floor) * 0.5;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
|
||||
const from = clamp(Number.isFinite(current) ? current : 1, tuning.dimFloor, 1);
|
||||
const from = clamp(
|
||||
Number.isFinite(current) ? current : 1,
|
||||
tuning.dimFloor,
|
||||
1,
|
||||
);
|
||||
const to = clamp(Number.isFinite(target) ? target : 1, tuning.dimFloor, 1);
|
||||
if (Math.abs(to - from) < Number.EPSILON) return to;
|
||||
const duration = to < from ? tuning.attackMs : tuning.releaseMs;
|
||||
if (duration <= 0) return to;
|
||||
const t = clamp((Number.isFinite(elapsedMs) ? elapsedMs : 0) / duration, 0, 1);
|
||||
const t = clamp(
|
||||
(Number.isFinite(elapsedMs) ? elapsedMs : 0) / duration,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
// Cubic ease-out reaches the exact endpoint at the configured duration and
|
||||
// stays continuous when a moving contact reverses direction mid-transition.
|
||||
const eased = 1 - ((1 - t) ** 3);
|
||||
const eased = 1 - (1 - t) ** 3;
|
||||
return clamp(from + (to - from) * eased, tuning.dimFloor, 1);
|
||||
}
|
||||
|
||||
@@ -118,18 +138,24 @@ function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
|
||||
*/
|
||||
export function setFocusDeemphasisParams(patch = {}) {
|
||||
const next = { ..._params };
|
||||
if (Number.isFinite(patch.paddingPx)) next.paddingPx = Math.max(0, patch.paddingPx);
|
||||
if (Number.isFinite(patch.dimFloor)) next.dimFloor = clamp(patch.dimFloor, 0.01, 1);
|
||||
if (Number.isFinite(patch.paddingPx))
|
||||
next.paddingPx = Math.max(0, patch.paddingPx);
|
||||
if (Number.isFinite(patch.dimFloor))
|
||||
next.dimFloor = clamp(patch.dimFloor, 0.01, 1);
|
||||
if (['allow', 'dim', 'partial'].includes(patch.nearerBehavior)) {
|
||||
next.nearerBehavior = patch.nearerBehavior;
|
||||
}
|
||||
if (Number.isFinite(patch.hysteresisPx)) next.hysteresisPx = Math.max(0, patch.hysteresisPx);
|
||||
if (Number.isFinite(patch.hysteresisPx))
|
||||
next.hysteresisPx = Math.max(0, patch.hysteresisPx);
|
||||
if (Number.isFinite(patch.distanceHysteresisRatio)) {
|
||||
next.distanceHysteresisRatio = clamp(patch.distanceHysteresisRatio, 0, 0.5);
|
||||
}
|
||||
if (Number.isFinite(patch.attackMs)) next.attackMs = Math.max(0, patch.attackMs);
|
||||
if (Number.isFinite(patch.releaseMs)) next.releaseMs = Math.max(0, patch.releaseMs);
|
||||
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon);
|
||||
if (Number.isFinite(patch.attackMs))
|
||||
next.attackMs = Math.max(0, patch.attackMs);
|
||||
if (Number.isFinite(patch.releaseMs))
|
||||
next.releaseMs = Math.max(0, patch.releaseMs);
|
||||
if (Number.isFinite(patch.writeEpsilon))
|
||||
next.writeEpsilon = Math.max(0, patch.writeEpsilon);
|
||||
_params = next;
|
||||
return { ..._params };
|
||||
}
|
||||
@@ -171,11 +197,22 @@ export function publishFocusTargetFromCachedPosition({
|
||||
heightPx = 24,
|
||||
params,
|
||||
}) {
|
||||
if (!ownerLayer || id === null || id === undefined || !scene || !camera || !displayPosition) {
|
||||
if (
|
||||
!ownerLayer ||
|
||||
id === null ||
|
||||
id === undefined ||
|
||||
!scene ||
|
||||
!camera ||
|
||||
!displayPosition
|
||||
) {
|
||||
clearFocusTarget(ownerLayer, id);
|
||||
return null;
|
||||
}
|
||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(scene, displayPosition, _scratchScreen);
|
||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
displayPosition,
|
||||
_scratchScreen,
|
||||
);
|
||||
if (!screen || !Number.isFinite(screen.x) || !Number.isFinite(screen.y)) {
|
||||
clearFocusTarget(ownerLayer, id);
|
||||
return null;
|
||||
@@ -195,12 +232,19 @@ export function publishFocusTargetFromCachedPosition({
|
||||
bottom: screen.y + halfHeight + padding,
|
||||
},
|
||||
paddingPx: padding,
|
||||
cameraDistance: Cesium.Cartesian3.distance(camera.positionWC, displayPosition),
|
||||
cameraDistance: Cesium.Cartesian3.distance(
|
||||
camera.positionWC,
|
||||
displayPosition,
|
||||
),
|
||||
frameNumber: scene.frameState?.frameNumber ?? -1,
|
||||
};
|
||||
if (appeared) {
|
||||
for (const listener of _focusAppearListeners) {
|
||||
try { listener(); } catch (e) { console.warn('[focus] appear listener error:', e); }
|
||||
try {
|
||||
listener();
|
||||
} catch (e) {
|
||||
console.warn('[focus] appear listener error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return _focusTarget;
|
||||
@@ -311,15 +355,18 @@ export function smoothFocusEmphasis(current, target, elapsedMs, params) {
|
||||
* every needed property before the next call; never retain it as a snapshot.
|
||||
* @returns {{factor:number, changed:boolean, transitioning:boolean,active:boolean,desired:number}}
|
||||
*/
|
||||
export function advanceSpriteFocus(sprite, {
|
||||
screenPosition,
|
||||
cameraDistance,
|
||||
nowMs,
|
||||
target = _focusTarget,
|
||||
params,
|
||||
spriteHalfWidthPx = 0,
|
||||
spriteHalfHeightPx = spriteHalfWidthPx,
|
||||
}) {
|
||||
export function advanceSpriteFocus(
|
||||
sprite,
|
||||
{
|
||||
screenPosition,
|
||||
cameraDistance,
|
||||
nowMs,
|
||||
target = _focusTarget,
|
||||
params,
|
||||
spriteHalfWidthPx = 0,
|
||||
spriteHalfHeightPx = spriteHalfWidthPx,
|
||||
},
|
||||
) {
|
||||
const tuning = resolvedParams(params);
|
||||
let state = _spriteStates.get(sprite);
|
||||
if (!state) {
|
||||
@@ -369,12 +416,18 @@ export function advanceSpriteFocus(sprite, {
|
||||
state.desired = desired;
|
||||
}
|
||||
const elapsedMs = Math.max(0, nowMs - state.transitionStartMs);
|
||||
const next = smoothFocusEmphasisResolved(state.transitionFrom, desired, elapsedMs, tuning);
|
||||
const next = smoothFocusEmphasisResolved(
|
||||
state.transitionFrom,
|
||||
desired,
|
||||
elapsedMs,
|
||||
tuning,
|
||||
);
|
||||
const changed = Math.abs(next - state.factor) > tuning.writeEpsilon;
|
||||
state.factor = next;
|
||||
state.desired = desired;
|
||||
state.wasFarther = tuning.nearerBehavior !== 'dim'
|
||||
&& Math.abs(desired - tuning.dimFloor) <= tuning.writeEpsilon;
|
||||
state.wasFarther =
|
||||
tuning.nearerBehavior !== 'dim' &&
|
||||
Math.abs(desired - tuning.dimFloor) <= tuning.writeEpsilon;
|
||||
const transitioning = Math.abs(next - desired) > tuning.writeEpsilon;
|
||||
const active = Math.abs(next - 1) > tuning.writeEpsilon;
|
||||
|
||||
@@ -426,12 +479,18 @@ export function advanceProjectedSpriteFocus(
|
||||
_advanceResult.desired = 1;
|
||||
return _advanceResult;
|
||||
}
|
||||
const screen = position && scene
|
||||
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, _scratchScreen)
|
||||
: null;
|
||||
const cameraDistance = position && camera?.positionWC
|
||||
? Cesium.Cartesian3.distance(camera.positionWC, position)
|
||||
: Number.NaN;
|
||||
const screen =
|
||||
position && scene
|
||||
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
position,
|
||||
_scratchScreen,
|
||||
)
|
||||
: null;
|
||||
const cameraDistance =
|
||||
position && camera?.positionWC
|
||||
? Cesium.Cartesian3.distance(camera.positionWC, position)
|
||||
: Number.NaN;
|
||||
return advanceSpriteFocus(sprite, {
|
||||
screenPosition: screen,
|
||||
cameraDistance,
|
||||
@@ -451,8 +510,10 @@ export function advanceProjectedSpriteFocus(
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function focusPassIsNeeded(target, activeCount) {
|
||||
return target !== null && target !== undefined
|
||||
|| (Number.isFinite(activeCount) && activeCount > 0);
|
||||
return (
|
||||
(target !== null && target !== undefined) ||
|
||||
(Number.isFinite(activeCount) && activeCount > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -466,8 +527,9 @@ export function nearFarScalarValueAtDistance(scalar, cameraDistanceM) {
|
||||
if (!scalar || !Number.isFinite(cameraDistanceM)) return 1;
|
||||
const nearSq = scalar.near * scalar.near;
|
||||
const farSq = scalar.far * scalar.far;
|
||||
if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq) return 1;
|
||||
const rawT = ((cameraDistanceM * cameraDistanceM) - nearSq) / (farSq - nearSq);
|
||||
if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq)
|
||||
return 1;
|
||||
const rawT = (cameraDistanceM * cameraDistanceM - nearSq) / (farSq - nearSq);
|
||||
const t = Math.pow(clamp(rawT, 0, 1), 0.2);
|
||||
return scalar.nearValue + (scalar.farValue - scalar.nearValue) * t;
|
||||
}
|
||||
@@ -507,6 +569,8 @@ export function forgetSpriteFocus(sprite) {
|
||||
*/
|
||||
export function focusAlphaNeedsWrite(currentAlpha, nextAlpha, params) {
|
||||
const tuning = resolvedParams(params);
|
||||
return !Number.isFinite(currentAlpha)
|
||||
|| Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon;
|
||||
return (
|
||||
!Number.isFinite(currentAlpha) ||
|
||||
Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -51,7 +51,7 @@ export function geoidHeight(latDeg, lonDeg) {
|
||||
if (!egm96Module) {
|
||||
throw new Error(
|
||||
'geoid.js: geoidHeight() called before ensureGeoidReady() resolved — ' +
|
||||
'await ensureGeoidReady() first.'
|
||||
'await ensureGeoidReady() first.',
|
||||
);
|
||||
}
|
||||
return egm96Module.meanSeaLevel(latDeg, lonDeg);
|
||||
|
||||
+29
-1
@@ -1,4 +1,32 @@
|
||||
import { createGroundFloor } from '../services/groundFloor.js';
|
||||
import * as terrain from './terrainHeights.js';
|
||||
const groundFloor = createGroundFloor({ terrain });
|
||||
export const { GROUND_FLOOR_LIFT_M, MESH_FLOOR_BELOW_PRIOR_M, MESH_FLOOR_ABOVE_PRIOR_M, coarseFloorCoord, floorAltitudeM, displayFloorHeightM, FLOOR_CELL_DEG, NEIGHBOR_FLOOR_MIN_SAMPLES, neighborFloorM, CORRIDOR_MAX_CELLS, CORRIDOR_WALK_STEP_DEG, CELL_HYSTERESIS_DEG, stickyFloorCell, corridorFloorCells, allocateCorridorCells, setMeshFloorPreferred, meshFloorPreferred, reportMeshFloorCell, meshFloorSampleWithinPrior, reportValidatedMeshFloorCell, cachedMeshFloor, _clearMeshFloorCellsForTest, cachedGroundFloor, FLOOR_RESOLVE_DEADLINE_MS, resolveGroundFloorCellsBounded, resolveGroundFloorCells, warmGroundFloor } = groundFloor;
|
||||
export const {
|
||||
GROUND_FLOOR_LIFT_M,
|
||||
MESH_FLOOR_BELOW_PRIOR_M,
|
||||
MESH_FLOOR_ABOVE_PRIOR_M,
|
||||
coarseFloorCoord,
|
||||
floorAltitudeM,
|
||||
displayFloorHeightM,
|
||||
FLOOR_CELL_DEG,
|
||||
NEIGHBOR_FLOOR_MIN_SAMPLES,
|
||||
neighborFloorM,
|
||||
CORRIDOR_MAX_CELLS,
|
||||
CORRIDOR_WALK_STEP_DEG,
|
||||
CELL_HYSTERESIS_DEG,
|
||||
stickyFloorCell,
|
||||
corridorFloorCells,
|
||||
allocateCorridorCells,
|
||||
setMeshFloorPreferred,
|
||||
meshFloorPreferred,
|
||||
reportMeshFloorCell,
|
||||
meshFloorSampleWithinPrior,
|
||||
reportValidatedMeshFloorCell,
|
||||
cachedMeshFloor,
|
||||
_clearMeshFloorCellsForTest,
|
||||
cachedGroundFloor,
|
||||
FLOOR_RESOLVE_DEADLINE_MS,
|
||||
resolveGroundFloorCellsBounded,
|
||||
resolveGroundFloorCells,
|
||||
warmGroundFloor,
|
||||
} = groundFloor;
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createGroundSnap as createSnap } from '../services/groundSnap.js';
|
||||
import * as groundFloor from './groundFloor.js';
|
||||
/** Compatibility constructor using the default floor cache. */
|
||||
export function createGroundSnap() { return createSnap({ groundFloor }); }
|
||||
export function createGroundSnap() {
|
||||
return createSnap({ groundFloor });
|
||||
}
|
||||
+25
-6
@@ -29,8 +29,14 @@ export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
|
||||
}
|
||||
|
||||
export function findNextIssPass({
|
||||
satrec, latDeg, lonDeg, fromMs,
|
||||
minElevDeg = 10, horizonHours = 24, coarseStepSec = 30, fineStepSec = 5,
|
||||
satrec,
|
||||
latDeg,
|
||||
lonDeg,
|
||||
fromMs,
|
||||
minElevDeg = 10,
|
||||
horizonHours = 24,
|
||||
coarseStepSec = 30,
|
||||
fineStepSec = 5,
|
||||
}) {
|
||||
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
|
||||
const horizonMs = fromMs + horizonHours * 3600_000;
|
||||
@@ -41,13 +47,17 @@ export function findNextIssPass({
|
||||
// pass, that's still "the next pass" for a voice answer — accept it.
|
||||
let hit = null;
|
||||
for (let t = fromMs; t <= horizonMs; t += coarse) {
|
||||
if (elev(t) >= minElevDeg) { hit = t; break; }
|
||||
if (elev(t) >= minElevDeg) {
|
||||
hit = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hit == null) return null;
|
||||
|
||||
// Refine rise: walk back in fine steps to the first sample ≥ threshold.
|
||||
let riseMs = hit;
|
||||
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg) riseMs -= fine;
|
||||
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg)
|
||||
riseMs -= fine;
|
||||
|
||||
// Walk forward through the pass tracking the peak until we drop below.
|
||||
let maxElevDeg = -90;
|
||||
@@ -56,11 +66,20 @@ export function findNextIssPass({
|
||||
while (t <= horizonMs) {
|
||||
const e = elev(t);
|
||||
if (e < minElevDeg && t > riseMs) break;
|
||||
if (e > maxElevDeg) { maxElevDeg = e; maxElevMs = t; }
|
||||
if (e > maxElevDeg) {
|
||||
maxElevDeg = e;
|
||||
maxElevMs = t;
|
||||
}
|
||||
t += fine;
|
||||
}
|
||||
const setMs = t;
|
||||
|
||||
const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
|
||||
return { riseMs, setMs, maxElevDeg, maxElevMs, riseAzDeg: rise ? rise.azDeg : 0 };
|
||||
return {
|
||||
riseMs,
|
||||
setMs,
|
||||
maxElevDeg,
|
||||
maxElevMs,
|
||||
riseAzDeg: rise ? rise.azDeg : 0,
|
||||
};
|
||||
}
|
||||
+175
-72
@@ -16,11 +16,15 @@ function clampInt(value, min, max) {
|
||||
}
|
||||
|
||||
function demandEntries(demandByLayer) {
|
||||
const entries = demandByLayer instanceof Map
|
||||
? Array.from(demandByLayer.entries())
|
||||
: Object.entries(demandByLayer || {});
|
||||
const entries =
|
||||
demandByLayer instanceof Map
|
||||
? Array.from(demandByLayer.entries())
|
||||
: Object.entries(demandByLayer || {});
|
||||
return entries
|
||||
.map(([layerId, demand]) => [String(layerId), Math.max(0, Math.floor(Number(demand) || 0))])
|
||||
.map(([layerId, demand]) => [
|
||||
String(layerId),
|
||||
Math.max(0, Math.floor(Number(demand) || 0)),
|
||||
])
|
||||
.filter(([, demand]) => demand > 0)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
}
|
||||
@@ -104,7 +108,9 @@ function allocateLayerQuotasInto(
|
||||
weighted.length = idCount;
|
||||
priorityOrder.length = idCount;
|
||||
remainders.length = idCount;
|
||||
priorityOrder.sort((a, b) => b.weight - a.weight || a.layerId.localeCompare(b.layerId));
|
||||
priorityOrder.sort(
|
||||
(a, b) => b.weight - a.weight || a.layerId.localeCompare(b.layerId),
|
||||
);
|
||||
|
||||
let remaining = capacity;
|
||||
if (capacity >= idCount) {
|
||||
@@ -115,7 +121,7 @@ function allocateLayerQuotasInto(
|
||||
let apportioned = 0;
|
||||
for (let i = 0; i < idCount; i++) {
|
||||
const entry = weighted[i];
|
||||
const exact = remaining * entry.weight / totalWeight;
|
||||
const exact = (remaining * entry.weight) / totalWeight;
|
||||
const room = entry.count - (quotas.get(entry.layerId) || 0);
|
||||
const whole = Math.min(room, Math.floor(exact));
|
||||
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
|
||||
@@ -123,9 +129,12 @@ function allocateLayerQuotasInto(
|
||||
entry.fraction = exact - Math.floor(exact);
|
||||
}
|
||||
remaining -= apportioned;
|
||||
remainders.sort((a, b) => b.fraction - a.fraction
|
||||
|| b.weight - a.weight
|
||||
|| a.layerId.localeCompare(b.layerId));
|
||||
remainders.sort(
|
||||
(a, b) =>
|
||||
b.fraction - a.fraction ||
|
||||
b.weight - a.weight ||
|
||||
a.layerId.localeCompare(b.layerId),
|
||||
);
|
||||
for (let i = 0; i < idCount && remaining > 0; i++) {
|
||||
const entry = remainders[i];
|
||||
const current = quotas.get(entry.layerId) || 0;
|
||||
@@ -156,7 +165,12 @@ function allocateLayerQuotasInto(
|
||||
* Allocate a collective capacity across non-empty layers. Both strategies are
|
||||
* work-conserving and deterministic; unused entitlement is always borrowed.
|
||||
*/
|
||||
export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATION_ELASTIC, layerWeights = {}) {
|
||||
export function allocateLayerQuotas(
|
||||
demandByLayer,
|
||||
capacity,
|
||||
strategy = ALLOCATION_ELASTIC,
|
||||
layerWeights = {},
|
||||
) {
|
||||
const entries = demandEntries(demandByLayer);
|
||||
const totalDemand = entries.reduce((sum, [, demand]) => sum + demand, 0);
|
||||
const cap = clampInt(capacity, 0, totalDemand);
|
||||
@@ -198,15 +212,24 @@ export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATI
|
||||
if (remaining > 0 && totalWeight > 0) {
|
||||
let apportioned = 0;
|
||||
for (const entry of weighted) {
|
||||
const exact = remaining * entry.weight / totalWeight;
|
||||
const exact = (remaining * entry.weight) / totalWeight;
|
||||
const room = entry.count - (quotas.get(entry.layerId) || 0);
|
||||
const whole = Math.min(room, Math.floor(exact));
|
||||
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
|
||||
apportioned += whole;
|
||||
remainders.push({ layerId: entry.layerId, fraction: exact - Math.floor(exact), weight: entry.weight });
|
||||
remainders.push({
|
||||
layerId: entry.layerId,
|
||||
fraction: exact - Math.floor(exact),
|
||||
weight: entry.weight,
|
||||
});
|
||||
}
|
||||
remaining -= apportioned;
|
||||
remainders.sort((a, b) => b.fraction - a.fraction || b.weight - a.weight || a.layerId.localeCompare(b.layerId));
|
||||
remainders.sort(
|
||||
(a, b) =>
|
||||
b.fraction - a.fraction ||
|
||||
b.weight - a.weight ||
|
||||
a.layerId.localeCompare(b.layerId),
|
||||
);
|
||||
for (const entry of remainders) {
|
||||
if (remaining <= 0) break;
|
||||
const current = quotas.get(entry.layerId) || 0;
|
||||
@@ -220,20 +243,24 @@ export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATI
|
||||
}
|
||||
|
||||
function rectIsFinite(rect) {
|
||||
return rect
|
||||
&& Number.isFinite(rect.x)
|
||||
&& Number.isFinite(rect.y)
|
||||
&& Number.isFinite(rect.w)
|
||||
&& Number.isFinite(rect.h)
|
||||
&& rect.w > 0
|
||||
&& rect.h > 0;
|
||||
return (
|
||||
rect &&
|
||||
Number.isFinite(rect.x) &&
|
||||
Number.isFinite(rect.y) &&
|
||||
Number.isFinite(rect.w) &&
|
||||
Number.isFinite(rect.h) &&
|
||||
rect.w > 0 &&
|
||||
rect.h > 0
|
||||
);
|
||||
}
|
||||
|
||||
function overlaps(a, b, padding = 4) {
|
||||
return a.x < b.x + b.w + padding
|
||||
&& a.x + a.w + padding > b.x
|
||||
&& a.y < b.y + b.h + padding
|
||||
&& a.y + a.h + padding > b.y;
|
||||
return (
|
||||
a.x < b.x + b.w + padding &&
|
||||
a.x + a.w + padding > b.x &&
|
||||
a.y < b.y + b.h + padding &&
|
||||
a.y + a.h + padding > b.y
|
||||
);
|
||||
}
|
||||
|
||||
const CELL_ORIGIN = 8192;
|
||||
@@ -271,8 +298,10 @@ class SpatialHash {
|
||||
* never hide a real collision.
|
||||
*/
|
||||
_cellKey(x, y) {
|
||||
const cx = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, x)) + CELL_ORIGIN;
|
||||
const cy = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, y)) + CELL_ORIGIN;
|
||||
const cx =
|
||||
Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, x)) + CELL_ORIGIN;
|
||||
const cy =
|
||||
Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, y)) + CELL_ORIGIN;
|
||||
// Both coordinates occupy 14 bits. Bit-packing forces the lookup key to a
|
||||
// Smi; arithmetic multiplication materializes a boxed double at each
|
||||
// spatial-hash get/add on this hot solve path.
|
||||
@@ -325,8 +354,14 @@ function candidateCompare(a, b, states, now) {
|
||||
// A stateless candidate is never lifetime-pinned: its source shipped as a
|
||||
// per-frame rebuild, so it must be free to lose its slot the moment something
|
||||
// better wants it. Plain incumbency ordering below still applies.
|
||||
const aPinned = !!aState?.selected && a.stateless !== true && now - aState.selectedAt < MIN_LIFETIME_MS;
|
||||
const bPinned = !!bState?.selected && b.stateless !== true && now - bState.selectedAt < MIN_LIFETIME_MS;
|
||||
const aPinned =
|
||||
!!aState?.selected &&
|
||||
a.stateless !== true &&
|
||||
now - aState.selectedAt < MIN_LIFETIME_MS;
|
||||
const bPinned =
|
||||
!!bState?.selected &&
|
||||
b.stateless !== true &&
|
||||
now - bState.selectedAt < MIN_LIFETIME_MS;
|
||||
if (aPinned !== bPinned) return aPinned ? -1 : 1;
|
||||
const aIncumbent = !!aState?.selected;
|
||||
const bIncumbent = !!bState?.selected;
|
||||
@@ -346,12 +381,20 @@ function candidateCompare(a, b, states, now) {
|
||||
function cacheCandidateAnchorScalars(candidate) {
|
||||
let anchorX = Number.NaN;
|
||||
let anchorY = Number.NaN;
|
||||
if (Number.isFinite(candidate?.screenX) && Number.isFinite(candidate?.screenY)) {
|
||||
if (
|
||||
Number.isFinite(candidate?.screenX) &&
|
||||
Number.isFinite(candidate?.screenY)
|
||||
) {
|
||||
anchorX = candidate.screenX;
|
||||
anchorY = candidate.screenY;
|
||||
} else {
|
||||
const placement = Array.isArray(candidate?.placements) ? candidate.placements[0] : null;
|
||||
if (Number.isFinite(placement?.leadFromX) && Number.isFinite(placement?.leadFromY)) {
|
||||
const placement = Array.isArray(candidate?.placements)
|
||||
? candidate.placements[0]
|
||||
: null;
|
||||
if (
|
||||
Number.isFinite(placement?.leadFromX) &&
|
||||
Number.isFinite(placement?.leadFromY)
|
||||
) {
|
||||
anchorX = placement.leadFromX;
|
||||
anchorY = placement.leadFromY;
|
||||
} else if (rectIsFinite(placement?.rect)) {
|
||||
@@ -401,7 +444,16 @@ class SpatialCandidateQueue {
|
||||
* stay unboxed. Few-placement candidates already blocked by the
|
||||
* monotonically growing collision field are dismissed before anchor scans.
|
||||
*/
|
||||
reset(candidates, count, anchors, anchorCount, attemptStamps, stamp, spatial, states) {
|
||||
reset(
|
||||
candidates,
|
||||
count,
|
||||
anchors,
|
||||
anchorCount,
|
||||
attemptStamps,
|
||||
stamp,
|
||||
spatial,
|
||||
states,
|
||||
) {
|
||||
this.candidates = candidates;
|
||||
this.count = count;
|
||||
this.anchorCount = anchorCount;
|
||||
@@ -529,10 +581,13 @@ class SpatialCandidateQueue {
|
||||
const rectY = placementYs[rectIndex];
|
||||
const rectW = placementWs[rectIndex];
|
||||
const rectH = placementHs[rectIndex];
|
||||
if (rectX < anchorRectX + anchorRectW + 4
|
||||
&& rectX + rectW + 4 > anchorRectX
|
||||
&& rectY < anchorRectY + anchorRectH + 4
|
||||
&& rectY + rectH + 4 > anchorRectY) freeMask &= ~bit;
|
||||
if (
|
||||
rectX < anchorRectX + anchorRectW + 4 &&
|
||||
rectX + rectW + 4 > anchorRectX &&
|
||||
rectY < anchorRectY + anchorRectH + 4 &&
|
||||
rectY + rectH + 4 > anchorRectY
|
||||
)
|
||||
freeMask &= ~bit;
|
||||
}
|
||||
placementMasks[i] = freeMask;
|
||||
if (freeMask === 0 && this._dismissBlockedKey(i)) continue;
|
||||
@@ -558,7 +613,8 @@ class SpatialCandidateQueue {
|
||||
bestIndex = i;
|
||||
continue;
|
||||
}
|
||||
const priorityDelta = (Number(candidate.priority) || 0) - (Number(best.priority) || 0);
|
||||
const priorityDelta =
|
||||
(Number(candidate.priority) || 0) - (Number(best.priority) || 0);
|
||||
if (priorityDelta !== 0) {
|
||||
if (priorityDelta > 0) {
|
||||
best = candidate;
|
||||
@@ -621,7 +677,8 @@ function firstFreePlacement(candidate, stickyCorner, spatial) {
|
||||
if (stickyCorner) {
|
||||
for (let i = 0; i < placements.length; i++) {
|
||||
const placement = placements[i];
|
||||
if (placement?.corner !== stickyCorner || !rectIsFinite(placement.rect)) continue;
|
||||
if (placement?.corner !== stickyCorner || !rectIsFinite(placement.rect))
|
||||
continue;
|
||||
if (!spatial.collides(placement.rect)) return placement;
|
||||
}
|
||||
}
|
||||
@@ -790,7 +847,13 @@ export class LabelArbiter {
|
||||
let candidateCount = 0;
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const candidate = source[i];
|
||||
if (!candidate || !candidate.key || !candidate.layerId || !(candidate.keyholeAlpha > 0)) continue;
|
||||
if (
|
||||
!candidate ||
|
||||
!candidate.key ||
|
||||
!candidate.layerId ||
|
||||
!(candidate.keyholeAlpha > 0)
|
||||
)
|
||||
continue;
|
||||
cacheCandidateAnchorScalars(candidate);
|
||||
candidateList[candidateCount++] = candidate;
|
||||
let bucket = byLayer.get(candidate.layerId);
|
||||
@@ -815,13 +878,15 @@ export class LabelArbiter {
|
||||
});
|
||||
} else if (options.demandByLayer != null) {
|
||||
const supplied = demandEntries(options.demandByLayer);
|
||||
for (let i = 0; i < supplied.length; i++) demand.set(supplied[i][0], supplied[i][1]);
|
||||
for (let i = 0; i < supplied.length; i++)
|
||||
demand.set(supplied[i][0], supplied[i][1]);
|
||||
}
|
||||
// A supplied demand map describes the full lightweight field while the
|
||||
// candidate list may be only a bounded materialized cohort. Keep defensive
|
||||
// support for candidate layers omitted by an incomplete caller map.
|
||||
byLayer.forEach((bucket, layerId) => {
|
||||
if (bucket.count > 0 && !demand.has(layerId)) demand.set(layerId, bucket.count);
|
||||
if (bucket.count > 0 && !demand.has(layerId))
|
||||
demand.set(layerId, bucket.count);
|
||||
});
|
||||
|
||||
const activeLayers = this._activeLayersScratch;
|
||||
@@ -857,14 +922,20 @@ export class LabelArbiter {
|
||||
|
||||
let sameActiveLayers = activeCount === this.lastActiveLayers.length;
|
||||
for (let i = 0; sameActiveLayers && i < activeCount; i++) {
|
||||
if (activeLayers[i] !== this.lastActiveLayers[i]) sameActiveLayers = false;
|
||||
if (activeLayers[i] !== this.lastActiveLayers[i])
|
||||
sameActiveLayers = false;
|
||||
}
|
||||
const preserveIncumbents = options.preserveIncumbents !== false && sameActiveLayers;
|
||||
const preserveIncumbents =
|
||||
options.preserveIncumbents !== false && sameActiveLayers;
|
||||
let spatialQueueBuildCount = 0;
|
||||
let spatialQueueNextCount = 0;
|
||||
|
||||
const attempt = (candidate, allowOverlapFallback = false) => {
|
||||
if (selectedCount >= capacity || attemptStamps.get(candidate.key) === stamp) return false;
|
||||
if (
|
||||
selectedCount >= capacity ||
|
||||
attemptStamps.get(candidate.key) === stamp
|
||||
)
|
||||
return false;
|
||||
attemptStamps.set(candidate.key, stamp);
|
||||
const previous = this.states.get(candidate.key);
|
||||
const stateless = candidate.stateless === true;
|
||||
@@ -872,10 +943,12 @@ export class LabelArbiter {
|
||||
// very next solve, as their stateless originals did) and carry no sticky
|
||||
// corner, so above/below is re-decided from geometry every solve instead
|
||||
// of being pinned by whichever side happened to be free once.
|
||||
if (!stateless && !previous?.selected && previous?.cooldownUntil > now) return false;
|
||||
if (!stateless && !previous?.selected && previous?.cooldownUntil > now)
|
||||
return false;
|
||||
const sticky = stateless ? undefined : previous?.corner;
|
||||
let placement = firstFreePlacement(candidate, sticky, spatial);
|
||||
if (!placement && allowOverlapFallback) placement = firstOrderedPlacement(candidate, sticky);
|
||||
if (!placement && allowOverlapFallback)
|
||||
placement = firstOrderedPlacement(candidate, sticky);
|
||||
if (!placement) return false;
|
||||
spatial.add(placement.rect);
|
||||
selectStamps.set(candidate.key, stamp);
|
||||
@@ -953,7 +1026,8 @@ export class LabelArbiter {
|
||||
}
|
||||
let retainedIncumbents = 0;
|
||||
for (let i = 0; i < selectedCount; i++) {
|
||||
if (this.selectedKeys.has(selectedCandidates[i].key)) retainedIncumbents++;
|
||||
if (this.selectedKeys.has(selectedCandidates[i].key))
|
||||
retainedIncumbents++;
|
||||
}
|
||||
|
||||
const exitingStates = this._refreshStateList();
|
||||
@@ -964,7 +1038,9 @@ export class LabelArbiter {
|
||||
state.exitStartedAt = now;
|
||||
// Stateless entries may be re-selected immediately; the cooldown exists
|
||||
// to damp thrash for sources that shipped with hysteresis.
|
||||
state.cooldownUntil = state.stateless ? 0 : now + FADE_OUT_MS + COOLDOWN_MS;
|
||||
state.cooldownUntil = state.stateless
|
||||
? 0
|
||||
: now + FADE_OUT_MS + COOLDOWN_MS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1002,7 +1078,8 @@ export class LabelArbiter {
|
||||
this._stateListDirty = true;
|
||||
}
|
||||
if (labelsByLayer) {
|
||||
labelsByLayer[candidate.layerId] = (labelsByLayer[candidate.layerId] || 0) + 1;
|
||||
labelsByLayer[candidate.layerId] =
|
||||
(labelsByLayer[candidate.layerId] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1013,7 +1090,10 @@ export class LabelArbiter {
|
||||
const livingStates = this._refreshStateList();
|
||||
for (let i = 0; i < livingStates.length; i++) {
|
||||
const state = livingStates[i];
|
||||
if (!state.selected && now - (state.exitStartedAt || now) > FADE_OUT_MS + COOLDOWN_MS) {
|
||||
if (
|
||||
!state.selected &&
|
||||
now - (state.exitStartedAt || now) > FADE_OUT_MS + COOLDOWN_MS
|
||||
) {
|
||||
this.states.delete(state.key);
|
||||
this._stateListDirty = true;
|
||||
}
|
||||
@@ -1026,25 +1106,28 @@ export class LabelArbiter {
|
||||
});
|
||||
for (let i = 0; i < droppedCount; i++) this.selectedKeys.delete(dropped[i]);
|
||||
dropped.length = droppedCount;
|
||||
for (let i = 0; i < selectedCount; i++) this.selectedKeys.add(selectedCandidates[i].key);
|
||||
for (let i = 0; i < selectedCount; i++)
|
||||
this.selectedKeys.add(selectedCandidates[i].key);
|
||||
|
||||
const previousActiveLayers = this.lastActiveLayers;
|
||||
this.lastActiveLayers = activeLayers;
|
||||
this._activeLayersScratch = previousActiveLayers;
|
||||
this.solveRevision++;
|
||||
this.lastDiagnostics = collectDiagnostics ? {
|
||||
strategy,
|
||||
capacity,
|
||||
selectedCount,
|
||||
quotas: Object.fromEntries(quotas),
|
||||
demand: Object.fromEntries(demand),
|
||||
labelsByLayer,
|
||||
solveRevision: this.solveRevision,
|
||||
spatialQueueBuildCount,
|
||||
spatialQueueNextCount,
|
||||
eligibleIncumbents,
|
||||
retainedIncumbents,
|
||||
} : null;
|
||||
this.lastDiagnostics = collectDiagnostics
|
||||
? {
|
||||
strategy,
|
||||
capacity,
|
||||
selectedCount,
|
||||
quotas: Object.fromEntries(quotas),
|
||||
demand: Object.fromEntries(demand),
|
||||
labelsByLayer,
|
||||
solveRevision: this.solveRevision,
|
||||
spatialQueueBuildCount,
|
||||
spatialQueueNextCount,
|
||||
eligibleIncumbents,
|
||||
retainedIncumbents,
|
||||
}
|
||||
: null;
|
||||
selectedCandidates.length = selectedCount;
|
||||
selectedPlacements.length = selectedCount;
|
||||
pruneStampMap(attemptStamps, candidateCount);
|
||||
@@ -1057,9 +1140,15 @@ export class LabelArbiter {
|
||||
* A caller-owned output array enables allocation-free per-frame rendering.
|
||||
*/
|
||||
renderEntries(currentCandidates, now = Date.now(), out = []) {
|
||||
const current = currentCandidates instanceof Map
|
||||
? currentCandidates
|
||||
: new Map((currentCandidates || []).map((candidate) => [candidate.key, candidate]));
|
||||
const current =
|
||||
currentCandidates instanceof Map
|
||||
? currentCandidates
|
||||
: new Map(
|
||||
(currentCandidates || []).map((candidate) => [
|
||||
candidate.key,
|
||||
candidate,
|
||||
]),
|
||||
);
|
||||
const states = this._refreshStateList();
|
||||
let outIndex = 0;
|
||||
for (let i = 0; i < states.length; i++) {
|
||||
@@ -1070,14 +1159,23 @@ export class LabelArbiter {
|
||||
// fully painted this solve or gone. No enter ramp, no exit tail.
|
||||
temporalAlpha = state.selected ? 1 : 0;
|
||||
} else if (state.selected) {
|
||||
temporalAlpha = Math.min(1, Math.max(0, (now - state.enterStartedAt) / FADE_IN_MS));
|
||||
temporalAlpha = Math.min(
|
||||
1,
|
||||
Math.max(0, (now - state.enterStartedAt) / FADE_IN_MS),
|
||||
);
|
||||
} else {
|
||||
temporalAlpha = 1 - Math.min(1, Math.max(0, (now - state.exitStartedAt) / FADE_OUT_MS));
|
||||
temporalAlpha =
|
||||
1 -
|
||||
Math.min(1, Math.max(0, (now - state.exitStartedAt) / FADE_OUT_MS));
|
||||
}
|
||||
if (temporalAlpha <= 0) continue;
|
||||
const candidate = current.get(state.key) || state.lastCandidate;
|
||||
if (!candidate) continue;
|
||||
const placement = renderPlacement(candidate, state.stateless ? undefined : state.corner, state.lastPlacement);
|
||||
const placement = renderPlacement(
|
||||
candidate,
|
||||
state.stateless ? undefined : state.corner,
|
||||
state.lastPlacement,
|
||||
);
|
||||
if (!placement) continue;
|
||||
const entry = out[outIndex] || (out[outIndex] = {});
|
||||
entry.candidate = candidate;
|
||||
@@ -1100,11 +1198,16 @@ export class LabelArbiter {
|
||||
for (const state of this.states.values()) {
|
||||
if (!state.selected) {
|
||||
if (!includeFading) continue;
|
||||
if (!Number.isFinite(state.exitStartedAt) || now - state.exitStartedAt >= FADE_OUT_MS) continue;
|
||||
if (
|
||||
!Number.isFinite(state.exitStartedAt) ||
|
||||
now - state.exitStartedAt >= FADE_OUT_MS
|
||||
)
|
||||
continue;
|
||||
}
|
||||
const candidate = state.lastCandidate;
|
||||
if (!candidate?.layerId || candidate.sourceId == null) continue;
|
||||
if (!grouped.has(candidate.layerId)) grouped.set(candidate.layerId, new Set());
|
||||
if (!grouped.has(candidate.layerId))
|
||||
grouped.set(candidate.layerId, new Set());
|
||||
grouped.get(candidate.layerId).add(candidate.sourceId);
|
||||
}
|
||||
return grouped;
|
||||
|
||||
+438
-179
File diff suppressed because it is too large.
Load diff
@@ -11,8 +11,10 @@ export * from './localGeojsonCore.js';
|
||||
export function createLocalGeoJsonLayer(options) {
|
||||
return createLayer(options, {
|
||||
...localGeoJsonServices,
|
||||
overlayHost: options.overlayHost === undefined
|
||||
? localGeoJsonServices.overlayHost : options.overlayHost,
|
||||
overlayHost:
|
||||
options.overlayHost === undefined
|
||||
? localGeoJsonServices.overlayHost
|
||||
: options.overlayHost,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +22,9 @@ export function createLocalGeoJsonLayer(options) {
|
||||
export function createLocalInfrastructureOverlayPublisher(options) {
|
||||
return createPublisher({
|
||||
...options,
|
||||
host: options.host === undefined ? localGeoJsonServices.overlayHost : options.host,
|
||||
host:
|
||||
options.host === undefined
|
||||
? localGeoJsonServices.overlayHost
|
||||
: options.host,
|
||||
});
|
||||
}
|
||||
@@ -15,9 +15,4 @@ const fires = createFirmsHeatmapLayer({
|
||||
source: 'NASA FIRMS · LIVE',
|
||||
});
|
||||
|
||||
export default [
|
||||
datacenters,
|
||||
dams,
|
||||
submarineCablesLayer,
|
||||
fires,
|
||||
];
|
||||
export default [datacenters, dams, submarineCablesLayer, fires];
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createMeshFloorSampler } from '../services/meshFloorSampler.js';
|
||||
import * as groundFloor from './groundFloor.js';
|
||||
const meshFloorSampler = createMeshFloorSampler({ groundFloor, eventTarget: typeof window === 'undefined' ? null : window });
|
||||
const meshFloorSampler = createMeshFloorSampler({
|
||||
groundFloor,
|
||||
eventTarget: typeof window === 'undefined' ? null : window,
|
||||
});
|
||||
export const { sampleMeshFloorCells, cachedGroundFloor } = meshFloorSampler;
|
||||
@@ -18,10 +18,13 @@ export const AWARENESS_RELATIONSHIP = Object.freeze({
|
||||
* @param {{initialRadiusM?: number, maxRadiusM?: number}} [options] Bounds.
|
||||
* @returns {{candidate: T, radiusM: number}|null} First match and radius used.
|
||||
*/
|
||||
export function findByDoublingRadius(search, {
|
||||
initialRadiusM = AWARENESS_RADIUS_M,
|
||||
maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
|
||||
} = {}) {
|
||||
export function findByDoublingRadius(
|
||||
search,
|
||||
{
|
||||
initialRadiusM = AWARENESS_RADIUS_M,
|
||||
maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
|
||||
} = {},
|
||||
) {
|
||||
if (typeof search !== 'function') return null;
|
||||
if (!Number.isFinite(initialRadiusM) || initialRadiusM <= 0) return null;
|
||||
if (!Number.isFinite(maxRadiusM) || maxRadiusM < initialRadiusM) return null;
|
||||
@@ -39,13 +42,22 @@ export function findByDoublingRadius(search, {
|
||||
/** @param {number} meters @returns {string} */
|
||||
export function formatAwarenessDistance(meters) {
|
||||
if (!Number.isFinite(meters) || meters < 0) return '—';
|
||||
return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
|
||||
return meters < 1000
|
||||
? `${Math.round(meters)} m`
|
||||
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
|
||||
}
|
||||
|
||||
/** Return a stable visible label without leaking missing values into UI text. */
|
||||
export function formatAwarenessLabel(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
for (const candidate of [value.callsign, value.name, value.label, value.id, value.icao24, value.mmsi]) {
|
||||
for (const candidate of [
|
||||
value.callsign,
|
||||
value.name,
|
||||
value.label,
|
||||
value.id,
|
||||
value.icao24,
|
||||
value.mmsi,
|
||||
]) {
|
||||
const label = formatAwarenessLabel(candidate);
|
||||
if (label !== '—') return label;
|
||||
}
|
||||
@@ -61,19 +73,41 @@ export function formatAwarenessLabel(value) {
|
||||
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range
|
||||
* claim in this MVP.
|
||||
*/
|
||||
export function summarizeAwarenessCohort(items, { available = true, stale = false, limit = 3 } = {}) {
|
||||
if (!available) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed unavailable' };
|
||||
if (stale) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed stale' };
|
||||
export function summarizeAwarenessCohort(
|
||||
items,
|
||||
{ available = true, stale = false, limit = 3 } = {},
|
||||
) {
|
||||
if (!available)
|
||||
return {
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: 'feed unavailable',
|
||||
};
|
||||
if (stale)
|
||||
return {
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: 'feed stale',
|
||||
};
|
||||
const normalized = (Array.isArray(items) ? items : [])
|
||||
.filter((item) => Number.isFinite(item?.distanceM ?? item?.distance)
|
||||
&& (item.distanceM ?? item.distance) >= 0)
|
||||
.filter(
|
||||
(item) =>
|
||||
Number.isFinite(item?.distanceM ?? item?.distance) &&
|
||||
(item.distanceM ?? item.distance) >= 0,
|
||||
)
|
||||
.map((item) => ({ ...item, distanceM: item.distanceM ?? item.distance }))
|
||||
.sort((a, b) => a.distanceM - b.distanceM);
|
||||
return {
|
||||
relationship: normalized.length ? AWARENESS_RELATIONSHIP.NEARBY : AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
relationship: normalized.length
|
||||
? AWARENESS_RELATIONSHIP.NEARBY
|
||||
: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: normalized.length,
|
||||
nearest: normalized.slice(0, limit),
|
||||
reason: normalized.length ? 'observed or mapped nearby context' : 'no observed or mapped objects in current feeds',
|
||||
reason: normalized.length
|
||||
? 'observed or mapped nearby context'
|
||||
: 'no observed or mapped objects in current feeds',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,21 +118,40 @@ export function summarizeAwarenessCohort(items, { available = true, stale = fals
|
||||
* @param {Iterable<string>} [visitedKeys] Previously visited layer/id keys.
|
||||
* @returns {Array<{layerId: string, id: string, item: Object}>} Selectable targets.
|
||||
*/
|
||||
export function getAwarenessNavigationTargets(cohorts, subject = null, visitedKeys = []) {
|
||||
export function getAwarenessNavigationTargets(
|
||||
cohorts,
|
||||
subject = null,
|
||||
visitedKeys = [],
|
||||
) {
|
||||
const visited = new Set(visitedKeys);
|
||||
const targets = [];
|
||||
for (const cohort of Array.isArray(cohorts) ? cohorts : []) {
|
||||
const layerId = cohort?.id;
|
||||
if (!['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(layerId)) continue;
|
||||
if (
|
||||
![
|
||||
'flights',
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
].includes(layerId)
|
||||
)
|
||||
continue;
|
||||
const sourceItems = Array.isArray(cohort?.summary?.navigationNearest)
|
||||
? cohort.summary.navigationNearest
|
||||
: (Array.isArray(cohort?.summary?.nearest) ? cohort.summary.nearest : []);
|
||||
: Array.isArray(cohort?.summary?.nearest)
|
||||
? cohort.summary.nearest
|
||||
: [];
|
||||
for (const item of sourceItems) {
|
||||
const id = item?.icao24 || item?.mmsi || item?.id;
|
||||
if (!id) continue;
|
||||
const key = `${layerId}:${id}`;
|
||||
if (key === `${subject?.layerId}:${subject?.id}`) continue;
|
||||
targets.push({ layerId, id: String(id), item, visited: visited.has(key) });
|
||||
targets.push({
|
||||
layerId,
|
||||
id: String(id),
|
||||
item,
|
||||
visited: visited.has(key),
|
||||
});
|
||||
}
|
||||
}
|
||||
targets.sort((a, b) => {
|
||||
@@ -108,7 +161,9 @@ export function getAwarenessNavigationTargets(cohorts, subject = null, visitedKe
|
||||
// reached vessels, installations, or the other flight layer at all.
|
||||
const visitedOrder = Number(a.visited) - Number(b.visited);
|
||||
if (visitedOrder) return visitedOrder;
|
||||
const subjectOrder = Number(a.layerId !== subject?.layerId) - Number(b.layerId !== subject?.layerId);
|
||||
const subjectOrder =
|
||||
Number(a.layerId !== subject?.layerId) -
|
||||
Number(b.layerId !== subject?.layerId);
|
||||
if (subjectOrder) return subjectOrder;
|
||||
return (a.item.distanceM ?? Infinity) - (b.item.distanceM ?? Infinity);
|
||||
});
|
||||
|
||||
@@ -37,10 +37,14 @@ const MAX_FOOTPRINT_POINTS = 400;
|
||||
* @returns {string} Display label, never empty.
|
||||
*/
|
||||
export function humanizeInstallationClass(klass) {
|
||||
const key = String(klass || '').trim().toLowerCase();
|
||||
const key = String(klass || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (LABEL_BY_CLASS[key]) return LABEL_BY_CLASS[key];
|
||||
const words = key.replaceAll('_', ' ').trim();
|
||||
return words ? `${words[0].toUpperCase()}${words.slice(1)}` : 'Mapped installation';
|
||||
return words
|
||||
? `${words[0].toUpperCase()}${words.slice(1)}`
|
||||
: 'Mapped installation';
|
||||
}
|
||||
|
||||
function finiteLatitude(value) {
|
||||
@@ -54,7 +58,8 @@ function finiteLongitude(value) {
|
||||
function pointFrom(element) {
|
||||
const lat = Number(element?.lat ?? element?.center?.lat);
|
||||
const longitude = Number(element?.lon ?? element?.center?.lon);
|
||||
if (finiteLatitude(lat) && finiteLongitude(longitude)) return { latitude: lat, longitude };
|
||||
if (finiteLatitude(lat) && finiteLongitude(longitude))
|
||||
return { latitude: lat, longitude };
|
||||
|
||||
// Bounds midpoint fallback (field test 2026-08-28, Warendorf: nothing drawn).
|
||||
// The proxy asks for `out center tags geom`, but Overpass takes the LAST
|
||||
@@ -67,8 +72,12 @@ function pointFrom(element) {
|
||||
// recovers all of them without touching the query or the footprint path.
|
||||
const bounds = element?.bounds;
|
||||
// JSON nulls, booleans, and empty strings must not coerce to a false 0,0 site.
|
||||
if (![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon]
|
||||
.every((value) => typeof value === 'number' && Number.isFinite(value))) return null;
|
||||
if (
|
||||
![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon].every(
|
||||
(value) => typeof value === 'number' && Number.isFinite(value),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
const south = Number(bounds?.minlat);
|
||||
const west = Number(bounds?.minlon);
|
||||
const north = Number(bounds?.maxlat);
|
||||
@@ -79,17 +88,28 @@ function pointFrom(element) {
|
||||
// A single installation footprint is never wider than the request bbox cap
|
||||
// (10°): an ascending box that spans the antimeridian (minlon -179, maxlon
|
||||
// 179) passes the ordering check yet midpoints to longitude 0.
|
||||
if (finiteLatitude(south) && finiteLatitude(north)
|
||||
&& finiteLongitude(west) && finiteLongitude(east)
|
||||
&& south <= north && west <= east
|
||||
&& north - south <= 10 && east - west <= 10) {
|
||||
if (
|
||||
finiteLatitude(south) &&
|
||||
finiteLatitude(north) &&
|
||||
finiteLongitude(west) &&
|
||||
finiteLongitude(east) &&
|
||||
south <= north &&
|
||||
west <= east &&
|
||||
north - south <= 10 &&
|
||||
east - west <= 10
|
||||
) {
|
||||
return { latitude: (south + north) / 2, longitude: (west + east) / 2 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function footprintFrom(element) {
|
||||
if (!Array.isArray(element?.geometry) || element.geometry.length < 3 || element.geometry.length > MAX_FOOTPRINT_POINTS) return null;
|
||||
if (
|
||||
!Array.isArray(element?.geometry) ||
|
||||
element.geometry.length < 3 ||
|
||||
element.geometry.length > MAX_FOOTPRINT_POINTS
|
||||
)
|
||||
return null;
|
||||
const points = [];
|
||||
for (const point of element.geometry) {
|
||||
const latitude = Number(point?.lat);
|
||||
@@ -106,21 +126,30 @@ function footprintFrom(element) {
|
||||
* @param {string} [retrievedAt]
|
||||
* @returns {{records: Array, droppedCount: number}}
|
||||
*/
|
||||
export function normalizeMilitaryInstallations(payload, retrievedAt = new Date().toISOString()) {
|
||||
export function normalizeMilitaryInstallations(
|
||||
payload,
|
||||
retrievedAt = new Date().toISOString(),
|
||||
) {
|
||||
const records = [];
|
||||
const ids = new Set();
|
||||
let droppedCount = 0;
|
||||
for (const element of Array.isArray(payload?.elements) ? payload.elements : []) {
|
||||
for (const element of Array.isArray(payload?.elements)
|
||||
? payload.elements
|
||||
: []) {
|
||||
const type = String(element?.type || '');
|
||||
const osmId = Number(element?.id);
|
||||
if (!['node', 'way', 'relation'].includes(type) || !Number.isSafeInteger(osmId)) {
|
||||
if (
|
||||
!['node', 'way', 'relation'].includes(type) ||
|
||||
!Number.isSafeInteger(osmId)
|
||||
) {
|
||||
droppedCount += 1;
|
||||
continue;
|
||||
}
|
||||
const id = `osm:${type}:${osmId}`;
|
||||
const tags = element?.tags || {};
|
||||
const klass = CLASS_BY_MILITARY_TAG[String(tags.military || '').toLowerCase()]
|
||||
|| (tags.landuse === 'military' ? 'military_land' : null);
|
||||
const klass =
|
||||
CLASS_BY_MILITARY_TAG[String(tags.military || '').toLowerCase()] ||
|
||||
(tags.landuse === 'military' ? 'military_land' : null);
|
||||
const point = pointFrom(element);
|
||||
if (!klass || !point || ids.has(id)) {
|
||||
droppedCount += 1;
|
||||
@@ -134,7 +163,9 @@ export function normalizeMilitaryInstallations(payload, retrievedAt = new Date()
|
||||
// geometry, while a footprint-less way/relation has unknown extent.
|
||||
osmType: type,
|
||||
class: klass,
|
||||
name: String(tags.name || tags['name:en'] || '').trim() || humanizeInstallationClass(klass),
|
||||
name:
|
||||
String(tags.name || tags['name:en'] || '').trim() ||
|
||||
humanizeInstallationClass(klass),
|
||||
...point,
|
||||
footprint: footprintFrom(element),
|
||||
sources: [{ name: 'OpenStreetMap', id: `${type}/${osmId}`, retrievedAt }],
|
||||
@@ -152,6 +183,14 @@ export function isValidInstallationBoundingBox(value) {
|
||||
const west = Number(box.west);
|
||||
const north = Number(box.north);
|
||||
const east = Number(box.east);
|
||||
return finiteLatitude(south) && finiteLatitude(north) && finiteLongitude(west) && finiteLongitude(east)
|
||||
&& south < north && west < east && north - south <= 10 && east - west <= 10;
|
||||
return (
|
||||
finiteLatitude(south) &&
|
||||
finiteLatitude(north) &&
|
||||
finiteLongitude(west) &&
|
||||
finiteLongitude(east) &&
|
||||
south < north &&
|
||||
west < east &&
|
||||
north - south <= 10 &&
|
||||
east - west <= 10
|
||||
);
|
||||
}
|
||||
@@ -77,7 +77,9 @@ export const MODEL_TRAIL_ANCHOR_NATIVE = Object.freeze({
|
||||
* trail anchor has to ride the same chain or it lands on a different axis than
|
||||
* the aircraft it is supposed to be attached to. */
|
||||
const AXIS_CORRECTION = Cesium.Matrix4.multiplyTransformation(
|
||||
Cesium.Axis.Y_UP_TO_Z_UP, Cesium.Axis.Z_UP_TO_X_UP, new Cesium.Matrix4(),
|
||||
Cesium.Axis.Y_UP_TO_Z_UP,
|
||||
Cesium.Axis.Z_UP_TO_X_UP,
|
||||
new Cesium.Matrix4(),
|
||||
);
|
||||
const _anchorScratch = new Cesium.Cartesian3();
|
||||
const _chainScratch = new Cesium.Matrix4();
|
||||
@@ -124,7 +126,11 @@ export function modelAnchorWorld(model, nativeAnchor, result) {
|
||||
components || Cesium.Matrix4.IDENTITY,
|
||||
_chainScratch,
|
||||
);
|
||||
Cesium.Matrix4.multiplyTransformation(_chainScratch, AXIS_CORRECTION, _chainScratch);
|
||||
Cesium.Matrix4.multiplyTransformation(
|
||||
_chainScratch,
|
||||
AXIS_CORRECTION,
|
||||
_chainScratch,
|
||||
);
|
||||
return Cesium.Matrix4.multiplyByPoint(_chainScratch, _anchorScratch, result);
|
||||
}
|
||||
|
||||
@@ -220,9 +226,17 @@ export function trailHeadStart(start, anchor, center, radiusM, result) {
|
||||
// a real trail.
|
||||
if (!anchor || !center || !result) return start;
|
||||
if (!Number.isFinite(radiusM) || radiusM <= 0) return start;
|
||||
const startD = Math.hypot(start.x - center.x, start.y - center.y, start.z - center.z);
|
||||
const startD = Math.hypot(
|
||||
start.x - center.x,
|
||||
start.y - center.y,
|
||||
start.z - center.z,
|
||||
);
|
||||
if (startD >= radiusM) return start;
|
||||
const anchorD = Math.hypot(anchor.x - center.x, anchor.y - center.y, anchor.z - center.z);
|
||||
const anchorD = Math.hypot(
|
||||
anchor.x - center.x,
|
||||
anchor.y - center.y,
|
||||
anchor.z - center.z,
|
||||
);
|
||||
// A degenerate shell (an anchor at or outside the envelope — no shipped asset
|
||||
// has one, since every anchor is a measured hull point) leaves nothing to
|
||||
// ramp across; fall back to the containment verdict rather than divide by it.
|
||||
@@ -252,8 +266,20 @@ export function modelVisualAnchor(modelMatrix, nativeCenter, scale, result) {
|
||||
const x = (nativeCenter?.[0] || 0) * safeScale;
|
||||
const y = (nativeCenter?.[1] || 0) * safeScale;
|
||||
const z = (nativeCenter?.[2] || 0) * safeScale;
|
||||
result.x = modelMatrix[0] * x + modelMatrix[4] * y + modelMatrix[8] * z + modelMatrix[12];
|
||||
result.y = modelMatrix[1] * x + modelMatrix[5] * y + modelMatrix[9] * z + modelMatrix[13];
|
||||
result.z = modelMatrix[2] * x + modelMatrix[6] * y + modelMatrix[10] * z + modelMatrix[14];
|
||||
result.x =
|
||||
modelMatrix[0] * x +
|
||||
modelMatrix[4] * y +
|
||||
modelMatrix[8] * z +
|
||||
modelMatrix[12];
|
||||
result.y =
|
||||
modelMatrix[1] * x +
|
||||
modelMatrix[5] * y +
|
||||
modelMatrix[9] * z +
|
||||
modelMatrix[13];
|
||||
result.z =
|
||||
modelMatrix[2] * x +
|
||||
modelMatrix[6] * y +
|
||||
modelMatrix[10] * z +
|
||||
modelMatrix[14];
|
||||
return result;
|
||||
}
|
||||
+102
-27
@@ -29,10 +29,14 @@ export function norm360(deg) {
|
||||
export function liftRepeatedGroundFix(newest, candidatePosition, onGround) {
|
||||
if (!onGround || !newest?.position || !candidatePosition) return false;
|
||||
const stored = Cesium.Cartographic.fromCartesian(
|
||||
newest.position, Cesium.Ellipsoid.WGS84, _mmCarto,
|
||||
newest.position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_mmCarto,
|
||||
);
|
||||
const candidate = Cesium.Cartographic.fromCartesian(
|
||||
candidatePosition, Cesium.Ellipsoid.WGS84, _mmCandidateCarto,
|
||||
candidatePosition,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_mmCandidateCarto,
|
||||
);
|
||||
if (!stored || !candidate || candidate.height <= stored.height) return false;
|
||||
Cesium.Cartesian3.fromRadians(
|
||||
@@ -62,7 +66,11 @@ export function courseBetweenCartesians(from, to, minChordM = 25) {
|
||||
const dy = to.y - from.y;
|
||||
const dz = to.z - from.z;
|
||||
if (dx * dx + dy * dy + dz * dz < minChordM * minChordM) return null;
|
||||
const carto = Cesium.Cartographic.fromCartesian(from, Cesium.Ellipsoid.WGS84, _mmCarto);
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
from,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_mmCarto,
|
||||
);
|
||||
if (!carto) return null;
|
||||
const sLat = Math.sin(carto.latitude);
|
||||
const cLat = Math.cos(carto.latitude);
|
||||
@@ -110,7 +118,10 @@ export function speedRamp(speedMps) {
|
||||
if (!Number.isFinite(speedMps)) return 1;
|
||||
if (speedMps <= COURSE_TRACK_ONLY_MPS) return 0;
|
||||
if (speedMps >= COURSE_CHORD_ONLY_MPS) return 1;
|
||||
return (speedMps - COURSE_TRACK_ONLY_MPS) / (COURSE_CHORD_ONLY_MPS - COURSE_TRACK_ONLY_MPS);
|
||||
return (
|
||||
(speedMps - COURSE_TRACK_ONLY_MPS) /
|
||||
(COURSE_CHORD_ONLY_MPS - COURSE_TRACK_ONLY_MPS)
|
||||
);
|
||||
}
|
||||
|
||||
/** Shortest-arc angle lerp: from → to by t (t=0 → from, t=1 → to). Also the
|
||||
@@ -141,10 +152,16 @@ export function displayedKinematics({
|
||||
reportedTrackDeg,
|
||||
} = {}) {
|
||||
return {
|
||||
speedMps: Number.isFinite(derivedSpeedMps) ? Math.max(0, derivedSpeedMps)
|
||||
: (Number.isFinite(reportedSpeedMps) ? Math.max(0, reportedSpeedMps) : null),
|
||||
trackDeg: Number.isFinite(derivedTrackDeg) ? derivedTrackDeg
|
||||
: (Number.isFinite(reportedTrackDeg) ? reportedTrackDeg : null),
|
||||
speedMps: Number.isFinite(derivedSpeedMps)
|
||||
? Math.max(0, derivedSpeedMps)
|
||||
: Number.isFinite(reportedSpeedMps)
|
||||
? Math.max(0, reportedSpeedMps)
|
||||
: null,
|
||||
trackDeg: Number.isFinite(derivedTrackDeg)
|
||||
? derivedTrackDeg
|
||||
: Number.isFinite(reportedTrackDeg)
|
||||
? reportedTrackDeg
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -167,10 +184,17 @@ export function staleCoastLimitSeconds({
|
||||
maximumSec = 300,
|
||||
} = {}) {
|
||||
const floor = Math.max(0, Number.isFinite(minimumSec) ? minimumSec : 60);
|
||||
const ceiling = Math.max(floor, Number.isFinite(maximumSec) ? maximumSec : 300);
|
||||
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(lastContactEpochMs)) return floor;
|
||||
const ceiling = Math.max(
|
||||
floor,
|
||||
Number.isFinite(maximumSec) ? maximumSec : 300,
|
||||
);
|
||||
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(lastContactEpochMs))
|
||||
return floor;
|
||||
const contactLeadSec = Math.max(0, (lastContactEpochMs - fixEpochMs) / 1000);
|
||||
const grace = Math.max(0, Number.isFinite(contactGraceSec) ? contactGraceSec : 60);
|
||||
const grace = Math.max(
|
||||
0,
|
||||
Number.isFinite(contactGraceSec) ? contactGraceSec : 60,
|
||||
);
|
||||
return Math.min(ceiling, Math.max(floor, contactLeadSec + grace));
|
||||
}
|
||||
|
||||
@@ -197,18 +221,31 @@ export function limitCourseStep(prevDeg, targetDeg, maxDegPerSec, dtSec) {
|
||||
* is GPS-vector noise, not a turn (samples without a speed keep the legacy
|
||||
* behavior: no way to tell, so they still count).
|
||||
*/
|
||||
export function estimateTurnRateDps(samples, noiseFloorDps = 0.4, maxDps = 4, minSpeedMps = 0) {
|
||||
export function estimateTurnRateDps(
|
||||
samples,
|
||||
noiseFloorDps = 0.4,
|
||||
maxDps = 4,
|
||||
minSpeedMps = 0,
|
||||
) {
|
||||
if (!samples || samples.length < 2) return 0;
|
||||
let sum = 0;
|
||||
let n = 0;
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
const dt = samples[i].tSec - samples[i - 1].tSec;
|
||||
if (dt < 2 || dt > 120) continue;
|
||||
if (!Number.isFinite(samples[i].trackDeg) || !Number.isFinite(samples[i - 1].trackDeg)) continue;
|
||||
if (
|
||||
!Number.isFinite(samples[i].trackDeg) ||
|
||||
!Number.isFinite(samples[i - 1].trackDeg)
|
||||
)
|
||||
continue;
|
||||
if (minSpeedMps > 0) {
|
||||
const v0 = samples[i - 1].speedMps;
|
||||
const v1 = samples[i].speedMps;
|
||||
if ((Number.isFinite(v0) && v0 < minSpeedMps) || (Number.isFinite(v1) && v1 < minSpeedMps)) continue;
|
||||
if (
|
||||
(Number.isFinite(v0) && v0 < minSpeedMps) ||
|
||||
(Number.isFinite(v1) && v1 < minSpeedMps)
|
||||
)
|
||||
continue;
|
||||
}
|
||||
sum += norm180(samples[i].trackDeg - samples[i - 1].trackDeg) / dt;
|
||||
n += 1;
|
||||
@@ -274,13 +311,31 @@ const _scratchProjectArc = { east: 0, north: 0, endCourseDeg: 0 };
|
||||
* @param {number} dtSec - Seconds ahead; 0 or negative returns the start.
|
||||
* @returns {{lat: number, lon: number}} Projected coordinate, degrees.
|
||||
*/
|
||||
export function projectGroundArcLatLon(lat, lon, courseDeg, speedMps, turnRateDps, dtSec) {
|
||||
export function projectGroundArcLatLon(
|
||||
lat,
|
||||
lon,
|
||||
courseDeg,
|
||||
speedMps,
|
||||
turnRateDps,
|
||||
dtSec,
|
||||
) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return { lat, lon };
|
||||
if (!Number.isFinite(courseDeg) || !Number.isFinite(speedMps) || !Number.isFinite(dtSec)
|
||||
|| speedMps <= 0 || dtSec <= 0) {
|
||||
if (
|
||||
!Number.isFinite(courseDeg) ||
|
||||
!Number.isFinite(speedMps) ||
|
||||
!Number.isFinite(dtSec) ||
|
||||
speedMps <= 0 ||
|
||||
dtSec <= 0
|
||||
) {
|
||||
return { lat, lon };
|
||||
}
|
||||
const off = arcOffsetEnu(speedMps, courseDeg, turnRateDps || 0, dtSec, _scratchProjectArc);
|
||||
const off = arcOffsetEnu(
|
||||
speedMps,
|
||||
courseDeg,
|
||||
turnRateDps || 0,
|
||||
dtSec,
|
||||
_scratchProjectArc,
|
||||
);
|
||||
// Guard the pole: cos(lat) → 0 would send the longitude to infinity.
|
||||
const cosLat = Math.max(Math.cos(lat * DEG), 1e-6);
|
||||
return {
|
||||
@@ -320,8 +375,15 @@ export function projectGroundArcLatLon(lat, lon, courseDeg, speedMps, turnRateDp
|
||||
* @returns {Array<{lat: number, lon: number}>} Polyline, display position first.
|
||||
*/
|
||||
export function corridorPathLatLon({
|
||||
extrapolating, displayLat, displayLon, courseDeg, speedMps, turnRateDps = 0,
|
||||
fixLat, fixLon, lookaheadSec,
|
||||
extrapolating,
|
||||
displayLat,
|
||||
displayLon,
|
||||
courseDeg,
|
||||
speedMps,
|
||||
turnRateDps = 0,
|
||||
fixLat,
|
||||
fixLon,
|
||||
lookaheadSec,
|
||||
}) {
|
||||
if (!Number.isFinite(displayLat) || !Number.isFinite(displayLon)) return [];
|
||||
const start = { lat: displayLat, lon: displayLon };
|
||||
@@ -338,9 +400,16 @@ export function corridorPathLatLon({
|
||||
const n = Math.max(1, Math.ceil(arcLengthM / CORRIDOR_SAMPLE_SPACING_M));
|
||||
const out = [start];
|
||||
for (let i = 1; i <= n; i++) {
|
||||
out.push(projectGroundArcLatLon(
|
||||
displayLat, displayLon, courseDeg, speed, turnRateDps, cappedSec * (i / n),
|
||||
));
|
||||
out.push(
|
||||
projectGroundArcLatLon(
|
||||
displayLat,
|
||||
displayLon,
|
||||
courseDeg,
|
||||
speed,
|
||||
turnRateDps,
|
||||
cappedSec * (i / n),
|
||||
),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -385,13 +454,17 @@ const _forwardFixOffset = new Cesium.Cartesian3();
|
||||
* velocity: number, track: number}|null} Forward synthetic fix, or null.
|
||||
*/
|
||||
export function synthesizeForwardKinematicsFix(newest, next = {}) {
|
||||
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs)) return null;
|
||||
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs))
|
||||
return null;
|
||||
const startEpochMs = Number.isFinite(newest.epochMs)
|
||||
? newest.epochMs
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime();
|
||||
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs) return null;
|
||||
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs)
|
||||
return null;
|
||||
|
||||
const previousVelocity = Number.isFinite(newest.velocity) ? Math.max(0, newest.velocity) : 0;
|
||||
const previousVelocity = Number.isFinite(newest.velocity)
|
||||
? Math.max(0, newest.velocity)
|
||||
: 0;
|
||||
const previousTrack = Number.isFinite(newest.track) ? newest.track : 0;
|
||||
const dtSec = (next.epochMs - startEpochMs) / 1000;
|
||||
arcOffsetEnu(
|
||||
@@ -421,7 +494,9 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
|
||||
time: Cesium.JulianDate.fromDate(new Date(next.epochMs)),
|
||||
epochMs: next.epochMs,
|
||||
position,
|
||||
velocity: Number.isFinite(next.velocity) ? Math.max(0, next.velocity) : previousVelocity,
|
||||
velocity: Number.isFinite(next.velocity)
|
||||
? Math.max(0, next.velocity)
|
||||
: previousVelocity,
|
||||
track: Number.isFinite(next.track) ? next.track : previousTrack,
|
||||
};
|
||||
}
|
||||
@@ -32,7 +32,8 @@ export function resolvePickId(picked) {
|
||||
if (id === null || id === undefined) return undefined;
|
||||
if (typeof id === 'object') {
|
||||
// AIS vessel record (id object with .mmsi) or Cesium Entity (.id string)
|
||||
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number') return id.mmsi;
|
||||
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number')
|
||||
return id.mmsi;
|
||||
if (typeof id.id === 'string' || typeof id.id === 'number') return id.id;
|
||||
return undefined;
|
||||
}
|
||||
@@ -40,7 +41,7 @@ export function resolvePickId(picked) {
|
||||
};
|
||||
let id = unwrap(picked.id);
|
||||
if (id === undefined) id = unwrap(picked.primitive?.id);
|
||||
return (typeof id === 'string' || typeof id === 'number') ? String(id) : null;
|
||||
return typeof id === 'string' || typeof id === 'number' ? String(id) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+22
-13
@@ -1,6 +1,7 @@
|
||||
const RADIO_COUNTRY_MAX_LENGTH = 80;
|
||||
|
||||
const ISO_ALPHA_2_CODES = Object.freeze(`
|
||||
const ISO_ALPHA_2_CODES = Object.freeze(
|
||||
`
|
||||
AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
|
||||
CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR
|
||||
GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO
|
||||
@@ -8,12 +9,16 @@ JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME
|
||||
MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO
|
||||
RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV
|
||||
TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW
|
||||
`.trim().split(/\s+/));
|
||||
`
|
||||
.trim()
|
||||
.split(/\s+/),
|
||||
);
|
||||
|
||||
const ISO_ALPHA_2_SET = new Set(ISO_ALPHA_2_CODES);
|
||||
const ENGLISH_REGION_NAMES = typeof Intl?.DisplayNames === 'function'
|
||||
? new Intl.DisplayNames(['en'], { type: 'region' })
|
||||
: null;
|
||||
const ENGLISH_REGION_NAMES =
|
||||
typeof Intl?.DisplayNames === 'function'
|
||||
? new Intl.DisplayNames(['en'], { type: 'region' })
|
||||
: null;
|
||||
|
||||
function countryKey(value) {
|
||||
return String(value || '')
|
||||
@@ -29,7 +34,8 @@ function countryKey(value) {
|
||||
const COUNTRY_NAME_TO_CODE = new Map();
|
||||
for (const code of ISO_ALPHA_2_CODES) {
|
||||
const displayName = ENGLISH_REGION_NAMES?.of(code);
|
||||
if (displayName && displayName !== code) COUNTRY_NAME_TO_CODE.set(countryKey(displayName), code);
|
||||
if (displayName && displayName !== code)
|
||||
COUNTRY_NAME_TO_CODE.set(countryKey(displayName), code);
|
||||
}
|
||||
|
||||
for (const [name, code] of Object.entries({
|
||||
@@ -71,7 +77,8 @@ for (const [name, code] of Object.entries({
|
||||
'east timor': 'TL',
|
||||
'cabo verde': 'CV',
|
||||
vatican: 'VA',
|
||||
})) COUNTRY_NAME_TO_CODE.set(countryKey(name), code);
|
||||
}))
|
||||
COUNTRY_NAME_TO_CODE.set(countryKey(name), code);
|
||||
|
||||
function canonicalCountryName(code) {
|
||||
return ENGLISH_REGION_NAMES?.of(code) || code;
|
||||
@@ -90,17 +97,19 @@ export function normalizeRadioCountryInput(value) {
|
||||
}
|
||||
const trimmed = value.normalize('NFKC').trim().replace(/\s+/g, ' ');
|
||||
if (
|
||||
!trimmed
|
||||
|| trimmed.length > RADIO_COUNTRY_MAX_LENGTH
|
||||
|| /[\u0000-\u001f\u007f]/.test(trimmed)
|
||||
|| !/^[\p{L}\p{M}.&'’()\-\s]+$/u.test(trimmed)
|
||||
) return Object.freeze({ valid: false, empty: false, code: '', name: '' });
|
||||
!trimmed ||
|
||||
trimmed.length > RADIO_COUNTRY_MAX_LENGTH ||
|
||||
/[\u0000-\u001f\u007f]/.test(trimmed) ||
|
||||
!/^[\p{L}\p{M}.&'’()\-\s]+$/u.test(trimmed)
|
||||
)
|
||||
return Object.freeze({ valid: false, empty: false, code: '', name: '' });
|
||||
|
||||
const possibleCode = trimmed.toUpperCase();
|
||||
const code = ISO_ALPHA_2_SET.has(possibleCode)
|
||||
? possibleCode
|
||||
: COUNTRY_NAME_TO_CODE.get(countryKey(trimmed)) || '';
|
||||
if (!code) return Object.freeze({ valid: false, empty: false, code: '', name: '' });
|
||||
if (!code)
|
||||
return Object.freeze({ valid: false, empty: false, code: '', name: '' });
|
||||
return Object.freeze({
|
||||
valid: true,
|
||||
empty: false,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { applicationServices } from '../services/application.js';
|
||||
export * from './regionalModel.js';
|
||||
/** Fetch a bounded regional brief through the same-origin dev/preview proxy. */
|
||||
export async function fetchRegionalBrief(latitude, longitude, { signal } = {}) {
|
||||
if (![latitude, longitude].every(Number.isFinite)) throw new Error('Valid coordinates are required');
|
||||
if (![latitude, longitude].every(Number.isFinite))
|
||||
throw new Error('Valid coordinates are required');
|
||||
return applicationServices.regional.getBrief(latitude, longitude, { signal });
|
||||
}
|
||||
+52
-20
@@ -1,13 +1,18 @@
|
||||
const MAX_ARTICLES = 5;
|
||||
|
||||
function cleanText(value, maxLength = 180) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxLength);
|
||||
return String(value || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function safeHttpUrl(value) {
|
||||
try {
|
||||
const parsed = new URL(String(value || ''));
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null;
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||
? parsed.href
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -17,14 +22,27 @@ function safeHttpUrl(value) {
|
||||
export function normalizeRegionalPlace(payload) {
|
||||
const address = payload?.address || {};
|
||||
const locality = cleanText(
|
||||
address.city || address.town || address.village || address.municipality
|
||||
|| address.hamlet || address.county,
|
||||
address.city ||
|
||||
address.town ||
|
||||
address.village ||
|
||||
address.municipality ||
|
||||
address.hamlet ||
|
||||
address.county,
|
||||
90,
|
||||
);
|
||||
const region = cleanText(
|
||||
address.state || address.region || address.county,
|
||||
90,
|
||||
);
|
||||
const region = cleanText(address.state || address.region || address.county, 90);
|
||||
const country = cleanText(address.country, 90);
|
||||
const label = [locality, region].filter((value, index, values) => value && values.indexOf(value) === index)
|
||||
.join(', ') || country || cleanText(payload?.display_name, 120);
|
||||
const label =
|
||||
[locality, region]
|
||||
.filter(
|
||||
(value, index, values) => value && values.indexOf(value) === index,
|
||||
)
|
||||
.join(', ') ||
|
||||
country ||
|
||||
cleanText(payload?.display_name, 120);
|
||||
if (!label) return null;
|
||||
return {
|
||||
label,
|
||||
@@ -51,11 +69,16 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
|
||||
const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate);
|
||||
const publishedAt = compactDate
|
||||
? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z`
|
||||
: Number.isNaN(Date.parse(rawDate)) ? null : new Date(rawDate).toISOString();
|
||||
: Number.isNaN(Date.parse(rawDate))
|
||||
? null
|
||||
: new Date(rawDate).toISOString();
|
||||
articles.push({
|
||||
title,
|
||||
url,
|
||||
domain: cleanText(row?.domain || new URL(url).hostname.replace(/^www\./, ''), 80),
|
||||
domain: cleanText(
|
||||
row?.domain || new URL(url).hostname.replace(/^www\./, ''),
|
||||
80,
|
||||
),
|
||||
publishedAt,
|
||||
sourceCountry: cleanText(row?.sourcecountry, 60) || null,
|
||||
});
|
||||
@@ -74,11 +97,15 @@ export function normalizeRegionalWeather(payload) {
|
||||
};
|
||||
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
|
||||
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
|
||||
const observedRaw = typeof current.time === 'string' && !/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time)
|
||||
? `${current.time}Z`
|
||||
: current.time;
|
||||
const observedRaw =
|
||||
typeof current.time === 'string' &&
|
||||
!/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time)
|
||||
? `${current.time}Z`
|
||||
: current.time;
|
||||
return {
|
||||
observedAt: Number.isNaN(Date.parse(observedRaw)) ? null : new Date(observedRaw).toISOString(),
|
||||
observedAt: Number.isNaN(Date.parse(observedRaw))
|
||||
? null
|
||||
: new Date(observedRaw).toISOString(),
|
||||
temperatureC: numberOrNull(current.temperature_2m),
|
||||
apparentTemperatureC: numberOrNull(current.apparent_temperature),
|
||||
precipitationMm: numberOrNull(current.precipitation),
|
||||
@@ -109,14 +136,19 @@ export function weatherCodeLabel(code) {
|
||||
|
||||
/** Great-circle distance used to avoid refetching a regional brief every animation frame. */
|
||||
export function regionalDistanceM(from, to) {
|
||||
if (![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(Number.isFinite)) {
|
||||
if (
|
||||
![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(
|
||||
Number.isFinite,
|
||||
)
|
||||
) {
|
||||
return Infinity;
|
||||
}
|
||||
const phi1 = from.latitude * Math.PI / 180;
|
||||
const phi2 = to.latitude * Math.PI / 180;
|
||||
const deltaPhi = (to.latitude - from.latitude) * Math.PI / 180;
|
||||
const deltaLambda = (to.longitude - from.longitude) * Math.PI / 180;
|
||||
const a = Math.sin(deltaPhi / 2) ** 2
|
||||
+ Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
|
||||
const phi1 = (from.latitude * Math.PI) / 180;
|
||||
const phi2 = (to.latitude * Math.PI) / 180;
|
||||
const deltaPhi = ((to.latitude - from.latitude) * Math.PI) / 180;
|
||||
const deltaLambda = ((to.longitude - from.longitude) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(deltaPhi / 2) ** 2 +
|
||||
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
|
||||
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
@@ -26,7 +26,13 @@
|
||||
* none of the above are usable — the caller applies its OWN existing sticky/default
|
||||
* fallback (this helper never invents one, so it can't drift from the caller's policy).
|
||||
*/
|
||||
export function pickRenderAltitudeM({ geoAltM, baroAltM, onGround, surfaceM, geoidN }) {
|
||||
export function pickRenderAltitudeM({
|
||||
geoAltM,
|
||||
baroAltM,
|
||||
onGround,
|
||||
surfaceM,
|
||||
geoidN,
|
||||
}) {
|
||||
if (onGround && Number.isFinite(surfaceM)) {
|
||||
return surfaceM;
|
||||
}
|
||||
@@ -89,7 +95,12 @@ export function reuseGroundedSurfaceM(currentM, previousM) {
|
||||
* @param {number|null|undefined} params.geoidN - Geoid undulation N here.
|
||||
* @returns {number|null} The geoid guess, or null when it must not be used.
|
||||
*/
|
||||
export function geoidSurfaceLastResortM({ geoAltM, baroAltM, priorRenderM, geoidN }) {
|
||||
export function geoidSurfaceLastResortM({
|
||||
geoAltM,
|
||||
baroAltM,
|
||||
priorRenderM,
|
||||
geoidN,
|
||||
}) {
|
||||
if (Number.isFinite(geoAltM) || Number.isFinite(baroAltM)) return null;
|
||||
if (Number.isFinite(priorRenderM)) return null;
|
||||
return Number.isFinite(geoidN) ? geoidN : null;
|
||||
|
||||
@@ -34,11 +34,14 @@ export const RETRY_COOLDOWN_MAX_MS = 300_000;
|
||||
* @param {() => number} [options.now] - Clock seam for tests.
|
||||
* @returns {() => Promise<T>} Memoized loader; retries once the cooldown elapses.
|
||||
*/
|
||||
export function createRetryableLoader(load, {
|
||||
cooldownMs = RETRY_COOLDOWN_MS,
|
||||
maxCooldownMs = RETRY_COOLDOWN_MAX_MS,
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
export function createRetryableLoader(
|
||||
load,
|
||||
{
|
||||
cooldownMs = RETRY_COOLDOWN_MS,
|
||||
maxCooldownMs = RETRY_COOLDOWN_MAX_MS,
|
||||
now = () => Date.now(),
|
||||
} = {},
|
||||
) {
|
||||
/** @type {Promise<T>|null} */
|
||||
let inflight = null;
|
||||
/** @type {*} Last rejection, replayed to callers inside the cooldown. */
|
||||
@@ -51,7 +54,10 @@ export function createRetryableLoader(load, {
|
||||
return function loadOnce() {
|
||||
if (inflight) return inflight;
|
||||
if (hasFailure) {
|
||||
const wait = Math.min(cooldownMs * 2 ** (consecutiveFailures - 1), maxCooldownMs);
|
||||
const wait = Math.min(
|
||||
cooldownMs * 2 ** (consecutiveFailures - 1),
|
||||
maxCooldownMs,
|
||||
);
|
||||
// Replay the recorded reason rather than re-running a loader that is
|
||||
// probably still broken. Callers see identical behavior either way.
|
||||
if (now() - failedAt < wait) return Promise.reject(failure);
|
||||
|
||||
+44
-12
@@ -19,7 +19,8 @@ export function greatCircleKm(lat1, lon1, lat2, lon2) {
|
||||
const p2 = lat2 * D2R;
|
||||
const dp = (lat2 - lat1) * D2R;
|
||||
const dl = (lon2 - lon1) * D2R;
|
||||
const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||
const a =
|
||||
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||
return R_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
@@ -28,7 +29,8 @@ function bearingRad(lat1, lon1, lat2, lon2) {
|
||||
const p2 = lat2 * D2R;
|
||||
const dl = (lon2 - lon1) * D2R;
|
||||
const y = Math.sin(dl) * Math.cos(p2);
|
||||
const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
|
||||
const x =
|
||||
Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
|
||||
return Math.atan2(y, x);
|
||||
}
|
||||
|
||||
@@ -42,8 +44,8 @@ export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) {
|
||||
|
||||
const NEAR_ENDPOINT_KM = 130;
|
||||
const CROSS_TRACK_KM = 200;
|
||||
const LOW_ALT_M = 3700; // ~12 000 ft
|
||||
const VERT_TREND_MPS = 2; // ~400 fpm
|
||||
const LOW_ALT_M = 3700; // ~12 000 ft
|
||||
const VERT_TREND_MPS = 2; // ~400 fpm
|
||||
const LOCAL_AIRPORT_KM = 150;
|
||||
|
||||
/**
|
||||
@@ -55,16 +57,35 @@ const LOCAL_AIRPORT_KM = 150;
|
||||
* @param {{lat:number|null, lon:number|null}|null} [p.destination]
|
||||
* @returns {boolean} false ONLY when the route is confidently wrong.
|
||||
*/
|
||||
export function routePlausible({ latDeg, lonDeg, altitudeM = null, verticalRateMps = null, origin = null, destination = null }) {
|
||||
export function routePlausible({
|
||||
latDeg,
|
||||
lonDeg,
|
||||
altitudeM = null,
|
||||
verticalRateMps = null,
|
||||
origin = null,
|
||||
destination = null,
|
||||
}) {
|
||||
const haveO = Number.isFinite(origin?.lat) && Number.isFinite(origin?.lon);
|
||||
const haveD = Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
|
||||
const haveD =
|
||||
Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
|
||||
if (!haveO && !haveD) return true; // no coordinates — cannot judge, do not hide
|
||||
|
||||
// (a) Geographic consistency: near an endpoint, or roughly on the path.
|
||||
const near = (pt) => greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM;
|
||||
const near = (pt) =>
|
||||
greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM;
|
||||
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
|
||||
if (!geomOk && haveO && haveD) {
|
||||
geomOk = Math.abs(crossTrackKm(latDeg, lonDeg, origin.lat, origin.lon, destination.lat, destination.lon)) < CROSS_TRACK_KM;
|
||||
geomOk =
|
||||
Math.abs(
|
||||
crossTrackKm(
|
||||
latDeg,
|
||||
lonDeg,
|
||||
origin.lat,
|
||||
origin.lon,
|
||||
destination.lat,
|
||||
destination.lon,
|
||||
),
|
||||
) < CROSS_TRACK_KM;
|
||||
} else if (!geomOk) {
|
||||
geomOk = true; // only one endpoint known and not near — cannot judge, allow
|
||||
}
|
||||
@@ -72,13 +93,24 @@ export function routePlausible({ latDeg, lonDeg, altitudeM = null, verticalRateM
|
||||
|
||||
// (b) Vertical-trend consistency for low traffic (observer-free adaptation).
|
||||
if (
|
||||
Number.isFinite(altitudeM) && altitudeM < LOW_ALT_M &&
|
||||
Number.isFinite(verticalRateMps) && Math.abs(verticalRateMps) > VERT_TREND_MPS
|
||||
Number.isFinite(altitudeM) &&
|
||||
altitudeM < LOW_ALT_M &&
|
||||
Number.isFinite(verticalRateMps) &&
|
||||
Math.abs(verticalRateMps) > VERT_TREND_MPS
|
||||
) {
|
||||
if (verticalRateMps > 0) {
|
||||
if (haveO && greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM) return false; // departing — origin should be local
|
||||
if (
|
||||
haveO &&
|
||||
greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM
|
||||
)
|
||||
return false; // departing — origin should be local
|
||||
} else {
|
||||
if (haveD && greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) > LOCAL_AIRPORT_KM) return false; // arriving — destination should be local
|
||||
if (
|
||||
haveD &&
|
||||
greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) >
|
||||
LOCAL_AIRPORT_KM
|
||||
)
|
||||
return false; // arriving — destination should be local
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -69,7 +69,13 @@ export const SATELLITE_CLASSES = Object.freeze({
|
||||
});
|
||||
|
||||
/** Legend/report order for the classes above. */
|
||||
export const SATELLITE_CLASS_ORDER = Object.freeze(['station', 'nav', 'geo', 'visual', 'comms']);
|
||||
export const SATELLITE_CLASS_ORDER = Object.freeze([
|
||||
'station',
|
||||
'nav',
|
||||
'geo',
|
||||
'visual',
|
||||
'comms',
|
||||
]);
|
||||
|
||||
/**
|
||||
* CelesTrak group tag → { class, subtype }. Subtype names the specific
|
||||
@@ -150,8 +156,11 @@ export function satelliteClassLabel(group, { isIss = false } = {}) {
|
||||
export function tallySatelliteClasses(entries) {
|
||||
const counts = Object.create(null);
|
||||
for (const entry of entries || []) {
|
||||
const descriptor = entry && typeof entry === 'object' ? entry : { group: entry };
|
||||
const { klass } = satelliteClassOf(descriptor.group, { isIss: descriptor.isIss });
|
||||
const descriptor =
|
||||
entry && typeof entry === 'object' ? entry : { group: entry };
|
||||
const { klass } = satelliteClassOf(descriptor.group, {
|
||||
isIss: descriptor.isIss,
|
||||
});
|
||||
counts[klass] = (counts[klass] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
@@ -171,7 +180,13 @@ export function satelliteClassLegend(counts) {
|
||||
const count = counts?.[klass];
|
||||
if (!(count > 0)) continue;
|
||||
const spec = SATELLITE_CLASSES[klass];
|
||||
result.push({ klass, label: spec.label, color: spec.color, blurb: spec.blurb, count });
|
||||
result.push({
|
||||
klass,
|
||||
label: spec.label,
|
||||
color: spec.color,
|
||||
blurb: spec.blurb,
|
||||
count,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -42,7 +42,8 @@ const MAX_PICK_MAGNITUDE_M = 1_000_000_000;
|
||||
export function isPickedWorldPosition(position) {
|
||||
if (!position) return false;
|
||||
const { x, y, z } = position;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return false;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
|
||||
return false;
|
||||
const magnitude = Math.hypot(x, y, z);
|
||||
return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M;
|
||||
}
|
||||
@@ -47,7 +47,8 @@ export function restoreSpriteOrder(viewer) {
|
||||
if (!viewer || viewer.isDestroyed?.()) return;
|
||||
const scene = viewer.scene;
|
||||
const primitives = scene?.primitives;
|
||||
if (!primitives || scene.isDestroyed?.() || primitives.isDestroyed?.()) return;
|
||||
if (!primitives || scene.isDestroyed?.() || primitives.isDestroyed?.())
|
||||
return;
|
||||
|
||||
for (const layerId of SPRITE_LAYER_ORDER) {
|
||||
const collection = _collections.get(layerId);
|
||||
@@ -71,6 +72,7 @@ export function restoreSpriteOrderOnEnable(
|
||||
viewer,
|
||||
restore = restoreSpriteOrder,
|
||||
) {
|
||||
if (!SPRITE_LAYER_ORDER.includes(layerId) || typeof restore !== 'function') return;
|
||||
if (!SPRITE_LAYER_ORDER.includes(layerId) || typeof restore !== 'function')
|
||||
return;
|
||||
restore(viewer);
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { createTerrainHeights } from '../services/terrainHeights.js';
|
||||
import { applicationServices } from '../services/application.js';
|
||||
const terrainHeights = createTerrainHeights({ source: applicationServices.terrain });
|
||||
export const { cachedEllipsoidalGround, cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrainHeights;
|
||||
const terrainHeights = createTerrainHeights({
|
||||
source: applicationServices.terrain,
|
||||
});
|
||||
export const {
|
||||
cachedEllipsoidalGround,
|
||||
cachedRealEllipsoidalGround,
|
||||
resolveEllipsoidalGround,
|
||||
} = terrainHeights;
|
||||
@@ -38,7 +38,9 @@ export const TR3B_CLASS = 'tr3b';
|
||||
|
||||
/** @param {*} id Raw contact id. @returns {string} Normalized registry key. */
|
||||
function key(id) {
|
||||
return String(id ?? '').trim().toLowerCase();
|
||||
return String(id ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+37
-19
@@ -26,17 +26,23 @@ export function trackedModelScaleForPixelCap({
|
||||
maximumPixelSize,
|
||||
}) {
|
||||
if (
|
||||
!Number.isFinite(baseScale) || baseScale <= 0
|
||||
|| !Number.isFinite(nativeRadiusM) || nativeRadiusM <= 0
|
||||
|| !Number.isFinite(rangeM) || rangeM <= 0
|
||||
|| !Number.isFinite(viewportHeightPx) || viewportHeightPx <= 0
|
||||
|| !Number.isFinite(fovyRad) || fovyRad <= 0
|
||||
|| !Number.isFinite(maximumPixelSize) || maximumPixelSize <= 0
|
||||
) return baseScale;
|
||||
!Number.isFinite(baseScale) ||
|
||||
baseScale <= 0 ||
|
||||
!Number.isFinite(nativeRadiusM) ||
|
||||
nativeRadiusM <= 0 ||
|
||||
!Number.isFinite(rangeM) ||
|
||||
rangeM <= 0 ||
|
||||
!Number.isFinite(viewportHeightPx) ||
|
||||
viewportHeightPx <= 0 ||
|
||||
!Number.isFinite(fovyRad) ||
|
||||
fovyRad <= 0 ||
|
||||
!Number.isFinite(maximumPixelSize) ||
|
||||
maximumPixelSize <= 0
|
||||
)
|
||||
return baseScale;
|
||||
const focalLengthPx = viewportHeightPx / (2 * Math.tan(fovyRad / 2));
|
||||
const projectedDiameterPx = (
|
||||
2 * nativeRadiusM * baseScale * focalLengthPx
|
||||
) / rangeM;
|
||||
const projectedDiameterPx =
|
||||
(2 * nativeRadiusM * baseScale * focalLengthPx) / rangeM;
|
||||
if (projectedDiameterPx <= maximumPixelSize) return baseScale;
|
||||
return baseScale * (maximumPixelSize / projectedDiameterPx);
|
||||
}
|
||||
@@ -99,17 +105,25 @@ export function clampTrackedCameraPosition(
|
||||
previousPosition,
|
||||
minimumRangeM = MIN_TRACKED_RANGE_M,
|
||||
) {
|
||||
const crossedOrigin = Cesium.Cartesian3.dot(camera.position, previousPosition) <= 0;
|
||||
const forwardDistance = -Cesium.Cartesian3.dot(camera.position, camera.direction);
|
||||
const crossedOrigin =
|
||||
Cesium.Cartesian3.dot(camera.position, previousPosition) <= 0;
|
||||
const forwardDistance = -Cesium.Cartesian3.dot(
|
||||
camera.position,
|
||||
camera.direction,
|
||||
);
|
||||
const rangeSquared = Cesium.Cartesian3.magnitudeSquared(camera.position);
|
||||
if (crossedOrigin) {
|
||||
Cesium.Cartesian3.normalize(previousPosition, camera.position);
|
||||
Cesium.Cartesian3.multiplyByScalar(camera.position, minimumRangeM, camera.position);
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
camera.position,
|
||||
minimumRangeM,
|
||||
camera.position,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
forwardDistance < minimumRangeM
|
||||
|| rangeSquared < minimumRangeM * minimumRangeM
|
||||
forwardDistance < minimumRangeM ||
|
||||
rangeSquared < minimumRangeM * minimumRangeM
|
||||
) {
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
camera.direction,
|
||||
@@ -192,10 +206,14 @@ export function applyTrackedCameraFrame(viewer, entity, viewFrom) {
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
trackedTransform,
|
||||
);
|
||||
const offset = typeof viewFrom.getValue === 'function'
|
||||
? viewFrom.getValue(viewer.clock.currentTime, resolvedViewFrom)
|
||||
: Cesium.Cartesian3.clone(viewFrom, resolvedViewFrom);
|
||||
if (!offset || Cesium.Cartesian3.magnitudeSquared(offset) < Cesium.Math.EPSILON12) {
|
||||
const offset =
|
||||
typeof viewFrom.getValue === 'function'
|
||||
? viewFrom.getValue(viewer.clock.currentTime, resolvedViewFrom)
|
||||
: Cesium.Cartesian3.clone(viewFrom, resolvedViewFrom);
|
||||
if (
|
||||
!offset ||
|
||||
Cesium.Cartesian3.magnitudeSquared(offset) < Cesium.Math.EPSILON12
|
||||
) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,5 +76,8 @@ export const TRACKED_MODEL_EXIT_ALT_M =
|
||||
*/
|
||||
export function trackedModelZoomActive(cameraHeightM, wasActive = false) {
|
||||
if (!Number.isFinite(cameraHeightM)) return false;
|
||||
return cameraHeightM < (wasActive ? TRACKED_MODEL_EXIT_ALT_M : TRACKED_MODEL_ENTER_ALT_M);
|
||||
return (
|
||||
cameraHeightM <
|
||||
(wasActive ? TRACKED_MODEL_EXIT_ALT_M : TRACKED_MODEL_ENTER_ALT_M)
|
||||
);
|
||||
}
|
||||
+59
-21
@@ -49,17 +49,24 @@ let _overlayHost = DEFAULT_TRACKED_OVERLAY_HOST;
|
||||
* @param {string} accent Source-owned accent color.
|
||||
* @returns {{title:string,details:string[],accent:string}}
|
||||
*/
|
||||
export function trackedLabelModelFromText(text, accent = WORLD_OVERLAY_STYLE.accent) {
|
||||
export function trackedLabelModelFromText(
|
||||
text,
|
||||
accent = WORLD_OVERLAY_STYLE.accent,
|
||||
) {
|
||||
const raw = String(text || '').trim();
|
||||
if (!raw) return { title: '', details: [], accent };
|
||||
let lines;
|
||||
if (raw.includes('\n')) {
|
||||
lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
lines = raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
const parts = raw.split(' · ');
|
||||
lines = parts.length > 1
|
||||
? [parts[0].trim(), parts.slice(1).join(' · ').trim()]
|
||||
: [raw];
|
||||
lines =
|
||||
parts.length > 1
|
||||
? [parts[0].trim(), parts.slice(1).join(' · ').trim()]
|
||||
: [raw];
|
||||
}
|
||||
return { title: lines[0] || '', details: lines.slice(1), accent };
|
||||
}
|
||||
@@ -101,7 +108,9 @@ export function cachedTrackedVisualPosition(entity) {
|
||||
try {
|
||||
const visual = entity.gevVisualPosition();
|
||||
if (visual) return visual;
|
||||
} catch { /* fall through to the display position */ }
|
||||
} catch {
|
||||
/* fall through to the display position */
|
||||
}
|
||||
}
|
||||
return cachedTrackedDisplayPosition(entity);
|
||||
}
|
||||
@@ -140,7 +149,9 @@ export function createTrackedOverlayEntry(entity) {
|
||||
collisionGroup: 'ambient-card',
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
title,
|
||||
details: Array.isArray(model.details) ? model.details.map((line) => String(line)) : [],
|
||||
details: Array.isArray(model.details)
|
||||
? model.details.map((line) => String(line))
|
||||
: [],
|
||||
accent: model.accent || WORLD_OVERLAY_STYLE.accent,
|
||||
cardStyle: model.cardStyle,
|
||||
selected: model.selected === true,
|
||||
@@ -149,9 +160,14 @@ export function createTrackedOverlayEntry(entity) {
|
||||
leaderAnimationStartedAt: model.leaderAnimationStartedAt,
|
||||
leaderDrawRatio: model.leaderDrawRatio,
|
||||
anchorRadiusPx: Number.isFinite(Number(model.anchorRadiusPx))
|
||||
? Math.max(0, Number(model.anchorRadiusPx)) : 10,
|
||||
anchorRadiusScale: Object.prototype.hasOwnProperty.call(model, 'anchorRadiusScale')
|
||||
? model.anchorRadiusScale : TRACKED_BILLBOARD_SCALE,
|
||||
? Math.max(0, Number(model.anchorRadiusPx))
|
||||
: 10,
|
||||
anchorRadiusScale: Object.prototype.hasOwnProperty.call(
|
||||
model,
|
||||
'anchorRadiusScale',
|
||||
)
|
||||
? model.anchorRadiusScale
|
||||
: TRACKED_BILLBOARD_SCALE,
|
||||
minAnchorGapPx: 16,
|
||||
anchorGapPaddingPx: 10,
|
||||
verticalOnly: true,
|
||||
@@ -203,7 +219,10 @@ export function getActiveTrackedReadoutId() {
|
||||
}
|
||||
|
||||
/** Static-context layers whose click selection publishes the readout card. */
|
||||
const READOUT_CONTEXT_LAYERS = new Set(['military-installations', 'alpr-cameras']);
|
||||
const READOUT_CONTEXT_LAYERS = new Set([
|
||||
'military-installations',
|
||||
'alpr-cameras',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Initialize the model bridge and selection listeners. No render listener is
|
||||
@@ -215,10 +234,11 @@ export function initTrackedReadout(viewer) {
|
||||
if (_viewer) destroyTrackedReadout();
|
||||
_viewer = viewer;
|
||||
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true);
|
||||
_trackedEntityChangedRemove = viewer.trackedEntityChanged?.addEventListener?.(() => {
|
||||
if (viewer.trackedEntity) _selectedContext = null;
|
||||
syncActiveEntity();
|
||||
}) || null;
|
||||
_trackedEntityChangedRemove =
|
||||
viewer.trackedEntityChanged?.addEventListener?.(() => {
|
||||
if (viewer.trackedEntity) _selectedContext = null;
|
||||
syncActiveEntity();
|
||||
}) || null;
|
||||
_contextSelectedHandler = (event) => {
|
||||
const record = event.detail;
|
||||
if (READOUT_CONTEXT_LAYERS.has(record?.layerId)) {
|
||||
@@ -232,7 +252,10 @@ export function initTrackedReadout(viewer) {
|
||||
}
|
||||
};
|
||||
_contextClearedHandler = (event) => {
|
||||
if (!_selectedContext || event.detail?.layerId === _selectedContext.layerId) {
|
||||
if (
|
||||
!_selectedContext ||
|
||||
event.detail?.layerId === _selectedContext.layerId
|
||||
) {
|
||||
_selectedContext = null;
|
||||
syncActiveEntity();
|
||||
}
|
||||
@@ -242,8 +265,14 @@ export function initTrackedReadout(viewer) {
|
||||
if (!_viewer?.trackedEntity) clearTrackedSource();
|
||||
};
|
||||
window.addEventListener('gev:entity-selected', _contextSelectedHandler);
|
||||
window.addEventListener('gev:entity-selection-cleared', _contextClearedHandler);
|
||||
window.addEventListener('gev:awareness-subject-selected', _aircraftSelectedHandler);
|
||||
window.addEventListener(
|
||||
'gev:entity-selection-cleared',
|
||||
_contextClearedHandler,
|
||||
);
|
||||
window.addEventListener(
|
||||
'gev:awareness-subject-selected',
|
||||
_aircraftSelectedHandler,
|
||||
);
|
||||
syncActiveEntity();
|
||||
// Boot-verification contract: the track regression harness asserts this
|
||||
// exact line at init (dropped by the host migration; restored).
|
||||
@@ -254,9 +283,18 @@ export function initTrackedReadout(viewer) {
|
||||
export function destroyTrackedReadout() {
|
||||
_trackedEntityChangedRemove?.();
|
||||
_trackedEntityChangedRemove = null;
|
||||
if (_contextSelectedHandler) window.removeEventListener('gev:entity-selected', _contextSelectedHandler);
|
||||
if (_contextClearedHandler) window.removeEventListener('gev:entity-selection-cleared', _contextClearedHandler);
|
||||
if (_aircraftSelectedHandler) window.removeEventListener('gev:awareness-subject-selected', _aircraftSelectedHandler);
|
||||
if (_contextSelectedHandler)
|
||||
window.removeEventListener('gev:entity-selected', _contextSelectedHandler);
|
||||
if (_contextClearedHandler)
|
||||
window.removeEventListener(
|
||||
'gev:entity-selection-cleared',
|
||||
_contextClearedHandler,
|
||||
);
|
||||
if (_aircraftSelectedHandler)
|
||||
window.removeEventListener(
|
||||
'gev:awareness-subject-selected',
|
||||
_aircraftSelectedHandler,
|
||||
);
|
||||
_contextSelectedHandler = null;
|
||||
_contextClearedHandler = null;
|
||||
_aircraftSelectedHandler = null;
|
||||
|
||||
@@ -28,8 +28,10 @@ export function isTrackingClickGesture(gesture = {}) {
|
||||
const durationMs = Number.isFinite(gesture.durationMs)
|
||||
? Math.max(0, gesture.durationMs)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return isTrackingSelectionGesture(gesture)
|
||||
&& durationMs <= MAX_TRACKING_CLICK_DURATION_MS;
|
||||
return (
|
||||
isTrackingSelectionGesture(gesture) &&
|
||||
durationMs <= MAX_TRACKING_CLICK_DURATION_MS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +55,12 @@ export function bindTrackingClickGesture(handler, onClick, options = {}) {
|
||||
let completedGesture = null;
|
||||
|
||||
const appendTravel = (position) => {
|
||||
if (!pressActive || !Number.isFinite(position?.x) || !Number.isFinite(position?.y)) return;
|
||||
if (
|
||||
!pressActive ||
|
||||
!Number.isFinite(position?.x) ||
|
||||
!Number.isFinite(position?.y)
|
||||
)
|
||||
return;
|
||||
if (previousPosition) {
|
||||
travelPx += Math.hypot(
|
||||
position.x - previousPosition.x,
|
||||
|
||||
@@ -33,8 +33,8 @@ export function greatCircleKm(lat1, lon1, lat2, lon2) {
|
||||
const p2 = toRad(lat2);
|
||||
const dp = toRad(lat2 - lat1);
|
||||
const dl = toRad(lon2 - lon1);
|
||||
const a = Math.sin(dp / 2) ** 2
|
||||
+ Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||
const a =
|
||||
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ function initialBearingRad(lat1, lon1, lat2, lon2) {
|
||||
const p2 = toRad(lat2);
|
||||
const dl = toRad(lon2 - lon1);
|
||||
const y = Math.sin(dl) * Math.cos(p2);
|
||||
const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
|
||||
const x =
|
||||
Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
|
||||
return Math.atan2(y, x);
|
||||
}
|
||||
|
||||
@@ -68,12 +69,15 @@ function destinationPoint(lat, lon, bearingRad, distKm) {
|
||||
const p1 = toRad(lat);
|
||||
const l1 = toRad(lon);
|
||||
const p2 = Math.asin(
|
||||
Math.sin(p1) * Math.cos(delta) + Math.cos(p1) * Math.sin(delta) * Math.cos(bearingRad)
|
||||
);
|
||||
const l2 = l1 + Math.atan2(
|
||||
Math.sin(bearingRad) * Math.sin(delta) * Math.cos(p1),
|
||||
Math.cos(delta) - Math.sin(p1) * Math.sin(p2)
|
||||
Math.sin(p1) * Math.cos(delta) +
|
||||
Math.cos(p1) * Math.sin(delta) * Math.cos(bearingRad),
|
||||
);
|
||||
const l2 =
|
||||
l1 +
|
||||
Math.atan2(
|
||||
Math.sin(bearingRad) * Math.sin(delta) * Math.cos(p1),
|
||||
Math.cos(delta) - Math.sin(p1) * Math.sin(p2),
|
||||
);
|
||||
// Normalize longitude to [-180, 180)
|
||||
const lonDeg = ((toDeg(l2) + 540) % 360) - 180;
|
||||
return { lat: toDeg(p2), lon: lonDeg };
|
||||
@@ -100,7 +104,13 @@ function destinationPoint(lat, lon, bearingRad, distKm) {
|
||||
* @returns {{lat:number, lon:number, source:'hit'|'nadir'|'pulled'}}
|
||||
* The fetch center and which rule produced it.
|
||||
*/
|
||||
export function deriveFetchCenter({ nadirLat, nadirLon, hitLat, hitLon, maxPullKm = 12 }) {
|
||||
export function deriveFetchCenter({
|
||||
nadirLat,
|
||||
nadirLon,
|
||||
hitLat,
|
||||
hitLon,
|
||||
maxPullKm = 12,
|
||||
}) {
|
||||
if (!Number.isFinite(hitLat) || !Number.isFinite(hitLon)) {
|
||||
return { lat: nadirLat, lon: nadirLon, source: 'nadir' };
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@
|
||||
/** @const {Object<string,'mono'|'crt'>} Style name → non-normal profile. */
|
||||
const PROFILE_BY_STYLE = {
|
||||
surveillance: 'mono', // NVG — P43 phosphor × luma
|
||||
thermal: 'mono', // FLIR — grayscale/ironbow × luma
|
||||
noir: 'mono', // full desaturation
|
||||
retro: 'crt', // CRT — hue survives, small dots don't
|
||||
thermal: 'mono', // FLIR — grayscale/ironbow × luma
|
||||
noir: 'mono', // full desaturation
|
||||
retro: 'crt', // CRT — hue survives, small dots don't
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -49,13 +49,33 @@ const PROFILE_BY_STYLE = {
|
||||
*/
|
||||
const DOT_STYLE = {
|
||||
mono: {
|
||||
jam: { rgba: [255, 255, 255, 0.95], sizeDelta: 3, outline: { rgba: [0, 0, 0, 0.9], width: 2 } },
|
||||
slow: { rgba: [255, 255, 255, 0.9], sizeDelta: 1, outline: { rgba: [0, 0, 0, 0.85], width: 1 } },
|
||||
free: { rgba: [255, 255, 255, 0.85], sizeDelta: 0, outline: { rgba: [0, 0, 0, 0.8], width: 1 } },
|
||||
jam: {
|
||||
rgba: [255, 255, 255, 0.95],
|
||||
sizeDelta: 3,
|
||||
outline: { rgba: [0, 0, 0, 0.9], width: 2 },
|
||||
},
|
||||
slow: {
|
||||
rgba: [255, 255, 255, 0.9],
|
||||
sizeDelta: 1,
|
||||
outline: { rgba: [0, 0, 0, 0.85], width: 1 },
|
||||
},
|
||||
free: {
|
||||
rgba: [255, 255, 255, 0.85],
|
||||
sizeDelta: 0,
|
||||
outline: { rgba: [0, 0, 0, 0.8], width: 1 },
|
||||
},
|
||||
},
|
||||
crt: {
|
||||
jam: { rgba: [255, 59, 48, 0.95], sizeDelta: 3, outline: { rgba: [0, 0, 0, 0.9], width: 2 } },
|
||||
slow: { rgba: [255, 179, 0, 0.92], sizeDelta: 2, outline: { rgba: [0, 0, 0, 0.85], width: 1 } },
|
||||
jam: {
|
||||
rgba: [255, 59, 48, 0.95],
|
||||
sizeDelta: 3,
|
||||
outline: { rgba: [0, 0, 0, 0.9], width: 2 },
|
||||
},
|
||||
slow: {
|
||||
rgba: [255, 179, 0, 0.92],
|
||||
sizeDelta: 2,
|
||||
outline: { rgba: [0, 0, 0, 0.85], width: 1 },
|
||||
},
|
||||
free: { rgba: [0, 255, 102, 0.9], sizeDelta: 1, outline: null },
|
||||
},
|
||||
};
|
||||
@@ -123,7 +143,8 @@ export function presetDotOutline(styleName, bucket) {
|
||||
* @returns {string|null} Tier key, or null (no override → stock 'vehicle').
|
||||
*/
|
||||
export function trafficBucketTier(bucket) {
|
||||
if (bucket === 'free' || bucket === 'slow' || bucket === 'jam') return `veh_${bucket}`;
|
||||
if (bucket === 'free' || bucket === 'slow' || bucket === 'jam')
|
||||
return `veh_${bucket}`;
|
||||
if (bucket === 'sim') return 'veh_nodata';
|
||||
return null;
|
||||
}
|
||||
@@ -33,13 +33,14 @@ const GAP_SPREAD_M = 6;
|
||||
*/
|
||||
export function queuePlatoons(totalLen, count, rng = Math.random) {
|
||||
const platoons = [];
|
||||
if (!Number.isFinite(totalLen) || totalLen <= 0 || !(count > 0)) return platoons;
|
||||
if (!Number.isFinite(totalLen) || totalLen <= 0 || !(count > 0))
|
||||
return platoons;
|
||||
|
||||
let placed = 0;
|
||||
while (placed < count) {
|
||||
const platoonSize = Math.min(
|
||||
count - placed,
|
||||
PLATOON_MIN + Math.floor(rng() * PLATOON_SPREAD)
|
||||
PLATOON_MIN + Math.floor(rng() * PLATOON_SPREAD),
|
||||
);
|
||||
const anchor = rng() * totalLen;
|
||||
const platoon = [];
|
||||
|
||||
@@ -28,7 +28,9 @@ import { registerPickOwner } from './pickRegistry.js';
|
||||
// plane being tracked. Claiming the 'gev-trail:' id namespace makes
|
||||
// isOwnedByOtherLayer() true for every layer — clicking a trail is a no-op
|
||||
// everywhere. Registered once at module load; the predicate is pure.
|
||||
registerPickOwner('trails', (pickedId) => String(pickedId).startsWith('gev-trail:'));
|
||||
registerPickOwner('trails', (pickedId) =>
|
||||
String(pickedId).startsWith('gev-trail:'),
|
||||
);
|
||||
|
||||
/** @type {number} Uniquifier for trail entity ids (Cesium requires unique entity ids). */
|
||||
let _trailSeq = 0;
|
||||
@@ -99,7 +101,12 @@ export function createTrail(viewer, { color, width = 2.5 }) {
|
||||
for (const position of Array.isArray(cartesians) ? cartesians : []) {
|
||||
if (!position) continue;
|
||||
const last = positions[positions.length - 1];
|
||||
if (last && Cesium.Cartesian3.distanceSquared(last, position) < MIN_SEGMENT_DISTANCE_SQ) continue;
|
||||
if (
|
||||
last &&
|
||||
Cesium.Cartesian3.distanceSquared(last, position) <
|
||||
MIN_SEGMENT_DISTANCE_SQ
|
||||
)
|
||||
continue;
|
||||
positions.push(position);
|
||||
}
|
||||
current = positions.length >= 2 ? positions : [];
|
||||
@@ -122,7 +129,11 @@ export function createTrail(viewer, { color, width = 2.5 }) {
|
||||
destroyed = true;
|
||||
current = [];
|
||||
if (entity && viewer && !viewer.isDestroyed()) {
|
||||
try { viewer.entities.remove(entity); } catch { /* torn down */ }
|
||||
try {
|
||||
viewer.entities.remove(entity);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
}
|
||||
entity = null;
|
||||
},
|
||||
|
||||
+51
-12
@@ -20,21 +20,48 @@ export const VESSEL_CARD_FADE_DISTANCE_M = 5_000_000;
|
||||
*/
|
||||
const TYPE_STYLES = [
|
||||
{ pattern: /tanker/i, css: '#ffb347', accent: '255, 179, 71' },
|
||||
{ pattern: /cargo|container|bulk|carrier/i, css: '#39d5ff', accent: '57, 213, 255' },
|
||||
{ pattern: /passenger|ferry|cruise/i, css: '#ff7adf', accent: '255, 122, 223' },
|
||||
{
|
||||
pattern: /cargo|container|bulk|carrier/i,
|
||||
css: '#39d5ff',
|
||||
accent: '57, 213, 255',
|
||||
},
|
||||
{
|
||||
pattern: /passenger|ferry|cruise/i,
|
||||
css: '#ff7adf',
|
||||
accent: '255, 122, 223',
|
||||
},
|
||||
{ pattern: /fishing/i, css: '#7cff9b', accent: '124, 255, 155' },
|
||||
{ pattern: /tug|tow|pilot|supply|service/i, css: '#f7f0a3', accent: '247, 240, 163' },
|
||||
{
|
||||
pattern: /tug|tow|pilot|supply|service/i,
|
||||
css: '#f7f0a3',
|
||||
accent: '247, 240, 163',
|
||||
},
|
||||
];
|
||||
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
||||
|
||||
const NUMERIC_TYPE_SPECIALS = {
|
||||
30: 'FISHING', 31: 'TOWING', 32: 'TOWING', 33: 'DREDGER', 34: 'DIVE OPS',
|
||||
35: 'MILITARY', 36: 'SAILING', 37: 'PLEASURE',
|
||||
50: 'PILOT', 51: 'SAR', 52: 'TUG', 53: 'PORT TENDER', 54: 'ANTI-POLLUTION',
|
||||
55: 'LAW ENFORCE', 58: 'MEDICAL',
|
||||
30: 'FISHING',
|
||||
31: 'TOWING',
|
||||
32: 'TOWING',
|
||||
33: 'DREDGER',
|
||||
34: 'DIVE OPS',
|
||||
35: 'MILITARY',
|
||||
36: 'SAILING',
|
||||
37: 'PLEASURE',
|
||||
50: 'PILOT',
|
||||
51: 'SAR',
|
||||
52: 'TUG',
|
||||
53: 'PORT TENDER',
|
||||
54: 'ANTI-POLLUTION',
|
||||
55: 'LAW ENFORCE',
|
||||
58: 'MEDICAL',
|
||||
};
|
||||
const NUMERIC_TYPE_FAMILIES = {
|
||||
4: 'HIGH-SPEED', 6: 'PASSENGER', 7: 'CARGO', 8: 'TANKER', 9: 'OTHER',
|
||||
4: 'HIGH-SPEED',
|
||||
6: 'PASSENGER',
|
||||
7: 'CARGO',
|
||||
8: 'TANKER',
|
||||
9: 'OTHER',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -76,13 +103,22 @@ function styleForType(type) {
|
||||
* @param {number} [rowLimit=900] Configured source row ceiling.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function vesselOverlayCohortLimit(width, height, rowLimit = VESSEL_DEFAULT_LABEL_LIMIT) {
|
||||
export function vesselOverlayCohortLimit(
|
||||
width,
|
||||
height,
|
||||
rowLimit = VESSEL_DEFAULT_LABEL_LIMIT,
|
||||
) {
|
||||
const w = Number(width);
|
||||
const h = Number(height);
|
||||
const requested = Number(rowLimit);
|
||||
if (!(w > 0) || !(h > 0) || !(requested > 0)) return 0;
|
||||
const gridCapacity = Math.ceil(w / VESSEL_LABEL_GRID_PX) * Math.ceil(h / VESSEL_LABEL_GRID_PX);
|
||||
return Math.min(VESSEL_OVERLAY_MAX_COHORT, Math.floor(requested), gridCapacity);
|
||||
const gridCapacity =
|
||||
Math.ceil(w / VESSEL_LABEL_GRID_PX) * Math.ceil(h / VESSEL_LABEL_GRID_PX);
|
||||
return Math.min(
|
||||
VESSEL_OVERLAY_MAX_COHORT,
|
||||
Math.floor(requested),
|
||||
gridCapacity,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,7 +129,10 @@ export function vesselOverlayCohortLimit(width, height, rowLimit = VESSEL_DEFAUL
|
||||
* @param {number} [fadeDistance=5000000] Ambient distance-fade endpoint.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function applyVesselOverlayPolicy(card, fadeDistance = VESSEL_CARD_FADE_DISTANCE_M) {
|
||||
export function applyVesselOverlayPolicy(
|
||||
card,
|
||||
fadeDistance = VESSEL_CARD_FADE_DISTANCE_M,
|
||||
) {
|
||||
const selected = card?.selected === true;
|
||||
const rawGap = Number(card?.gapPx) || 10;
|
||||
const gapPx = Math.max(12, rawGap + 8);
|
||||
|
||||
+67
-35
@@ -143,7 +143,9 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
|
||||
function resolveStore(kind, injected) {
|
||||
if (injected !== undefined) return injected;
|
||||
try {
|
||||
return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;
|
||||
return kind === 'session'
|
||||
? globalThis.sessionStorage
|
||||
: globalThis.localStorage;
|
||||
} catch {
|
||||
// Privacy-restricted storage should not make first launch silent.
|
||||
return null;
|
||||
@@ -211,8 +213,13 @@ export function shouldShowFirstRun({
|
||||
if (params.get('welcome') === '0') return false;
|
||||
// The demo/support escape hatch outranks both suppressions on purpose.
|
||||
if (params.get('welcome') === '1') return true;
|
||||
if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;
|
||||
if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;
|
||||
if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')
|
||||
return false;
|
||||
if (
|
||||
readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) ===
|
||||
'dismissed'
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -253,7 +260,10 @@ export function rememberFirstRunSessionDismissed(sessionStorageRef) {
|
||||
* @param {() => Promise<any>} deps.flyToGlobe
|
||||
* @returns {Promise<{ok: boolean, choice: string, result?: object, failedLayerIds?: string[]}>}
|
||||
*/
|
||||
export async function runFirstRunChoice(choice, { setContextMode, setLayerEnabled, flyToGlobe }) {
|
||||
export async function runFirstRunChoice(
|
||||
choice,
|
||||
{ setContextMode, setLayerEnabled, flyToGlobe },
|
||||
) {
|
||||
const mission = FIRST_RUN_MISSIONS[choice];
|
||||
if (!mission) return { ok: false, choice };
|
||||
if (mission.kind === 'none') return { ok: true, choice };
|
||||
@@ -267,15 +277,19 @@ export async function runFirstRunChoice(choice, { setContextMode, setLayerEnable
|
||||
const flight = Promise.resolve()
|
||||
.then(() => flyToGlobe())
|
||||
.catch(() => null);
|
||||
const outcomes = await Promise.all(mission.layerIds.map(async (layerId) => {
|
||||
try {
|
||||
return { layerId, ok: (await setLayerEnabled(layerId)) !== false };
|
||||
} catch {
|
||||
return { layerId, ok: false };
|
||||
}
|
||||
}));
|
||||
const outcomes = await Promise.all(
|
||||
mission.layerIds.map(async (layerId) => {
|
||||
try {
|
||||
return { layerId, ok: (await setLayerEnabled(layerId)) !== false };
|
||||
} catch {
|
||||
return { layerId, ok: false };
|
||||
}
|
||||
}),
|
||||
);
|
||||
await flight;
|
||||
const failedLayerIds = outcomes.filter((entry) => !entry.ok).map((entry) => entry.layerId);
|
||||
const failedLayerIds = outcomes
|
||||
.filter((entry) => !entry.ok)
|
||||
.map((entry) => entry.layerId);
|
||||
return { ok: failedLayerIds.length === 0, choice, failedLayerIds };
|
||||
}
|
||||
|
||||
@@ -325,20 +339,25 @@ export function initFirstRunExperience({
|
||||
if (!root || root.dataset.initialized === 'true') return null;
|
||||
root.dataset.initialized = 'true';
|
||||
|
||||
if (!shouldShowFirstRun({
|
||||
hasShareState: styleManager?.hasShareState,
|
||||
storage,
|
||||
sessionStorageRef,
|
||||
location,
|
||||
})) {
|
||||
if (
|
||||
!shouldShowFirstRun({
|
||||
hasShareState: styleManager?.hasShareState,
|
||||
storage,
|
||||
sessionStorageRef,
|
||||
location,
|
||||
})
|
||||
) {
|
||||
root.remove();
|
||||
return null;
|
||||
}
|
||||
|
||||
// The tile name is owner-switchable from one constant, so paint it from the
|
||||
// module rather than trusting the markup to have been edited to match.
|
||||
const environmentalTitle = root.querySelector('[data-first-run-environmental-title]');
|
||||
if (environmentalTitle) environmentalTitle.textContent = environmentalLabel().title;
|
||||
const environmentalTitle = root.querySelector(
|
||||
'[data-first-run-environmental-title]',
|
||||
);
|
||||
if (environmentalTitle)
|
||||
environmentalTitle.textContent = environmentalLabel().title;
|
||||
|
||||
const status = root.querySelector('[data-first-run-status]');
|
||||
const suppressBox = root.querySelector('[data-first-run-suppress]');
|
||||
@@ -387,10 +406,11 @@ export function initFirstRunExperience({
|
||||
* is exactly how every one of those surfaces hides this card; the hit test
|
||||
* then covers the overlays that leave the box intact and simply sit on top.
|
||||
*/
|
||||
const isTopmost = () => root.isConnected
|
||||
&& root.classList.contains('visible')
|
||||
&& root.getClientRects().length > 0
|
||||
&& !coveredByOverlay();
|
||||
const isTopmost = () =>
|
||||
root.isConnected &&
|
||||
root.classList.contains('visible') &&
|
||||
root.getClientRects().length > 0 &&
|
||||
!coveredByOverlay();
|
||||
|
||||
const dismiss = ({ restoreFocus = true } = {}) => {
|
||||
if (closing) return;
|
||||
@@ -416,10 +436,13 @@ export function initFirstRunExperience({
|
||||
root.setAttribute('aria-busy', String(next));
|
||||
// aria-disabled, not `disabled`: disabling the focused button drops focus to
|
||||
// <body> mid-flight and strands a keyboard visitor outside the launcher.
|
||||
for (const button of buttons) button.setAttribute('aria-disabled', String(next));
|
||||
for (const button of buttons)
|
||||
button.setAttribute('aria-disabled', String(next));
|
||||
if (!status) return;
|
||||
if (next) status.textContent = FIRST_RUN_MISSIONS[choice]?.busyText || 'Working…';
|
||||
else if (status.dataset.sticky !== 'true') status.textContent = defaultStatus;
|
||||
if (next)
|
||||
status.textContent = FIRST_RUN_MISSIONS[choice]?.busyText || 'Working…';
|
||||
else if (status.dataset.sticky !== 'true')
|
||||
status.textContent = defaultStatus;
|
||||
};
|
||||
|
||||
const onChoice = async (event) => {
|
||||
@@ -438,13 +461,16 @@ export function initFirstRunExperience({
|
||||
// does not decide panel chrome. This first-run click is an explicit
|
||||
// visual choice, so reveal the result exactly as the visible
|
||||
// Contacts / Space Missions tabs do.
|
||||
styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });
|
||||
styleManager.setPanelCollapsed?.('global-context-panel', false, {
|
||||
explicit: true,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
// `origin: 'user'` on purpose: a mission tile is a real person choosing
|
||||
// these layers, so it persists exactly as clicking those rows would.
|
||||
setLayerEnabled: (layerId) => dataManager.setEnabled(layerId, true, { origin: 'user' }),
|
||||
setLayerEnabled: (layerId) =>
|
||||
dataManager.setEnabled(layerId, true, { origin: 'user' }),
|
||||
flyToGlobe: () => styleManager.resetToGlobeView(),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -460,7 +486,8 @@ export function initFirstRunExperience({
|
||||
const failed = outcome?.failedLayerIds?.length
|
||||
? outcome.failedLayerIds
|
||||
: outcome?.result?.failedLayerIds;
|
||||
const detail = Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
|
||||
const detail =
|
||||
Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
|
||||
if (status) {
|
||||
status.dataset.sticky = 'true';
|
||||
status.textContent = `Could not open that mission${detail}. Retry or explore manually.`;
|
||||
@@ -479,7 +506,8 @@ export function initFirstRunExperience({
|
||||
if (box) box.checked = !wanted;
|
||||
if (!status) return;
|
||||
status.dataset.sticky = 'true';
|
||||
status.textContent = 'This browser is blocking storage, so that could not be saved.';
|
||||
status.textContent =
|
||||
'This browser is blocking storage, so that could not be saved.';
|
||||
};
|
||||
|
||||
const keyboard = createSurfaceKeyboard({
|
||||
@@ -585,13 +613,17 @@ export function initFirstRunExperience({
|
||||
const onViewportResize = () => syncScrollAffordance();
|
||||
globalThis.addEventListener?.('resize', onViewportResize);
|
||||
|
||||
const surfaceObserver = typeof globalThis.MutationObserver === 'function'
|
||||
? new globalThis.MutationObserver(syncToExclusiveSurfaces)
|
||||
: null;
|
||||
const surfaceObserver =
|
||||
typeof globalThis.MutationObserver === 'function'
|
||||
? new globalThis.MutationObserver(syncToExclusiveSurfaces)
|
||||
: null;
|
||||
// Attributes only, no subtree: this is a class watch on one element, so it
|
||||
// costs nothing per frame and never asks the render governor for a frame.
|
||||
if (documentRef.body) {
|
||||
surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });
|
||||
surfaceObserver?.observe(documentRef.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
}
|
||||
syncToExclusiveSurfaces();
|
||||
|
||||
|
||||
+96
-31
@@ -18,16 +18,36 @@ import * as Cesium from 'cesium';
|
||||
import { forward as toMGRS } from 'mgrs';
|
||||
import { CITY_POIS } from './locations.js';
|
||||
import { composeLocalityTag } from './hudLocality.js';
|
||||
import { ellipsoidalToMslDisplayM, ensureGeoidReady, geoidHeight } from './data/geoid.js';
|
||||
import {
|
||||
ellipsoidalToMslDisplayM,
|
||||
ensureGeoidReady,
|
||||
geoidHeight,
|
||||
} from './data/geoid.js';
|
||||
import { getBasemapLabelContext } from './voice/gevActions.js';
|
||||
import { isHudSummaryUnconfigured } from './hudSummaryResponse.js';
|
||||
|
||||
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||
const HUD_COLORS = {
|
||||
surveillance: { main: 'rgba(51, 255, 51, 0.8)', glow: 'rgba(51, 255, 51, 0.5)', border: 'rgba(51, 255, 51, 0.2)' },
|
||||
thermal: { main: 'rgba(255, 255, 255, 0.7)', glow: 'rgba(255, 255, 255, 0.4)', border: 'rgba(255, 255, 255, 0.15)' },
|
||||
retro: { main: 'rgba(255, 170, 0, 0.8)', glow: 'rgba(255, 170, 0, 0.5)', border: 'rgba(255, 170, 0, 0.2)' },
|
||||
_default: { main: 'rgba(0, 255, 255, 0.6)', glow: 'rgba(0, 255, 255, 0.4)', border: 'rgba(0, 255, 255, 0.15)' },
|
||||
surveillance: {
|
||||
main: 'rgba(51, 255, 51, 0.8)',
|
||||
glow: 'rgba(51, 255, 51, 0.5)',
|
||||
border: 'rgba(51, 255, 51, 0.2)',
|
||||
},
|
||||
thermal: {
|
||||
main: 'rgba(255, 255, 255, 0.7)',
|
||||
glow: 'rgba(255, 255, 255, 0.4)',
|
||||
border: 'rgba(255, 255, 255, 0.15)',
|
||||
},
|
||||
retro: {
|
||||
main: 'rgba(255, 170, 0, 0.8)',
|
||||
glow: 'rgba(255, 170, 0, 0.5)',
|
||||
border: 'rgba(255, 170, 0, 0.2)',
|
||||
},
|
||||
_default: {
|
||||
main: 'rgba(0, 255, 255, 0.6)',
|
||||
glow: 'rgba(0, 255, 255, 0.4)',
|
||||
border: 'rgba(0, 255, 255, 0.15)',
|
||||
},
|
||||
};
|
||||
|
||||
/** Shader modes that automatically show the HUD overlay. */
|
||||
@@ -45,13 +65,14 @@ const HUD_SUMMARY_INTERVAL_MS = 15000;
|
||||
const HUD_GEOID_CELL_DEG = 0.01;
|
||||
|
||||
/** Flattened list of all city POIs for nearest-point lookups. */
|
||||
const NEARBY_POINTS = Object.values(CITY_POIS)
|
||||
.flatMap((city) => city.pois.map((poi) => ({
|
||||
const NEARBY_POINTS = Object.values(CITY_POIS).flatMap((city) =>
|
||||
city.pois.map((poi) => ({
|
||||
city: city.name,
|
||||
poi: poi.name,
|
||||
lat: poi.lat,
|
||||
lon: poi.lon,
|
||||
})));
|
||||
})),
|
||||
);
|
||||
|
||||
/**
|
||||
* Full-screen intelligence HUD overlay rendered on top of the Cesium canvas.
|
||||
@@ -66,7 +87,15 @@ export class IntelHUD {
|
||||
* @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for
|
||||
* camera telemetry and coordinate derivation.
|
||||
*/
|
||||
constructor(viewer, { placeSearch, summaryPolicy = {}, basemapContext = {}, summaryService = applicationServices.summary } = {}) {
|
||||
constructor(
|
||||
viewer,
|
||||
{
|
||||
placeSearch,
|
||||
summaryPolicy = {},
|
||||
basemapContext = {},
|
||||
summaryService = applicationServices.summary,
|
||||
} = {},
|
||||
) {
|
||||
this.summaryService = summaryService;
|
||||
this.summaryPolicy = summaryPolicy;
|
||||
this.basemapContext = basemapContext;
|
||||
@@ -227,7 +256,8 @@ export class IntelHUD {
|
||||
this._recBlinkInterval = setInterval(() => {
|
||||
this._recBlinkState = !this._recBlinkState;
|
||||
const dot = document.getElementById('hud-rec-dot');
|
||||
if (dot) dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden';
|
||||
if (dot)
|
||||
dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden';
|
||||
}, 800);
|
||||
|
||||
// Camera-derived data — 4 updates/second (250ms)
|
||||
@@ -269,8 +299,12 @@ export class IntelHUD {
|
||||
if (!this._geoidRequested) {
|
||||
this._geoidRequested = true;
|
||||
ensureGeoidReady()
|
||||
.then(() => { this._geoidReady = true; })
|
||||
.catch(() => { /* readout falls back to the uncorrected height */ });
|
||||
.then(() => {
|
||||
this._geoidReady = true;
|
||||
})
|
||||
.catch(() => {
|
||||
/* readout falls back to the uncorrected height */
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -331,9 +365,13 @@ export class IntelHUD {
|
||||
// Equation (GIQE) simplified form: NIIRS = 10.25 - 3.32 * log10(GSD_inches).
|
||||
const gsd = Math.max(0.01, altM * 0.000375);
|
||||
const gsdInches = gsd * 39.37;
|
||||
const niirs = Math.max(0, Math.min(9, 10.25 - 3.32 * Math.log10(gsdInches)));
|
||||
const niirs = Math.max(
|
||||
0,
|
||||
Math.min(9, 10.25 - 3.32 * Math.log10(gsdInches)),
|
||||
);
|
||||
const gsdEl = document.getElementById('hud-gsd');
|
||||
if (gsdEl) gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
|
||||
if (gsdEl)
|
||||
gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
|
||||
|
||||
// Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
|
||||
// ellipsoidal camera height, which reads far below zero wherever the geoid
|
||||
@@ -344,7 +382,8 @@ export class IntelHUD {
|
||||
const geoidN = this._geoidUndulationM(latDeg, lonDeg);
|
||||
const altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
|
||||
const sunEl = this._estimateSunElevation(latDeg, lonDeg);
|
||||
if (altEl) altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
||||
if (altEl)
|
||||
altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
||||
|
||||
// Collection timestamp
|
||||
const collEl = document.getElementById('hud-coll');
|
||||
@@ -435,7 +474,10 @@ export class IntelHUD {
|
||||
if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S';
|
||||
else dir = decimal >= 0 ? 'E' : 'W';
|
||||
|
||||
const degStr = type === 'lon' ? String(deg).padStart(3, '0') : String(deg).padStart(2, '0');
|
||||
const degStr =
|
||||
type === 'lon'
|
||||
? String(deg).padStart(3, '0')
|
||||
: String(deg).padStart(2, '0');
|
||||
return `${degStr}°${String(min).padStart(2, '0')}'${String(sec).padStart(5, '0')}"${dir}`;
|
||||
}
|
||||
|
||||
@@ -457,12 +499,20 @@ export class IntelHUD {
|
||||
const solarNoon = 12;
|
||||
const hourAngle = (hours - solarNoon) * 15;
|
||||
// Solar declination approximation (~23.45 deg amplitude sinusoidal over the year)
|
||||
const declination = 23.45 * Math.sin(Cesium.Math.toRadians((360 / 365) * (now.getUTCDate() + 30 * now.getUTCMonth() - 81)));
|
||||
const declination =
|
||||
23.45 *
|
||||
Math.sin(
|
||||
Cesium.Math.toRadians(
|
||||
(360 / 365) * (now.getUTCDate() + 30 * now.getUTCMonth() - 81),
|
||||
),
|
||||
);
|
||||
const latRad = Cesium.Math.toRadians(lat);
|
||||
const decRad = Cesium.Math.toRadians(declination);
|
||||
const haRad = Cesium.Math.toRadians(hourAngle);
|
||||
// Standard formula: sin(el) = sin(lat)*sin(dec) + cos(lat)*cos(dec)*cos(ha)
|
||||
const sinEl = Math.sin(latRad) * Math.sin(decRad) + Math.cos(latRad) * Math.cos(decRad) * Math.cos(haRad);
|
||||
const sinEl =
|
||||
Math.sin(latRad) * Math.sin(decRad) +
|
||||
Math.cos(latRad) * Math.cos(decRad) * Math.cos(haRad);
|
||||
// Clamp to [-1,1] to guard against floating-point drift before asin
|
||||
return Cesium.Math.toDegrees(Math.asin(Math.max(-1, Math.min(1, sinEl))));
|
||||
}
|
||||
@@ -489,8 +539,10 @@ export class IntelHUD {
|
||||
_regionLabel(lat, lon) {
|
||||
if (lat > 72) return 'ARCTIC';
|
||||
if (lat < -60) return 'ANTARCTIC';
|
||||
if (lat >= 5 && lat <= 83 && lon >= -170 && lon <= -50) return 'NORTH AMERICA';
|
||||
if (lat >= -60 && lat <= 15 && lon >= -90 && lon <= -30) return 'SOUTH AMERICA';
|
||||
if (lat >= 5 && lat <= 83 && lon >= -170 && lon <= -50)
|
||||
return 'NORTH AMERICA';
|
||||
if (lat >= -60 && lat <= 15 && lon >= -90 && lon <= -30)
|
||||
return 'SOUTH AMERICA';
|
||||
if (lat >= 34 && lat <= 72 && lon >= -25 && lon <= 45) return 'EUROPE';
|
||||
if (lat >= -35 && lat <= 38 && lon >= -20 && lon <= 55) return 'AFRICA';
|
||||
if (lat >= 5 && lat <= 80 && lon >= 45 && lon <= 180) return 'ASIA';
|
||||
@@ -519,7 +571,10 @@ export class IntelHUD {
|
||||
const latSpan = Math.abs(north - south);
|
||||
// 111 km/deg is the approximate surface distance per degree of latitude;
|
||||
// longitude distance is scaled by cos(lat) to account for meridian convergence.
|
||||
const widthKm = Math.max(0, lonSpan * 111 * Math.cos(Cesium.Math.toRadians(latDeg)));
|
||||
const widthKm = Math.max(
|
||||
0,
|
||||
lonSpan * 111 * Math.cos(Cesium.Math.toRadians(latDeg)),
|
||||
);
|
||||
const heightKm = Math.max(0, latSpan * 111);
|
||||
return { widthKm, heightKm };
|
||||
}
|
||||
@@ -537,8 +592,9 @@ export class IntelHUD {
|
||||
const toRad = (deg) => Cesium.Math.toRadians(deg);
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
@@ -583,9 +639,10 @@ export class IntelHUD {
|
||||
// together, so they must never disagree. The view band above deliberately
|
||||
// keeps the ellipsoidal height: its thresholds were tuned against it.
|
||||
const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM;
|
||||
const altTag = altDisplayM >= 1000
|
||||
? `${(altDisplayM / 1000).toFixed(1)}KM`
|
||||
: `${Math.round(altDisplayM)}M`;
|
||||
const altTag =
|
||||
altDisplayM >= 1000
|
||||
? `${(altDisplayM / 1000).toFixed(1)}KM`
|
||||
: `${Math.round(altDisplayM)}M`;
|
||||
const winTag = window
|
||||
? `${Math.max(1, Math.round(window.widthKm))}x${Math.max(1, Math.round(window.heightKm))}KM`
|
||||
: 'N/A';
|
||||
@@ -665,7 +722,9 @@ export class IntelHUD {
|
||||
this._summaryRequest = controller;
|
||||
try {
|
||||
this.summaryPolicy.onRequest?.();
|
||||
const response = await this.summaryService.summarize(context, { signal: controller.signal });
|
||||
const response = await this.summaryService.summarize(context, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const data = response.data;
|
||||
if (revision !== this._summaryRevision) return;
|
||||
if (isHudSummaryUnconfigured(response.status, data)) {
|
||||
@@ -701,10 +760,16 @@ export class IntelHUD {
|
||||
}
|
||||
|
||||
async _summaryContext() {
|
||||
const labels = await getBasemapLabelContext(this.viewer, this.placeSearch, this.basemapContext);
|
||||
const enabledLayers = this._dataManager?.getAll?.()
|
||||
?.filter((layer) => layer.enabled)
|
||||
.map((layer) => layer.name) || [];
|
||||
const labels = await getBasemapLabelContext(
|
||||
this.viewer,
|
||||
this.placeSearch,
|
||||
this.basemapContext,
|
||||
);
|
||||
const enabledLayers =
|
||||
this._dataManager
|
||||
?.getAll?.()
|
||||
?.filter((layer) => layer.enabled)
|
||||
.map((layer) => layer.name) || [];
|
||||
return {
|
||||
placeLabels: labels.placeLabels,
|
||||
streetLabels: labels.streetLabels,
|
||||
|
||||
@@ -23,13 +23,16 @@ export function keylessHudSummaryResponse(apiKey) {
|
||||
|
||||
/** Return true only for the deliberate, successful no-key capability response. */
|
||||
export function isHudSummaryUnconfigured(status, data) {
|
||||
const keys = data !== null && typeof data === 'object' && !Array.isArray(data)
|
||||
? Object.keys(data)
|
||||
: [];
|
||||
return keys.length === 4
|
||||
&& status === 200
|
||||
&& data?.configured === false
|
||||
&& data?.code === HUD_SUMMARY_UNCONFIGURED_CODE
|
||||
&& data?.error === null
|
||||
&& data?.summary === null;
|
||||
const keys =
|
||||
data !== null && typeof data === 'object' && !Array.isArray(data)
|
||||
? Object.keys(data)
|
||||
: [];
|
||||
return (
|
||||
keys.length === 4 &&
|
||||
status === 200 &&
|
||||
data?.configured === false &&
|
||||
data?.code === HUD_SUMMARY_UNCONFIGURED_CODE &&
|
||||
data?.error === null &&
|
||||
data?.summary === null
|
||||
);
|
||||
}
|
||||
+69
-26
@@ -19,7 +19,9 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
||||
/** Chip label — pure, exported for tests. */
|
||||
export function keySetupChipLabel(status) {
|
||||
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0));
|
||||
return missing > 0 ? `POWER UP · ${missing} ${missing === 1 ? 'KEY' : 'KEYS'} WAITING` : 'POWERED UP';
|
||||
return missing > 0
|
||||
? `POWER UP · ${missing} ${missing === 1 ? 'KEY' : 'KEYS'} WAITING`
|
||||
: 'POWERED UP';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,13 +80,17 @@ function buildRow(documentRef, key) {
|
||||
const tier = documentRef.createElement('span');
|
||||
tier.className = 'key-setup-tier';
|
||||
tier.textContent = TIER_DOTS[key.tier] || '';
|
||||
tier.title = key.tier === 'metered' ? 'Metered — a billing-enabled account' : 'Free key — register, paste, done';
|
||||
tier.title =
|
||||
key.tier === 'metered'
|
||||
? 'Metered — a billing-enabled account'
|
||||
: 'Free key — register, paste, done';
|
||||
head.append(led, title, tier);
|
||||
if (key.clientExposed) {
|
||||
const exposed = documentRef.createElement('span');
|
||||
exposed.className = 'key-setup-exposed';
|
||||
exposed.textContent = 'browser-side';
|
||||
exposed.title = 'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)';
|
||||
exposed.title =
|
||||
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)';
|
||||
head.append(exposed);
|
||||
}
|
||||
if (external) {
|
||||
@@ -93,7 +99,8 @@ function buildRow(documentRef, key) {
|
||||
const badge = documentRef.createElement('span');
|
||||
badge.className = 'key-setup-external';
|
||||
badge.textContent = 'configured externally';
|
||||
badge.title = 'Supplied by your environment, Keychain, or launcher — change it where it was set';
|
||||
badge.title =
|
||||
'Supplied by your environment, Keychain, or launcher — change it where it was set';
|
||||
head.append(badge);
|
||||
}
|
||||
const get = documentRef.createElement('a');
|
||||
@@ -144,7 +151,11 @@ function buildRow(documentRef, key) {
|
||||
* Wire the chip + dialog. Fire-and-forget from main.js; resolves to null when
|
||||
* the surface has no business existing (prod build, LAN visitor, no markup).
|
||||
*/
|
||||
export async function initKeySetup({ documentRef = globalThis.document, fetchImpl, signal } = {}) {
|
||||
export async function initKeySetup({
|
||||
documentRef = globalThis.document,
|
||||
fetchImpl,
|
||||
signal,
|
||||
} = {}) {
|
||||
const chip = documentRef?.getElementById?.('key-setup-chip');
|
||||
const root = documentRef?.getElementById?.('key-setup');
|
||||
if (!chip || !root || root.dataset.initialized === 'true') return null;
|
||||
@@ -161,13 +172,19 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
||||
chip.remove();
|
||||
root.remove();
|
||||
};
|
||||
if (signal?.aborted) { destroy(); return null; }
|
||||
if (signal?.aborted) {
|
||||
destroy();
|
||||
return null;
|
||||
}
|
||||
signal?.addEventListener('abort', destroy, { once: true });
|
||||
const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis);
|
||||
|
||||
let status = null;
|
||||
try {
|
||||
const response = await doFetch('/api/setup/status', { cache: 'no-store', signal: lifetime.signal });
|
||||
const response = await doFetch('/api/setup/status', {
|
||||
cache: 'no-store',
|
||||
signal: lifetime.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
status = await response.json();
|
||||
if (disposed) return null;
|
||||
@@ -196,12 +213,14 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
||||
chip.hidden = status.setCount >= status.total;
|
||||
if (!rowsHost) return;
|
||||
rowsHost.textContent = '';
|
||||
for (const key of status.keys || []) rowsHost.append(buildRow(documentRef, key));
|
||||
for (const key of status.keys || [])
|
||||
rowsHost.append(buildRow(documentRef, key));
|
||||
};
|
||||
|
||||
const visible = () => root.isConnected
|
||||
&& root.classList.contains('visible')
|
||||
&& root.getClientRects().length > 0;
|
||||
const visible = () =>
|
||||
root.isConnected &&
|
||||
root.classList.contains('visible') &&
|
||||
root.getClientRects().length > 0;
|
||||
|
||||
const keyboard = createSurfaceKeyboard({
|
||||
root,
|
||||
@@ -226,22 +245,29 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
||||
if (!open) return;
|
||||
open = false;
|
||||
root.classList.remove('visible');
|
||||
const hide = () => { if (!open) root.hidden = true; };
|
||||
const hide = () => {
|
||||
if (!open) root.hidden = true;
|
||||
};
|
||||
root.addEventListener('transitionend', hide, { once: true });
|
||||
globalThis.setTimeout?.(hide, 400);
|
||||
if (statusLine) statusLine.textContent = defaultStatusText;
|
||||
keyboard.deactivate({ restoreFocus: true });
|
||||
};
|
||||
|
||||
const say = (text) => { if (statusLine) statusLine.textContent = text; };
|
||||
const say = (text) => {
|
||||
if (statusLine) statusLine.textContent = text;
|
||||
};
|
||||
|
||||
const storeLabel = () => (status?.store === 'pinokio-environment'
|
||||
? 'your app configuration'
|
||||
: 'your local .env');
|
||||
const storeLabel = () =>
|
||||
status?.store === 'pinokio-environment'
|
||||
? 'your app configuration'
|
||||
: 'your local .env';
|
||||
|
||||
const submitUpdates = async (updates, doneVerb) => {
|
||||
if (disposed || busy) return;
|
||||
const googleWasUnset = !status?.keys?.find((key) => key.id === 'google-maps')?.set;
|
||||
const googleWasUnset = !status?.keys?.find(
|
||||
(key) => key.id === 'google-maps',
|
||||
)?.set;
|
||||
busy = true;
|
||||
applyButton?.setAttribute('aria-disabled', 'true');
|
||||
say('Saving…');
|
||||
@@ -258,13 +284,17 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
||||
say(payload.error || `Save failed (${response.status}).`);
|
||||
return;
|
||||
}
|
||||
for (const input of root.querySelectorAll('input[data-env-var]')) input.value = '';
|
||||
for (const input of root.querySelectorAll('input[data-env-var]'))
|
||||
input.value = '';
|
||||
render(payload.status);
|
||||
if (googleWasUnset && payload.saved?.includes('GOOGLE_MAPS_API_KEY')) {
|
||||
const strip = () => {
|
||||
try {
|
||||
const next = stripKeylessBasemapFromHash(globalThis.location?.hash?.slice(1) || '');
|
||||
if (next !== null) globalThis.history?.replaceState?.(null, '', `#${next}`);
|
||||
const next = stripKeylessBasemapFromHash(
|
||||
globalThis.location?.hash?.slice(1) || '',
|
||||
);
|
||||
if (next !== null)
|
||||
globalThis.history?.replaceState?.(null, '', `#${next}`);
|
||||
} catch {
|
||||
// Continuity is a nicety, never a blocker.
|
||||
}
|
||||
@@ -272,9 +302,14 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
||||
strip();
|
||||
// The live share writer may re-serialize the still-OSM stack before
|
||||
// the restart's reload lands, so strip again at the door.
|
||||
globalThis.addEventListener?.('pagehide', strip, { once: true, signal: lifetime.signal });
|
||||
globalThis.addEventListener?.('pagehide', strip, {
|
||||
once: true,
|
||||
signal: lifetime.signal,
|
||||
});
|
||||
}
|
||||
say(`${doneVerb} ${storeLabel()}. Restarting — this page reloads itself.`);
|
||||
say(
|
||||
`${doneVerb} ${storeLabel()}. Restarting — this page reloads itself.`,
|
||||
);
|
||||
} catch (error) {
|
||||
say(`Save failed: ${error?.message || error}`);
|
||||
} finally {
|
||||
@@ -287,7 +322,10 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
||||
if (disposed || busy) return;
|
||||
const inputs = [...root.querySelectorAll('input[data-env-var]')];
|
||||
const updates = collectKeyUpdates(
|
||||
inputs.map((input) => ({ envVar: input.dataset.envVar, value: input.value })),
|
||||
inputs.map((input) => ({
|
||||
envVar: input.dataset.envVar,
|
||||
value: input.value,
|
||||
})),
|
||||
);
|
||||
if (!Object.keys(updates).length) {
|
||||
say('Paste at least one key first.');
|
||||
@@ -313,8 +351,9 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
||||
// Removal is destructive and — behind a framing defense that should already
|
||||
// stop it — a clickjack target. A confirm turns a single aligned click into
|
||||
// a deliberate two-step the lure cannot pre-satisfy.
|
||||
const ok = typeof globalThis.confirm !== 'function'
|
||||
|| globalThis.confirm('Remove this key from your saved configuration?');
|
||||
const ok =
|
||||
typeof globalThis.confirm !== 'function' ||
|
||||
globalThis.confirm('Remove this key from your saved configuration?');
|
||||
if (!ok) return;
|
||||
void submitUpdates(
|
||||
Object.fromEntries(envVars.map((name) => [name, null])),
|
||||
@@ -327,7 +366,11 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
||||
// Re-entry for a fully-keyed setup, demos, and support: ?setup=1 opens the
|
||||
// dialog even though the chip has retired.
|
||||
try {
|
||||
if (new URLSearchParams(globalThis.location?.search || '').get('setup') === '1') openDialog();
|
||||
if (
|
||||
new URLSearchParams(globalThis.location?.search || '').get('setup') ===
|
||||
'1'
|
||||
)
|
||||
openDialog();
|
||||
} catch {
|
||||
// An unparsable location never blocks init.
|
||||
}
|
||||
|
||||
+15
-3
@@ -1336,7 +1336,12 @@ function buildingPitch(bounds) {
|
||||
return -32;
|
||||
}
|
||||
|
||||
async function resolveBuildingBounds(lat, lon, query, boundaries = applicationServices.boundaries) {
|
||||
async function resolveBuildingBounds(
|
||||
lat,
|
||||
lon,
|
||||
query,
|
||||
boundaries = applicationServices.boundaries,
|
||||
) {
|
||||
const overpassQuery = `
|
||||
[out:json][timeout:10];
|
||||
(
|
||||
@@ -1352,8 +1357,15 @@ async function resolveBuildingBounds(lat, lon, query, boundaries = applicationSe
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), 6000);
|
||||
try {
|
||||
const elements = await boundaries.query(overpassQuery, { signal: controller.signal });
|
||||
return selectBuildingBounds(Array.isArray(elements) ? elements : [], lat, lon, query);
|
||||
const elements = await boundaries.query(overpassQuery, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
return selectBuildingBounds(
|
||||
Array.isArray(elements) ? elements : [],
|
||||
lat,
|
||||
lon,
|
||||
query,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
+38
-8
@@ -13,9 +13,27 @@ const GAZE_EPSILON = 0.08;
|
||||
* @param {number} [maxOffset=MAX_GAZE_SVG_UNITS] - Maximum SVG-space translation.
|
||||
* @returns {{x:number, y:number}}
|
||||
*/
|
||||
export function calculateLogoGaze(clientX, clientY, rect, maxOffset = MAX_GAZE_SVG_UNITS) {
|
||||
const values = [clientX, clientY, rect?.left, rect?.top, rect?.width, rect?.height, maxOffset];
|
||||
if (!values.every(Number.isFinite) || rect.width <= 0 || rect.height <= 0 || maxOffset < 0) {
|
||||
export function calculateLogoGaze(
|
||||
clientX,
|
||||
clientY,
|
||||
rect,
|
||||
maxOffset = MAX_GAZE_SVG_UNITS,
|
||||
) {
|
||||
const values = [
|
||||
clientX,
|
||||
clientY,
|
||||
rect?.left,
|
||||
rect?.top,
|
||||
rect?.width,
|
||||
rect?.height,
|
||||
maxOffset,
|
||||
];
|
||||
if (
|
||||
!values.every(Number.isFinite) ||
|
||||
rect.width <= 0 ||
|
||||
rect.height <= 0 ||
|
||||
maxOffset < 0
|
||||
) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
@@ -25,7 +43,7 @@ export function calculateLogoGaze(clientX, clientY, rect, maxOffset = MAX_GAZE_S
|
||||
if (distance === 0) return { x: 0, y: 0 };
|
||||
|
||||
const strength = Math.min(distance / FULL_GAZE_DISTANCE_PX, 1);
|
||||
const scale = maxOffset * strength / distance;
|
||||
const scale = (maxOffset * strength) / distance;
|
||||
return { x: dx * scale, y: dy * scale };
|
||||
}
|
||||
|
||||
@@ -43,7 +61,9 @@ export function initLogoGaze(root = document) {
|
||||
const logos = [...root.querySelectorAll('[data-logo-gaze]')];
|
||||
if (!logos.length) return () => {};
|
||||
|
||||
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
const reducedMotion = window.matchMedia?.(
|
||||
'(prefers-reduced-motion: reduce)',
|
||||
).matches;
|
||||
const states = logos.map((element) => ({
|
||||
element,
|
||||
parts: [],
|
||||
@@ -68,7 +88,10 @@ export function initLogoGaze(root = document) {
|
||||
const deltaX = state.targetX - state.currentX;
|
||||
const deltaY = state.targetY - state.currentY;
|
||||
|
||||
if (Math.abs(deltaX) <= GAZE_EPSILON && Math.abs(deltaY) <= GAZE_EPSILON) {
|
||||
if (
|
||||
Math.abs(deltaX) <= GAZE_EPSILON &&
|
||||
Math.abs(deltaY) <= GAZE_EPSILON
|
||||
) {
|
||||
state.currentX = state.targetX;
|
||||
state.currentY = state.targetY;
|
||||
} else {
|
||||
@@ -94,7 +117,10 @@ export function initLogoGaze(root = document) {
|
||||
const markup = await response.text();
|
||||
if (disposed) return;
|
||||
|
||||
const svgDocument = new DOMParser().parseFromString(markup, 'image/svg+xml');
|
||||
const svgDocument = new DOMParser().parseFromString(
|
||||
markup,
|
||||
'image/svg+xml',
|
||||
);
|
||||
if (svgDocument.querySelector('parsererror')) return;
|
||||
const sourceSvg = svgDocument.documentElement;
|
||||
|
||||
@@ -122,7 +148,11 @@ export function initLogoGaze(root = document) {
|
||||
const onPointerMove = (event) => {
|
||||
if (reducedMotion || event.pointerType === 'touch') return;
|
||||
for (const state of states) {
|
||||
const gaze = calculateLogoGaze(event.clientX, event.clientY, state.element.getBoundingClientRect());
|
||||
const gaze = calculateLogoGaze(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
state.element.getBoundingClientRect(),
|
||||
);
|
||||
state.targetX = gaze.x;
|
||||
state.targetY = gaze.y;
|
||||
}
|
||||
|
||||
+14
-8
@@ -30,12 +30,14 @@ export function announceNavigationAuthority(
|
||||
{ eventTarget = globalThis.window, cancelPendingSelection = true } = {},
|
||||
) {
|
||||
if (typeof eventTarget?.dispatchEvent !== 'function') return false;
|
||||
eventTarget.dispatchEvent(new CustomEvent(NAVIGATION_AUTHORITY_EVENT, {
|
||||
detail: {
|
||||
reason: String(reason || 'layer-focus'),
|
||||
cancelPendingSelection: Boolean(cancelPendingSelection),
|
||||
},
|
||||
}));
|
||||
eventTarget.dispatchEvent(
|
||||
new CustomEvent(NAVIGATION_AUTHORITY_EVENT, {
|
||||
detail: {
|
||||
reason: String(reason || 'layer-focus'),
|
||||
cancelPendingSelection: Boolean(cancelPendingSelection),
|
||||
},
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -46,8 +48,12 @@ export function announceNavigationAuthority(
|
||||
* @returns {() => void} Idempotent disposer.
|
||||
*/
|
||||
export function registerNavigationAuthorityListener(eventTarget, listener) {
|
||||
if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener
|
||||
|| typeof listener !== 'function') return () => {};
|
||||
if (
|
||||
!eventTarget?.addEventListener ||
|
||||
!eventTarget?.removeEventListener ||
|
||||
typeof listener !== 'function'
|
||||
)
|
||||
return () => {};
|
||||
eventTarget.addEventListener(NAVIGATION_AUTHORITY_EVENT, listener);
|
||||
let disposed = false;
|
||||
return () => {
|
||||
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { holdContinuousRender, releaseContinuousRender } from './renderGovernor.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
} from './renderGovernor.js';
|
||||
|
||||
/**
|
||||
* OrbitController — smooth orbit around a target point.
|
||||
@@ -54,7 +57,7 @@ export class OrbitController {
|
||||
const hpr = new Cesium.HeadingPitchRange(
|
||||
this.angle,
|
||||
Cesium.Math.toRadians(this.pitch),
|
||||
this.radius
|
||||
this.radius,
|
||||
);
|
||||
this.viewer.camera.lookAt(this.target, hpr);
|
||||
});
|
||||
|
||||
+572
-230
File diff suppressed because it is too large.
Load diff
@@ -49,22 +49,34 @@ export function clearWorldOverlayTextMeasureCache() {
|
||||
|
||||
/** Install the font-loading invalidation hooks once, when the API exists. */
|
||||
export function installWorldOverlayFontInvalidation() {
|
||||
if (_fontInvalidationInstalled || typeof document === 'undefined' || !document.fonts) return;
|
||||
if (
|
||||
_fontInvalidationInstalled ||
|
||||
typeof document === 'undefined' ||
|
||||
!document.fonts
|
||||
)
|
||||
return;
|
||||
_fontInvalidationInstalled = true;
|
||||
_observedFontSet = document.fonts;
|
||||
const generation = ++_fontInvalidationGeneration;
|
||||
Promise.resolve(document.fonts.ready)
|
||||
.then(() => {
|
||||
if (generation === _fontInvalidationGeneration) clearWorldOverlayTextMeasureCache();
|
||||
if (generation === _fontInvalidationGeneration)
|
||||
clearWorldOverlayTextMeasureCache();
|
||||
})
|
||||
.catch(() => {});
|
||||
_observedFontSet.addEventListener?.('loadingdone', clearWorldOverlayTextMeasureCache);
|
||||
_observedFontSet.addEventListener?.(
|
||||
'loadingdone',
|
||||
clearWorldOverlayTextMeasureCache,
|
||||
);
|
||||
}
|
||||
|
||||
/** Remove font hooks and cached measurements when the overlay host is destroyed. */
|
||||
export function destroyWorldOverlayDraw() {
|
||||
_fontInvalidationGeneration++;
|
||||
_observedFontSet?.removeEventListener?.('loadingdone', clearWorldOverlayTextMeasureCache);
|
||||
_observedFontSet?.removeEventListener?.(
|
||||
'loadingdone',
|
||||
clearWorldOverlayTextMeasureCache,
|
||||
);
|
||||
_observedFontSet = null;
|
||||
_fontInvalidationInstalled = false;
|
||||
clearWorldOverlayTextMeasureCache();
|
||||
@@ -95,7 +107,8 @@ export function measureWorldOverlayText(ctx, text, font) {
|
||||
}
|
||||
fontCache.set(normalizedText, { width, usedAt: ++_textMeasureClock });
|
||||
_textMeasureCacheSize++;
|
||||
if (_textMeasureCacheSize > TEXT_MEASURE_CACHE_LIMIT) evictOldestTextMeasureEntry();
|
||||
if (_textMeasureCacheSize > TEXT_MEASURE_CACHE_LIMIT)
|
||||
evictOldestTextMeasureEntry();
|
||||
return width;
|
||||
}
|
||||
|
||||
@@ -113,7 +126,14 @@ export function getWorldOverlayTextMeasureCacheSize() {
|
||||
* @param {number} h
|
||||
* @param {number} radius
|
||||
*/
|
||||
export function roundedRectPath(path, x, y, w, h, radius = WORLD_OVERLAY_STYLE.radius) {
|
||||
export function roundedRectPath(
|
||||
path,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
radius = WORLD_OVERLAY_STYLE.radius,
|
||||
) {
|
||||
if (typeof path.roundRect === 'function') {
|
||||
path.roundRect(x, y, w, h, radius);
|
||||
return;
|
||||
@@ -137,11 +157,14 @@ export function roundedRectPath(path, x, y, w, h, radius = WORLD_OVERLAY_STYLE.r
|
||||
* @param {number} [options.fadeStartRatio]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function distanceFade(distanceM, {
|
||||
minDistance = 0,
|
||||
maxDistance = Number.POSITIVE_INFINITY,
|
||||
fadeStartRatio = 0.7,
|
||||
} = {}) {
|
||||
export function distanceFade(
|
||||
distanceM,
|
||||
{
|
||||
minDistance = 0,
|
||||
maxDistance = Number.POSITIVE_INFINITY,
|
||||
fadeStartRatio = 0.7,
|
||||
} = {},
|
||||
) {
|
||||
if (!Number.isFinite(distanceM)) return 0;
|
||||
const min = Number.isFinite(minDistance) ? Math.max(0, minDistance) : 0;
|
||||
if (distanceM < min) return 0;
|
||||
@@ -192,17 +215,27 @@ export function altitudeScale(altitudeM, curve = null) {
|
||||
const end = Number(curve.end);
|
||||
const midValue = Number(curve.midValue);
|
||||
const endValue = Number(curve.endValue);
|
||||
if (!Number.isFinite(fullEnd) || !Number.isFinite(midEnd) || !Number.isFinite(end)
|
||||
|| !Number.isFinite(midValue) || !Number.isFinite(endValue)) return 1;
|
||||
if (
|
||||
!Number.isFinite(fullEnd) ||
|
||||
!Number.isFinite(midEnd) ||
|
||||
!Number.isFinite(end) ||
|
||||
!Number.isFinite(midValue) ||
|
||||
!Number.isFinite(endValue)
|
||||
)
|
||||
return 1;
|
||||
if (altitudeM <= fullEnd) return 1;
|
||||
if (altitudeM <= midEnd) {
|
||||
const span = Math.max(1, midEnd - fullEnd);
|
||||
let progress = Math.max(0, Math.min(1, (altitudeM - fullEnd) / span));
|
||||
if (curve.smoothToMid === true) progress = progress * progress * (3 - 2 * progress);
|
||||
if (curve.smoothToMid === true)
|
||||
progress = progress * progress * (3 - 2 * progress);
|
||||
return 1 + (midValue - 1) * progress;
|
||||
}
|
||||
if (altitudeM >= end) return endValue;
|
||||
const progress = Math.max(0, Math.min(1, (altitudeM - midEnd) / Math.max(1, end - midEnd)));
|
||||
const progress = Math.max(
|
||||
0,
|
||||
Math.min(1, (altitudeM - midEnd) / Math.max(1, end - midEnd)),
|
||||
);
|
||||
return midValue + (endValue - midValue) * progress;
|
||||
}
|
||||
|
||||
@@ -215,11 +248,14 @@ export function altitudeScale(altitudeM, curve = null) {
|
||||
* @param {number} [options.fadeEnd]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function altitudeFade(altitudeM, {
|
||||
minAltitude = Number.NEGATIVE_INFINITY,
|
||||
fadeStart = Number.POSITIVE_INFINITY,
|
||||
fadeEnd = Number.POSITIVE_INFINITY,
|
||||
} = {}) {
|
||||
export function altitudeFade(
|
||||
altitudeM,
|
||||
{
|
||||
minAltitude = Number.NEGATIVE_INFINITY,
|
||||
fadeStart = Number.POSITIVE_INFINITY,
|
||||
fadeEnd = Number.POSITIVE_INFINITY,
|
||||
} = {},
|
||||
) {
|
||||
if (!Number.isFinite(altitudeM)) return 1;
|
||||
if (Number.isFinite(minAltitude) && altitudeM < minAltitude) return 0;
|
||||
if (!Number.isFinite(fadeEnd)) return 1;
|
||||
@@ -238,24 +274,32 @@ export function combinedOverlayAlpha({
|
||||
altitudeFade: altitudeAlpha = 1,
|
||||
keyholeEdgeFade = 1,
|
||||
} = {}) {
|
||||
return clampUnit(sourceAlpha)
|
||||
* clampUnit(temporalFade)
|
||||
* clampUnit(distanceAlpha)
|
||||
* clampUnit(altitudeAlpha)
|
||||
* clampUnit(keyholeEdgeFade);
|
||||
return (
|
||||
clampUnit(sourceAlpha) *
|
||||
clampUnit(temporalFade) *
|
||||
clampUnit(distanceAlpha) *
|
||||
clampUnit(altitudeAlpha) *
|
||||
clampUnit(keyholeEdgeFade)
|
||||
);
|
||||
}
|
||||
|
||||
function clampUnit(value) {
|
||||
return Number.isFinite(Number(value)) ? Math.max(0, Math.min(1, Number(value))) : 1;
|
||||
return Number.isFinite(Number(value))
|
||||
? Math.max(0, Math.min(1, Number(value)))
|
||||
: 1;
|
||||
}
|
||||
|
||||
function trackDisplayText(entry) {
|
||||
const title = entry?.title || '';
|
||||
const detail = Array.isArray(entry?.details) ? entry.details[0] || '' : '';
|
||||
if (entry._overlayTrackDisplayTitle !== title || entry._overlayTrackDisplayDetail !== detail) {
|
||||
if (
|
||||
entry._overlayTrackDisplayTitle !== title ||
|
||||
entry._overlayTrackDisplayDetail !== detail
|
||||
) {
|
||||
entry._overlayTrackDisplayTitle = title;
|
||||
entry._overlayTrackDisplayDetail = detail;
|
||||
entry._overlayTrackDisplayText = title && detail ? `${title} · ${detail}` : title || detail;
|
||||
entry._overlayTrackDisplayText =
|
||||
title && detail ? `${title} · ${detail}` : title || detail;
|
||||
}
|
||||
return entry._overlayTrackDisplayText || '';
|
||||
}
|
||||
@@ -268,16 +312,23 @@ function trackDisplayText(entry) {
|
||||
* @returns {{w:number,h:number,padX:number,padY:number,titleH:number,lineH:number,thumbW:number,thumbH:number}}
|
||||
*/
|
||||
export function measureOverlayEntry(ctx, entry, out = {}) {
|
||||
const variant = entry?.selected ? 'selected' : String(entry?.variant || 'label');
|
||||
const variant = entry?.selected
|
||||
? 'selected'
|
||||
: String(entry?.variant || 'label');
|
||||
const details = Array.isArray(entry?.details) ? entry.details : [];
|
||||
const selected = variant === 'selected';
|
||||
const tracked = variant === 'tracked';
|
||||
const tactical = entry?.cardStyle === 'tactical';
|
||||
const titleFont = tracked
|
||||
? WORLD_OVERLAY_STYLE.fontTrackedTitle
|
||||
: selected ? WORLD_OVERLAY_STYLE.fontSelected : WORLD_OVERLAY_STYLE.fontTitle;
|
||||
let titleWidth = measureWorldOverlayText(ctx, entry?.title || '',
|
||||
variant === 'label' ? WORLD_OVERLAY_STYLE.fontLabel : titleFont);
|
||||
: selected
|
||||
? WORLD_OVERLAY_STYLE.fontSelected
|
||||
: WORLD_OVERLAY_STYLE.fontTitle;
|
||||
let titleWidth = measureWorldOverlayText(
|
||||
ctx,
|
||||
entry?.title || '',
|
||||
variant === 'label' ? WORLD_OVERLAY_STYLE.fontLabel : titleFont,
|
||||
);
|
||||
if (variant === 'track' && details[0]) {
|
||||
titleWidth = measureWorldOverlayText(
|
||||
ctx,
|
||||
@@ -292,16 +343,38 @@ export function measureOverlayEntry(ctx, entry, out = {}) {
|
||||
measureWorldOverlayText(
|
||||
ctx,
|
||||
details[i],
|
||||
tracked ? WORLD_OVERLAY_STYLE.fontTrackedDetail : WORLD_OVERLAY_STYLE.fontDetail,
|
||||
tracked
|
||||
? WORLD_OVERLAY_STYLE.fontTrackedDetail
|
||||
: WORLD_OVERLAY_STYLE.fontDetail,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
out.padX = tracked ? 13 : selected ? 12 : variant === 'label' || variant === 'track' ? 6 : 9;
|
||||
out.padY = tracked ? 9 : selected ? 8 : variant === 'label' || variant === 'track' ? 4 : 6;
|
||||
out.padX = tracked
|
||||
? 13
|
||||
: selected
|
||||
? 12
|
||||
: variant === 'label' || variant === 'track'
|
||||
? 6
|
||||
: 9;
|
||||
out.padY = tracked
|
||||
? 9
|
||||
: selected
|
||||
? 8
|
||||
: variant === 'label' || variant === 'track'
|
||||
? 4
|
||||
: 6;
|
||||
out.titleH = tactical
|
||||
? (selected ? 14 : 12)
|
||||
: tracked ? 13 : selected ? 15 : variant === 'label' || variant === 'track' ? 11 : 13;
|
||||
? selected
|
||||
? 14
|
||||
: 12
|
||||
: tracked
|
||||
? 13
|
||||
: selected
|
||||
? 15
|
||||
: variant === 'label' || variant === 'track'
|
||||
? 11
|
||||
: 13;
|
||||
out.lineH = tracked ? 17 : selected ? 15 : 13;
|
||||
out.thumbW = 0;
|
||||
out.thumbH = 0;
|
||||
@@ -325,7 +398,17 @@ export function measureOverlayEntry(ctx, entry, out = {}) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function writePlacement(out, corner, x, y, w, h, anchorX, anchorY, signedLeaderOffset = 0) {
|
||||
function writePlacement(
|
||||
out,
|
||||
corner,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
anchorX,
|
||||
anchorY,
|
||||
signedLeaderOffset = 0,
|
||||
) {
|
||||
const placement = out || {};
|
||||
placement.corner = corner;
|
||||
placement.rect ||= {};
|
||||
@@ -350,12 +433,17 @@ function writePlacement(out, corner, x, y, w, h, anchorX, anchorY, signedLeaderO
|
||||
// pushed the card sideways off its anchor, which reads as "the card isn't
|
||||
// attached to that camera".
|
||||
placement.leadToX = anchorX;
|
||||
placement.leadToY = corner === 'above' ? placement.rect.y + h : placement.rect.y;
|
||||
placement.leadToY =
|
||||
corner === 'above' ? placement.rect.y + h : placement.rect.y;
|
||||
} else {
|
||||
placement.leadFromX = anchorX;
|
||||
placement.leadFromY = anchorY;
|
||||
placement.leadToX = corner === 'left' ? placement.rect.x + w : placement.rect.x;
|
||||
placement.leadToY = Math.max(placement.rect.y, Math.min(anchorY, placement.rect.y + h));
|
||||
placement.leadToX =
|
||||
corner === 'left' ? placement.rect.x + w : placement.rect.x;
|
||||
placement.leadToY = Math.max(
|
||||
placement.rect.y,
|
||||
Math.min(anchorY, placement.rect.y + h),
|
||||
);
|
||||
}
|
||||
return placement;
|
||||
}
|
||||
@@ -373,7 +461,10 @@ const VERTICAL_PLACEMENT_ORDERS = Object.freeze({
|
||||
});
|
||||
|
||||
function clampPlacementCoordinate(value, size, viewportSize, margin = 4) {
|
||||
return Math.max(margin, Math.min(value, Math.max(margin, viewportSize - size - margin)));
|
||||
return Math.max(
|
||||
margin,
|
||||
Math.min(value, Math.max(margin, viewportSize - size - margin)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -394,19 +485,22 @@ function clampPlacementCoordinate(value, size, viewportSize, margin = 4) {
|
||||
* @param {Array<object>} [out]
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function placementVariants({
|
||||
anchorX,
|
||||
anchorY,
|
||||
width,
|
||||
height,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
gap = 12,
|
||||
preferred = 'auto',
|
||||
leaderOffset = 0,
|
||||
verticalOnly = false,
|
||||
viewportMargin = 4,
|
||||
}, out = []) {
|
||||
export function placementVariants(
|
||||
{
|
||||
anchorX,
|
||||
anchorY,
|
||||
width,
|
||||
height,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
gap = 12,
|
||||
preferred = 'auto',
|
||||
leaderOffset = 0,
|
||||
verticalOnly = false,
|
||||
viewportMargin = 4,
|
||||
},
|
||||
out = [],
|
||||
) {
|
||||
const automatic = anchorY - gap - height >= 4 ? 'above' : 'below';
|
||||
const orders = verticalOnly ? VERTICAL_PLACEMENT_ORDERS : PLACEMENT_ORDERS;
|
||||
const order = orders[preferred] || orders[automatic];
|
||||
@@ -437,7 +531,9 @@ export function placementVariants({
|
||||
height,
|
||||
anchorX,
|
||||
anchorY,
|
||||
corner === 'above' || corner === 'left' ? negativeLeaderOffset : positiveLeaderOffset,
|
||||
corner === 'above' || corner === 'left'
|
||||
? negativeLeaderOffset
|
||||
: positiveLeaderOffset,
|
||||
);
|
||||
}
|
||||
out.length = order.length;
|
||||
@@ -454,9 +550,15 @@ function drawLeader(ctx, placement, accent) {
|
||||
if (placement.leaderOffset === 0) {
|
||||
ctx.moveTo(placement.leadFromX, placement.leadFromY);
|
||||
} else if (placement.corner === 'above' || placement.corner === 'below') {
|
||||
ctx.moveTo(placement.leadFromX, placement.leadFromY + placement.leaderOffset);
|
||||
ctx.moveTo(
|
||||
placement.leadFromX,
|
||||
placement.leadFromY + placement.leaderOffset,
|
||||
);
|
||||
} else {
|
||||
ctx.moveTo(placement.leadFromX + placement.leaderOffset, placement.leadFromY);
|
||||
ctx.moveTo(
|
||||
placement.leadFromX + placement.leaderOffset,
|
||||
placement.leadFromY,
|
||||
);
|
||||
}
|
||||
ctx.lineTo(placement.leadToX, placement.leadToY);
|
||||
ctx.stroke();
|
||||
@@ -468,9 +570,13 @@ function drawCardChrome(ctx, entry, placement, selected = false) {
|
||||
drawLeader(ctx, placement, accent);
|
||||
ctx.beginPath();
|
||||
roundedRectPath(ctx, x, y, w, h);
|
||||
ctx.fillStyle = selected ? WORLD_OVERLAY_STYLE.selectedBackground : WORLD_OVERLAY_STYLE.background;
|
||||
ctx.fillStyle = selected
|
||||
? WORLD_OVERLAY_STYLE.selectedBackground
|
||||
: WORLD_OVERLAY_STYLE.background;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = selected ? WORLD_OVERLAY_STYLE.selectedBorder : WORLD_OVERLAY_STYLE.border;
|
||||
ctx.strokeStyle = selected
|
||||
? WORLD_OVERLAY_STYLE.selectedBorder
|
||||
: WORLD_OVERLAY_STYLE.border;
|
||||
ctx.lineWidth = selected ? 1.25 : 1;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = accent;
|
||||
@@ -482,7 +588,9 @@ function drawCardText(ctx, entry, placement, selected = false, topOffset = 0) {
|
||||
const x = placement.rect.x + (selected ? 12 : 9);
|
||||
let y = placement.rect.y + (selected ? 8 : 6) + topOffset;
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
|
||||
ctx.font = selected ? WORLD_OVERLAY_STYLE.fontSelected : WORLD_OVERLAY_STYLE.fontTitle;
|
||||
ctx.font = selected
|
||||
? WORLD_OVERLAY_STYLE.fontSelected
|
||||
: WORLD_OVERLAY_STYLE.fontTitle;
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(String(entry.title || ''), x, y);
|
||||
y += selected ? 15 : 13;
|
||||
@@ -497,7 +605,8 @@ function drawCardText(ctx, entry, placement, selected = false, topOffset = 0) {
|
||||
function colorWithAlpha(color, alpha) {
|
||||
const text = String(color || WORLD_OVERLAY_STYLE.accent).trim();
|
||||
const triplet = text.match(/^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})$/);
|
||||
if (triplet) return `rgba(${triplet[1]}, ${triplet[2]}, ${triplet[3]}, ${alpha})`;
|
||||
if (triplet)
|
||||
return `rgba(${triplet[1]}, ${triplet[2]}, ${triplet[3]}, ${alpha})`;
|
||||
const hex = text.match(/^#([0-9a-f]{6})$/i);
|
||||
if (hex) {
|
||||
const value = Number.parseInt(hex[1], 16);
|
||||
@@ -534,33 +643,57 @@ function leaderDrawRatio(entry) {
|
||||
return Number.isFinite(ratio) ? Math.max(0.1, Math.min(0.9, ratio)) : 0.68;
|
||||
}
|
||||
|
||||
export function leaderRevealProgress(entry, timestamp = globalThis.performance?.now?.() ?? Date.now()) {
|
||||
const linear = Math.min(1, animationLinearProgress(entry, timestamp) / leaderDrawRatio(entry));
|
||||
export function leaderRevealProgress(
|
||||
entry,
|
||||
timestamp = globalThis.performance?.now?.() ?? Date.now(),
|
||||
) {
|
||||
const linear = Math.min(
|
||||
1,
|
||||
animationLinearProgress(entry, timestamp) / leaderDrawRatio(entry),
|
||||
);
|
||||
return 1 - Math.pow(1 - linear, 3);
|
||||
}
|
||||
|
||||
export function tacticalCardRevealAlpha(entry, timestamp = globalThis.performance?.now?.() ?? Date.now()) {
|
||||
export function tacticalCardRevealAlpha(
|
||||
entry,
|
||||
timestamp = globalThis.performance?.now?.() ?? Date.now(),
|
||||
) {
|
||||
if (!(Math.max(0, Number(entry?.leaderAnimationMs) || 0) > 0)) return 1;
|
||||
const start = leaderDrawRatio(entry);
|
||||
const linear = Math.max(0, Math.min(1,
|
||||
(animationLinearProgress(entry, timestamp) - start) / Math.max(0.1, 1 - start)));
|
||||
const linear = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(animationLinearProgress(entry, timestamp) - start) /
|
||||
Math.max(0.1, 1 - start),
|
||||
),
|
||||
);
|
||||
return linear * linear * (3 - 2 * linear);
|
||||
}
|
||||
|
||||
function drawTacticalLeader(ctx, entry, placement, accent, timestamp) {
|
||||
ctx.strokeStyle = accent;
|
||||
ctx.lineWidth = (entry.leaderStyle === 'elbow' ? 1.5 : 1) / (placement.paintScale || 1);
|
||||
ctx.lineWidth =
|
||||
(entry.leaderStyle === 'elbow' ? 1.5 : 1) / (placement.paintScale || 1);
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
if (entry.leaderStyle !== 'elbow'
|
||||
|| (placement.corner !== 'above' && placement.corner !== 'below')) {
|
||||
if (
|
||||
entry.leaderStyle !== 'elbow' ||
|
||||
(placement.corner !== 'above' && placement.corner !== 'below')
|
||||
) {
|
||||
if (placement.leaderOffset === 0) {
|
||||
ctx.moveTo(placement.leadFromX, placement.leadFromY);
|
||||
} else if (placement.corner === 'above' || placement.corner === 'below') {
|
||||
ctx.moveTo(placement.leadFromX, placement.leadFromY + placement.leaderOffset);
|
||||
ctx.moveTo(
|
||||
placement.leadFromX,
|
||||
placement.leadFromY + placement.leaderOffset,
|
||||
);
|
||||
} else {
|
||||
ctx.moveTo(placement.leadFromX + placement.leaderOffset, placement.leadFromY);
|
||||
ctx.moveTo(
|
||||
placement.leadFromX + placement.leaderOffset,
|
||||
placement.leadFromY,
|
||||
);
|
||||
}
|
||||
ctx.lineTo(placement.leadToX, placement.leadToY);
|
||||
ctx.stroke();
|
||||
@@ -575,7 +708,9 @@ function drawTacticalLeader(ctx, entry, placement, accent, timestamp) {
|
||||
const anchorY = placement.leadFromY;
|
||||
const horizontalLength = Math.abs(anchorX - cardX);
|
||||
const verticalLength = Math.abs(anchorY - cardY);
|
||||
let remaining = leaderRevealProgress(entry, timestamp) * (horizontalLength + verticalLength);
|
||||
let remaining =
|
||||
leaderRevealProgress(entry, timestamp) *
|
||||
(horizontalLength + verticalLength);
|
||||
ctx.moveTo(anchorX, anchorY);
|
||||
if (remaining <= horizontalLength) {
|
||||
const direction = Math.sign(cardX - anchorX) || -1;
|
||||
@@ -584,7 +719,10 @@ function drawTacticalLeader(ctx, entry, placement, accent, timestamp) {
|
||||
ctx.lineTo(cardX, anchorY);
|
||||
remaining -= horizontalLength;
|
||||
const direction = Math.sign(cardY - anchorY) || -1;
|
||||
ctx.lineTo(cardX, anchorY + direction * Math.min(verticalLength, remaining));
|
||||
ctx.lineTo(
|
||||
cardX,
|
||||
anchorY + direction * Math.min(verticalLength, remaining),
|
||||
);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
@@ -600,9 +738,15 @@ export function paintTacticalCard(ctx, entry, placement, alpha = 1) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
drawTacticalLeader(ctx, entry, placement, entry.leaderStyle === 'elbow'
|
||||
? colorWithAlpha(entry.accent, 0.95)
|
||||
: accentColors.leader, animationTimestamp);
|
||||
drawTacticalLeader(
|
||||
ctx,
|
||||
entry,
|
||||
placement,
|
||||
entry.leaderStyle === 'elbow'
|
||||
? colorWithAlpha(entry.accent, 0.95)
|
||||
: accentColors.leader,
|
||||
animationTimestamp,
|
||||
);
|
||||
|
||||
ctx.globalAlpha = alpha * tacticalCardRevealAlpha(entry, animationTimestamp);
|
||||
|
||||
@@ -625,13 +769,19 @@ export function paintTacticalCard(ctx, entry, placement, alpha = 1) {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
|
||||
ctx.font = selected ? WORLD_OVERLAY_STYLE.fontSelected : WORLD_OVERLAY_STYLE.fontTitle;
|
||||
ctx.font = selected
|
||||
? WORLD_OVERLAY_STYLE.fontSelected
|
||||
: WORLD_OVERLAY_STYLE.fontTitle;
|
||||
const titleBaseline = y + layout.padY + layout.titleH - 2;
|
||||
ctx.fillText(String(entry.title || ''), x + layout.padX, titleBaseline);
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
||||
ctx.font = WORLD_OVERLAY_STYLE.fontDetail;
|
||||
for (let i = 0; i < details.length; i++) {
|
||||
ctx.fillText(String(details[i]), x + layout.padX, titleBaseline + (i + 1) * layout.lineH);
|
||||
ctx.fillText(
|
||||
String(details[i]),
|
||||
x + layout.padX,
|
||||
titleBaseline + (i + 1) * layout.lineH,
|
||||
);
|
||||
}
|
||||
ctx.restore();
|
||||
return placement.rect;
|
||||
@@ -645,7 +795,11 @@ export function paintLabel(ctx, entry, placement, alpha = 1) {
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
|
||||
ctx.font = WORLD_OVERLAY_STYLE.fontLabel;
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(String(entry.title || ''), placement.rect.x + 6, placement.rect.y + 4);
|
||||
ctx.fillText(
|
||||
String(entry.title || ''),
|
||||
placement.rect.x + 6,
|
||||
placement.rect.y + 4,
|
||||
);
|
||||
ctx.restore();
|
||||
return placement.rect;
|
||||
}
|
||||
@@ -690,7 +844,11 @@ export function paintThumbnail(ctx, entry, placement, alpha = 1) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
drawLeader(ctx, placement, entry.thumbnailLeaderColor || WORLD_OVERLAY_STYLE.leader);
|
||||
drawLeader(
|
||||
ctx,
|
||||
placement,
|
||||
entry.thumbnailLeaderColor || WORLD_OVERLAY_STYLE.leader,
|
||||
);
|
||||
ctx.beginPath();
|
||||
roundedRectPath(ctx, x, y, w, h, Number(entry.thumbnailRadius) || 4);
|
||||
ctx.fillStyle = entry.thumbnailBackground || WORLD_OVERLAY_STYLE.background;
|
||||
@@ -711,13 +869,17 @@ export function paintThumbnail(ctx, entry, placement, alpha = 1) {
|
||||
if (ruleHeight > 0) {
|
||||
ctx.beginPath();
|
||||
roundedRectPath(ctx, x, y, w, ruleHeight, Math.min(1, ruleHeight));
|
||||
ctx.fillStyle = entry.thumbnailRuleColor || entry.accent || WORLD_OVERLAY_STYLE.accent;
|
||||
ctx.fillStyle =
|
||||
entry.thumbnailRuleColor || entry.accent || WORLD_OVERLAY_STYLE.accent;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
const titleChars = Math.max(0, Math.floor(Number(entry.thumbnailTitleChars) || 0));
|
||||
const title = entry._overlayThumbnailTitle
|
||||
|| String(entry.title || '').toUpperCase();
|
||||
const titleChars = Math.max(
|
||||
0,
|
||||
Math.floor(Number(entry.thumbnailTitleChars) || 0),
|
||||
);
|
||||
const title =
|
||||
entry._overlayThumbnailTitle || String(entry.title || '').toUpperCase();
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
ctx.fillStyle = entry.thumbnailTitleColor || WORLD_OVERLAY_STYLE.title;
|
||||
@@ -769,7 +931,11 @@ export function paintTracked(ctx, entry, placement, alpha = 1) {
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
||||
ctx.font = WORLD_OVERLAY_STYLE.fontTrackedDetail;
|
||||
for (let i = 0; i < details.length; i++) {
|
||||
ctx.fillText(String(details[i]), centerX, titleBaseline + (i + 1) * layout.lineH);
|
||||
ctx.fillText(
|
||||
String(details[i]),
|
||||
centerX,
|
||||
titleBaseline + (i + 1) * layout.lineH,
|
||||
);
|
||||
}
|
||||
ctx.restore();
|
||||
return placement.rect;
|
||||
@@ -842,7 +1008,8 @@ export function paintDetectionCallout(ctx, callout, alpha = 1) {
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = callout.label;
|
||||
ctx.font = callout.font;
|
||||
if (callout.primary) ctx.fillText(callout.primary, callout.primaryX, callout.baseline);
|
||||
if (callout.primary)
|
||||
ctx.fillText(callout.primary, callout.primaryX, callout.baseline);
|
||||
if (callout.micro) {
|
||||
ctx.globalAlpha = alpha * 0.8;
|
||||
ctx.font = callout.microFont;
|
||||
@@ -852,11 +1019,16 @@ export function paintDetectionCallout(ctx, callout, alpha = 1) {
|
||||
|
||||
/** Dispatch a normalized entry to its pure variant painter. */
|
||||
export function paintOverlayEntry(ctx, entry, placement, alpha = 1) {
|
||||
if (entry.cardStyle === 'tactical') return paintTacticalCard(ctx, entry, placement, alpha);
|
||||
if (entry.variant === 'tracked') return paintTracked(ctx, entry, placement, alpha);
|
||||
if (entry.selected || entry.variant === 'selected') return paintSelected(ctx, entry, placement, alpha);
|
||||
if (entry.variant === 'thumbnail') return paintThumbnail(ctx, entry, placement, alpha);
|
||||
if (entry.cardStyle === 'tactical')
|
||||
return paintTacticalCard(ctx, entry, placement, alpha);
|
||||
if (entry.variant === 'tracked')
|
||||
return paintTracked(ctx, entry, placement, alpha);
|
||||
if (entry.selected || entry.variant === 'selected')
|
||||
return paintSelected(ctx, entry, placement, alpha);
|
||||
if (entry.variant === 'thumbnail')
|
||||
return paintThumbnail(ctx, entry, placement, alpha);
|
||||
if (entry.variant === 'card') return paintCard(ctx, entry, placement, alpha);
|
||||
if (entry.variant === 'track') return paintTrack(ctx, entry, placement, alpha);
|
||||
if (entry.variant === 'track')
|
||||
return paintTrack(ctx, entry, placement, alpha);
|
||||
return paintLabel(ctx, entry, placement, alpha);
|
||||
}
|
||||
@@ -61,7 +61,8 @@ function applyMode() {
|
||||
* @returns {void}
|
||||
*/
|
||||
export function installRenderGovernor(viewer) {
|
||||
if (!viewer?.scene) throw new TypeError('installRenderGovernor requires a Cesium viewer');
|
||||
if (!viewer?.scene)
|
||||
throw new TypeError('installRenderGovernor requires a Cesium viewer');
|
||||
_viewer = viewer;
|
||||
_installed = true;
|
||||
// Never let Cesium re-render on simulation-time deltas behind our back —
|
||||
|
||||
@@ -29,9 +29,9 @@ export function shouldExpandGlobalContextPanel({
|
||||
restoring = false,
|
||||
}) {
|
||||
return Boolean(
|
||||
GLOBAL_CONTEXT_EXPLICIT_ACTIONS.has(action)
|
||||
&& explicitUserAction
|
||||
&& succeeded
|
||||
&& !restoring
|
||||
GLOBAL_CONTEXT_EXPLICIT_ACTIONS.has(action) &&
|
||||
explicitUserAction &&
|
||||
succeeded &&
|
||||
!restoring,
|
||||
);
|
||||
}
|
||||
+223
-76
@@ -60,7 +60,10 @@ function normalizeLayerEntry(entry) {
|
||||
if (entry && typeof entry === 'object') {
|
||||
return {
|
||||
enabled: !!entry.enabled,
|
||||
params: entry.params && typeof entry.params === 'object' ? deepClone(entry.params) : undefined,
|
||||
params:
|
||||
entry.params && typeof entry.params === 'object'
|
||||
? deepClone(entry.params)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return { enabled: !!entry };
|
||||
@@ -75,13 +78,18 @@ function normalizeLayerEntry(entry) {
|
||||
* @param {number} [options.fallbackIntensity] - Default intensity if not stored
|
||||
* @returns {{ enabled: boolean, intensity: number, version: number }}
|
||||
*/
|
||||
function normalizeBloomState(rawBloom = {}, { projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {}) {
|
||||
function normalizeBloomState(
|
||||
rawBloom = {},
|
||||
{ projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {},
|
||||
) {
|
||||
// Determine which bloom scale the stored value was encoded under.
|
||||
// Older projects (version < PROJECT_VERSION) used scale version 1.
|
||||
const explicitVersion = Number(rawBloom.version);
|
||||
const bloomVersion = Number.isFinite(explicitVersion)
|
||||
? explicitVersion
|
||||
: (projectVersion >= PROJECT_VERSION ? BLOOM_SCALE_VERSION : 1);
|
||||
: projectVersion >= PROJECT_VERSION
|
||||
? BLOOM_SCALE_VERSION
|
||||
: 1;
|
||||
|
||||
const rawIntensity = Number.isFinite(Number(rawBloom.intensity))
|
||||
? Number(rawBloom.intensity)
|
||||
@@ -104,9 +112,10 @@ function normalizeBloomState(rawBloom = {}, { projectVersion = PROJECT_VERSION,
|
||||
function recipeToScene(recipe) {
|
||||
const post = recipe.post || {};
|
||||
const ui = recipe.ui || {};
|
||||
const styleParams = post.styleParams && typeof post.styleParams === 'object'
|
||||
? deepClone(post.styleParams)
|
||||
: {};
|
||||
const styleParams =
|
||||
post.styleParams && typeof post.styleParams === 'object'
|
||||
? deepClone(post.styleParams)
|
||||
: {};
|
||||
|
||||
// Normalize layer targets from the recipe into canonical form
|
||||
const layers = {};
|
||||
@@ -138,13 +147,15 @@ function recipeToScene(recipe) {
|
||||
style: recipe.style || 'normal',
|
||||
bloom: {
|
||||
enabled: typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom,
|
||||
intensity: typeof post.bloom === 'number'
|
||||
? decodeBloomIntensity(post.bloom, 1)
|
||||
: BLOOM_INTENSITY_DEFAULT,
|
||||
intensity:
|
||||
typeof post.bloom === 'number'
|
||||
? decodeBloomIntensity(post.bloom, 1)
|
||||
: BLOOM_INTENSITY_DEFAULT,
|
||||
version: BLOOM_SCALE_VERSION,
|
||||
},
|
||||
sharpen: {
|
||||
enabled: typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen,
|
||||
enabled:
|
||||
typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen,
|
||||
intensity: 65,
|
||||
},
|
||||
hud: {
|
||||
@@ -190,7 +201,11 @@ function createDefaultProject() {
|
||||
* @param {number} [options.projectVersion] - Schema version of the enclosing project
|
||||
* @returns {Object} Fully normalized shot
|
||||
*/
|
||||
function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION } = {}) {
|
||||
function normalizeShot(
|
||||
rawShot,
|
||||
index = 0,
|
||||
{ projectVersion = PROJECT_VERSION } = {},
|
||||
) {
|
||||
const camera = rawShot?.camera || {};
|
||||
const visual = rawShot?.visual || {};
|
||||
const bloom = visual.bloom || {};
|
||||
@@ -201,7 +216,10 @@ function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION }
|
||||
return {
|
||||
id: rawShot?.id || uid('shot'),
|
||||
title: rawShot?.title || `Shot ${index + 1}`,
|
||||
durationSec: Math.max(0.2, Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC),
|
||||
durationSec: Math.max(
|
||||
0.2,
|
||||
Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC,
|
||||
),
|
||||
holdSec: Math.max(0, Number(rawShot?.holdSec) || 0),
|
||||
camera: {
|
||||
lat: Number(camera.lat) || 0,
|
||||
@@ -213,10 +231,21 @@ function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION }
|
||||
},
|
||||
visual: {
|
||||
style: visual.style || 'normal',
|
||||
bloom: normalizeBloomState(bloom, { projectVersion, fallbackIntensity: 50 }),
|
||||
bloom: normalizeBloomState(bloom, {
|
||||
projectVersion,
|
||||
fallbackIntensity: 50,
|
||||
}),
|
||||
sharpen: {
|
||||
enabled: !!sharpen.enabled,
|
||||
intensity: Math.max(0, Math.min(100, Number.isFinite(Number(sharpen.intensity)) ? Number(sharpen.intensity) : 65)),
|
||||
intensity: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
Number.isFinite(Number(sharpen.intensity))
|
||||
? Number(sharpen.intensity)
|
||||
: 65,
|
||||
),
|
||||
),
|
||||
},
|
||||
hud: {
|
||||
visible: typeof hud.visible === 'boolean' ? hud.visible : true,
|
||||
@@ -224,12 +253,26 @@ function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION }
|
||||
},
|
||||
detection: {
|
||||
mode: typeof detection.mode === 'string' ? detection.mode : 'OFF',
|
||||
density: Math.max(0, Math.min(100, Number.isFinite(Number(detection.density)) ? Number(detection.density) : 35)),
|
||||
density: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
Number.isFinite(Number(detection.density))
|
||||
? Number(detection.density)
|
||||
: 35,
|
||||
),
|
||||
),
|
||||
},
|
||||
styleParams: visual.styleParams && typeof visual.styleParams === 'object' ? deepClone(visual.styleParams) : {},
|
||||
styleParams:
|
||||
visual.styleParams && typeof visual.styleParams === 'object'
|
||||
? deepClone(visual.styleParams)
|
||||
: {},
|
||||
},
|
||||
layers: Object.fromEntries(
|
||||
Object.entries(rawShot?.layers || {}).map(([layerId, value]) => [layerId, normalizeLayerEntry(value)])
|
||||
Object.entries(rawShot?.layers || {}).map(([layerId, value]) => [
|
||||
layerId,
|
||||
normalizeLayerEntry(value),
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -241,7 +284,8 @@ function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION }
|
||||
* @returns {Object} Fully normalized project at the current PROJECT_VERSION
|
||||
*/
|
||||
function normalizeProject(rawProject) {
|
||||
if (!rawProject || typeof rawProject !== 'object') return createDefaultProject();
|
||||
if (!rawProject || typeof rawProject !== 'object')
|
||||
return createDefaultProject();
|
||||
const projectVersion = Number.isFinite(Number(rawProject.version))
|
||||
? Number(rawProject.version)
|
||||
: 1;
|
||||
@@ -250,7 +294,9 @@ function normalizeProject(rawProject) {
|
||||
const scenes = scenesRaw
|
||||
.map((scene, sceneIdx) => {
|
||||
const shotsRaw = Array.isArray(scene?.shots) ? scene.shots : [];
|
||||
const shots = shotsRaw.map((shot, shotIdx) => normalizeShot(shot, shotIdx, { projectVersion }));
|
||||
const shots = shotsRaw.map((shot, shotIdx) =>
|
||||
normalizeShot(shot, shotIdx, { projectVersion }),
|
||||
);
|
||||
return {
|
||||
id: scene?.id || uid('scene'),
|
||||
title: scene?.title || `Scene ${sceneIdx + 1}`,
|
||||
@@ -316,12 +362,21 @@ export class SceneDirector {
|
||||
this._selectedSceneId = this._project.scenes[0]?.id || null;
|
||||
this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null;
|
||||
|
||||
this._presentation = { status: 'Ready', progress: 0, runtime: '', playbackActive: false, keyboardEnabled: false };
|
||||
this._state = createStateChannel(() => ({ ...this.getPlaybackStatus(), ...this._presentation, hasRun: !!this._lastRunJson }));
|
||||
this._presentation = {
|
||||
status: 'Ready',
|
||||
progress: 0,
|
||||
runtime: '',
|
||||
playbackActive: false,
|
||||
keyboardEnabled: false,
|
||||
};
|
||||
this._state = createStateChannel(() => ({
|
||||
...this.getPlaybackStatus(),
|
||||
...this._presentation,
|
||||
hasRun: !!this._lastRunJson,
|
||||
}));
|
||||
this._initUI();
|
||||
}
|
||||
|
||||
|
||||
_trackWork(promise) {
|
||||
this._pendingWork ||= new Set();
|
||||
this._pendingWork.add(promise);
|
||||
@@ -372,7 +427,10 @@ export class SceneDirector {
|
||||
// Private browsing / block-all-cookies / quota-exceeded throws here. The
|
||||
// in-memory project stays usable this session, but persistence failed —
|
||||
// tell the user instead of crashing the caller (M11).
|
||||
console.warn('[Scenes] Could not persist project (storage unavailable):', e);
|
||||
console.warn(
|
||||
'[Scenes] Could not persist project (storage unavailable):',
|
||||
e,
|
||||
);
|
||||
this._toastStorageError();
|
||||
}
|
||||
}
|
||||
@@ -391,17 +449,30 @@ export class SceneDirector {
|
||||
toast.classList.remove('visible');
|
||||
}, 2600);
|
||||
}
|
||||
} catch { /* toast is best-effort */ }
|
||||
} catch {
|
||||
/* toast is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Immutable playback snapshots and completed editing actions. */
|
||||
subscribe(listener, options) { return this._state.subscribe(listener, options); }
|
||||
subscribe(listener, options) {
|
||||
return this._state.subscribe(listener, options);
|
||||
}
|
||||
|
||||
_publish(change) { this._state?.publish(change); }
|
||||
_publish(change) {
|
||||
this._state?.publish(change);
|
||||
}
|
||||
|
||||
_shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) {
|
||||
if (type === 'shot-loaded') this._presentation.status = `Loaded: ${scene.title} / ${shot.title}`;
|
||||
this._publish({ type, sceneId: scene.id, sceneTitle: scene.title, shot, index });
|
||||
if (type === 'shot-loaded')
|
||||
this._presentation.status = `Loaded: ${scene.title} / ${shot.title}`;
|
||||
this._publish({
|
||||
type,
|
||||
sceneId: scene.id,
|
||||
sceneTitle: scene.title,
|
||||
shot,
|
||||
index,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -424,7 +495,10 @@ export class SceneDirector {
|
||||
this._selectedShotId = this._getSelectedScene()?.shots[0]?.id || null;
|
||||
this._renderShotList();
|
||||
},
|
||||
selectShot: (id) => { this._selectedShotId = id; this._publish({ type: 'selection-changed' }); },
|
||||
selectShot: (id) => {
|
||||
this._selectedShotId = id;
|
||||
this._publish({ type: 'selection-changed' });
|
||||
},
|
||||
renameShot: (sceneId, shotId, title) => {
|
||||
const { scene, shot } = this._getShot(sceneId, shotId);
|
||||
if (!shot) return;
|
||||
@@ -450,7 +524,9 @@ export class SceneDirector {
|
||||
|
||||
/** Rebuild the scene dropdown options and sync the selected value. */
|
||||
_renderSceneSelect() {
|
||||
if (!this._project.scenes.some((scene) => scene.id === this._selectedSceneId)) {
|
||||
if (
|
||||
!this._project.scenes.some((scene) => scene.id === this._selectedSceneId)
|
||||
) {
|
||||
this._selectedSceneId = this._project.scenes[0]?.id || null;
|
||||
}
|
||||
this._publish({ type: 'scene-options-changed' });
|
||||
@@ -463,7 +539,10 @@ export class SceneDirector {
|
||||
*/
|
||||
_renderShotList() {
|
||||
const scene = this._getSelectedScene();
|
||||
if (scene?.shots.length && !scene.shots.some((shot) => shot.id === this._selectedShotId)) {
|
||||
if (
|
||||
scene?.shots.length &&
|
||||
!scene.shots.some((shot) => shot.id === this._selectedShotId)
|
||||
) {
|
||||
this._selectedShotId = scene.shots[0].id;
|
||||
}
|
||||
this._publish({ type: 'shots-changed' });
|
||||
@@ -474,7 +553,11 @@ export class SceneDirector {
|
||||
* @returns {Object|null} The scene, or null if no valid selection
|
||||
*/
|
||||
_getSelectedScene() {
|
||||
return this._project.scenes.find((scene) => scene.id === this._selectedSceneId) || null;
|
||||
return (
|
||||
this._project.scenes.find(
|
||||
(scene) => scene.id === this._selectedSceneId,
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -511,7 +594,9 @@ export class SceneDirector {
|
||||
const scene = this._getSelectedScene();
|
||||
if (!scene) return;
|
||||
|
||||
this._project.scenes = this._project.scenes.filter((item) => item.id !== scene.id);
|
||||
this._project.scenes = this._project.scenes.filter(
|
||||
(item) => item.id !== scene.id,
|
||||
);
|
||||
// Restore default recipes if the user deleted all scenes
|
||||
if (!this._project.scenes.length) {
|
||||
this._project = createDefaultProject();
|
||||
@@ -553,15 +638,18 @@ export class SceneDirector {
|
||||
return;
|
||||
}
|
||||
|
||||
const shot = normalizeShot({
|
||||
id: uid('shot'),
|
||||
title: `Shot ${scene.shots.length + 1}`,
|
||||
durationSec: DEFAULT_SHOT_DURATION_SEC,
|
||||
holdSec: DEFAULT_HOLD_SEC,
|
||||
camera,
|
||||
visual: this.styleManager.getVisualState(),
|
||||
layers: this._captureLayerStates(),
|
||||
}, scene.shots.length);
|
||||
const shot = normalizeShot(
|
||||
{
|
||||
id: uid('shot'),
|
||||
title: `Shot ${scene.shots.length + 1}`,
|
||||
durationSec: DEFAULT_SHOT_DURATION_SEC,
|
||||
holdSec: DEFAULT_HOLD_SEC,
|
||||
camera,
|
||||
visual: this.styleManager.getVisualState(),
|
||||
layers: this._captureLayerStates(),
|
||||
},
|
||||
scene.shots.length,
|
||||
);
|
||||
|
||||
scene.shots.push(shot);
|
||||
this._selectedShotId = shot.id;
|
||||
@@ -628,7 +716,8 @@ export class SceneDirector {
|
||||
* @param {number} [options.flyDuration=2.2] - Camera flight duration in seconds
|
||||
*/
|
||||
loadShot(sceneId, shotId, options) {
|
||||
if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' });
|
||||
if (this._destroyed)
|
||||
return Promise.resolve({ started: false, reason: 'destroyed' });
|
||||
return this._trackWork(this._loadShot(sceneId, shotId, options));
|
||||
}
|
||||
|
||||
@@ -652,7 +741,9 @@ export class SceneDirector {
|
||||
this._renderSceneSelect();
|
||||
this._renderShotList();
|
||||
|
||||
await this.styleManager.applyVisualState(shot.visual, { isCurrent: () => !token.cancelled });
|
||||
await this.styleManager.applyVisualState(shot.visual, {
|
||||
isCurrent: () => !token.cancelled,
|
||||
});
|
||||
if (token.cancelled) return;
|
||||
await this._applyLayerStates(shot.layers || {}, token);
|
||||
if (token.cancelled) return;
|
||||
@@ -693,8 +784,12 @@ export class SceneDirector {
|
||||
*/
|
||||
_claimCameraOwnership() {
|
||||
// Older/headless style managers may predate the facade — proceed then.
|
||||
if (typeof this.styleManager?.runImmediateNavigation !== 'function') return true;
|
||||
const claimed = this.styleManager.runImmediateNavigation('scene', () => true);
|
||||
if (typeof this.styleManager?.runImmediateNavigation !== 'function')
|
||||
return true;
|
||||
const claimed = this.styleManager.runImmediateNavigation(
|
||||
'scene',
|
||||
() => true,
|
||||
);
|
||||
if (claimed === false) {
|
||||
this._updateStatus('Camera unavailable — exit cockpit first');
|
||||
return false;
|
||||
@@ -712,13 +807,16 @@ export class SceneDirector {
|
||||
if (!this._project.scenes.length) return [];
|
||||
|
||||
// Rotate the scene list so startSceneId comes first
|
||||
const startIdx = Math.max(0, this._project.scenes.findIndex((scene) => scene.id === startSceneId));
|
||||
const startIdx = Math.max(
|
||||
0,
|
||||
this._project.scenes.findIndex((scene) => scene.id === startSceneId),
|
||||
);
|
||||
const ordered = single
|
||||
? this._project.scenes.slice(startIdx, startIdx + 1)
|
||||
: [
|
||||
...this._project.scenes.slice(startIdx),
|
||||
...this._project.scenes.slice(0, startIdx),
|
||||
];
|
||||
...this._project.scenes.slice(startIdx),
|
||||
...this._project.scenes.slice(0, startIdx),
|
||||
];
|
||||
|
||||
// Flatten scenes into a sequential shot queue
|
||||
const queue = [];
|
||||
@@ -748,12 +846,17 @@ export class SceneDirector {
|
||||
* @returns {{id: string, title: string, shots: number}|null}
|
||||
*/
|
||||
findSceneByQuery(query) {
|
||||
const q = String(query ?? '').trim().toLowerCase();
|
||||
const q = String(query ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!q) return null;
|
||||
const scene = this._project.scenes.find((item) => item.id === query)
|
||||
|| this._project.scenes.find((item) => item.title.toLowerCase() === q)
|
||||
|| this._project.scenes.find((item) => item.title.toLowerCase().includes(q));
|
||||
return scene ? { id: scene.id, title: scene.title, shots: scene.shots.length } : null;
|
||||
const scene =
|
||||
this._project.scenes.find((item) => item.id === query) ||
|
||||
this._project.scenes.find((item) => item.title.toLowerCase() === q) ||
|
||||
this._project.scenes.find((item) => item.title.toLowerCase().includes(q));
|
||||
return scene
|
||||
? { id: scene.id, title: scene.title, shots: scene.shots.length }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -765,8 +868,12 @@ export class SceneDirector {
|
||||
running: this._running,
|
||||
selectedSceneId: this._selectedSceneId,
|
||||
selectedShotId: this._selectedShotId,
|
||||
elapsedMs: this._activeRun ? Math.max(0, Date.now() - Date.parse(this._activeRun.startedAt)) : null,
|
||||
estimatedDurationMs: this._activeRun ? Math.round(this._activeRun.estimatedDurationSec * 1000) : null,
|
||||
elapsedMs: this._activeRun
|
||||
? Math.max(0, Date.now() - Date.parse(this._activeRun.startedAt))
|
||||
: null,
|
||||
estimatedDurationMs: this._activeRun
|
||||
? Math.round(this._activeRun.estimatedDurationSec * 1000)
|
||||
: null,
|
||||
sceneCount: this._project.scenes.length,
|
||||
};
|
||||
}
|
||||
@@ -792,14 +899,18 @@ export class SceneDirector {
|
||||
* @returns {Promise<{started: boolean, reason?: string, shots?: number}>}
|
||||
*/
|
||||
startScene(sceneId, options) {
|
||||
if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' });
|
||||
if (this._destroyed)
|
||||
return Promise.resolve({ started: false, reason: 'destroyed' });
|
||||
return this._trackWork(this._startScene(sceneId, options));
|
||||
}
|
||||
|
||||
async _startScene(sceneId, { single = false } = {}) {
|
||||
if (this._running) return { started: false, reason: 'already-running' };
|
||||
|
||||
const queue = this._buildPlaybackQueue(sceneId || this._selectedSceneId || this._project.scenes[0]?.id, { single });
|
||||
const queue = this._buildPlaybackQueue(
|
||||
sceneId || this._selectedSceneId || this._project.scenes[0]?.id,
|
||||
{ single },
|
||||
);
|
||||
if (!queue.length) {
|
||||
this._updateStatus('No shots to run');
|
||||
return { started: false, reason: 'no-shots' };
|
||||
@@ -872,7 +983,9 @@ export class SceneDirector {
|
||||
this._renderSceneSelect();
|
||||
this._renderShotList();
|
||||
|
||||
this._updateStatus(`Running ${idx + 1}/${queue.length}: ${scene.title} / ${shot.title}`);
|
||||
this._updateStatus(
|
||||
`Running ${idx + 1}/${queue.length}: ${scene.title} / ${shot.title}`,
|
||||
);
|
||||
this._updateRuntime(`${scene.title} · ${shot.title}`);
|
||||
|
||||
this._logEvent('shot_start', {
|
||||
@@ -897,7 +1010,11 @@ export class SceneDirector {
|
||||
if (token.cancelled) break;
|
||||
await this._applyLayerStates(shot.layers || {}, token);
|
||||
if (token.cancelled) break;
|
||||
await this._flyCamera(shot.camera, shot.durationSec || DEFAULT_SHOT_DURATION_SEC, token);
|
||||
await this._flyCamera(
|
||||
shot.camera,
|
||||
shot.durationSec || DEFAULT_SHOT_DURATION_SEC,
|
||||
token,
|
||||
);
|
||||
if (token.cancelled) break;
|
||||
// Hold on the final frame before transitioning to the next shot
|
||||
await this._sleep((shot.holdSec || 0) * 1000, token);
|
||||
@@ -917,7 +1034,9 @@ export class SceneDirector {
|
||||
}
|
||||
} catch (error) {
|
||||
this._updateStatus(`Error: ${error.message || 'run failed'}`);
|
||||
this._logEvent('scene_run_error', { message: error.message || 'unknown error' });
|
||||
this._logEvent('scene_run_error', {
|
||||
message: error.message || 'unknown error',
|
||||
});
|
||||
} finally {
|
||||
this._finishRun();
|
||||
}
|
||||
@@ -930,13 +1049,17 @@ export class SceneDirector {
|
||||
async runNextScene() {
|
||||
if (this._destroyed || this._running) return;
|
||||
|
||||
const queue = this._buildPlaybackQueue(this._selectedSceneId || this._project.scenes[0]?.id);
|
||||
const queue = this._buildPlaybackQueue(
|
||||
this._selectedSceneId || this._project.scenes[0]?.id,
|
||||
);
|
||||
if (!queue.length) return;
|
||||
|
||||
// Find the shot after the current selection, wrapping to the start
|
||||
let next = queue[0];
|
||||
if (this._selectedShotId) {
|
||||
const idx = queue.findIndex((item) => item.shot.id === this._selectedShotId);
|
||||
const idx = queue.findIndex(
|
||||
(item) => item.shot.id === this._selectedShotId,
|
||||
);
|
||||
if (idx >= 0) next = queue[(idx + 1) % queue.length];
|
||||
}
|
||||
|
||||
@@ -1064,13 +1187,24 @@ export class SceneDirector {
|
||||
if (token?.cancelled) return abort();
|
||||
|
||||
const signal = token?.signal;
|
||||
const registered = new Set(this.dataManager.getAll().map((layer) => layer.id));
|
||||
for (const { id, enabled, params } of sceneLayerPlan(targetStates, registered)) {
|
||||
const settled = await this.dataManager.setEnabled(id, enabled, signal ? { signal } : undefined);
|
||||
const registered = new Set(
|
||||
this.dataManager.getAll().map((layer) => layer.id),
|
||||
);
|
||||
for (const { id, enabled, params } of sceneLayerPlan(
|
||||
targetStates,
|
||||
registered,
|
||||
)) {
|
||||
const settled = await this.dataManager.setEnabled(
|
||||
id,
|
||||
enabled,
|
||||
signal ? { signal } : undefined,
|
||||
);
|
||||
if (token?.cancelled) return abort();
|
||||
if (settled === false) {
|
||||
refused.push(id);
|
||||
console.warn(`[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`);
|
||||
console.warn(
|
||||
`[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
applied.push(id);
|
||||
@@ -1105,7 +1239,8 @@ export class SceneDirector {
|
||||
*/
|
||||
async _exitIsolatingContextMode() {
|
||||
// Older/headless style managers may predate the Context facade.
|
||||
if (typeof this.styleManager?.getContextModeState !== 'function') return false;
|
||||
if (typeof this.styleManager?.getContextModeState !== 'function')
|
||||
return false;
|
||||
if (typeof this.styleManager?.setContextMode !== 'function') return false;
|
||||
|
||||
const state = this.styleManager.getContextModeState() || {};
|
||||
@@ -1115,9 +1250,17 @@ export class SceneDirector {
|
||||
|
||||
const result = await this.styleManager.setContextMode('off');
|
||||
if (result && result.ok === false) {
|
||||
console.warn(`[Scenes] Could not exit ${mode}:`, result.error || 'unknown reason');
|
||||
this._updateStatus(`Could not exit ${mode} — scene layers may be refused`);
|
||||
this._logEvent('context_mode_exit_failed', { mode, error: result.error || null });
|
||||
console.warn(
|
||||
`[Scenes] Could not exit ${mode}:`,
|
||||
result.error || 'unknown reason',
|
||||
);
|
||||
this._updateStatus(
|
||||
`Could not exit ${mode} — scene layers may be refused`,
|
||||
);
|
||||
this._logEvent('context_mode_exit_failed', {
|
||||
mode,
|
||||
error: result.error || null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
this._logEvent('context_mode_exited', { mode });
|
||||
@@ -1140,11 +1283,14 @@ export class SceneDirector {
|
||||
// a free-text search the shot has already flown away from.
|
||||
this.styleManager?.clearSearchedLocation?.();
|
||||
|
||||
const duration = Math.max(0.2, Number(durationSec) || DEFAULT_SHOT_DURATION_SEC);
|
||||
const duration = Math.max(
|
||||
0.2,
|
||||
Number(durationSec) || DEFAULT_SHOT_DURATION_SEC,
|
||||
);
|
||||
const destination = Cesium.Cartesian3.fromDegrees(
|
||||
cameraState.lon,
|
||||
cameraState.lat,
|
||||
cameraState.alt
|
||||
cameraState.alt,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
@@ -1244,7 +1390,9 @@ export class SceneDirector {
|
||||
* Editing controls are disabled during a run; stop is disabled when idle.
|
||||
* @param {boolean} isRunning
|
||||
*/
|
||||
_setButtons(isRunning) { this._publish({ type: 'buttons-changed', running: isRunning }); }
|
||||
_setButtons(isRunning) {
|
||||
this._publish({ type: 'buttons-changed', running: isRunning });
|
||||
}
|
||||
|
||||
_setPlaybackActive(active) {
|
||||
this._presentation.playbackActive = active;
|
||||
@@ -1297,5 +1445,4 @@ export class SceneDirector {
|
||||
payload: payload || null,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
+240
-24
@@ -22,11 +22,56 @@ export const SCENE_RECIPES = [
|
||||
detectionMode: 'OFF',
|
||||
},
|
||||
cameraPath: [
|
||||
{ lat: 20.0, lon: -30.0, alt: 19000000, heading: 25, pitch: -65, roll: 0, duration: 6, hold: 1 },
|
||||
{ lat: 46.0, lon: 2.0, alt: 8000000, heading: 40, pitch: -52, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 35.0, lon: 139.0, alt: 4200000, heading: 22, pitch: -46, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 37.6, lon: -122.4, alt: 1700000, heading: 8, pitch: -40, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 0.0, lon: -20.0, alt: 12000000, heading: -10, pitch: -70, roll: 0, duration: 4, hold: 0 },
|
||||
{
|
||||
lat: 20.0,
|
||||
lon: -30.0,
|
||||
alt: 19000000,
|
||||
heading: 25,
|
||||
pitch: -65,
|
||||
roll: 0,
|
||||
duration: 6,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 46.0,
|
||||
lon: 2.0,
|
||||
alt: 8000000,
|
||||
heading: 40,
|
||||
pitch: -52,
|
||||
roll: 0,
|
||||
duration: 5,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 35.0,
|
||||
lon: 139.0,
|
||||
alt: 4200000,
|
||||
heading: 22,
|
||||
pitch: -46,
|
||||
roll: 0,
|
||||
duration: 5,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 37.6,
|
||||
lon: -122.4,
|
||||
alt: 1700000,
|
||||
heading: 8,
|
||||
pitch: -40,
|
||||
roll: 0,
|
||||
duration: 5,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 0.0,
|
||||
lon: -20.0,
|
||||
alt: 12000000,
|
||||
heading: -10,
|
||||
pitch: -70,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -55,11 +100,56 @@ export const SCENE_RECIPES = [
|
||||
},
|
||||
},
|
||||
cameraPath: [
|
||||
{ lat: 28.0, lon: -82.0, alt: 22000000, heading: 0, pitch: -82, roll: 0, duration: 7, hold: 1 },
|
||||
{ lat: 10.0, lon: 20.0, alt: 12000000, heading: 18, pitch: -74, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 35.7, lon: 139.7, alt: 6500000, heading: 24, pitch: -64, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 48.8, lon: 2.3, alt: 3000000, heading: 32, pitch: -58, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 30.0, lon: -25.0, alt: 15000000, heading: -20, pitch: -78, roll: 0, duration: 4, hold: 0 },
|
||||
{
|
||||
lat: 28.0,
|
||||
lon: -82.0,
|
||||
alt: 22000000,
|
||||
heading: 0,
|
||||
pitch: -82,
|
||||
roll: 0,
|
||||
duration: 7,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 10.0,
|
||||
lon: 20.0,
|
||||
alt: 12000000,
|
||||
heading: 18,
|
||||
pitch: -74,
|
||||
roll: 0,
|
||||
duration: 5,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 35.7,
|
||||
lon: 139.7,
|
||||
alt: 6500000,
|
||||
heading: 24,
|
||||
pitch: -64,
|
||||
roll: 0,
|
||||
duration: 5,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 48.8,
|
||||
lon: 2.3,
|
||||
alt: 3000000,
|
||||
heading: 32,
|
||||
pitch: -58,
|
||||
roll: 0,
|
||||
duration: 5,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 30.0,
|
||||
lon: -25.0,
|
||||
alt: 15000000,
|
||||
heading: -20,
|
||||
pitch: -78,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -88,10 +178,46 @@ export const SCENE_RECIPES = [
|
||||
},
|
||||
},
|
||||
cameraPath: [
|
||||
{ lat: 37.0, lon: -121.0, alt: 5200000, heading: 20, pitch: -62, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 35.7, lon: 140.0, alt: 3100000, heading: 5, pitch: -55, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: -36.8, lon: 174.7, alt: 2600000, heading: -12, pitch: -50, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 38.0, lon: -10.0, alt: 9000000, heading: 12, pitch: -70, roll: 0, duration: 4, hold: 0 },
|
||||
{
|
||||
lat: 37.0,
|
||||
lon: -121.0,
|
||||
alt: 5200000,
|
||||
heading: 20,
|
||||
pitch: -62,
|
||||
roll: 0,
|
||||
duration: 5,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 35.7,
|
||||
lon: 140.0,
|
||||
alt: 3100000,
|
||||
heading: 5,
|
||||
pitch: -55,
|
||||
roll: 0,
|
||||
duration: 5,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: -36.8,
|
||||
lon: 174.7,
|
||||
alt: 2600000,
|
||||
heading: -12,
|
||||
pitch: -50,
|
||||
roll: 0,
|
||||
duration: 5,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 38.0,
|
||||
lon: -10.0,
|
||||
alt: 9000000,
|
||||
heading: 12,
|
||||
pitch: -70,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -120,11 +246,56 @@ export const SCENE_RECIPES = [
|
||||
},
|
||||
},
|
||||
cameraPath: [
|
||||
{ lat: 40.73, lon: -74.0, alt: 1500000, heading: 25, pitch: -44, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 40.73, lon: -74.0, alt: 550000, heading: 58, pitch: -36, roll: 0, duration: 4, hold: 1 },
|
||||
{ lat: 40.73, lon: -74.0, alt: 260000, heading: 102, pitch: -33, roll: 0, duration: 4, hold: 1 },
|
||||
{ lat: 34.05, lon: -118.24, alt: 1200000, heading: 45, pitch: -42, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 51.5, lon: -0.12, alt: 1200000, heading: 22, pitch: -44, roll: 0, duration: 5, hold: 0 },
|
||||
{
|
||||
lat: 40.73,
|
||||
lon: -74.0,
|
||||
alt: 1500000,
|
||||
heading: 25,
|
||||
pitch: -44,
|
||||
roll: 0,
|
||||
duration: 5,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 40.73,
|
||||
lon: -74.0,
|
||||
alt: 550000,
|
||||
heading: 58,
|
||||
pitch: -36,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 40.73,
|
||||
lon: -74.0,
|
||||
alt: 260000,
|
||||
heading: 102,
|
||||
pitch: -33,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 34.05,
|
||||
lon: -118.24,
|
||||
alt: 1200000,
|
||||
heading: 45,
|
||||
pitch: -42,
|
||||
roll: 0,
|
||||
duration: 5,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 51.5,
|
||||
lon: -0.12,
|
||||
alt: 1200000,
|
||||
heading: 22,
|
||||
pitch: -44,
|
||||
roll: 0,
|
||||
duration: 5,
|
||||
hold: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -152,11 +323,56 @@ export const SCENE_RECIPES = [
|
||||
},
|
||||
},
|
||||
cameraPath: [
|
||||
{ lat: 35.68, lon: 139.76, alt: 280000, heading: 30, pitch: -26, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 35.68, lon: 139.76, alt: 900000, heading: 26, pitch: -38, roll: 0, duration: 4, hold: 1 },
|
||||
{ lat: 35.68, lon: 139.76, alt: 3800000, heading: 18, pitch: -52, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 20.0, lon: 110.0, alt: 9500000, heading: 8, pitch: -66, roll: 0, duration: 5, hold: 1 },
|
||||
{ lat: 5.0, lon: 30.0, alt: 18000000, heading: -8, pitch: -78, roll: 0, duration: 6, hold: 0 },
|
||||
{
|
||||
lat: 35.68,
|
||||
lon: 139.76,
|
||||
alt: 280000,
|
||||
heading: 30,
|
||||
pitch: -26,
|
||||
roll: 0,
|
||||
duration: 5,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 35.68,
|
||||
lon: 139.76,
|
||||
alt: 900000,
|
||||
heading: 26,
|
||||
pitch: -38,
|
||||
roll: 0,
|
||||
duration: 4,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 35.68,
|
||||
lon: 139.76,
|
||||
alt: 3800000,
|
||||
heading: 18,
|
||||
pitch: -52,
|
||||
roll: 0,
|
||||
duration: 5,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 20.0,
|
||||
lon: 110.0,
|
||||
alt: 9500000,
|
||||
heading: 8,
|
||||
pitch: -66,
|
||||
roll: 0,
|
||||
duration: 5,
|
||||
hold: 1,
|
||||
},
|
||||
{
|
||||
lat: 5.0,
|
||||
lon: 30.0,
|
||||
alt: 18000000,
|
||||
heading: -8,
|
||||
pitch: -78,
|
||||
roll: 0,
|
||||
duration: 6,
|
||||
hold: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -80,7 +80,8 @@ export const SCENE_SELECTION_PARAM_PATTERN =
|
||||
*/
|
||||
export function stripSceneTrackingParams(params) {
|
||||
if (!params || typeof params !== 'object') return undefined;
|
||||
if (!SCENE_TRACKING_PARAM_KEYS.some((key) => Object.hasOwn(params, key))) return params;
|
||||
if (!SCENE_TRACKING_PARAM_KEYS.some((key) => Object.hasOwn(params, key)))
|
||||
return params;
|
||||
|
||||
const kept = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
@@ -122,10 +123,12 @@ export const SCENE_EXCLUSIVITY_PROBE_LAYER_ID = '__scene-exclusivity-probe__';
|
||||
*/
|
||||
export function sceneRequiresContextModeExit(contextMode) {
|
||||
if (!contextMode) return false;
|
||||
return contextLayerEnableBlockReason({
|
||||
contextMode,
|
||||
change: { layerId: SCENE_EXCLUSIVITY_PROBE_LAYER_ID, enabled: true },
|
||||
}) !== null;
|
||||
return (
|
||||
contextLayerEnableBlockReason({
|
||||
contextMode,
|
||||
change: { layerId: SCENE_EXCLUSIVITY_PROBE_LAYER_ID, enabled: true },
|
||||
}) !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,9 +147,12 @@ export function sceneRequiresContextModeExit(contextMode) {
|
||||
* @returns {Array<{ id: string, enabled: boolean, params: Object|undefined }>}
|
||||
*/
|
||||
export function sceneLayerPlan(targetStates, registeredIds) {
|
||||
const known = registeredIds instanceof Set
|
||||
? registeredIds
|
||||
: (registeredIds ? new Set(registeredIds) : null);
|
||||
const known =
|
||||
registeredIds instanceof Set
|
||||
? registeredIds
|
||||
: registeredIds
|
||||
? new Set(registeredIds)
|
||||
: null;
|
||||
|
||||
const plan = [];
|
||||
for (const [id, target] of Object.entries(targetStates || {})) {
|
||||
@@ -154,7 +160,10 @@ export function sceneLayerPlan(targetStates, registeredIds) {
|
||||
plan.push({
|
||||
id,
|
||||
enabled: !!(target && target.enabled),
|
||||
params: target && target.params ? stripSceneTrackingParams(target.params) : undefined,
|
||||
params:
|
||||
target && target.params
|
||||
? stripSceneTrackingParams(target.params)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
return plan;
|
||||
|
||||
+65
-22
@@ -133,9 +133,13 @@ export function scopeTerminusAlpha(heightM) {
|
||||
if (h >= SCOPE_TERMINUS_FAR_M) return SCOPE_OUTSIDE_ALPHA;
|
||||
if (h <= SCOPE_TERMINUS_NEAR_M) return SCOPE_TERMINUS_ALPHA_NEAR;
|
||||
// 0 at the far edge → 1 at the near edge, eased so neither end steps visibly.
|
||||
const t = (SCOPE_TERMINUS_FAR_M - h) / (SCOPE_TERMINUS_FAR_M - SCOPE_TERMINUS_NEAR_M);
|
||||
const t =
|
||||
(SCOPE_TERMINUS_FAR_M - h) / (SCOPE_TERMINUS_FAR_M - SCOPE_TERMINUS_NEAR_M);
|
||||
const eased = t * t * (3 - 2 * t);
|
||||
return SCOPE_OUTSIDE_ALPHA + (SCOPE_TERMINUS_ALPHA_NEAR - SCOPE_OUTSIDE_ALPHA) * eased;
|
||||
return (
|
||||
SCOPE_OUTSIDE_ALPHA +
|
||||
(SCOPE_TERMINUS_ALPHA_NEAR - SCOPE_OUTSIDE_ALPHA) * eased
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,7 +153,11 @@ export function quantizeScopeTerminusAlpha(alpha) {
|
||||
// Round the PRODUCT too: n * 0.005 lands on values like 0.9400000000000001,
|
||||
// which would ride straight into the rgba() string. The quantum is 3-decimal,
|
||||
// so 3 decimals is lossless here.
|
||||
return Math.round(Math.round(a / SCOPE_TERMINUS_QUANTUM) * SCOPE_TERMINUS_QUANTUM * 1000) / 1000;
|
||||
return (
|
||||
Math.round(
|
||||
Math.round(a / SCOPE_TERMINUS_QUANTUM) * SCOPE_TERMINUS_QUANTUM * 1000,
|
||||
) / 1000
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,10 +172,14 @@ export function clampScopeTerminusPct(value) {
|
||||
// boolean — is ABSENT, not zero. (Number(null) === 0 would otherwise pin the
|
||||
// floor on every adaptive write.)
|
||||
const raw = typeof value === 'string' ? value.trim() : value;
|
||||
if (typeof raw !== 'number' && (typeof raw !== 'string' || raw === '')) return null;
|
||||
if (typeof raw !== 'number' && (typeof raw !== 'string' || raw === ''))
|
||||
return null;
|
||||
const pct = Number(raw);
|
||||
if (!Number.isFinite(pct)) return null;
|
||||
return Math.max(SCOPE_TERMINUS_MIN_PCT, Math.min(SCOPE_TERMINUS_MAX_PCT, Math.round(pct)));
|
||||
return Math.max(
|
||||
SCOPE_TERMINUS_MIN_PCT,
|
||||
Math.min(SCOPE_TERMINUS_MAX_PCT, Math.round(pct)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,7 +213,10 @@ export function updateScopeTerminusForHeight(heightM) {
|
||||
* @returns {void}
|
||||
*/
|
||||
function withCoalescedPaint(fn) {
|
||||
if (_coalescingPaint) { fn(); return; } // already inside a scope
|
||||
if (_coalescingPaint) {
|
||||
fn();
|
||||
return;
|
||||
} // already inside a scope
|
||||
_coalescingPaint = true;
|
||||
_paintDirty = false;
|
||||
try {
|
||||
@@ -218,7 +233,9 @@ function withCoalescedPaint(fn) {
|
||||
/** @returns {number} Quantized terminus the CURRENT camera + override imply. */
|
||||
function currentTerminusTarget() {
|
||||
return quantizeScopeTerminusAlpha(
|
||||
_terminusOverride == null ? scopeTerminusAlpha(currentCameraHeightM()) : _terminusOverride,
|
||||
_terminusOverride == null
|
||||
? scopeTerminusAlpha(currentCameraHeightM())
|
||||
: _terminusOverride,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -228,7 +245,10 @@ export function setScopeTerminusOverride(alpha) {
|
||||
} else {
|
||||
// Same supported band as the `sce` hash key: a sub-globe-scale terminus is
|
||||
// a hole in the mask, not a scope, so every entry point floors it.
|
||||
_terminusOverride = Math.max(SCOPE_OUTSIDE_ALPHA, Math.min(1, Number(alpha)));
|
||||
_terminusOverride = Math.max(
|
||||
SCOPE_OUTSIDE_ALPHA,
|
||||
Math.min(1, Number(alpha)),
|
||||
);
|
||||
}
|
||||
// Re-resolve immediately against the live camera so the override is visible
|
||||
// without waiting for the next sample.
|
||||
@@ -256,7 +276,9 @@ export function getScopeTerminusRepaintCount() {
|
||||
}
|
||||
|
||||
/** Backing-store scale actually used by the last draw(). */
|
||||
export function scopeMaskDevicePixelRatio(ratio = (typeof window !== 'undefined' ? window.devicePixelRatio : 1)) {
|
||||
export function scopeMaskDevicePixelRatio(
|
||||
ratio = typeof window !== 'undefined' ? window.devicePixelRatio : 1,
|
||||
) {
|
||||
return Math.min(2, Number(ratio) || 1);
|
||||
}
|
||||
|
||||
@@ -272,7 +294,8 @@ export function scopeMaskDevicePixelRatio(ratio = (typeof window !== 'undefined'
|
||||
* @returns {void}
|
||||
*/
|
||||
function watchDevicePixelRatio() {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
||||
return;
|
||||
teardownDevicePixelRatioWatch();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
let query;
|
||||
@@ -292,14 +315,18 @@ function watchDevicePixelRatio() {
|
||||
draw();
|
||||
});
|
||||
};
|
||||
if (typeof query.addEventListener === 'function') query.addEventListener('change', _dprListener, { once: true });
|
||||
else if (typeof query.addListener === 'function') query.addListener(_dprListener);
|
||||
if (typeof query.addEventListener === 'function')
|
||||
query.addEventListener('change', _dprListener, { once: true });
|
||||
else if (typeof query.addListener === 'function')
|
||||
query.addListener(_dprListener);
|
||||
}
|
||||
|
||||
function teardownDevicePixelRatioWatch() {
|
||||
if (_dprQuery && _dprListener) {
|
||||
if (typeof _dprQuery.removeEventListener === 'function') _dprQuery.removeEventListener('change', _dprListener);
|
||||
else if (typeof _dprQuery.removeListener === 'function') _dprQuery.removeListener(_dprListener);
|
||||
if (typeof _dprQuery.removeEventListener === 'function')
|
||||
_dprQuery.removeEventListener('change', _dprListener);
|
||||
else if (typeof _dprQuery.removeListener === 'function')
|
||||
_dprQuery.removeListener(_dprListener);
|
||||
}
|
||||
_dprQuery = null;
|
||||
_dprListener = null;
|
||||
@@ -326,13 +353,18 @@ export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) {
|
||||
centerY: keyhole.centerY,
|
||||
innerR: Math.max(0, keyhole.radius - half),
|
||||
outerR: keyhole.radius + half,
|
||||
maxR: Math.hypot(Math.max(keyhole.centerX, width - keyhole.centerX),
|
||||
Math.max(keyhole.centerY, height - keyhole.centerY)),
|
||||
maxR: Math.hypot(
|
||||
Math.max(keyhole.centerX, width - keyhole.centerX),
|
||||
Math.max(keyhole.centerY, height - keyhole.centerY),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (_coalescingPaint) { _paintDirty = true; return; } // one paint at scope exit
|
||||
if (_coalescingPaint) {
|
||||
_paintDirty = true;
|
||||
return;
|
||||
} // one paint at scope exit
|
||||
if (!_canvas || !_container) return;
|
||||
// SCOPE OFF is the cheapest state, not a painted one: bail out BEFORE the
|
||||
// backing-store resize + clear (a full-viewport allocation) that used to run
|
||||
@@ -377,8 +409,12 @@ function draw() {
|
||||
return;
|
||||
}
|
||||
const gradient = ctx.createRadialGradient(
|
||||
geo.centerX, geo.centerY, geo.innerR,
|
||||
geo.centerX, geo.centerY, geo.outerR,
|
||||
geo.centerX,
|
||||
geo.centerY,
|
||||
geo.innerR,
|
||||
geo.centerX,
|
||||
geo.centerY,
|
||||
geo.outerR,
|
||||
);
|
||||
gradient.addColorStop(0, `rgba(${r},${g},${b},0)`);
|
||||
gradient.addColorStop(1, `rgba(${r},${g},${b},${_terminusAlpha})`);
|
||||
@@ -433,7 +469,8 @@ function watchCameraHeight(viewer) {
|
||||
// throttle bookkeeping, no quantize — the listener is a single compare.
|
||||
// setScopeMaskEnabled(true) re-syncs the alpha it skipped.
|
||||
if (!_enabled) return;
|
||||
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||
const now =
|
||||
typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
if (now - _lastTerminusSampleMs < SCOPE_TERMINUS_SAMPLE_MS) return;
|
||||
_lastTerminusSampleMs = now;
|
||||
updateScopeTerminusForHeight(currentCameraHeightM());
|
||||
@@ -449,8 +486,14 @@ function watchCameraHeight(viewer) {
|
||||
}
|
||||
|
||||
function teardownCameraHeightWatch() {
|
||||
if (_cameraSampleRemover) { _cameraSampleRemover(); _cameraSampleRemover = null; }
|
||||
if (_cameraMoveEndRemover) { _cameraMoveEndRemover(); _cameraMoveEndRemover = null; }
|
||||
if (_cameraSampleRemover) {
|
||||
_cameraSampleRemover();
|
||||
_cameraSampleRemover = null;
|
||||
}
|
||||
if (_cameraMoveEndRemover) {
|
||||
_cameraMoveEndRemover();
|
||||
_cameraMoveEndRemover = null;
|
||||
}
|
||||
_lastTerminusSampleMs = -Infinity;
|
||||
}
|
||||
|
||||
|
||||
+156
-67
@@ -5,7 +5,10 @@ import {
|
||||
normalizeAllocationStrategy,
|
||||
} from './data/detectionPolicy.js';
|
||||
import { clampScopeTerminusPct } from './scopeMask.js';
|
||||
import { decodeLayerStateParams, encodeLayerStateParams } from './data/layerState.js';
|
||||
import {
|
||||
decodeLayerStateParams,
|
||||
encodeLayerStateParams,
|
||||
} from './data/layerState.js';
|
||||
|
||||
/**
|
||||
* Share Links — URL Hash State Management
|
||||
@@ -44,12 +47,12 @@ const SHARE_PANEL_STATE_REGISTRY = Object.freeze([
|
||||
{ id: 'param-slider-panel', token: 'm', pinnable: false },
|
||||
]);
|
||||
|
||||
const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze(new Map(
|
||||
SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry]),
|
||||
));
|
||||
const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze(
|
||||
new Map(SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry])),
|
||||
);
|
||||
|
||||
const URL_TO_STYLE = Object.fromEntries(
|
||||
Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k])
|
||||
Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k]),
|
||||
);
|
||||
|
||||
const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({
|
||||
@@ -87,11 +90,10 @@ const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({
|
||||
});
|
||||
|
||||
export class ShareLinkManager {
|
||||
constructor(viewer, {
|
||||
onRestore,
|
||||
isNavigationCurrent,
|
||||
cancelOwnedNavigation,
|
||||
} = {}) {
|
||||
constructor(
|
||||
viewer,
|
||||
{ onRestore, isNavigationCurrent, cancelOwnedNavigation } = {},
|
||||
) {
|
||||
this.viewer = viewer;
|
||||
this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void
|
||||
this._debounceTimer = null;
|
||||
@@ -135,17 +137,21 @@ export class ShareLinkManager {
|
||||
this._destroyed = false;
|
||||
this._restoreGeneration = 0;
|
||||
this._activeCameraFlight = null;
|
||||
this._isNavigationCurrent = typeof isNavigationCurrent === 'function'
|
||||
? isNavigationCurrent
|
||||
: () => true;
|
||||
this._cancelOwnedNavigation = typeof cancelOwnedNavigation === 'function'
|
||||
? cancelOwnedNavigation
|
||||
: null;
|
||||
this._isNavigationCurrent =
|
||||
typeof isNavigationCurrent === 'function'
|
||||
? isNavigationCurrent
|
||||
: () => true;
|
||||
this._cancelOwnedNavigation =
|
||||
typeof cancelOwnedNavigation === 'function'
|
||||
? cancelOwnedNavigation
|
||||
: null;
|
||||
|
||||
// Listen for camera changes
|
||||
this._removeCameraChanged = this.viewer.camera.changed.addEventListener(() => {
|
||||
this._scheduleUpdate();
|
||||
});
|
||||
this._removeCameraChanged = this.viewer.camera.changed.addEventListener(
|
||||
() => {
|
||||
this._scheduleUpdate();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,10 +198,15 @@ export class ShareLinkManager {
|
||||
sharpenIntensity: parseOr(params.get('si'), 49),
|
||||
hudVariant: params.get('hud') || 'tactical',
|
||||
hudVisible: params.get('hv') === '1',
|
||||
detectionMode: restoredDetection.enabled ? restoredDetection.profile : 'OFF',
|
||||
detectionMode: restoredDetection.enabled
|
||||
? restoredDetection.profile
|
||||
: 'OFF',
|
||||
detectionDensity: restoredDetection.densityPct,
|
||||
detectionAllocation: normalizeAllocationStrategy(params.get('da')),
|
||||
detectionFadePct: Math.max(0, Math.min(40, Math.round(parseOr(params.get('kf'), 16)))),
|
||||
detectionFadePct: Math.max(
|
||||
0,
|
||||
Math.min(40, Math.round(parseOr(params.get('kf'), 16))),
|
||||
),
|
||||
// Deliberately still 5 after the 2026-08-23 default moved to 3. Same rule
|
||||
// as `scf` below: this is the PARSE fallback for a link that predates
|
||||
// `ko`, and such a link was authored when 5 was what its author saw. Every
|
||||
@@ -203,7 +214,10 @@ export class ShareLinkManager {
|
||||
// the field — so nothing from the 5 % era depends on this number either
|
||||
// way. The first-run default is a different question, answered in
|
||||
// celestialRing.js.
|
||||
detectionOutsideOpacityPct: Math.max(0, Math.min(100, Math.round(parseOr(params.get('ko'), 5)))),
|
||||
detectionOutsideOpacityPct: Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(parseOr(params.get('ko'), 5))),
|
||||
),
|
||||
celestialRing: params.has('cr') ? params.get('cr') === '1' : false,
|
||||
scopeEnabled: params.has('sc') ? params.get('sc') === '1' : true,
|
||||
// Deliberately still 35 through both later default moves (0 on
|
||||
@@ -216,7 +230,10 @@ export class ShareLinkManager {
|
||||
// (`_scopeFeatherPct` in the constructor tracks the default: that one
|
||||
// mirrors live state for the link this session generates, so it must match
|
||||
// the mask, not the archive.)
|
||||
scopeFeatherPct: Math.max(0, Math.min(100, Math.round(parseOr(params.get('scf'), 35)))),
|
||||
scopeFeatherPct: Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(parseOr(params.get('scf'), 35))),
|
||||
),
|
||||
// Absent (or non-numeric) `sce` = adaptive (null), the default behavior;
|
||||
// a value pins the terminus opacity percent, clamped into the SUPPORTED
|
||||
// 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in
|
||||
@@ -226,9 +243,10 @@ export class ShareLinkManager {
|
||||
: null,
|
||||
mapStack: params.get('map') || 'photoreal',
|
||||
layerState: decodedLayerState,
|
||||
layerStateInvalid: params.get('v') === '2'
|
||||
&& params.has('l')
|
||||
&& decodedLayerState === null,
|
||||
layerStateInvalid:
|
||||
params.get('v') === '2' &&
|
||||
params.has('l') &&
|
||||
decodedLayerState === null,
|
||||
panelState: decodePanelStateParams(params),
|
||||
sharedAtMs: decodeShareCreatedAtMs(params),
|
||||
};
|
||||
@@ -247,27 +265,40 @@ export class ShareLinkManager {
|
||||
* Apply a parsed state to the viewer + style manager.
|
||||
*/
|
||||
async applyState(state, { applyCamera = true, navigationToken = null } = {}) {
|
||||
if (this._destroyed || !state) return { succeeded: false, reason: 'unavailable' };
|
||||
if (this._destroyed || !state)
|
||||
return { succeeded: false, reason: 'unavailable' };
|
||||
const view = {
|
||||
destination: Cesium.Cartesian3.fromDegrees(state.lon, state.lat, state.alt),
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
state.lon,
|
||||
state.lat,
|
||||
state.alt,
|
||||
),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(state.heading),
|
||||
pitch: Cesium.Math.toRadians(state.pitch),
|
||||
roll: Cesium.Math.toRadians(state.roll),
|
||||
},
|
||||
};
|
||||
let cameraPromise = Promise.resolve({ status: applyCamera ? 'superseded' : 'skipped' });
|
||||
let cameraPromise = Promise.resolve({
|
||||
status: applyCamera ? 'superseded' : 'skipped',
|
||||
});
|
||||
if (applyCamera && this._isNavigationCurrent(navigationToken)) {
|
||||
const restoreGeneration = ++this._restoreGeneration;
|
||||
let settleCamera;
|
||||
cameraPromise = new Promise((resolve) => { settleCamera = resolve; });
|
||||
cameraPromise = new Promise((resolve) => {
|
||||
settleCamera = resolve;
|
||||
});
|
||||
const releaseOwnedFlight = (status = 'cancelled') => {
|
||||
if (this._activeCameraFlight?.restoreGeneration === restoreGeneration) {
|
||||
this._activeCameraFlight = null;
|
||||
}
|
||||
settleCamera({ status });
|
||||
};
|
||||
this._activeCameraFlight = { restoreGeneration, navigationToken, settle: releaseOwnedFlight };
|
||||
this._activeCameraFlight = {
|
||||
restoreGeneration,
|
||||
navigationToken,
|
||||
settle: releaseOwnedFlight,
|
||||
};
|
||||
// Re-apply the final pose only while this share restoration still owns
|
||||
// navigation. A later user or voice command wins over delayed restore.
|
||||
this.viewer.camera.flyTo({
|
||||
@@ -276,9 +307,9 @@ export class ShareLinkManager {
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
complete: () => {
|
||||
if (
|
||||
this._destroyed
|
||||
|| restoreGeneration !== this._restoreGeneration
|
||||
|| !this._isNavigationCurrent(navigationToken)
|
||||
this._destroyed ||
|
||||
restoreGeneration !== this._restoreGeneration ||
|
||||
!this._isNavigationCurrent(navigationToken)
|
||||
) {
|
||||
releaseOwnedFlight('superseded');
|
||||
return;
|
||||
@@ -293,14 +324,17 @@ export class ShareLinkManager {
|
||||
|
||||
// Notify the style manager via callback
|
||||
const reserved = state.restoreAuthority || null;
|
||||
const visualCurrent = !reserved || reserved.visual === this._restoreAuthority.visual;
|
||||
const visualCurrent =
|
||||
!reserved || reserved.visual === this._restoreAuthority.visual;
|
||||
const mapCurrent = !reserved || reserved.map === this._restoreAuthority.map;
|
||||
let panelState = state.panelState;
|
||||
if (reserved && panelState?.specs) {
|
||||
panelState = {
|
||||
specs: panelState.specs.filter((spec) => (
|
||||
(reserved.panels?.get(spec.id) || 0) === (this._restoreAuthority.panels.get(spec.id) || 0)
|
||||
)),
|
||||
specs: panelState.specs.filter(
|
||||
(spec) =>
|
||||
(reserved.panels?.get(spec.id) || 0) ===
|
||||
(this._restoreAuthority.panels.get(spec.id) || 0),
|
||||
),
|
||||
};
|
||||
if (panelState.specs.length === 0) panelState = null;
|
||||
}
|
||||
@@ -317,9 +351,13 @@ export class ShareLinkManager {
|
||||
hudVisible: visualCurrent ? state.hudVisible : undefined,
|
||||
detectionMode: visualCurrent ? state.detectionMode : undefined,
|
||||
detectionDensity: visualCurrent ? state.detectionDensity : undefined,
|
||||
detectionAllocation: visualCurrent ? state.detectionAllocation : undefined,
|
||||
detectionAllocation: visualCurrent
|
||||
? state.detectionAllocation
|
||||
: undefined,
|
||||
detectionFadePct: visualCurrent ? state.detectionFadePct : undefined,
|
||||
detectionOutsideOpacityPct: visualCurrent ? state.detectionOutsideOpacityPct : undefined,
|
||||
detectionOutsideOpacityPct: visualCurrent
|
||||
? state.detectionOutsideOpacityPct
|
||||
: undefined,
|
||||
celestialRing: visualCurrent ? state.celestialRing : undefined,
|
||||
scopeEnabled: visualCurrent ? state.scopeEnabled : undefined,
|
||||
scopeFeatherPct: visualCurrent ? state.scopeFeatherPct : undefined,
|
||||
@@ -336,7 +374,11 @@ export class ShareLinkManager {
|
||||
camera: camera.status,
|
||||
visual: visualCurrent ? restoreStatus : 'superseded',
|
||||
map: mapCurrent ? restoreStatus : 'superseded',
|
||||
panels: panelState ? restoreStatus : (state.panelState ? 'superseded' : 'skipped'),
|
||||
panels: panelState
|
||||
? restoreStatus
|
||||
: state.panelState
|
||||
? 'superseded'
|
||||
: 'skipped',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -351,7 +393,10 @@ export class ShareLinkManager {
|
||||
claimRestoreLane(lane, panelId = null) {
|
||||
if (!this._initialRestorePending) return;
|
||||
if (lane === 'panel' && panelId) {
|
||||
this._restoreAuthority.panels.set(panelId, (this._restoreAuthority.panels.get(panelId) || 0) + 1);
|
||||
this._restoreAuthority.panels.set(
|
||||
panelId,
|
||||
(this._restoreAuthority.panels.get(panelId) || 0) + 1,
|
||||
);
|
||||
} else if (lane === 'visual' || lane === 'map') {
|
||||
this._restoreAuthority[lane] += 1;
|
||||
}
|
||||
@@ -369,7 +414,8 @@ export class ShareLinkManager {
|
||||
|
||||
/** Install the active visual preset parameter source used by URL generation. */
|
||||
setStyleParamStateProvider(provider) {
|
||||
this._styleParamStateProvider = typeof provider === 'function' ? provider : null;
|
||||
this._styleParamStateProvider =
|
||||
typeof provider === 'function' ? provider : null;
|
||||
}
|
||||
|
||||
/** Called only when the durable layer preference model changes. */
|
||||
@@ -384,7 +430,11 @@ export class ShareLinkManager {
|
||||
}
|
||||
|
||||
_encodePanelStateParam(params, panelState) {
|
||||
if (!panelState || !Array.isArray(panelState.specs) || panelState.specs.length === 0) {
|
||||
if (
|
||||
!panelState ||
|
||||
!Array.isArray(panelState.specs) ||
|
||||
panelState.specs.length === 0
|
||||
) {
|
||||
params.delete(SHARE_UI_STATE_PARAM);
|
||||
return;
|
||||
}
|
||||
@@ -397,7 +447,8 @@ export class ShareLinkManager {
|
||||
assignments.push(`${spec.token}.p.${state.pinned ? '1' : '0'}`);
|
||||
}
|
||||
}
|
||||
if (assignments.length) params.set(SHARE_UI_STATE_PARAM, assignments.join('_'));
|
||||
if (assignments.length)
|
||||
params.set(SHARE_UI_STATE_PARAM, assignments.join('_'));
|
||||
else params.delete(SHARE_UI_STATE_PARAM);
|
||||
}
|
||||
|
||||
@@ -410,18 +461,30 @@ export class ShareLinkManager {
|
||||
onToggleChange(bloom, sharpen, extras = {}) {
|
||||
this._bloomEnabled = bloom;
|
||||
this._sharpenEnabled = sharpen;
|
||||
if (typeof extras.bloomIntensity === 'number') this._bloomIntensity = extras.bloomIntensity;
|
||||
if (typeof extras.bloomVersion === 'number') this._bloomVersion = extras.bloomVersion;
|
||||
if (typeof extras.sharpenIntensity === 'number') this._sharpenIntensity = extras.sharpenIntensity;
|
||||
if (typeof extras.hudVariant === 'string') this._hudVariant = extras.hudVariant;
|
||||
if (typeof extras.hudVisible === 'boolean') this._hudVisible = extras.hudVisible;
|
||||
if (typeof extras.detectionMode === 'string') this._detectionMode = extras.detectionMode.toUpperCase();
|
||||
if (typeof extras.detectionDensity === 'number') this._detectionDensity = extras.detectionDensity;
|
||||
if (typeof extras.bloomIntensity === 'number')
|
||||
this._bloomIntensity = extras.bloomIntensity;
|
||||
if (typeof extras.bloomVersion === 'number')
|
||||
this._bloomVersion = extras.bloomVersion;
|
||||
if (typeof extras.sharpenIntensity === 'number')
|
||||
this._sharpenIntensity = extras.sharpenIntensity;
|
||||
if (typeof extras.hudVariant === 'string')
|
||||
this._hudVariant = extras.hudVariant;
|
||||
if (typeof extras.hudVisible === 'boolean')
|
||||
this._hudVisible = extras.hudVisible;
|
||||
if (typeof extras.detectionMode === 'string')
|
||||
this._detectionMode = extras.detectionMode.toUpperCase();
|
||||
if (typeof extras.detectionDensity === 'number')
|
||||
this._detectionDensity = extras.detectionDensity;
|
||||
if (typeof extras.detectionAllocation === 'string') {
|
||||
this._detectionAllocation = normalizeAllocationStrategy(extras.detectionAllocation);
|
||||
this._detectionAllocation = normalizeAllocationStrategy(
|
||||
extras.detectionAllocation,
|
||||
);
|
||||
}
|
||||
if (typeof extras.detectionFadePct === 'number') {
|
||||
this._detectionFadePct = Math.max(0, Math.min(40, Math.round(extras.detectionFadePct)));
|
||||
this._detectionFadePct = Math.max(
|
||||
0,
|
||||
Math.min(40, Math.round(extras.detectionFadePct)),
|
||||
);
|
||||
}
|
||||
if (typeof extras.detectionOutsideOpacityPct === 'number') {
|
||||
this._detectionOutsideOpacityPct = Math.max(
|
||||
@@ -429,10 +492,15 @@ export class ShareLinkManager {
|
||||
Math.min(100, Math.round(extras.detectionOutsideOpacityPct)),
|
||||
);
|
||||
}
|
||||
if (typeof extras.celestialRingEnabled === 'boolean') this._celestialRingEnabled = extras.celestialRingEnabled;
|
||||
if (typeof extras.scopeEnabled === 'boolean') this._scopeEnabled = extras.scopeEnabled;
|
||||
if (typeof extras.celestialRingEnabled === 'boolean')
|
||||
this._celestialRingEnabled = extras.celestialRingEnabled;
|
||||
if (typeof extras.scopeEnabled === 'boolean')
|
||||
this._scopeEnabled = extras.scopeEnabled;
|
||||
if (typeof extras.scopeFeatherPct === 'number') {
|
||||
this._scopeFeatherPct = Math.max(0, Math.min(100, Math.round(extras.scopeFeatherPct)));
|
||||
this._scopeFeatherPct = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(extras.scopeFeatherPct)),
|
||||
);
|
||||
}
|
||||
if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null;
|
||||
else if (typeof extras.scopeTerminusPct === 'number') {
|
||||
@@ -482,9 +550,18 @@ export class ShareLinkManager {
|
||||
params.set('lat', Cesium.Math.toDegrees(carto.latitude).toFixed(4));
|
||||
params.set('lon', Cesium.Math.toDegrees(carto.longitude).toFixed(4));
|
||||
params.set('alt', Math.round(carto.height).toString());
|
||||
params.set('heading', Math.round(Cesium.Math.toDegrees(camera.heading)).toString());
|
||||
params.set('pitch', Math.round(Cesium.Math.toDegrees(camera.pitch)).toString());
|
||||
params.set('roll', Math.round(Cesium.Math.toDegrees(camera.roll)).toString());
|
||||
params.set(
|
||||
'heading',
|
||||
Math.round(Cesium.Math.toDegrees(camera.heading)).toString(),
|
||||
);
|
||||
params.set(
|
||||
'pitch',
|
||||
Math.round(Cesium.Math.toDegrees(camera.pitch)).toString(),
|
||||
);
|
||||
params.set(
|
||||
'roll',
|
||||
Math.round(Cesium.Math.toDegrees(camera.roll)).toString(),
|
||||
);
|
||||
params.set('style', STYLE_TO_URL[this._currentStyle] || 'normal');
|
||||
params.set('bloom', this._bloomEnabled ? '1' : '0');
|
||||
params.set('sharpen', this._sharpenEnabled ? '1' : '0');
|
||||
@@ -527,7 +604,10 @@ export class ShareLinkManager {
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
const activeFlight = this._activeCameraFlight;
|
||||
if (activeFlight && this._isNavigationCurrent(activeFlight.navigationToken)) {
|
||||
if (
|
||||
activeFlight &&
|
||||
this._isNavigationCurrent(activeFlight.navigationToken)
|
||||
) {
|
||||
this._cancelOwnedNavigation?.();
|
||||
}
|
||||
activeFlight?.settle?.('destroyed');
|
||||
@@ -570,18 +650,22 @@ export function encodeStyleParamState(params, styleName, values) {
|
||||
const clamped = Math.max(spec.min, Math.min(spec.max, numeric));
|
||||
assignments.push(`${spec.token}.${Math.round(clamped * 100)}`);
|
||||
}
|
||||
if (assignments.length) params.set(SHARE_STYLE_PARAMS_PARAM, assignments.join('_'));
|
||||
if (assignments.length)
|
||||
params.set(SHARE_STYLE_PARAMS_PARAM, assignments.join('_'));
|
||||
else params.delete(SHARE_STYLE_PARAMS_PARAM);
|
||||
}
|
||||
|
||||
/** Decode allowlisted parameters for the selected visual preset. */
|
||||
export function decodeStyleParamState(params, styleName) {
|
||||
if (params.get('v') !== '2' || !params.has(SHARE_STYLE_PARAMS_PARAM)) return null;
|
||||
if (params.get('v') !== '2' || !params.has(SHARE_STYLE_PARAMS_PARAM))
|
||||
return null;
|
||||
const registry = SHARE_STYLE_PARAM_REGISTRY[styleName];
|
||||
if (!registry) return null;
|
||||
const byToken = new Map(registry.map((spec) => [spec.token, spec]));
|
||||
const decoded = {};
|
||||
for (const assignment of String(params.get(SHARE_STYLE_PARAMS_PARAM) || '').split('_')) {
|
||||
for (const assignment of String(
|
||||
params.get(SHARE_STYLE_PARAMS_PARAM) || '',
|
||||
).split('_')) {
|
||||
const [token, scaledRaw, ...extra] = assignment.split('.');
|
||||
if (extra.length || !/^-?\d+$/.test(scaledRaw || '')) continue;
|
||||
const spec = byToken.get(token);
|
||||
@@ -606,12 +690,17 @@ export function decodePanelStateParams(params) {
|
||||
if (!spec || (field !== 'c' && field !== 'p')) continue;
|
||||
if (value !== '0' && value !== '1') continue;
|
||||
const bool = value === '1';
|
||||
const current = stateById.get(spec.id) || { id: spec.id, collapsed: null, pinned: null };
|
||||
const current = stateById.get(spec.id) || {
|
||||
id: spec.id,
|
||||
collapsed: null,
|
||||
pinned: null,
|
||||
};
|
||||
if (field === 'c') current.collapsed = bool;
|
||||
else if (field === 'p' && spec.pinnable) current.pinned = bool;
|
||||
stateById.set(spec.id, current);
|
||||
}
|
||||
const specs = Array.from(stateById.values())
|
||||
.filter((entry) => typeof entry.collapsed === 'boolean');
|
||||
const specs = Array.from(stateById.values()).filter(
|
||||
(entry) => typeof entry.collapsed === 'boolean',
|
||||
);
|
||||
return specs.length ? { specs } : null;
|
||||
}
|
||||
+1574
-626
File diff suppressed because it is too large.
Load diff
@@ -4,5 +4,8 @@ export * from './realtimeController.js';
|
||||
|
||||
/** Compose the standalone action runner with the voice controls. */
|
||||
export function initGevVoiceCommands(options) {
|
||||
return createVoiceCommands({ ...options, runner: createGevActionRunner(options) });
|
||||
return createVoiceCommands({
|
||||
...options,
|
||||
runner: createGevActionRunner(options),
|
||||
});
|
||||
}
|
||||
+678
-327
File diff suppressed because it is too large.
Load diff
+13
-6
@@ -122,7 +122,7 @@ export function isKnownVoiceTier(tier) {
|
||||
export function mostExpensiveVoiceModel() {
|
||||
// Rank by audio output — the dominant cost in a speech-to-speech session.
|
||||
return Object.values(VOICE_MODELS).reduce((worst, entry) =>
|
||||
entry.rates.audioOutput > worst.rates.audioOutput ? entry : worst
|
||||
entry.rates.audioOutput > worst.rates.audioOutput ? entry : worst,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,7 +191,10 @@ export const VOICE_COST_LIMIT_OFF = 'off';
|
||||
*/
|
||||
export function normalizeCostLimits(limits) {
|
||||
const clean = (value, fallback) => {
|
||||
if (typeof value === 'string' && value.trim().toLowerCase() === VOICE_COST_LIMIT_OFF) {
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
value.trim().toLowerCase() === VOICE_COST_LIMIT_OFF
|
||||
) {
|
||||
return Infinity;
|
||||
}
|
||||
if (value === Infinity) return Infinity;
|
||||
@@ -209,7 +212,8 @@ export function normalizeCostLimits(limits) {
|
||||
/** Convert limits to a JSON-safe shape that round-trips a disabled threshold. */
|
||||
export function serializeCostLimits(limits) {
|
||||
const normalized = normalizeCostLimits(limits);
|
||||
const encode = (value) => (Number.isFinite(value) ? value : VOICE_COST_LIMIT_OFF);
|
||||
const encode = (value) =>
|
||||
Number.isFinite(value) ? value : VOICE_COST_LIMIT_OFF;
|
||||
return {
|
||||
warnUsd: encode(normalized.warnUsd),
|
||||
capUsd: encode(normalized.capUsd),
|
||||
@@ -269,7 +273,7 @@ export function splitUsageTokens(usage) {
|
||||
if (!cached && nonNegative(inDetails.cached_tokens) > 0) {
|
||||
audioCached = Math.min(
|
||||
nonNegative(inDetails.cached_tokens),
|
||||
nonNegative(inDetails.audio_tokens)
|
||||
nonNegative(inDetails.audio_tokens),
|
||||
);
|
||||
}
|
||||
textIn = Math.max(0, nonNegative(inDetails.text_tokens) - textCached);
|
||||
@@ -289,12 +293,15 @@ export function splitUsageTokens(usage) {
|
||||
// to audio (priciest) so a partial payload over-estimates, never under.
|
||||
const inputResidual = Math.max(
|
||||
0,
|
||||
inputTotal - (textIn + audioIn + imageIn + textCached + audioCached + imageCached)
|
||||
inputTotal -
|
||||
(textIn + audioIn + imageIn + textCached + audioCached + imageCached),
|
||||
);
|
||||
audioIn += inputResidual;
|
||||
|
||||
const textOut = outDetails ? nonNegative(outDetails.text_tokens) : 0;
|
||||
let audioOut = outDetails ? nonNegative(outDetails.audio_tokens) : outputTotal;
|
||||
let audioOut = outDetails
|
||||
? nonNegative(outDetails.audio_tokens)
|
||||
: outputTotal;
|
||||
const outputResidual = Math.max(0, outputTotal - (textOut + audioOut));
|
||||
audioOut += outputResidual;
|
||||
|
||||
|
||||
@@ -33,10 +33,13 @@ export function deriveWeatherEffectProfile(weather) {
|
||||
const precipitation = Math.max(0, Number(weather.precipitationMm) || 0);
|
||||
const wind = range01(weather.windKph, 4, 90);
|
||||
const visibilityM = Number(weather.visibilityM);
|
||||
const visibilityFog = Number.isFinite(visibilityM) ? range01(12000 - visibilityM, 0, 11500) : 0;
|
||||
const visibilityFog = Number.isFinite(visibilityM)
|
||||
? range01(12000 - visibilityM, 0, 11500)
|
||||
: 0;
|
||||
|
||||
const drizzle = code >= 51 && code <= 57;
|
||||
const rainCode = (code >= 61 && code <= 67) || (code >= 80 && code <= 82) || code >= 95;
|
||||
const rainCode =
|
||||
(code >= 61 && code <= 67) || (code >= 80 && code <= 82) || code >= 95;
|
||||
const snowCode = (code >= 71 && code <= 77) || (code >= 85 && code <= 86);
|
||||
const fogCode = code === 45 || code === 48;
|
||||
const stormCode = code >= 95;
|
||||
@@ -48,7 +51,13 @@ export function deriveWeatherEffectProfile(weather) {
|
||||
const snow = snowCode ? Math.max(snowFloor, observedPrecip) : 0;
|
||||
const fog = Math.max(fogCode ? 0.78 : 0, visibilityFog);
|
||||
const storm = stormCode ? Math.max(0.55, observedPrecip) : 0;
|
||||
const forcedCloud = stormCode ? 0.92 : (rain || snow) ? 0.7 : fogCode ? 0.55 : 0;
|
||||
const forcedCloud = stormCode
|
||||
? 0.92
|
||||
: rain || snow
|
||||
? 0.7
|
||||
: fogCode
|
||||
? 0.55
|
||||
: 0;
|
||||
const cloud = Math.max(cover, forcedCloud);
|
||||
|
||||
return {
|
||||
|
||||
+26
-13
@@ -16,30 +16,41 @@ export function isValidWorldFocusTarget(detail) {
|
||||
if (!detail || !WORLD_FOCUS_FRAMING[detail.kind]) return false;
|
||||
if (!String(detail.id || '').trim()) return false;
|
||||
const { position } = detail;
|
||||
if (!position
|
||||
|| !Number.isFinite(position.x)
|
||||
|| !Number.isFinite(position.y)
|
||||
|| !Number.isFinite(position.z)) return false;
|
||||
if (
|
||||
!position ||
|
||||
!Number.isFinite(position.x) ||
|
||||
!Number.isFinite(position.y) ||
|
||||
!Number.isFinite(position.z)
|
||||
)
|
||||
return false;
|
||||
// Vessel and fire targets are surface-anchored Earth positions. Merely
|
||||
// finite coordinates near the ECEF origin cannot be flown to, and must be
|
||||
// rejected before the camera policy releases a current follow owner.
|
||||
const magnitude = Cesium.Cartesian3.magnitude(position);
|
||||
return Number.isFinite(magnitude)
|
||||
&& magnitude >= Cesium.Ellipsoid.WGS84.minimumRadius * 0.95;
|
||||
return (
|
||||
Number.isFinite(magnitude) &&
|
||||
magnitude >= Cesium.Ellipsoid.WGS84.minimumRadius * 0.95
|
||||
);
|
||||
}
|
||||
|
||||
/** Announce a valid user-click focus request. */
|
||||
export function requestWorldFocus(detail, eventTarget = globalThis.window) {
|
||||
if (!isValidWorldFocusTarget(detail)) return false;
|
||||
if (typeof eventTarget?.dispatchEvent !== 'function') return false;
|
||||
eventTarget.dispatchEvent(new CustomEvent(WORLD_FOCUS_REQUEST_EVENT, { detail }));
|
||||
eventTarget.dispatchEvent(
|
||||
new CustomEvent(WORLD_FOCUS_REQUEST_EVENT, { detail }),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Register one listener and return an idempotent disposer. */
|
||||
export function registerWorldFocusRequestListener(eventTarget, listener) {
|
||||
if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener
|
||||
|| typeof listener !== 'function') return () => {};
|
||||
if (
|
||||
!eventTarget?.addEventListener ||
|
||||
!eventTarget?.removeEventListener ||
|
||||
typeof listener !== 'function'
|
||||
)
|
||||
return () => {};
|
||||
eventTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, listener);
|
||||
let disposed = false;
|
||||
return () => {
|
||||
@@ -53,7 +64,8 @@ export function registerWorldFocusRequestListener(eventTarget, listener) {
|
||||
export function routeWorldFocusRequest(event, runExplicitFocus, fly) {
|
||||
const detail = event?.detail;
|
||||
if (!isValidWorldFocusTarget(detail)) return false;
|
||||
if (typeof runExplicitFocus !== 'function' || typeof fly !== 'function') return false;
|
||||
if (typeof runExplicitFocus !== 'function' || typeof fly !== 'function')
|
||||
return false;
|
||||
return runExplicitFocus(detail, () => fly(detail));
|
||||
}
|
||||
|
||||
@@ -63,9 +75,10 @@ export function flyToWorldTarget(viewer, target = {}) {
|
||||
const framing = WORLD_FOCUS_FRAMING[target.kind];
|
||||
if (!camera || !framing || !isValidWorldFocusTarget(target)) return false;
|
||||
const heading = Number.isFinite(camera.heading) ? camera.heading : 0;
|
||||
const duration = target.durationSec > 0
|
||||
? target.durationSec
|
||||
: WORLD_CLICK_FOCUS_DURATION_SEC;
|
||||
const duration =
|
||||
target.durationSec > 0
|
||||
? target.durationSec
|
||||
: WORLD_CLICK_FOCUS_DURATION_SEC;
|
||||
camera.cancelFlight?.();
|
||||
camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(target.position, framing.radiusM),
|
||||
|
||||
Reference in new issue
Block a user