mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
style: format remaining owned runtime modules
This commit is contained in:
1 parent
ae99843c14
commit
2aca7aecbb
94 files changed
+8230
-2516
No files matched your search
@@ -1,12 +1,20 @@
|
|||||||
import { defaultGeospatial } from '../search/defaults.js';
|
import { defaultGeospatial } from '../search/defaults.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js';
|
import {
|
||||||
import { isRateLimitedOutcome, resolveAnnotationTarget, sampleGroundHeight } from './annotationResolver.js';
|
holdContinuousRender,
|
||||||
|
releaseContinuousRender,
|
||||||
|
} from '../renderGovernor.js';
|
||||||
|
import {
|
||||||
|
isRateLimitedOutcome,
|
||||||
|
resolveAnnotationTarget,
|
||||||
|
sampleGroundHeight,
|
||||||
|
} from './annotationResolver.js';
|
||||||
import { ringCentroid } from './drawMode.js';
|
import { ringCentroid } from './drawMode.js';
|
||||||
|
|
||||||
// Dev convenience: expose the app's Cesium instance for console/preview probing
|
// Dev convenience: expose the app's Cesium instance for console/preview probing
|
||||||
// (single shared module instance — avoids dual-Cesium state bugs when testing).
|
// (single shared module instance — avoids dual-Cesium state bugs when testing).
|
||||||
if (typeof window !== 'undefined' && !window.__CESIUM__) window.__CESIUM__ = Cesium;
|
if (typeof window !== 'undefined' && !window.__CESIUM__)
|
||||||
|
window.__CESIUM__ = Cesium;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Annotation engine — the voice agent's "whiteboard" over the 3D world.
|
* Annotation engine — the voice agent's "whiteboard" over the 3D world.
|
||||||
@@ -25,7 +33,13 @@ const COLORS = new Set(['primary', 'amber', 'cyan', 'green', 'red']);
|
|||||||
// Entity FACTS the voice model may attach to an annotation (annotate_map.entityKind) —
|
// Entity FACTS the voice model may attach to an annotation (annotate_map.entityKind) —
|
||||||
// what kind of thing the target IS, routing the resolver (e.g. point_feature keeps a
|
// what kind of thing the target IS, routing the resolver (e.g. point_feature keeps a
|
||||||
// monument point-first). Unknown values are dropped, never guessed.
|
// monument point-first). Unknown values are dropped, never guessed.
|
||||||
const ENTITY_KINDS = new Set(['building', 'compound', 'district', 'street', 'point_feature']);
|
const ENTITY_KINDS = new Set([
|
||||||
|
'building',
|
||||||
|
'compound',
|
||||||
|
'district',
|
||||||
|
'street',
|
||||||
|
'point_feature',
|
||||||
|
]);
|
||||||
const DEFAULT_TTL_MS = 22_000;
|
const DEFAULT_TTL_MS = 22_000;
|
||||||
const FADE_MS = 1200;
|
const FADE_MS = 1200;
|
||||||
// Hard ceiling on simultaneously-live marks. Protects against a runaway voice
|
// Hard ceiling on simultaneously-live marks. Protects against a runaway voice
|
||||||
@@ -51,11 +65,14 @@ const OUTLINE_UPGRADE_CONCURRENCY = 2;
|
|||||||
* retrying immediately. A thrown resolver counts as definitive — the pre-retry
|
* retrying immediately. A thrown resolver counts as definitive — the pre-retry
|
||||||
* catch→point behavior. Exported for tests.
|
* catch→point behavior. Exported for tests.
|
||||||
*/
|
*/
|
||||||
export async function resolveOutlineWithRetry(resolveOutline, {
|
export async function resolveOutlineWithRetry(
|
||||||
|
resolveOutline,
|
||||||
|
{
|
||||||
delaysMs = OUTLINE_RETRY_DELAYS_MS,
|
delaysMs = OUTLINE_RETRY_DELAYS_MS,
|
||||||
isStale = () => false,
|
isStale = () => false,
|
||||||
waitFn = wait,
|
waitFn = wait,
|
||||||
} = {}) {
|
} = {},
|
||||||
|
) {
|
||||||
let retriedRateLimit = false;
|
let retriedRateLimit = false;
|
||||||
for (let attempt = 0; ; attempt += 1) {
|
for (let attempt = 0; ; attempt += 1) {
|
||||||
let fp;
|
let fp;
|
||||||
@@ -68,7 +85,9 @@ export async function resolveOutlineWithRetry(resolveOutline, {
|
|||||||
// A throttle gets one deliberately spaced retry. If that retry is throttled
|
// A throttle gets one deliberately spaced retry. If that retry is throttled
|
||||||
// too, stop this mark's outline task instead of replaying the batch storm.
|
// too, stop this mark's outline task instead of replaying the batch storm.
|
||||||
if (retriedRateLimit || attempt >= delaysMs.length) return undefined;
|
if (retriedRateLimit || attempt >= delaysMs.length) return undefined;
|
||||||
const retryAfterMs = Number.isFinite(fp.retryAfterMs) ? fp.retryAfterMs : 0;
|
const retryAfterMs = Number.isFinite(fp.retryAfterMs)
|
||||||
|
? fp.retryAfterMs
|
||||||
|
: 0;
|
||||||
await waitFn(Math.max(retryAfterMs, delaysMs[attempt]));
|
await waitFn(Math.max(retryAfterMs, delaysMs[attempt]));
|
||||||
if (isStale()) return undefined;
|
if (isStale()) return undefined;
|
||||||
retriedRateLimit = true;
|
retriedRateLimit = true;
|
||||||
@@ -89,7 +108,9 @@ export async function resolveOutlineWithRetry(resolveOutline, {
|
|||||||
* geometry is unknown. Exported for tests.
|
* geometry is unknown. Exported for tests.
|
||||||
*/
|
*/
|
||||||
export function normalizeTargetKey(target) {
|
export function normalizeTargetKey(target) {
|
||||||
const raw = String(target ?? '').trim().toLowerCase();
|
const raw = String(target ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const head = raw.split(',')[0].trim();
|
const head = raw.split(',')[0].trim();
|
||||||
return head || raw;
|
return head || raw;
|
||||||
@@ -123,12 +144,22 @@ export function createAnnotationEngine({
|
|||||||
|
|
||||||
function emitOutlineEvent(evt) {
|
function emitOutlineEvent(evt) {
|
||||||
for (const listener of outlineListeners) {
|
for (const listener of outlineListeners) {
|
||||||
try { listener(evt); } catch { /* a listener error must never break the upgrade */ }
|
try {
|
||||||
|
listener(evt);
|
||||||
|
} catch {
|
||||||
|
/* a listener error must never break the upgrade */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function abortPending() {
|
function abortPending() {
|
||||||
for (const c of activeControllers) { try { c.abort(); } catch { /* no-op */ } }
|
for (const c of activeControllers) {
|
||||||
|
try {
|
||||||
|
c.abort();
|
||||||
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
}
|
||||||
activeControllers.clear();
|
activeControllers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +205,9 @@ export function createAnnotationEngine({
|
|||||||
function rollbackRendererState(anno) {
|
function rollbackRendererState(anno) {
|
||||||
try {
|
try {
|
||||||
renderer.remove(anno);
|
renderer.remove(anno);
|
||||||
} catch { /* the renderer is already in a bad way; the original error wins */ }
|
} catch {
|
||||||
|
/* the renderer is already in a bad way; the original error wins */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureTicking() {
|
function ensureTicking() {
|
||||||
@@ -226,7 +259,15 @@ export function createAnnotationEngine({
|
|||||||
* @returns {Promise<{ok, drawn, failed, ids, results}>}
|
* @returns {Promise<{ok, drawn, failed, ids, results}>}
|
||||||
*/
|
*/
|
||||||
async function annotate(requests, opts = {}) {
|
async function annotate(requests, opts = {}) {
|
||||||
if (destroyed) return { ok: false, drawn: 0, failed: 0, ids: [], results: [], error: 'destroyed' };
|
if (destroyed)
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
drawn: 0,
|
||||||
|
failed: 0,
|
||||||
|
ids: [],
|
||||||
|
results: [],
|
||||||
|
error: 'destroyed',
|
||||||
|
};
|
||||||
const list = Array.isArray(requests) ? requests : [requests];
|
const list = Array.isArray(requests) ? requests : [requests];
|
||||||
if (opts.clearPrevious) clear(); // bumps generation + aborts older pending work
|
if (opts.clearPrevious) clear(); // bumps generation + aborts older pending work
|
||||||
|
|
||||||
@@ -247,7 +288,11 @@ export function createAnnotationEngine({
|
|||||||
// allSettled gives per-item error isolation (one failed item never aborts the batch); the
|
// allSettled gives per-item error isolation (one failed item never aborts the batch); the
|
||||||
// mutation pass below then runs in ORDER, so de-dup, the synchronous live-cap check, and output
|
// mutation pass below then runs in ORDER, so de-dup, the synchronous live-cap check, and output
|
||||||
// order are all preserved exactly as the old serial loop had them.
|
// order are all preserved exactly as the old serial loop had them.
|
||||||
const settled = await Promise.allSettled(list.map((spec) => resolveSpec(spec, controller.signal, opts.flyTo === true)));
|
const settled = await Promise.allSettled(
|
||||||
|
list.map((spec) =>
|
||||||
|
resolveSpec(spec, controller.signal, opts.flyTo === true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < list.length; i += 1) {
|
for (let i = 0; i < list.length; i += 1) {
|
||||||
@@ -270,8 +315,15 @@ export function createAnnotationEngine({
|
|||||||
// stacked. De-dup runs BEFORE the cap so a re-narration still refreshes when full.
|
// stacked. De-dup runs BEFORE the cap so a re-narration still refreshes when full.
|
||||||
const dup = findDuplicate(anno);
|
const dup = findDuplicate(anno);
|
||||||
if (dup) {
|
if (dup) {
|
||||||
const labelChanged = String(dup.label || '').trim().toLowerCase() !== String(anno.label || '').trim().toLowerCase();
|
const labelChanged =
|
||||||
const colorChanged = (dup.color || 'primary') !== (anno.color || 'primary');
|
String(dup.label || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase() !==
|
||||||
|
String(anno.label || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const colorChanged =
|
||||||
|
(dup.color || 'primary') !== (anno.color || 'primary');
|
||||||
if (labelChanged || colorChanged) {
|
if (labelChanged || colorChanged) {
|
||||||
// Same place + shape, but a NEW label and/or color ("Marina" → "Marina District",
|
// Same place + shape, but a NEW label and/or color ("Marina" → "Marina District",
|
||||||
// or a recolor) → replace in place so the latest caption/color wins (count
|
// or a recolor) → replace in place so the latest caption/color wins (count
|
||||||
@@ -305,13 +357,26 @@ export function createAnnotationEngine({
|
|||||||
ids.push(anno.id);
|
ids.push(anno.id);
|
||||||
if (!firstAnchor) firstAnchor = anno;
|
if (!firstAnchor) firstAnchor = anno;
|
||||||
if (typeof resolved.resolveOutline === 'function') {
|
if (typeof resolved.resolveOutline === 'function') {
|
||||||
startOutlineUpgrade(anno, resolved.resolveOutline, myGen, controller);
|
startOutlineUpgrade(
|
||||||
|
anno,
|
||||||
|
resolved.resolveOutline,
|
||||||
|
myGen,
|
||||||
|
controller,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
results.push({ ...okResult(anno, resolved, anno.id), duplicate: true });
|
results.push({
|
||||||
|
...okResult(anno, resolved, anno.id),
|
||||||
|
duplicate: true,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Identical re-narration → refresh the existing mark's lifecycle in place (keep
|
// Identical re-narration → refresh the existing mark's lifecycle in place (keep
|
||||||
// it visible whether persistent or TTL-based, and revive a fading-out one).
|
// it visible whether persistent or TTL-based, and revive a fading-out one).
|
||||||
if (persist) { dup.ttlMs = null; } else { dup.createdAt = performance.now(); dup.ttlMs = anno.ttlMs; }
|
if (persist) {
|
||||||
|
dup.ttlMs = null;
|
||||||
|
} else {
|
||||||
|
dup.createdAt = performance.now();
|
||||||
|
dup.ttlMs = anno.ttlMs;
|
||||||
|
}
|
||||||
dup.expiring = false;
|
dup.expiring = false;
|
||||||
dup.fadeStart = null;
|
dup.fadeStart = null;
|
||||||
ids.push(dup.id);
|
ids.push(dup.id);
|
||||||
@@ -319,11 +384,19 @@ export function createAnnotationEngine({
|
|||||||
// A re-narration is also a RETRY: if the existing mark never got its outline
|
// A re-narration is also a RETRY: if the existing mark never got its outline
|
||||||
// (transient Overpass miss), attach this call's deferred resolver to it.
|
// (transient Overpass miss), attach this call's deferred resolver to it.
|
||||||
if (!dup.ring && typeof resolved.resolveOutline === 'function') {
|
if (!dup.ring && typeof resolved.resolveOutline === 'function') {
|
||||||
startOutlineUpgrade(dup, resolved.resolveOutline, myGen, controller);
|
startOutlineUpgrade(
|
||||||
|
dup,
|
||||||
|
resolved.resolveOutline,
|
||||||
|
myGen,
|
||||||
|
controller,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Same result builder as the fresh path (full metadata) but the existing id —
|
// Same result builder as the fresh path (full metadata) but the existing id —
|
||||||
// route fallback / approximate honesty fields survive a re-narration.
|
// route fallback / approximate honesty fields survive a re-narration.
|
||||||
results.push({ ...okResult(anno, resolved, dup.id), duplicate: true });
|
results.push({
|
||||||
|
...okResult(anno, resolved, dup.id),
|
||||||
|
duplicate: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -350,12 +423,23 @@ export function createAnnotationEngine({
|
|||||||
if (!firstAnchor) firstAnchor = anno;
|
if (!firstAnchor) firstAnchor = anno;
|
||||||
// Progressive: the point is on the board; trace the outline behind the narration.
|
// Progressive: the point is on the board; trace the outline behind the narration.
|
||||||
if (typeof resolved.resolveOutline === 'function') {
|
if (typeof resolved.resolveOutline === 'function') {
|
||||||
startOutlineUpgrade(anno, resolved.resolveOutline, myGen, controller);
|
startOutlineUpgrade(
|
||||||
|
anno,
|
||||||
|
resolved.resolveOutline,
|
||||||
|
myGen,
|
||||||
|
controller,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
results.push(okResult(anno, resolved, anno.id));
|
results.push(okResult(anno, resolved, anno.id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (superseded()) break;
|
if (superseded()) break;
|
||||||
results.push(failResult(spec, error?.message || 'annotation failed', Array.isArray(error?.failedTargets) ? error.failedTargets : null));
|
results.push(
|
||||||
|
failResult(
|
||||||
|
spec,
|
||||||
|
error?.message || 'annotation failed',
|
||||||
|
Array.isArray(error?.failedTargets) ? error.failedTargets : null,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -365,7 +449,14 @@ export function createAnnotationEngine({
|
|||||||
// A clear/new-topic landed mid-flight: drop everything this call produced and
|
// A clear/new-topic landed mid-flight: drop everything this call produced and
|
||||||
// do not touch the renderer or camera — the board now belongs to a newer call.
|
// do not touch the renderer or camera — the board now belongs to a newer call.
|
||||||
if (superseded()) {
|
if (superseded()) {
|
||||||
return { ok: false, drawn: 0, failed: results.length, ids: [], results, aborted: true };
|
return {
|
||||||
|
ok: false,
|
||||||
|
drawn: 0,
|
||||||
|
failed: results.length,
|
||||||
|
ids: [],
|
||||||
|
results,
|
||||||
|
aborted: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ids.length) {
|
if (ids.length) {
|
||||||
@@ -393,7 +484,8 @@ export function createAnnotationEngine({
|
|||||||
if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);
|
if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);
|
||||||
if (type === 'route') {
|
if (type === 'route') {
|
||||||
const points = Array.isArray(spec.points) ? spec.points : [];
|
const points = Array.isArray(spec.points) ? spec.points : [];
|
||||||
if (points.length < 2) throw new Error('a route needs at least 2 waypoints');
|
if (points.length < 2)
|
||||||
|
throw new Error('a route needs at least 2 waypoints');
|
||||||
const resolvedPts = [];
|
const resolvedPts = [];
|
||||||
const failed = [];
|
const failed = [];
|
||||||
for (const pt of points) {
|
for (const pt of points) {
|
||||||
@@ -425,7 +517,12 @@ export function createAnnotationEngine({
|
|||||||
}
|
}
|
||||||
// Real street-following route (OSM/OSRM), mode-aware.
|
// Real street-following route (OSM/OSRM), mode-aware.
|
||||||
const mode = normalizeMode(spec.mode);
|
const mode = normalizeMode(spec.mode);
|
||||||
const routed = await fetchRoute(resolvedPts.map((p) => [p.lon, p.lat]), mode, signal, placeSearch);
|
const routed = await fetchRoute(
|
||||||
|
resolvedPts.map((p) => [p.lon, p.lat]),
|
||||||
|
mode,
|
||||||
|
signal,
|
||||||
|
placeSearch,
|
||||||
|
);
|
||||||
if (routed) {
|
if (routed) {
|
||||||
return {
|
return {
|
||||||
path: routed.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
path: routed.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
||||||
@@ -439,8 +536,16 @@ export function createAnnotationEngine({
|
|||||||
// Routing unavailable: do NOT pass off straight segments as a real route. Draw
|
// Routing unavailable: do NOT pass off straight segments as a real route. Draw
|
||||||
// a clearly-labeled direct line with great-circle distance and NO travel time.
|
// a clearly-labeled direct line with great-circle distance and NO travel time.
|
||||||
let straight = 0;
|
let straight = 0;
|
||||||
for (let i = 1; i < resolvedPts.length; i += 1) straight += greatCircleM(resolvedPts[i - 1], resolvedPts[i]);
|
for (let i = 1; i < resolvedPts.length; i += 1)
|
||||||
return { path: resolvedPts, distanceM: straight, durationS: null, mode, source: resolvedPts[0].source, fallback: true };
|
straight += greatCircleM(resolvedPts[i - 1], resolvedPts[i]);
|
||||||
|
return {
|
||||||
|
path: resolvedPts,
|
||||||
|
distanceM: straight,
|
||||||
|
durationS: null,
|
||||||
|
mode,
|
||||||
|
source: resolvedPts[0].source,
|
||||||
|
fallback: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (type === 'arrow') {
|
if (type === 'arrow') {
|
||||||
const from = await resolveTarget({
|
const from = await resolveTarget({
|
||||||
@@ -479,9 +584,15 @@ export function createAnnotationEngine({
|
|||||||
err.failedTargets = failed;
|
err.failedTargets = failed;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
return { from, to, distanceM: greatCircleM(from, to), source: from.source };
|
return {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
distanceM: greatCircleM(from, to),
|
||||||
|
source: from.source,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const wantFootprint = type === 'area' ? spec.footprint !== false : Boolean(spec.footprint);
|
const wantFootprint =
|
||||||
|
type === 'area' ? spec.footprint !== false : Boolean(spec.footprint);
|
||||||
return resolveTarget({
|
return resolveTarget({
|
||||||
placeSearch,
|
placeSearch,
|
||||||
viewer,
|
viewer,
|
||||||
@@ -491,7 +602,8 @@ export function createAnnotationEngine({
|
|||||||
screenX: spec.screenX,
|
screenX: spec.screenX,
|
||||||
screenY: spec.screenY,
|
screenY: spec.screenY,
|
||||||
footprint: wantFootprint,
|
footprint: wantFootprint,
|
||||||
intent: spec.intent === 'around_the_thing' ? 'around_the_thing' : 'the_thing',
|
intent:
|
||||||
|
spec.intent === 'around_the_thing' ? 'around_the_thing' : 'the_thing',
|
||||||
entityKind: ENTITY_KINDS.has(spec?.entityKind) ? spec.entityKind : null,
|
entityKind: ENTITY_KINDS.has(spec?.entityKind) ? spec.entityKind : null,
|
||||||
// The label often carries the ask's true shape when the target omits it
|
// The label often carries the ask's true shape when the target omits it
|
||||||
// ("target: Texas State Capitol" + "label: Capitol grounds") — a resolver HINT only.
|
// ("target: Texas State Capitol" + "label: Capitol grounds") — a resolver HINT only.
|
||||||
@@ -525,11 +637,15 @@ export function createAnnotationEngine({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drainOutlineQueue() {
|
function drainOutlineQueue() {
|
||||||
while (activeOutlineUpgrades < OUTLINE_UPGRADE_CONCURRENCY && outlineQueue.length) {
|
while (
|
||||||
|
activeOutlineUpgrades < OUTLINE_UPGRADE_CONCURRENCY &&
|
||||||
|
outlineQueue.length
|
||||||
|
) {
|
||||||
const task = outlineQueue.shift();
|
const task = outlineQueue.shift();
|
||||||
const stale = task.myGen !== generation
|
const stale =
|
||||||
|| task.controller.signal.aborted
|
task.myGen !== generation ||
|
||||||
|| !annotations.has(task.anno.id);
|
task.controller.signal.aborted ||
|
||||||
|
!annotations.has(task.anno.id);
|
||||||
if (stale) {
|
if (stale) {
|
||||||
if (task.anno._outlineTask === task) task.anno._outlineTask = null;
|
if (task.anno._outlineTask === task) task.anno._outlineTask = null;
|
||||||
releaseController(task.controller);
|
releaseController(task.controller);
|
||||||
@@ -549,7 +665,10 @@ export function createAnnotationEngine({
|
|||||||
// proxy caches the late completion, so the retry is usually a warm cache hit.
|
// proxy caches the late completion, so the retry is usually a warm cache hit.
|
||||||
fp = await resolveOutlineWithRetry(resolveOutline, {
|
fp = await resolveOutlineWithRetry(resolveOutline, {
|
||||||
delaysMs: outlineRetryDelaysMs,
|
delaysMs: outlineRetryDelaysMs,
|
||||||
isStale: () => myGen !== generation || controller.signal.aborted || !annotations.has(anno.id),
|
isStale: () =>
|
||||||
|
myGen !== generation ||
|
||||||
|
controller.signal.aborted ||
|
||||||
|
!annotations.has(anno.id),
|
||||||
});
|
});
|
||||||
if (myGen !== generation || controller.signal.aborted) return; // board superseded
|
if (myGen !== generation || controller.signal.aborted) return; // board superseded
|
||||||
if (!annotations.has(anno.id)) return; // mark replaced/removed while resolving
|
if (!annotations.has(anno.id)) return; // mark replaced/removed while resolving
|
||||||
@@ -577,11 +696,22 @@ export function createAnnotationEngine({
|
|||||||
// wins, exactly like the add-time replace path). Not an auto-clear — it removes
|
// wins, exactly like the add-time replace path). Not an auto-clear — it removes
|
||||||
// a second representation of the same mark, never a distinct one.
|
// a second representation of the same mark, never a distinct one.
|
||||||
for (const other of annotations.values()) {
|
for (const other of annotations.values()) {
|
||||||
if (other.id === anno.id || other.type !== 'area' || other.pendingOutline) continue;
|
if (
|
||||||
if (!other.anchor || Math.abs(other.anchor.lon - anno.anchor.lon) >= 5e-4
|
other.id === anno.id ||
|
||||||
|| Math.abs(other.anchor.lat - anno.anchor.lat) >= 5e-4) continue;
|
other.type !== 'area' ||
|
||||||
if (Boolean(other.synthesized) !== Boolean(anno.synthesized)) continue;
|
other.pendingOutline
|
||||||
if ((other.footprintKind || null) !== (anno.footprintKind || null)) continue;
|
)
|
||||||
|
continue;
|
||||||
|
if (
|
||||||
|
!other.anchor ||
|
||||||
|
Math.abs(other.anchor.lon - anno.anchor.lon) >= 5e-4 ||
|
||||||
|
Math.abs(other.anchor.lat - anno.anchor.lat) >= 5e-4
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
if (Boolean(other.synthesized) !== Boolean(anno.synthesized))
|
||||||
|
continue;
|
||||||
|
if ((other.footprintKind || null) !== (anno.footprintKind || null))
|
||||||
|
continue;
|
||||||
if (!ringsEqual(other.ring, anno.ring)) continue;
|
if (!ringsEqual(other.ring, anno.ring)) continue;
|
||||||
annotations.delete(other.id);
|
annotations.delete(other.id);
|
||||||
renderer.remove(other);
|
renderer.remove(other);
|
||||||
@@ -629,7 +759,12 @@ export function createAnnotationEngine({
|
|||||||
// Route-specific signal so the voice layer can be honest about an OSRM outage:
|
// Route-specific signal so the voice layer can be honest about an OSRM outage:
|
||||||
// fallback=true means a straight direct line, not a real route.
|
// fallback=true means a straight direct line, not a real route.
|
||||||
...(anno.type === 'route'
|
...(anno.type === 'route'
|
||||||
? { fallback: Boolean(anno.fallback), mode: anno.mode, distanceM: anno.distanceM, durationS: anno.durationS }
|
? {
|
||||||
|
fallback: Boolean(anno.fallback),
|
||||||
|
mode: anno.mode,
|
||||||
|
distanceM: anno.distanceM,
|
||||||
|
durationS: anno.durationS,
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -643,13 +778,18 @@ export function createAnnotationEngine({
|
|||||||
* degenerate geocode stacks on one point (several Capitol monuments) would erase each other. */
|
* degenerate geocode stacks on one point (several Capitol monuments) would erase each other. */
|
||||||
function findDuplicate(anno) {
|
function findDuplicate(anno) {
|
||||||
if (!anno.anchor) return null;
|
if (!anno.anchor) return null;
|
||||||
const near = (a, b) => a && b && Math.abs(a.lon - b.lon) < 5e-4 && Math.abs(a.lat - b.lat) < 5e-4;
|
const near = (a, b) =>
|
||||||
|
a &&
|
||||||
|
b &&
|
||||||
|
Math.abs(a.lon - b.lon) < 5e-4 &&
|
||||||
|
Math.abs(a.lat - b.lat) < 5e-4;
|
||||||
// FULL per-vertex geometry comparison (the live cap is only 120 and this runs solely for
|
// FULL per-vertex geometry comparison (the live cap is only 120 and this runs solely for
|
||||||
// candidates that already matched type+label+anchor, so it's cheap) — so two DIFFERENT
|
// candidates that already matched type+label+anchor, so it's cheap) — so two DIFFERENT
|
||||||
// same-length rings/paths at the same anchor are never falsely merged.
|
// same-length rings/paths at the same anchor are never falsely merged.
|
||||||
const ringSame = ringsEqual;
|
const ringSame = ringsEqual;
|
||||||
const pathSame = (a, b) => {
|
const pathSame = (a, b) => {
|
||||||
const la = a ? a.length : 0; const lb = b ? b.length : 0;
|
const la = a ? a.length : 0;
|
||||||
|
const lb = b ? b.length : 0;
|
||||||
if (la !== lb) return false;
|
if (la !== lb) return false;
|
||||||
for (let i = 0; i < la; i++) if (!near(a[i], b[i])) return false;
|
for (let i = 0; i < la; i++) if (!near(a[i], b[i])) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -674,10 +814,12 @@ export function createAnnotationEngine({
|
|||||||
// the identity; the target is only the stand-in while geometry is unknown.
|
// the identity; the target is only the stand-in while geometry is unknown.
|
||||||
if (!anno.targetKey || !ex.targetKey) continue;
|
if (!anno.targetKey || !ex.targetKey) continue;
|
||||||
if (anno.targetKey !== ex.targetKey) continue;
|
if (anno.targetKey !== ex.targetKey) continue;
|
||||||
if ((ex.intentKey || 'thing') !== (anno.intentKey || 'thing')) continue;
|
if ((ex.intentKey || 'thing') !== (anno.intentKey || 'thing'))
|
||||||
|
continue;
|
||||||
} else {
|
} else {
|
||||||
if (Boolean(ex.synthesized) !== Boolean(anno.synthesized)) continue;
|
if (Boolean(ex.synthesized) !== Boolean(anno.synthesized)) continue;
|
||||||
if ((ex.footprintKind || null) !== (anno.footprintKind || null)) continue;
|
if ((ex.footprintKind || null) !== (anno.footprintKind || null))
|
||||||
|
continue;
|
||||||
if (!ringSame(ex.ring, anno.ring)) continue;
|
if (!ringSame(ex.ring, anno.ring)) continue;
|
||||||
}
|
}
|
||||||
} else if (anno.type === 'arrow') {
|
} else if (anno.type === 'arrow') {
|
||||||
@@ -691,7 +833,13 @@ export function createAnnotationEngine({
|
|||||||
// point (e.g. several Texas Capitol monuments all geocoding to the dome centroid) — NOT a
|
// point (e.g. several Texas Capitol monuments all geocoding to the dome centroid) — NOT a
|
||||||
// duplicate. Only collapse a genuine re-narration of the SAME point (same label); distinct
|
// duplicate. Only collapse a genuine re-narration of the SAME point (same label); distinct
|
||||||
// labels stay as separate marks (their callouts de-collide in the renderer).
|
// labels stay as separate marks (their callouts de-collide in the renderer).
|
||||||
const sameLabel = String(ex.label || '').trim().toLowerCase() === String(anno.label || '').trim().toLowerCase();
|
const sameLabel =
|
||||||
|
String(ex.label || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase() ===
|
||||||
|
String(anno.label || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
if (!sameLabel) continue;
|
if (!sameLabel) continue;
|
||||||
}
|
}
|
||||||
return ex;
|
return ex;
|
||||||
@@ -703,7 +851,11 @@ export function createAnnotationEngine({
|
|||||||
const type = normalizeType(spec?.type);
|
const type = normalizeType(spec?.type);
|
||||||
const id = `anno-${++_seq}`;
|
const id = `anno-${++_seq}`;
|
||||||
const color = COLORS.has(spec?.color) ? spec.color : 'primary';
|
const color = COLORS.has(spec?.color) ? spec.color : 'primary';
|
||||||
const label = cleanLabel(spec?.label) || resolved?.label || resolved?.from?.label || null;
|
const label =
|
||||||
|
cleanLabel(spec?.label) ||
|
||||||
|
resolved?.label ||
|
||||||
|
resolved?.from?.label ||
|
||||||
|
null;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
@@ -712,17 +864,27 @@ export function createAnnotationEngine({
|
|||||||
color,
|
color,
|
||||||
label,
|
label,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
ttlMs: persist ? null : (Number(spec?.ttlMs) || DEFAULT_TTL_MS),
|
ttlMs: persist ? null : Number(spec?.ttlMs) || DEFAULT_TTL_MS,
|
||||||
alpha: 0, // animate in
|
alpha: 0, // animate in
|
||||||
bornAt: now,
|
bornAt: now,
|
||||||
expiring: false,
|
expiring: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type === 'route') {
|
if (type === 'route') {
|
||||||
const path = resolved.path.map((p) => ({ lon: p.lon, lat: p.lat, height: p.height }));
|
const path = resolved.path.map((p) => ({
|
||||||
|
lon: p.lon,
|
||||||
|
lat: p.lat,
|
||||||
|
height: p.height,
|
||||||
|
}));
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
label: composeRouteLabel(base.label, resolved.distanceM, resolved.durationS, resolved.mode, resolved.fallback),
|
label: composeRouteLabel(
|
||||||
|
base.label,
|
||||||
|
resolved.distanceM,
|
||||||
|
resolved.durationS,
|
||||||
|
resolved.mode,
|
||||||
|
resolved.fallback,
|
||||||
|
),
|
||||||
anchor: path[0],
|
anchor: path[0],
|
||||||
to: null,
|
to: null,
|
||||||
path,
|
path,
|
||||||
@@ -738,8 +900,16 @@ export function createAnnotationEngine({
|
|||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
label: appendDistance(base.label, resolved.distanceM),
|
label: appendDistance(base.label, resolved.distanceM),
|
||||||
anchor: { lon: resolved.from.lon, lat: resolved.from.lat, height: resolved.from.height },
|
anchor: {
|
||||||
to: { lon: resolved.to.lon, lat: resolved.to.lat, height: resolved.to.height },
|
lon: resolved.from.lon,
|
||||||
|
lat: resolved.from.lat,
|
||||||
|
height: resolved.from.height,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
lon: resolved.to.lon,
|
||||||
|
lat: resolved.to.lat,
|
||||||
|
height: resolved.to.height,
|
||||||
|
},
|
||||||
ring: null,
|
ring: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -814,13 +984,17 @@ export function createAnnotationEngine({
|
|||||||
if (m.to) points.push(m.to);
|
if (m.to) points.push(m.to);
|
||||||
}
|
}
|
||||||
if (!points.length || points.some((p) => isPointOnScreen(p))) return;
|
if (!points.length || points.some((p) => isPointOnScreen(p))) return;
|
||||||
console.log(`[Annotations] auto-framing ${marks.length} off-screen mark(s)`);
|
console.log(
|
||||||
|
`[Annotations] auto-framing ${marks.length} off-screen mark(s)`,
|
||||||
|
);
|
||||||
assistFlightUntil = performance.now() + 2600; // ~flight duration + settle
|
assistFlightUntil = performance.now() + 2600; // ~flight duration + settle
|
||||||
if (marks.length === 1) {
|
if (marks.length === 1) {
|
||||||
frameAnnotation(marks[0]);
|
frameAnnotation(marks[0]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cart = points.map((p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0));
|
const cart = points.map((p) =>
|
||||||
|
Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0),
|
||||||
|
);
|
||||||
const sphere = Cesium.BoundingSphere.fromPoints(cart);
|
const sphere = Cesium.BoundingSphere.fromPoints(cart);
|
||||||
viewer.camera.flyToBoundingSphere(sphere, {
|
viewer.camera.flyToBoundingSphere(sphere, {
|
||||||
offset: new Cesium.HeadingPitchRange(
|
offset: new Cesium.HeadingPitchRange(
|
||||||
@@ -841,9 +1015,20 @@ export function createAnnotationEngine({
|
|||||||
try {
|
try {
|
||||||
const camera = viewer.camera;
|
const camera = viewer.camera;
|
||||||
const pos = Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0);
|
const pos = Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0);
|
||||||
const cv = camera.frustum.computeCullingVolume(camera.position, camera.direction, camera.up);
|
const cv = camera.frustum.computeCullingVolume(
|
||||||
if (cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) === Cesium.Intersect.OUTSIDE) return false;
|
camera.position,
|
||||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, camera.position);
|
camera.direction,
|
||||||
|
camera.up,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) ===
|
||||||
|
Cesium.Intersect.OUTSIDE
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
const occluder = new Cesium.EllipsoidalOccluder(
|
||||||
|
Cesium.Ellipsoid.WGS84,
|
||||||
|
camera.position,
|
||||||
|
);
|
||||||
return occluder.isPointVisible(pos);
|
return occluder.isPointVisible(pos);
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return true;
|
||||||
@@ -853,15 +1038,24 @@ export function createAnnotationEngine({
|
|||||||
function frameAnnotation(anno) {
|
function frameAnnotation(anno) {
|
||||||
try {
|
try {
|
||||||
const target = anno.to
|
const target = anno.to
|
||||||
? { lon: (anno.anchor.lon + anno.to.lon) / 2, lat: (anno.anchor.lat + anno.to.lat) / 2 }
|
? {
|
||||||
|
lon: (anno.anchor.lon + anno.to.lon) / 2,
|
||||||
|
lat: (anno.anchor.lat + anno.to.lat) / 2,
|
||||||
|
}
|
||||||
: anno.anchor;
|
: anno.anchor;
|
||||||
// No ring yet (progressive outline still resolving) → size the flight from the
|
// No ring yet (progressive outline still resolving) → size the flight from the
|
||||||
// Places viewport box when we have one, so a big compound isn't framed at
|
// Places viewport box when we have one, so a big compound isn't framed at
|
||||||
// building scale while its outline is traced. Never re-fly when the ring lands.
|
// building scale while its outline is traced. Never re-fly when the ring lands.
|
||||||
const range = anno.ring ? ringRange(anno.ring) : (viewportRange(anno.viewport) || 600);
|
const range = anno.ring
|
||||||
|
? ringRange(anno.ring)
|
||||||
|
: viewportRange(anno.viewport) || 600;
|
||||||
viewer.camera.flyToBoundingSphere(
|
viewer.camera.flyToBoundingSphere(
|
||||||
new Cesium.BoundingSphere(
|
new Cesium.BoundingSphere(
|
||||||
Cesium.Cartesian3.fromDegrees(target.lon, target.lat, anno.anchor.height || 0),
|
Cesium.Cartesian3.fromDegrees(
|
||||||
|
target.lon,
|
||||||
|
target.lat,
|
||||||
|
anno.anchor.height || 0,
|
||||||
|
),
|
||||||
range,
|
range,
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
@@ -884,7 +1078,9 @@ export function createAnnotationEngine({
|
|||||||
destroy() {
|
destroy() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
try { clear(); } finally {
|
try {
|
||||||
|
clear();
|
||||||
|
} finally {
|
||||||
if (tickHandle != null) cancelAnimationFrame(tickHandle);
|
if (tickHandle != null) cancelAnimationFrame(tickHandle);
|
||||||
tickHandle = null;
|
tickHandle = null;
|
||||||
outlineListeners.clear();
|
outlineListeners.clear();
|
||||||
@@ -912,12 +1108,36 @@ export function createAnnotationEngine({
|
|||||||
* Lays down a small San Francisco "tour" the way the voice agent would.
|
* Lays down a small San Francisco "tour" the way the voice agent would.
|
||||||
*/
|
*/
|
||||||
async demo() {
|
async demo() {
|
||||||
return annotate([
|
return annotate(
|
||||||
{ type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' },
|
[
|
||||||
{ type: 'area', target: 'Presidio of San Francisco', label: 'The Presidio (former Army base)', color: 'green', footprint: true },
|
{
|
||||||
{ type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'cyan' },
|
type: 'highlight',
|
||||||
{ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina District, San Francisco', label: 'next to the Marina' },
|
target: 'Palace of Fine Arts, San Francisco',
|
||||||
], { flyTo: true, clearPrevious: true, persist: true });
|
label: 'Palace of Fine Arts',
|
||||||
|
color: 'amber',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'area',
|
||||||
|
target: 'Presidio of San Francisco',
|
||||||
|
label: 'The Presidio (former Army base)',
|
||||||
|
color: 'green',
|
||||||
|
footprint: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'pin',
|
||||||
|
target: 'Letterman Digital Arts Center, San Francisco',
|
||||||
|
label: 'ILM / Lucasfilm',
|
||||||
|
color: 'cyan',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'arrow',
|
||||||
|
target: 'Palace of Fine Arts, San Francisco',
|
||||||
|
toTarget: 'Marina District, San Francisco',
|
||||||
|
label: 'next to the Marina',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ flyTo: true, clearPrevious: true, persist: true },
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -927,34 +1147,107 @@ export function createAnnotationEngine({
|
|||||||
*/
|
*/
|
||||||
async tour() {
|
async tour() {
|
||||||
clear();
|
clear();
|
||||||
flyTo({ lon: -122.4486, lat: 37.7960, height: 520, heading: 0, pitch: -26, duration: 3 });
|
flyTo({
|
||||||
|
lon: -122.4486,
|
||||||
|
lat: 37.796,
|
||||||
|
height: 520,
|
||||||
|
heading: 0,
|
||||||
|
pitch: -26,
|
||||||
|
duration: 3,
|
||||||
|
});
|
||||||
if (destroyed) return { ok: false };
|
if (destroyed) return { ok: false };
|
||||||
await wait(3200);
|
await wait(3200);
|
||||||
await annotate([{ type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' }], { persist: true });
|
await annotate(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'highlight',
|
||||||
|
target: 'Palace of Fine Arts, San Francisco',
|
||||||
|
label: 'Palace of Fine Arts',
|
||||||
|
color: 'amber',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ persist: true },
|
||||||
|
);
|
||||||
if (destroyed) return { ok: false };
|
if (destroyed) return { ok: false };
|
||||||
await wait(2600);
|
await wait(2600);
|
||||||
await annotate([{ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina Green, San Francisco', label: 'next to the Marina', color: 'cyan' }], { persist: true });
|
await annotate(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'arrow',
|
||||||
|
target: 'Palace of Fine Arts, San Francisco',
|
||||||
|
toTarget: 'Marina Green, San Francisco',
|
||||||
|
label: 'next to the Marina',
|
||||||
|
color: 'cyan',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ persist: true },
|
||||||
|
);
|
||||||
if (destroyed) return { ok: false };
|
if (destroyed) return { ok: false };
|
||||||
await wait(2600);
|
await wait(2600);
|
||||||
flyTo({ lon: -122.4545, lat: 37.7880, height: 1500, heading: 18, pitch: -32, duration: 3 });
|
flyTo({
|
||||||
|
lon: -122.4545,
|
||||||
|
lat: 37.788,
|
||||||
|
height: 1500,
|
||||||
|
heading: 18,
|
||||||
|
pitch: -32,
|
||||||
|
duration: 3,
|
||||||
|
});
|
||||||
if (destroyed) return { ok: false };
|
if (destroyed) return { ok: false };
|
||||||
await wait(3200);
|
await wait(3200);
|
||||||
await annotate([{ type: 'area', target: 'Presidio of San Francisco', label: 'The Presidio — a former Army base', color: 'green', footprint: true }], { persist: true });
|
await annotate(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'area',
|
||||||
|
target: 'Presidio of San Francisco',
|
||||||
|
label: 'The Presidio — a former Army base',
|
||||||
|
color: 'green',
|
||||||
|
footprint: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ persist: true },
|
||||||
|
);
|
||||||
if (destroyed) return { ok: false };
|
if (destroyed) return { ok: false };
|
||||||
await wait(2800);
|
await wait(2800);
|
||||||
await annotate([{ type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'red' }], { persist: true });
|
await annotate(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'pin',
|
||||||
|
target: 'Letterman Digital Arts Center, San Francisco',
|
||||||
|
label: 'ILM / Lucasfilm',
|
||||||
|
color: 'red',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ persist: true },
|
||||||
|
);
|
||||||
if (destroyed) return { ok: false };
|
if (destroyed) return { ok: false };
|
||||||
await wait(2600);
|
await wait(2600);
|
||||||
await annotate([{ type: 'route', color: 'amber', label: 'Crissy Field shoreline', points: [
|
await annotate(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'route',
|
||||||
|
color: 'amber',
|
||||||
|
label: 'Crissy Field shoreline',
|
||||||
|
points: [
|
||||||
{ target: 'Palace of Fine Arts, San Francisco' },
|
{ target: 'Palace of Fine Arts, San Francisco' },
|
||||||
{ target: 'Crissy Field, San Francisco' },
|
{ target: 'Crissy Field, San Francisco' },
|
||||||
{ target: 'Fort Point, San Francisco' },
|
{ target: 'Fort Point, San Francisco' },
|
||||||
] }], { persist: true });
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ persist: true },
|
||||||
|
);
|
||||||
return { ok: true, steps: 6 };
|
return { ok: true, steps: 6 };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function flyTo({ lon, lat, height, heading = 0, pitch = -30, duration = 2.5 }) {
|
function flyTo({
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
height,
|
||||||
|
heading = 0,
|
||||||
|
pitch = -30,
|
||||||
|
duration = 2.5,
|
||||||
|
}) {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
try {
|
try {
|
||||||
viewer.camera.flyTo({
|
viewer.camera.flyTo({
|
||||||
@@ -984,7 +1277,11 @@ function ringsEqual(a, b) {
|
|||||||
const lb = b ? b.length : 0;
|
const lb = b ? b.length : 0;
|
||||||
if (la !== lb) return false;
|
if (la !== lb) return false;
|
||||||
for (let i = 0; i < la; i++) {
|
for (let i = 0; i < la; i++) {
|
||||||
if (Math.abs(a[i][0] - b[i][0]) >= 1e-5 || Math.abs(a[i][1] - b[i][1]) >= 1e-5) return false;
|
if (
|
||||||
|
Math.abs(a[i][0] - b[i][0]) >= 1e-5 ||
|
||||||
|
Math.abs(a[i][1] - b[i][1]) >= 1e-5
|
||||||
|
)
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1010,7 +1307,8 @@ function specTargets(spec) {
|
|||||||
* arrow endpoint) when known; otherwise it derives them from the spec.
|
* arrow endpoint) when known; otherwise it derives them from the spec.
|
||||||
*/
|
*/
|
||||||
function failResult(spec, error, failedTargets) {
|
function failResult(spec, error, failedTargets) {
|
||||||
const targets = (failedTargets && failedTargets.length) ? failedTargets : specTargets(spec);
|
const targets =
|
||||||
|
failedTargets && failedTargets.length ? failedTargets : specTargets(spec);
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
label: spec?.label || spec?.target || targets[0] || null,
|
label: spec?.label || spec?.target || targets[0] || null,
|
||||||
@@ -1022,7 +1320,8 @@ function failResult(spec, error, failedTargets) {
|
|||||||
|
|
||||||
function normalizeType(type) {
|
function normalizeType(type) {
|
||||||
const t = String(type || '').toLowerCase();
|
const t = String(type || '').toLowerCase();
|
||||||
if (t === 'area' || t === 'polygon' || t === 'outline' || t === 'compound') return 'area';
|
if (t === 'area' || t === 'polygon' || t === 'outline' || t === 'compound')
|
||||||
|
return 'area';
|
||||||
if (t === 'route' || t === 'path' || t === 'line') return 'route';
|
if (t === 'route' || t === 'path' || t === 'line') return 'route';
|
||||||
if (t === 'arrow' || t === 'vector' || t === 'connector') return 'arrow';
|
if (t === 'arrow' || t === 'vector' || t === 'connector') return 'arrow';
|
||||||
if (t === 'label' || t === 'callout' || t === 'text') return 'label';
|
if (t === 'label' || t === 'callout' || t === 'text') return 'label';
|
||||||
@@ -1036,7 +1335,8 @@ function computeAlpha(anno, now) {
|
|||||||
|
|
||||||
if (anno.ttlMs == null && !anno.expiring) return fadeIn;
|
if (anno.ttlMs == null && !anno.expiring) return fadeIn;
|
||||||
|
|
||||||
const fadeStart = anno.fadeStart != null
|
const fadeStart =
|
||||||
|
anno.fadeStart != null
|
||||||
? anno.fadeStart
|
? anno.fadeStart
|
||||||
: anno.createdAt + (anno.ttlMs || 0);
|
: anno.createdAt + (anno.ttlMs || 0);
|
||||||
if (now < fadeStart) return fadeIn;
|
if (now < fadeStart) return fadeIn;
|
||||||
@@ -1052,7 +1352,10 @@ function pendingAnimation(anno, now) {
|
|||||||
if (now - anno.bornAt < 260) return true; // fading in
|
if (now - anno.bornAt < 260) return true; // fading in
|
||||||
if (anno.expiring) return true; // fading out
|
if (anno.expiring) return true; // fading out
|
||||||
if (anno.ttlMs != null) {
|
if (anno.ttlMs != null) {
|
||||||
const fadeStart = anno.fadeStart != null ? anno.fadeStart : anno.createdAt + (anno.ttlMs || 0);
|
const fadeStart =
|
||||||
|
anno.fadeStart != null
|
||||||
|
? anno.fadeStart
|
||||||
|
: anno.createdAt + (anno.ttlMs || 0);
|
||||||
return now < fadeStart + FADE_MS; // TTL fade still ahead or in progress
|
return now < fadeStart + FADE_MS; // TTL fade still ahead or in progress
|
||||||
}
|
}
|
||||||
return false; // persistent + faded in → stable
|
return false; // persistent + faded in → stable
|
||||||
@@ -1110,16 +1413,30 @@ function round5(n) {
|
|||||||
/** A spec whose geometry was supplied by hand (drawTool.js) rather than by a name. */
|
/** A spec whose geometry was supplied by hand (drawTool.js) rather than by a name. */
|
||||||
function isManualSpec(spec, type) {
|
function isManualSpec(spec, type) {
|
||||||
if (!spec || spec.manual !== true) return false;
|
if (!spec || spec.manual !== true) return false;
|
||||||
if (type === 'route') return Array.isArray(spec.path) && spec.path.length >= 2;
|
if (type === 'route')
|
||||||
|
return Array.isArray(spec.path) && spec.path.length >= 2;
|
||||||
if (type === 'area') return Array.isArray(spec.ring) && spec.ring.length >= 3;
|
if (type === 'area') return Array.isArray(spec.ring) && spec.ring.length >= 3;
|
||||||
return Number.isFinite(Number(spec.latitude)) && Number.isFinite(Number(spec.longitude));
|
return (
|
||||||
|
Number.isFinite(Number(spec.latitude)) &&
|
||||||
|
Number.isFinite(Number(spec.longitude))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** [lon, lat] pairs or {lon, lat} objects → [lon, lat] pairs, invalid entries dropped. */
|
/** [lon, lat] pairs or {lon, lat} objects → [lon, lat] pairs, invalid entries dropped. */
|
||||||
function manualPairs(list) {
|
function manualPairs(list) {
|
||||||
return (Array.isArray(list) ? list : [])
|
return (Array.isArray(list) ? list : [])
|
||||||
.map((p) => (Array.isArray(p) ? [Number(p[0]), Number(p[1])] : [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)]))
|
.map((p) =>
|
||||||
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180);
|
Array.isArray(p)
|
||||||
|
? [Number(p[0]), Number(p[1])]
|
||||||
|
: [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)],
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
([lon, lat]) =>
|
||||||
|
Number.isFinite(lon) &&
|
||||||
|
Number.isFinite(lat) &&
|
||||||
|
Math.abs(lat) <= 90 &&
|
||||||
|
Math.abs(lon) <= 180,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1132,15 +1449,28 @@ function resolveManualSpec(spec, type, viewer) {
|
|||||||
// height 0 is a placeholder, not a placement: the world renderer draws a
|
// height 0 is a placeholder, not a placement: the world renderer draws a
|
||||||
// route with clampToGround + CESIUM_3D_TILE classification, so the line is
|
// route with clampToGround + CESIUM_3D_TILE classification, so the line is
|
||||||
// draped onto the photoreal surface whatever this number says.
|
// draped onto the photoreal surface whatever this number says.
|
||||||
const pts = manualPairs(spec.path).map(([lon, lat]) => ({ lon, lat, height: 0 }));
|
const pts = manualPairs(spec.path).map(([lon, lat]) => ({
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
height: 0,
|
||||||
|
}));
|
||||||
if (pts.length < 2) throw new Error('a drawn line needs at least 2 points');
|
if (pts.length < 2) throw new Error('a drawn line needs at least 2 points');
|
||||||
let distanceM = 0;
|
let distanceM = 0;
|
||||||
for (let i = 1; i < pts.length; i += 1) distanceM += greatCircleM(pts[i - 1], pts[i]);
|
for (let i = 1; i < pts.length; i += 1)
|
||||||
return { path: pts, distanceM, durationS: null, mode: 'manual', source: 'manual', fallback: false };
|
distanceM += greatCircleM(pts[i - 1], pts[i]);
|
||||||
|
return {
|
||||||
|
path: pts,
|
||||||
|
distanceM,
|
||||||
|
durationS: null,
|
||||||
|
mode: 'manual',
|
||||||
|
source: 'manual',
|
||||||
|
fallback: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (type === 'area') {
|
if (type === 'area') {
|
||||||
const ring = manualPairs(spec.ring);
|
const ring = manualPairs(spec.ring);
|
||||||
if (ring.length < 3) throw new Error('a drawn area needs at least 3 points');
|
if (ring.length < 3)
|
||||||
|
throw new Error('a drawn area needs at least 3 points');
|
||||||
// Averaged through ringCentroid, which unwraps longitudes first: a ring
|
// Averaged through ringCentroid, which unwraps longitudes first: a ring
|
||||||
// straddling the antimeridian has coordinates like [179.999, -179.999], and
|
// straddling the antimeridian has coordinates like [179.999, -179.999], and
|
||||||
// a raw mean of those puts the anchor on the Greenwich meridian, half a
|
// a raw mean of those puts the anchor on the Greenwich meridian, half a
|
||||||
@@ -1151,13 +1481,25 @@ function resolveManualSpec(spec, type, viewer) {
|
|||||||
const lon = centre.lon;
|
const lon = centre.lon;
|
||||||
const lat = centre.lat;
|
const lat = centre.lat;
|
||||||
return {
|
return {
|
||||||
lon, lat, height: sampleGroundHeight(viewer, lon, lat),
|
lon,
|
||||||
ring, footprintKind: 'area', buildingHeight: null, synthesized: false, source: 'manual',
|
lat,
|
||||||
|
height: sampleGroundHeight(viewer, lon, lat),
|
||||||
|
ring,
|
||||||
|
footprintKind: 'area',
|
||||||
|
buildingHeight: null,
|
||||||
|
synthesized: false,
|
||||||
|
source: 'manual',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const lon = Number(spec.longitude);
|
const lon = Number(spec.longitude);
|
||||||
const lat = Number(spec.latitude);
|
const lat = Number(spec.latitude);
|
||||||
return { lon, lat, height: sampleGroundHeight(viewer, lon, lat), ring: null, source: 'manual' };
|
return {
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
height: sampleGroundHeight(viewer, lon, lat),
|
||||||
|
ring: null,
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The ring's distinct positions: a closing vertex that repeats the first is dropped. */
|
/** The ring's distinct positions: a closing vertex that repeats the first is dropped. */
|
||||||
@@ -1165,20 +1507,26 @@ function closedRingWithoutRepeat(ring) {
|
|||||||
if (ring.length < 2) return ring;
|
if (ring.length < 2) return ring;
|
||||||
const [firstLon, firstLat] = ring[0];
|
const [firstLon, firstLat] = ring[0];
|
||||||
const [lastLon, lastLat] = ring[ring.length - 1];
|
const [lastLon, lastLat] = ring[ring.length - 1];
|
||||||
return firstLon === lastLon && firstLat === lastLat ? ring.slice(0, -1) : ring;
|
return firstLon === lastLon && firstLat === lastLat
|
||||||
|
? ring.slice(0, -1)
|
||||||
|
: ring;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMode(m) {
|
function normalizeMode(m) {
|
||||||
const t = String(m || '').toLowerCase();
|
const t = String(m || '').toLowerCase();
|
||||||
if (t === 'car' || t === 'drive' || t === 'driving') return 'car';
|
if (t === 'car' || t === 'drive' || t === 'driving') return 'car';
|
||||||
if (t === 'bike' || t === 'cycle' || t === 'cycling' || t === 'bicycle') return 'bike';
|
if (t === 'bike' || t === 'cycle' || t === 'cycling' || t === 'bicycle')
|
||||||
|
return 'bike';
|
||||||
return 'foot';
|
return 'foot';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Route failure remains an explicitly labelled direct line in the caller. */
|
/** Route failure remains an explicitly labelled direct line in the caller. */
|
||||||
async function fetchRoute(coordPairs, mode, signal, service) {
|
async function fetchRoute(coordPairs, mode, signal, service) {
|
||||||
try { return await service.route?.(coordPairs, mode, { signal }) || null; }
|
try {
|
||||||
catch { return null; }
|
return (await service.route?.(coordPairs, mode, { signal })) || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function greatCircleM(a, b) {
|
function greatCircleM(a, b) {
|
||||||
@@ -1188,7 +1536,9 @@ function greatCircleM(a, b) {
|
|||||||
const dLon = toRad(b.lon - a.lon);
|
const dLon = toRad(b.lon - a.lon);
|
||||||
const la1 = toRad(a.lat);
|
const la1 = toRad(a.lat);
|
||||||
const la2 = toRad(b.lat);
|
const la2 = toRad(b.lat);
|
||||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
|
const h =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
|
||||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
|
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
import { createAnnotationResolver } from './resolver.js';
|
import { createAnnotationResolver } from './resolver.js';
|
||||||
import { applicationServices } from '../services/application.js';
|
import { applicationServices } from '../services/application.js';
|
||||||
const resolver = createAnnotationResolver({ boundarySource: applicationServices.boundaries });
|
const resolver = createAnnotationResolver({
|
||||||
export const { resolveAnnotationTarget, refineScope, isRateLimitedOutcome, isGroundsLikeAsk, selectFootprint, viewportBias, placesNearViewRecovery, resolveRegionRingForQuery, pickWorldFromScreen, sampleGroundHeight } = resolver;
|
boundarySource: applicationServices.boundaries,
|
||||||
|
});
|
||||||
|
export const {
|
||||||
|
resolveAnnotationTarget,
|
||||||
|
refineScope,
|
||||||
|
isRateLimitedOutcome,
|
||||||
|
isGroundsLikeAsk,
|
||||||
|
selectFootprint,
|
||||||
|
viewportBias,
|
||||||
|
placesNearViewRecovery,
|
||||||
|
resolveRegionRingForQuery,
|
||||||
|
pickWorldFromScreen,
|
||||||
|
sampleGroundHeight,
|
||||||
|
} = resolver;
|
||||||
@@ -42,7 +42,11 @@ export function createHybridAnnotationRenderer(viewer) {
|
|||||||
entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
|
entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
|
||||||
screen.add(entry.screenProxy);
|
screen.add(entry.screenProxy);
|
||||||
}
|
}
|
||||||
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
|
} else if (
|
||||||
|
anno.type === 'route' &&
|
||||||
|
Array.isArray(anno.path) &&
|
||||||
|
anno.path.length >= 2
|
||||||
|
) {
|
||||||
// Drape the path on the tiles (world), caption it with a screen callout
|
// Drape the path on the tiles (world), caption it with a screen callout
|
||||||
// at the path midpoint.
|
// at the path midpoint.
|
||||||
entry.worldProxy = liveProxy(anno, { label: null });
|
entry.worldProxy = liveProxy(anno, { label: null });
|
||||||
@@ -86,7 +90,11 @@ export function createHybridAnnotationRenderer(viewer) {
|
|||||||
} else if (anno.label) {
|
} else if (anno.label) {
|
||||||
screen.add(screenProxy);
|
screen.add(screenProxy);
|
||||||
}
|
}
|
||||||
entry.screenProxy = entry.screenProxy ? screenProxy : (anno.label ? screenProxy : null);
|
entry.screenProxy = entry.screenProxy
|
||||||
|
? screenProxy
|
||||||
|
: anno.label
|
||||||
|
? screenProxy
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,13 +10,27 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
|||||||
* footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver,
|
* footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver,
|
||||||
* and voice tool wiring are identical to the other two branches.
|
* and voice tool wiring are identical to the other two branches.
|
||||||
*/
|
*/
|
||||||
export function initAnnotations({ viewer, tileset = null, placeSearch, resolver }) {
|
export function initAnnotations({
|
||||||
|
viewer,
|
||||||
|
tileset = null,
|
||||||
|
placeSearch,
|
||||||
|
resolver,
|
||||||
|
}) {
|
||||||
// World-space footprint draping; clamped marks can use the photoreal tiles.
|
// World-space footprint draping; clamped marks can use the photoreal tiles.
|
||||||
if (tileset) {
|
if (tileset) {
|
||||||
try { tileset.enableCollision = true; } catch { /* older tileset */ }
|
try {
|
||||||
|
tileset.enableCollision = true;
|
||||||
|
} catch {
|
||||||
|
/* older tileset */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const renderer = createHybridAnnotationRenderer(viewer);
|
const renderer = createHybridAnnotationRenderer(viewer);
|
||||||
const engine = createAnnotationEngine({ viewer, renderer, placeSearch, resolveTarget: resolver?.resolveAnnotationTarget });
|
const engine = createAnnotationEngine({
|
||||||
|
viewer,
|
||||||
|
renderer,
|
||||||
|
placeSearch,
|
||||||
|
resolveTarget: resolver?.resolveAnnotationTarget,
|
||||||
|
});
|
||||||
window.__gevAnnotations = engine;
|
window.__gevAnnotations = engine;
|
||||||
return engine;
|
return engine;
|
||||||
}
|
}
|
||||||
@@ -51,16 +51,22 @@ function markScale(h) {
|
|||||||
return 1 - t * (1 - MARK_SCALE_MIN);
|
return 1 - t * (1 - MARK_SCALE_MIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createScreenAnnotationRenderer(viewer, {
|
export function createScreenAnnotationRenderer(
|
||||||
|
viewer,
|
||||||
|
{
|
||||||
overlayPaintRect = getOverlayPaintRect,
|
overlayPaintRect = getOverlayPaintRect,
|
||||||
activeTrackedReadoutId = getActiveTrackedReadoutId,
|
activeTrackedReadoutId = getActiveTrackedReadoutId,
|
||||||
} = {}) {
|
} = {},
|
||||||
|
) {
|
||||||
injectStyles();
|
injectStyles();
|
||||||
const { layer, svg, defs } = buildOverlay();
|
const { layer, svg, defs } = buildOverlay();
|
||||||
document.body.appendChild(layer);
|
document.body.appendChild(layer);
|
||||||
|
|
||||||
const scene = viewer.scene;
|
const scene = viewer.scene;
|
||||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, scene.camera.positionWC);
|
const occluder = new Cesium.EllipsoidalOccluder(
|
||||||
|
Cesium.Ellipsoid.WGS84,
|
||||||
|
scene.camera.positionWC,
|
||||||
|
);
|
||||||
const records = new Map(); // anno.id -> { anno, group, parts }
|
const records = new Map(); // anno.id -> { anno, group, parts }
|
||||||
const scratch = new Cesium.Cartesian2();
|
const scratch = new Cesium.Cartesian2();
|
||||||
const scratchDir = new Cesium.Cartesian3();
|
const scratchDir = new Cesium.Cartesian3();
|
||||||
@@ -112,8 +118,13 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
return cached.h;
|
return cached.h;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (scene.clampToHeightSupported && typeof scene.clampToHeight === 'function') {
|
if (
|
||||||
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
|
scene.clampToHeightSupported &&
|
||||||
|
typeof scene.clampToHeight === 'function'
|
||||||
|
) {
|
||||||
|
const c = scene.clampToHeight(
|
||||||
|
Cesium.Cartesian3.fromDegrees(lon, lat, 0),
|
||||||
|
);
|
||||||
if (c) {
|
if (c) {
|
||||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
const h = Cesium.Cartographic.fromCartesian(c).height;
|
||||||
if (Number.isFinite(h) && h > -430 && h < 9000) {
|
if (Number.isFinite(h) && h > -430 && h < 9000) {
|
||||||
@@ -123,7 +134,9 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* tiles not ready */ }
|
} catch {
|
||||||
|
/* tiles not ready */
|
||||||
|
}
|
||||||
const fallback = cached ? cached.h : 0;
|
const fallback = cached ? cached.h : 0;
|
||||||
heightCache.set(key, { h: fallback, settled: false, gen: projGen });
|
heightCache.set(key, { h: fallback, settled: false, gen: projGen });
|
||||||
trimHeightCache();
|
trimHeightCache();
|
||||||
@@ -140,7 +153,8 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
// "draw-on" gesture, so they never masquerade as an authoritative boundary.
|
// "draw-on" gesture, so they never masquerade as an authoritative boundary.
|
||||||
parts.poly = svgEl('polygon', {
|
parts.poly = svgEl('polygon', {
|
||||||
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw',
|
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw',
|
||||||
fill: c, stroke: c,
|
fill: c,
|
||||||
|
stroke: c,
|
||||||
'fill-opacity': anno.synthesized ? '0.08' : '0.14',
|
'fill-opacity': anno.synthesized ? '0.08' : '0.14',
|
||||||
'stroke-width': '2.5',
|
'stroke-width': '2.5',
|
||||||
...(anno.synthesized ? { 'stroke-dasharray': '9 7' } : {}),
|
...(anno.synthesized ? { 'stroke-dasharray': '9 7' } : {}),
|
||||||
@@ -151,25 +165,41 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
if (parts.label) group.appendChild(parts.label.node);
|
if (parts.label) group.appendChild(parts.label.node);
|
||||||
} else if (anno.type === 'arrow' && anno.to) {
|
} else if (anno.type === 'arrow' && anno.to) {
|
||||||
parts.path = svgEl('path', {
|
parts.path = svgEl('path', {
|
||||||
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c,
|
class: 'gev-anno-arrow gev-draw',
|
||||||
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
fill: 'none',
|
||||||
|
stroke: c,
|
||||||
|
'stroke-width': '3',
|
||||||
|
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||||
filter: 'url(#gev-sketch)',
|
filter: 'url(#gev-sketch)',
|
||||||
});
|
});
|
||||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
ensureArrowMarker(defs, anno.color || 'primary', c);
|
||||||
group.appendChild(parts.path);
|
group.appendChild(parts.path);
|
||||||
parts.label = makeCallout(anno.label, c);
|
parts.label = makeCallout(anno.label, c);
|
||||||
if (parts.label) group.appendChild(parts.label.node);
|
if (parts.label) group.appendChild(parts.label.node);
|
||||||
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
|
} else if (
|
||||||
|
anno.type === 'route' &&
|
||||||
|
Array.isArray(anno.path) &&
|
||||||
|
anno.path.length >= 2
|
||||||
|
) {
|
||||||
// Multi-waypoint path: a drawn-on polyline with a dot at each waypoint.
|
// Multi-waypoint path: a drawn-on polyline with a dot at each waypoint.
|
||||||
parts.poly = svgEl('polyline', {
|
parts.poly = svgEl('polyline', {
|
||||||
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c,
|
class: 'gev-anno-arrow gev-draw',
|
||||||
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
fill: 'none',
|
||||||
|
stroke: c,
|
||||||
|
'stroke-width': '3',
|
||||||
|
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||||
filter: 'url(#gev-sketch)',
|
filter: 'url(#gev-sketch)',
|
||||||
});
|
});
|
||||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
ensureArrowMarker(defs, anno.color || 'primary', c);
|
||||||
group.appendChild(parts.poly);
|
group.appendChild(parts.poly);
|
||||||
parts.dots = anno.path.map(() => {
|
parts.dots = anno.path.map(() => {
|
||||||
const dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: '4' });
|
const dot = svgEl('circle', {
|
||||||
|
class: 'gev-anno-dot',
|
||||||
|
fill: c,
|
||||||
|
stroke: '#06121c',
|
||||||
|
'stroke-width': '2',
|
||||||
|
r: '4',
|
||||||
|
});
|
||||||
group.appendChild(dot);
|
group.appendChild(dot);
|
||||||
return dot;
|
return dot;
|
||||||
});
|
});
|
||||||
@@ -178,14 +208,39 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
} else {
|
} else {
|
||||||
// pin / highlight / label — pulsing target rings + a marker dot + callout
|
// pin / highlight / label — pulsing target rings + a marker dot + callout
|
||||||
if (anno.type !== 'label') {
|
if (anno.type !== 'label') {
|
||||||
parts.ringOuter = svgEl('circle', { class: 'gev-anno-ring gev-pulse', fill: 'none', stroke: c, 'stroke-width': '2', r: '34' });
|
parts.ringOuter = svgEl('circle', {
|
||||||
parts.ringInner = svgEl('circle', { class: 'gev-anno-ring', fill: c, 'fill-opacity': '0.12', stroke: c, 'stroke-width': '2.5', r: '18', filter: 'url(#gev-sketch)' });
|
class: 'gev-anno-ring gev-pulse',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: c,
|
||||||
|
'stroke-width': '2',
|
||||||
|
r: '34',
|
||||||
|
});
|
||||||
|
parts.ringInner = svgEl('circle', {
|
||||||
|
class: 'gev-anno-ring',
|
||||||
|
fill: c,
|
||||||
|
'fill-opacity': '0.12',
|
||||||
|
stroke: c,
|
||||||
|
'stroke-width': '2.5',
|
||||||
|
r: '18',
|
||||||
|
filter: 'url(#gev-sketch)',
|
||||||
|
});
|
||||||
group.appendChild(parts.ringOuter);
|
group.appendChild(parts.ringOuter);
|
||||||
group.appendChild(parts.ringInner);
|
group.appendChild(parts.ringInner);
|
||||||
}
|
}
|
||||||
parts.dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: anno.type === 'label' ? '4' : '5' });
|
parts.dot = svgEl('circle', {
|
||||||
|
class: 'gev-anno-dot',
|
||||||
|
fill: c,
|
||||||
|
stroke: '#06121c',
|
||||||
|
'stroke-width': '2',
|
||||||
|
r: anno.type === 'label' ? '4' : '5',
|
||||||
|
});
|
||||||
group.appendChild(parts.dot);
|
group.appendChild(parts.dot);
|
||||||
parts.leader = svgEl('line', { class: 'gev-anno-leader', stroke: c, 'stroke-width': '1.5', 'stroke-opacity': '0.7' });
|
parts.leader = svgEl('line', {
|
||||||
|
class: 'gev-anno-leader',
|
||||||
|
stroke: c,
|
||||||
|
'stroke-width': '1.5',
|
||||||
|
'stroke-opacity': '0.7',
|
||||||
|
});
|
||||||
group.appendChild(parts.leader);
|
group.appendChild(parts.leader);
|
||||||
parts.label = makeCallout(anno.label, c);
|
parts.label = makeCallout(anno.label, c);
|
||||||
if (parts.label) group.appendChild(parts.label.node);
|
if (parts.label) group.appendChild(parts.label.node);
|
||||||
@@ -209,18 +264,26 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
// any length. Guarded on the specific property so an unrelated transition
|
// any length. Guarded on the specific property so an unrelated transition
|
||||||
// (none here, but defensive) doesn't clear it early.
|
// (none here, but defensive) doesn't clear it early.
|
||||||
for (const drawEl of group.querySelectorAll('.gev-draw')) {
|
for (const drawEl of group.querySelectorAll('.gev-draw')) {
|
||||||
drawEl.addEventListener('transitionend', (e) => {
|
drawEl.addEventListener(
|
||||||
|
'transitionend',
|
||||||
|
(e) => {
|
||||||
if (e.propertyName === 'stroke-dashoffset') {
|
if (e.propertyName === 'stroke-dashoffset') {
|
||||||
drawEl.style.strokeDasharray = 'none';
|
drawEl.style.strokeDasharray = 'none';
|
||||||
}
|
}
|
||||||
}, { once: true });
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
projectAll();
|
projectAll();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Hard, immediate unwind — a mark that never finished has nothing to
|
// Hard, immediate unwind — a mark that never finished has nothing to
|
||||||
// fade out, and the caller (engine rollback) must find a clean board.
|
// fade out, and the caller (engine rollback) must find a clean board.
|
||||||
records.delete(anno.id);
|
records.delete(anno.id);
|
||||||
try { group.remove(); } catch { /* never inserted */ }
|
try {
|
||||||
|
group.remove();
|
||||||
|
} catch {
|
||||||
|
/* never inserted */
|
||||||
|
}
|
||||||
if (records.size === 0) heightCache.clear();
|
if (records.size === 0) heightCache.clear();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -269,13 +332,22 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
// Anchor at the real surface height (cached + validated), so marks sit on
|
// Anchor at the real surface height (cached + validated), so marks sit on
|
||||||
// the ground/building instead of at sea level. The settle-once cache keeps
|
// the ground/building instead of at sea level. The settle-once cache keeps
|
||||||
// it stable (no per-frame jitter that earlier broke nearby projections).
|
// it stable (no per-frame jitter that earlier broke nearby projections).
|
||||||
const world = Cesium.Cartesian3.fromDegrees(lon, lat, groundHeight(lon, lat));
|
const world = Cesium.Cartesian3.fromDegrees(
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
groundHeight(lon, lat),
|
||||||
|
);
|
||||||
// Reject points behind the camera (they produce wrapped/extreme coords).
|
// Reject points behind the camera (they produce wrapped/extreme coords).
|
||||||
Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir);
|
Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir);
|
||||||
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0) return null;
|
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0)
|
||||||
|
return null;
|
||||||
// Reject points beyond the globe horizon.
|
// Reject points beyond the globe horizon.
|
||||||
if (!occluder.isPointVisible(world)) return null;
|
if (!occluder.isPointVisible(world)) return null;
|
||||||
const win = Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, scratch);
|
const win = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||||
|
scene,
|
||||||
|
world,
|
||||||
|
scratch,
|
||||||
|
);
|
||||||
if (!win || !Number.isFinite(win.x) || !Number.isFinite(win.y)) return null;
|
if (!win || !Number.isFinite(win.x) || !Number.isFinite(win.y)) return null;
|
||||||
// Reject absurd off-screen projections (anchor not in view).
|
// Reject absurd off-screen projections (anchor not in view).
|
||||||
const w = scene.canvas.clientWidth || scene.canvas.width;
|
const w = scene.canvas.clientWidth || scene.canvas.width;
|
||||||
@@ -317,11 +389,16 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
|
|
||||||
const ent = viewer.trackedEntity;
|
const ent = viewer.trackedEntity;
|
||||||
const now = Cesium.JulianDate.now();
|
const now = Cesium.JulianDate.now();
|
||||||
const world = typeof ent?.gevDisplayPosition === 'function'
|
const world =
|
||||||
|
typeof ent?.gevDisplayPosition === 'function'
|
||||||
? ent.gevDisplayPosition()
|
? ent.gevDisplayPosition()
|
||||||
: null;
|
: null;
|
||||||
const win = world
|
const win = world
|
||||||
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, _scratchTrackedWin)
|
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||||
|
scene,
|
||||||
|
world,
|
||||||
|
_scratchTrackedWin,
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Billboard box — centered on the anchor, magnified by scaleByDistance.
|
// Billboard box — centered on the anchor, magnified by scaleByDistance.
|
||||||
@@ -331,8 +408,13 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
const baseH = bb.height?.getValue?.(now) ?? 28;
|
const baseH = bb.height?.getValue?.(now) ?? 28;
|
||||||
let scale = 1;
|
let scale = 1;
|
||||||
const sbd = bb.scaleByDistance?.getValue?.(now);
|
const sbd = bb.scaleByDistance?.getValue?.(now);
|
||||||
if (sbd) scale = nearFarValue(sbd, Cesium.Cartesian3.distance(scene.camera.positionWC, world));
|
if (sbd)
|
||||||
const hw = (baseW * scale) / 2; const hh = (baseH * scale) / 2;
|
scale = nearFarValue(
|
||||||
|
sbd,
|
||||||
|
Cesium.Cartesian3.distance(scene.camera.positionWC, world),
|
||||||
|
);
|
||||||
|
const hw = (baseW * scale) / 2;
|
||||||
|
const hh = (baseH * scale) / 2;
|
||||||
const bbLeft = win.x - hw;
|
const bbLeft = win.x - hw;
|
||||||
const bbRight = win.x + hw;
|
const bbRight = win.x + hw;
|
||||||
const bbTop = win.y - hh;
|
const bbTop = win.y - hh;
|
||||||
@@ -345,7 +427,12 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
if (![left, right, top, bottom].every(Number.isFinite)) return null;
|
if (![left, right, top, bottom].every(Number.isFinite)) return null;
|
||||||
|
|
||||||
const m = TRACKED_BBOX_MARGIN;
|
const m = TRACKED_BBOX_MARGIN;
|
||||||
return { left: left - m, right: right + m, top: top - m, bottom: bottom + m };
|
return {
|
||||||
|
left: left - m,
|
||||||
|
right: right + m,
|
||||||
|
top: top - m,
|
||||||
|
bottom: bottom + m,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectAll() {
|
function projectAll() {
|
||||||
@@ -357,7 +444,9 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
const w = scene.canvas.clientWidth || scene.canvas.width;
|
const w = scene.canvas.clientWidth || scene.canvas.width;
|
||||||
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
||||||
// Shrink point-marker geometry as the camera pulls back (declutter the blob).
|
// Shrink point-marker geometry as the camera pulls back (declutter the blob).
|
||||||
const mScale = markScale(viewer.camera.positionCartographic?.height ?? 1000);
|
const mScale = markScale(
|
||||||
|
viewer.camera.positionCartographic?.height ?? 1000,
|
||||||
|
);
|
||||||
|
|
||||||
for (const rec of records.values()) {
|
for (const rec of records.values()) {
|
||||||
const { anno, group, parts } = rec;
|
const { anno, group, parts } = rec;
|
||||||
@@ -369,17 +458,22 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
|
|
||||||
if (anno.type === 'area' && parts.poly) {
|
if (anno.type === 'area' && parts.poly) {
|
||||||
const pts = [];
|
const pts = [];
|
||||||
let cx = 0; let cy = 0; let n = 0;
|
let cx = 0;
|
||||||
|
let cy = 0;
|
||||||
|
let n = 0;
|
||||||
for (const [lon, lat] of anno.ring) {
|
for (const [lon, lat] of anno.ring) {
|
||||||
const p = project(lon, lat);
|
const p = project(lon, lat);
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
|
pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
|
||||||
cx += p.x; cy += p.y; n++;
|
cx += p.x;
|
||||||
|
cy += p.y;
|
||||||
|
n++;
|
||||||
}
|
}
|
||||||
if (pts.length >= 3) {
|
if (pts.length >= 3) {
|
||||||
parts.poly.setAttribute('points', pts.join(' '));
|
parts.poly.setAttribute('points', pts.join(' '));
|
||||||
group.style.display = '';
|
group.style.display = '';
|
||||||
if (parts.label) positionCallout(parts.label, cx / n, cy / n - 6, true);
|
if (parts.label)
|
||||||
|
positionCallout(parts.label, cx / n, cy / n - 6, true);
|
||||||
} else {
|
} else {
|
||||||
group.style.display = 'none';
|
group.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -388,16 +482,27 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
const b = project(anno.to.lon, anno.to.lat);
|
const b = project(anno.to.lon, anno.to.lat);
|
||||||
if (a && b) {
|
if (a && b) {
|
||||||
// gentle arc so the connector reads as a drawn gesture, not a ruler line
|
// gentle arc so the connector reads as a drawn gesture, not a ruler line
|
||||||
const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2;
|
const mx = (a.x + b.x) / 2;
|
||||||
const dx = b.x - a.x; const dy = b.y - a.y;
|
const my = (a.y + b.y) / 2;
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
const len = Math.hypot(dx, dy) || 1;
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
const bow = Math.min(60, len * 0.18);
|
const bow = Math.min(60, len * 0.18);
|
||||||
const ctrlX = mx - (dy / len) * bow;
|
const ctrlX = mx - (dy / len) * bow;
|
||||||
const ctrlY = my + (dx / len) * bow;
|
const ctrlY = my + (dx / len) * bow;
|
||||||
parts.path.setAttribute('d', `M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`);
|
parts.path.setAttribute(
|
||||||
|
'd',
|
||||||
|
`M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`,
|
||||||
|
);
|
||||||
group.style.display = '';
|
group.style.display = '';
|
||||||
// Anchor the label to the (stable) endpoint midpoint, lifted toward the bow.
|
// Anchor the label to the (stable) endpoint midpoint, lifted toward the bow.
|
||||||
if (parts.label) positionCallout(parts.label, mx + (ctrlX - mx) * 0.5, my + (ctrlY - my) * 0.5 - 8, true);
|
if (parts.label)
|
||||||
|
positionCallout(
|
||||||
|
parts.label,
|
||||||
|
mx + (ctrlX - mx) * 0.5,
|
||||||
|
my + (ctrlY - my) * 0.5 - 8,
|
||||||
|
true,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
group.style.display = 'none';
|
group.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -412,13 +517,22 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
// dots at each resolvable waypoint; hide the rest
|
// dots at each resolvable waypoint; hide the rest
|
||||||
parts.dots.forEach((dot, i) => {
|
parts.dots.forEach((dot, i) => {
|
||||||
const p = projected[i];
|
const p = projected[i];
|
||||||
if (p) { dot.setAttribute('cx', p.x.toFixed(1)); dot.setAttribute('cy', p.y.toFixed(1)); dot.style.display = ''; }
|
if (p) {
|
||||||
else { dot.style.display = 'none'; }
|
dot.setAttribute('cx', p.x.toFixed(1));
|
||||||
|
dot.setAttribute('cy', p.y.toFixed(1));
|
||||||
|
dot.style.display = '';
|
||||||
|
} else {
|
||||||
|
dot.style.display = 'none';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
group.style.display = '';
|
group.style.display = '';
|
||||||
// label at the middle waypoint
|
// label at the middle waypoint
|
||||||
const mid = projected.filter(Boolean)[Math.floor(projected.filter(Boolean).length / 2)];
|
const mid =
|
||||||
if (parts.label && mid) positionCallout(parts.label, mid.x, mid.y - 12, true);
|
projected.filter(Boolean)[
|
||||||
|
Math.floor(projected.filter(Boolean).length / 2)
|
||||||
|
];
|
||||||
|
if (parts.label && mid)
|
||||||
|
positionCallout(parts.label, mid.x, mid.y - 12, true);
|
||||||
} else {
|
} else {
|
||||||
group.style.display = 'none';
|
group.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -427,16 +541,40 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
if (p) {
|
if (p) {
|
||||||
group.style.display = '';
|
group.style.display = '';
|
||||||
const baseDotR = anno.type === 'label' ? LABEL_DOT_R : DOT_R;
|
const baseDotR = anno.type === 'label' ? LABEL_DOT_R : DOT_R;
|
||||||
if (parts.dot) { parts.dot.setAttribute('cx', p.x.toFixed(1)); parts.dot.setAttribute('cy', p.y.toFixed(1)); parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1)); }
|
if (parts.dot) {
|
||||||
if (parts.ringOuter) { parts.ringOuter.setAttribute('cx', p.x.toFixed(1)); parts.ringOuter.setAttribute('cy', p.y.toFixed(1)); parts.ringOuter.setAttribute('r', (RING_OUTER_R * mScale).toFixed(1)); }
|
parts.dot.setAttribute('cx', p.x.toFixed(1));
|
||||||
if (parts.ringInner) { parts.ringInner.setAttribute('cx', p.x.toFixed(1)); parts.ringInner.setAttribute('cy', p.y.toFixed(1)); parts.ringInner.setAttribute('r', (RING_INNER_R * mScale).toFixed(1)); }
|
parts.dot.setAttribute('cy', p.y.toFixed(1));
|
||||||
|
parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1));
|
||||||
|
}
|
||||||
|
if (parts.ringOuter) {
|
||||||
|
parts.ringOuter.setAttribute('cx', p.x.toFixed(1));
|
||||||
|
parts.ringOuter.setAttribute('cy', p.y.toFixed(1));
|
||||||
|
parts.ringOuter.setAttribute(
|
||||||
|
'r',
|
||||||
|
(RING_OUTER_R * mScale).toFixed(1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (parts.ringInner) {
|
||||||
|
parts.ringInner.setAttribute('cx', p.x.toFixed(1));
|
||||||
|
parts.ringInner.setAttribute('cy', p.y.toFixed(1));
|
||||||
|
parts.ringInner.setAttribute(
|
||||||
|
'r',
|
||||||
|
(RING_INNER_R * mScale).toFixed(1),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (parts.label) {
|
if (parts.label) {
|
||||||
// Keep the callout tucked near the (now smaller) reticle by scaling its offset.
|
// Keep the callout tucked near the (now smaller) reticle by scaling its offset.
|
||||||
const lx = p.x + 18 * mScale; const ly = p.y - 34 * mScale;
|
const lx = p.x + 18 * mScale;
|
||||||
|
const ly = p.y - 34 * mScale;
|
||||||
positionCallout(parts.label, lx, ly, false);
|
positionCallout(parts.label, lx, ly, false);
|
||||||
if (parts.leader) {
|
if (parts.leader) {
|
||||||
parts.leader.setAttribute('x1', p.x.toFixed(1)); parts.leader.setAttribute('y1', p.y.toFixed(1));
|
parts.leader.setAttribute('x1', p.x.toFixed(1));
|
||||||
parts.leader.setAttribute('x2', lx.toFixed(1)); parts.leader.setAttribute('y2', (ly + parts.label.height).toFixed(1));
|
parts.leader.setAttribute('y1', p.y.toFixed(1));
|
||||||
|
parts.leader.setAttribute('x2', lx.toFixed(1));
|
||||||
|
parts.leader.setAttribute(
|
||||||
|
'y2',
|
||||||
|
(ly + parts.label.height).toFixed(1),
|
||||||
|
);
|
||||||
parts.label._leader = parts.leader; // so de-collision can re-point it
|
parts.label._leader = parts.leader; // so de-collision can re-point it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,7 +588,12 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
const placed = [];
|
const placed = [];
|
||||||
for (const rec of records.values()) {
|
for (const rec of records.values()) {
|
||||||
const cal = rec.parts.label;
|
const cal = rec.parts.label;
|
||||||
if (cal && cal.sized && rec.group.style.display !== 'none' && Number.isFinite(cal._x)) {
|
if (
|
||||||
|
cal &&
|
||||||
|
cal.sized &&
|
||||||
|
rec.group.style.display !== 'none' &&
|
||||||
|
Number.isFinite(cal._x)
|
||||||
|
) {
|
||||||
placed.push(cal);
|
placed.push(cal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,7 +614,11 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
let overlapping = false;
|
let overlapping = false;
|
||||||
if (trackedRect) {
|
if (trackedRect) {
|
||||||
let bb = null;
|
let bb = null;
|
||||||
try { bb = group.getBBox(); } catch { bb = null; }
|
try {
|
||||||
|
bb = group.getBBox();
|
||||||
|
} catch {
|
||||||
|
bb = null;
|
||||||
|
}
|
||||||
if (bb && (bb.width !== 0 || bb.height !== 0)) {
|
if (bb && (bb.width !== 0 || bb.height !== 0)) {
|
||||||
// HYSTERESIS: a mark that's already hidden must clear the rect by
|
// HYSTERESIS: a mark that's already hidden must clear the rect by
|
||||||
// TRACKED_HYSTERESIS_PX before it starts showing again; one that's visible fades
|
// TRACKED_HYSTERESIS_PX before it starts showing again; one that's visible fades
|
||||||
@@ -479,8 +626,11 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
// frame-to-frame (the pulsing reticle stroke + de-collided cards keep the bbox in
|
// frame-to-frame (the pulsing reticle stroke + de-collided cards keep the bbox in
|
||||||
// motion), which was the source of the flicker.
|
// motion), which was the source of the flicker.
|
||||||
const m = cur < 0.5 ? TRACKED_HYSTERESIS_PX : 0;
|
const m = cur < 0.5 ? TRACKED_HYSTERESIS_PX : 0;
|
||||||
overlapping = bb.x <= trackedRect.right + m && bb.x + bb.width >= trackedRect.left - m
|
overlapping =
|
||||||
&& bb.y <= trackedRect.bottom + m && bb.y + bb.height >= trackedRect.top - m;
|
bb.x <= trackedRect.right + m &&
|
||||||
|
bb.x + bb.width >= trackedRect.left - m &&
|
||||||
|
bb.y <= trackedRect.bottom + m &&
|
||||||
|
bb.y + bb.height >= trackedRect.top - m;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const target = overlapping ? 0 : 1;
|
const target = overlapping ? 0 : 1;
|
||||||
@@ -490,7 +640,10 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
// (The CSS `transition: opacity` was removed from .gev-anno so this per-frame JS ease
|
// (The CSS `transition: opacity` was removed from .gev-anno so this per-frame JS ease
|
||||||
// is the ONLY easing — previously the two fought and produced an opacity oscillation.)
|
// is the ONLY easing — previously the two fought and produced an opacity oscillation.)
|
||||||
if (rec._trackedFade !== 1) {
|
if (rec._trackedFade !== 1) {
|
||||||
group.setAttribute('opacity', String((anno.alpha ?? 1) * rec._trackedFade));
|
group.setAttribute(
|
||||||
|
'opacity',
|
||||||
|
String((anno.alpha ?? 1) * rec._trackedFade),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -501,7 +654,13 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
rec.group.classList.remove('gev-in');
|
rec.group.classList.remove('gev-in');
|
||||||
rec.group.classList.add('gev-out');
|
rec.group.classList.add('gev-out');
|
||||||
const node = rec.group;
|
const node = rec.group;
|
||||||
window.setTimeout(() => { try { node.remove(); } catch { /* gone */ } }, 360);
|
window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
node.remove();
|
||||||
|
} catch {
|
||||||
|
/* gone */
|
||||||
|
}
|
||||||
|
}, 360);
|
||||||
records.delete(anno.id);
|
records.delete(anno.id);
|
||||||
// Board emptied → drop accumulated height samples (natural reset point).
|
// Board emptied → drop accumulated height samples (natural reset point).
|
||||||
if (records.size === 0) heightCache.clear();
|
if (records.size === 0) heightCache.clear();
|
||||||
@@ -513,8 +672,16 @@ export function createScreenAnnotationRenderer(viewer, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function destroy() {
|
function destroy() {
|
||||||
try { scene.postRender.removeEventListener(onPostRender); } catch { /* torn down */ }
|
try {
|
||||||
try { layer.remove(); } catch { /* gone */ }
|
scene.postRender.removeEventListener(onPostRender);
|
||||||
|
} catch {
|
||||||
|
/* torn down */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
layer.remove();
|
||||||
|
} catch {
|
||||||
|
/* gone */
|
||||||
|
}
|
||||||
records.clear();
|
records.clear();
|
||||||
heightCache.clear();
|
heightCache.clear();
|
||||||
}
|
}
|
||||||
@@ -562,7 +729,15 @@ function makeCallout(text, c) {
|
|||||||
node.appendChild(rect);
|
node.appendChild(rect);
|
||||||
node.appendChild(accent);
|
node.appendChild(accent);
|
||||||
node.appendChild(t);
|
node.appendChild(t);
|
||||||
const callout = { node, rect, text: t, accent, width: 0, height: 26, sized: false };
|
const callout = {
|
||||||
|
node,
|
||||||
|
rect,
|
||||||
|
text: t,
|
||||||
|
accent,
|
||||||
|
width: 0,
|
||||||
|
height: 26,
|
||||||
|
sized: false,
|
||||||
|
};
|
||||||
// size the card to the text once it is in the DOM
|
// size the card to the text once it is in the DOM
|
||||||
requestAnimationFrame(() => sizeCallout(callout));
|
requestAnimationFrame(() => sizeCallout(callout));
|
||||||
return callout;
|
return callout;
|
||||||
@@ -581,7 +756,9 @@ function sizeCallout(callout) {
|
|||||||
callout.width = w;
|
callout.width = w;
|
||||||
callout.height = h;
|
callout.height = h;
|
||||||
callout.sized = true;
|
callout.sized = true;
|
||||||
} catch { /* not laid out yet */ }
|
} catch {
|
||||||
|
/* not laid out yet */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function positionCallout(callout, x, y, center) {
|
function positionCallout(callout, x, y, center) {
|
||||||
@@ -589,7 +766,10 @@ function positionCallout(callout, x, y, center) {
|
|||||||
const tx = center ? x - callout.width / 2 : x;
|
const tx = center ? x - callout.width / 2 : x;
|
||||||
callout._x = tx;
|
callout._x = tx;
|
||||||
callout._y = y;
|
callout._y = y;
|
||||||
callout.node.setAttribute('transform', `translate(${tx.toFixed(1)}, ${y.toFixed(1)})`);
|
callout.node.setAttribute(
|
||||||
|
'transform',
|
||||||
|
`translate(${tx.toFixed(1)}, ${y.toFixed(1)})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -607,7 +787,10 @@ function decollideCallouts(callouts) {
|
|||||||
const overlapY = a._y < b._y + b.height && a._y + a.height > b._y;
|
const overlapY = a._y < b._y + b.height && a._y + a.height > b._y;
|
||||||
if (overlapX && overlapY) {
|
if (overlapX && overlapY) {
|
||||||
a._y = b._y + b.height + 5;
|
a._y = b._y + b.height + 5;
|
||||||
a.node.setAttribute('transform', `translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`);
|
a.node.setAttribute(
|
||||||
|
'transform',
|
||||||
|
`translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`,
|
||||||
|
);
|
||||||
if (a._leader) {
|
if (a._leader) {
|
||||||
a._leader.setAttribute('x2', a._x.toFixed(1));
|
a._leader.setAttribute('x2', a._x.toFixed(1));
|
||||||
a._leader.setAttribute('y2', a._y.toFixed(1));
|
a._leader.setAttribute('y2', a._y.toFixed(1));
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ export function createWorldAnnotationRenderer(viewer) {
|
|||||||
ensureFlowFabricRegistered();
|
ensureFlowFabricRegistered();
|
||||||
|
|
||||||
function colorFor(anno) {
|
function colorFor(anno) {
|
||||||
return Cesium.Color.fromCssColorString(PALETTE[anno.color] || PALETTE.primary);
|
return Cesium.Color.fromCssColorString(
|
||||||
|
PALETTE[anno.color] || PALETTE.primary,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Target-ring radius (meters) scaled to camera height so it reads at any
|
// Target-ring radius (meters) scaled to camera height so it reads at any
|
||||||
@@ -86,7 +88,11 @@ export function createWorldAnnotationRenderer(viewer) {
|
|||||||
const entities = [];
|
const entities = [];
|
||||||
anno._entities = entities;
|
anno._entities = entities;
|
||||||
|
|
||||||
if (anno.ring && anno.ring.length >= 3 && anno.footprintKind === 'building') {
|
if (
|
||||||
|
anno.ring &&
|
||||||
|
anno.ring.length >= 3 &&
|
||||||
|
anno.footprintKind === 'building'
|
||||||
|
) {
|
||||||
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION
|
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION
|
||||||
// volume that tints the real photogrammetry mesh (dome, walls, roof —
|
// volume that tints the real photogrammetry mesh (dome, walls, roof —
|
||||||
// everything inside the column) amber and pulses. A flat footprint
|
// everything inside the column) amber and pulses. A flat footprint
|
||||||
@@ -94,29 +100,39 @@ export function createWorldAnnotationRenderer(viewer) {
|
|||||||
// actual tiles. A faint wireframe cage rides on top as a secondary cue.
|
// actual tiles. A faint wireframe cage rides on top as a secondary cue.
|
||||||
const buffered = bufferRing(anno.ring, 3);
|
const buffered = bufferRing(anno.ring, 3);
|
||||||
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
|
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
|
||||||
const groundH = sampleGroundOutside(viewer.scene, anno.ring)
|
const groundH =
|
||||||
?? (Number.isFinite(anno.anchor.height) ? anno.anchor.height - (anno.buildingHeight || 25) : 0);
|
sampleGroundOutside(viewer.scene, anno.ring) ??
|
||||||
|
(Number.isFinite(anno.anchor.height)
|
||||||
|
? anno.anchor.height - (anno.buildingHeight || 25)
|
||||||
|
: 0);
|
||||||
const baseH = groundH - 3;
|
const baseH = groundH - 3;
|
||||||
// Tall headroom is free: classification only colors where tiles exist, so
|
// Tall headroom is free: classification only colors where tiles exist, so
|
||||||
// empty air above the roof isn't tinted — this just guarantees we cover
|
// empty air above the roof isn't tinted — this just guarantees we cover
|
||||||
// under-tagged building heights.
|
// under-tagged building heights.
|
||||||
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
|
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
|
||||||
// 1) Classification volume — tints the mesh amber, pulsing.
|
// 1) Classification volume — tints the mesh amber, pulsing.
|
||||||
entities.push(dataSource.entities.add({
|
entities.push(
|
||||||
|
dataSource.entities.add({
|
||||||
polygon: {
|
polygon: {
|
||||||
hierarchy: new Cesium.PolygonHierarchy(positions),
|
hierarchy: new Cesium.PolygonHierarchy(positions),
|
||||||
height: baseH,
|
height: baseH,
|
||||||
extrudedHeight: topH,
|
extrudedHeight: topH,
|
||||||
perPositionHeight: false,
|
perPositionHeight: false,
|
||||||
classificationType: CLASSIFY,
|
classificationType: CLASSIFY,
|
||||||
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.45, pulse: true })),
|
material: new Cesium.ColorMaterialProperty(
|
||||||
|
liveColor(anno, base, { alpha: 0.45, pulse: true }),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
|
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
|
||||||
// the footprint so the extruded outline draws ~14 clean verticals, not one
|
// the footprint so the extruded outline draws ~14 clean verticals, not one
|
||||||
// per (often 100+) ring vertex.
|
// per (often 100+) ring vertex.
|
||||||
const cagePositions = Cesium.Cartesian3.fromDegreesArray(decimateRing(buffered, 14).flat());
|
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
|
||||||
entities.push(dataSource.entities.add({
|
decimateRing(buffered, 14).flat(),
|
||||||
|
);
|
||||||
|
entities.push(
|
||||||
|
dataSource.entities.add({
|
||||||
polygon: {
|
polygon: {
|
||||||
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
|
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
|
||||||
height: baseH,
|
height: baseH,
|
||||||
@@ -126,61 +142,92 @@ export function createWorldAnnotationRenderer(viewer) {
|
|||||||
outline: true,
|
outline: true,
|
||||||
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
|
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
|
||||||
},
|
},
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
// 3) Crisp draped base outline so the footprint reads on the ground.
|
// 3) Crisp draped base outline so the footprint reads on the ground.
|
||||||
entities.push(dataSource.entities.add({
|
entities.push(
|
||||||
|
dataSource.entities.add({
|
||||||
polyline: {
|
polyline: {
|
||||||
positions,
|
positions,
|
||||||
width: 3,
|
width: 3,
|
||||||
material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.25, color: liveColor(anno, base, { alpha: 0.7 }) }),
|
material: new Cesium.PolylineGlowMaterialProperty({
|
||||||
|
glowPower: 0.25,
|
||||||
|
color: liveColor(anno, base, { alpha: 0.7 }),
|
||||||
|
}),
|
||||||
clampToGround: true,
|
clampToGround: true,
|
||||||
classificationType: CLASSIFY,
|
classificationType: CLASSIFY,
|
||||||
},
|
},
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||||
} else if (anno.ring && anno.ring.length >= 3) {
|
} else if (anno.ring && anno.ring.length >= 3) {
|
||||||
// Larger area (district / compound / park) → flat fill draped on the tiles
|
// Larger area (district / compound / park) → flat fill draped on the tiles
|
||||||
// + a glowing outline. Draping is right here: you can't extrude a whole
|
// + a glowing outline. Draping is right here: you can't extrude a whole
|
||||||
// neighbourhood, and the GIS overlay shows the boundary clearly.
|
// neighbourhood, and the GIS overlay shows the boundary clearly.
|
||||||
const fillPositions = Cesium.Cartesian3.fromDegreesArray(anno.ring.flat());
|
const fillPositions = Cesium.Cartesian3.fromDegreesArray(
|
||||||
entities.push(dataSource.entities.add({
|
anno.ring.flat(),
|
||||||
|
);
|
||||||
|
entities.push(
|
||||||
|
dataSource.entities.add({
|
||||||
polygon: {
|
polygon: {
|
||||||
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
|
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
|
||||||
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
||||||
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: anno.synthesized ? 0.10 : 0.20, pulse: true })),
|
material: new Cesium.ColorMaterialProperty(
|
||||||
|
liveColor(anno, base, {
|
||||||
|
alpha: anno.synthesized ? 0.1 : 0.2,
|
||||||
|
pulse: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
classificationType: CLASSIFY,
|
classificationType: CLASSIFY,
|
||||||
},
|
},
|
||||||
}));
|
}),
|
||||||
entities.push(dataSource.entities.add({
|
);
|
||||||
|
entities.push(
|
||||||
|
dataSource.entities.add({
|
||||||
polyline: {
|
polyline: {
|
||||||
positions: fillPositions,
|
positions: fillPositions,
|
||||||
width: 6,
|
width: 6,
|
||||||
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
||||||
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
||||||
material: anno.synthesized
|
material: anno.synthesized
|
||||||
? new Cesium.PolylineDashMaterialProperty({ color: liveColor(anno, base, { alpha: 0.95 }), dashLength: 24 })
|
? new Cesium.PolylineDashMaterialProperty({
|
||||||
: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.35, color: liveColor(anno, base, { alpha: 1 }) }),
|
color: liveColor(anno, base, { alpha: 0.95 }),
|
||||||
|
dashLength: 24,
|
||||||
|
})
|
||||||
|
: new Cesium.PolylineGlowMaterialProperty({
|
||||||
|
glowPower: 0.35,
|
||||||
|
color: liveColor(anno, base, { alpha: 1 }),
|
||||||
|
}),
|
||||||
clampToGround: true,
|
clampToGround: true,
|
||||||
classificationType: CLASSIFY,
|
classificationType: CLASSIFY,
|
||||||
},
|
},
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||||
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
|
} else if (
|
||||||
|
anno.type === 'route' &&
|
||||||
|
Array.isArray(anno.path) &&
|
||||||
|
anno.path.length >= 2
|
||||||
|
) {
|
||||||
// A real path (street-following) draped on the 3D tiles, with dashes that
|
// A real path (street-following) draped on the 3D tiles, with dashes that
|
||||||
// FLOW toward the destination (custom st.s + time material; works on the
|
// FLOW toward the destination (custom st.s + time material; works on the
|
||||||
// clamped/classified ground polyline).
|
// clamped/classified ground polyline).
|
||||||
const positions = Cesium.Cartesian3.fromDegreesArray(
|
const positions = Cesium.Cartesian3.fromDegreesArray(
|
||||||
anno.path.flatMap((p) => [p.lon, p.lat]),
|
anno.path.flatMap((p) => [p.lon, p.lat]),
|
||||||
);
|
);
|
||||||
entities.push(dataSource.entities.add({
|
entities.push(
|
||||||
|
dataSource.entities.add({
|
||||||
polyline: {
|
polyline: {
|
||||||
positions,
|
positions,
|
||||||
width: 9,
|
width: 9,
|
||||||
material: new FlowMaterialProperty(PALETTE[anno.color] || PALETTE.primary),
|
material: new FlowMaterialProperty(
|
||||||
|
PALETTE[anno.color] || PALETTE.primary,
|
||||||
|
),
|
||||||
clampToGround: true,
|
clampToGround: true,
|
||||||
classificationType: CLASSIFY,
|
classificationType: CLASSIFY,
|
||||||
},
|
},
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||||
} else if (anno.type === 'arrow' && anno.to) {
|
} else if (anno.type === 'arrow' && anno.to) {
|
||||||
// Connector draped across the ground from origin to destination.
|
// Connector draped across the ground from origin to destination.
|
||||||
@@ -188,24 +235,30 @@ export function createWorldAnnotationRenderer(viewer) {
|
|||||||
Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
||||||
Cesium.Cartesian3.fromDegrees(anno.to.lon, anno.to.lat),
|
Cesium.Cartesian3.fromDegrees(anno.to.lon, anno.to.lat),
|
||||||
];
|
];
|
||||||
entities.push(dataSource.entities.add({
|
entities.push(
|
||||||
|
dataSource.entities.add({
|
||||||
polyline: {
|
polyline: {
|
||||||
positions,
|
positions,
|
||||||
width: 16,
|
width: 16,
|
||||||
material: new Cesium.PolylineArrowMaterialProperty(liveColor(anno, base, { alpha: 0.95 })),
|
material: new Cesium.PolylineArrowMaterialProperty(
|
||||||
|
liveColor(anno, base, { alpha: 0.95 }),
|
||||||
|
),
|
||||||
clampToGround: true,
|
clampToGround: true,
|
||||||
classificationType: CLASSIFY,
|
classificationType: CLASSIFY,
|
||||||
},
|
},
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
if (anno.label) {
|
if (anno.label) {
|
||||||
const mid = {
|
const mid = {
|
||||||
lon: (anno.anchor.lon + anno.to.lon) / 2,
|
lon: (anno.anchor.lon + anno.to.lon) / 2,
|
||||||
lat: (anno.anchor.lat + anno.to.lat) / 2,
|
lat: (anno.anchor.lat + anno.to.lat) / 2,
|
||||||
};
|
};
|
||||||
entities.push(dataSource.entities.add({
|
entities.push(
|
||||||
|
dataSource.entities.add({
|
||||||
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
|
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
|
||||||
label: labelGraphic(anno, base),
|
label: labelGraphic(anno, base),
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// pin / highlight / label — a camera-proportional target ring + a marker.
|
// pin / highlight / label — a camera-proportional target ring + a marker.
|
||||||
@@ -213,16 +266,23 @@ export function createWorldAnnotationRenderer(viewer) {
|
|||||||
// Radius scales with camera height so the ring reads at any altitude.
|
// Radius scales with camera height so the ring reads at any altitude.
|
||||||
// semiMajor === semiMinor is required AND must hold every frame, so both
|
// semiMajor === semiMinor is required AND must hold every frame, so both
|
||||||
// axes read the SAME ringRadius() (camera height is constant per frame).
|
// axes read the SAME ringRadius() (camera height is constant per frame).
|
||||||
entities.push(dataSource.entities.add({
|
entities.push(
|
||||||
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
dataSource.entities.add({
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(
|
||||||
|
anno.anchor.lon,
|
||||||
|
anno.anchor.lat,
|
||||||
|
),
|
||||||
ellipse: {
|
ellipse: {
|
||||||
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
|
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
|
||||||
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
|
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
|
||||||
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.38, pulse: true })),
|
material: new Cesium.ColorMaterialProperty(
|
||||||
|
liveColor(anno, base, { alpha: 0.38, pulse: true }),
|
||||||
|
),
|
||||||
outline: false,
|
outline: false,
|
||||||
classificationType: CLASSIFY,
|
classificationType: CLASSIFY,
|
||||||
},
|
},
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
entities.push(labelMarker(anno, base, { point: true }));
|
entities.push(labelMarker(anno, base, { point: true }));
|
||||||
}
|
}
|
||||||
@@ -232,14 +292,16 @@ export function createWorldAnnotationRenderer(viewer) {
|
|||||||
function labelMarker(anno, base, { point }) {
|
function labelMarker(anno, base, { point }) {
|
||||||
return dataSource.entities.add({
|
return dataSource.entities.add({
|
||||||
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
||||||
point: point ? {
|
point: point
|
||||||
|
? {
|
||||||
pixelSize: anno.type === 'label' ? 8 : 14,
|
pixelSize: anno.type === 'label' ? 8 : 14,
|
||||||
color: liveColor(anno, base, { alpha: 1 }),
|
color: liveColor(anno, base, { alpha: 1 }),
|
||||||
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
||||||
outlineWidth: 3,
|
outlineWidth: 3,
|
||||||
heightReference: CLAMP,
|
heightReference: CLAMP,
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
} : undefined,
|
}
|
||||||
|
: undefined,
|
||||||
label: anno.label ? labelGraphic(anno, base) : undefined,
|
label: anno.label ? labelGraphic(anno, base) : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -256,7 +318,10 @@ export function createWorldAnnotationRenderer(viewer) {
|
|||||||
pixelOffset: new Cesium.Cartesian2(0, -16),
|
pixelOffset: new Cesium.Cartesian2(0, -16),
|
||||||
showBackground: true,
|
showBackground: true,
|
||||||
backgroundColor: new Cesium.CallbackProperty(
|
backgroundColor: new Cesium.CallbackProperty(
|
||||||
() => Cesium.Color.fromCssColorString('#0b1622').withAlpha(0.72 * (anno.alpha ?? 1)),
|
() =>
|
||||||
|
Cesium.Color.fromCssColorString('#0b1622').withAlpha(
|
||||||
|
0.72 * (anno.alpha ?? 1),
|
||||||
|
),
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
backgroundPadding: new Cesium.Cartesian2(8, 5),
|
backgroundPadding: new Cesium.Cartesian2(8, 5),
|
||||||
@@ -357,8 +422,16 @@ export function FlowMaterialProperty(colorCss) {
|
|||||||
this._definitionChanged = new Cesium.Event();
|
this._definitionChanged = new Cesium.Event();
|
||||||
}
|
}
|
||||||
Object.defineProperties(FlowMaterialProperty.prototype, {
|
Object.defineProperties(FlowMaterialProperty.prototype, {
|
||||||
isConstant: { get() { return false; } }, // re-evaluated each frame → it animates
|
isConstant: {
|
||||||
definitionChanged: { get() { return this._definitionChanged; } },
|
get() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}, // re-evaluated each frame → it animates
|
||||||
|
definitionChanged: {
|
||||||
|
get() {
|
||||||
|
return this._definitionChanged;
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
FlowMaterialProperty.prototype.getType = function getType() {
|
FlowMaterialProperty.prototype.getType = function getType() {
|
||||||
return 'GevRouteFlow';
|
return 'GevRouteFlow';
|
||||||
@@ -394,10 +467,17 @@ function decimateRing(ring, n) {
|
|||||||
* tiles under those points aren't loaded yet.
|
* tiles under those points aren't loaded yet.
|
||||||
*/
|
*/
|
||||||
function sampleGroundOutside(scene, ring) {
|
function sampleGroundOutside(scene, ring) {
|
||||||
if (!scene?.clampToHeightSupported || typeof scene.clampToHeight !== 'function') return null;
|
if (
|
||||||
|
!scene?.clampToHeightSupported ||
|
||||||
|
typeof scene.clampToHeight !== 'function'
|
||||||
|
)
|
||||||
|
return null;
|
||||||
let clon = 0;
|
let clon = 0;
|
||||||
let clat = 0;
|
let clat = 0;
|
||||||
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
|
for (const [lon, lat] of ring) {
|
||||||
|
clon += lon;
|
||||||
|
clat += lat;
|
||||||
|
}
|
||||||
clon /= ring.length;
|
clon /= ring.length;
|
||||||
clat /= ring.length;
|
clat /= ring.length;
|
||||||
const latS = 111320;
|
const latS = 111320;
|
||||||
@@ -407,7 +487,16 @@ function sampleGroundOutside(scene, ring) {
|
|||||||
maxR = Math.max(maxR, Math.hypot((lon - clon) * lonS, (lat - clat) * latS));
|
maxR = Math.max(maxR, Math.hypot((lon - clon) * lonS, (lat - clat) * latS));
|
||||||
}
|
}
|
||||||
const out = maxR * 1.5 + 12;
|
const out = maxR * 1.5 + 12;
|
||||||
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7, 0.7], [-0.7, -0.7], [0.7, -0.7], [-0.7, 0.7]];
|
const dirs = [
|
||||||
|
[1, 0],
|
||||||
|
[-1, 0],
|
||||||
|
[0, 1],
|
||||||
|
[0, -1],
|
||||||
|
[0.7, 0.7],
|
||||||
|
[-0.7, -0.7],
|
||||||
|
[0.7, -0.7],
|
||||||
|
[-0.7, 0.7],
|
||||||
|
];
|
||||||
const samples = [];
|
const samples = [];
|
||||||
for (const [dx, dy] of dirs) {
|
for (const [dx, dy] of dirs) {
|
||||||
const lon = clon + (dx * out) / lonS;
|
const lon = clon + (dx * out) / lonS;
|
||||||
@@ -418,7 +507,9 @@ function sampleGroundOutside(scene, ring) {
|
|||||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
const h = Cesium.Cartographic.fromCartesian(c).height;
|
||||||
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
|
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
|
||||||
}
|
}
|
||||||
} catch { /* tile not ready */ }
|
} catch {
|
||||||
|
/* tile not ready */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!samples.length) return null;
|
if (!samples.length) return null;
|
||||||
samples.sort((a, b) => a - b);
|
samples.sort((a, b) => a - b);
|
||||||
@@ -433,7 +524,10 @@ function sampleGroundOutside(scene, ring) {
|
|||||||
function bufferRing(ring, meters) {
|
function bufferRing(ring, meters) {
|
||||||
let clon = 0;
|
let clon = 0;
|
||||||
let clat = 0;
|
let clat = 0;
|
||||||
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
|
for (const [lon, lat] of ring) {
|
||||||
|
clon += lon;
|
||||||
|
clat += lat;
|
||||||
|
}
|
||||||
clon /= ring.length;
|
clon /= ring.length;
|
||||||
clat /= ring.length;
|
clat /= ring.length;
|
||||||
const latScale = 111320;
|
const latScale = 111320;
|
||||||
|
|||||||
+460
-128
File diff suppressed because it is too large.
Load diff
+17
-6
@@ -22,9 +22,11 @@ export function activateCctvCameraFromWorldClick(
|
|||||||
) {
|
) {
|
||||||
if (!cameraId || typeof activate !== 'function') return false;
|
if (!cameraId || typeof activate !== 'function') return false;
|
||||||
if (activate(cameraId) !== CCTV_ACTIVATION_RESULT.ACTIVATED) return false;
|
if (activate(cameraId) !== CCTV_ACTIVATION_RESULT.ACTIVATED) return false;
|
||||||
eventTarget.dispatchEvent(new CustomEvent(CCTV_FOCUS_REQUEST_EVENT, {
|
eventTarget.dispatchEvent(
|
||||||
|
new CustomEvent(CCTV_FOCUS_REQUEST_EVENT, {
|
||||||
detail: { cameraId },
|
detail: { cameraId },
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +38,12 @@ export function activateCctvCameraFromWorldClick(
|
|||||||
* @returns {() => void} Listener disposer.
|
* @returns {() => void} Listener disposer.
|
||||||
*/
|
*/
|
||||||
export function registerCctvFocusRequestListener(eventTarget, listener) {
|
export function registerCctvFocusRequestListener(eventTarget, listener) {
|
||||||
if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener
|
if (
|
||||||
|| typeof listener !== 'function') return () => {};
|
!eventTarget?.addEventListener ||
|
||||||
|
!eventTarget?.removeEventListener ||
|
||||||
|
typeof listener !== 'function'
|
||||||
|
)
|
||||||
|
return () => {};
|
||||||
eventTarget.addEventListener(CCTV_FOCUS_REQUEST_EVENT, listener);
|
eventTarget.addEventListener(CCTV_FOCUS_REQUEST_EVENT, listener);
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -57,11 +63,16 @@ export function registerCctvFocusRequestListener(eventTarget, listener) {
|
|||||||
*/
|
*/
|
||||||
export function routeCctvFocusRequest(event, runExplicitFocus, focusCamera) {
|
export function routeCctvFocusRequest(event, runExplicitFocus, focusCamera) {
|
||||||
const cameraId = event?.detail?.cameraId;
|
const cameraId = event?.detail?.cameraId;
|
||||||
if (typeof cameraId !== 'string' || !cameraId || typeof runExplicitFocus !== 'function') {
|
if (
|
||||||
|
typeof cameraId !== 'string' ||
|
||||||
|
!cameraId ||
|
||||||
|
typeof runExplicitFocus !== 'function'
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return runExplicitFocus(
|
return runExplicitFocus(
|
||||||
() => cameraId,
|
() => cameraId,
|
||||||
(selectedId) => focusCamera(selectedId, CCTV_WORLD_CLICK_FOCUS_DURATION_SEC),
|
(selectedId) =>
|
||||||
|
focusCamera(selectedId, CCTV_WORLD_CLICK_FOCUS_DURATION_SEC),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+186
-56
@@ -90,16 +90,30 @@ export function getKeyholeGeometry(width, height) {
|
|||||||
* floor through a band derived from the live keyhole radius.
|
* floor through a band derived from the live keyhole radius.
|
||||||
*/
|
*/
|
||||||
export function keyholeLabelAlpha(labelX, labelY, width, height) {
|
export function keyholeLabelAlpha(labelX, labelY, width, height) {
|
||||||
return keyholeLabelAlphaFromGeometry(labelX, labelY, getKeyholeGeometry(width, height));
|
return keyholeLabelAlphaFromGeometry(
|
||||||
|
labelX,
|
||||||
|
labelY,
|
||||||
|
getKeyholeGeometry(width, height),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute keyhole opacity from geometry already cached by a hot render loop. */
|
/** Compute keyhole opacity from geometry already cached by a hot render loop. */
|
||||||
export function keyholeLabelAlphaFromGeometry(labelX, labelY, geometry) {
|
export function keyholeLabelAlphaFromGeometry(labelX, labelY, geometry) {
|
||||||
if (!geometry || !(geometry.radius > 0) || !Number.isFinite(labelX) || !Number.isFinite(labelY)) return 0;
|
if (
|
||||||
|
!geometry ||
|
||||||
|
!(geometry.radius > 0) ||
|
||||||
|
!Number.isFinite(labelX) ||
|
||||||
|
!Number.isFinite(labelY)
|
||||||
|
)
|
||||||
|
return 0;
|
||||||
const feather = geometry.featherPx;
|
const feather = geometry.featherPx;
|
||||||
const distance = Math.hypot(labelX - geometry.centerX, labelY - geometry.centerY);
|
const distance = Math.hypot(
|
||||||
|
labelX - geometry.centerX,
|
||||||
|
labelY - geometry.centerY,
|
||||||
|
);
|
||||||
if (distance <= geometry.radius) return 1;
|
if (distance <= geometry.radius) return 1;
|
||||||
if (!(feather > 0) || distance >= geometry.radius + feather) return keyholeOutsideOpacity;
|
if (!(feather > 0) || distance >= geometry.radius + feather)
|
||||||
|
return keyholeOutsideOpacity;
|
||||||
const progress = clamp((distance - geometry.radius) / feather, 0, 1);
|
const progress = clamp((distance - geometry.radius) / feather, 0, 1);
|
||||||
return 1 - (1 - keyholeOutsideOpacity) * progress;
|
return 1 - (1 - keyholeOutsideOpacity) * progress;
|
||||||
}
|
}
|
||||||
@@ -131,7 +145,11 @@ export function isCelestialRingStyleSupported(styleName) {
|
|||||||
* @param {number} lastAngle - Previous stable angle in radians.
|
* @param {number} lastAngle - Previous stable angle in radians.
|
||||||
* @returns {{angle:number, opacity:number, stable:boolean}}
|
* @returns {{angle:number, opacity:number, stable:boolean}}
|
||||||
*/
|
*/
|
||||||
export function celestialScreenAngle(rightComponent, upComponent, lastAngle = 0) {
|
export function celestialScreenAngle(
|
||||||
|
rightComponent,
|
||||||
|
upComponent,
|
||||||
|
lastAngle = 0,
|
||||||
|
) {
|
||||||
const planeLength = Math.hypot(rightComponent, upComponent);
|
const planeLength = Math.hypot(rightComponent, upComponent);
|
||||||
if (!Number.isFinite(planeLength) || planeLength < CELESTIAL_PLANE_EPSILON) {
|
if (!Number.isFinite(planeLength) || planeLength < CELESTIAL_PLANE_EPSILON) {
|
||||||
return {
|
return {
|
||||||
@@ -170,26 +188,42 @@ export function isFullGlobeInsideKeyhole(geometry, wasVisible = false) {
|
|||||||
keyholeCenterY,
|
keyholeCenterY,
|
||||||
keyholeRadius,
|
keyholeRadius,
|
||||||
} = geometry || {};
|
} = geometry || {};
|
||||||
const values = [earthCenterX, earthCenterY, earthRadius, keyholeCenterX, keyholeCenterY, keyholeRadius];
|
const values = [
|
||||||
if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0) return false;
|
earthCenterX,
|
||||||
const offset = Math.hypot(earthCenterX - keyholeCenterX, earthCenterY - keyholeCenterY);
|
earthCenterY,
|
||||||
|
earthRadius,
|
||||||
|
keyholeCenterX,
|
||||||
|
keyholeCenterY,
|
||||||
|
keyholeRadius,
|
||||||
|
];
|
||||||
|
if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0)
|
||||||
|
return false;
|
||||||
|
const offset = Math.hypot(
|
||||||
|
earthCenterX - keyholeCenterX,
|
||||||
|
earthCenterY - keyholeCenterY,
|
||||||
|
);
|
||||||
const clearance = keyholeRadius - (offset + earthRadius);
|
const clearance = keyholeRadius - (offset + earthRadius);
|
||||||
return clearance >= (wasVisible ? GLOBE_EXIT_CLEARANCE_PX : GLOBE_ENTER_CLEARANCE_PX);
|
return (
|
||||||
|
clearance >=
|
||||||
|
(wasVisible ? GLOBE_EXIT_CLEARANCE_PX : GLOBE_ENTER_CLEARANCE_PX)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return the Earth-disc radius in CSS pixels for a perspective camera. */
|
/** Return the Earth-disc radius in CSS pixels for a perspective camera. */
|
||||||
export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) {
|
export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) {
|
||||||
const earthRadiusM = Cesium.Ellipsoid.WGS84.maximumRadius;
|
const earthRadiusM = Cesium.Ellipsoid.WGS84.maximumRadius;
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(cameraDistance)
|
!Number.isFinite(cameraDistance) ||
|
||||||
|| cameraDistance <= earthRadiusM
|
cameraDistance <= earthRadiusM ||
|
||||||
|| !(viewportHeight > 0)
|
!(viewportHeight > 0) ||
|
||||||
|| !Number.isFinite(fovy)
|
!Number.isFinite(fovy) ||
|
||||||
|| fovy <= 0
|
fovy <= 0 ||
|
||||||
|| fovy >= Math.PI
|
fovy >= Math.PI
|
||||||
) return null;
|
)
|
||||||
|
return null;
|
||||||
const angularRadius = Math.asin(clamp(earthRadiusM / cameraDistance, 0, 1));
|
const angularRadius = Math.asin(clamp(earthRadiusM / cameraDistance, 0, 1));
|
||||||
const radius = (viewportHeight * 0.5) * Math.tan(angularRadius) / Math.tan(fovy * 0.5);
|
const radius =
|
||||||
|
(viewportHeight * 0.5 * Math.tan(angularRadius)) / Math.tan(fovy * 0.5);
|
||||||
return Number.isFinite(radius) && radius > 0 ? radius : null;
|
return Number.isFinite(radius) && radius > 0 ? radius : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +250,11 @@ export function projectEarthDiscToViewport(
|
|||||||
if (!camera || !scene || !(width > 0) || !(height > 0)) return null;
|
if (!camera || !scene || !(width > 0) || !(height > 0)) return null;
|
||||||
|
|
||||||
const distance = Cesium.Cartesian3.magnitude(camera.positionWC);
|
const distance = Cesium.Cartesian3.magnitude(camera.positionWC);
|
||||||
if (!Number.isFinite(distance) || distance <= Cesium.Ellipsoid.WGS84.maximumRadius) return null;
|
if (
|
||||||
|
!Number.isFinite(distance) ||
|
||||||
|
distance <= Cesium.Ellipsoid.WGS84.maximumRadius
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
|
||||||
const toCenter = Cesium.Cartesian3.negate(
|
const toCenter = Cesium.Cartesian3.negate(
|
||||||
camera.positionWC,
|
camera.positionWC,
|
||||||
@@ -229,9 +267,14 @@ export function projectEarthDiscToViewport(
|
|||||||
Cesium.Cartesian3.ZERO,
|
Cesium.Cartesian3.ZERO,
|
||||||
scratchCenter,
|
scratchCenter,
|
||||||
);
|
);
|
||||||
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) return null;
|
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y))
|
||||||
|
return null;
|
||||||
|
|
||||||
const earthRadius = earthDiscScreenRadius(distance, height, camera.frustum?.fovy);
|
const earthRadius = earthDiscScreenRadius(
|
||||||
|
distance,
|
||||||
|
height,
|
||||||
|
camera.frustum?.fovy,
|
||||||
|
);
|
||||||
if (!earthRadius) return null;
|
if (!earthRadius) return null;
|
||||||
|
|
||||||
const keyhole = getKeyholeGeometry(width, height);
|
const keyhole = getKeyholeGeometry(width, height);
|
||||||
@@ -286,7 +329,12 @@ function drawSunRays(ctx, cx, cy, radius, innerRadius, angle) {
|
|||||||
glow.addColorStop(0.58, 'rgba(255, 235, 188, 0.027)');
|
glow.addColorStop(0.58, 'rgba(255, 235, 188, 0.027)');
|
||||||
glow.addColorStop(1, 'rgba(255, 242, 214, 0)');
|
glow.addColorStop(1, 'rgba(255, 242, 214, 0)');
|
||||||
ctx.fillStyle = glow;
|
ctx.fillStyle = glow;
|
||||||
ctx.fillRect(cx - radius - 4, cy - radius - 4, (radius + 4) * 2, (radius + 4) * 2);
|
ctx.fillRect(
|
||||||
|
cx - radius - 4,
|
||||||
|
cy - radius - 4,
|
||||||
|
(radius + 4) * 2,
|
||||||
|
(radius + 4) * 2,
|
||||||
|
);
|
||||||
|
|
||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
ctx.shadowColor = 'rgba(255, 232, 171, 0.11)';
|
ctx.shadowColor = 'rgba(255, 232, 171, 0.11)';
|
||||||
@@ -299,7 +347,10 @@ function drawSunRays(ctx, cx, cy, radius, innerRadius, angle) {
|
|||||||
const ex = sx + Math.cos(rayAngle) * rayLength;
|
const ex = sx + Math.cos(rayAngle) * rayLength;
|
||||||
const ey = sy + Math.sin(rayAngle) * rayLength;
|
const ey = sy + Math.sin(rayAngle) * rayLength;
|
||||||
const lineGradient = ctx.createLinearGradient(sx, sy, ex, ey);
|
const lineGradient = ctx.createLinearGradient(sx, sy, ex, ey);
|
||||||
lineGradient.addColorStop(0, `rgba(255, 230, 166, ${0.075 - Math.abs(i) * 0.011})`);
|
lineGradient.addColorStop(
|
||||||
|
0,
|
||||||
|
`rgba(255, 230, 166, ${0.075 - Math.abs(i) * 0.011})`,
|
||||||
|
);
|
||||||
lineGradient.addColorStop(1, 'rgba(255, 226, 156, 0)');
|
lineGradient.addColorStop(1, 'rgba(255, 226, 156, 0)');
|
||||||
ctx.strokeStyle = lineGradient;
|
ctx.strokeStyle = lineGradient;
|
||||||
ctx.lineWidth = i === 0 ? 1.8 : 0.9;
|
ctx.lineWidth = i === 0 ? 1.8 : 0.9;
|
||||||
@@ -340,7 +391,8 @@ export class CelestialRing {
|
|||||||
this.viewer = viewer;
|
this.viewer = viewer;
|
||||||
this.enabled = !!enabled;
|
this.enabled = !!enabled;
|
||||||
this.visible = false;
|
this.visible = false;
|
||||||
this._onAutoDisable = typeof onAutoDisable === 'function' ? onAutoDisable : null;
|
this._onAutoDisable =
|
||||||
|
typeof onAutoDisable === 'function' ? onAutoDisable : null;
|
||||||
this._focusInProgress = false;
|
this._focusInProgress = false;
|
||||||
this._ephemerisDirty = true;
|
this._ephemerisDirty = true;
|
||||||
this._ephemerisUpdateCount = 0;
|
this._ephemerisUpdateCount = 0;
|
||||||
@@ -364,7 +416,9 @@ export class CelestialRing {
|
|||||||
this._fixedMatrix = new Cesium.Matrix3();
|
this._fixedMatrix = new Cesium.Matrix3();
|
||||||
|
|
||||||
this._buildDOM();
|
this._buildDOM();
|
||||||
this._removePostRender = viewer.scene.postRender.addEventListener(() => this._draw());
|
this._removePostRender = viewer.scene.postRender.addEventListener(() =>
|
||||||
|
this._draw(),
|
||||||
|
);
|
||||||
// Pre-existing staleness fix (perf wave 2 review): the ephemeris was
|
// Pre-existing staleness fix (perf wave 2 review): the ephemeris was
|
||||||
// sampled once per visible-enable from the FROZEN app clock, so the
|
// sampled once per visible-enable from the FROZEN app clock, so the
|
||||||
// sun/moon markers aged with the app. Resample real wall time each
|
// sun/moon markers aged with the app. Resample real wall time each
|
||||||
@@ -394,18 +448,26 @@ export class CelestialRing {
|
|||||||
|
|
||||||
this._sunCanvas = document.createElement('canvas');
|
this._sunCanvas = document.createElement('canvas');
|
||||||
this._sunCanvas.className = 'celestial-ring-canvas celestial-sun-canvas';
|
this._sunCanvas.className = 'celestial-ring-canvas celestial-sun-canvas';
|
||||||
this._sunCtx = this._sunCanvas.getContext('2d', { alpha: true, desynchronized: true });
|
this._sunCtx = this._sunCanvas.getContext('2d', {
|
||||||
|
alpha: true,
|
||||||
|
desynchronized: true,
|
||||||
|
});
|
||||||
|
|
||||||
this._moonCanvas = document.createElement('canvas');
|
this._moonCanvas = document.createElement('canvas');
|
||||||
this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-canvas';
|
this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-canvas';
|
||||||
this._moonCtx = this._moonCanvas.getContext('2d', { alpha: true, desynchronized: true });
|
this._moonCtx = this._moonCanvas.getContext('2d', {
|
||||||
|
alpha: true,
|
||||||
|
desynchronized: true,
|
||||||
|
});
|
||||||
|
|
||||||
this._sunMarker = document.createElement('span');
|
this._sunMarker = document.createElement('span');
|
||||||
this._sunMarker.className = 'celestial-marker celestial-sun material-symbols-outlined';
|
this._sunMarker.className =
|
||||||
|
'celestial-marker celestial-sun material-symbols-outlined';
|
||||||
this._sunMarker.textContent = 'light_mode';
|
this._sunMarker.textContent = 'light_mode';
|
||||||
|
|
||||||
this._moonMarker = document.createElement('span');
|
this._moonMarker = document.createElement('span');
|
||||||
this._moonMarker.className = 'celestial-marker celestial-moon material-symbols-outlined';
|
this._moonMarker.className =
|
||||||
|
'celestial-marker celestial-moon material-symbols-outlined';
|
||||||
this._moonMarker.textContent = 'dark_mode';
|
this._moonMarker.textContent = 'dark_mode';
|
||||||
|
|
||||||
this._root.append(
|
this._root.append(
|
||||||
@@ -413,7 +475,7 @@ export class CelestialRing {
|
|||||||
this._sunCanvas,
|
this._sunCanvas,
|
||||||
this._moonCanvas,
|
this._moonCanvas,
|
||||||
this._sunMarker,
|
this._sunMarker,
|
||||||
this._moonMarker
|
this._moonMarker,
|
||||||
);
|
);
|
||||||
this.viewer.container.appendChild(this._root);
|
this.viewer.container.appendChild(this._root);
|
||||||
}
|
}
|
||||||
@@ -458,15 +520,24 @@ export class CelestialRing {
|
|||||||
const height = canvas.clientHeight || canvas.height;
|
const height = canvas.clientHeight || canvas.height;
|
||||||
const cartographic = this.viewer.camera.positionCartographic;
|
const cartographic = this.viewer.camera.positionCartographic;
|
||||||
const fovy = this.viewer.camera.frustum?.fovy;
|
const fovy = this.viewer.camera.frustum?.fovy;
|
||||||
if (!(height > 0) || !cartographic || !Number.isFinite(fovy) || fovy <= 0 || fovy >= Math.PI) {
|
if (
|
||||||
|
!(height > 0) ||
|
||||||
|
!cartographic ||
|
||||||
|
!Number.isFinite(fovy) ||
|
||||||
|
fovy <= 0 ||
|
||||||
|
fovy >= Math.PI
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius;
|
const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius;
|
||||||
const keyholeRadius = getKeyholeGeometry(canvas.clientWidth || canvas.width, height).radius;
|
const keyholeRadius = getKeyholeGeometry(
|
||||||
|
canvas.clientWidth || canvas.width,
|
||||||
|
height,
|
||||||
|
).radius;
|
||||||
const targetScreenRadius = keyholeRadius * FULL_GLOBE_RADIUS_RATIO;
|
const targetScreenRadius = keyholeRadius * FULL_GLOBE_RADIUS_RATIO;
|
||||||
const angularRadius = Math.atan(
|
const angularRadius = Math.atan(
|
||||||
(targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5)
|
(targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5),
|
||||||
);
|
);
|
||||||
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
|
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
|
||||||
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
|
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
|
||||||
@@ -480,7 +551,7 @@ export class CelestialRing {
|
|||||||
destination: Cesium.Cartesian3.fromRadians(
|
destination: Cesium.Cartesian3.fromRadians(
|
||||||
cartographic.longitude,
|
cartographic.longitude,
|
||||||
cartographic.latitude,
|
cartographic.latitude,
|
||||||
altitude
|
altitude,
|
||||||
),
|
),
|
||||||
orientation: {
|
orientation: {
|
||||||
heading: this.viewer.camera.heading,
|
heading: this.viewer.camera.heading,
|
||||||
@@ -500,7 +571,10 @@ export class CelestialRing {
|
|||||||
|
|
||||||
/** Clear the backing canvas. */
|
/** Clear the backing canvas. */
|
||||||
_clear() {
|
_clear() {
|
||||||
for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) {
|
for (const [canvas, ctx] of [
|
||||||
|
[this._sunCanvas, this._sunCtx],
|
||||||
|
[this._moonCanvas, this._moonCtx],
|
||||||
|
]) {
|
||||||
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
|
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
}
|
}
|
||||||
this._ringOutline?.style.setProperty('display', 'none');
|
this._ringOutline?.style.setProperty('display', 'none');
|
||||||
@@ -512,12 +586,23 @@ export class CelestialRing {
|
|||||||
/** Resize the canvas backing store while drawing in CSS pixels. */
|
/** Resize the canvas backing store while drawing in CSS pixels. */
|
||||||
_resize(width, height) {
|
_resize(width, height) {
|
||||||
const nativeDpr = Math.max(1, window.devicePixelRatio || 1);
|
const nativeDpr = Math.max(1, window.devicePixelRatio || 1);
|
||||||
const pixelsScale = Math.sqrt(CELESTIAL_MAX_BACKING_PIXELS / (width * height));
|
const pixelsScale = Math.sqrt(
|
||||||
const dimensionScale = CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height);
|
CELESTIAL_MAX_BACKING_PIXELS / (width * height),
|
||||||
const dpr = Math.min(nativeDpr, CELESTIAL_MAX_DEVICE_PIXEL_RATIO, pixelsScale, dimensionScale);
|
);
|
||||||
|
const dimensionScale =
|
||||||
|
CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height);
|
||||||
|
const dpr = Math.min(
|
||||||
|
nativeDpr,
|
||||||
|
CELESTIAL_MAX_DEVICE_PIXEL_RATIO,
|
||||||
|
pixelsScale,
|
||||||
|
dimensionScale,
|
||||||
|
);
|
||||||
const bw = Math.max(1, Math.round(width * dpr));
|
const bw = Math.max(1, Math.round(width * dpr));
|
||||||
const bh = Math.max(1, Math.round(height * dpr));
|
const bh = Math.max(1, Math.round(height * dpr));
|
||||||
for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) {
|
for (const [canvas, ctx] of [
|
||||||
|
[this._sunCanvas, this._sunCtx],
|
||||||
|
[this._moonCanvas, this._moonCtx],
|
||||||
|
]) {
|
||||||
if (canvas.width !== bw || canvas.height !== bh) {
|
if (canvas.width !== bw || canvas.height !== bh) {
|
||||||
canvas.width = bw;
|
canvas.width = bw;
|
||||||
canvas.height = bh;
|
canvas.height = bh;
|
||||||
@@ -536,14 +621,25 @@ export class CelestialRing {
|
|||||||
_updateEphemeris(time) {
|
_updateEphemeris(time) {
|
||||||
if (!this._ephemerisDirty) return true;
|
if (!this._ephemerisDirty) return true;
|
||||||
|
|
||||||
Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(time, this._sunInertial);
|
Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(
|
||||||
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(time, this._moonInertial);
|
time,
|
||||||
const matrix = Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix)
|
this._sunInertial,
|
||||||
|| Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix);
|
);
|
||||||
|
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(
|
||||||
|
time,
|
||||||
|
this._moonInertial,
|
||||||
|
);
|
||||||
|
const matrix =
|
||||||
|
Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix) ||
|
||||||
|
Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix);
|
||||||
if (!matrix) return false;
|
if (!matrix) return false;
|
||||||
|
|
||||||
Cesium.Matrix3.multiplyByVector(matrix, this._sunInertial, this._sunFixed);
|
Cesium.Matrix3.multiplyByVector(matrix, this._sunInertial, this._sunFixed);
|
||||||
Cesium.Matrix3.multiplyByVector(matrix, this._moonInertial, this._moonFixed);
|
Cesium.Matrix3.multiplyByVector(
|
||||||
|
matrix,
|
||||||
|
this._moonInertial,
|
||||||
|
this._moonFixed,
|
||||||
|
);
|
||||||
Cesium.Cartesian3.normalize(this._sunFixed, this._sunFixed);
|
Cesium.Cartesian3.normalize(this._sunFixed, this._sunFixed);
|
||||||
Cesium.Cartesian3.normalize(this._moonFixed, this._moonFixed);
|
Cesium.Cartesian3.normalize(this._moonFixed, this._moonFixed);
|
||||||
this._ephemerisDirty = false;
|
this._ephemerisDirty = false;
|
||||||
@@ -574,8 +670,14 @@ export class CelestialRing {
|
|||||||
const cos = Math.cos(angle);
|
const cos = Math.cos(angle);
|
||||||
const sin = Math.sin(angle);
|
const sin = Math.sin(angle);
|
||||||
const margin = 14;
|
const margin = 14;
|
||||||
const maxRadiusX = Math.abs(cos) > 1e-4 ? (cx - margin) / Math.abs(cos) : Number.POSITIVE_INFINITY;
|
const maxRadiusX =
|
||||||
const maxRadiusY = Math.abs(sin) > 1e-4 ? (cy - margin) / Math.abs(sin) : Number.POSITIVE_INFINITY;
|
Math.abs(cos) > 1e-4
|
||||||
|
? (cx - margin) / Math.abs(cos)
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
const maxRadiusY =
|
||||||
|
Math.abs(sin) > 1e-4
|
||||||
|
? (cy - margin) / Math.abs(sin)
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
const markerRadius = Math.min(radius, maxRadiusX, maxRadiusY);
|
const markerRadius = Math.min(radius, maxRadiusX, maxRadiusY);
|
||||||
marker.style.left = `${cx + cos * markerRadius}px`;
|
marker.style.left = `${cx + cos * markerRadius}px`;
|
||||||
marker.style.top = `${cy + sin * markerRadius}px`;
|
marker.style.top = `${cy + sin * markerRadius}px`;
|
||||||
@@ -596,14 +698,24 @@ export class CelestialRing {
|
|||||||
|
|
||||||
const sunKey = `${outlineKey}:${Math.round(rayInnerRadius)}`;
|
const sunKey = `${outlineKey}:${Math.round(rayInnerRadius)}`;
|
||||||
if (sunKey !== this._sunRenderKey) {
|
if (sunKey !== this._sunRenderKey) {
|
||||||
this._sunCtx.clearRect(0, 0, this._sunCanvas.width, this._sunCanvas.height);
|
this._sunCtx.clearRect(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
this._sunCanvas.width,
|
||||||
|
this._sunCanvas.height,
|
||||||
|
);
|
||||||
drawSunRays(this._sunCtx, cx, cy, radius, rayInnerRadius, 0);
|
drawSunRays(this._sunCtx, cx, cy, radius, rayInnerRadius, 0);
|
||||||
drawTaperedArc(this._sunCtx, cx, cy, radius, 0, '222, 190, 89', 0.56);
|
drawTaperedArc(this._sunCtx, cx, cy, radius, 0, '222, 190, 89', 0.56);
|
||||||
this._sunRenderKey = sunKey;
|
this._sunRenderKey = sunKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (outlineKey !== this._moonRenderKey) {
|
if (outlineKey !== this._moonRenderKey) {
|
||||||
this._moonCtx.clearRect(0, 0, this._moonCanvas.width, this._moonCanvas.height);
|
this._moonCtx.clearRect(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
this._moonCanvas.width,
|
||||||
|
this._moonCanvas.height,
|
||||||
|
);
|
||||||
drawMoonHaze(this._moonCtx, cx, cy, radius, 0);
|
drawMoonHaze(this._moonCtx, cx, cy, radius, 0);
|
||||||
drawTaperedArc(this._moonCtx, cx, cy, radius, 0, '48, 201, 229', 0.42);
|
drawTaperedArc(this._moonCtx, cx, cy, radius, 0, '48, 201, 229', 0.42);
|
||||||
this._moonRenderKey = outlineKey;
|
this._moonRenderKey = outlineKey;
|
||||||
@@ -624,7 +736,9 @@ export class CelestialRing {
|
|||||||
|
|
||||||
const disc = this._projectedEarthDisc(width, height);
|
const disc = this._projectedEarthDisc(width, height);
|
||||||
const wasVisible = this.visible;
|
const wasVisible = this.visible;
|
||||||
const nextVisible = disc ? isFullGlobeInsideKeyhole(disc, wasVisible) : false;
|
const nextVisible = disc
|
||||||
|
? isFullGlobeInsideKeyhole(disc, wasVisible)
|
||||||
|
: false;
|
||||||
this.visible = nextVisible;
|
this.visible = nextVisible;
|
||||||
this._root.classList.toggle('visible', nextVisible);
|
this._root.classList.toggle('visible', nextVisible);
|
||||||
this._root.dataset.globeVisible = String(nextVisible);
|
this._root.dataset.globeVisible = String(nextVisible);
|
||||||
@@ -645,12 +759,12 @@ export class CelestialRing {
|
|||||||
const sunProjection = celestialScreenAngle(
|
const sunProjection = celestialScreenAngle(
|
||||||
Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC),
|
Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC),
|
||||||
Cesium.Cartesian3.dot(this._sunFixed, camera.upWC),
|
Cesium.Cartesian3.dot(this._sunFixed, camera.upWC),
|
||||||
this._sunAngle
|
this._sunAngle,
|
||||||
);
|
);
|
||||||
const moonProjection = celestialScreenAngle(
|
const moonProjection = celestialScreenAngle(
|
||||||
Cesium.Cartesian3.dot(this._moonFixed, camera.rightWC),
|
Cesium.Cartesian3.dot(this._moonFixed, camera.rightWC),
|
||||||
Cesium.Cartesian3.dot(this._moonFixed, camera.upWC),
|
Cesium.Cartesian3.dot(this._moonFixed, camera.upWC),
|
||||||
this._moonAngle
|
this._moonAngle,
|
||||||
);
|
);
|
||||||
if (sunProjection.stable) this._sunAngle = sunProjection.angle;
|
if (sunProjection.stable) this._sunAngle = sunProjection.angle;
|
||||||
if (moonProjection.stable) this._moonAngle = moonProjection.angle;
|
if (moonProjection.stable) this._moonAngle = moonProjection.angle;
|
||||||
@@ -660,7 +774,10 @@ export class CelestialRing {
|
|||||||
const cx = width * 0.5;
|
const cx = width * 0.5;
|
||||||
const cy = height * 0.5;
|
const cy = height * 0.5;
|
||||||
const radius = disc.keyholeRadius - RING_INSET_PX;
|
const radius = disc.keyholeRadius - RING_INSET_PX;
|
||||||
const rayInnerRadius = Math.min(radius - 12, disc.earthRadius + Math.max(38, radius * 0.065));
|
const rayInnerRadius = Math.min(
|
||||||
|
radius - 12,
|
||||||
|
disc.earthRadius + Math.max(38, radius * 0.065),
|
||||||
|
);
|
||||||
this._renderEffectLayers(cx, cy, radius, rayInnerRadius);
|
this._renderEffectLayers(cx, cy, radius, rayInnerRadius);
|
||||||
this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`;
|
this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`;
|
||||||
this._sunCanvas.style.opacity = String(this._sunOpacity);
|
this._sunCanvas.style.opacity = String(this._sunOpacity);
|
||||||
@@ -671,16 +788,24 @@ export class CelestialRing {
|
|||||||
// stroke. Near conjunction (such as a new moon), keep the true bearings but
|
// stroke. Near conjunction (such as a new moon), keep the true bearings but
|
||||||
// move the moon into a second radial lane so both bodies remain legible.
|
// move the moon into a second radial lane so both bodies remain legible.
|
||||||
const markerRadius = radius - MARKER_INSET_PX;
|
const markerRadius = radius - MARKER_INSET_PX;
|
||||||
const markersCollide = circularAngleDistance(this._sunAngle, this._moonAngle)
|
const markersCollide =
|
||||||
< MARKER_COLLISION_ANGLE;
|
circularAngleDistance(this._sunAngle, this._moonAngle) <
|
||||||
this._positionMarker(this._sunMarker, cx, cy, markerRadius, this._sunAngle, this._sunOpacity);
|
MARKER_COLLISION_ANGLE;
|
||||||
|
this._positionMarker(
|
||||||
|
this._sunMarker,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
markerRadius,
|
||||||
|
this._sunAngle,
|
||||||
|
this._sunOpacity,
|
||||||
|
);
|
||||||
this._positionMarker(
|
this._positionMarker(
|
||||||
this._moonMarker,
|
this._moonMarker,
|
||||||
cx,
|
cx,
|
||||||
cy,
|
cy,
|
||||||
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
|
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
|
||||||
this._moonAngle,
|
this._moonAngle,
|
||||||
this._moonOpacity
|
this._moonOpacity,
|
||||||
);
|
);
|
||||||
this._debug = {
|
this._debug = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -698,7 +823,12 @@ export class CelestialRing {
|
|||||||
|
|
||||||
/** Read-only geometry snapshot for browser QA. */
|
/** Read-only geometry snapshot for browser QA. */
|
||||||
getDebugState() {
|
getDebugState() {
|
||||||
return this._debug ? { ...this._debug, disc: this._debug.disc ? { ...this._debug.disc } : null } : null;
|
return this._debug
|
||||||
|
? {
|
||||||
|
...this._debug,
|
||||||
|
disc: this._debug.disc ? { ...this._debug.disc } : null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Detach the render hook and remove all overlay DOM. */
|
/** Detach the render hook and remove all overlay DOM. */
|
||||||
|
|||||||
+127
-41
@@ -1,6 +1,9 @@
|
|||||||
import { applicationServices } from './services/application.js';
|
import { applicationServices } from './services/application.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { deriveWeatherEffectProfile, weatherAltitudeFactors } from './weatherEffectsMath.js';
|
import {
|
||||||
|
deriveWeatherEffectProfile,
|
||||||
|
weatherAltitudeFactors,
|
||||||
|
} from './weatherEffectsMath.js';
|
||||||
|
|
||||||
const WEATHER_REFRESH_MS = 5 * 60_000;
|
const WEATHER_REFRESH_MS = 5 * 60_000;
|
||||||
const CLOUD_FRAME_MS = 1000 / 12;
|
const CLOUD_FRAME_MS = 1000 / 12;
|
||||||
@@ -117,28 +120,47 @@ function compileShader(gl, type, source) {
|
|||||||
gl.shaderSource(shader, source);
|
gl.shaderSource(shader, source);
|
||||||
gl.compileShader(shader);
|
gl.compileShader(shader);
|
||||||
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
|
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
|
||||||
const error = gl.getShaderInfoLog(shader) || 'Unknown shader compilation error';
|
const error =
|
||||||
|
gl.getShaderInfoLog(shader) || 'Unknown shader compilation error';
|
||||||
gl.deleteShader(shader);
|
gl.deleteShader(shader);
|
||||||
throw new Error(error);
|
throw new Error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function greatCircleM(a, b) {
|
function greatCircleM(a, b) {
|
||||||
if (![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(Number.isFinite)) return Infinity;
|
if (
|
||||||
|
![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(
|
||||||
|
Number.isFinite,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return Infinity;
|
||||||
const latitudeA = Cesium.Math.toRadians(a.latitude);
|
const latitudeA = Cesium.Math.toRadians(a.latitude);
|
||||||
const latitudeB = Cesium.Math.toRadians(b.latitude);
|
const latitudeB = Cesium.Math.toRadians(b.latitude);
|
||||||
const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude);
|
const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude);
|
||||||
const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude);
|
const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude);
|
||||||
const haversine = Math.sin(latitudeDelta / 2) ** 2
|
const haversine =
|
||||||
+ Math.cos(latitudeA) * Math.cos(latitudeB) * Math.sin(longitudeDelta / 2) ** 2;
|
Math.sin(latitudeDelta / 2) ** 2 +
|
||||||
return 6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine));
|
Math.cos(latitudeA) *
|
||||||
|
Math.cos(latitudeB) *
|
||||||
|
Math.sin(longitudeDelta / 2) ** 2;
|
||||||
|
return (
|
||||||
|
6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return whether cockpit weather must refresh for elapsed time or movement. */
|
/** Return whether cockpit weather must refresh for elapsed time or movement. */
|
||||||
export function cockpitWeatherRefreshDue({ nowMs, fetchedAt, anchor, point, hasWeather }) {
|
export function cockpitWeatherRefreshDue({
|
||||||
|
nowMs,
|
||||||
|
fetchedAt,
|
||||||
|
anchor,
|
||||||
|
point,
|
||||||
|
hasWeather,
|
||||||
|
}) {
|
||||||
if (!hasWeather) return true;
|
if (!hasWeather) return true;
|
||||||
if (!Number.isFinite(nowMs) || !Number.isFinite(fetchedAt)) return true;
|
if (!Number.isFinite(nowMs) || !Number.isFinite(fetchedAt)) return true;
|
||||||
return nowMs - fetchedAt >= WEATHER_REFRESH_MS
|
return (
|
||||||
|| greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M;
|
nowMs - fetchedAt >= WEATHER_REFRESH_MS ||
|
||||||
|
greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the capped framebuffer size used by the cockpit cloud pass. */
|
/** Returns the capped framebuffer size used by the cockpit cloud pass. */
|
||||||
@@ -192,18 +214,24 @@ export class CockpitCloudEffectsController {
|
|||||||
this.pending = null;
|
this.pending = null;
|
||||||
this.abort = null;
|
this.abort = null;
|
||||||
this.destroyed = false;
|
this.destroyed = false;
|
||||||
this.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
this.reducedMotion = window.matchMedia(
|
||||||
|
'(prefers-reduced-motion: reduce)',
|
||||||
|
).matches;
|
||||||
this.enabled = this.readEnabledPreference();
|
this.enabled = this.readEnabledPreference();
|
||||||
|
|
||||||
this.onResize = () => this.resize();
|
this.onResize = () => this.resize();
|
||||||
this.onCockpitMode = (event) => {
|
this.onCockpitMode = (event) => {
|
||||||
const active = event?.detail?.active ?? document.body.classList.contains('cockpit-mode');
|
const active =
|
||||||
|
event?.detail?.active ??
|
||||||
|
document.body.classList.contains('cockpit-mode');
|
||||||
if (active && this.enabled) this.start();
|
if (active && this.enabled) this.start();
|
||||||
else this.stop();
|
else this.stop();
|
||||||
};
|
};
|
||||||
this.onEnabledChange = (event) => {
|
this.onEnabledChange = (event) => {
|
||||||
const requested = event?.detail?.enabled;
|
const requested = event?.detail?.enabled;
|
||||||
this.setEnabled(typeof requested === 'boolean' ? requested : !this.enabled);
|
this.setEnabled(
|
||||||
|
typeof requested === 'boolean' ? requested : !this.enabled,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
window.addEventListener('resize', this.onResize);
|
window.addEventListener('resize', this.onResize);
|
||||||
window.addEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
|
window.addEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
|
||||||
@@ -222,9 +250,11 @@ export class CockpitCloudEffectsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
emitEnabledState() {
|
emitEnabledState() {
|
||||||
window.dispatchEvent(new CustomEvent('gev:cockpit-weather-state', {
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('gev:cockpit-weather-state', {
|
||||||
detail: { enabled: this.enabled },
|
detail: { enabled: this.enabled },
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setEnabled(enabled) {
|
setEnabled(enabled) {
|
||||||
@@ -232,7 +262,9 @@ export class CockpitCloudEffectsController {
|
|||||||
this.enabled = next;
|
this.enabled = next;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(WEATHER_ENABLED_STORAGE_KEY, next ? '1' : '0');
|
localStorage.setItem(WEATHER_ENABLED_STORAGE_KEY, next ? '1' : '0');
|
||||||
} catch { /* best effort */ }
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
if (!next) {
|
if (!next) {
|
||||||
this.stop();
|
this.stop();
|
||||||
} else if (document.body.classList.contains('cockpit-mode')) {
|
} else if (document.body.classList.contains('cockpit-mode')) {
|
||||||
@@ -255,7 +287,11 @@ export class CockpitCloudEffectsController {
|
|||||||
if (!gl) throw new Error('WebGL unavailable');
|
if (!gl) throw new Error('WebGL unavailable');
|
||||||
|
|
||||||
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
|
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
|
||||||
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
|
const fragmentShader = compileShader(
|
||||||
|
gl,
|
||||||
|
gl.FRAGMENT_SHADER,
|
||||||
|
FRAGMENT_SHADER,
|
||||||
|
);
|
||||||
const program = gl.createProgram();
|
const program = gl.createProgram();
|
||||||
gl.attachShader(program, vertexShader);
|
gl.attachShader(program, vertexShader);
|
||||||
gl.attachShader(program, fragmentShader);
|
gl.attachShader(program, fragmentShader);
|
||||||
@@ -263,15 +299,18 @@ export class CockpitCloudEffectsController {
|
|||||||
gl.deleteShader(vertexShader);
|
gl.deleteShader(vertexShader);
|
||||||
gl.deleteShader(fragmentShader);
|
gl.deleteShader(fragmentShader);
|
||||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||||
throw new Error(gl.getProgramInfoLog(program) || 'Cloud shader link failed');
|
throw new Error(
|
||||||
|
gl.getProgramInfoLog(program) || 'Cloud shader link failed',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = gl.createBuffer();
|
const buffer = gl.createBuffer();
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
gl.bufferData(
|
||||||
-1, -1, 1, -1, -1, 1,
|
gl.ARRAY_BUFFER,
|
||||||
-1, 1, 1, -1, 1, 1,
|
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
|
||||||
]), gl.STATIC_DRAW);
|
gl.STATIC_DRAW,
|
||||||
|
);
|
||||||
const position = gl.getAttribLocation(program, 'position');
|
const position = gl.getAttribLocation(program, 'position');
|
||||||
gl.enableVertexAttribArray(position);
|
gl.enableVertexAttribArray(position);
|
||||||
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
|
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
|
||||||
@@ -293,7 +332,8 @@ export class CockpitCloudEffectsController {
|
|||||||
resize() {
|
resize() {
|
||||||
if (!this.gl) return;
|
if (!this.gl) return;
|
||||||
const size = cockpitCloudRenderSize(window.innerWidth, window.innerHeight);
|
const size = cockpitCloudRenderSize(window.innerWidth, window.innerHeight);
|
||||||
if (this.canvas.width === size.width && this.canvas.height === size.height) return;
|
if (this.canvas.width === size.width && this.canvas.height === size.height)
|
||||||
|
return;
|
||||||
this.canvas.width = size.width;
|
this.canvas.width = size.width;
|
||||||
this.canvas.height = size.height;
|
this.canvas.height = size.height;
|
||||||
this.gl.viewport(0, 0, size.width, size.height);
|
this.gl.viewport(0, 0, size.width, size.height);
|
||||||
@@ -313,7 +353,13 @@ export class CockpitCloudEffectsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
if (this.destroyed || !this.enabled || this.frame !== null || this.suspended) return;
|
if (
|
||||||
|
this.destroyed ||
|
||||||
|
!this.enabled ||
|
||||||
|
this.frame !== null ||
|
||||||
|
this.suspended
|
||||||
|
)
|
||||||
|
return;
|
||||||
if (!this.gl) {
|
if (!this.gl) {
|
||||||
this.initializeRenderer();
|
this.initializeRenderer();
|
||||||
this.resize();
|
this.resize();
|
||||||
@@ -353,16 +399,21 @@ export class CockpitCloudEffectsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refresh() {
|
async refresh() {
|
||||||
if (this.destroyed || !this.enabled || this.pending || this.suspended
|
if (
|
||||||
|| !document.body.classList.contains('cockpit-mode')) {
|
this.destroyed ||
|
||||||
|
!this.enabled ||
|
||||||
|
this.pending ||
|
||||||
|
this.suspended ||
|
||||||
|
!document.body.classList.contains('cockpit-mode')
|
||||||
|
) {
|
||||||
return this.pending;
|
return this.pending;
|
||||||
}
|
}
|
||||||
const point = this.cameraPoint();
|
const point = this.cameraPoint();
|
||||||
if (!point) return null;
|
if (!point) return null;
|
||||||
if (
|
if (
|
||||||
this.weather
|
this.weather &&
|
||||||
&& Date.now() - this.fetchedAt < WEATHER_REFRESH_MS
|
Date.now() - this.fetchedAt < WEATHER_REFRESH_MS &&
|
||||||
&& greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M
|
greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M
|
||||||
) {
|
) {
|
||||||
this.applyWeather(this.weather, point.altitudeM);
|
this.applyWeather(this.weather, point.altitudeM);
|
||||||
return this.weather;
|
return this.weather;
|
||||||
@@ -370,11 +421,17 @@ export class CockpitCloudEffectsController {
|
|||||||
|
|
||||||
this.abort?.abort();
|
this.abort?.abort();
|
||||||
this.abort = new AbortController();
|
this.abort = new AbortController();
|
||||||
const pending = this.weatherService.getConditions(point.latitude, point.longitude, { signal: this.abort.signal });
|
const pending = this.weatherService.getConditions(
|
||||||
|
point.latitude,
|
||||||
|
point.longitude,
|
||||||
|
{ signal: this.abort.signal },
|
||||||
|
);
|
||||||
const request = this.abort;
|
const request = this.abort;
|
||||||
this.pending = pending.then(async (payload) => {
|
this.pending = pending
|
||||||
|
.then(async (payload) => {
|
||||||
if (this.abort !== request || request.signal.aborted) return null;
|
if (this.abort !== request || request.signal.aborted) return null;
|
||||||
if (!payload?.weather) throw new Error('Cloud weather observation unavailable');
|
if (!payload?.weather)
|
||||||
|
throw new Error('Cloud weather observation unavailable');
|
||||||
this.weather = payload.weather;
|
this.weather = payload.weather;
|
||||||
this.fetchedAt = Date.now();
|
this.fetchedAt = Date.now();
|
||||||
this.anchor = point;
|
this.anchor = point;
|
||||||
@@ -391,7 +448,10 @@ export class CockpitCloudEffectsController {
|
|||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (this.abort === request) { this.pending = null; this.abort = null; }
|
if (this.abort === request) {
|
||||||
|
this.pending = null;
|
||||||
|
this.abort = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return this.pending;
|
return this.pending;
|
||||||
}
|
}
|
||||||
@@ -399,12 +459,17 @@ export class CockpitCloudEffectsController {
|
|||||||
applyWeather(weather, altitudeM) {
|
applyWeather(weather, altitudeM) {
|
||||||
const profile = deriveWeatherEffectProfile(weather);
|
const profile = deriveWeatherEffectProfile(weather);
|
||||||
const altitude = weatherAltitudeFactors(altitudeM);
|
const altitude = weatherAltitudeFactors(altitudeM);
|
||||||
this.targetStrength = profile.available ? profile.cloud * altitude.cloud : 0;
|
this.targetStrength = profile.available
|
||||||
|
? profile.cloud * altitude.cloud
|
||||||
|
: 0;
|
||||||
this.windDirectionDeg = profile.windDirectionDeg || 0;
|
this.windDirectionDeg = profile.windDirectionDeg || 0;
|
||||||
this.windStrength = profile.wind || 0;
|
this.windStrength = profile.wind || 0;
|
||||||
this.canvas.dataset.cloudStrength = this.targetStrength.toFixed(3);
|
this.canvas.dataset.cloudStrength = this.targetStrength.toFixed(3);
|
||||||
this.canvas.dataset.weatherCode = String(weather?.weatherCode ?? 'unknown');
|
this.canvas.dataset.weatherCode = String(weather?.weatherCode ?? 'unknown');
|
||||||
if (this.reducedMotion && document.body.classList.contains('cockpit-mode')) {
|
if (
|
||||||
|
this.reducedMotion &&
|
||||||
|
document.body.classList.contains('cockpit-mode')
|
||||||
|
) {
|
||||||
this.strength = this.targetStrength;
|
this.strength = this.targetStrength;
|
||||||
const visible = this.strength > 0.035;
|
const visible = this.strength > 0.035;
|
||||||
this.canvas.classList.toggle('active', visible);
|
this.canvas.classList.toggle('active', visible);
|
||||||
@@ -414,7 +479,11 @@ export class CockpitCloudEffectsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tick(timeMs) {
|
tick(timeMs) {
|
||||||
if (this.destroyed || !this.enabled || !document.body.classList.contains('cockpit-mode')) {
|
if (
|
||||||
|
this.destroyed ||
|
||||||
|
!this.enabled ||
|
||||||
|
!document.body.classList.contains('cockpit-mode')
|
||||||
|
) {
|
||||||
this.stop();
|
this.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -431,20 +500,27 @@ export class CockpitCloudEffectsController {
|
|||||||
if (timeMs - this.lastRefreshCheckMs >= 1000) {
|
if (timeMs - this.lastRefreshCheckMs >= 1000) {
|
||||||
this.lastRefreshCheckMs = timeMs;
|
this.lastRefreshCheckMs = timeMs;
|
||||||
const point = this.cameraPoint();
|
const point = this.cameraPoint();
|
||||||
if (point && cockpitWeatherRefreshDue({
|
if (
|
||||||
|
point &&
|
||||||
|
cockpitWeatherRefreshDue({
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
fetchedAt: this.fetchedAt,
|
fetchedAt: this.fetchedAt,
|
||||||
anchor: this.anchor,
|
anchor: this.anchor,
|
||||||
point,
|
point,
|
||||||
hasWeather: Boolean(this.weather),
|
hasWeather: Boolean(this.weather),
|
||||||
})) void this.refresh();
|
})
|
||||||
|
)
|
||||||
|
void this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
const blend = this.reducedMotion ? 1 : 0.08;
|
const blend = this.reducedMotion ? 1 : 0.08;
|
||||||
this.strength += (this.targetStrength - this.strength) * blend;
|
this.strength += (this.targetStrength - this.strength) * blend;
|
||||||
const visible = this.strength > 0.035;
|
const visible = this.strength > 0.035;
|
||||||
this.canvas.classList.toggle('active', visible);
|
this.canvas.classList.toggle('active', visible);
|
||||||
if (visible && (this.reducedMotion || timeMs - this.lastFrameMs >= CLOUD_FRAME_MS)) {
|
if (
|
||||||
|
visible &&
|
||||||
|
(this.reducedMotion || timeMs - this.lastFrameMs >= CLOUD_FRAME_MS)
|
||||||
|
) {
|
||||||
this.lastFrameMs = timeMs;
|
this.lastFrameMs = timeMs;
|
||||||
this.render(timeMs / 1000);
|
this.render(timeMs / 1000);
|
||||||
} else if (!visible) {
|
} else if (!visible) {
|
||||||
@@ -468,9 +544,16 @@ export class CockpitCloudEffectsController {
|
|||||||
const windRadians = Cesium.Math.toRadians(this.windDirectionDeg);
|
const windRadians = Cesium.Math.toRadians(this.windDirectionDeg);
|
||||||
const windScale = 0.008 + this.windStrength * 0.022;
|
const windScale = 0.008 + this.windStrength * 0.022;
|
||||||
gl.useProgram(this.program);
|
gl.useProgram(this.program);
|
||||||
gl.uniform2f(this.locations.resolution, this.canvas.width, this.canvas.height);
|
gl.uniform2f(
|
||||||
|
this.locations.resolution,
|
||||||
|
this.canvas.width,
|
||||||
|
this.canvas.height,
|
||||||
|
);
|
||||||
gl.uniform1f(this.locations.time, timeSec);
|
gl.uniform1f(this.locations.time, timeSec);
|
||||||
gl.uniform1f(this.locations.strength, Math.min(1, Math.max(0, this.strength)));
|
gl.uniform1f(
|
||||||
|
this.locations.strength,
|
||||||
|
Math.min(1, Math.max(0, this.strength)),
|
||||||
|
);
|
||||||
gl.uniform2f(
|
gl.uniform2f(
|
||||||
this.locations.wind,
|
this.locations.wind,
|
||||||
Math.sin(windRadians) * windScale,
|
Math.sin(windRadians) * windScale,
|
||||||
@@ -517,7 +600,10 @@ export class CockpitCloudEffectsController {
|
|||||||
this.stop();
|
this.stop();
|
||||||
window.removeEventListener('resize', this.onResize);
|
window.removeEventListener('resize', this.onResize);
|
||||||
window.removeEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
|
window.removeEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
|
||||||
window.removeEventListener('gev:cockpit-weather-toggle', this.onEnabledChange);
|
window.removeEventListener(
|
||||||
|
'gev:cockpit-weather-toggle',
|
||||||
|
this.onEnabledChange,
|
||||||
|
);
|
||||||
if (this.program && this.gl) this.gl.deleteProgram(this.program);
|
if (this.program && this.gl) this.gl.deleteProgram(this.program);
|
||||||
this.canvas.remove();
|
this.canvas.remove();
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-15
@@ -8,7 +8,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Force the prior aircraft layer to reacquire Cesium and durable tracking ownership. */
|
/** Force the prior aircraft layer to reacquire Cesium and durable tracking ownership. */
|
||||||
export function restoreAircraftTrackingOwner(layer, id, { origin = 'programmatic' } = {}) {
|
export function restoreAircraftTrackingOwner(
|
||||||
|
layer,
|
||||||
|
id,
|
||||||
|
{ origin = 'programmatic' } = {},
|
||||||
|
) {
|
||||||
if (!layer?.trackById || !id) return false;
|
if (!layer?.trackById || !id) return false;
|
||||||
layer.stopTracking?.({ origin });
|
layer.stopTracking?.({ origin });
|
||||||
return Boolean(layer.trackById(id, { origin }));
|
return Boolean(layer.trackById(id, { origin }));
|
||||||
@@ -33,11 +37,13 @@ export function enterCockpitWithTracking({
|
|||||||
rollbackTarget = undefined,
|
rollbackTarget = undefined,
|
||||||
selectionOrigin = 'programmatic',
|
selectionOrigin = 'programmatic',
|
||||||
}) {
|
}) {
|
||||||
const currentTarget = aircraftTrackingTarget(cockpitView?.readAircraftInfo?.());
|
const currentTarget = aircraftTrackingTarget(
|
||||||
const restoreTarget = rollbackTarget === undefined ? currentTarget : rollbackTarget;
|
cockpitView?.readAircraftInfo?.(),
|
||||||
const key = (target) => target?.layerId && target?.id
|
);
|
||||||
? `${target.layerId}:${target.id}`
|
const restoreTarget =
|
||||||
: null;
|
rollbackTarget === undefined ? currentTarget : rollbackTarget;
|
||||||
|
const key = (target) =>
|
||||||
|
target?.layerId && target?.id ? `${target.layerId}:${target.id}` : null;
|
||||||
let activeLayer = currentLayer;
|
let activeLayer = currentLayer;
|
||||||
let activeTarget = currentTarget;
|
let activeTarget = currentTarget;
|
||||||
let entryError = null;
|
let entryError = null;
|
||||||
@@ -49,8 +55,14 @@ export function enterCockpitWithTracking({
|
|||||||
if (selectingTarget) {
|
if (selectingTarget) {
|
||||||
activeLayer = selectedLayer;
|
activeLayer = selectedLayer;
|
||||||
activeTarget = selectedTarget;
|
activeTarget = selectedTarget;
|
||||||
if (!selectedLayer.trackById?.(selectedTarget.id, { origin: selectionOrigin })) {
|
if (
|
||||||
entryError = new Error('Selected aircraft could not be tracked for Cockpit entry');
|
!selectedLayer.trackById?.(selectedTarget.id, {
|
||||||
|
origin: selectionOrigin,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
entryError = new Error(
|
||||||
|
'Selected aircraft could not be tracked for Cockpit entry',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!entryError) entered = Boolean(cockpitView.enter());
|
if (!entryError) entered = Boolean(cockpitView.enter());
|
||||||
@@ -66,12 +78,15 @@ export function enterCockpitWithTracking({
|
|||||||
// programmatic rollback would fix the camera while leaving the failed
|
// programmatic rollback would fix the camera while leaving the failed
|
||||||
// target durable in local/share state.
|
// target durable in local/share state.
|
||||||
activeLayer?.stopTracking?.({ origin: selectionOrigin });
|
activeLayer?.stopTracking?.({ origin: selectionOrigin });
|
||||||
if (restoreTarget && !restoreAircraftTrackingOwner(
|
if (
|
||||||
rollbackLayer,
|
restoreTarget &&
|
||||||
restoreTarget.id,
|
!restoreAircraftTrackingOwner(rollbackLayer, restoreTarget.id, {
|
||||||
{ origin: selectionOrigin },
|
origin: selectionOrigin,
|
||||||
)) {
|
})
|
||||||
entryError ||= new Error('Prior aircraft tracking could not be restored');
|
) {
|
||||||
|
entryError ||= new Error(
|
||||||
|
'Prior aircraft tracking could not be restored',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
entryError ||= error instanceof Error ? error : new Error(String(error));
|
entryError ||= error instanceof Error ? error : new Error(String(error));
|
||||||
@@ -87,6 +102,8 @@ export function enterCockpitWithTracking({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
entered,
|
entered,
|
||||||
error: entered ? null : entryError?.message || 'Cockpit entry was unavailable',
|
error: entered
|
||||||
|
? null
|
||||||
|
: entryError?.message || 'Cockpit entry was unavailable',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -75,11 +75,16 @@ export function contactsDetectionEnterPlan(current, restore) {
|
|||||||
* @returns {?{mode: string, densityPct: ?number}} State to replay, or null when
|
* @returns {?{mode: string, densityPct: ?number}} State to replay, or null when
|
||||||
* there is nothing to do.
|
* there is nothing to do.
|
||||||
*/
|
*/
|
||||||
export function contactsDetectionExitPlan(restore, current, styleOwnsDetection = false) {
|
export function contactsDetectionExitPlan(
|
||||||
|
restore,
|
||||||
|
current,
|
||||||
|
styleOwnsDetection = false,
|
||||||
|
) {
|
||||||
if (!restore || styleOwnsDetection) return null;
|
if (!restore || styleOwnsDetection) return null;
|
||||||
const saved = normalizeDetectionState(restore);
|
const saved = normalizeDetectionState(restore);
|
||||||
const now = normalizeDetectionState(current);
|
const now = normalizeDetectionState(current);
|
||||||
if (saved.mode === now.mode && saved.densityPct === now.densityPct) return null;
|
if (saved.mode === now.mode && saved.densityPct === now.densityPct)
|
||||||
|
return null;
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +124,11 @@ export function applyContactsDetection({
|
|||||||
if (plan.turnOn) applyPreset();
|
if (plan.turnOn) applyPreset();
|
||||||
return { restore: plan.restore, changed: plan.turnOn };
|
return { restore: plan.restore, changed: plan.turnOn };
|
||||||
}
|
}
|
||||||
const plan = contactsDetectionExitPlan(restore, getState(), styleOwnsDetection);
|
const plan = contactsDetectionExitPlan(
|
||||||
|
restore,
|
||||||
|
getState(),
|
||||||
|
styleOwnsDetection,
|
||||||
|
);
|
||||||
if (plan) restoreState(plan);
|
if (plan) restoreState(plan);
|
||||||
return { restore: null, changed: Boolean(plan) };
|
return { restore: null, changed: Boolean(plan) };
|
||||||
}
|
}
|
||||||
@@ -145,7 +154,9 @@ export function shareableDetectionState({ owned, liveMode, liveDensityPct }) {
|
|||||||
if (!owned) return { mode: liveMode, densityPct: liveDensityPct };
|
if (!owned) return { mode: liveMode, densityPct: liveDensityPct };
|
||||||
return {
|
return {
|
||||||
mode: owned.mode ?? liveMode,
|
mode: owned.mode ?? liveMode,
|
||||||
densityPct: Number.isFinite(owned.densityPct) ? owned.densityPct : liveDensityPct,
|
densityPct: Number.isFinite(owned.densityPct)
|
||||||
|
? owned.densityPct
|
||||||
|
: liveDensityPct,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+119
-66
@@ -1,5 +1,11 @@
|
|||||||
const CONTEXT_DEPENDENCIES = Object.freeze({
|
const CONTEXT_DEPENDENCIES = Object.freeze({
|
||||||
flights: new Set(['military-awareness', 'flights', 'military', 'ais-live-vessels', 'military-installations']),
|
flights: new Set([
|
||||||
|
'military-awareness',
|
||||||
|
'flights',
|
||||||
|
'military',
|
||||||
|
'ais-live-vessels',
|
||||||
|
'military-installations',
|
||||||
|
]),
|
||||||
'space-missions': new Set(['rocket-launches', 'satellites']),
|
'space-missions': new Set(['rocket-launches', 'satellites']),
|
||||||
});
|
});
|
||||||
const CONTEXT_COMPANIONS = new Set(['radio']);
|
const CONTEXT_COMPANIONS = new Set(['radio']);
|
||||||
@@ -11,11 +17,13 @@ export function isExplicitUserIntentOrigin(origin, layerId = null) {
|
|||||||
/** Preserve explicit layer intent that completes while a stale restore is queued. */
|
/** Preserve explicit layer intent that completes while a stale restore is queued. */
|
||||||
export function recordContextRestoreExplicitChange({ restoreState, change }) {
|
export function recordContextRestoreExplicitChange({ restoreState, change }) {
|
||||||
if (
|
if (
|
||||||
!restoreState?.enabledLayerIds
|
!restoreState?.enabledLayerIds ||
|
||||||
|| change?.type !== 'visibility'
|
change?.type !== 'visibility' ||
|
||||||
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
|
!isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||||
) return false;
|
)
|
||||||
if (!restoreState.explicitLayerStates) restoreState.explicitLayerStates = new Map();
|
return false;
|
||||||
|
if (!restoreState.explicitLayerStates)
|
||||||
|
restoreState.explicitLayerStates = new Map();
|
||||||
restoreState.explicitLayerStates.set(change.layerId, Boolean(change.enabled));
|
restoreState.explicitLayerStates.set(change.layerId, Boolean(change.enabled));
|
||||||
if (change.enabled) restoreState.enabledLayerIds.add(change.layerId);
|
if (change.enabled) restoreState.enabledLayerIds.add(change.layerId);
|
||||||
else restoreState.enabledLayerIds.delete(change.layerId);
|
else restoreState.enabledLayerIds.delete(change.layerId);
|
||||||
@@ -23,30 +31,41 @@ export function recordContextRestoreExplicitChange({ restoreState, change }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Settle every explicit Context-restore replay without losing semantic failures. */
|
/** Settle every explicit Context-restore replay without losing semantic failures. */
|
||||||
export async function settleContextIntentReplay({ restoreState, setEnabled, notificationToken = null }) {
|
export async function settleContextIntentReplay({
|
||||||
|
restoreState,
|
||||||
|
setEnabled,
|
||||||
|
notificationToken = null,
|
||||||
|
}) {
|
||||||
if (restoreState?.cancelled) return null;
|
if (restoreState?.cancelled) return null;
|
||||||
const entries = [...(restoreState?.explicitLayerStates || [])];
|
const entries = [...(restoreState?.explicitLayerStates || [])];
|
||||||
const results = await Promise.allSettled(entries.map(([layerId, enabled]) => (
|
const results = await Promise.allSettled(
|
||||||
|
entries.map(([layerId, enabled]) =>
|
||||||
setEnabled(layerId, enabled, {
|
setEnabled(layerId, enabled, {
|
||||||
origin: 'context-intent-replay',
|
origin: 'context-intent-replay',
|
||||||
...(notificationToken ? { notificationToken } : {}),
|
...(notificationToken ? { notificationToken } : {}),
|
||||||
})
|
}),
|
||||||
)));
|
),
|
||||||
const failedIndexes = results.flatMap((result, index) => (
|
);
|
||||||
result.status === 'rejected' || result.value === false ? [index] : []
|
const failedIndexes = results.flatMap((result, index) =>
|
||||||
));
|
result.status === 'rejected' || result.value === false ? [index] : [],
|
||||||
|
);
|
||||||
if (failedIndexes.length === 0) return null;
|
if (failedIndexes.length === 0) return null;
|
||||||
const failedLayerIds = failedIndexes.map((index) => entries[index][0]);
|
const failedLayerIds = failedIndexes.map((index) => entries[index][0]);
|
||||||
const rejected = failedIndexes
|
const rejected = failedIndexes
|
||||||
.map((index) => results[index])
|
.map((index) => results[index])
|
||||||
.find((result) => result.status === 'rejected');
|
.find((result) => result.status === 'rejected');
|
||||||
const error = rejected?.reason instanceof Error
|
const error =
|
||||||
|
rejected?.reason instanceof Error
|
||||||
? rejected.reason
|
? rejected.reason
|
||||||
: new Error(`Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`);
|
: new Error(
|
||||||
error.failedLayerIds = [...new Set([
|
`Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`,
|
||||||
|
);
|
||||||
|
error.failedLayerIds = [
|
||||||
|
...new Set([
|
||||||
...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []),
|
...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []),
|
||||||
...failedLayerIds,
|
...failedLayerIds,
|
||||||
])];
|
]),
|
||||||
|
];
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,18 +73,29 @@ export async function settleContextIntentReplay({ restoreState, setEnabled, noti
|
|||||||
export function mergeContextTransitionErrors(primaryError, secondaryError) {
|
export function mergeContextTransitionErrors(primaryError, secondaryError) {
|
||||||
if (!primaryError) return secondaryError || null;
|
if (!primaryError) return secondaryError || null;
|
||||||
if (!secondaryError) return primaryError;
|
if (!secondaryError) return primaryError;
|
||||||
primaryError.failedLayerIds = [...new Set([
|
primaryError.failedLayerIds = [
|
||||||
...(Array.isArray(primaryError.failedLayerIds) ? primaryError.failedLayerIds : []),
|
...new Set([
|
||||||
...(Array.isArray(secondaryError.failedLayerIds) ? secondaryError.failedLayerIds : []),
|
...(Array.isArray(primaryError.failedLayerIds)
|
||||||
])];
|
? primaryError.failedLayerIds
|
||||||
|
: []),
|
||||||
|
...(Array.isArray(secondaryError.failedLayerIds)
|
||||||
|
? secondaryError.failedLayerIds
|
||||||
|
: []),
|
||||||
|
]),
|
||||||
|
];
|
||||||
return primaryError;
|
return primaryError;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settle a user-facing Context action and convert every failure form to false. */
|
/** Settle a user-facing Context action and convert every failure form to false. */
|
||||||
export async function settleUserFacingContextAction({ operation, onFailure, falseIsFailure = true }) {
|
export async function settleUserFacingContextAction({
|
||||||
|
operation,
|
||||||
|
onFailure,
|
||||||
|
falseIsFailure = true,
|
||||||
|
}) {
|
||||||
try {
|
try {
|
||||||
const result = await operation();
|
const result = await operation();
|
||||||
if (falseIsFailure && result === false) throw new Error('Context transition did not complete');
|
if (falseIsFailure && result === false)
|
||||||
|
throw new Error('Context transition did not complete');
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
try {
|
||||||
@@ -83,7 +113,10 @@ export async function settleUserFacingContextAction({ operation, onFailure, fals
|
|||||||
* one into `userAdded` makes exit restoration re-enable the mode layer the
|
* one into `userAdded` makes exit restoration re-enable the mode layer the
|
||||||
* user just turned off.
|
* user just turned off.
|
||||||
*/
|
*/
|
||||||
export const CONTEXT_ENTRY_LAYER_IDS = Object.freeze(['military-awareness', 'rocket-launches']);
|
export const CONTEXT_ENTRY_LAYER_IDS = Object.freeze([
|
||||||
|
'military-awareness',
|
||||||
|
'rocket-launches',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leave a Context transaction: publish the settled coordination flag, then
|
* Leave a Context transaction: publish the settled coordination flag, then
|
||||||
@@ -108,7 +141,8 @@ export function settleContextModeChange(owner, changing = false) {
|
|||||||
if (!owner) return;
|
if (!owner) return;
|
||||||
const wasChanging = Boolean(owner._contextModeChanging);
|
const wasChanging = Boolean(owner._contextModeChanging);
|
||||||
owner._contextModeChanging = Boolean(changing);
|
owner._contextModeChanging = Boolean(changing);
|
||||||
if (wasChanging && !owner._contextModeChanging) owner._syncContextModeButtons?.();
|
if (wasChanging && !owner._contextModeChanging)
|
||||||
|
owner._syncContextModeButtons?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,19 +176,24 @@ export async function runWithContextModeChanging(owner, operation) {
|
|||||||
* @param {object|null} input.change DataLayerManager visibility notification.
|
* @param {object|null} input.change DataLayerManager visibility notification.
|
||||||
* @returns {boolean} Whether the active Context bundle should be disabled.
|
* @returns {boolean} Whether the active Context bundle should be disabled.
|
||||||
*/
|
*/
|
||||||
export function shouldExitContextForLayerChange({ contextMode, globalContextEnabled, change }) {
|
export function shouldExitContextForLayerChange({
|
||||||
|
contextMode,
|
||||||
|
globalContextEnabled,
|
||||||
|
change,
|
||||||
|
}) {
|
||||||
if (
|
if (
|
||||||
change?.type !== 'visibility'
|
change?.type !== 'visibility' ||
|
||||||
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
|
!isExplicitUserIntentOrigin(change.origin, change.layerId) ||
|
||||||
|| change.layerId === 'military-awareness'
|
change.layerId === 'military-awareness' ||
|
||||||
|| CONTEXT_COMPANIONS.has(change.layerId)
|
CONTEXT_COMPANIONS.has(change.layerId)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const active = Boolean(globalContextEnabled || contextMode);
|
const active = Boolean(globalContextEnabled || contextMode);
|
||||||
if (!active) return false;
|
if (!active) return false;
|
||||||
|
|
||||||
const dependencies = CONTEXT_DEPENDENCIES[contextMode] || new Set(['military-awareness']);
|
const dependencies =
|
||||||
|
CONTEXT_DEPENDENCIES[contextMode] || new Set(['military-awareness']);
|
||||||
if (change.enabled) return false;
|
if (change.enabled) return false;
|
||||||
return Boolean(contextMode && dependencies.has(change.layerId));
|
return Boolean(contextMode && dependencies.has(change.layerId));
|
||||||
}
|
}
|
||||||
@@ -181,10 +220,10 @@ export function contextAllowedLayerIds(contextMode) {
|
|||||||
*/
|
*/
|
||||||
export function shouldCaptureContextSession(change) {
|
export function shouldCaptureContextSession(change) {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
['visibility-requested', 'visibility-will-change'].includes(change?.type)
|
['visibility-requested', 'visibility-will-change'].includes(change?.type) &&
|
||||||
&& isExplicitUserIntentOrigin(change.origin, change.layerId)
|
isExplicitUserIntentOrigin(change.origin, change.layerId) &&
|
||||||
&& change.enabled
|
change.enabled &&
|
||||||
&& CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId)
|
CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,12 +233,12 @@ export function shouldCaptureContextSession(change) {
|
|||||||
*/
|
*/
|
||||||
export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
|
export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
clearInFlight
|
clearInFlight &&
|
||||||
&& change?.type === 'visibility-requested'
|
change?.type === 'visibility-requested' &&
|
||||||
&& change.layerId === 'rocket-launches'
|
change.layerId === 'rocket-launches' &&
|
||||||
&& change.enabled === true
|
change.enabled === true &&
|
||||||
&& isExplicitUserIntentOrigin(change.origin, change.layerId)
|
isExplicitUserIntentOrigin(change.origin, change.layerId) &&
|
||||||
&& Number.isInteger(change.intentEpoch)
|
Number.isInteger(change.intentEpoch),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,11 +257,15 @@ export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
|
|||||||
* @param {string|null} [input.effectiveContextMode] Committed or entering mode.
|
* @param {string|null} [input.effectiveContextMode] Committed or entering mode.
|
||||||
* @returns {boolean} Whether the snapshot's userAdded set was modified.
|
* @returns {boolean} Whether the snapshot's userAdded set was modified.
|
||||||
*/
|
*/
|
||||||
export function recordContextSessionUserChange({ snapshot, change, effectiveContextMode = null }) {
|
export function recordContextSessionUserChange({
|
||||||
|
snapshot,
|
||||||
|
change,
|
||||||
|
effectiveContextMode = null,
|
||||||
|
}) {
|
||||||
if (
|
if (
|
||||||
!snapshot?.userAdded
|
!snapshot?.userAdded ||
|
||||||
|| change?.type !== 'visibility'
|
change?.type !== 'visibility' ||
|
||||||
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
|
!isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -233,7 +276,11 @@ export function recordContextSessionUserChange({ snapshot, change, effectiveCont
|
|||||||
snapshot.userAdded.add(change.layerId);
|
snapshot.userAdded.add(change.layerId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (contextAllowedLayerIds(effectiveContextMode).has(change.layerId) && !isCompanion) return false;
|
if (
|
||||||
|
contextAllowedLayerIds(effectiveContextMode).has(change.layerId) &&
|
||||||
|
!isCompanion
|
||||||
|
)
|
||||||
|
return false;
|
||||||
snapshot.userAdded.add(change.layerId);
|
snapshot.userAdded.add(change.layerId);
|
||||||
if (isCompanion) snapshot.userRemoved?.delete(change.layerId);
|
if (isCompanion) snapshot.userRemoved?.delete(change.layerId);
|
||||||
return true;
|
return true;
|
||||||
@@ -276,11 +323,15 @@ export function contextSnapshotLayerIds(
|
|||||||
* @param {string|null} [input.layerName] User-facing layer name.
|
* @param {string|null} [input.layerName] User-facing layer name.
|
||||||
* @returns {string|null} Honest user-facing refusal reason, or null when allowed.
|
* @returns {string|null} Honest user-facing refusal reason, or null when allowed.
|
||||||
*/
|
*/
|
||||||
export function contextLayerEnableBlockReason({ contextMode, change, layerName = null }) {
|
export function contextLayerEnableBlockReason({
|
||||||
|
contextMode,
|
||||||
|
change,
|
||||||
|
layerName = null,
|
||||||
|
}) {
|
||||||
if (
|
if (
|
||||||
contextMode !== 'space-missions'
|
contextMode !== 'space-missions' ||
|
||||||
|| change?.enabled !== true
|
change?.enabled !== true ||
|
||||||
|| contextAllowedLayerIds('space-missions').has(change.layerId)
|
contextAllowedLayerIds('space-missions').has(change.layerId)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -297,20 +348,20 @@ export function contextLayerEnableBlockReason({ contextMode, change, layerName =
|
|||||||
* @param {object|null} input.change Manager visibility notification.
|
* @param {object|null} input.change Manager visibility notification.
|
||||||
* @returns {'ignore'|'replacement'|'restore'} Cancellation disposition.
|
* @returns {'ignore'|'replacement'|'restore'} Cancellation disposition.
|
||||||
*/
|
*/
|
||||||
export function spaceMissionEntryCancellationDisposition({
|
export function spaceMissionEntryCancellationDisposition({ change }) {
|
||||||
change,
|
|
||||||
}) {
|
|
||||||
if (
|
if (
|
||||||
change?.type !== 'visibility-cancelled'
|
change?.type !== 'visibility-cancelled' ||
|
||||||
|| change.layerId !== 'rocket-launches'
|
change.layerId !== 'rocket-launches' ||
|
||||||
|| change.enabled !== true
|
change.enabled !== true
|
||||||
) return 'ignore';
|
)
|
||||||
|
return 'ignore';
|
||||||
if (
|
if (
|
||||||
change.cancellationReason === 'superseded'
|
change.cancellationReason === 'superseded' &&
|
||||||
&& Number.isInteger(change.successorIntentEpoch)
|
Number.isInteger(change.successorIntentEpoch) &&
|
||||||
&& change.successorIntentEpoch > change.intentEpoch
|
change.successorIntentEpoch > change.intentEpoch &&
|
||||||
&& change.successorEnabled === true
|
change.successorEnabled === true
|
||||||
) return 'replacement';
|
)
|
||||||
|
return 'replacement';
|
||||||
return 'restore';
|
return 'restore';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,10 +399,12 @@ export function cockpitEntryAllowed({
|
|||||||
flightsEnabled,
|
flightsEnabled,
|
||||||
militaryEnabled,
|
militaryEnabled,
|
||||||
}) {
|
}) {
|
||||||
return contextMode === 'flights'
|
return (
|
||||||
&& !contextModeChanging
|
contextMode === 'flights' &&
|
||||||
&& Boolean(flightsEnabled)
|
!contextModeChanging &&
|
||||||
&& Boolean(militaryEnabled);
|
Boolean(flightsEnabled) &&
|
||||||
|
Boolean(militaryEnabled)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
* Cesium retains ownership of showing and hiding its required credit lightbox.
|
* Cesium retains ownership of showing and hiding its required credit lightbox.
|
||||||
*/
|
*/
|
||||||
export function configureCreditKeyboardAccess(root = document) {
|
export function configureCreditKeyboardAccess(root = document) {
|
||||||
const expand = root?.querySelector?.('#cesium-credits .cesium-credit-expand-link');
|
const expand = root?.querySelector?.(
|
||||||
|
'#cesium-credits .cesium-credit-expand-link',
|
||||||
|
);
|
||||||
const lightbox = root?.querySelector?.('.cesium-credit-lightbox');
|
const lightbox = root?.querySelector?.('.cesium-credit-lightbox');
|
||||||
const close = lightbox?.querySelector?.('.cesium-credit-lightbox-close');
|
const close = lightbox?.querySelector?.('.cesium-credit-lightbox-close');
|
||||||
if (!expand || !lightbox || !close) return false;
|
if (!expand || !lightbox || !close) return false;
|
||||||
@@ -44,7 +46,9 @@ export function configureCreditKeyboardAccess(root = document) {
|
|||||||
// A long Space hold may hand control to push-to-talk and move focus away.
|
// A long Space hold may hand control to push-to-talk and move focus away.
|
||||||
// Cancelling the armed release prevents that eventual keyup from also
|
// Cancelling the armed release prevents that eventual keyup from also
|
||||||
// activating the attribution control.
|
// activating the attribution control.
|
||||||
control.addEventListener('blur', () => { spacePressed = false; });
|
control.addEventListener('blur', () => {
|
||||||
|
spacePressed = false;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const closeAndRestoreFocus = () => {
|
const closeAndRestoreFocus = () => {
|
||||||
expand.setAttribute('aria-expanded', 'false');
|
expand.setAttribute('aria-expanded', 'false');
|
||||||
|
|||||||
+402
-55
@@ -8,68 +8,385 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const HELI = new Set([
|
const HELI = new Set([
|
||||||
'EC20', 'EC25', 'EC30', 'EC35', 'EC45', 'EC55', 'AS50', 'AS55', 'AS65', 'AS32',
|
'EC20',
|
||||||
'A109', 'A119', 'A139', 'A169', 'A189', 'B06', 'B06T', 'B407', 'B412', 'B427',
|
'EC25',
|
||||||
'B429', 'B430', 'B505', 'S76', 'S92', 'S61', 'S64', 'H60', 'H500', 'MD52',
|
'EC30',
|
||||||
'MD60', 'R22', 'R44', 'R66', 'EXEC', 'EXPL', 'GAZL', 'LYNX', 'NH90', 'PUMA',
|
'EC35',
|
||||||
'SCAV', 'UH1', 'B105', 'B212', 'B214', 'B222', 'AC', 'H47', 'H64',
|
'EC45',
|
||||||
|
'EC55',
|
||||||
|
'AS50',
|
||||||
|
'AS55',
|
||||||
|
'AS65',
|
||||||
|
'AS32',
|
||||||
|
'A109',
|
||||||
|
'A119',
|
||||||
|
'A139',
|
||||||
|
'A169',
|
||||||
|
'A189',
|
||||||
|
'B06',
|
||||||
|
'B06T',
|
||||||
|
'B407',
|
||||||
|
'B412',
|
||||||
|
'B427',
|
||||||
|
'B429',
|
||||||
|
'B430',
|
||||||
|
'B505',
|
||||||
|
'S76',
|
||||||
|
'S92',
|
||||||
|
'S61',
|
||||||
|
'S64',
|
||||||
|
'H60',
|
||||||
|
'H500',
|
||||||
|
'MD52',
|
||||||
|
'MD60',
|
||||||
|
'R22',
|
||||||
|
'R44',
|
||||||
|
'R66',
|
||||||
|
'EXEC',
|
||||||
|
'EXPL',
|
||||||
|
'GAZL',
|
||||||
|
'LYNX',
|
||||||
|
'NH90',
|
||||||
|
'PUMA',
|
||||||
|
'SCAV',
|
||||||
|
'UH1',
|
||||||
|
'B105',
|
||||||
|
'B212',
|
||||||
|
'B214',
|
||||||
|
'B222',
|
||||||
|
'AC',
|
||||||
|
'H47',
|
||||||
|
'H64',
|
||||||
]);
|
]);
|
||||||
const QUAD = new Set([
|
const QUAD = new Set([
|
||||||
'B741', 'B742', 'B743', 'B744', 'B748', 'B74S', 'B74R', 'B74D', 'A388', 'A342',
|
'B741',
|
||||||
'A343', 'A345', 'A346', 'A124', 'C5M', 'A225', 'IL96', 'B52', 'A140',
|
'B742',
|
||||||
|
'B743',
|
||||||
|
'B744',
|
||||||
|
'B748',
|
||||||
|
'B74S',
|
||||||
|
'B74R',
|
||||||
|
'B74D',
|
||||||
|
'A388',
|
||||||
|
'A342',
|
||||||
|
'A343',
|
||||||
|
'A345',
|
||||||
|
'A346',
|
||||||
|
'A124',
|
||||||
|
'C5M',
|
||||||
|
'A225',
|
||||||
|
'IL96',
|
||||||
|
'B52',
|
||||||
|
'A140',
|
||||||
]);
|
]);
|
||||||
const WIDE = new Set([
|
const WIDE = new Set([
|
||||||
'A306', 'A30B', 'A310', 'A332', 'A333', 'A338', 'A339', 'A359', 'A35K', 'B762',
|
'A306',
|
||||||
'B763', 'B764', 'B772', 'B77L', 'B773', 'B77W', 'B778', 'B779', 'B788', 'B789',
|
'A30B',
|
||||||
'B78X', 'MD11', 'IL86', 'DC10', 'L101', 'A337', 'B767', 'B777', 'B787',
|
'A310',
|
||||||
'C17', 'K35R', // military heavies that read as widebodies
|
'A332',
|
||||||
|
'A333',
|
||||||
|
'A338',
|
||||||
|
'A339',
|
||||||
|
'A359',
|
||||||
|
'A35K',
|
||||||
|
'B762',
|
||||||
|
'B763',
|
||||||
|
'B764',
|
||||||
|
'B772',
|
||||||
|
'B77L',
|
||||||
|
'B773',
|
||||||
|
'B77W',
|
||||||
|
'B778',
|
||||||
|
'B779',
|
||||||
|
'B788',
|
||||||
|
'B789',
|
||||||
|
'B78X',
|
||||||
|
'MD11',
|
||||||
|
'IL86',
|
||||||
|
'DC10',
|
||||||
|
'L101',
|
||||||
|
'A337',
|
||||||
|
'B767',
|
||||||
|
'B777',
|
||||||
|
'B787',
|
||||||
|
'C17',
|
||||||
|
'K35R', // military heavies that read as widebodies
|
||||||
]);
|
]);
|
||||||
const TPROP = new Set([
|
const TPROP = new Set([
|
||||||
'DH8A', 'DH8B', 'DH8C', 'DH8D', 'AT43', 'AT44', 'AT45', 'AT46', 'AT72', 'AT73',
|
'DH8A',
|
||||||
'AT75', 'AT76', 'SF34', 'SB20', 'SW3', 'SW4', 'E110', 'E120', 'C208', 'C212',
|
'DH8B',
|
||||||
'C408', 'PC12', 'B190', 'BE20', 'B350', 'B300', 'JS31', 'JS32', 'JS41', 'D228',
|
'DH8C',
|
||||||
'D328', 'F50', 'F27', 'ATP', 'TBM7', 'TBM8', 'TBM9', 'TBM0', 'PC6', 'C441',
|
'DH8D',
|
||||||
'C425', 'DHC6', 'DHC7', 'C130', 'AN12', 'AN26', 'AN32', 'SH36', 'CVLT', 'SAAB',
|
'AT43',
|
||||||
|
'AT44',
|
||||||
|
'AT45',
|
||||||
|
'AT46',
|
||||||
|
'AT72',
|
||||||
|
'AT73',
|
||||||
|
'AT75',
|
||||||
|
'AT76',
|
||||||
|
'SF34',
|
||||||
|
'SB20',
|
||||||
|
'SW3',
|
||||||
|
'SW4',
|
||||||
|
'E110',
|
||||||
|
'E120',
|
||||||
|
'C208',
|
||||||
|
'C212',
|
||||||
|
'C408',
|
||||||
|
'PC12',
|
||||||
|
'B190',
|
||||||
|
'BE20',
|
||||||
|
'B350',
|
||||||
|
'B300',
|
||||||
|
'JS31',
|
||||||
|
'JS32',
|
||||||
|
'JS41',
|
||||||
|
'D228',
|
||||||
|
'D328',
|
||||||
|
'F50',
|
||||||
|
'F27',
|
||||||
|
'ATP',
|
||||||
|
'TBM7',
|
||||||
|
'TBM8',
|
||||||
|
'TBM9',
|
||||||
|
'TBM0',
|
||||||
|
'PC6',
|
||||||
|
'C441',
|
||||||
|
'C425',
|
||||||
|
'DHC6',
|
||||||
|
'DHC7',
|
||||||
|
'C130',
|
||||||
|
'AN12',
|
||||||
|
'AN26',
|
||||||
|
'AN32',
|
||||||
|
'SH36',
|
||||||
|
'CVLT',
|
||||||
|
'SAAB',
|
||||||
'A400',
|
'A400',
|
||||||
]);
|
]);
|
||||||
const GLIDER = new Set([
|
const GLIDER = new Set([
|
||||||
'DISC', 'DUOD', 'VENT', 'NIMB', 'NIM3', 'NIM4', 'JANS', 'ARCE', 'DG40', 'DG80',
|
'DISC',
|
||||||
'DG1T', 'DG30', 'DG50', 'LS3', 'LS4', 'LS6', 'LS7', 'LS8', 'STD3', 'G103',
|
'DUOD',
|
||||||
'G102', 'G104', 'PW5', 'PW6', 'L13', 'L23', 'L33', 'PIK', 'PEGA', 'KEST',
|
'VENT',
|
||||||
'TWIN', 'AS33', 'ASW', 'ASG', 'ASK', 'VENS', 'GLID', 'MOSQ', 'DIMO',
|
'NIMB',
|
||||||
|
'NIM3',
|
||||||
|
'NIM4',
|
||||||
|
'JANS',
|
||||||
|
'ARCE',
|
||||||
|
'DG40',
|
||||||
|
'DG80',
|
||||||
|
'DG1T',
|
||||||
|
'DG30',
|
||||||
|
'DG50',
|
||||||
|
'LS3',
|
||||||
|
'LS4',
|
||||||
|
'LS6',
|
||||||
|
'LS7',
|
||||||
|
'LS8',
|
||||||
|
'STD3',
|
||||||
|
'G103',
|
||||||
|
'G102',
|
||||||
|
'G104',
|
||||||
|
'PW5',
|
||||||
|
'PW6',
|
||||||
|
'L13',
|
||||||
|
'L23',
|
||||||
|
'L33',
|
||||||
|
'PIK',
|
||||||
|
'PEGA',
|
||||||
|
'KEST',
|
||||||
|
'TWIN',
|
||||||
|
'AS33',
|
||||||
|
'ASW',
|
||||||
|
'ASG',
|
||||||
|
'ASK',
|
||||||
|
'VENS',
|
||||||
|
'GLID',
|
||||||
|
'MOSQ',
|
||||||
|
'DIMO',
|
||||||
]);
|
]);
|
||||||
const LIGHT = new Set([
|
const LIGHT = new Set([
|
||||||
'C150', 'C152', 'C162', 'C172', 'C72R', 'C175', 'C177', 'C180', 'C182', 'C185',
|
'C150',
|
||||||
'C188', 'C206', 'C207', 'C210', 'C310', 'C337', 'SR20', 'SR22', 'S22T', 'PA18',
|
'C152',
|
||||||
'PA24', 'PA28', 'P28A', 'P28B', 'P28R', 'PA32', 'P32R', 'PA34', 'PA38', 'PA44',
|
'C162',
|
||||||
'PA46', 'DA20', 'DA40', 'DA42', 'DA62', 'BE33', 'BE35', 'BE36', 'BE58', 'BE76',
|
'C172',
|
||||||
'BE19', 'BE23', 'BE24', 'M20P', 'M20T', 'AA1', 'AA5', 'GLAS', 'COL4', 'RV4',
|
'C72R',
|
||||||
'RV6', 'RV7', 'RV8', 'RV9', 'RV10', 'RV14', 'GA8', 'G115', 'BL8', 'CH7',
|
'C175',
|
||||||
|
'C177',
|
||||||
|
'C180',
|
||||||
|
'C182',
|
||||||
|
'C185',
|
||||||
|
'C188',
|
||||||
|
'C206',
|
||||||
|
'C207',
|
||||||
|
'C210',
|
||||||
|
'C310',
|
||||||
|
'C337',
|
||||||
|
'SR20',
|
||||||
|
'SR22',
|
||||||
|
'S22T',
|
||||||
|
'PA18',
|
||||||
|
'PA24',
|
||||||
|
'PA28',
|
||||||
|
'P28A',
|
||||||
|
'P28B',
|
||||||
|
'P28R',
|
||||||
|
'PA32',
|
||||||
|
'P32R',
|
||||||
|
'PA34',
|
||||||
|
'PA38',
|
||||||
|
'PA44',
|
||||||
|
'PA46',
|
||||||
|
'DA20',
|
||||||
|
'DA40',
|
||||||
|
'DA42',
|
||||||
|
'DA62',
|
||||||
|
'BE33',
|
||||||
|
'BE35',
|
||||||
|
'BE36',
|
||||||
|
'BE58',
|
||||||
|
'BE76',
|
||||||
|
'BE19',
|
||||||
|
'BE23',
|
||||||
|
'BE24',
|
||||||
|
'M20P',
|
||||||
|
'M20T',
|
||||||
|
'AA1',
|
||||||
|
'AA5',
|
||||||
|
'GLAS',
|
||||||
|
'COL4',
|
||||||
|
'RV4',
|
||||||
|
'RV6',
|
||||||
|
'RV7',
|
||||||
|
'RV8',
|
||||||
|
'RV9',
|
||||||
|
'RV10',
|
||||||
|
'RV14',
|
||||||
|
'GA8',
|
||||||
|
'G115',
|
||||||
|
'BL8',
|
||||||
|
'CH7',
|
||||||
]);
|
]);
|
||||||
// GEV addition (2026-08-15 Hangar fleet): business jets — distinct class so the
|
// GEV addition (2026-08-15 Hangar fleet): business jets — distinct class so the
|
||||||
// Citation II GLB + a slimmer glyph read apart from airliners.
|
// Citation II GLB + a slimmer glyph read apart from airliners.
|
||||||
const BIZJET = new Set([
|
const BIZJET = new Set([
|
||||||
'C500', 'C501', 'C510', 'C525', 'C25A', 'C25B', 'C25C', 'C25M', 'C550',
|
'C500',
|
||||||
'C551', 'C560', 'C56X', 'C650', 'C680', 'C68A', 'C700', 'C750',
|
'C501',
|
||||||
'CL30', 'CL35', 'CL60', 'GLF2', 'GLF3', 'GLF4', 'GLF5', 'GLF6', 'GA5C',
|
'C510',
|
||||||
'GA6C', 'G150', 'G280', 'GL5T', 'GL7T', 'GLEX',
|
'C525',
|
||||||
'LJ23', 'LJ24', 'LJ25', 'LJ31', 'LJ35', 'LJ40', 'LJ45', 'LJ55', 'LJ60',
|
'C25A',
|
||||||
'LJ70', 'LJ75', 'FA10', 'FA20', 'FA50', 'FA7X', 'FA8X', 'F900', 'F2TH',
|
'C25B',
|
||||||
'H25A', 'H25B', 'H25C', 'HDJT', 'E50P', 'E55P', 'E545', 'E550', 'PC24',
|
'C25C',
|
||||||
'PRM1', 'BE40', 'ASTR', 'WW24',
|
'C25M',
|
||||||
|
'C550',
|
||||||
|
'C551',
|
||||||
|
'C560',
|
||||||
|
'C56X',
|
||||||
|
'C650',
|
||||||
|
'C680',
|
||||||
|
'C68A',
|
||||||
|
'C700',
|
||||||
|
'C750',
|
||||||
|
'CL30',
|
||||||
|
'CL35',
|
||||||
|
'CL60',
|
||||||
|
'GLF2',
|
||||||
|
'GLF3',
|
||||||
|
'GLF4',
|
||||||
|
'GLF5',
|
||||||
|
'GLF6',
|
||||||
|
'GA5C',
|
||||||
|
'GA6C',
|
||||||
|
'G150',
|
||||||
|
'G280',
|
||||||
|
'GL5T',
|
||||||
|
'GL7T',
|
||||||
|
'GLEX',
|
||||||
|
'LJ23',
|
||||||
|
'LJ24',
|
||||||
|
'LJ25',
|
||||||
|
'LJ31',
|
||||||
|
'LJ35',
|
||||||
|
'LJ40',
|
||||||
|
'LJ45',
|
||||||
|
'LJ55',
|
||||||
|
'LJ60',
|
||||||
|
'LJ70',
|
||||||
|
'LJ75',
|
||||||
|
'FA10',
|
||||||
|
'FA20',
|
||||||
|
'FA50',
|
||||||
|
'FA7X',
|
||||||
|
'FA8X',
|
||||||
|
'F900',
|
||||||
|
'F2TH',
|
||||||
|
'H25A',
|
||||||
|
'H25B',
|
||||||
|
'H25C',
|
||||||
|
'HDJT',
|
||||||
|
'E50P',
|
||||||
|
'E55P',
|
||||||
|
'E545',
|
||||||
|
'E550',
|
||||||
|
'PC24',
|
||||||
|
'PRM1',
|
||||||
|
'BE40',
|
||||||
|
'ASTR',
|
||||||
|
'WW24',
|
||||||
'SF50', // Cirrus Vision Jet — a JET (was mis-set in LIGHT before the bizjet class existed)
|
'SF50', // Cirrus Vision Jet — a JET (was mis-set in LIGHT before the bizjet class existed)
|
||||||
]);
|
]);
|
||||||
// GEV addition (2026-08-15 Hangar fleet): large UAVs — ICAO designators seen on
|
// GEV addition (2026-08-15 Hangar fleet): large UAVs — ICAO designators seen on
|
||||||
// ADS-B/adsb.lol for Predator/Reaper/Global Hawk-class airframes.
|
// ADS-B/adsb.lol for Predator/Reaper/Global Hawk-class airframes.
|
||||||
const UAV = new Set([
|
const UAV = new Set([
|
||||||
'Q1', 'Q4', 'Q9', 'MQ1', 'MQ4', 'MQ9', 'RQ4', 'TB2', 'SHDW', 'HERN',
|
'Q1',
|
||||||
|
'Q4',
|
||||||
|
'Q9',
|
||||||
|
'MQ1',
|
||||||
|
'MQ4',
|
||||||
|
'MQ9',
|
||||||
|
'RQ4',
|
||||||
|
'TB2',
|
||||||
|
'SHDW',
|
||||||
|
'HERN',
|
||||||
]);
|
]);
|
||||||
// GEV addition: fast jets, for the military layer's adsb.lol `t` codes.
|
// GEV addition: fast jets, for the military layer's adsb.lol `t` codes.
|
||||||
const FASTJET = new Set([
|
const FASTJET = new Set([
|
||||||
'F16', 'F15', 'F18', 'FA18', 'F14', 'F22', 'F35', 'F4', 'F5', 'A10', 'AV8B',
|
'F16',
|
||||||
'TYPH', 'EUFI', 'RFAL', 'RAFL', 'GRIP', 'JAS39', 'TOR', 'MIR2', 'M2000',
|
'F15',
|
||||||
'SU27', 'SU30', 'SU33', 'SU34', 'SU35', 'SU57', 'MG29', 'MIG29', 'MG31', 'J20',
|
'F18',
|
||||||
'T38', 'HAWK', 'L39', 'M346', 'T7A',
|
'FA18',
|
||||||
|
'F14',
|
||||||
|
'F22',
|
||||||
|
'F35',
|
||||||
|
'F4',
|
||||||
|
'F5',
|
||||||
|
'A10',
|
||||||
|
'AV8B',
|
||||||
|
'TYPH',
|
||||||
|
'EUFI',
|
||||||
|
'RFAL',
|
||||||
|
'RAFL',
|
||||||
|
'GRIP',
|
||||||
|
'JAS39',
|
||||||
|
'TOR',
|
||||||
|
'MIR2',
|
||||||
|
'M2000',
|
||||||
|
'SU27',
|
||||||
|
'SU30',
|
||||||
|
'SU33',
|
||||||
|
'SU34',
|
||||||
|
'SU35',
|
||||||
|
'SU57',
|
||||||
|
'MG29',
|
||||||
|
'MIG29',
|
||||||
|
'MG31',
|
||||||
|
'J20',
|
||||||
|
'T38',
|
||||||
|
'HAWK',
|
||||||
|
'L39',
|
||||||
|
'M346',
|
||||||
|
'T7A',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** OpenSky extended-states integer category → class. */
|
/** OpenSky extended-states integer category → class. */
|
||||||
@@ -86,12 +403,20 @@ const OPENSKY_CATEGORY = {
|
|||||||
|
|
||||||
/** ADS-B emitter-category string → class. */
|
/** ADS-B emitter-category string → class. */
|
||||||
const EMITTER_CATEGORY = {
|
const EMITTER_CATEGORY = {
|
||||||
A1: 'light', A2: 'light', A3: 'airliner', A4: 'airliner',
|
A1: 'light',
|
||||||
A5: 'widebody', A6: 'fastjet', A7: 'helicopter', B1: 'glider',
|
A2: 'light',
|
||||||
|
A3: 'airliner',
|
||||||
|
A4: 'airliner',
|
||||||
|
A5: 'widebody',
|
||||||
|
A6: 'fastjet',
|
||||||
|
A7: 'helicopter',
|
||||||
|
B1: 'glider',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function classifyAircraft({ typeCode, category } = {}) {
|
export function classifyAircraft({ typeCode, category } = {}) {
|
||||||
const code = String(typeCode || '').trim().toUpperCase();
|
const code = String(typeCode || '')
|
||||||
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
if (code) {
|
if (code) {
|
||||||
if (FASTJET.has(code)) return 'fastjet';
|
if (FASTJET.has(code)) return 'fastjet';
|
||||||
if (UAV.has(code)) return 'uav';
|
if (UAV.has(code)) return 'uav';
|
||||||
@@ -104,37 +429,59 @@ export function classifyAircraft({ typeCode, category } = {}) {
|
|||||||
if (LIGHT.has(code)) return 'light';
|
if (LIGHT.has(code)) return 'light';
|
||||||
return 'airliner'; // known type code, not in a special set → default jet
|
return 'airliner'; // known type code, not in a special set → default jet
|
||||||
}
|
}
|
||||||
if (Number.isFinite(category) && OPENSKY_CATEGORY[category]) return OPENSKY_CATEGORY[category];
|
if (Number.isFinite(category) && OPENSKY_CATEGORY[category])
|
||||||
const cat = String(category || '').trim().toUpperCase();
|
return OPENSKY_CATEGORY[category];
|
||||||
|
const cat = String(category || '')
|
||||||
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
if (EMITTER_CATEGORY[cat]) return EMITTER_CATEGORY[cat];
|
if (EMITTER_CATEGORY[cat]) return EMITTER_CATEGORY[cat];
|
||||||
return 'airliner';
|
return 'airliner';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
|
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
|
||||||
export const CLASS_SCALE_2D = {
|
export const CLASS_SCALE_2D = {
|
||||||
light: 0.62, glider: 0.58, turboprop: 0.86, airliner: 1.0,
|
light: 0.62,
|
||||||
widebody: 1.3, quadjet: 1.45, helicopter: 0.82, fastjet: 0.8,
|
glider: 0.58,
|
||||||
bizjet: 0.72, uav: 0.75,
|
turboprop: 0.86,
|
||||||
|
airliner: 1.0,
|
||||||
|
widebody: 1.3,
|
||||||
|
quadjet: 1.45,
|
||||||
|
helicopter: 0.82,
|
||||||
|
fastjet: 0.8,
|
||||||
|
bizjet: 0.72,
|
||||||
|
uav: 0.75,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 3D model scale multipliers — clamped [0.75, 1.45] while every class shares
|
/** 3D model scale multipliers — clamped [0.75, 1.45] while every class shares
|
||||||
* the single airplane.glb (a jet mesh at C172 scale reads wrong). Widen when
|
* the single airplane.glb (a jet mesh at C172 scale reads wrong). Widen when
|
||||||
* real per-class models land in CLASS_MODEL_URL. */
|
* real per-class models land in CLASS_MODEL_URL. */
|
||||||
export const CLASS_SCALE_3D = {
|
export const CLASS_SCALE_3D = {
|
||||||
light: 0.75, glider: 0.75, turboprop: 0.85, airliner: 1.0,
|
light: 0.75,
|
||||||
widebody: 1.3, quadjet: 1.45, helicopter: 0.8, fastjet: 0.8,
|
glider: 0.75,
|
||||||
bizjet: 0.8, uav: 0.8,
|
turboprop: 0.85,
|
||||||
|
airliner: 1.0,
|
||||||
|
widebody: 1.3,
|
||||||
|
quadjet: 1.45,
|
||||||
|
helicopter: 0.8,
|
||||||
|
fastjet: 0.8,
|
||||||
|
bizjet: 0.8,
|
||||||
|
uav: 0.8,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Per-class glTF — all the shared airplane today; drop real assets in here.
|
/** Per-class glTF — all the shared airplane today; drop real assets in here.
|
||||||
* NOTE for future models: each asset may need its own heading offset (the
|
* NOTE for future models: each asset may need its own heading offset (the
|
||||||
* shared GLB uses MODEL_HEADING_OFFSET_DEG = 180 in both layers). */
|
* shared GLB uses MODEL_HEADING_OFFSET_DEG = 180 in both layers). */
|
||||||
export const CLASS_MODEL_URL = {
|
export const CLASS_MODEL_URL = {
|
||||||
light: '/models/airplane.glb', glider: '/models/airplane.glb',
|
light: '/models/airplane.glb',
|
||||||
turboprop: '/models/airplane.glb', airliner: '/models/airplane.glb',
|
glider: '/models/airplane.glb',
|
||||||
widebody: '/models/airplane.glb', quadjet: '/models/airplane.glb',
|
turboprop: '/models/airplane.glb',
|
||||||
helicopter: '/models/airplane.glb', fastjet: '/models/airplane.glb',
|
airliner: '/models/airplane.glb',
|
||||||
bizjet: '/models/airplane.glb', uav: '/models/airplane.glb',
|
widebody: '/models/airplane.glb',
|
||||||
|
quadjet: '/models/airplane.glb',
|
||||||
|
helicopter: '/models/airplane.glb',
|
||||||
|
fastjet: '/models/airplane.glb',
|
||||||
|
bizjet: '/models/airplane.glb',
|
||||||
|
uav: '/models/airplane.glb',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Real per-class GLBs (2026-08-15 Hangar fleet, owner picks; CC-BY 4.0 —
|
/** Real per-class GLBs (2026-08-15 Hangar fleet, owner picks; CC-BY 4.0 —
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ const VIEW = 96;
|
|||||||
const C = VIEW / 2; // 48 — glyph centre
|
const C = VIEW / 2; // 48 — glyph centre
|
||||||
|
|
||||||
// Hairline dark edge (scaled for the 96 box: ~1.5u ≈ the old 0.5u at 32).
|
// Hairline dark edge (scaled for the 96 box: ~1.5u ≈ the old 0.5u at 32).
|
||||||
const STROKE = 'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"';
|
const STROKE =
|
||||||
|
'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"';
|
||||||
// Heavier edge for the chart-symbol ("bold") glyphs adopted 2026-08-15.
|
// Heavier edge for the chart-symbol ("bold") glyphs adopted 2026-08-15.
|
||||||
const STROKE_BOLD = 'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"';
|
const STROKE_BOLD =
|
||||||
|
'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"';
|
||||||
// Softer white for translucent detail (prop discs, rotor disc) — still white so
|
// Softer white for translucent detail (prop discs, rotor disc) — still white so
|
||||||
// the tint multiplies cleanly; only the alpha differs.
|
// the tint multiplies cleanly; only the alpha differs.
|
||||||
const DISC = 'fill="white" fill-opacity="0.5"';
|
const DISC = 'fill="white" fill-opacity="0.5"';
|
||||||
@@ -239,7 +241,9 @@ const BODIES = {
|
|||||||
const _iconCache = new Map();
|
const _iconCache = new Map();
|
||||||
|
|
||||||
const _b64 = (s) =>
|
const _b64 = (s) =>
|
||||||
typeof btoa === 'function' ? btoa(s) : Buffer.from(s, 'utf8').toString('base64');
|
typeof btoa === 'function'
|
||||||
|
? btoa(s)
|
||||||
|
: Buffer.from(s, 'utf8').toString('base64');
|
||||||
|
|
||||||
/** Fleet raster: billboards render at ~40–58 DEVICE px (width 20–24 CSS ×
|
/** Fleet raster: billboards render at ~40–58 DEVICE px (width 20–24 CSS ×
|
||||||
* Retina × class scale). Cesium's billboard atlas has no mipmaps, so a big
|
* Retina × class scale). Cesium's billboard atlas has no mipmaps, so a big
|
||||||
|
|||||||
@@ -11,5 +11,7 @@ export function aircraftIncludedInNearby({
|
|||||||
modelRendering = false,
|
modelRendering = false,
|
||||||
includeHidden = false,
|
includeHidden = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return Boolean(includeHidden || isTracked || billboardShown || modelRendering);
|
return Boolean(
|
||||||
|
includeHidden || isTracked || billboardShown || modelRendering,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
|
|||||||
startLimbRatio: 0.5,
|
startLimbRatio: 0.5,
|
||||||
scaleFloor: 0.45,
|
scaleFloor: 0.45,
|
||||||
alphaFloor: 0.35,
|
alphaFloor: 0.35,
|
||||||
combinedAlphaFloor: 0.20,
|
combinedAlphaFloor: 0.2,
|
||||||
globeViewBlendStartM: 3_500_000,
|
globeViewBlendStartM: 3_500_000,
|
||||||
globeViewBlendEndM: 4_500_000,
|
globeViewBlendEndM: 4_500_000,
|
||||||
earthRadiusM: 6_378_137,
|
earthRadiusM: 6_378_137,
|
||||||
@@ -36,10 +36,19 @@ function resolvedParams(overrides) {
|
|||||||
return { ..._params, ...overrides };
|
return { ..._params, ...overrides };
|
||||||
}
|
}
|
||||||
|
|
||||||
function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning, result) {
|
function aircraftRecessionFactorsResolved(
|
||||||
if (!Number.isFinite(cameraDistanceM) || cameraDistanceM < 0
|
cameraDistanceM,
|
||||||
|| !Number.isFinite(cameraHeightM) || cameraHeightM <= 0
|
cameraHeightM,
|
||||||
|| cameraHeightM >= tuning.globeViewBlendEndM) {
|
tuning,
|
||||||
|
result,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(cameraDistanceM) ||
|
||||||
|
cameraDistanceM < 0 ||
|
||||||
|
!Number.isFinite(cameraHeightM) ||
|
||||||
|
cameraHeightM <= 0 ||
|
||||||
|
cameraHeightM >= tuning.globeViewBlendEndM
|
||||||
|
) {
|
||||||
result.scale = 1;
|
result.scale = 1;
|
||||||
result.alpha = 1;
|
result.alpha = 1;
|
||||||
result.limbRatio = null;
|
result.limbRatio = null;
|
||||||
@@ -60,11 +69,13 @@ function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawT = (limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio);
|
const rawT =
|
||||||
|
(limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio);
|
||||||
const t = clamp(rawT, 0, 1);
|
const t = clamp(rawT, 0, 1);
|
||||||
const limbEase = t * t * (3 - 2 * t); // smoothstep: zero slope at both band edges
|
const limbEase = t * t * (3 - 2 * t); // smoothstep: zero slope at both band edges
|
||||||
const globeRawT = (cameraHeightM - tuning.globeViewBlendStartM)
|
const globeRawT =
|
||||||
/ (tuning.globeViewBlendEndM - tuning.globeViewBlendStartM);
|
(cameraHeightM - tuning.globeViewBlendStartM) /
|
||||||
|
(tuning.globeViewBlendEndM - tuning.globeViewBlendStartM);
|
||||||
const globeT = clamp(globeRawT, 0, 1);
|
const globeT = clamp(globeRawT, 0, 1);
|
||||||
const globeEase = globeT * globeT * (3 - 2 * globeT);
|
const globeEase = globeT * globeT * (3 - 2 * globeT);
|
||||||
const strength = 1 - globeEase;
|
const strength = 1 - globeEase;
|
||||||
@@ -84,8 +95,10 @@ export function setAircraftRecessionParams(patch = {}) {
|
|||||||
if (Number.isFinite(patch.startLimbRatio)) {
|
if (Number.isFinite(patch.startLimbRatio)) {
|
||||||
next.startLimbRatio = clamp(patch.startLimbRatio, 0, 0.99);
|
next.startLimbRatio = clamp(patch.startLimbRatio, 0, 0.99);
|
||||||
}
|
}
|
||||||
if (Number.isFinite(patch.scaleFloor)) next.scaleFloor = clamp(patch.scaleFloor, 0.05, 1);
|
if (Number.isFinite(patch.scaleFloor))
|
||||||
if (Number.isFinite(patch.alphaFloor)) next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1);
|
next.scaleFloor = clamp(patch.scaleFloor, 0.05, 1);
|
||||||
|
if (Number.isFinite(patch.alphaFloor))
|
||||||
|
next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1);
|
||||||
if (Number.isFinite(patch.combinedAlphaFloor)) {
|
if (Number.isFinite(patch.combinedAlphaFloor)) {
|
||||||
next.combinedAlphaFloor = clamp(patch.combinedAlphaFloor, 0.05, 1);
|
next.combinedAlphaFloor = clamp(patch.combinedAlphaFloor, 0.05, 1);
|
||||||
}
|
}
|
||||||
@@ -109,8 +122,10 @@ export function setAircraftRecessionParams(patch = {}) {
|
|||||||
if (next.globeViewBlendStartM >= next.globeViewBlendEndM) {
|
if (next.globeViewBlendStartM >= next.globeViewBlendEndM) {
|
||||||
next.globeViewBlendStartM = Math.max(1, next.globeViewBlendEndM - 1);
|
next.globeViewBlendStartM = Math.max(1, next.globeViewBlendEndM - 1);
|
||||||
}
|
}
|
||||||
if (Number.isFinite(patch.earthRadiusM)) next.earthRadiusM = Math.max(1, patch.earthRadiusM);
|
if (Number.isFinite(patch.earthRadiusM))
|
||||||
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon);
|
next.earthRadiusM = Math.max(1, patch.earthRadiusM);
|
||||||
|
if (Number.isFinite(patch.writeEpsilon))
|
||||||
|
next.writeEpsilon = Math.max(0, patch.writeEpsilon);
|
||||||
_params = next;
|
_params = next;
|
||||||
return { ..._params };
|
return { ..._params };
|
||||||
}
|
}
|
||||||
@@ -149,7 +164,10 @@ export function cameraLimbDistanceM(
|
|||||||
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [params]
|
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [params]
|
||||||
* @returns {{scale:number,alpha:number,limbRatio:number|null}}
|
* @returns {{scale:number,alpha:number,limbRatio:number|null}}
|
||||||
*/
|
*/
|
||||||
export function aircraftRecessionFactors({ cameraDistanceM, cameraHeightM }, params) {
|
export function aircraftRecessionFactors(
|
||||||
|
{ cameraDistanceM, cameraHeightM },
|
||||||
|
params,
|
||||||
|
) {
|
||||||
const tuning = resolvedParams(params);
|
const tuning = resolvedParams(params);
|
||||||
return aircraftRecessionFactorsResolved(
|
return aircraftRecessionFactorsResolved(
|
||||||
cameraDistanceM,
|
cameraDistanceM,
|
||||||
@@ -202,12 +220,17 @@ export function applyAircraftBillboardTreatment({
|
|||||||
const alpha = baseAlpha * composedTreatment;
|
const alpha = baseAlpha * composedTreatment;
|
||||||
let scaleWrites = 0;
|
let scaleWrites = 0;
|
||||||
let alphaWrites = 0;
|
let alphaWrites = 0;
|
||||||
if (!Number.isFinite(billboard.scale) || Math.abs(billboard.scale - scale) > tuning.writeEpsilon) {
|
if (
|
||||||
|
!Number.isFinite(billboard.scale) ||
|
||||||
|
Math.abs(billboard.scale - scale) > tuning.writeEpsilon
|
||||||
|
) {
|
||||||
billboard.scale = scale;
|
billboard.scale = scale;
|
||||||
scaleWrites = 1;
|
scaleWrites = 1;
|
||||||
}
|
}
|
||||||
if (!Number.isFinite(billboard.color?.alpha)
|
if (
|
||||||
|| Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon) {
|
!Number.isFinite(billboard.color?.alpha) ||
|
||||||
|
Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon
|
||||||
|
) {
|
||||||
billboard.color = baseColor.withAlpha(alpha);
|
billboard.color = baseColor.withAlpha(alpha);
|
||||||
alphaWrites = 1;
|
alphaWrites = 1;
|
||||||
}
|
}
|
||||||
@@ -228,11 +251,19 @@ export function applyAircraftBillboardTreatment({
|
|||||||
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
|
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
|
||||||
* @returns {number} One when a color write occurred, otherwise zero.
|
* @returns {number} One when a color write occurred, otherwise zero.
|
||||||
*/
|
*/
|
||||||
export function applyAircraftModelTreatment({ model, baseColor, alpha, params }) {
|
export function applyAircraftModelTreatment({
|
||||||
|
model,
|
||||||
|
baseColor,
|
||||||
|
alpha,
|
||||||
|
params,
|
||||||
|
}) {
|
||||||
const tuning = resolvedParams(params);
|
const tuning = resolvedParams(params);
|
||||||
if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
|
if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
|
||||||
if (Number.isFinite(model.color?.alpha)
|
if (
|
||||||
&& Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon) return 0;
|
Number.isFinite(model.color?.alpha) &&
|
||||||
|
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon
|
||||||
|
)
|
||||||
|
return 0;
|
||||||
model.color = baseColor.withAlpha(alpha);
|
model.color = baseColor.withAlpha(alpha);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
+107
-37
@@ -28,11 +28,39 @@ import { pointInRing } from './naturalEarthRegions.js';
|
|||||||
|
|
||||||
/** Layers the engine understands, with the fields queries may reference. */
|
/** Layers the engine understands, with the fields queries may reference. */
|
||||||
export const ANALYST_LAYERS = {
|
export const ANALYST_LAYERS = {
|
||||||
flights: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass'], flags: ['military', 'onGround'] },
|
flights: {
|
||||||
military: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'], flags: ['military', 'onGround'] },
|
numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
|
||||||
'ais-live-vessels': { numeric: ['speedKts', 'courseDeg'], text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'], flags: [] },
|
text: [
|
||||||
'local-firms': { numeric: ['frp'], text: ['confidence', 'satellite'], flags: [] },
|
'callsign',
|
||||||
earthquakes: { numeric: ['magnitude', 'depthKm'], text: ['place'], flags: [] },
|
'icao24',
|
||||||
|
'originCountry',
|
||||||
|
'operator',
|
||||||
|
'routeOrigin',
|
||||||
|
'routeDestination',
|
||||||
|
'aircraftClass',
|
||||||
|
],
|
||||||
|
flags: ['military', 'onGround'],
|
||||||
|
},
|
||||||
|
military: {
|
||||||
|
numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
|
||||||
|
text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'],
|
||||||
|
flags: ['military', 'onGround'],
|
||||||
|
},
|
||||||
|
'ais-live-vessels': {
|
||||||
|
numeric: ['speedKts', 'courseDeg'],
|
||||||
|
text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'],
|
||||||
|
flags: [],
|
||||||
|
},
|
||||||
|
'local-firms': {
|
||||||
|
numeric: ['frp'],
|
||||||
|
text: ['confidence', 'satellite'],
|
||||||
|
flags: [],
|
||||||
|
},
|
||||||
|
earthquakes: {
|
||||||
|
numeric: ['magnitude', 'depthKm'],
|
||||||
|
text: ['place'],
|
||||||
|
flags: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const EARTH_R_KM = 6371;
|
const EARTH_R_KM = 6371;
|
||||||
@@ -42,8 +70,9 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
|
|||||||
const d2r = Math.PI / 180;
|
const d2r = Math.PI / 180;
|
||||||
const dLat = (lat2 - lat1) * d2r;
|
const dLat = (lat2 - lat1) * d2r;
|
||||||
const dLon = (lon2 - lon1) * d2r;
|
const dLon = (lon2 - lon1) * d2r;
|
||||||
const a = Math.sin(dLat / 2) ** 2
|
const a =
|
||||||
+ Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
|
||||||
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,17 +84,25 @@ export function applyFilter(records, filter) {
|
|||||||
const got = r[field];
|
const got = r[field];
|
||||||
if (got === null || got === undefined) return false;
|
if (got === null || got === undefined) return false;
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case 'gt': return Number(got) > Number(value);
|
case 'gt':
|
||||||
case 'gte': return Number(got) >= Number(value);
|
return Number(got) > Number(value);
|
||||||
case 'lt': return Number(got) < Number(value);
|
case 'gte':
|
||||||
case 'lte': return Number(got) <= Number(value);
|
return Number(got) >= Number(value);
|
||||||
|
case 'lt':
|
||||||
|
return Number(got) < Number(value);
|
||||||
|
case 'lte':
|
||||||
|
return Number(got) <= Number(value);
|
||||||
case 'eq': {
|
case 'eq': {
|
||||||
if (typeof got === 'boolean' || typeof value === 'boolean') return Boolean(got) === Boolean(value);
|
if (typeof got === 'boolean' || typeof value === 'boolean')
|
||||||
|
return Boolean(got) === Boolean(value);
|
||||||
return String(got).toLowerCase() === String(value).toLowerCase();
|
return String(got).toLowerCase() === String(value).toLowerCase();
|
||||||
}
|
}
|
||||||
case 'neq': return String(got).toLowerCase() !== String(value).toLowerCase();
|
case 'neq':
|
||||||
case 'contains': return String(got).toLowerCase().includes(String(value).toLowerCase());
|
return String(got).toLowerCase() !== String(value).toLowerCase();
|
||||||
default: return true;
|
case 'contains':
|
||||||
|
return String(got).toLowerCase().includes(String(value).toLowerCase());
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -74,15 +111,23 @@ export function applyFilter(records, filter) {
|
|||||||
export function applyScope(records, scope, resolved) {
|
export function applyScope(records, scope, resolved) {
|
||||||
if (!scope || scope.kind === 'anywhere') return records;
|
if (!scope || scope.kind === 'anywhere') return records;
|
||||||
if (scope.kind === 'region' && resolved?.ring) {
|
if (scope.kind === 'region' && resolved?.ring) {
|
||||||
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon)
|
return records.filter(
|
||||||
&& pointInRing(resolved.ring, r.lat, r.lon));
|
(r) =>
|
||||||
|
Number.isFinite(r.lat) &&
|
||||||
|
Number.isFinite(r.lon) &&
|
||||||
|
pointInRing(resolved.ring, r.lat, r.lon),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (scope.kind === 'radius' || scope.kind === 'view') {
|
if (scope.kind === 'radius' || scope.kind === 'view') {
|
||||||
const c = resolved?.center;
|
const c = resolved?.center;
|
||||||
const km = resolved?.km;
|
const km = resolved?.km;
|
||||||
if (!c || !Number.isFinite(km)) return records;
|
if (!c || !Number.isFinite(km)) return records;
|
||||||
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon)
|
return records.filter(
|
||||||
&& haversineKm(c.lat, c.lon, r.lat, r.lon) <= km);
|
(r) =>
|
||||||
|
Number.isFinite(r.lat) &&
|
||||||
|
Number.isFinite(r.lon) &&
|
||||||
|
haversineKm(c.lat, c.lon, r.lat, r.lon) <= km,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
@@ -108,9 +153,12 @@ export function createAnalystEngine(providers) {
|
|||||||
let lastResult = null;
|
let lastResult = null;
|
||||||
|
|
||||||
async function query(spec = {}) {
|
async function query(spec = {}) {
|
||||||
const layers = (spec.followUp && lastResult)
|
const layers =
|
||||||
|
spec.followUp && lastResult
|
||||||
? null // follow-up: re-filter the remembered set, no re-snapshot
|
? null // follow-up: re-filter the remembered set, no re-snapshot
|
||||||
: (Array.isArray(spec.layers) && spec.layers.length ? spec.layers : ['flights']);
|
: Array.isArray(spec.layers) && spec.layers.length
|
||||||
|
? spec.layers
|
||||||
|
: ['flights'];
|
||||||
|
|
||||||
// 1) Source records
|
// 1) Source records
|
||||||
let records;
|
let records;
|
||||||
@@ -164,20 +212,23 @@ export function createAnalystEngine(providers) {
|
|||||||
// camera made the two disagree — a parked, high-altitude camera answered
|
// camera made the two disagree — a parked, high-altitude camera answered
|
||||||
// "46 within 250 km" while the panel showed a far larger contact-centred
|
// "46 within 250 km" while the panel showed a far larger contact-centred
|
||||||
// count. Same question, two numbers.
|
// count. Same question, two numbers.
|
||||||
const explicitCenter = scope.center && Number.isFinite(scope.center.lat)
|
const explicitCenter =
|
||||||
? scope.center
|
scope.center && Number.isFinite(scope.center.lat) ? scope.center : null;
|
||||||
: null;
|
const subject = explicitCenter
|
||||||
const subject = explicitCenter ? null : providers.getContextSubject?.() || null;
|
? null
|
||||||
|
: providers.getContextSubject?.() || null;
|
||||||
const view = providers.getViewContext();
|
const view = providers.getViewContext();
|
||||||
// Only a subject that actually SUPPLIED the centre may name it. A
|
// Only a subject that actually SUPPLIED the centre may name it. A
|
||||||
// subject present but without usable coordinates silently fell back to
|
// subject present but without usable coordinates silently fell back to
|
||||||
// the camera while the label still read "within 250 km of <contact>" —
|
// the camera while the label still read "within 250 km of <contact>" —
|
||||||
// a count centred on one place, reported as centred on another, with
|
// a count centred on one place, reported as centred on another, with
|
||||||
// nothing in the payload to show which.
|
// nothing in the payload to show which.
|
||||||
const subjectCenter = Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
|
const subjectCenter =
|
||||||
|
Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
|
||||||
? { lat: subject.lat, lon: subject.lon }
|
? { lat: subject.lat, lon: subject.lon }
|
||||||
: null;
|
: null;
|
||||||
const center = explicitCenter || subjectCenter || { lat: view.lat, lon: view.lon };
|
const center = explicitCenter ||
|
||||||
|
subjectCenter || { lat: view.lat, lon: view.lon };
|
||||||
resolvedScope = { center, km: Number(scope.km) || 100 };
|
resolvedScope = { center, km: Number(scope.km) || 100 };
|
||||||
if (subjectCenter) resolvedScope.centeredOn = subject.label || null;
|
if (subjectCenter) resolvedScope.centeredOn = subject.label || null;
|
||||||
scopeNote = resolvedScope.centeredOn
|
scopeNote = resolvedScope.centeredOn
|
||||||
@@ -188,7 +239,10 @@ export function createAnalystEngine(providers) {
|
|||||||
: `within ${resolvedScope.km} km`;
|
: `within ${resolvedScope.km} km`;
|
||||||
} else if (scope.kind === 'view') {
|
} else if (scope.kind === 'view') {
|
||||||
const view = providers.getViewContext();
|
const view = providers.getViewContext();
|
||||||
resolvedScope = { center: { lat: view.lat, lon: view.lon }, km: view.viewRadiusKm };
|
resolvedScope = {
|
||||||
|
center: { lat: view.lat, lon: view.lon },
|
||||||
|
km: view.viewRadiusKm,
|
||||||
|
};
|
||||||
scopeNote = `view:${Math.round(view.viewRadiusKm)}km`;
|
scopeNote = `view:${Math.round(view.viewRadiusKm)}km`;
|
||||||
scopeLabel = 'in view';
|
scopeLabel = 'in view';
|
||||||
} else {
|
} else {
|
||||||
@@ -205,15 +259,22 @@ export function createAnalystEngine(providers) {
|
|||||||
if (sortBy === 'distance') {
|
if (sortBy === 'distance') {
|
||||||
const ref = resolvedScope?.center || providers.getViewContext();
|
const ref = resolvedScope?.center || providers.getViewContext();
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
it.distanceKm = (Number.isFinite(it.lat) && Number.isFinite(it.lon))
|
it.distanceKm =
|
||||||
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) / 10 : null;
|
Number.isFinite(it.lat) && Number.isFinite(it.lon)
|
||||||
|
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) /
|
||||||
|
10
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (sortBy) {
|
if (sortBy) {
|
||||||
const dir = spec.sortDir === 'asc' ? 1 : -1;
|
const dir = spec.sortDir === 'asc' ? 1 : -1;
|
||||||
items.sort((a, b) => (Number(a[sortBy]) - Number(b[sortBy])) * dir
|
items.sort(
|
||||||
|| String(a.id).localeCompare(String(b.id)));
|
(a, b) =>
|
||||||
if (sortBy === 'distance') items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9));
|
(Number(a[sortBy]) - Number(b[sortBy])) * dir ||
|
||||||
|
String(a.id).localeCompare(String(b.id)),
|
||||||
|
);
|
||||||
|
if (sortBy === 'distance')
|
||||||
|
items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9));
|
||||||
}
|
}
|
||||||
const limit = Math.max(1, Math.min(50, Number(spec.limit) || 10));
|
const limit = Math.max(1, Math.min(50, Number(spec.limit) || 10));
|
||||||
const top = items.slice(0, limit);
|
const top = items.slice(0, limit);
|
||||||
@@ -223,7 +284,10 @@ export function createAnalystEngine(providers) {
|
|||||||
count: items.length,
|
count: items.length,
|
||||||
items: top,
|
items: top,
|
||||||
truncated: items.length > top.length,
|
truncated: items.length > top.length,
|
||||||
summary: summarize(items, sortBy && sortBy !== 'distance' ? sortBy : null),
|
summary: summarize(
|
||||||
|
items,
|
||||||
|
sortBy && sortBy !== 'distance' ? sortBy : null,
|
||||||
|
),
|
||||||
scopeLabel,
|
scopeLabel,
|
||||||
coverage: {
|
coverage: {
|
||||||
layersQueried,
|
layersQueried,
|
||||||
@@ -233,7 +297,9 @@ export function createAnalystEngine(providers) {
|
|||||||
},
|
},
|
||||||
// Surfaced so the narration can name the centre it measured from rather
|
// Surfaced so the narration can name the centre it measured from rather
|
||||||
// than implying a view-centred answer.
|
// than implying a view-centred answer.
|
||||||
...(resolvedScope?.centeredOn ? { centeredOn: resolvedScope.centeredOn } : {}),
|
...(resolvedScope?.centeredOn
|
||||||
|
? { centeredOn: resolvedScope.centeredOn }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
lastResult = { items, coverage: result.coverage };
|
lastResult = { items, coverage: result.coverage };
|
||||||
return result;
|
return result;
|
||||||
@@ -241,7 +307,11 @@ export function createAnalystEngine(providers) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
query,
|
query,
|
||||||
reset() { lastResult = null; },
|
reset() {
|
||||||
hasMemory() { return Boolean(lastResult); },
|
lastResult = null;
|
||||||
|
},
|
||||||
|
hasMemory() {
|
||||||
|
return Boolean(lastResult);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+67
-19
@@ -110,8 +110,12 @@ export const CCTV_THUMBNAIL_ALTITUDE_SCALE = Object.freeze({
|
|||||||
*/
|
*/
|
||||||
export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
|
export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
|
||||||
if (pinned) return Number.isFinite(sy);
|
if (pinned) return Number.isFinite(sy);
|
||||||
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0) return false;
|
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0)
|
||||||
const safeTop = Math.min(CCTV_CARD_SAFE_TOP_MAX_PX, viewH * CCTV_CARD_SAFE_TOP_RATIO);
|
return false;
|
||||||
|
const safeTop = Math.min(
|
||||||
|
CCTV_CARD_SAFE_TOP_MAX_PX,
|
||||||
|
viewH * CCTV_CARD_SAFE_TOP_RATIO,
|
||||||
|
);
|
||||||
return sy >= safeTop;
|
return sy >= safeTop;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,11 +130,24 @@ export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
|
|||||||
* @param {number} [options.limit]
|
* @param {number} [options.limit]
|
||||||
* @returns {string[]} Accepted ids, nearest-first.
|
* @returns {string[]} Accepted ids, nearest-first.
|
||||||
*/
|
*/
|
||||||
export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {}) {
|
export function declutterCctvCards(
|
||||||
|
candidates,
|
||||||
|
{ minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {},
|
||||||
|
) {
|
||||||
const valid = (Array.isArray(candidates) ? candidates : [])
|
const valid = (Array.isArray(candidates) ? candidates : [])
|
||||||
.filter((c) => c && typeof c.id === 'string' && c.id
|
.filter(
|
||||||
&& Number.isFinite(c.sx) && Number.isFinite(c.sy))
|
(c) =>
|
||||||
.sort((a, b) => (a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) || a.id.localeCompare(b.id));
|
c &&
|
||||||
|
typeof c.id === 'string' &&
|
||||||
|
c.id &&
|
||||||
|
Number.isFinite(c.sx) &&
|
||||||
|
Number.isFinite(c.sy),
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) ||
|
||||||
|
a.id.localeCompare(b.id),
|
||||||
|
);
|
||||||
const minSq = minSepPx * minSepPx;
|
const minSq = minSepPx * minSepPx;
|
||||||
const accepted = [];
|
const accepted = [];
|
||||||
const acceptedIds = [];
|
const acceptedIds = [];
|
||||||
@@ -140,7 +157,10 @@ export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX
|
|||||||
for (let i = 0; i < accepted.length; i++) {
|
for (let i = 0; i < accepted.length; i++) {
|
||||||
const dx = candidate.sx - accepted[i].sx;
|
const dx = candidate.sx - accepted[i].sx;
|
||||||
const dy = candidate.sy - accepted[i].sy;
|
const dy = candidate.sy - accepted[i].sy;
|
||||||
if (dx * dx + dy * dy < minSq) { clear = false; break; }
|
if (dx * dx + dy * dy < minSq) {
|
||||||
|
clear = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!clear) continue;
|
if (!clear) continue;
|
||||||
accepted.push(candidate);
|
accepted.push(candidate);
|
||||||
@@ -165,15 +185,26 @@ export function cardScaleForAltitude(cameraHeightM) {
|
|||||||
const h = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : 0;
|
const h = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : 0;
|
||||||
if (h <= CCTV_CARD_SCALE_FULL_M) return { scale: 1, alpha: 1 };
|
if (h <= CCTV_CARD_SCALE_FULL_M) return { scale: 1, alpha: 1 };
|
||||||
if (h <= CCTV_CARD_SCALE_MID_M) {
|
if (h <= CCTV_CARD_SCALE_MID_M) {
|
||||||
const t = (h - CCTV_CARD_SCALE_FULL_M) / (CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M);
|
const t =
|
||||||
|
(h - CCTV_CARD_SCALE_FULL_M) /
|
||||||
|
(CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M);
|
||||||
const s = t * t * (3 - 2 * t); // smoothstep — no visible kink at either end
|
const s = t * t * (3 - 2 * t); // smoothstep — no visible kink at either end
|
||||||
return { scale: 1 + (CCTV_CARD_SCALE_AT_MID - 1) * s, alpha: 1 };
|
return { scale: 1 + (CCTV_CARD_SCALE_AT_MID - 1) * s, alpha: 1 };
|
||||||
}
|
}
|
||||||
const t = Math.min(1, (h - CCTV_CARD_SCALE_MID_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M));
|
const t = Math.min(
|
||||||
const scale = CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t;
|
1,
|
||||||
if (h >= CCTV_CARD_FADE_END_M) return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 };
|
(h - CCTV_CARD_SCALE_MID_M) /
|
||||||
|
(CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M),
|
||||||
|
);
|
||||||
|
const scale =
|
||||||
|
CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t;
|
||||||
|
if (h >= CCTV_CARD_FADE_END_M)
|
||||||
|
return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 };
|
||||||
if (h <= CCTV_CARD_FADE_START_M) return { scale, alpha: 1 };
|
if (h <= CCTV_CARD_FADE_START_M) return { scale, alpha: 1 };
|
||||||
const alpha = 1 - (h - CCTV_CARD_FADE_START_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
|
const alpha =
|
||||||
|
1 -
|
||||||
|
(h - CCTV_CARD_FADE_START_M) /
|
||||||
|
(CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
|
||||||
return { scale, alpha };
|
return { scale, alpha };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,17 +223,23 @@ export function cardScaleForAltitude(cameraHeightM) {
|
|||||||
* @param {number} [input.sinceLastLaunchMs] - Ms since the last fetch launch.
|
* @param {number} [input.sinceLastLaunchMs] - Ms since the last fetch launch.
|
||||||
* @returns {{mode:('burst'|'steady'), launch:boolean}}
|
* @returns {{mode:('burst'|'steady'), launch:boolean}}
|
||||||
*/
|
*/
|
||||||
export function cardFetchPolicy({ coldFill = false, inFlight = 0, sinceLastLaunchMs = Infinity } = {}) {
|
export function cardFetchPolicy({
|
||||||
|
coldFill = false,
|
||||||
|
inFlight = 0,
|
||||||
|
sinceLastLaunchMs = Infinity,
|
||||||
|
} = {}) {
|
||||||
if (coldFill) {
|
if (coldFill) {
|
||||||
return {
|
return {
|
||||||
mode: 'burst',
|
mode: 'burst',
|
||||||
launch: inFlight < CCTV_CARD_FETCH_BURST_LIMIT
|
launch:
|
||||||
&& sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
|
inFlight < CCTV_CARD_FETCH_BURST_LIMIT &&
|
||||||
|
sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
mode: 'steady',
|
mode: 'steady',
|
||||||
launch: inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
|
launch:
|
||||||
|
inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +267,12 @@ export function createFrameSlot() {
|
|||||||
export function applyFrameResult(prev, result, nowMs) {
|
export function applyFrameResult(prev, result, nowMs) {
|
||||||
const base = prev || createFrameSlot();
|
const base = prev || createFrameSlot();
|
||||||
if (result?.ok && result.frame) {
|
if (result?.ok && result.frame) {
|
||||||
return { frame: result.frame, stamp: nowMs, failCount: 0, lastAttemptAt: nowMs };
|
return {
|
||||||
|
frame: result.frame,
|
||||||
|
stamp: nowMs,
|
||||||
|
failCount: 0,
|
||||||
|
lastAttemptAt: nowMs,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
frame: base.frame,
|
frame: base.frame,
|
||||||
@@ -278,11 +320,17 @@ export function frameFetchDue(slot, refreshMs, nowMs) {
|
|||||||
* @param {number} [cap]
|
* @param {number} [cap]
|
||||||
* @returns {string[]} Ids to drop from the cache.
|
* @returns {string[]} Ids to drop from the cache.
|
||||||
*/
|
*/
|
||||||
export function planFrameCachePrune(slots, keepIds, cap = CCTV_FRAME_CACHE_MAX) {
|
export function planFrameCachePrune(
|
||||||
|
slots,
|
||||||
|
keepIds,
|
||||||
|
cap = CCTV_FRAME_CACHE_MAX,
|
||||||
|
) {
|
||||||
const keep = new Set(keepIds);
|
const keep = new Set(keepIds);
|
||||||
const spare = (Array.isArray(slots) ? slots : [])
|
const spare = (Array.isArray(slots) ? slots : [])
|
||||||
.filter((slot) => slot && typeof slot.id === 'string' && !keep.has(slot.id))
|
.filter((slot) => slot && typeof slot.id === 'string' && !keep.has(slot.id))
|
||||||
.sort((a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id));
|
.sort(
|
||||||
|
(a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id),
|
||||||
|
);
|
||||||
const capacity = Math.max(0, cap - keep.size);
|
const capacity = Math.max(0, cap - keep.size);
|
||||||
return spare.slice(capacity).map((slot) => slot.id);
|
return spare.slice(capacity).map((slot) => slot.id);
|
||||||
}
|
}
|
||||||
|
|||||||
+165
-36
@@ -74,7 +74,11 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
|
|||||||
const toPlane = Cesium.Cartesian3.subtract(planeOrigin, rayOrigin, scratchW);
|
const toPlane = Cesium.Cartesian3.subtract(planeOrigin, rayOrigin, scratchW);
|
||||||
const s = Cesium.Cartesian3.dot(toPlane, planeNormal) / denom;
|
const s = Cesium.Cartesian3.dot(toPlane, planeNormal) / denom;
|
||||||
if (s < 0) return null;
|
if (s < 0) return null;
|
||||||
const hit = Cesium.Cartesian3.multiplyByScalar(rayDir, s, new Cesium.Cartesian3());
|
const hit = Cesium.Cartesian3.multiplyByScalar(
|
||||||
|
rayDir,
|
||||||
|
s,
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
);
|
||||||
return Cesium.Cartesian3.add(rayOrigin, hit, hit);
|
return Cesium.Cartesian3.add(rayOrigin, hit, hit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +93,10 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
|
|||||||
*/
|
*/
|
||||||
export function ringAngle(hitPoint, center, basisA, basisB) {
|
export function ringAngle(hitPoint, center, basisA, basisB) {
|
||||||
const v = Cesium.Cartesian3.subtract(hitPoint, center, scratchW);
|
const v = Cesium.Cartesian3.subtract(hitPoint, center, scratchW);
|
||||||
return Math.atan2(Cesium.Cartesian3.dot(v, basisB), Cesium.Cartesian3.dot(v, basisA));
|
return Math.atan2(
|
||||||
|
Cesium.Cartesian3.dot(v, basisB),
|
||||||
|
Cesium.Cartesian3.dot(v, basisA),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,7 +157,11 @@ function enuAxes(position) {
|
|||||||
|
|
||||||
/** a*sa + b*sb (fresh Cartesian3). */
|
/** a*sa + b*sb (fresh Cartesian3). */
|
||||||
function combine2(a, sa, b, sb) {
|
function combine2(a, sa, b, sb) {
|
||||||
const out = Cesium.Cartesian3.multiplyByScalar(a, sa, new Cesium.Cartesian3());
|
const out = Cesium.Cartesian3.multiplyByScalar(
|
||||||
|
a,
|
||||||
|
sa,
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
);
|
||||||
const t = Cesium.Cartesian3.multiplyByScalar(b, sb, new Cesium.Cartesian3());
|
const t = Cesium.Cartesian3.multiplyByScalar(b, sb, new Cesium.Cartesian3());
|
||||||
return Cesium.Cartesian3.add(out, t, out);
|
return Cesium.Cartesian3.add(out, t, out);
|
||||||
}
|
}
|
||||||
@@ -165,7 +176,12 @@ function combine2(a, sa, b, sb) {
|
|||||||
function viewAxesFor(headingDeg, pitchDeg, axes) {
|
function viewAxesFor(headingDeg, pitchDeg, axes) {
|
||||||
const h = toRadians(headingDeg);
|
const h = toRadians(headingDeg);
|
||||||
const p = toRadians(pitchDeg);
|
const p = toRadians(pitchDeg);
|
||||||
const forwardHoriz = combine2(axes.east, Math.sin(h), axes.north, Math.cos(h));
|
const forwardHoriz = combine2(
|
||||||
|
axes.east,
|
||||||
|
Math.sin(h),
|
||||||
|
axes.north,
|
||||||
|
Math.cos(h),
|
||||||
|
);
|
||||||
const right = combine2(axes.east, Math.cos(h), axes.north, -Math.sin(h));
|
const right = combine2(axes.east, Math.cos(h), axes.north, -Math.sin(h));
|
||||||
const view = combine2(forwardHoriz, Math.cos(p), axes.up, Math.sin(p));
|
const view = combine2(forwardHoriz, Math.cos(p), axes.up, Math.sin(p));
|
||||||
return { forwardHoriz, right, view };
|
return { forwardHoriz, right, view };
|
||||||
@@ -176,7 +192,12 @@ function ringPositions(center, radius, basisA, basisB) {
|
|||||||
const positions = [];
|
const positions = [];
|
||||||
for (let i = 0; i <= RING_SEGMENTS; i++) {
|
for (let i = 0; i <= RING_SEGMENTS; i++) {
|
||||||
const angle = (i / RING_SEGMENTS) * 2 * Math.PI;
|
const angle = (i / RING_SEGMENTS) * 2 * Math.PI;
|
||||||
const offset = combine2(basisA, Math.cos(angle) * radius, basisB, Math.sin(angle) * radius);
|
const offset = combine2(
|
||||||
|
basisA,
|
||||||
|
Math.cos(angle) * radius,
|
||||||
|
basisB,
|
||||||
|
Math.sin(angle) * radius,
|
||||||
|
);
|
||||||
positions.push(Cesium.Cartesian3.add(center, offset, offset));
|
positions.push(Cesium.Cartesian3.add(center, offset, offset));
|
||||||
}
|
}
|
||||||
return positions;
|
return positions;
|
||||||
@@ -202,7 +223,12 @@ function ringPositions(center, radius, basisA, basisB) {
|
|||||||
* destroy: function(): void, isDragging: function(): boolean,
|
* destroy: function(): void, isDragging: function(): boolean,
|
||||||
* isEnabled: function(): boolean}}
|
* isEnabled: function(): boolean}}
|
||||||
*/
|
*/
|
||||||
export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, endPatch }) {
|
export function createCalibrationGizmo({
|
||||||
|
viewer,
|
||||||
|
getActiveRecord,
|
||||||
|
applyPatch,
|
||||||
|
endPatch,
|
||||||
|
}) {
|
||||||
let enabled = false;
|
let enabled = false;
|
||||||
let drag = null; // { part, startCal, basePose, refs... }
|
let drag = null; // { part, startCal, basePose, refs... }
|
||||||
let hoveredId = null;
|
let hoveredId = null;
|
||||||
@@ -225,7 +251,8 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
|||||||
|
|
||||||
function polylinePart(part, color, width, arrow = false) {
|
function polylinePart(part, color, width, arrow = false) {
|
||||||
addEntity(part, {
|
addEntity(part, {
|
||||||
...(arrow ? {
|
...(arrow
|
||||||
|
? {
|
||||||
position: Cesium.Cartesian3.ZERO,
|
position: Cesium.Cartesian3.ZERO,
|
||||||
point: {
|
point: {
|
||||||
pixelSize: 14,
|
pixelSize: 14,
|
||||||
@@ -234,11 +261,14 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
|||||||
outlineWidth: 2,
|
outlineWidth: 2,
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
},
|
},
|
||||||
} : {}),
|
}
|
||||||
|
: {}),
|
||||||
polyline: {
|
polyline: {
|
||||||
positions: [],
|
positions: [],
|
||||||
width,
|
width,
|
||||||
material: arrow ? new Cesium.PolylineArrowMaterialProperty(color) : color.withAlpha(0.9),
|
material: arrow
|
||||||
|
? new Cesium.PolylineArrowMaterialProperty(color)
|
||||||
|
: color.withAlpha(0.9),
|
||||||
// Standard gizmo convention: parts behind geometry stay clearly
|
// Standard gizmo convention: parts behind geometry stay clearly
|
||||||
// visible, just dimmed (a street-level mount buries half the heading
|
// visible, just dimmed (a street-level mount buries half the heading
|
||||||
// ring in sloped photogrammetry tiles — smoke-tested 2026-07-05).
|
// ring in sloped photogrammetry tiles — smoke-tested 2026-07-05).
|
||||||
@@ -286,16 +316,42 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
|||||||
}
|
}
|
||||||
const mount = positions.mount;
|
const mount = positions.mount;
|
||||||
const axes = enuAxes(mount);
|
const axes = enuAxes(mount);
|
||||||
const { forwardHoriz, right, view } = viewAxesFor(camera.headingDeg, camera.pitchDeg, axes);
|
const { forwardHoriz, right, view } = viewAxesFor(
|
||||||
|
camera.headingDeg,
|
||||||
|
camera.pitchDeg,
|
||||||
|
axes,
|
||||||
|
);
|
||||||
const rangeM = Math.max(1, Number(camera.rangeM) || 1);
|
const rangeM = Math.max(1, Number(camera.rangeM) || 1);
|
||||||
const radius = Math.min(RING_RADIUS_MAX_M, Math.max(RING_RADIUS_MIN_M, rangeM * RING_RADIUS_FACTOR));
|
const radius = Math.min(
|
||||||
|
RING_RADIUS_MAX_M,
|
||||||
|
Math.max(RING_RADIUS_MIN_M, rangeM * RING_RADIUS_FACTOR),
|
||||||
|
);
|
||||||
const arrowLen = radius * ARROW_LENGTH_FACTOR;
|
const arrowLen = radius * ARROW_LENGTH_FACTOR;
|
||||||
|
|
||||||
entities.get('ring-heading').polyline.positions = ringPositions(mount, radius, axes.north, axes.east);
|
entities.get('ring-heading').polyline.positions = ringPositions(
|
||||||
entities.get('ring-pitch').polyline.positions = ringPositions(mount, radius, forwardHoriz, axes.up);
|
mount,
|
||||||
const arrowTo = (dir) => [mount, Cesium.Cartesian3.add(
|
radius,
|
||||||
mount, Cesium.Cartesian3.multiplyByScalar(dir, arrowLen, new Cesium.Cartesian3()), new Cesium.Cartesian3()
|
axes.north,
|
||||||
)];
|
axes.east,
|
||||||
|
);
|
||||||
|
entities.get('ring-pitch').polyline.positions = ringPositions(
|
||||||
|
mount,
|
||||||
|
radius,
|
||||||
|
forwardHoriz,
|
||||||
|
axes.up,
|
||||||
|
);
|
||||||
|
const arrowTo = (dir) => [
|
||||||
|
mount,
|
||||||
|
Cesium.Cartesian3.add(
|
||||||
|
mount,
|
||||||
|
Cesium.Cartesian3.multiplyByScalar(
|
||||||
|
dir,
|
||||||
|
arrowLen,
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
),
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
),
|
||||||
|
];
|
||||||
const eastArrow = arrowTo(axes.east);
|
const eastArrow = arrowTo(axes.east);
|
||||||
const northArrow = arrowTo(axes.north);
|
const northArrow = arrowTo(axes.north);
|
||||||
const upArrow = arrowTo(axes.up);
|
const upArrow = arrowTo(axes.up);
|
||||||
@@ -306,8 +362,16 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
|||||||
entities.get('move-up').polyline.positions = upArrow;
|
entities.get('move-up').polyline.positions = upArrow;
|
||||||
entities.get('move-up').position = upArrow[1];
|
entities.get('move-up').position = upArrow[1];
|
||||||
entities.get('handle-range').position = positions.capCenter;
|
entities.get('handle-range').position = positions.capCenter;
|
||||||
entities.get('handle-fov-l').position = Cesium.Cartesian3.midpoint(positions.tl, positions.bl, new Cesium.Cartesian3());
|
entities.get('handle-fov-l').position = Cesium.Cartesian3.midpoint(
|
||||||
entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint(positions.tr, positions.br, new Cesium.Cartesian3());
|
positions.tl,
|
||||||
|
positions.bl,
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
);
|
||||||
|
entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint(
|
||||||
|
positions.tr,
|
||||||
|
positions.br,
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
);
|
||||||
for (const entity of entities.values()) entity.show = true;
|
for (const entity of entities.values()) entity.show = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,8 +407,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (typeof window !== 'undefined' && window.__gevGizmoDebug) {
|
if (typeof window !== 'undefined' && window.__gevGizmoDebug) {
|
||||||
console.debug('[CCTV:gizmo] drillPick @', windowPosition?.x, windowPosition?.y, '→',
|
console.debug(
|
||||||
JSON.stringify(results.map((r) => String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name))));
|
'[CCTV:gizmo] drillPick @',
|
||||||
|
windowPosition?.x,
|
||||||
|
windowPosition?.y,
|
||||||
|
'→',
|
||||||
|
JSON.stringify(
|
||||||
|
results.map((r) =>
|
||||||
|
String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (const picked of results) {
|
for (const picked of results) {
|
||||||
const part = gizmoPartFrom(picked);
|
const part = gizmoPartFrom(picked);
|
||||||
@@ -379,7 +452,8 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
|||||||
for (const [name, entity] of entities.entries()) {
|
for (const [name, entity] of entities.entries()) {
|
||||||
if (!entity.point) continue;
|
if (!entity.point) continue;
|
||||||
const hot = hoveredId === `${GIZMO_ID_PREFIX}${name}`;
|
const hot = hoveredId === `${GIZMO_ID_PREFIX}${name}`;
|
||||||
entity.point.pixelSize = (name === 'handle-range' ? 13 : 10) + (hot ? 4 : 0);
|
entity.point.pixelSize =
|
||||||
|
(name === 'handle-range' ? 13 : 10) + (hot ? 4 : 0);
|
||||||
}
|
}
|
||||||
setCursor(hoveredId ? 'grab' : '');
|
setCursor(hoveredId ? 'grab' : '');
|
||||||
}
|
}
|
||||||
@@ -397,7 +471,11 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
|||||||
const mount = Cesium.Cartesian3.clone(positions.mount);
|
const mount = Cesium.Cartesian3.clone(positions.mount);
|
||||||
const capCenter = Cesium.Cartesian3.clone(positions.capCenter);
|
const capCenter = Cesium.Cartesian3.clone(positions.capCenter);
|
||||||
const axes = enuAxes(mount);
|
const axes = enuAxes(mount);
|
||||||
const { forwardHoriz, right, view } = viewAxesFor(camera.headingDeg, camera.pitchDeg, axes);
|
const { forwardHoriz, right, view } = viewAxesFor(
|
||||||
|
camera.headingDeg,
|
||||||
|
camera.pitchDeg,
|
||||||
|
axes,
|
||||||
|
);
|
||||||
const startCal = { ...camera.calibration };
|
const startCal = { ...camera.calibration };
|
||||||
const basePose = { ...camera.basePose };
|
const basePose = { ...camera.basePose };
|
||||||
const state = {
|
const state = {
|
||||||
@@ -405,7 +483,15 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
|||||||
// mid-drag active-camera switch (voice select / auto-hop) onto a
|
// mid-drag active-camera switch (voice select / auto-hop) onto a
|
||||||
// different camera — its basePose makes the captured offsets wrong.
|
// different camera — its basePose makes the captured offsets wrong.
|
||||||
record,
|
record,
|
||||||
part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view,
|
part,
|
||||||
|
startCal,
|
||||||
|
basePose,
|
||||||
|
mount,
|
||||||
|
capCenter,
|
||||||
|
axes,
|
||||||
|
forwardHoriz,
|
||||||
|
right,
|
||||||
|
view,
|
||||||
effectiveRangeM: geometry.rangeM,
|
effectiveRangeM: geometry.rangeM,
|
||||||
startAngle: null,
|
startAngle: null,
|
||||||
startParam: null,
|
startParam: null,
|
||||||
@@ -419,8 +505,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
|||||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
|
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
|
||||||
if (!hit) return false;
|
if (!hit) return false;
|
||||||
state.startAngle = ringAngle(hit, mount, forwardHoriz, axes.up); // 0 = level, + = up
|
state.startAngle = ringAngle(hit, mount, forwardHoriz, axes.up); // 0 = level, + = up
|
||||||
} else if (part === 'move-east' || part === 'move-north' || part === 'move-up') {
|
} else if (
|
||||||
const dir = part === 'move-east' ? axes.east : part === 'move-north' ? axes.north : axes.up;
|
part === 'move-east' ||
|
||||||
|
part === 'move-north' ||
|
||||||
|
part === 'move-up'
|
||||||
|
) {
|
||||||
|
const dir =
|
||||||
|
part === 'move-east'
|
||||||
|
? axes.east
|
||||||
|
: part === 'move-north'
|
||||||
|
? axes.north
|
||||||
|
: axes.up;
|
||||||
state.axisDir = dir;
|
state.axisDir = dir;
|
||||||
const t = closestParamOnAxis(ray.origin, ray.direction, mount, dir);
|
const t = closestParamOnAxis(ray.origin, ray.direction, mount, dir);
|
||||||
if (t === null) return false;
|
if (t === null) return false;
|
||||||
@@ -448,26 +543,49 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
|||||||
function dragPatch(windowPosition) {
|
function dragPatch(windowPosition) {
|
||||||
const ray = pickRay(windowPosition);
|
const ray = pickRay(windowPosition);
|
||||||
if (!ray || !drag) return null;
|
if (!ray || !drag) return null;
|
||||||
const { part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view } = drag;
|
const {
|
||||||
|
part,
|
||||||
|
startCal,
|
||||||
|
basePose,
|
||||||
|
mount,
|
||||||
|
capCenter,
|
||||||
|
axes,
|
||||||
|
forwardHoriz,
|
||||||
|
right,
|
||||||
|
view,
|
||||||
|
} = drag;
|
||||||
|
|
||||||
if (part === 'ring-heading') {
|
if (part === 'ring-heading') {
|
||||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, axes.up);
|
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, axes.up);
|
||||||
if (!hit) return null;
|
if (!hit) return null;
|
||||||
const angle = ringAngle(hit, mount, axes.north, axes.east);
|
const angle = ringAngle(hit, mount, axes.north, axes.east);
|
||||||
return { headingDeg: startCal.headingDeg + toDeg(signedAngleDelta(drag.startAngle, angle)) };
|
return {
|
||||||
|
headingDeg:
|
||||||
|
startCal.headingDeg + toDeg(signedAngleDelta(drag.startAngle, angle)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (part === 'ring-pitch') {
|
if (part === 'ring-pitch') {
|
||||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
|
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
|
||||||
if (!hit) return null;
|
if (!hit) return null;
|
||||||
const angle = ringAngle(hit, mount, forwardHoriz, axes.up);
|
const angle = ringAngle(hit, mount, forwardHoriz, axes.up);
|
||||||
return { pitchDeg: startCal.pitchDeg + toDeg(signedAngleDelta(drag.startAngle, angle)) };
|
return {
|
||||||
|
pitchDeg:
|
||||||
|
startCal.pitchDeg + toDeg(signedAngleDelta(drag.startAngle, angle)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (part === 'move-east' || part === 'move-north' || part === 'move-up') {
|
if (part === 'move-east' || part === 'move-north' || part === 'move-up') {
|
||||||
const t = closestParamOnAxis(ray.origin, ray.direction, mount, drag.axisDir);
|
const t = closestParamOnAxis(
|
||||||
|
ray.origin,
|
||||||
|
ray.direction,
|
||||||
|
mount,
|
||||||
|
drag.axisDir,
|
||||||
|
);
|
||||||
if (t === null) return null;
|
if (t === null) return null;
|
||||||
const delta = t - drag.startParam;
|
const delta = t - drag.startParam;
|
||||||
if (part === 'move-east') return { offsetEastM: startCal.offsetEastM + delta };
|
if (part === 'move-east')
|
||||||
if (part === 'move-north') return { offsetNorthM: startCal.offsetNorthM + delta };
|
return { offsetEastM: startCal.offsetEastM + delta };
|
||||||
|
if (part === 'move-north')
|
||||||
|
return { offsetNorthM: startCal.offsetNorthM + delta };
|
||||||
return { heightM: startCal.heightM + delta };
|
return { heightM: startCal.heightM + delta };
|
||||||
}
|
}
|
||||||
if (part === 'handle-range') {
|
if (part === 'handle-range') {
|
||||||
@@ -478,11 +596,16 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
|||||||
if (part === 'handle-fov-l' || part === 'handle-fov-r') {
|
if (part === 'handle-fov-l' || part === 'handle-fov-r') {
|
||||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, capCenter, view);
|
const hit = rayPlaneIntersect(ray.origin, ray.direction, capCenter, view);
|
||||||
if (!hit) return null;
|
if (!hit) return null;
|
||||||
const lateral = Math.abs(Cesium.Cartesian3.dot(
|
const lateral = Math.abs(
|
||||||
Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()), right
|
Cesium.Cartesian3.dot(
|
||||||
));
|
Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()),
|
||||||
|
right,
|
||||||
|
),
|
||||||
|
);
|
||||||
const halfW = Math.max(0.5, lateral);
|
const halfW = Math.max(0.5, lateral);
|
||||||
const fovDeg = toDeg(2 * Math.atan(halfW / Math.max(1, drag.effectiveRangeM)));
|
const fovDeg = toDeg(
|
||||||
|
2 * Math.atan(halfW / Math.max(1, drag.effectiveRangeM)),
|
||||||
|
);
|
||||||
return { fovDeg: fovDeg - basePose.fovDeg };
|
return { fovDeg: fovDeg - basePose.fovDeg };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -531,7 +654,13 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const patch = dragPatch(event.endPosition);
|
const patch = dragPatch(event.endPosition);
|
||||||
debugLog('dragPatch', drag.part, event.endPosition, '→', patch ? JSON.stringify(patch) : null);
|
debugLog(
|
||||||
|
'dragPatch',
|
||||||
|
drag.part,
|
||||||
|
event.endPosition,
|
||||||
|
'→',
|
||||||
|
patch ? JSON.stringify(patch) : null,
|
||||||
|
);
|
||||||
if (patch) applyPatch(patch, drag.record);
|
if (patch) applyPatch(patch, drag.record);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function cameraHue(index) {
|
|||||||
* @returns {{fill: Cesium.Color, fillActive: Cesium.Color, line: Cesium.Color, lineActive: Cesium.Color}}
|
* @returns {{fill: Cesium.Color, fillActive: Cesium.Color, line: Cesium.Color, lineActive: Cesium.Color}}
|
||||||
*/
|
*/
|
||||||
export function viewshedColors(hueDeg) {
|
export function viewshedColors(hueDeg) {
|
||||||
const hue = ((Number(hueDeg) % 360) + 360) % 360 / 360;
|
const hue = (((Number(hueDeg) % 360) + 360) % 360) / 360;
|
||||||
return {
|
return {
|
||||||
fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
|
fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
|
||||||
fillActive: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE),
|
fillActive: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE),
|
||||||
@@ -66,7 +66,13 @@ export function viewshedColors(hueDeg) {
|
|||||||
* @returns {{positions: Float64Array, indices: Uint16Array}}
|
* @returns {{positions: Float64Array, indices: Uint16Array}}
|
||||||
*/
|
*/
|
||||||
export function frustumVolumeGeometryData(positions) {
|
export function frustumVolumeGeometryData(positions) {
|
||||||
const pts = [positions.mount, positions.tl, positions.tr, positions.br, positions.bl];
|
const pts = [
|
||||||
|
positions.mount,
|
||||||
|
positions.tl,
|
||||||
|
positions.tr,
|
||||||
|
positions.br,
|
||||||
|
positions.bl,
|
||||||
|
];
|
||||||
const flat = new Float64Array(15);
|
const flat = new Float64Array(15);
|
||||||
pts.forEach((p, i) => {
|
pts.forEach((p, i) => {
|
||||||
flat[i * 3] = p.x;
|
flat[i * 3] = p.x;
|
||||||
@@ -75,12 +81,7 @@ export function frustumVolumeGeometryData(positions) {
|
|||||||
});
|
});
|
||||||
// apex=0, tl=1, tr=2, br=3, bl=4 — 4 side faces + far cap (2 triangles).
|
// apex=0, tl=1, tr=2, br=3, bl=4 — 4 side faces + far cap (2 triangles).
|
||||||
const indices = new Uint16Array([
|
const indices = new Uint16Array([
|
||||||
0, 1, 2,
|
0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1, 1, 2, 3, 1, 3, 4,
|
||||||
0, 2, 3,
|
|
||||||
0, 3, 4,
|
|
||||||
0, 4, 1,
|
|
||||||
1, 2, 3,
|
|
||||||
1, 3, 4,
|
|
||||||
]);
|
]);
|
||||||
return { positions: flat, indices };
|
return { positions: flat, indices };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function nextCockpitNearContacts(
|
|||||||
) {
|
) {
|
||||||
const previous = previousIds instanceof Set ? previousIds : new Set();
|
const previous = previousIds instanceof Set ? previousIds : new Set();
|
||||||
const addSq = Math.max(0, Number(addDistanceM) || 0) ** 2;
|
const addSq = Math.max(0, Number(addDistanceM) || 0) ** 2;
|
||||||
const keepSq = Math.max(addSq, (Math.max(0, Number(keepDistanceM) || 0) ** 2));
|
const keepSq = Math.max(addSq, Math.max(0, Number(keepDistanceM) || 0) ** 2);
|
||||||
const next = new Set();
|
const next = new Set();
|
||||||
|
|
||||||
for (const [id, distanceSq] of distancesSquared || []) {
|
for (const [id, distanceSq] of distancesSquared || []) {
|
||||||
@@ -32,4 +32,3 @@ export function nextCockpitNearContacts(
|
|||||||
|
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +42,9 @@ export const CONTACT_MATCH_TIER = Object.freeze({
|
|||||||
* @returns {string} Canonical form, or '' when there is nothing to compare.
|
* @returns {string} Canonical form, or '' when there is nothing to compare.
|
||||||
*/
|
*/
|
||||||
export function canonicalizeContactId(value) {
|
export function canonicalizeContactId(value) {
|
||||||
return String(value ?? '').replace(/[^a-z0-9]/gi, '').toUpperCase();
|
return String(value ?? '')
|
||||||
|
.replace(/[^a-z0-9]/gi, '')
|
||||||
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,16 +59,31 @@ export function canonicalizeContactId(value) {
|
|||||||
* @param {string} [params.registration] Contact's registration, if any.
|
* @param {string} [params.registration] Contact's registration, if any.
|
||||||
* @returns {number} A {@link CONTACT_MATCH_TIER} value; `NONE` when no match.
|
* @returns {number} A {@link CONTACT_MATCH_TIER} value; `NONE` when no match.
|
||||||
*/
|
*/
|
||||||
export function rankContactMatch({ query, hex = '', callsign = '', registration = '' }) {
|
export function rankContactMatch({
|
||||||
const rawQuery = String(query ?? '').trim().toLowerCase();
|
query,
|
||||||
|
hex = '',
|
||||||
|
callsign = '',
|
||||||
|
registration = '',
|
||||||
|
}) {
|
||||||
|
const rawQuery = String(query ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
if (!rawQuery) return CONTACT_MATCH_TIER.NONE;
|
if (!rawQuery) return CONTACT_MATCH_TIER.NONE;
|
||||||
|
|
||||||
if (String(hex ?? '').trim().toLowerCase() === rawQuery) return CONTACT_MATCH_TIER.HEX_EXACT;
|
if (
|
||||||
|
String(hex ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase() === rawQuery
|
||||||
|
)
|
||||||
|
return CONTACT_MATCH_TIER.HEX_EXACT;
|
||||||
|
|
||||||
const callsignText = String(callsign ?? '').trim().toLowerCase();
|
const callsignText = String(callsign ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
// Callsigns carry no separators, so they compare raw — canonicalizing them
|
// Callsigns carry no separators, so they compare raw — canonicalizing them
|
||||||
// would silently widen the long-standing callsign contract.
|
// would silently widen the long-standing callsign contract.
|
||||||
if (callsignText && callsignText === rawQuery) return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
|
if (callsignText && callsignText === rawQuery)
|
||||||
|
return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
|
||||||
|
|
||||||
const canonicalQuery = canonicalizeContactId(rawQuery);
|
const canonicalQuery = canonicalizeContactId(rawQuery);
|
||||||
const canonicalRegistration = canonicalizeContactId(registration);
|
const canonicalRegistration = canonicalizeContactId(registration);
|
||||||
@@ -74,11 +91,13 @@ export function rankContactMatch({ query, hex = '', callsign = '', registration
|
|||||||
return CONTACT_MATCH_TIER.REGISTRATION_EXACT;
|
return CONTACT_MATCH_TIER.REGISTRATION_EXACT;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (callsignText && callsignText.startsWith(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
|
if (callsignText && callsignText.startsWith(rawQuery))
|
||||||
|
return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
|
||||||
if (canonicalQuery && canonicalRegistration.startsWith(canonicalQuery)) {
|
if (canonicalQuery && canonicalRegistration.startsWith(canonicalQuery)) {
|
||||||
return CONTACT_MATCH_TIER.REGISTRATION_PREFIX;
|
return CONTACT_MATCH_TIER.REGISTRATION_PREFIX;
|
||||||
}
|
}
|
||||||
if (callsignText && callsignText.includes(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
|
if (callsignText && callsignText.includes(rawQuery))
|
||||||
|
return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
|
||||||
if (canonicalQuery && canonicalRegistration.includes(canonicalQuery)) {
|
if (canonicalQuery && canonicalRegistration.includes(canonicalQuery)) {
|
||||||
return CONTACT_MATCH_TIER.REGISTRATION_SUBSTRING;
|
return CONTACT_MATCH_TIER.REGISTRATION_SUBSTRING;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ export function selectEntityContext(entity) {
|
|||||||
store.selectedEntityId = contextId;
|
store.selectedEntityId = contextId;
|
||||||
store.selectedAt = Date.now();
|
store.selectedAt = Date.now();
|
||||||
const record = store.entities.get(contextId);
|
const record = store.entities.get(contextId);
|
||||||
window.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: record }));
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('gev:entity-selected', { detail: record }),
|
||||||
|
);
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +80,8 @@ export function selectTrackedSubjectContext(metadata) {
|
|||||||
const store = getContextStore();
|
const store = getContextStore();
|
||||||
const id = String(metadata.id);
|
const id = String(metadata.id);
|
||||||
for (const [key, record] of store.entities) {
|
for (const [key, record] of store.entities) {
|
||||||
if (record?.layerId === metadata.layerId && key !== id) store.entities.delete(key);
|
if (record?.layerId === metadata.layerId && key !== id)
|
||||||
|
store.entities.delete(key);
|
||||||
}
|
}
|
||||||
// Reuse the existing carrier so a per-poll refresh does not churn identity.
|
// Reuse the existing carrier so a per-poll refresh does not churn identity.
|
||||||
const carrier = store.entities.get(id)?.entity || { __gevContextId: id };
|
const carrier = store.entities.get(id)?.entity || { __gevContextId: id };
|
||||||
@@ -146,16 +149,21 @@ export function getSelectedEntityContext({ dataManager = null } = {}) {
|
|||||||
* rather than being deselected. Readouts that stay on screen hold their
|
* rather than being deselected. Readouts that stay on screen hold their
|
||||||
* last-known values for an eviction and only tear down on a deliberate clear.
|
* last-known values for an eviction and only tear down on a deliberate clear.
|
||||||
*/
|
*/
|
||||||
export function clearSelectedEntityContextForLayer(layerId, { evicted = false } = {}) {
|
export function clearSelectedEntityContextForLayer(
|
||||||
|
layerId,
|
||||||
|
{ evicted = false } = {},
|
||||||
|
) {
|
||||||
const store = getContextStore();
|
const store = getContextStore();
|
||||||
if (!store.selectedEntityId) return;
|
if (!store.selectedEntityId) return;
|
||||||
const record = store.entities.get(store.selectedEntityId);
|
const record = store.entities.get(store.selectedEntityId);
|
||||||
if (record?.layerId === layerId) {
|
if (record?.layerId === layerId) {
|
||||||
store.selectedEntityId = null;
|
store.selectedEntityId = null;
|
||||||
store.selectedAt = null;
|
store.selectedAt = null;
|
||||||
window.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', {
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('gev:entity-selection-cleared', {
|
||||||
detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' },
|
detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' },
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,16 +174,19 @@ export function clearSelectedEntityContextForLayer(layerId, { evicted = false }
|
|||||||
export function removeEntityContextsForLayer(layerId, { retainIds } = {}) {
|
export function removeEntityContextsForLayer(layerId, { retainIds } = {}) {
|
||||||
const store = getContextStore();
|
const store = getContextStore();
|
||||||
for (const [id, record] of store.entities) {
|
for (const [id, record] of store.entities) {
|
||||||
if (record?.layerId === layerId && !retainIds?.has(id)) store.entities.delete(id);
|
if (record?.layerId === layerId && !retainIds?.has(id))
|
||||||
|
store.entities.delete(id);
|
||||||
}
|
}
|
||||||
if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) {
|
if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) {
|
||||||
store.selectedEntityId = null;
|
store.selectedEntityId = null;
|
||||||
store.selectedAt = null;
|
store.selectedAt = null;
|
||||||
// A viewport refresh dropped the record out from under the selection —
|
// A viewport refresh dropped the record out from under the selection —
|
||||||
// the user did not deselect anything.
|
// the user did not deselect anything.
|
||||||
window.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', {
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('gev:entity-selection-cleared', {
|
||||||
detail: { layerId, reason: 'evicted' },
|
detail: { layerId, reason: 'evicted' },
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +194,7 @@ export function isContextRecordActive(record, dataManager = null) {
|
|||||||
if (!record) return false;
|
if (!record) return false;
|
||||||
if (record.entity?.show === false) return false;
|
if (record.entity?.show === false) return false;
|
||||||
if (record.dataSource && record.dataSource.show === false) return false;
|
if (record.dataSource && record.dataSource.show === false) return false;
|
||||||
if (dataManager && record.layerId && !dataManager.isEnabled(record.layerId)) return false;
|
if (dataManager && record.layerId && !dataManager.isEnabled(record.layerId))
|
||||||
|
return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -185,8 +185,7 @@ export const DATA_CREDITS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'gtfs-rt',
|
key: 'gtfs-rt',
|
||||||
html:
|
html: 'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
|
||||||
'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'radio-browser',
|
key: 'radio-browser',
|
||||||
@@ -299,7 +298,8 @@ export const NATURAL_EARTH_CREDIT = {
|
|||||||
* @returns {{ key: string, html: string }}
|
* @returns {{ key: string, html: string }}
|
||||||
*/
|
*/
|
||||||
export function transitFeedCredit(feed) {
|
export function transitFeedCredit(feed) {
|
||||||
const escape = (text) => String(text)
|
const escape = (text) =>
|
||||||
|
String(text)
|
||||||
.replaceAll('&', '&')
|
.replaceAll('&', '&')
|
||||||
.replaceAll('<', '<')
|
.replaceAll('<', '<')
|
||||||
.replaceAll('>', '>')
|
.replaceAll('>', '>')
|
||||||
|
|||||||
+205
-56
@@ -279,7 +279,10 @@ let _cockpitModeListener = null;
|
|||||||
*/
|
*/
|
||||||
export function initDetection(viewer, layers, onModeChange) {
|
export function initDetection(viewer, layers, onModeChange) {
|
||||||
if (_cockpitModeListener && typeof window !== 'undefined') {
|
if (_cockpitModeListener && typeof window !== 'undefined') {
|
||||||
window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener);
|
window.removeEventListener(
|
||||||
|
'gev:cockpit-mode-changed',
|
||||||
|
_cockpitModeListener,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_hostLane?.unregister?.();
|
_hostLane?.unregister?.();
|
||||||
_calloutLane?.unregister?.();
|
_calloutLane?.unregister?.();
|
||||||
@@ -323,7 +326,10 @@ export function initDetection(viewer, layers, onModeChange) {
|
|||||||
/** Release the host lane and all retained detection runtime state. */
|
/** Release the host lane and all retained detection runtime state. */
|
||||||
export function destroyDetection() {
|
export function destroyDetection() {
|
||||||
if (_cockpitModeListener && typeof window !== 'undefined') {
|
if (_cockpitModeListener && typeof window !== 'undefined') {
|
||||||
window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener);
|
window.removeEventListener(
|
||||||
|
'gev:cockpit-mode-changed',
|
||||||
|
_cockpitModeListener,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_cockpitModeListener = null;
|
_cockpitModeListener = null;
|
||||||
_cockpitActive = false;
|
_cockpitActive = false;
|
||||||
@@ -397,7 +403,8 @@ export function setMode(modeLabel) {
|
|||||||
_mode = MODE_OFF;
|
_mode = MODE_OFF;
|
||||||
} else {
|
} else {
|
||||||
const requestedDensity = defaultDensityForProfile(target);
|
const requestedDensity = defaultDensityForProfile(target);
|
||||||
if (profileForDensity(_densityPct) !== target) _densityPct = requestedDensity;
|
if (profileForDensity(_densityPct) !== target)
|
||||||
|
_densityPct = requestedDensity;
|
||||||
_mode = _modeForDensity();
|
_mode = _modeForDensity();
|
||||||
_lastNonOffMode = _mode;
|
_lastNonOffMode = _mode;
|
||||||
}
|
}
|
||||||
@@ -462,7 +469,10 @@ export function isDetectionSuspended() {
|
|||||||
*/
|
*/
|
||||||
export function setDetectionTuning(options = {}) {
|
export function setDetectionTuning(options = {}) {
|
||||||
let modeChanged = false;
|
let modeChanged = false;
|
||||||
if (typeof options.densityPct === 'number' && Number.isFinite(options.densityPct)) {
|
if (
|
||||||
|
typeof options.densityPct === 'number' &&
|
||||||
|
Number.isFinite(options.densityPct)
|
||||||
|
) {
|
||||||
const nextDensity = canonicalizeDensity(options.densityPct);
|
const nextDensity = canonicalizeDensity(options.densityPct);
|
||||||
if (nextDensity !== _densityPct) {
|
if (nextDensity !== _densityPct) {
|
||||||
_densityPct = nextDensity;
|
_densityPct = nextDensity;
|
||||||
@@ -477,7 +487,10 @@ export function setDetectionTuning(options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (options.allocationStrategy != null) {
|
if (options.allocationStrategy != null) {
|
||||||
const nextStrategy = normalizeAllocationStrategy(options.allocationStrategy, _allocationStrategy);
|
const nextStrategy = normalizeAllocationStrategy(
|
||||||
|
options.allocationStrategy,
|
||||||
|
_allocationStrategy,
|
||||||
|
);
|
||||||
if (nextStrategy !== _allocationStrategy) {
|
if (nextStrategy !== _allocationStrategy) {
|
||||||
_allocationStrategy = nextStrategy;
|
_allocationStrategy = nextStrategy;
|
||||||
_labelSolveDirty = true;
|
_labelSolveDirty = true;
|
||||||
@@ -526,7 +539,9 @@ export function getDetectionDiagnostics() {
|
|||||||
const result = JSON.parse(JSON.stringify(_lastDiagnostics));
|
const result = JSON.parse(JSON.stringify(_lastDiagnostics));
|
||||||
// Readback only: expose painted plates so pixel QA can exclude occluded
|
// Readback only: expose painted plates so pixel QA can exclude occluded
|
||||||
// sprites/strokes. Nothing is copied on the production paint path.
|
// sprites/strokes. Nothing is copied on the production paint path.
|
||||||
result.calloutRects = _calloutPool.slice(0, _calloutCount).map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
|
result.calloutRects = _calloutPool
|
||||||
|
.slice(0, _calloutCount)
|
||||||
|
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,19 +550,30 @@ function _publishDiagnostics() {
|
|||||||
const dataset = _hostCanvas.dataset;
|
const dataset = _hostCanvas.dataset;
|
||||||
dataset.profile = _lastDiagnostics.profile || 'OFF';
|
dataset.profile = _lastDiagnostics.profile || 'OFF';
|
||||||
dataset.densityPct = String(_lastDiagnostics.densityPct ?? _densityPct);
|
dataset.densityPct = String(_lastDiagnostics.densityPct ?? _densityPct);
|
||||||
dataset.allocationStrategy = _lastDiagnostics.allocationStrategy || _allocationStrategy;
|
dataset.allocationStrategy =
|
||||||
|
_lastDiagnostics.allocationStrategy || _allocationStrategy;
|
||||||
dataset.solveRevision = String(_lastDiagnostics.solveRevision ?? 0);
|
dataset.solveRevision = String(_lastDiagnostics.solveRevision ?? 0);
|
||||||
dataset.collectiveLabelBudget = String(_lastDiagnostics.collectiveLabelBudget ?? 0);
|
dataset.collectiveLabelBudget = String(
|
||||||
|
_lastDiagnostics.collectiveLabelBudget ?? 0,
|
||||||
|
);
|
||||||
dataset.labelsByLayer = JSON.stringify(_lastDiagnostics.labelsByLayer || {});
|
dataset.labelsByLayer = JSON.stringify(_lastDiagnostics.labelsByLayer || {});
|
||||||
dataset.entitlementByLayer = JSON.stringify(_lastDiagnostics.entitlementByLayer || {});
|
dataset.entitlementByLayer = JSON.stringify(
|
||||||
|
_lastDiagnostics.entitlementByLayer || {},
|
||||||
|
);
|
||||||
dataset.labeledKeys = JSON.stringify(_lastDiagnostics.labeledKeys || []);
|
dataset.labeledKeys = JSON.stringify(_lastDiagnostics.labeledKeys || []);
|
||||||
dataset.labelAlphaByKey = JSON.stringify(_lastDiagnostics.labelAlphaByKey || {});
|
dataset.labelAlphaByKey = JSON.stringify(
|
||||||
dataset.bracketOpacityCounts = JSON.stringify(_lastDiagnostics.bracketOpacityCounts || {});
|
_lastDiagnostics.labelAlphaByKey || {},
|
||||||
|
);
|
||||||
|
dataset.bracketOpacityCounts = JSON.stringify(
|
||||||
|
_lastDiagnostics.bracketOpacityCounts || {},
|
||||||
|
);
|
||||||
dataset.keyholeRadius = String(_lastDiagnostics.keyholeRadius ?? 0);
|
dataset.keyholeRadius = String(_lastDiagnostics.keyholeRadius ?? 0);
|
||||||
dataset.keyholeFeatherPx = String(_lastDiagnostics.keyholeFeatherPx ?? 0);
|
dataset.keyholeFeatherPx = String(_lastDiagnostics.keyholeFeatherPx ?? 0);
|
||||||
dataset.demandByLayer = JSON.stringify(_lastDiagnostics.demandByLayer || {});
|
dataset.demandByLayer = JSON.stringify(_lastDiagnostics.demandByLayer || {});
|
||||||
dataset.cohortByLayer = JSON.stringify(_lastDiagnostics.cohortByLayer || {});
|
dataset.cohortByLayer = JSON.stringify(_lastDiagnostics.cohortByLayer || {});
|
||||||
dataset.placementBuildCount = String(_lastDiagnostics.placementBuildCount ?? 0);
|
dataset.placementBuildCount = String(
|
||||||
|
_lastDiagnostics.placementBuildCount ?? 0,
|
||||||
|
);
|
||||||
dataset.observationCount = String(_lastDiagnostics.observationCount ?? 0);
|
dataset.observationCount = String(_lastDiagnostics.observationCount ?? 0);
|
||||||
dataset.selectedCount = String(_lastDiagnostics.selectedCount ?? 0);
|
dataset.selectedCount = String(_lastDiagnostics.selectedCount ?? 0);
|
||||||
dataset.fadingCount = String(_lastDiagnostics.fadingCount ?? 0);
|
dataset.fadingCount = String(_lastDiagnostics.fadingCount ?? 0);
|
||||||
@@ -632,7 +658,11 @@ function _applyModeState() {
|
|||||||
_labelArbiter.clear();
|
_labelArbiter.clear();
|
||||||
_lastLabelSolveAt = 0;
|
_lastLabelSolveAt = 0;
|
||||||
_labelSolveDirty = true;
|
_labelSolveDirty = true;
|
||||||
_lastSolveSnapshot = { demandByLayer: {}, cohortByLayer: {}, cohortCount: 0 };
|
_lastSolveSnapshot = {
|
||||||
|
demandByLayer: {},
|
||||||
|
cohortByLayer: {},
|
||||||
|
cohortCount: 0,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
_enableTime = _nowMs(); // restart the subtle fade-in on (re)activation
|
_enableTime = _nowMs(); // restart the subtle fade-in on (re)activation
|
||||||
_syncSurfaceVisibility();
|
_syncSurfaceVisibility();
|
||||||
@@ -665,8 +695,10 @@ function _shouldPaintDetectionLane(frame) {
|
|||||||
});
|
});
|
||||||
if (decision.skip) {
|
if (decision.skip) {
|
||||||
_throttleSkipCount++;
|
_throttleSkipCount++;
|
||||||
if (_lastDiagnostics) _lastDiagnostics.throttleSkipCount = _throttleSkipCount;
|
if (_lastDiagnostics)
|
||||||
if (decision.requestFollowUp) governorRequestRender('detection-paint-skipped');
|
_lastDiagnostics.throttleSkipCount = _throttleSkipCount;
|
||||||
|
if (decision.requestFollowUp)
|
||||||
|
governorRequestRender('detection-paint-skipped');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -687,8 +719,13 @@ function _paintDetectionLane(frame) {
|
|||||||
_labelSolveDirty = true;
|
_labelSolveDirty = true;
|
||||||
}
|
}
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const result = _drawOverlay(frame)
|
const result = _drawOverlay(frame) || {
|
||||||
|| { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
|
didSolve: false,
|
||||||
|
solveMs: 0,
|
||||||
|
fadingCount: 0,
|
||||||
|
animatingCount: 0,
|
||||||
|
solvePending: false,
|
||||||
|
};
|
||||||
_lastRenderMs = performance.now() - start;
|
_lastRenderMs = performance.now() - start;
|
||||||
_lastSolveMs = result.solveMs || 0;
|
_lastSolveMs = result.solveMs || 0;
|
||||||
_lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs);
|
_lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs);
|
||||||
@@ -705,7 +742,8 @@ function _paintDetectionLane(frame) {
|
|||||||
// exactly what makes it safe where the old blanket hold was not. On a parked
|
// exactly what makes it safe where the old blanket hold was not. On a parked
|
||||||
// scene with nothing animating it asks for nothing, and the governor stays
|
// scene with nothing animating it asks for nothing, and the governor stays
|
||||||
// idle. See `detectionRenderDemand.js`.
|
// idle. See `detectionRenderDemand.js`.
|
||||||
if (detectionNeedsFollowUpFrame({
|
if (
|
||||||
|
detectionNeedsFollowUpFrame({
|
||||||
active: _mode !== MODE_OFF && !_suspended,
|
active: _mode !== MODE_OFF && !_suspended,
|
||||||
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here
|
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here
|
||||||
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at
|
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at
|
||||||
@@ -715,7 +753,8 @@ function _paintDetectionLane(frame) {
|
|||||||
fadeMs: FADE_MS,
|
fadeMs: FADE_MS,
|
||||||
animatingLabelCount: result.animatingCount || 0,
|
animatingLabelCount: result.animatingCount || 0,
|
||||||
solvePending: result.solvePending === true,
|
solvePending: result.solvePending === true,
|
||||||
})) {
|
})
|
||||||
|
) {
|
||||||
governorRequestRender('detection-animation');
|
governorRequestRender('detection-animation');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -804,7 +843,8 @@ function _drawModeBanner(visibleCount, sampledCount) {
|
|||||||
if (!_debugBanner) return;
|
if (!_debugBanner) return;
|
||||||
const mode = MODE_LABELS[_mode];
|
const mode = MODE_LABELS[_mode];
|
||||||
const paused = _suspended ? ` PAUSED:${_suspendReason || 'transition'}` : '';
|
const paused = _suspended ? ` PAUSED:${_suspendReason || 'transition'}` : '';
|
||||||
const allocation = _allocationStrategy === ALLOCATION_ELASTIC ? 'ELASTIC' : 'WEIGHTED';
|
const allocation =
|
||||||
|
_allocationStrategy === ALLOCATION_ELASTIC ? 'ELASTIC' : 'WEIGHTED';
|
||||||
const text = `${mode} VIS:${visibleCount} SRC:${sampledCount} DENS:${_densityPct}% ${allocation} ${_lastRenderMs.toFixed(1)}ms${paused}`;
|
const text = `${mode} VIS:${visibleCount} SRC:${sampledCount} DENS:${_densityPct}% ${allocation} ${_lastRenderMs.toFixed(1)}ms${paused}`;
|
||||||
_ctx.fillStyle = _theme.labelBg;
|
_ctx.fillStyle = _theme.labelBg;
|
||||||
_ctx.fillRect(12, 12, Math.max(180, _ctx.measureText(text).width + 12), 16);
|
_ctx.fillRect(12, 12, Math.max(180, _ctx.measureText(text).width + 12), 16);
|
||||||
@@ -900,16 +940,27 @@ function _buildLabelPlacements(
|
|||||||
const placements = [];
|
const placements = [];
|
||||||
for (const placement of raw) {
|
for (const placement of raw) {
|
||||||
const { cardX, cardY } = placement;
|
const { cardX, cardY } = placement;
|
||||||
if (cardX < margin || cardY < margin || cardX + card.w > width - margin || cardY + card.h > height - margin) {
|
if (
|
||||||
|
cardX < margin ||
|
||||||
|
cardY < margin ||
|
||||||
|
cardX + card.w > width - margin ||
|
||||||
|
cardY + card.h > height - margin
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const cardRect = { x: cardX, y: cardY, w: card.w, h: card.h };
|
const cardRect = { x: cardX, y: cardY, w: card.w, h: card.h };
|
||||||
if (rectIntersectsAny(cardRect, occlusionRects)) continue;
|
if (rectIntersectsAny(cardRect, occlusionRects)) continue;
|
||||||
const leadToX = placement.leadToSide.endsWith('E') ? cardX + card.w : cardX;
|
const leadToX = placement.leadToSide.endsWith('E') ? cardX + card.w : cardX;
|
||||||
const leadToY = placement.leadToSide.startsWith('S') ? cardY + card.h : cardY;
|
const leadToY = placement.leadToSide.startsWith('S')
|
||||||
|
? cardY + card.h
|
||||||
|
: cardY;
|
||||||
const centerX = cardX + card.w * 0.5;
|
const centerX = cardX + card.w * 0.5;
|
||||||
const centerY = cardY + card.h * 0.5;
|
const centerY = cardY + card.h * 0.5;
|
||||||
const radialAlpha = keyholeLabelAlphaFromGeometry(centerX, centerY, keyhole);
|
const radialAlpha = keyholeLabelAlphaFromGeometry(
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
keyhole,
|
||||||
|
);
|
||||||
if (radialAlpha <= 0) continue;
|
if (radialAlpha <= 0) continue;
|
||||||
placements.push({
|
placements.push({
|
||||||
...placement,
|
...placement,
|
||||||
@@ -940,18 +991,37 @@ function _buildLabelPlacements(
|
|||||||
*/
|
*/
|
||||||
function _stashCallout(entry, acquireFade, keyhole) {
|
function _stashCallout(entry, acquireFade, keyhole) {
|
||||||
const { candidate, placement, temporalAlpha } = entry;
|
const { candidate, placement, temporalAlpha } = entry;
|
||||||
const radialAlpha = keyholeLabelAlphaFromGeometry(placement.centerX, placement.centerY, keyhole);
|
const radialAlpha = keyholeLabelAlphaFromGeometry(
|
||||||
|
placement.centerX,
|
||||||
|
placement.centerY,
|
||||||
|
keyhole,
|
||||||
|
);
|
||||||
const alpha = acquireFade * temporalAlpha * radialAlpha;
|
const alpha = acquireFade * temporalAlpha * radialAlpha;
|
||||||
if (alpha <= 0.001) return;
|
if (alpha <= 0.001) return;
|
||||||
|
|
||||||
let row = _calloutPool[_calloutCount];
|
let row = _calloutPool[_calloutCount];
|
||||||
if (!row) {
|
if (!row) {
|
||||||
row = {
|
row = {
|
||||||
x: 0, y: 0, w: 0, h: 0,
|
x: 0,
|
||||||
primaryX: 0, microX: 0, baseline: 0,
|
y: 0,
|
||||||
leadFromX: 0, leadFromY: 0, leadToX: 0, leadToY: 0,
|
w: 0,
|
||||||
plate: '', plateScale: 1, accent: '', label: '', primary: '', micro: '',
|
h: 0,
|
||||||
font: FONT, microFont: MICRO_FONT, alpha: 1,
|
primaryX: 0,
|
||||||
|
microX: 0,
|
||||||
|
baseline: 0,
|
||||||
|
leadFromX: 0,
|
||||||
|
leadFromY: 0,
|
||||||
|
leadToX: 0,
|
||||||
|
leadToY: 0,
|
||||||
|
plate: '',
|
||||||
|
plateScale: 1,
|
||||||
|
accent: '',
|
||||||
|
label: '',
|
||||||
|
primary: '',
|
||||||
|
micro: '',
|
||||||
|
font: FONT,
|
||||||
|
microFont: MICRO_FONT,
|
||||||
|
alpha: 1,
|
||||||
};
|
};
|
||||||
_calloutPool[_calloutCount] = row;
|
_calloutPool[_calloutCount] = row;
|
||||||
}
|
}
|
||||||
@@ -996,7 +1066,13 @@ function _paintCalloutLane(frame) {
|
|||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
for (const bands of _transitBracketPaths.values()) {
|
for (const bands of _transitBracketPaths.values()) {
|
||||||
for (const entry of bands) {
|
for (const entry of bands) {
|
||||||
if (entry) paintTransitBracket(ctx, entry.path, entry.color, entry.alpha * _transitBracketAlpha);
|
if (entry)
|
||||||
|
paintTransitBracket(
|
||||||
|
ctx,
|
||||||
|
entry.path,
|
||||||
|
entry.color,
|
||||||
|
entry.alpha * _transitBracketAlpha,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
@@ -1021,7 +1097,14 @@ function _plateScaleForBackdrop(skyFactor) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Materialize one bounded rich-callout candidate for the existing arbiter. */
|
/** Materialize one bounded rich-callout candidate for the existing arbiter. */
|
||||||
function _materializeCandidate(obj, width, height, keyhole, occlusionRects, cameraPosition) {
|
function _materializeCandidate(
|
||||||
|
obj,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
keyhole,
|
||||||
|
occlusionRects,
|
||||||
|
cameraPosition,
|
||||||
|
) {
|
||||||
const primary = obj._candidatePrimary;
|
const primary = obj._candidatePrimary;
|
||||||
const micro = obj._candidateMicro;
|
const micro = obj._candidateMicro;
|
||||||
const card = measureTrackLabel(primary, micro, _charWidth);
|
const card = measureTrackLabel(primary, micro, _charWidth);
|
||||||
@@ -1042,7 +1125,10 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
|
|||||||
for (const placement of placements) {
|
for (const placement of placements) {
|
||||||
centerDistance = Math.min(
|
centerDistance = Math.min(
|
||||||
centerDistance,
|
centerDistance,
|
||||||
Math.hypot(placement.centerX - keyhole.centerX, placement.centerY - keyhole.centerY),
|
Math.hypot(
|
||||||
|
placement.centerX - keyhole.centerX,
|
||||||
|
placement.centerY - keyhole.centerY,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
keyholeAlpha = Math.max(keyholeAlpha, placement.keyholeAlpha);
|
keyholeAlpha = Math.max(keyholeAlpha, placement.keyholeAlpha);
|
||||||
}
|
}
|
||||||
@@ -1063,7 +1149,9 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
|
|||||||
// Costed here rather than in the object sweep: the sweep walks every
|
// Costed here rather than in the object sweep: the sweep walks every
|
||||||
// observation in view, while this runs only for callouts that actually
|
// observation in view, while this runs only for callouts that actually
|
||||||
// placed — a budgeted handful per frame.
|
// placed — a budgeted handful per frame.
|
||||||
plateScale: _plateScaleForBackdrop(skyBackdropFactor(cameraPosition, obj.position)),
|
plateScale: _plateScaleForBackdrop(
|
||||||
|
skyBackdropFactor(cameraPosition, obj.position),
|
||||||
|
),
|
||||||
primary,
|
primary,
|
||||||
micro,
|
micro,
|
||||||
hasMicro: card.hasMicro,
|
hasMicro: card.hasMicro,
|
||||||
@@ -1079,7 +1167,13 @@ function _drawOverlay(frame) {
|
|||||||
_transitBracketPaths.clear();
|
_transitBracketPaths.clear();
|
||||||
const { width, height } = frame;
|
const { width, height } = frame;
|
||||||
if (!_ctx || width <= 0 || height <= 0) {
|
if (!_ctx || width <= 0 || height <= 0) {
|
||||||
return { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
|
return {
|
||||||
|
didSolve: false,
|
||||||
|
solveMs: 0,
|
||||||
|
fadingCount: 0,
|
||||||
|
animatingCount: 0,
|
||||||
|
solvePending: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ONE timestamp per frame, monotonic, shared with the demand policy below and
|
// ONE timestamp per frame, monotonic, shared with the demand policy below and
|
||||||
@@ -1087,12 +1181,16 @@ function _drawOverlay(frame) {
|
|||||||
// animation's final state is the same frame that ends its demand.
|
// animation's final state is the same frame that ends its demand.
|
||||||
const now = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
|
const now = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
|
||||||
const bracketPresentationOpacity = detectionBracketOpacity(_cockpitActive);
|
const bracketPresentationOpacity = detectionBracketOpacity(_cockpitActive);
|
||||||
const shouldSolve = _labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
|
const shouldSolve =
|
||||||
|
_labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
|
||||||
const calloutOcclusionRects = frame.uiRects;
|
const calloutOcclusionRects = frame.uiRects;
|
||||||
const selectedIdentities = shouldSolve
|
const selectedIdentities = shouldSolve
|
||||||
? _labelArbiter.liveIdentities({ includeFading: false, now })
|
? _labelArbiter.liveIdentities({ includeFading: false, now })
|
||||||
: null;
|
: null;
|
||||||
const renderIdentities = _labelArbiter.liveIdentities({ includeFading: true, now });
|
const renderIdentities = _labelArbiter.liveIdentities({
|
||||||
|
includeFading: true,
|
||||||
|
now,
|
||||||
|
});
|
||||||
const objects = _collectDetectableObjects();
|
const objects = _collectDetectableObjects();
|
||||||
const sampledCount = objects.length;
|
const sampledCount = objects.length;
|
||||||
if (objects.length === 0) {
|
if (objects.length === 0) {
|
||||||
@@ -1116,7 +1214,9 @@ function _drawOverlay(frame) {
|
|||||||
profile: MODE_LABELS[_mode],
|
profile: MODE_LABELS[_mode],
|
||||||
densityPct: _densityPct,
|
densityPct: _densityPct,
|
||||||
allocationStrategy: _allocationStrategy,
|
allocationStrategy: _allocationStrategy,
|
||||||
viewScale: viewScaleForAltitude(_viewer?.camera?.positionCartographic?.height),
|
viewScale: viewScaleForAltitude(
|
||||||
|
_viewer?.camera?.positionCartographic?.height,
|
||||||
|
),
|
||||||
candidateCount: 0,
|
candidateCount: 0,
|
||||||
observationCount: 0,
|
observationCount: 0,
|
||||||
visibleCount: 0,
|
visibleCount: 0,
|
||||||
@@ -1144,7 +1244,13 @@ function _drawOverlay(frame) {
|
|||||||
// whole mechanism exists to remove.
|
// whole mechanism exists to remove.
|
||||||
_lastLabelSolveAt = now;
|
_lastLabelSolveAt = now;
|
||||||
_labelSolveDirty = false;
|
_labelSolveDirty = false;
|
||||||
return { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
|
return {
|
||||||
|
didSolve: false,
|
||||||
|
solveMs: 0,
|
||||||
|
fadingCount: 0,
|
||||||
|
animatingCount: 0,
|
||||||
|
solvePending: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Horizon culling, keyhole geometry, and camera transforms are shared with
|
// Horizon culling, keyhole geometry, and camera transforms are shared with
|
||||||
@@ -1179,7 +1285,10 @@ function _drawOverlay(frame) {
|
|||||||
const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
|
const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
|
||||||
const bracketPaths = new Map();
|
const bracketPaths = new Map();
|
||||||
const pathFor = (map, color, alpha) => {
|
const pathFor = (map, color, alpha) => {
|
||||||
const band = Math.max(1, Math.min(BRACKET_ALPHA_STEPS, Math.ceil(alpha * BRACKET_ALPHA_STEPS)));
|
const band = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(BRACKET_ALPHA_STEPS, Math.ceil(alpha * BRACKET_ALPHA_STEPS)),
|
||||||
|
);
|
||||||
let bands = map.get(color);
|
let bands = map.get(color);
|
||||||
if (!bands) {
|
if (!bands) {
|
||||||
bands = new Array(BRACKET_ALPHA_STEPS + 1);
|
bands = new Array(BRACKET_ALPHA_STEPS + 1);
|
||||||
@@ -1217,8 +1326,10 @@ function _drawOverlay(frame) {
|
|||||||
const clipW = vp3 * px + vp7 * py + vp11 * pz + vp15;
|
const clipW = vp3 * px + vp7 * py + vp11 * pz + vp15;
|
||||||
if (clipW <= 0) continue;
|
if (clipW <= 0) continue;
|
||||||
const invW = 1 / clipW;
|
const invW = 1 / clipW;
|
||||||
const sx = ((vp0 * px + vp4 * py + vp8 * pz + vp12) * invW * 0.5 + 0.5) * width;
|
const sx =
|
||||||
const sy = (0.5 - (vp1 * px + vp5 * py + vp9 * pz + vp13) * invW * 0.5) * height;
|
((vp0 * px + vp4 * py + vp8 * pz + vp12) * invW * 0.5 + 0.5) * width;
|
||||||
|
const sy =
|
||||||
|
(0.5 - (vp1 * px + vp5 * py + vp9 * pz + vp13) * invW * 0.5) * height;
|
||||||
|
|
||||||
// Tracked objects (skipLabel) get larger boxes. AIR reticles scale with the
|
// Tracked objects (skipLabel) get larger boxes. AIR reticles scale with the
|
||||||
// plane's on-screen size (same scaleByDistance curve as the billboards) so
|
// plane's on-screen size (same scaleByDistance curve as the billboards) so
|
||||||
@@ -1229,7 +1340,10 @@ function _drawOverlay(frame) {
|
|||||||
if (obj.type === 'AIR') {
|
if (obj.type === 'AIR') {
|
||||||
const bscale = nearFarScale(
|
const bscale = nearFarScale(
|
||||||
Cesium.Cartesian3.distance(camPos, obj.position),
|
Cesium.Cartesian3.distance(camPos, obj.position),
|
||||||
BILL_NEAR, BILL_NEAR_SCALE, BILL_FAR, BILL_FAR_SCALE,
|
BILL_NEAR,
|
||||||
|
BILL_NEAR_SCALE,
|
||||||
|
BILL_FAR,
|
||||||
|
BILL_FAR_SCALE,
|
||||||
);
|
);
|
||||||
halfW = _clamp((isTracked ? 14 : 9) * bscale, 7, 48);
|
halfW = _clamp((isTracked ? 14 : 9) * bscale, 7, 48);
|
||||||
halfH = _clamp((isTracked ? 11 : 7) * bscale, 5, 38);
|
halfH = _clamp((isTracked ? 11 : 7) * bscale, 5, 38);
|
||||||
@@ -1241,18 +1355,34 @@ function _drawOverlay(frame) {
|
|||||||
halfH = _mode === MODE_DENSE ? (isTracked ? 22 : 7) : 10;
|
halfH = _mode === MODE_DENSE ? (isTracked ? 22 : 7) : 10;
|
||||||
}
|
}
|
||||||
// Viewport bounds check with padding
|
// Viewport bounds check with padding
|
||||||
if (sx < -halfW || sx > width + halfW || sy < -halfH || sy > height + halfH) continue;
|
if (sx < -halfW || sx > width + halfW || sy < -halfH || sy > height + halfH)
|
||||||
|
continue;
|
||||||
|
|
||||||
// Tier color drives the bracket, accent bar, and leader line for this object.
|
// Tier color drives the bracket, accent bar, and leader line for this object.
|
||||||
// The bracket follows the same linear radial keyhole fade as its callout.
|
// The bracket follows the same linear radial keyhole fade as its callout.
|
||||||
const color = colorFor(resolveTier(obj));
|
const color = colorFor(resolveTier(obj));
|
||||||
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
|
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
|
||||||
const bracketAlpha = detectionBracketAlpha(obj.type, keyholeAlpha, keyholeOutsideOpacity);
|
const bracketAlpha = detectionBracketAlpha(
|
||||||
|
obj.type,
|
||||||
|
keyholeAlpha,
|
||||||
|
keyholeOutsideOpacity,
|
||||||
|
);
|
||||||
if (bracketAlpha > 0) {
|
if (bracketAlpha > 0) {
|
||||||
const transit = obj.tier?.startsWith('transit_');
|
const transit = obj.tier?.startsWith('transit_');
|
||||||
(transit ? appendTransitBracket : appendCornerBracket)(pathFor(transit ? _transitBracketPaths : bracketPaths, color, bracketAlpha), sx, sy, halfW, halfH);
|
(transit ? appendTransitBracket : appendCornerBracket)(
|
||||||
|
pathFor(
|
||||||
|
transit ? _transitBracketPaths : bracketPaths,
|
||||||
|
color,
|
||||||
|
bracketAlpha,
|
||||||
|
),
|
||||||
|
sx,
|
||||||
|
sy,
|
||||||
|
halfW,
|
||||||
|
halfH,
|
||||||
|
);
|
||||||
visibleCount++;
|
visibleCount++;
|
||||||
if (obj.type === 'AIR') aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
|
if (obj.type === 'AIR')
|
||||||
|
aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
|
||||||
if (bracketAlpha >= 1) bracketOpacityCounts.full++;
|
if (bracketAlpha >= 1) bracketOpacityCounts.full++;
|
||||||
else bracketOpacityCounts.partial++;
|
else bracketOpacityCounts.partial++;
|
||||||
} else {
|
} else {
|
||||||
@@ -1286,8 +1416,10 @@ function _drawOverlay(frame) {
|
|||||||
|
|
||||||
if (shouldSolve && keyholeAlpha > 0) {
|
if (shouldSolve && keyholeAlpha > 0) {
|
||||||
demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1);
|
demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1);
|
||||||
if (!cohortBuilders.has(layerId)) cohortBuilders.set(layerId, new BoundedCohort(256));
|
if (!cohortBuilders.has(layerId))
|
||||||
const incumbent = selectedIdentities?.get(layerId)?.has(sourceId) || false;
|
cohortBuilders.set(layerId, new BoundedCohort(256));
|
||||||
|
const incumbent =
|
||||||
|
selectedIdentities?.get(layerId)?.has(sourceId) || false;
|
||||||
cohortBuilders.get(layerId).consider(obj, incumbent);
|
cohortBuilders.get(layerId).consider(obj, incumbent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1301,13 +1433,17 @@ function _drawOverlay(frame) {
|
|||||||
calloutOcclusionRects,
|
calloutOcclusionRects,
|
||||||
camPos,
|
camPos,
|
||||||
);
|
);
|
||||||
if (candidate && !candidateMap.has(candidate.key)) candidateMap.set(candidate.key, candidate);
|
if (candidate && !candidateMap.has(candidate.key))
|
||||||
|
candidateMap.set(candidate.key, candidate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
|
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
|
||||||
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
|
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
|
||||||
const ambientBudget = Math.max(0, collectiveBudget - Math.min(collectiveBudget, protectedVisibleCount));
|
const ambientBudget = Math.max(
|
||||||
|
0,
|
||||||
|
collectiveBudget - Math.min(collectiveBudget, protectedVisibleCount),
|
||||||
|
);
|
||||||
let didSolve = false;
|
let didSolve = false;
|
||||||
let solveMs = 0;
|
let solveMs = 0;
|
||||||
if (shouldSolve) {
|
if (shouldSolve) {
|
||||||
@@ -1320,7 +1456,9 @@ function _drawOverlay(frame) {
|
|||||||
const solveCandidates = [];
|
const solveCandidates = [];
|
||||||
const cohortByLayer = {};
|
const cohortByLayer = {};
|
||||||
for (const [layerId, builder] of cohortBuilders) {
|
for (const [layerId, builder] of cohortBuilders) {
|
||||||
const cohort = builder.values(cohortCapForQuota(quotas.get(layerId) || 0));
|
const cohort = builder.values(
|
||||||
|
cohortCapForQuota(quotas.get(layerId) || 0),
|
||||||
|
);
|
||||||
for (const obj of cohort) {
|
for (const obj of cohort) {
|
||||||
const key = _detectionKey(layerId, obj._cohortSourceId);
|
const key = _detectionKey(layerId, obj._cohortSourceId);
|
||||||
let candidate = candidateMap.get(key);
|
let candidate = candidateMap.get(key);
|
||||||
@@ -1338,7 +1476,9 @@ function _drawOverlay(frame) {
|
|||||||
}
|
}
|
||||||
if (candidate) solveCandidates.push(candidate);
|
if (candidate) solveCandidates.push(candidate);
|
||||||
}
|
}
|
||||||
cohortByLayer[layerId] = solveCandidates.filter((candidate) => candidate.layerId === layerId).length;
|
cohortByLayer[layerId] = solveCandidates.filter(
|
||||||
|
(candidate) => candidate.layerId === layerId,
|
||||||
|
).length;
|
||||||
}
|
}
|
||||||
const solveStarted = performance.now();
|
const solveStarted = performance.now();
|
||||||
_labelArbiter.solve(solveCandidates, {
|
_labelArbiter.solve(solveCandidates, {
|
||||||
@@ -1419,14 +1559,17 @@ function _drawOverlay(frame) {
|
|||||||
labelsByLayer: arbiterDiagnostics.labelsByLayer || {},
|
labelsByLayer: arbiterDiagnostics.labelsByLayer || {},
|
||||||
entitlementByLayer: arbiterDiagnostics.quotas || {},
|
entitlementByLayer: arbiterDiagnostics.quotas || {},
|
||||||
labeledKeys: Array.from(_labelArbiter.selectedKeys),
|
labeledKeys: Array.from(_labelArbiter.selectedKeys),
|
||||||
labelAlphaByKey: Object.fromEntries(renderEntries.map((entry) => [
|
labelAlphaByKey: Object.fromEntries(
|
||||||
|
renderEntries.map((entry) => [
|
||||||
entry.candidate.key,
|
entry.candidate.key,
|
||||||
entry.temporalAlpha * keyholeLabelAlphaFromGeometry(
|
entry.temporalAlpha *
|
||||||
|
keyholeLabelAlphaFromGeometry(
|
||||||
entry.placement.centerX,
|
entry.placement.centerX,
|
||||||
entry.placement.centerY,
|
entry.placement.centerY,
|
||||||
keyhole,
|
keyhole,
|
||||||
),
|
),
|
||||||
])),
|
]),
|
||||||
|
),
|
||||||
solveRevision: _labelArbiter.solveRevision,
|
solveRevision: _labelArbiter.solveRevision,
|
||||||
keyholeRadius: keyhole.radius,
|
keyholeRadius: keyhole.radius,
|
||||||
keyholeFeatherPx: keyhole.featherPx,
|
keyholeFeatherPx: keyhole.featherPx,
|
||||||
@@ -1451,5 +1594,11 @@ function _drawOverlay(frame) {
|
|||||||
_drawModeBanner(visibleCount, sampledCount);
|
_drawModeBanner(visibleCount, sampledCount);
|
||||||
// `_labelSolveDirty` surviving a paint means the solve was owed and did not
|
// `_labelSolveDirty` surviving a paint means the solve was owed and did not
|
||||||
// run — the frame that carried the request cannot be the last one.
|
// run — the frame that carried the request cannot be the last one.
|
||||||
return { didSolve, solveMs, fadingCount, animatingCount, solvePending: _labelSolveDirty };
|
return {
|
||||||
|
didSolve,
|
||||||
|
solveMs,
|
||||||
|
fadingCount,
|
||||||
|
animatingCount,
|
||||||
|
solvePending: _labelSolveDirty,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
+41
-12
@@ -19,11 +19,14 @@ export function stableIdentityHash(layerId, sourceId) {
|
|||||||
|
|
||||||
/** Negative means a is a better deterministic contender than b. */
|
/** Negative means a is a better deterministic contender than b. */
|
||||||
export function compareCohortContenders(a, b) {
|
export function compareCohortContenders(a, b) {
|
||||||
const priorityDelta = (Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
|
const priorityDelta =
|
||||||
|
(Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
|
||||||
if (priorityDelta) return priorityDelta;
|
if (priorityDelta) return priorityDelta;
|
||||||
const bandDelta = (Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
|
const bandDelta =
|
||||||
|
(Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
|
||||||
if (bandDelta) return bandDelta;
|
if (bandDelta) return bandDelta;
|
||||||
const hashDelta = (Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
|
const hashDelta =
|
||||||
|
(Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
|
||||||
if (hashDelta) return hashDelta;
|
if (hashDelta) return hashDelta;
|
||||||
return String(a?._cohortSourceId).localeCompare(String(b?._cohortSourceId));
|
return String(a?._cohortSourceId).localeCompare(String(b?._cohortSourceId));
|
||||||
}
|
}
|
||||||
@@ -39,8 +42,14 @@ function isWorse(a, b) {
|
|||||||
*/
|
*/
|
||||||
export class BoundedCohort {
|
export class BoundedCohort {
|
||||||
constructor(maxSize = DEFAULT_MAX_COHORT, hardMax = DEFAULT_MAX_COHORT) {
|
constructor(maxSize = DEFAULT_MAX_COHORT, hardMax = DEFAULT_MAX_COHORT) {
|
||||||
const ceiling = Math.max(1, Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT));
|
const ceiling = Math.max(
|
||||||
this.maxSize = Math.max(1, Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT)));
|
1,
|
||||||
|
Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT),
|
||||||
|
);
|
||||||
|
this.maxSize = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT)),
|
||||||
|
);
|
||||||
this.incumbents = [];
|
this.incumbents = [];
|
||||||
this.heap = [];
|
this.heap = [];
|
||||||
}
|
}
|
||||||
@@ -62,11 +71,20 @@ export class BoundedCohort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
values(limit = this.maxSize) {
|
values(limit = this.maxSize) {
|
||||||
const cap = Math.max(0, Math.min(this.maxSize, Math.floor(Number(limit) || 0)));
|
const cap = Math.max(
|
||||||
const incumbents = this.incumbents.slice().sort(compareCohortContenders).slice(0, cap);
|
0,
|
||||||
|
Math.min(this.maxSize, Math.floor(Number(limit) || 0)),
|
||||||
|
);
|
||||||
|
const incumbents = this.incumbents
|
||||||
|
.slice()
|
||||||
|
.sort(compareCohortContenders)
|
||||||
|
.slice(0, cap);
|
||||||
const remaining = Math.max(0, cap - incumbents.length);
|
const remaining = Math.max(0, cap - incumbents.length);
|
||||||
if (remaining === 0) return incumbents;
|
if (remaining === 0) return incumbents;
|
||||||
const contenders = this.heap.slice().sort(compareCohortContenders).slice(0, remaining);
|
const contenders = this.heap
|
||||||
|
.slice()
|
||||||
|
.sort(compareCohortContenders)
|
||||||
|
.slice(0, remaining);
|
||||||
return incumbents.concat(contenders);
|
return incumbents.concat(contenders);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +93,10 @@ export class BoundedCohort {
|
|||||||
while (current > 0) {
|
while (current > 0) {
|
||||||
const parent = Math.floor((current - 1) / 2);
|
const parent = Math.floor((current - 1) / 2);
|
||||||
if (!isWorse(this.heap[current], this.heap[parent])) break;
|
if (!isWorse(this.heap[current], this.heap[parent])) break;
|
||||||
[this.heap[current], this.heap[parent]] = [this.heap[parent], this.heap[current]];
|
[this.heap[current], this.heap[parent]] = [
|
||||||
|
this.heap[parent],
|
||||||
|
this.heap[current],
|
||||||
|
];
|
||||||
current = parent;
|
current = parent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,10 +107,18 @@ export class BoundedCohort {
|
|||||||
const left = current * 2 + 1;
|
const left = current * 2 + 1;
|
||||||
const right = left + 1;
|
const right = left + 1;
|
||||||
let worst = current;
|
let worst = current;
|
||||||
if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst])) worst = left;
|
if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst]))
|
||||||
if (right < this.heap.length && isWorse(this.heap[right], this.heap[worst])) worst = right;
|
worst = left;
|
||||||
|
if (
|
||||||
|
right < this.heap.length &&
|
||||||
|
isWorse(this.heap[right], this.heap[worst])
|
||||||
|
)
|
||||||
|
worst = right;
|
||||||
if (worst === current) return;
|
if (worst === current) return;
|
||||||
[this.heap[current], this.heap[worst]] = [this.heap[worst], this.heap[current]];
|
[this.heap[current], this.heap[worst]] = [
|
||||||
|
this.heap[worst],
|
||||||
|
this.heap[current],
|
||||||
|
];
|
||||||
current = worst;
|
current = worst;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,11 +211,14 @@ export function measureTrackLabel(primary, micro, charWidth) {
|
|||||||
*/
|
*/
|
||||||
export function rectIntersectsAny(rect, obstacles, padding = 0) {
|
export function rectIntersectsAny(rect, obstacles, padding = 0) {
|
||||||
if (!rect || !Array.isArray(obstacles)) return false;
|
if (!rect || !Array.isArray(obstacles)) return false;
|
||||||
return obstacles.some((obstacle) => obstacle
|
return obstacles.some(
|
||||||
&& rect.x < obstacle.x + obstacle.w + padding
|
(obstacle) =>
|
||||||
&& rect.x + rect.w + padding > obstacle.x
|
obstacle &&
|
||||||
&& rect.y < obstacle.y + obstacle.h + padding
|
rect.x < obstacle.x + obstacle.w + padding &&
|
||||||
&& rect.y + rect.h + padding > obstacle.y);
|
rect.x + rect.w + padding > obstacle.x &&
|
||||||
|
rect.y < obstacle.y + obstacle.h + padding &&
|
||||||
|
rect.y + rect.h + padding > obstacle.y,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -238,7 +241,6 @@ export function nearFarScale(distance, near, nearValue, far, farValue) {
|
|||||||
return nearValue + t * (farValue - nearValue);
|
return nearValue + t * (farValue - nearValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Transit keeps literal mode colour on the normal-composite surface. */
|
/** Transit keeps literal mode colour on the normal-composite surface. */
|
||||||
export function paintTransitBracket(ctx, path, color, alpha = 1) {
|
export function paintTransitBracket(ctx, path, color, alpha = 1) {
|
||||||
ctx.save?.();
|
ctx.save?.();
|
||||||
@@ -255,6 +257,11 @@ export function paintTransitBracket(ctx, path, color, alpha = 1) {
|
|||||||
|
|
||||||
/** Centre 1.25 px strokes on pixels, so their cores are fully covered. */
|
/** Centre 1.25 px strokes on pixels, so their cores are fully covered. */
|
||||||
export function appendTransitBracket(sink, sx, sy, halfW, halfH) {
|
export function appendTransitBracket(sink, sx, sy, halfW, halfH) {
|
||||||
appendCornerBracket(sink, Math.floor(sx) + 0.5, Math.floor(sy) + 0.5,
|
appendCornerBracket(
|
||||||
Math.round(halfW), Math.round(halfH));
|
sink,
|
||||||
|
Math.floor(sx) + 0.5,
|
||||||
|
Math.floor(sy) + 0.5,
|
||||||
|
Math.round(halfW),
|
||||||
|
Math.round(halfH),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
+66
-20
@@ -8,7 +8,10 @@
|
|||||||
export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]);
|
export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]);
|
||||||
export const ALLOCATION_ELASTIC = 'ELASTIC';
|
export const ALLOCATION_ELASTIC = 'ELASTIC';
|
||||||
export const ALLOCATION_WEIGHTED = 'WEIGHTED';
|
export const ALLOCATION_WEIGHTED = 'WEIGHTED';
|
||||||
export const ALLOCATION_STRATEGIES = Object.freeze([ALLOCATION_ELASTIC, ALLOCATION_WEIGHTED]);
|
export const ALLOCATION_STRATEGIES = Object.freeze([
|
||||||
|
ALLOCATION_ELASTIC,
|
||||||
|
ALLOCATION_WEIGHTED,
|
||||||
|
]);
|
||||||
|
|
||||||
// The keyhole's default 1% outside opacity is appropriate for text, but it
|
// The keyhole's default 1% outside opacity is appropriate for text, but it
|
||||||
// made side aircraft brackets effectively disappear while the same contacts
|
// made side aircraft brackets effectively disappear while the same contacts
|
||||||
@@ -71,10 +74,16 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
|
|||||||
: AIRCRAFT_BRACKET_FLOOR_ANCHOR;
|
: AIRCRAFT_BRACKET_FLOOR_ANCHOR;
|
||||||
if (outside <= 0) return 0;
|
if (outside <= 0) return 0;
|
||||||
if (outside <= AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
|
if (outside <= AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
|
||||||
return AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR);
|
return (
|
||||||
|
AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const progress = (outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) / (1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR);
|
const progress =
|
||||||
return AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress;
|
(outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) /
|
||||||
|
(1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR);
|
||||||
|
return (
|
||||||
|
AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,7 +94,11 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
|
|||||||
* the default, so a caller that does not know it reproduces the shipped look.
|
* the default, so a caller that does not know it reproduces the shipped look.
|
||||||
* @returns {number} Paint alpha, 0-1.
|
* @returns {number} Paint alpha, 0-1.
|
||||||
*/
|
*/
|
||||||
export function detectionBracketAlpha(type, keyholeAlpha, outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
|
export function detectionBracketAlpha(
|
||||||
|
type,
|
||||||
|
keyholeAlpha,
|
||||||
|
outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR,
|
||||||
|
) {
|
||||||
const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0));
|
const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0));
|
||||||
if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha;
|
if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha;
|
||||||
return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha);
|
return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha);
|
||||||
@@ -97,7 +110,7 @@ export function detectionHorizontalSector(screenX, viewportWidth) {
|
|||||||
const x = Number(screenX);
|
const x = Number(screenX);
|
||||||
if (!(width > 0) || !Number.isFinite(x)) return 'front';
|
if (!(width > 0) || !Number.isFinite(x)) return 'front';
|
||||||
if (x < width / 3) return 'left';
|
if (x < width / 3) return 'left';
|
||||||
if (x > width * 2 / 3) return 'right';
|
if (x > (width * 2) / 3) return 'right';
|
||||||
return 'front';
|
return 'front';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,19 +153,29 @@ export function defaultDensityForProfile(profile) {
|
|||||||
|
|
||||||
/** Normalize current and legacy profile labels. OFF remains an enabled-state value. */
|
/** Normalize current and legacy profile labels. OFF remains an enabled-state value. */
|
||||||
export function normalizeProfile(profile) {
|
export function normalizeProfile(profile) {
|
||||||
const raw = String(profile || '').trim().toUpperCase();
|
const raw = String(profile || '')
|
||||||
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
if (raw === 'OFF') return 'OFF';
|
if (raw === 'OFF') return 'OFF';
|
||||||
if (raw === 'SPARSE' || raw === 'SURVEY') return 'SPARSE';
|
if (raw === 'SPARSE' || raw === 'SURVEY') return 'SPARSE';
|
||||||
if (raw === 'BALANCED' || raw === 'NORMAL') return 'BALANCED';
|
if (raw === 'BALANCED' || raw === 'NORMAL') return 'BALANCED';
|
||||||
if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON') return 'DENSE';
|
if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON')
|
||||||
|
return 'DENSE';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize the user-selectable layer allocation strategy. */
|
/** Normalize the user-selectable layer allocation strategy. */
|
||||||
export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELASTIC) {
|
export function normalizeAllocationStrategy(
|
||||||
const raw = String(strategy || '').trim().toUpperCase();
|
strategy,
|
||||||
|
fallback = ALLOCATION_ELASTIC,
|
||||||
|
) {
|
||||||
|
const raw = String(strategy || '')
|
||||||
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
if (ALLOCATION_STRATEGIES.includes(raw)) return raw;
|
if (ALLOCATION_STRATEGIES.includes(raw)) return raw;
|
||||||
const normalizedFallback = String(fallback || '').trim().toUpperCase();
|
const normalizedFallback = String(fallback || '')
|
||||||
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
return ALLOCATION_STRATEGIES.includes(normalizedFallback)
|
return ALLOCATION_STRATEGIES.includes(normalizedFallback)
|
||||||
? normalizedFallback
|
? normalizedFallback
|
||||||
: ALLOCATION_ELASTIC;
|
: ALLOCATION_ELASTIC;
|
||||||
@@ -160,7 +183,9 @@ export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELAS
|
|||||||
|
|
||||||
/** Classify camera altitude into the shared label-budget view scale. */
|
/** Classify camera altitude into the shared label-budget view scale. */
|
||||||
export function viewScaleForAltitude(altitudeM) {
|
export function viewScaleForAltitude(altitudeM) {
|
||||||
const altitude = Number.isFinite(Number(altitudeM)) ? Math.max(0, Number(altitudeM)) : 1e9;
|
const altitude = Number.isFinite(Number(altitudeM))
|
||||||
|
? Math.max(0, Number(altitudeM))
|
||||||
|
: 1e9;
|
||||||
if (altitude < 1200) return 'street';
|
if (altitude < 1200) return 'street';
|
||||||
if (altitude < 4500) return 'city';
|
if (altitude < 4500) return 'city';
|
||||||
if (altitude < 20000) return 'metro';
|
if (altitude < 20000) return 'metro';
|
||||||
@@ -181,25 +206,46 @@ export function labelBudgetFor(altitudeM, densityPct) {
|
|||||||
* density was below 75; legacy Sparse remains inside the Sparse band.
|
* density was below 75; legacy Sparse remains inside the Sparse band.
|
||||||
*/
|
*/
|
||||||
export function migrateDetectionState(mode, densityPct, fallbackDensity = 50) {
|
export function migrateDetectionState(mode, densityPct, fallbackDensity = 50) {
|
||||||
const rawMode = String(mode || '').trim().toUpperCase();
|
const rawMode = String(mode || '')
|
||||||
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
const normalized = normalizeProfile(rawMode);
|
const normalized = normalizeProfile(rawMode);
|
||||||
if (normalized === 'OFF') {
|
if (normalized === 'OFF') {
|
||||||
const supplied = Number.isFinite(Number(densityPct)) ? densityPct : fallbackDensity;
|
const supplied = Number.isFinite(Number(densityPct))
|
||||||
|
? densityPct
|
||||||
|
: fallbackDensity;
|
||||||
const stop = canonicalizeDensity(supplied, fallbackDensity);
|
const stop = canonicalizeDensity(supplied, fallbackDensity);
|
||||||
return { enabled: false, profile: profileForDensity(stop), densityPct: stop };
|
return {
|
||||||
|
enabled: false,
|
||||||
|
profile: profileForDensity(stop),
|
||||||
|
densityPct: stop,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (rawMode === 'PANOPTIC' || rawMode === 'GOD' || rawMode === 'ON') {
|
if (rawMode === 'PANOPTIC' || rawMode === 'GOD' || rawMode === 'ON') {
|
||||||
const density = Number.isFinite(Number(densityPct)) ? Math.max(75, Number(densityPct)) : 75;
|
const density = Number.isFinite(Number(densityPct))
|
||||||
return { enabled: true, profile: 'DENSE', densityPct: canonicalizeDensity(density) };
|
? Math.max(75, Number(densityPct))
|
||||||
|
: 75;
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
profile: 'DENSE',
|
||||||
|
densityPct: canonicalizeDensity(density),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (rawMode === 'SPARSE' || rawMode === 'SURVEY') {
|
if (rawMode === 'SPARSE' || rawMode === 'SURVEY') {
|
||||||
const density = Number.isFinite(Number(densityPct)) ? Math.min(25, Number(densityPct)) : 25;
|
const density = Number.isFinite(Number(densityPct))
|
||||||
|
? Math.min(25, Number(densityPct))
|
||||||
|
: 25;
|
||||||
const stop = canonicalizeDensity(density, 25);
|
const stop = canonicalizeDensity(density, 25);
|
||||||
return { enabled: true, profile: 'SPARSE', densityPct: stop };
|
return { enabled: true, profile: 'SPARSE', densityPct: stop };
|
||||||
}
|
}
|
||||||
if (normalized === 'BALANCED' || normalized === 'DENSE') {
|
if (normalized === 'BALANCED' || normalized === 'DENSE') {
|
||||||
const supplied = Number.isFinite(Number(densityPct)) ? canonicalizeDensity(densityPct) : defaultDensityForProfile(normalized);
|
const supplied = Number.isFinite(Number(densityPct))
|
||||||
const stop = profileForDensity(supplied) === normalized ? supplied : defaultDensityForProfile(normalized);
|
? canonicalizeDensity(densityPct)
|
||||||
|
: defaultDensityForProfile(normalized);
|
||||||
|
const stop =
|
||||||
|
profileForDensity(supplied) === normalized
|
||||||
|
? supplied
|
||||||
|
: defaultDensityForProfile(normalized);
|
||||||
return { enabled: true, profile: normalized, densityPct: stop };
|
return { enabled: true, profile: normalized, densityPct: stop };
|
||||||
}
|
}
|
||||||
const stop = canonicalizeDensity(densityPct, fallbackDensity);
|
const stop = canonicalizeDensity(densityPct, fallbackDensity);
|
||||||
|
|||||||
@@ -10,4 +10,3 @@ export const COCKPIT_BRACKET_OPACITY = 0.45;
|
|||||||
export function detectionBracketOpacity(cockpitActive) {
|
export function detectionBracketOpacity(cockpitActive) {
|
||||||
return cockpitActive === true ? COCKPIT_BRACKET_OPACITY : 1;
|
return cockpitActive === true ? COCKPIT_BRACKET_OPACITY : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,9 +158,10 @@ export function detectionPaintSkipDecision({
|
|||||||
frameCount,
|
frameCount,
|
||||||
thresholdMs = DETECTION_PAINT_SKIP_THRESHOLD_MS,
|
thresholdMs = DETECTION_PAINT_SKIP_THRESHOLD_MS,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const skip = !layoutChanged
|
const skip =
|
||||||
&& Number(lastPaintMs) > thresholdMs
|
!layoutChanged &&
|
||||||
&& Number(frameCount) % 2 !== 0;
|
Number(lastPaintMs) > thresholdMs &&
|
||||||
|
Number(frameCount) % 2 !== 0;
|
||||||
return { skip, requestFollowUp: skip };
|
return { skip, requestFollowUp: skip };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,9 @@
|
|||||||
* @returns {number} Heading in degrees [0..360), or NaN if unrecognized.
|
* @returns {number} Heading in degrees [0..360), or NaN if unrecognized.
|
||||||
*/
|
*/
|
||||||
export function directionToHeading(value, allowBare = false) {
|
export function directionToHeading(value, allowBare = false) {
|
||||||
const text = String(value || '').trim().toUpperCase();
|
const text = String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
if (!text) return NaN;
|
if (!text) return NaN;
|
||||||
// Explicit travel/intercardinal forms — safe on free-form text (a street
|
// Explicit travel/intercardinal forms — safe on free-form text (a street
|
||||||
// name almost never contains "NORTHBOUND" or a lone "NB" token).
|
// name almost never contains "NORTHBOUND" or a lone "NB" token).
|
||||||
|
|||||||
@@ -79,8 +79,12 @@ export function parseAcquisitionMs(date, time, cache = new Map()) {
|
|||||||
const hhmm = String(time ?? '0000').padStart(4, '0');
|
const hhmm = String(time ?? '0000').padStart(4, '0');
|
||||||
const hours = Number(hhmm.slice(0, 2));
|
const hours = Number(hhmm.slice(0, 2));
|
||||||
const minutes = Number(hhmm.slice(2, 4));
|
const minutes = Number(hhmm.slice(2, 4));
|
||||||
const valid = Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day)
|
const valid =
|
||||||
&& Number.isFinite(hours) && Number.isFinite(minutes);
|
Number.isFinite(year) &&
|
||||||
|
Number.isFinite(month) &&
|
||||||
|
Number.isFinite(day) &&
|
||||||
|
Number.isFinite(hours) &&
|
||||||
|
Number.isFinite(minutes);
|
||||||
const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
|
const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
|
||||||
cache.set(key, ms);
|
cache.set(key, ms);
|
||||||
return ms;
|
return ms;
|
||||||
|
|||||||
+11
-5
@@ -12,10 +12,14 @@ export const FIRMS_OVERLAY_SOURCE_ID = 'firms';
|
|||||||
export function fireDetectionKey(fire) {
|
export function fireDetectionKey(fire) {
|
||||||
const lat = Number.isFinite(fire?.lat) ? fire.lat.toFixed(4) : 'x';
|
const lat = Number.isFinite(fire?.lat) ? fire.lat.toFixed(4) : 'x';
|
||||||
const lon = Number.isFinite(fire?.lon) ? fire.lon.toFixed(4) : 'x';
|
const lon = Number.isFinite(fire?.lon) ? fire.lon.toFixed(4) : 'x';
|
||||||
const acq = Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? String(fire.acqMs) : '0';
|
const acq =
|
||||||
const source = satelliteShortName(fire?.satellite)
|
Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? String(fire.acqMs) : '0';
|
||||||
|| String(fire?.sensor || '').trim().toUpperCase()
|
const source =
|
||||||
|| 'x';
|
satelliteShortName(fire?.satellite) ||
|
||||||
|
String(fire?.sensor || '')
|
||||||
|
.trim()
|
||||||
|
.toUpperCase() ||
|
||||||
|
'x';
|
||||||
return `firms:${lat}:${lon}:${acq}:${source}`;
|
return `firms:${lat}:${lon}:${acq}:${source}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +41,9 @@ export function accentForSeverity(stopName) {
|
|||||||
|
|
||||||
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
|
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
|
||||||
export function satelliteShortName(satellite) {
|
export function satelliteShortName(satellite) {
|
||||||
const s = String(satellite || '').trim().toUpperCase();
|
const s = String(satellite || '')
|
||||||
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
if (s === 'N20' || s === 'NOAA-20') return 'N20';
|
if (s === 'N20' || s === 'NOAA-20') return 'N20';
|
||||||
if (s === 'N21' || s === 'NOAA-21') return 'N21';
|
if (s === 'N21' || s === 'NOAA-21') return 'N21';
|
||||||
if (s === 'N' || s === 'NPP' || s === 'SUOMI NPP') return 'SNPP';
|
if (s === 'N' || s === 'NPP' || s === 'SUOMI NPP') return 'SNPP';
|
||||||
|
|||||||
+29
-9
@@ -45,7 +45,9 @@ export function median(values) {
|
|||||||
if (!Array.isArray(values) || values.length === 0) return null;
|
if (!Array.isArray(values) || values.length === 0) return null;
|
||||||
const sorted = [...values].sort((a, b) => a - b);
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
const mid = sorted.length >> 1;
|
const mid = sorted.length >> 1;
|
||||||
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
return sorted.length % 2 === 1
|
||||||
|
? sorted[mid]
|
||||||
|
: (sorted[mid - 1] + sorted[mid]) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bearing (degrees, 0 = north, clockwise) of a projected dx/dy vector. */
|
/** Bearing (degrees, 0 = north, clockwise) of a projected dx/dy vector. */
|
||||||
@@ -98,13 +100,19 @@ export function matchFlowToRoads(roads, flowSegments) {
|
|||||||
const roadCount = Array.isArray(roads) ? roads.length : 0;
|
const roadCount = Array.isArray(roads) ? roads.length : 0;
|
||||||
const matches = new Array(roadCount).fill(null);
|
const matches = new Array(roadCount).fill(null);
|
||||||
const empty = { matches, matchedCount: 0, candidateCount: 0 };
|
const empty = { matches, matchedCount: 0, candidateCount: 0 };
|
||||||
if (roadCount === 0 || !Array.isArray(flowSegments) || flowSegments.length === 0) {
|
if (
|
||||||
|
roadCount === 0 ||
|
||||||
|
!Array.isArray(flowSegments) ||
|
||||||
|
flowSegments.length === 0
|
||||||
|
) {
|
||||||
return empty;
|
return empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local equirectangular projection anchored at the first flow coordinate —
|
// Local equirectangular projection anchored at the first flow coordinate —
|
||||||
// over a ≤0.05° fetch box the distortion is negligible.
|
// over a ≤0.05° fetch box the distortion is negligible.
|
||||||
const anchor = flowSegments.find((f) => Array.isArray(f?.coords) && f.coords.length >= 2);
|
const anchor = flowSegments.find(
|
||||||
|
(f) => Array.isArray(f?.coords) && f.coords.length >= 2,
|
||||||
|
);
|
||||||
if (!anchor) return empty;
|
if (!anchor) return empty;
|
||||||
const [refLon, refLat] = anchor.coords[0];
|
const [refLon, refLat] = anchor.coords[0];
|
||||||
const mPerDegLon = M_PER_DEG_LAT * Math.cos((refLat * Math.PI) / 180);
|
const mPerDegLon = M_PER_DEG_LAT * Math.cos((refLat * Math.PI) / 180);
|
||||||
@@ -114,7 +122,8 @@ export function matchFlowToRoads(roads, flowSegments) {
|
|||||||
// ── 1. Spatial hash of flow segments (midpoint-keyed 100 m cells) ──
|
// ── 1. Spatial hash of flow segments (midpoint-keyed 100 m cells) ──
|
||||||
/** @type {Map<string, Array<{ax:number,ay:number,bx:number,by:number,bearing:number,level:number,closure:boolean}>>} */
|
/** @type {Map<string, Array<{ax:number,ay:number,bx:number,by:number,bearing:number,level:number,closure:boolean}>>} */
|
||||||
const grid = new Map();
|
const grid = new Map();
|
||||||
const cellOf = (x, y) => `${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
|
const cellOf = (x, y) =>
|
||||||
|
`${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
|
||||||
|
|
||||||
for (const flow of flowSegments) {
|
for (const flow of flowSegments) {
|
||||||
const coords = flow?.coords;
|
const coords = flow?.coords;
|
||||||
@@ -173,7 +182,8 @@ export function matchFlowToRoads(roads, flowSegments) {
|
|||||||
for (let i = 0; i < coords.length; i++) {
|
for (let i = 0; i < coords.length; i++) {
|
||||||
xs[i] = projX(coords[i][0]);
|
xs[i] = projX(coords[i][0]);
|
||||||
ys[i] = projY(coords[i][1]);
|
ys[i] = projY(coords[i][1]);
|
||||||
if (i > 0) cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
|
if (i > 0)
|
||||||
|
cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
|
||||||
}
|
}
|
||||||
const totalLen = cum[coords.length - 1];
|
const totalLen = cum[coords.length - 1];
|
||||||
if (!(totalLen > 0)) continue;
|
if (!(totalLen > 0)) continue;
|
||||||
@@ -188,10 +198,14 @@ export function matchFlowToRoads(roads, flowSegments) {
|
|||||||
// cross-street flow lines meet at intersections.
|
// cross-street flow lines meet at intersections.
|
||||||
const target = (totalLen * (s + 0.5)) / ROAD_SAMPLES;
|
const target = (totalLen * (s + 0.5)) / ROAD_SAMPLES;
|
||||||
while (cursor < coords.length - 1 && cum[cursor] < target) cursor++;
|
while (cursor < coords.length - 1 && cum[cursor] < target) cursor++;
|
||||||
const segT = (target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
|
const segT =
|
||||||
|
(target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
|
||||||
const px = xs[cursor - 1] + (xs[cursor] - xs[cursor - 1]) * segT;
|
const px = xs[cursor - 1] + (xs[cursor] - xs[cursor - 1]) * segT;
|
||||||
const py = ys[cursor - 1] + (ys[cursor] - ys[cursor - 1]) * segT;
|
const py = ys[cursor - 1] + (ys[cursor] - ys[cursor - 1]) * segT;
|
||||||
const sampleBearing = bearingDeg(xs[cursor] - xs[cursor - 1], ys[cursor] - ys[cursor - 1]);
|
const sampleBearing = bearingDeg(
|
||||||
|
xs[cursor] - xs[cursor - 1],
|
||||||
|
ys[cursor] - ys[cursor - 1],
|
||||||
|
);
|
||||||
|
|
||||||
// 3×3 cell probe around the sample.
|
// 3×3 cell probe around the sample.
|
||||||
const cx = Math.floor(px / CELL_SIZE_M);
|
const cx = Math.floor(px / CELL_SIZE_M);
|
||||||
@@ -206,7 +220,11 @@ export function matchFlowToRoads(roads, flowSegments) {
|
|||||||
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
|
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
|
||||||
if (d2 > radius2) continue;
|
if (d2 > radius2) continue;
|
||||||
hadCandidate = true; // within radius, bearing not yet checked
|
hadCandidate = true; // within radius, bearing not yet checked
|
||||||
if (bearingDiffDeg(seg.bearing, sampleBearing) >= BEARING_TOLERANCE_DEG) continue;
|
if (
|
||||||
|
bearingDiffDeg(seg.bearing, sampleBearing) >=
|
||||||
|
BEARING_TOLERANCE_DEG
|
||||||
|
)
|
||||||
|
continue;
|
||||||
if (d2 <= bestDist2) {
|
if (d2 <= bestDist2) {
|
||||||
bestDist2 = d2;
|
bestDist2 = d2;
|
||||||
best = seg;
|
best = seg;
|
||||||
@@ -221,7 +239,9 @@ export function matchFlowToRoads(roads, flowSegments) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hadCandidate) candidateCount++;
|
if (hadCandidate) candidateCount++;
|
||||||
if (matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)) {
|
if (
|
||||||
|
matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)
|
||||||
|
) {
|
||||||
matches[r] = { level: median(matchedLevels), closure: matchedClosure };
|
matches[r] = { level: median(matchedLevels), closure: matchedClosure };
|
||||||
matchedCount++;
|
matchedCount++;
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-40
@@ -59,24 +59,35 @@ function focusTargetEmphasisResolved(
|
|||||||
previouslyFarther,
|
previouslyFarther,
|
||||||
) {
|
) {
|
||||||
if (!target?.screenRect || !spriteScreenPosition) return 1;
|
if (!target?.screenRect || !spriteScreenPosition) return 1;
|
||||||
if (!Number.isFinite(spriteScreenPosition.x) || !Number.isFinite(spriteScreenPosition.y)) return 1;
|
if (
|
||||||
|
!Number.isFinite(spriteScreenPosition.x) ||
|
||||||
|
!Number.isFinite(spriteScreenPosition.y)
|
||||||
|
)
|
||||||
|
return 1;
|
||||||
|
|
||||||
// Published rects already contain their publication-time padding. The
|
// Published rects already contain their publication-time padding. The
|
||||||
// delta keeps direct pure-function calls and runtime A/B overrides honest.
|
// delta keeps direct pure-function calls and runtime A/B overrides honest.
|
||||||
// The ambient extent makes overlap mean visual overlap, not center-point
|
// The ambient extent makes overlap mean visual overlap, not center-point
|
||||||
// containment: a class-sized icon whose edge intrudes into the focus rect
|
// containment: a class-sized icon whose edge intrudes into the focus rect
|
||||||
// yields even when its center remains just outside it.
|
// yields even when its center remains just outside it.
|
||||||
const paddingDelta = tuning.paddingPx - (Number.isFinite(target.paddingPx) ? target.paddingPx : 0);
|
const paddingDelta =
|
||||||
|
tuning.paddingPx -
|
||||||
|
(Number.isFinite(target.paddingPx) ? target.paddingPx : 0);
|
||||||
const hysteresis = previouslyCompeting ? tuning.hysteresisPx : 0;
|
const hysteresis = previouslyCompeting ? tuning.hysteresisPx : 0;
|
||||||
const expansionX = paddingDelta + hysteresis
|
const expansionX =
|
||||||
+ Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0);
|
paddingDelta +
|
||||||
const expansionY = paddingDelta + hysteresis
|
hysteresis +
|
||||||
+ Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0);
|
Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0);
|
||||||
|
const expansionY =
|
||||||
|
paddingDelta +
|
||||||
|
hysteresis +
|
||||||
|
Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0);
|
||||||
const rect = target.screenRect;
|
const rect = target.screenRect;
|
||||||
const inside = spriteScreenPosition.x >= rect.left - expansionX
|
const inside =
|
||||||
&& spriteScreenPosition.x <= rect.right + expansionX
|
spriteScreenPosition.x >= rect.left - expansionX &&
|
||||||
&& spriteScreenPosition.y >= rect.top - expansionY
|
spriteScreenPosition.x <= rect.right + expansionX &&
|
||||||
&& spriteScreenPosition.y <= rect.bottom + expansionY;
|
spriteScreenPosition.y >= rect.top - expansionY &&
|
||||||
|
spriteScreenPosition.y <= rect.bottom + expansionY;
|
||||||
if (!inside) return 1;
|
if (!inside) return 1;
|
||||||
|
|
||||||
const floor = clamp(tuning.dimFloor, 0.01, 1);
|
const floor = clamp(tuning.dimFloor, 0.01, 1);
|
||||||
@@ -88,24 +99,33 @@ function focusTargetEmphasisResolved(
|
|||||||
const fartherThreshold = Number.isFinite(target.cameraDistance)
|
const fartherThreshold = Number.isFinite(target.cameraDistance)
|
||||||
? target.cameraDistance + (previouslyFarther ? -distanceBand : distanceBand)
|
? target.cameraDistance + (previouslyFarther ? -distanceBand : distanceBand)
|
||||||
: Number.NaN;
|
: Number.NaN;
|
||||||
const farther = !Number.isFinite(spriteCameraDistance)
|
const farther =
|
||||||
|| !Number.isFinite(fartherThreshold)
|
!Number.isFinite(spriteCameraDistance) ||
|
||||||
|| spriteCameraDistance >= fartherThreshold;
|
!Number.isFinite(fartherThreshold) ||
|
||||||
|
spriteCameraDistance >= fartherThreshold;
|
||||||
if (farther) return floor;
|
if (farther) return floor;
|
||||||
if (tuning.nearerBehavior === 'partial') return floor + (1 - floor) * 0.5;
|
if (tuning.nearerBehavior === 'partial') return floor + (1 - floor) * 0.5;
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
|
function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
|
||||||
const from = clamp(Number.isFinite(current) ? current : 1, tuning.dimFloor, 1);
|
const from = clamp(
|
||||||
|
Number.isFinite(current) ? current : 1,
|
||||||
|
tuning.dimFloor,
|
||||||
|
1,
|
||||||
|
);
|
||||||
const to = clamp(Number.isFinite(target) ? target : 1, tuning.dimFloor, 1);
|
const to = clamp(Number.isFinite(target) ? target : 1, tuning.dimFloor, 1);
|
||||||
if (Math.abs(to - from) < Number.EPSILON) return to;
|
if (Math.abs(to - from) < Number.EPSILON) return to;
|
||||||
const duration = to < from ? tuning.attackMs : tuning.releaseMs;
|
const duration = to < from ? tuning.attackMs : tuning.releaseMs;
|
||||||
if (duration <= 0) return to;
|
if (duration <= 0) return to;
|
||||||
const t = clamp((Number.isFinite(elapsedMs) ? elapsedMs : 0) / duration, 0, 1);
|
const t = clamp(
|
||||||
|
(Number.isFinite(elapsedMs) ? elapsedMs : 0) / duration,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
// Cubic ease-out reaches the exact endpoint at the configured duration and
|
// Cubic ease-out reaches the exact endpoint at the configured duration and
|
||||||
// stays continuous when a moving contact reverses direction mid-transition.
|
// stays continuous when a moving contact reverses direction mid-transition.
|
||||||
const eased = 1 - ((1 - t) ** 3);
|
const eased = 1 - (1 - t) ** 3;
|
||||||
return clamp(from + (to - from) * eased, tuning.dimFloor, 1);
|
return clamp(from + (to - from) * eased, tuning.dimFloor, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,18 +138,24 @@ function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
|
|||||||
*/
|
*/
|
||||||
export function setFocusDeemphasisParams(patch = {}) {
|
export function setFocusDeemphasisParams(patch = {}) {
|
||||||
const next = { ..._params };
|
const next = { ..._params };
|
||||||
if (Number.isFinite(patch.paddingPx)) next.paddingPx = Math.max(0, patch.paddingPx);
|
if (Number.isFinite(patch.paddingPx))
|
||||||
if (Number.isFinite(patch.dimFloor)) next.dimFloor = clamp(patch.dimFloor, 0.01, 1);
|
next.paddingPx = Math.max(0, patch.paddingPx);
|
||||||
|
if (Number.isFinite(patch.dimFloor))
|
||||||
|
next.dimFloor = clamp(patch.dimFloor, 0.01, 1);
|
||||||
if (['allow', 'dim', 'partial'].includes(patch.nearerBehavior)) {
|
if (['allow', 'dim', 'partial'].includes(patch.nearerBehavior)) {
|
||||||
next.nearerBehavior = patch.nearerBehavior;
|
next.nearerBehavior = patch.nearerBehavior;
|
||||||
}
|
}
|
||||||
if (Number.isFinite(patch.hysteresisPx)) next.hysteresisPx = Math.max(0, patch.hysteresisPx);
|
if (Number.isFinite(patch.hysteresisPx))
|
||||||
|
next.hysteresisPx = Math.max(0, patch.hysteresisPx);
|
||||||
if (Number.isFinite(patch.distanceHysteresisRatio)) {
|
if (Number.isFinite(patch.distanceHysteresisRatio)) {
|
||||||
next.distanceHysteresisRatio = clamp(patch.distanceHysteresisRatio, 0, 0.5);
|
next.distanceHysteresisRatio = clamp(patch.distanceHysteresisRatio, 0, 0.5);
|
||||||
}
|
}
|
||||||
if (Number.isFinite(patch.attackMs)) next.attackMs = Math.max(0, patch.attackMs);
|
if (Number.isFinite(patch.attackMs))
|
||||||
if (Number.isFinite(patch.releaseMs)) next.releaseMs = Math.max(0, patch.releaseMs);
|
next.attackMs = Math.max(0, patch.attackMs);
|
||||||
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon);
|
if (Number.isFinite(patch.releaseMs))
|
||||||
|
next.releaseMs = Math.max(0, patch.releaseMs);
|
||||||
|
if (Number.isFinite(patch.writeEpsilon))
|
||||||
|
next.writeEpsilon = Math.max(0, patch.writeEpsilon);
|
||||||
_params = next;
|
_params = next;
|
||||||
return { ..._params };
|
return { ..._params };
|
||||||
}
|
}
|
||||||
@@ -171,11 +197,22 @@ export function publishFocusTargetFromCachedPosition({
|
|||||||
heightPx = 24,
|
heightPx = 24,
|
||||||
params,
|
params,
|
||||||
}) {
|
}) {
|
||||||
if (!ownerLayer || id === null || id === undefined || !scene || !camera || !displayPosition) {
|
if (
|
||||||
|
!ownerLayer ||
|
||||||
|
id === null ||
|
||||||
|
id === undefined ||
|
||||||
|
!scene ||
|
||||||
|
!camera ||
|
||||||
|
!displayPosition
|
||||||
|
) {
|
||||||
clearFocusTarget(ownerLayer, id);
|
clearFocusTarget(ownerLayer, id);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(scene, displayPosition, _scratchScreen);
|
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||||
|
scene,
|
||||||
|
displayPosition,
|
||||||
|
_scratchScreen,
|
||||||
|
);
|
||||||
if (!screen || !Number.isFinite(screen.x) || !Number.isFinite(screen.y)) {
|
if (!screen || !Number.isFinite(screen.x) || !Number.isFinite(screen.y)) {
|
||||||
clearFocusTarget(ownerLayer, id);
|
clearFocusTarget(ownerLayer, id);
|
||||||
return null;
|
return null;
|
||||||
@@ -195,12 +232,19 @@ export function publishFocusTargetFromCachedPosition({
|
|||||||
bottom: screen.y + halfHeight + padding,
|
bottom: screen.y + halfHeight + padding,
|
||||||
},
|
},
|
||||||
paddingPx: padding,
|
paddingPx: padding,
|
||||||
cameraDistance: Cesium.Cartesian3.distance(camera.positionWC, displayPosition),
|
cameraDistance: Cesium.Cartesian3.distance(
|
||||||
|
camera.positionWC,
|
||||||
|
displayPosition,
|
||||||
|
),
|
||||||
frameNumber: scene.frameState?.frameNumber ?? -1,
|
frameNumber: scene.frameState?.frameNumber ?? -1,
|
||||||
};
|
};
|
||||||
if (appeared) {
|
if (appeared) {
|
||||||
for (const listener of _focusAppearListeners) {
|
for (const listener of _focusAppearListeners) {
|
||||||
try { listener(); } catch (e) { console.warn('[focus] appear listener error:', e); }
|
try {
|
||||||
|
listener();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[focus] appear listener error:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return _focusTarget;
|
return _focusTarget;
|
||||||
@@ -311,7 +355,9 @@ export function smoothFocusEmphasis(current, target, elapsedMs, params) {
|
|||||||
* every needed property before the next call; never retain it as a snapshot.
|
* every needed property before the next call; never retain it as a snapshot.
|
||||||
* @returns {{factor:number, changed:boolean, transitioning:boolean,active:boolean,desired:number}}
|
* @returns {{factor:number, changed:boolean, transitioning:boolean,active:boolean,desired:number}}
|
||||||
*/
|
*/
|
||||||
export function advanceSpriteFocus(sprite, {
|
export function advanceSpriteFocus(
|
||||||
|
sprite,
|
||||||
|
{
|
||||||
screenPosition,
|
screenPosition,
|
||||||
cameraDistance,
|
cameraDistance,
|
||||||
nowMs,
|
nowMs,
|
||||||
@@ -319,7 +365,8 @@ export function advanceSpriteFocus(sprite, {
|
|||||||
params,
|
params,
|
||||||
spriteHalfWidthPx = 0,
|
spriteHalfWidthPx = 0,
|
||||||
spriteHalfHeightPx = spriteHalfWidthPx,
|
spriteHalfHeightPx = spriteHalfWidthPx,
|
||||||
}) {
|
},
|
||||||
|
) {
|
||||||
const tuning = resolvedParams(params);
|
const tuning = resolvedParams(params);
|
||||||
let state = _spriteStates.get(sprite);
|
let state = _spriteStates.get(sprite);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
@@ -369,12 +416,18 @@ export function advanceSpriteFocus(sprite, {
|
|||||||
state.desired = desired;
|
state.desired = desired;
|
||||||
}
|
}
|
||||||
const elapsedMs = Math.max(0, nowMs - state.transitionStartMs);
|
const elapsedMs = Math.max(0, nowMs - state.transitionStartMs);
|
||||||
const next = smoothFocusEmphasisResolved(state.transitionFrom, desired, elapsedMs, tuning);
|
const next = smoothFocusEmphasisResolved(
|
||||||
|
state.transitionFrom,
|
||||||
|
desired,
|
||||||
|
elapsedMs,
|
||||||
|
tuning,
|
||||||
|
);
|
||||||
const changed = Math.abs(next - state.factor) > tuning.writeEpsilon;
|
const changed = Math.abs(next - state.factor) > tuning.writeEpsilon;
|
||||||
state.factor = next;
|
state.factor = next;
|
||||||
state.desired = desired;
|
state.desired = desired;
|
||||||
state.wasFarther = tuning.nearerBehavior !== 'dim'
|
state.wasFarther =
|
||||||
&& Math.abs(desired - tuning.dimFloor) <= tuning.writeEpsilon;
|
tuning.nearerBehavior !== 'dim' &&
|
||||||
|
Math.abs(desired - tuning.dimFloor) <= tuning.writeEpsilon;
|
||||||
const transitioning = Math.abs(next - desired) > tuning.writeEpsilon;
|
const transitioning = Math.abs(next - desired) > tuning.writeEpsilon;
|
||||||
const active = Math.abs(next - 1) > tuning.writeEpsilon;
|
const active = Math.abs(next - 1) > tuning.writeEpsilon;
|
||||||
|
|
||||||
@@ -426,10 +479,16 @@ export function advanceProjectedSpriteFocus(
|
|||||||
_advanceResult.desired = 1;
|
_advanceResult.desired = 1;
|
||||||
return _advanceResult;
|
return _advanceResult;
|
||||||
}
|
}
|
||||||
const screen = position && scene
|
const screen =
|
||||||
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, _scratchScreen)
|
position && scene
|
||||||
|
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||||
|
scene,
|
||||||
|
position,
|
||||||
|
_scratchScreen,
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
const cameraDistance = position && camera?.positionWC
|
const cameraDistance =
|
||||||
|
position && camera?.positionWC
|
||||||
? Cesium.Cartesian3.distance(camera.positionWC, position)
|
? Cesium.Cartesian3.distance(camera.positionWC, position)
|
||||||
: Number.NaN;
|
: Number.NaN;
|
||||||
return advanceSpriteFocus(sprite, {
|
return advanceSpriteFocus(sprite, {
|
||||||
@@ -451,8 +510,10 @@ export function advanceProjectedSpriteFocus(
|
|||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
export function focusPassIsNeeded(target, activeCount) {
|
export function focusPassIsNeeded(target, activeCount) {
|
||||||
return target !== null && target !== undefined
|
return (
|
||||||
|| (Number.isFinite(activeCount) && activeCount > 0);
|
(target !== null && target !== undefined) ||
|
||||||
|
(Number.isFinite(activeCount) && activeCount > 0)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -466,8 +527,9 @@ export function nearFarScalarValueAtDistance(scalar, cameraDistanceM) {
|
|||||||
if (!scalar || !Number.isFinite(cameraDistanceM)) return 1;
|
if (!scalar || !Number.isFinite(cameraDistanceM)) return 1;
|
||||||
const nearSq = scalar.near * scalar.near;
|
const nearSq = scalar.near * scalar.near;
|
||||||
const farSq = scalar.far * scalar.far;
|
const farSq = scalar.far * scalar.far;
|
||||||
if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq) return 1;
|
if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq)
|
||||||
const rawT = ((cameraDistanceM * cameraDistanceM) - nearSq) / (farSq - nearSq);
|
return 1;
|
||||||
|
const rawT = (cameraDistanceM * cameraDistanceM - nearSq) / (farSq - nearSq);
|
||||||
const t = Math.pow(clamp(rawT, 0, 1), 0.2);
|
const t = Math.pow(clamp(rawT, 0, 1), 0.2);
|
||||||
return scalar.nearValue + (scalar.farValue - scalar.nearValue) * t;
|
return scalar.nearValue + (scalar.farValue - scalar.nearValue) * t;
|
||||||
}
|
}
|
||||||
@@ -507,6 +569,8 @@ export function forgetSpriteFocus(sprite) {
|
|||||||
*/
|
*/
|
||||||
export function focusAlphaNeedsWrite(currentAlpha, nextAlpha, params) {
|
export function focusAlphaNeedsWrite(currentAlpha, nextAlpha, params) {
|
||||||
const tuning = resolvedParams(params);
|
const tuning = resolvedParams(params);
|
||||||
return !Number.isFinite(currentAlpha)
|
return (
|
||||||
|| Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon;
|
!Number.isFinite(currentAlpha) ||
|
||||||
|
Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon
|
||||||
|
);
|
||||||
}
|
}
|
||||||
+1
-1
@@ -51,7 +51,7 @@ export function geoidHeight(latDeg, lonDeg) {
|
|||||||
if (!egm96Module) {
|
if (!egm96Module) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'geoid.js: geoidHeight() called before ensureGeoidReady() resolved — ' +
|
'geoid.js: geoidHeight() called before ensureGeoidReady() resolved — ' +
|
||||||
'await ensureGeoidReady() first.'
|
'await ensureGeoidReady() first.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return egm96Module.meanSeaLevel(latDeg, lonDeg);
|
return egm96Module.meanSeaLevel(latDeg, lonDeg);
|
||||||
|
|||||||
+29
-1
@@ -1,4 +1,32 @@
|
|||||||
import { createGroundFloor } from '../services/groundFloor.js';
|
import { createGroundFloor } from '../services/groundFloor.js';
|
||||||
import * as terrain from './terrainHeights.js';
|
import * as terrain from './terrainHeights.js';
|
||||||
const groundFloor = createGroundFloor({ terrain });
|
const groundFloor = createGroundFloor({ terrain });
|
||||||
export const { GROUND_FLOOR_LIFT_M, MESH_FLOOR_BELOW_PRIOR_M, MESH_FLOOR_ABOVE_PRIOR_M, coarseFloorCoord, floorAltitudeM, displayFloorHeightM, FLOOR_CELL_DEG, NEIGHBOR_FLOOR_MIN_SAMPLES, neighborFloorM, CORRIDOR_MAX_CELLS, CORRIDOR_WALK_STEP_DEG, CELL_HYSTERESIS_DEG, stickyFloorCell, corridorFloorCells, allocateCorridorCells, setMeshFloorPreferred, meshFloorPreferred, reportMeshFloorCell, meshFloorSampleWithinPrior, reportValidatedMeshFloorCell, cachedMeshFloor, _clearMeshFloorCellsForTest, cachedGroundFloor, FLOOR_RESOLVE_DEADLINE_MS, resolveGroundFloorCellsBounded, resolveGroundFloorCells, warmGroundFloor } = groundFloor;
|
export const {
|
||||||
|
GROUND_FLOOR_LIFT_M,
|
||||||
|
MESH_FLOOR_BELOW_PRIOR_M,
|
||||||
|
MESH_FLOOR_ABOVE_PRIOR_M,
|
||||||
|
coarseFloorCoord,
|
||||||
|
floorAltitudeM,
|
||||||
|
displayFloorHeightM,
|
||||||
|
FLOOR_CELL_DEG,
|
||||||
|
NEIGHBOR_FLOOR_MIN_SAMPLES,
|
||||||
|
neighborFloorM,
|
||||||
|
CORRIDOR_MAX_CELLS,
|
||||||
|
CORRIDOR_WALK_STEP_DEG,
|
||||||
|
CELL_HYSTERESIS_DEG,
|
||||||
|
stickyFloorCell,
|
||||||
|
corridorFloorCells,
|
||||||
|
allocateCorridorCells,
|
||||||
|
setMeshFloorPreferred,
|
||||||
|
meshFloorPreferred,
|
||||||
|
reportMeshFloorCell,
|
||||||
|
meshFloorSampleWithinPrior,
|
||||||
|
reportValidatedMeshFloorCell,
|
||||||
|
cachedMeshFloor,
|
||||||
|
_clearMeshFloorCellsForTest,
|
||||||
|
cachedGroundFloor,
|
||||||
|
FLOOR_RESOLVE_DEADLINE_MS,
|
||||||
|
resolveGroundFloorCellsBounded,
|
||||||
|
resolveGroundFloorCells,
|
||||||
|
warmGroundFloor,
|
||||||
|
} = groundFloor;
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createGroundSnap as createSnap } from '../services/groundSnap.js';
|
import { createGroundSnap as createSnap } from '../services/groundSnap.js';
|
||||||
import * as groundFloor from './groundFloor.js';
|
import * as groundFloor from './groundFloor.js';
|
||||||
/** Compatibility constructor using the default floor cache. */
|
/** Compatibility constructor using the default floor cache. */
|
||||||
export function createGroundSnap() { return createSnap({ groundFloor }); }
|
export function createGroundSnap() {
|
||||||
|
return createSnap({ groundFloor });
|
||||||
|
}
|
||||||
+25
-6
@@ -29,8 +29,14 @@ export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function findNextIssPass({
|
export function findNextIssPass({
|
||||||
satrec, latDeg, lonDeg, fromMs,
|
satrec,
|
||||||
minElevDeg = 10, horizonHours = 24, coarseStepSec = 30, fineStepSec = 5,
|
latDeg,
|
||||||
|
lonDeg,
|
||||||
|
fromMs,
|
||||||
|
minElevDeg = 10,
|
||||||
|
horizonHours = 24,
|
||||||
|
coarseStepSec = 30,
|
||||||
|
fineStepSec = 5,
|
||||||
}) {
|
}) {
|
||||||
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
|
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
|
||||||
const horizonMs = fromMs + horizonHours * 3600_000;
|
const horizonMs = fromMs + horizonHours * 3600_000;
|
||||||
@@ -41,13 +47,17 @@ export function findNextIssPass({
|
|||||||
// pass, that's still "the next pass" for a voice answer — accept it.
|
// pass, that's still "the next pass" for a voice answer — accept it.
|
||||||
let hit = null;
|
let hit = null;
|
||||||
for (let t = fromMs; t <= horizonMs; t += coarse) {
|
for (let t = fromMs; t <= horizonMs; t += coarse) {
|
||||||
if (elev(t) >= minElevDeg) { hit = t; break; }
|
if (elev(t) >= minElevDeg) {
|
||||||
|
hit = t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (hit == null) return null;
|
if (hit == null) return null;
|
||||||
|
|
||||||
// Refine rise: walk back in fine steps to the first sample ≥ threshold.
|
// Refine rise: walk back in fine steps to the first sample ≥ threshold.
|
||||||
let riseMs = hit;
|
let riseMs = hit;
|
||||||
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg) riseMs -= fine;
|
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg)
|
||||||
|
riseMs -= fine;
|
||||||
|
|
||||||
// Walk forward through the pass tracking the peak until we drop below.
|
// Walk forward through the pass tracking the peak until we drop below.
|
||||||
let maxElevDeg = -90;
|
let maxElevDeg = -90;
|
||||||
@@ -56,11 +66,20 @@ export function findNextIssPass({
|
|||||||
while (t <= horizonMs) {
|
while (t <= horizonMs) {
|
||||||
const e = elev(t);
|
const e = elev(t);
|
||||||
if (e < minElevDeg && t > riseMs) break;
|
if (e < minElevDeg && t > riseMs) break;
|
||||||
if (e > maxElevDeg) { maxElevDeg = e; maxElevMs = t; }
|
if (e > maxElevDeg) {
|
||||||
|
maxElevDeg = e;
|
||||||
|
maxElevMs = t;
|
||||||
|
}
|
||||||
t += fine;
|
t += fine;
|
||||||
}
|
}
|
||||||
const setMs = t;
|
const setMs = t;
|
||||||
|
|
||||||
const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
|
const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
|
||||||
return { riseMs, setMs, maxElevDeg, maxElevMs, riseAzDeg: rise ? rise.azDeg : 0 };
|
return {
|
||||||
|
riseMs,
|
||||||
|
setMs,
|
||||||
|
maxElevDeg,
|
||||||
|
maxElevMs,
|
||||||
|
riseAzDeg: rise ? rise.azDeg : 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
+161
-58
@@ -16,11 +16,15 @@ function clampInt(value, min, max) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function demandEntries(demandByLayer) {
|
function demandEntries(demandByLayer) {
|
||||||
const entries = demandByLayer instanceof Map
|
const entries =
|
||||||
|
demandByLayer instanceof Map
|
||||||
? Array.from(demandByLayer.entries())
|
? Array.from(demandByLayer.entries())
|
||||||
: Object.entries(demandByLayer || {});
|
: Object.entries(demandByLayer || {});
|
||||||
return entries
|
return entries
|
||||||
.map(([layerId, demand]) => [String(layerId), Math.max(0, Math.floor(Number(demand) || 0))])
|
.map(([layerId, demand]) => [
|
||||||
|
String(layerId),
|
||||||
|
Math.max(0, Math.floor(Number(demand) || 0)),
|
||||||
|
])
|
||||||
.filter(([, demand]) => demand > 0)
|
.filter(([, demand]) => demand > 0)
|
||||||
.sort(([a], [b]) => a.localeCompare(b));
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
}
|
}
|
||||||
@@ -104,7 +108,9 @@ function allocateLayerQuotasInto(
|
|||||||
weighted.length = idCount;
|
weighted.length = idCount;
|
||||||
priorityOrder.length = idCount;
|
priorityOrder.length = idCount;
|
||||||
remainders.length = idCount;
|
remainders.length = idCount;
|
||||||
priorityOrder.sort((a, b) => b.weight - a.weight || a.layerId.localeCompare(b.layerId));
|
priorityOrder.sort(
|
||||||
|
(a, b) => b.weight - a.weight || a.layerId.localeCompare(b.layerId),
|
||||||
|
);
|
||||||
|
|
||||||
let remaining = capacity;
|
let remaining = capacity;
|
||||||
if (capacity >= idCount) {
|
if (capacity >= idCount) {
|
||||||
@@ -115,7 +121,7 @@ function allocateLayerQuotasInto(
|
|||||||
let apportioned = 0;
|
let apportioned = 0;
|
||||||
for (let i = 0; i < idCount; i++) {
|
for (let i = 0; i < idCount; i++) {
|
||||||
const entry = weighted[i];
|
const entry = weighted[i];
|
||||||
const exact = remaining * entry.weight / totalWeight;
|
const exact = (remaining * entry.weight) / totalWeight;
|
||||||
const room = entry.count - (quotas.get(entry.layerId) || 0);
|
const room = entry.count - (quotas.get(entry.layerId) || 0);
|
||||||
const whole = Math.min(room, Math.floor(exact));
|
const whole = Math.min(room, Math.floor(exact));
|
||||||
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
|
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
|
||||||
@@ -123,9 +129,12 @@ function allocateLayerQuotasInto(
|
|||||||
entry.fraction = exact - Math.floor(exact);
|
entry.fraction = exact - Math.floor(exact);
|
||||||
}
|
}
|
||||||
remaining -= apportioned;
|
remaining -= apportioned;
|
||||||
remainders.sort((a, b) => b.fraction - a.fraction
|
remainders.sort(
|
||||||
|| b.weight - a.weight
|
(a, b) =>
|
||||||
|| a.layerId.localeCompare(b.layerId));
|
b.fraction - a.fraction ||
|
||||||
|
b.weight - a.weight ||
|
||||||
|
a.layerId.localeCompare(b.layerId),
|
||||||
|
);
|
||||||
for (let i = 0; i < idCount && remaining > 0; i++) {
|
for (let i = 0; i < idCount && remaining > 0; i++) {
|
||||||
const entry = remainders[i];
|
const entry = remainders[i];
|
||||||
const current = quotas.get(entry.layerId) || 0;
|
const current = quotas.get(entry.layerId) || 0;
|
||||||
@@ -156,7 +165,12 @@ function allocateLayerQuotasInto(
|
|||||||
* Allocate a collective capacity across non-empty layers. Both strategies are
|
* Allocate a collective capacity across non-empty layers. Both strategies are
|
||||||
* work-conserving and deterministic; unused entitlement is always borrowed.
|
* work-conserving and deterministic; unused entitlement is always borrowed.
|
||||||
*/
|
*/
|
||||||
export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATION_ELASTIC, layerWeights = {}) {
|
export function allocateLayerQuotas(
|
||||||
|
demandByLayer,
|
||||||
|
capacity,
|
||||||
|
strategy = ALLOCATION_ELASTIC,
|
||||||
|
layerWeights = {},
|
||||||
|
) {
|
||||||
const entries = demandEntries(demandByLayer);
|
const entries = demandEntries(demandByLayer);
|
||||||
const totalDemand = entries.reduce((sum, [, demand]) => sum + demand, 0);
|
const totalDemand = entries.reduce((sum, [, demand]) => sum + demand, 0);
|
||||||
const cap = clampInt(capacity, 0, totalDemand);
|
const cap = clampInt(capacity, 0, totalDemand);
|
||||||
@@ -198,15 +212,24 @@ export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATI
|
|||||||
if (remaining > 0 && totalWeight > 0) {
|
if (remaining > 0 && totalWeight > 0) {
|
||||||
let apportioned = 0;
|
let apportioned = 0;
|
||||||
for (const entry of weighted) {
|
for (const entry of weighted) {
|
||||||
const exact = remaining * entry.weight / totalWeight;
|
const exact = (remaining * entry.weight) / totalWeight;
|
||||||
const room = entry.count - (quotas.get(entry.layerId) || 0);
|
const room = entry.count - (quotas.get(entry.layerId) || 0);
|
||||||
const whole = Math.min(room, Math.floor(exact));
|
const whole = Math.min(room, Math.floor(exact));
|
||||||
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
|
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
|
||||||
apportioned += whole;
|
apportioned += whole;
|
||||||
remainders.push({ layerId: entry.layerId, fraction: exact - Math.floor(exact), weight: entry.weight });
|
remainders.push({
|
||||||
|
layerId: entry.layerId,
|
||||||
|
fraction: exact - Math.floor(exact),
|
||||||
|
weight: entry.weight,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
remaining -= apportioned;
|
remaining -= apportioned;
|
||||||
remainders.sort((a, b) => b.fraction - a.fraction || b.weight - a.weight || a.layerId.localeCompare(b.layerId));
|
remainders.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.fraction - a.fraction ||
|
||||||
|
b.weight - a.weight ||
|
||||||
|
a.layerId.localeCompare(b.layerId),
|
||||||
|
);
|
||||||
for (const entry of remainders) {
|
for (const entry of remainders) {
|
||||||
if (remaining <= 0) break;
|
if (remaining <= 0) break;
|
||||||
const current = quotas.get(entry.layerId) || 0;
|
const current = quotas.get(entry.layerId) || 0;
|
||||||
@@ -220,20 +243,24 @@ export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATI
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rectIsFinite(rect) {
|
function rectIsFinite(rect) {
|
||||||
return rect
|
return (
|
||||||
&& Number.isFinite(rect.x)
|
rect &&
|
||||||
&& Number.isFinite(rect.y)
|
Number.isFinite(rect.x) &&
|
||||||
&& Number.isFinite(rect.w)
|
Number.isFinite(rect.y) &&
|
||||||
&& Number.isFinite(rect.h)
|
Number.isFinite(rect.w) &&
|
||||||
&& rect.w > 0
|
Number.isFinite(rect.h) &&
|
||||||
&& rect.h > 0;
|
rect.w > 0 &&
|
||||||
|
rect.h > 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function overlaps(a, b, padding = 4) {
|
function overlaps(a, b, padding = 4) {
|
||||||
return a.x < b.x + b.w + padding
|
return (
|
||||||
&& a.x + a.w + padding > b.x
|
a.x < b.x + b.w + padding &&
|
||||||
&& a.y < b.y + b.h + padding
|
a.x + a.w + padding > b.x &&
|
||||||
&& a.y + a.h + padding > b.y;
|
a.y < b.y + b.h + padding &&
|
||||||
|
a.y + a.h + padding > b.y
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CELL_ORIGIN = 8192;
|
const CELL_ORIGIN = 8192;
|
||||||
@@ -271,8 +298,10 @@ class SpatialHash {
|
|||||||
* never hide a real collision.
|
* never hide a real collision.
|
||||||
*/
|
*/
|
||||||
_cellKey(x, y) {
|
_cellKey(x, y) {
|
||||||
const cx = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, x)) + CELL_ORIGIN;
|
const cx =
|
||||||
const cy = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, y)) + CELL_ORIGIN;
|
Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, x)) + CELL_ORIGIN;
|
||||||
|
const cy =
|
||||||
|
Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, y)) + CELL_ORIGIN;
|
||||||
// Both coordinates occupy 14 bits. Bit-packing forces the lookup key to a
|
// Both coordinates occupy 14 bits. Bit-packing forces the lookup key to a
|
||||||
// Smi; arithmetic multiplication materializes a boxed double at each
|
// Smi; arithmetic multiplication materializes a boxed double at each
|
||||||
// spatial-hash get/add on this hot solve path.
|
// spatial-hash get/add on this hot solve path.
|
||||||
@@ -325,8 +354,14 @@ function candidateCompare(a, b, states, now) {
|
|||||||
// A stateless candidate is never lifetime-pinned: its source shipped as a
|
// A stateless candidate is never lifetime-pinned: its source shipped as a
|
||||||
// per-frame rebuild, so it must be free to lose its slot the moment something
|
// per-frame rebuild, so it must be free to lose its slot the moment something
|
||||||
// better wants it. Plain incumbency ordering below still applies.
|
// better wants it. Plain incumbency ordering below still applies.
|
||||||
const aPinned = !!aState?.selected && a.stateless !== true && now - aState.selectedAt < MIN_LIFETIME_MS;
|
const aPinned =
|
||||||
const bPinned = !!bState?.selected && b.stateless !== true && now - bState.selectedAt < MIN_LIFETIME_MS;
|
!!aState?.selected &&
|
||||||
|
a.stateless !== true &&
|
||||||
|
now - aState.selectedAt < MIN_LIFETIME_MS;
|
||||||
|
const bPinned =
|
||||||
|
!!bState?.selected &&
|
||||||
|
b.stateless !== true &&
|
||||||
|
now - bState.selectedAt < MIN_LIFETIME_MS;
|
||||||
if (aPinned !== bPinned) return aPinned ? -1 : 1;
|
if (aPinned !== bPinned) return aPinned ? -1 : 1;
|
||||||
const aIncumbent = !!aState?.selected;
|
const aIncumbent = !!aState?.selected;
|
||||||
const bIncumbent = !!bState?.selected;
|
const bIncumbent = !!bState?.selected;
|
||||||
@@ -346,12 +381,20 @@ function candidateCompare(a, b, states, now) {
|
|||||||
function cacheCandidateAnchorScalars(candidate) {
|
function cacheCandidateAnchorScalars(candidate) {
|
||||||
let anchorX = Number.NaN;
|
let anchorX = Number.NaN;
|
||||||
let anchorY = Number.NaN;
|
let anchorY = Number.NaN;
|
||||||
if (Number.isFinite(candidate?.screenX) && Number.isFinite(candidate?.screenY)) {
|
if (
|
||||||
|
Number.isFinite(candidate?.screenX) &&
|
||||||
|
Number.isFinite(candidate?.screenY)
|
||||||
|
) {
|
||||||
anchorX = candidate.screenX;
|
anchorX = candidate.screenX;
|
||||||
anchorY = candidate.screenY;
|
anchorY = candidate.screenY;
|
||||||
} else {
|
} else {
|
||||||
const placement = Array.isArray(candidate?.placements) ? candidate.placements[0] : null;
|
const placement = Array.isArray(candidate?.placements)
|
||||||
if (Number.isFinite(placement?.leadFromX) && Number.isFinite(placement?.leadFromY)) {
|
? candidate.placements[0]
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
Number.isFinite(placement?.leadFromX) &&
|
||||||
|
Number.isFinite(placement?.leadFromY)
|
||||||
|
) {
|
||||||
anchorX = placement.leadFromX;
|
anchorX = placement.leadFromX;
|
||||||
anchorY = placement.leadFromY;
|
anchorY = placement.leadFromY;
|
||||||
} else if (rectIsFinite(placement?.rect)) {
|
} else if (rectIsFinite(placement?.rect)) {
|
||||||
@@ -401,7 +444,16 @@ class SpatialCandidateQueue {
|
|||||||
* stay unboxed. Few-placement candidates already blocked by the
|
* stay unboxed. Few-placement candidates already blocked by the
|
||||||
* monotonically growing collision field are dismissed before anchor scans.
|
* monotonically growing collision field are dismissed before anchor scans.
|
||||||
*/
|
*/
|
||||||
reset(candidates, count, anchors, anchorCount, attemptStamps, stamp, spatial, states) {
|
reset(
|
||||||
|
candidates,
|
||||||
|
count,
|
||||||
|
anchors,
|
||||||
|
anchorCount,
|
||||||
|
attemptStamps,
|
||||||
|
stamp,
|
||||||
|
spatial,
|
||||||
|
states,
|
||||||
|
) {
|
||||||
this.candidates = candidates;
|
this.candidates = candidates;
|
||||||
this.count = count;
|
this.count = count;
|
||||||
this.anchorCount = anchorCount;
|
this.anchorCount = anchorCount;
|
||||||
@@ -529,10 +581,13 @@ class SpatialCandidateQueue {
|
|||||||
const rectY = placementYs[rectIndex];
|
const rectY = placementYs[rectIndex];
|
||||||
const rectW = placementWs[rectIndex];
|
const rectW = placementWs[rectIndex];
|
||||||
const rectH = placementHs[rectIndex];
|
const rectH = placementHs[rectIndex];
|
||||||
if (rectX < anchorRectX + anchorRectW + 4
|
if (
|
||||||
&& rectX + rectW + 4 > anchorRectX
|
rectX < anchorRectX + anchorRectW + 4 &&
|
||||||
&& rectY < anchorRectY + anchorRectH + 4
|
rectX + rectW + 4 > anchorRectX &&
|
||||||
&& rectY + rectH + 4 > anchorRectY) freeMask &= ~bit;
|
rectY < anchorRectY + anchorRectH + 4 &&
|
||||||
|
rectY + rectH + 4 > anchorRectY
|
||||||
|
)
|
||||||
|
freeMask &= ~bit;
|
||||||
}
|
}
|
||||||
placementMasks[i] = freeMask;
|
placementMasks[i] = freeMask;
|
||||||
if (freeMask === 0 && this._dismissBlockedKey(i)) continue;
|
if (freeMask === 0 && this._dismissBlockedKey(i)) continue;
|
||||||
@@ -558,7 +613,8 @@ class SpatialCandidateQueue {
|
|||||||
bestIndex = i;
|
bestIndex = i;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const priorityDelta = (Number(candidate.priority) || 0) - (Number(best.priority) || 0);
|
const priorityDelta =
|
||||||
|
(Number(candidate.priority) || 0) - (Number(best.priority) || 0);
|
||||||
if (priorityDelta !== 0) {
|
if (priorityDelta !== 0) {
|
||||||
if (priorityDelta > 0) {
|
if (priorityDelta > 0) {
|
||||||
best = candidate;
|
best = candidate;
|
||||||
@@ -621,7 +677,8 @@ function firstFreePlacement(candidate, stickyCorner, spatial) {
|
|||||||
if (stickyCorner) {
|
if (stickyCorner) {
|
||||||
for (let i = 0; i < placements.length; i++) {
|
for (let i = 0; i < placements.length; i++) {
|
||||||
const placement = placements[i];
|
const placement = placements[i];
|
||||||
if (placement?.corner !== stickyCorner || !rectIsFinite(placement.rect)) continue;
|
if (placement?.corner !== stickyCorner || !rectIsFinite(placement.rect))
|
||||||
|
continue;
|
||||||
if (!spatial.collides(placement.rect)) return placement;
|
if (!spatial.collides(placement.rect)) return placement;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -790,7 +847,13 @@ export class LabelArbiter {
|
|||||||
let candidateCount = 0;
|
let candidateCount = 0;
|
||||||
for (let i = 0; i < source.length; i++) {
|
for (let i = 0; i < source.length; i++) {
|
||||||
const candidate = source[i];
|
const candidate = source[i];
|
||||||
if (!candidate || !candidate.key || !candidate.layerId || !(candidate.keyholeAlpha > 0)) continue;
|
if (
|
||||||
|
!candidate ||
|
||||||
|
!candidate.key ||
|
||||||
|
!candidate.layerId ||
|
||||||
|
!(candidate.keyholeAlpha > 0)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
cacheCandidateAnchorScalars(candidate);
|
cacheCandidateAnchorScalars(candidate);
|
||||||
candidateList[candidateCount++] = candidate;
|
candidateList[candidateCount++] = candidate;
|
||||||
let bucket = byLayer.get(candidate.layerId);
|
let bucket = byLayer.get(candidate.layerId);
|
||||||
@@ -815,13 +878,15 @@ export class LabelArbiter {
|
|||||||
});
|
});
|
||||||
} else if (options.demandByLayer != null) {
|
} else if (options.demandByLayer != null) {
|
||||||
const supplied = demandEntries(options.demandByLayer);
|
const supplied = demandEntries(options.demandByLayer);
|
||||||
for (let i = 0; i < supplied.length; i++) demand.set(supplied[i][0], supplied[i][1]);
|
for (let i = 0; i < supplied.length; i++)
|
||||||
|
demand.set(supplied[i][0], supplied[i][1]);
|
||||||
}
|
}
|
||||||
// A supplied demand map describes the full lightweight field while the
|
// A supplied demand map describes the full lightweight field while the
|
||||||
// candidate list may be only a bounded materialized cohort. Keep defensive
|
// candidate list may be only a bounded materialized cohort. Keep defensive
|
||||||
// support for candidate layers omitted by an incomplete caller map.
|
// support for candidate layers omitted by an incomplete caller map.
|
||||||
byLayer.forEach((bucket, layerId) => {
|
byLayer.forEach((bucket, layerId) => {
|
||||||
if (bucket.count > 0 && !demand.has(layerId)) demand.set(layerId, bucket.count);
|
if (bucket.count > 0 && !demand.has(layerId))
|
||||||
|
demand.set(layerId, bucket.count);
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeLayers = this._activeLayersScratch;
|
const activeLayers = this._activeLayersScratch;
|
||||||
@@ -857,14 +922,20 @@ export class LabelArbiter {
|
|||||||
|
|
||||||
let sameActiveLayers = activeCount === this.lastActiveLayers.length;
|
let sameActiveLayers = activeCount === this.lastActiveLayers.length;
|
||||||
for (let i = 0; sameActiveLayers && i < activeCount; i++) {
|
for (let i = 0; sameActiveLayers && i < activeCount; i++) {
|
||||||
if (activeLayers[i] !== this.lastActiveLayers[i]) sameActiveLayers = false;
|
if (activeLayers[i] !== this.lastActiveLayers[i])
|
||||||
|
sameActiveLayers = false;
|
||||||
}
|
}
|
||||||
const preserveIncumbents = options.preserveIncumbents !== false && sameActiveLayers;
|
const preserveIncumbents =
|
||||||
|
options.preserveIncumbents !== false && sameActiveLayers;
|
||||||
let spatialQueueBuildCount = 0;
|
let spatialQueueBuildCount = 0;
|
||||||
let spatialQueueNextCount = 0;
|
let spatialQueueNextCount = 0;
|
||||||
|
|
||||||
const attempt = (candidate, allowOverlapFallback = false) => {
|
const attempt = (candidate, allowOverlapFallback = false) => {
|
||||||
if (selectedCount >= capacity || attemptStamps.get(candidate.key) === stamp) return false;
|
if (
|
||||||
|
selectedCount >= capacity ||
|
||||||
|
attemptStamps.get(candidate.key) === stamp
|
||||||
|
)
|
||||||
|
return false;
|
||||||
attemptStamps.set(candidate.key, stamp);
|
attemptStamps.set(candidate.key, stamp);
|
||||||
const previous = this.states.get(candidate.key);
|
const previous = this.states.get(candidate.key);
|
||||||
const stateless = candidate.stateless === true;
|
const stateless = candidate.stateless === true;
|
||||||
@@ -872,10 +943,12 @@ export class LabelArbiter {
|
|||||||
// very next solve, as their stateless originals did) and carry no sticky
|
// very next solve, as their stateless originals did) and carry no sticky
|
||||||
// corner, so above/below is re-decided from geometry every solve instead
|
// corner, so above/below is re-decided from geometry every solve instead
|
||||||
// of being pinned by whichever side happened to be free once.
|
// of being pinned by whichever side happened to be free once.
|
||||||
if (!stateless && !previous?.selected && previous?.cooldownUntil > now) return false;
|
if (!stateless && !previous?.selected && previous?.cooldownUntil > now)
|
||||||
|
return false;
|
||||||
const sticky = stateless ? undefined : previous?.corner;
|
const sticky = stateless ? undefined : previous?.corner;
|
||||||
let placement = firstFreePlacement(candidate, sticky, spatial);
|
let placement = firstFreePlacement(candidate, sticky, spatial);
|
||||||
if (!placement && allowOverlapFallback) placement = firstOrderedPlacement(candidate, sticky);
|
if (!placement && allowOverlapFallback)
|
||||||
|
placement = firstOrderedPlacement(candidate, sticky);
|
||||||
if (!placement) return false;
|
if (!placement) return false;
|
||||||
spatial.add(placement.rect);
|
spatial.add(placement.rect);
|
||||||
selectStamps.set(candidate.key, stamp);
|
selectStamps.set(candidate.key, stamp);
|
||||||
@@ -953,7 +1026,8 @@ export class LabelArbiter {
|
|||||||
}
|
}
|
||||||
let retainedIncumbents = 0;
|
let retainedIncumbents = 0;
|
||||||
for (let i = 0; i < selectedCount; i++) {
|
for (let i = 0; i < selectedCount; i++) {
|
||||||
if (this.selectedKeys.has(selectedCandidates[i].key)) retainedIncumbents++;
|
if (this.selectedKeys.has(selectedCandidates[i].key))
|
||||||
|
retainedIncumbents++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const exitingStates = this._refreshStateList();
|
const exitingStates = this._refreshStateList();
|
||||||
@@ -964,7 +1038,9 @@ export class LabelArbiter {
|
|||||||
state.exitStartedAt = now;
|
state.exitStartedAt = now;
|
||||||
// Stateless entries may be re-selected immediately; the cooldown exists
|
// Stateless entries may be re-selected immediately; the cooldown exists
|
||||||
// to damp thrash for sources that shipped with hysteresis.
|
// to damp thrash for sources that shipped with hysteresis.
|
||||||
state.cooldownUntil = state.stateless ? 0 : now + FADE_OUT_MS + COOLDOWN_MS;
|
state.cooldownUntil = state.stateless
|
||||||
|
? 0
|
||||||
|
: now + FADE_OUT_MS + COOLDOWN_MS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1002,7 +1078,8 @@ export class LabelArbiter {
|
|||||||
this._stateListDirty = true;
|
this._stateListDirty = true;
|
||||||
}
|
}
|
||||||
if (labelsByLayer) {
|
if (labelsByLayer) {
|
||||||
labelsByLayer[candidate.layerId] = (labelsByLayer[candidate.layerId] || 0) + 1;
|
labelsByLayer[candidate.layerId] =
|
||||||
|
(labelsByLayer[candidate.layerId] || 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1013,7 +1090,10 @@ export class LabelArbiter {
|
|||||||
const livingStates = this._refreshStateList();
|
const livingStates = this._refreshStateList();
|
||||||
for (let i = 0; i < livingStates.length; i++) {
|
for (let i = 0; i < livingStates.length; i++) {
|
||||||
const state = livingStates[i];
|
const state = livingStates[i];
|
||||||
if (!state.selected && now - (state.exitStartedAt || now) > FADE_OUT_MS + COOLDOWN_MS) {
|
if (
|
||||||
|
!state.selected &&
|
||||||
|
now - (state.exitStartedAt || now) > FADE_OUT_MS + COOLDOWN_MS
|
||||||
|
) {
|
||||||
this.states.delete(state.key);
|
this.states.delete(state.key);
|
||||||
this._stateListDirty = true;
|
this._stateListDirty = true;
|
||||||
}
|
}
|
||||||
@@ -1026,13 +1106,15 @@ export class LabelArbiter {
|
|||||||
});
|
});
|
||||||
for (let i = 0; i < droppedCount; i++) this.selectedKeys.delete(dropped[i]);
|
for (let i = 0; i < droppedCount; i++) this.selectedKeys.delete(dropped[i]);
|
||||||
dropped.length = droppedCount;
|
dropped.length = droppedCount;
|
||||||
for (let i = 0; i < selectedCount; i++) this.selectedKeys.add(selectedCandidates[i].key);
|
for (let i = 0; i < selectedCount; i++)
|
||||||
|
this.selectedKeys.add(selectedCandidates[i].key);
|
||||||
|
|
||||||
const previousActiveLayers = this.lastActiveLayers;
|
const previousActiveLayers = this.lastActiveLayers;
|
||||||
this.lastActiveLayers = activeLayers;
|
this.lastActiveLayers = activeLayers;
|
||||||
this._activeLayersScratch = previousActiveLayers;
|
this._activeLayersScratch = previousActiveLayers;
|
||||||
this.solveRevision++;
|
this.solveRevision++;
|
||||||
this.lastDiagnostics = collectDiagnostics ? {
|
this.lastDiagnostics = collectDiagnostics
|
||||||
|
? {
|
||||||
strategy,
|
strategy,
|
||||||
capacity,
|
capacity,
|
||||||
selectedCount,
|
selectedCount,
|
||||||
@@ -1044,7 +1126,8 @@ export class LabelArbiter {
|
|||||||
spatialQueueNextCount,
|
spatialQueueNextCount,
|
||||||
eligibleIncumbents,
|
eligibleIncumbents,
|
||||||
retainedIncumbents,
|
retainedIncumbents,
|
||||||
} : null;
|
}
|
||||||
|
: null;
|
||||||
selectedCandidates.length = selectedCount;
|
selectedCandidates.length = selectedCount;
|
||||||
selectedPlacements.length = selectedCount;
|
selectedPlacements.length = selectedCount;
|
||||||
pruneStampMap(attemptStamps, candidateCount);
|
pruneStampMap(attemptStamps, candidateCount);
|
||||||
@@ -1057,9 +1140,15 @@ export class LabelArbiter {
|
|||||||
* A caller-owned output array enables allocation-free per-frame rendering.
|
* A caller-owned output array enables allocation-free per-frame rendering.
|
||||||
*/
|
*/
|
||||||
renderEntries(currentCandidates, now = Date.now(), out = []) {
|
renderEntries(currentCandidates, now = Date.now(), out = []) {
|
||||||
const current = currentCandidates instanceof Map
|
const current =
|
||||||
|
currentCandidates instanceof Map
|
||||||
? currentCandidates
|
? currentCandidates
|
||||||
: new Map((currentCandidates || []).map((candidate) => [candidate.key, candidate]));
|
: new Map(
|
||||||
|
(currentCandidates || []).map((candidate) => [
|
||||||
|
candidate.key,
|
||||||
|
candidate,
|
||||||
|
]),
|
||||||
|
);
|
||||||
const states = this._refreshStateList();
|
const states = this._refreshStateList();
|
||||||
let outIndex = 0;
|
let outIndex = 0;
|
||||||
for (let i = 0; i < states.length; i++) {
|
for (let i = 0; i < states.length; i++) {
|
||||||
@@ -1070,14 +1159,23 @@ export class LabelArbiter {
|
|||||||
// fully painted this solve or gone. No enter ramp, no exit tail.
|
// fully painted this solve or gone. No enter ramp, no exit tail.
|
||||||
temporalAlpha = state.selected ? 1 : 0;
|
temporalAlpha = state.selected ? 1 : 0;
|
||||||
} else if (state.selected) {
|
} else if (state.selected) {
|
||||||
temporalAlpha = Math.min(1, Math.max(0, (now - state.enterStartedAt) / FADE_IN_MS));
|
temporalAlpha = Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(0, (now - state.enterStartedAt) / FADE_IN_MS),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
temporalAlpha = 1 - Math.min(1, Math.max(0, (now - state.exitStartedAt) / FADE_OUT_MS));
|
temporalAlpha =
|
||||||
|
1 -
|
||||||
|
Math.min(1, Math.max(0, (now - state.exitStartedAt) / FADE_OUT_MS));
|
||||||
}
|
}
|
||||||
if (temporalAlpha <= 0) continue;
|
if (temporalAlpha <= 0) continue;
|
||||||
const candidate = current.get(state.key) || state.lastCandidate;
|
const candidate = current.get(state.key) || state.lastCandidate;
|
||||||
if (!candidate) continue;
|
if (!candidate) continue;
|
||||||
const placement = renderPlacement(candidate, state.stateless ? undefined : state.corner, state.lastPlacement);
|
const placement = renderPlacement(
|
||||||
|
candidate,
|
||||||
|
state.stateless ? undefined : state.corner,
|
||||||
|
state.lastPlacement,
|
||||||
|
);
|
||||||
if (!placement) continue;
|
if (!placement) continue;
|
||||||
const entry = out[outIndex] || (out[outIndex] = {});
|
const entry = out[outIndex] || (out[outIndex] = {});
|
||||||
entry.candidate = candidate;
|
entry.candidate = candidate;
|
||||||
@@ -1100,11 +1198,16 @@ export class LabelArbiter {
|
|||||||
for (const state of this.states.values()) {
|
for (const state of this.states.values()) {
|
||||||
if (!state.selected) {
|
if (!state.selected) {
|
||||||
if (!includeFading) continue;
|
if (!includeFading) continue;
|
||||||
if (!Number.isFinite(state.exitStartedAt) || now - state.exitStartedAt >= FADE_OUT_MS) continue;
|
if (
|
||||||
|
!Number.isFinite(state.exitStartedAt) ||
|
||||||
|
now - state.exitStartedAt >= FADE_OUT_MS
|
||||||
|
)
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
const candidate = state.lastCandidate;
|
const candidate = state.lastCandidate;
|
||||||
if (!candidate?.layerId || candidate.sourceId == null) continue;
|
if (!candidate?.layerId || candidate.sourceId == null) continue;
|
||||||
if (!grouped.has(candidate.layerId)) grouped.set(candidate.layerId, new Set());
|
if (!grouped.has(candidate.layerId))
|
||||||
|
grouped.set(candidate.layerId, new Set());
|
||||||
grouped.get(candidate.layerId).add(candidate.sourceId);
|
grouped.get(candidate.layerId).add(candidate.sourceId);
|
||||||
}
|
}
|
||||||
return grouped;
|
return grouped;
|
||||||
|
|||||||
+388
-129
@@ -42,7 +42,9 @@ const RADIO_FILTER_CODES = Object.freeze({
|
|||||||
other: 'o',
|
other: 'o',
|
||||||
});
|
});
|
||||||
const RADIO_CODE_FILTERS = Object.freeze(
|
const RADIO_CODE_FILTERS = Object.freeze(
|
||||||
Object.fromEntries(Object.entries(RADIO_FILTER_CODES).map(([key, value]) => [value, key])),
|
Object.fromEntries(
|
||||||
|
Object.entries(RADIO_FILTER_CODES).map(([key, value]) => [value, key]),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
function normalizeBoolean(value) {
|
function normalizeBoolean(value) {
|
||||||
@@ -54,7 +56,9 @@ function normalizeEnum(values, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeRadioFilter(value) {
|
export function normalizeRadioFilter(value) {
|
||||||
const normalized = String(value || '').trim().toLowerCase();
|
const normalized = String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
if (Object.hasOwn(RADIO_FILTER_CODES, normalized)) return normalized;
|
if (Object.hasOwn(RADIO_FILTER_CODES, normalized)) return normalized;
|
||||||
if (/^genre:[a-z0-9][a-z0-9 &-]{0,31}$/.test(normalized)) return normalized;
|
if (/^genre:[a-z0-9][a-z0-9 &-]{0,31}$/.test(normalized)) return normalized;
|
||||||
return null;
|
return null;
|
||||||
@@ -65,8 +69,10 @@ function encodeRadioFilter(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function decodeRadioFilter(value) {
|
function decodeRadioFilter(value) {
|
||||||
if (Object.hasOwn(RADIO_CODE_FILTERS, value)) return RADIO_CODE_FILTERS[value];
|
if (Object.hasOwn(RADIO_CODE_FILTERS, value))
|
||||||
if (/^g-[a-z0-9][a-z0-9 &-]{0,31}$/.test(value)) return `genre:${value.slice(2)}`;
|
return RADIO_CODE_FILTERS[value];
|
||||||
|
if (/^g-[a-z0-9][a-z0-9 &-]{0,31}$/.test(value))
|
||||||
|
return `genre:${value.slice(2)}`;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +82,12 @@ function normalizeVolume(value) {
|
|||||||
return Math.round(Math.max(0, Math.min(1, numeric)) * 100) / 100;
|
return Math.round(Math.max(0, Math.min(1, numeric)) * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
function booleanOption(key, token, defaultValue, { absentValue = defaultValue } = {}) {
|
function booleanOption(
|
||||||
|
key,
|
||||||
|
token,
|
||||||
|
defaultValue,
|
||||||
|
{ absentValue = defaultValue } = {},
|
||||||
|
) {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
key,
|
key,
|
||||||
token,
|
token,
|
||||||
@@ -109,15 +120,20 @@ function booleanOption(key, token, defaultValue, { absentValue = defaultValue }
|
|||||||
* first option in THIS codec to need it — flipped to default-ON on 2026-08-22.)
|
* first option in THIS codec to need it — flipped to default-ON on 2026-08-22.)
|
||||||
*/
|
*/
|
||||||
function absentTokenValue(spec) {
|
function absentTokenValue(spec) {
|
||||||
return Object.hasOwn(spec, 'absentValue') ? spec.absentValue : spec.defaultValue;
|
return Object.hasOwn(spec, 'absentValue')
|
||||||
|
? spec.absentValue
|
||||||
|
: spec.defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackingIdOption(key, token, defaultValue = null) {
|
function trackingIdOption(key, token, defaultValue = null) {
|
||||||
const bounded = (candidate) => {
|
const bounded = (candidate) => {
|
||||||
if (candidate === null || candidate === undefined) return null;
|
if (candidate === null || candidate === undefined) return null;
|
||||||
const raw = typeof candidate === 'number' && Number.isFinite(candidate)
|
const raw =
|
||||||
|
typeof candidate === 'number' && Number.isFinite(candidate)
|
||||||
? String(candidate)
|
? String(candidate)
|
||||||
: (typeof candidate === 'string' ? candidate : null);
|
: typeof candidate === 'string'
|
||||||
|
? candidate
|
||||||
|
: null;
|
||||||
if (raw === null) return null;
|
if (raw === null) return null;
|
||||||
const normalized = raw.trim().toLowerCase();
|
const normalized = raw.trim().toLowerCase();
|
||||||
return TRACKING_ID_GRAMMAR.test(normalized) ? normalized : null;
|
return TRACKING_ID_GRAMMAR.test(normalized) ? normalized : null;
|
||||||
@@ -138,18 +154,24 @@ function stringOption(key, token, defaultValue) {
|
|||||||
token,
|
token,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
normalize: (value) => {
|
normalize: (value) => {
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value).trim().toLowerCase();
|
if (typeof value === 'number' && Number.isFinite(value))
|
||||||
|
return String(value).trim().toLowerCase();
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
const normalized = value.trim().toLowerCase();
|
const normalized = value.trim().toLowerCase();
|
||||||
return normalized ? normalized : null;
|
return normalized ? normalized : null;
|
||||||
},
|
},
|
||||||
encode: (value) => String(value),
|
encode: (value) => String(value),
|
||||||
decode: (value) => (typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null),
|
decode: (value) =>
|
||||||
|
typeof value === 'string' && value.trim()
|
||||||
|
? value.trim().toLowerCase()
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function enumOption(key, token, defaultValue, values, codes) {
|
function enumOption(key, token, defaultValue, values, codes) {
|
||||||
const reverse = Object.fromEntries(Object.entries(codes).map(([name, code]) => [code, name]));
|
const reverse = Object.fromEntries(
|
||||||
|
Object.entries(codes).map(([name, code]) => [code, name]),
|
||||||
|
);
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
key,
|
key,
|
||||||
token,
|
token,
|
||||||
@@ -166,7 +188,8 @@ function integerOption(key, token, defaultValue) {
|
|||||||
token,
|
token,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
normalize: (value) => {
|
normalize: (value) => {
|
||||||
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
|
if (typeof value === 'number' && Number.isInteger(value) && value > 0)
|
||||||
|
return value;
|
||||||
const candidate = typeof value === 'string' ? value.trim() : '';
|
const candidate = typeof value === 'string' ? value.trim() : '';
|
||||||
const parsed = Number(candidate);
|
const parsed = Number(candidate);
|
||||||
if (!candidate || !Number.isInteger(parsed) || parsed <= 0) return null;
|
if (!candidate || !Number.isInteger(parsed) || parsed <= 0) return null;
|
||||||
@@ -211,7 +234,10 @@ const OPTION_GROUPS = Object.freeze({
|
|||||||
trackingIdOption('selectedMilitaryTrackingId', 'u', null),
|
trackingIdOption('selectedMilitaryTrackingId', 'u', null),
|
||||||
]),
|
]),
|
||||||
satellites: Object.freeze([
|
satellites: Object.freeze([
|
||||||
enumOption('catalog', 'c', 'core', ['core', 'dense'], { core: 'c', dense: 'd' }),
|
enumOption('catalog', 'c', 'core', ['core', 'dense'], {
|
||||||
|
core: 'c',
|
||||||
|
dense: 'd',
|
||||||
|
}),
|
||||||
integerOption('selectedSatTrackingId', 't', null),
|
integerOption('selectedSatTrackingId', 't', null),
|
||||||
]),
|
]),
|
||||||
cctv: Object.freeze([
|
cctv: Object.freeze([
|
||||||
@@ -238,7 +264,8 @@ const OPTION_GROUPS = Object.freeze({
|
|||||||
defaultValue: 0.8,
|
defaultValue: 0.8,
|
||||||
normalize: normalizeVolume,
|
normalize: normalizeVolume,
|
||||||
encode: (value) => String(Math.round(value * 100)),
|
encode: (value) => String(Math.round(value * 100)),
|
||||||
decode: (value) => (/^\d{1,3}$/.test(value) ? normalizeVolume(Number(value) / 100) : null),
|
decode: (value) =>
|
||||||
|
/^\d{1,3}$/.test(value) ? normalizeVolume(Number(value) / 100) : null,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
@@ -275,48 +302,113 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
|
|||||||
* owns stable URL ordering.
|
* owns stable URL ordering.
|
||||||
*/
|
*/
|
||||||
export const LAYER_STATE_REGISTRY = Object.freeze([
|
export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||||
Object.freeze({ id: 'ais-live-vessels', token: 'a', disposition: 'enabled-only' }),
|
Object.freeze({
|
||||||
Object.freeze({ id: 'alpr-cameras', token: 'p', disposition: 'enabled-only' }),
|
id: 'ais-live-vessels',
|
||||||
|
token: 'a',
|
||||||
|
disposition: 'enabled-only',
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'alpr-cameras',
|
||||||
|
token: 'p',
|
||||||
|
disposition: 'enabled-only',
|
||||||
|
}),
|
||||||
Object.freeze({ id: 'bikeshare', token: 'b', disposition: 'enabled-only' }),
|
Object.freeze({ id: 'bikeshare', token: 'b', disposition: 'enabled-only' }),
|
||||||
Object.freeze({ id: 'cctv', token: 'c', disposition: 'enabled+options', optionOwner: 'cctv' }),
|
Object.freeze({
|
||||||
|
id: 'cctv',
|
||||||
|
token: 'c',
|
||||||
|
disposition: 'enabled+options',
|
||||||
|
optionOwner: 'cctv',
|
||||||
|
}),
|
||||||
Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }),
|
Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }),
|
||||||
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
|
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
|
||||||
Object.freeze({ id: 'flights', token: 'f', disposition: 'enabled+options', optionOwner: 'flights' }),
|
Object.freeze({
|
||||||
|
id: 'flights',
|
||||||
|
token: 'f',
|
||||||
|
disposition: 'enabled+options',
|
||||||
|
optionOwner: 'flights',
|
||||||
|
}),
|
||||||
Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }),
|
Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }),
|
||||||
Object.freeze({ id: 'local-datacenters', token: 'd', disposition: 'enabled-only' }),
|
Object.freeze({
|
||||||
|
id: 'local-datacenters',
|
||||||
|
token: 'd',
|
||||||
|
disposition: 'enabled-only',
|
||||||
|
}),
|
||||||
Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }),
|
Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }),
|
||||||
Object.freeze({ id: 'military', token: 'm', disposition: 'enabled+mirrored-options', optionOwner: 'flights' }),
|
Object.freeze({
|
||||||
Object.freeze({ id: 'military-awareness', token: 'g', disposition: 'enabled-only' }),
|
id: 'military',
|
||||||
Object.freeze({ id: 'military-installations', token: 'i', disposition: 'enabled-only' }),
|
token: 'm',
|
||||||
Object.freeze({ id: 'radio', token: 'r', disposition: 'enabled+options', optionOwner: 'radio' }),
|
disposition: 'enabled+mirrored-options',
|
||||||
Object.freeze({ id: 'rocket-launches', token: 'x', disposition: 'enabled-only' }),
|
optionOwner: 'flights',
|
||||||
Object.freeze({ id: 'satellites', token: 's', disposition: 'enabled+options', optionOwner: 'satellites' }),
|
}),
|
||||||
Object.freeze({ id: 'telegeography-submarine-cables', token: 'u', disposition: 'enabled-only' }),
|
Object.freeze({
|
||||||
|
id: 'military-awareness',
|
||||||
|
token: 'g',
|
||||||
|
disposition: 'enabled-only',
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'military-installations',
|
||||||
|
token: 'i',
|
||||||
|
disposition: 'enabled-only',
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'radio',
|
||||||
|
token: 'r',
|
||||||
|
disposition: 'enabled+options',
|
||||||
|
optionOwner: 'radio',
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'rocket-launches',
|
||||||
|
token: 'x',
|
||||||
|
disposition: 'enabled-only',
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'satellites',
|
||||||
|
token: 's',
|
||||||
|
disposition: 'enabled+options',
|
||||||
|
optionOwner: 'satellites',
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'telegeography-submarine-cables',
|
||||||
|
token: 'u',
|
||||||
|
disposition: 'enabled-only',
|
||||||
|
}),
|
||||||
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
|
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
|
||||||
Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }),
|
Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const REGISTERED_LAYER_IDS = Object.freeze(LAYER_STATE_REGISTRY.map((entry) => entry.id));
|
export const REGISTERED_LAYER_IDS = Object.freeze(
|
||||||
|
LAYER_STATE_REGISTRY.map((entry) => entry.id),
|
||||||
|
);
|
||||||
|
|
||||||
const REGISTRY_BY_ID = new Map(LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]));
|
const REGISTRY_BY_ID = new Map(
|
||||||
const REGISTRY_BY_TOKEN = new Map(LAYER_STATE_REGISTRY.map((entry) => [entry.token, entry]));
|
LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]),
|
||||||
const OPTION_OWNER_IDS = Object.freeze([...new Set(
|
);
|
||||||
|
const REGISTRY_BY_TOKEN = new Map(
|
||||||
|
LAYER_STATE_REGISTRY.map((entry) => [entry.token, entry]),
|
||||||
|
);
|
||||||
|
const OPTION_OWNER_IDS = Object.freeze([
|
||||||
|
...new Set(
|
||||||
LAYER_STATE_REGISTRY.map((entry) => entry.optionOwner).filter(Boolean),
|
LAYER_STATE_REGISTRY.map((entry) => entry.optionOwner).filter(Boolean),
|
||||||
)]);
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
function optionSpecs(ownerId) {
|
function optionSpecs(ownerId) {
|
||||||
return OPTION_GROUPS[ownerId] || [];
|
return OPTION_GROUPS[ownerId] || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultsForOwner(ownerId) {
|
function defaultsForOwner(ownerId) {
|
||||||
return Object.fromEntries(optionSpecs(ownerId).map((spec) => [spec.key, spec.defaultValue]));
|
return Object.fromEntries(
|
||||||
|
optionSpecs(ownerId).map((spec) => [spec.key, spec.defaultValue]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeOwnerOptions(ownerId, candidate = {}) {
|
function normalizeOwnerOptions(ownerId, candidate = {}) {
|
||||||
const input = candidate && typeof candidate === 'object' ? candidate : {};
|
const input = candidate && typeof candidate === 'object' ? candidate : {};
|
||||||
const normalized = {};
|
const normalized = {};
|
||||||
for (const spec of optionSpecs(ownerId)) {
|
for (const spec of optionSpecs(ownerId)) {
|
||||||
const value = Object.hasOwn(input, spec.key) ? spec.normalize(input[spec.key]) : null;
|
const value = Object.hasOwn(input, spec.key)
|
||||||
|
? spec.normalize(input[spec.key])
|
||||||
|
: null;
|
||||||
normalized[spec.key] = value === null ? spec.defaultValue : value;
|
normalized[spec.key] = value === null ? spec.defaultValue : value;
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -335,12 +427,17 @@ export function validateLayerStateRegistry(registry = LAYER_STATE_REGISTRY) {
|
|||||||
const ids = new Set();
|
const ids = new Set();
|
||||||
const tokens = new Set();
|
const tokens = new Set();
|
||||||
for (const entry of registry) {
|
for (const entry of registry) {
|
||||||
if (!entry || typeof entry.id !== 'string' || !entry.id) throw new Error('Layer-state entry missing id');
|
if (!entry || typeof entry.id !== 'string' || !entry.id)
|
||||||
if (!/^[a-z0-9-]+$/.test(entry.id)) throw new Error(`Invalid layer-state id: ${entry.id}`);
|
throw new Error('Layer-state entry missing id');
|
||||||
if (ids.has(entry.id)) throw new Error(`Duplicate layer-state id: ${entry.id}`);
|
if (!/^[a-z0-9-]+$/.test(entry.id))
|
||||||
|
throw new Error(`Invalid layer-state id: ${entry.id}`);
|
||||||
|
if (ids.has(entry.id))
|
||||||
|
throw new Error(`Duplicate layer-state id: ${entry.id}`);
|
||||||
ids.add(entry.id);
|
ids.add(entry.id);
|
||||||
if (!/^[a-z0-9]$/.test(entry.token || '')) throw new Error(`Invalid layer-state token: ${entry.id}`);
|
if (!/^[a-z0-9]$/.test(entry.token || ''))
|
||||||
if (tokens.has(entry.token)) throw new Error(`Duplicate layer-state token: ${entry.token}`);
|
throw new Error(`Invalid layer-state token: ${entry.id}`);
|
||||||
|
if (tokens.has(entry.token))
|
||||||
|
throw new Error(`Duplicate layer-state token: ${entry.token}`);
|
||||||
tokens.add(entry.token);
|
tokens.add(entry.token);
|
||||||
if (!VALID_DISPOSITIONS.has(entry.disposition)) {
|
if (!VALID_DISPOSITIONS.has(entry.disposition)) {
|
||||||
throw new Error(`Invalid layer-state disposition: ${entry.id}`);
|
throw new Error(`Invalid layer-state disposition: ${entry.id}`);
|
||||||
@@ -363,10 +460,9 @@ export function createDefaultLayerState() {
|
|||||||
return {
|
return {
|
||||||
version: LAYER_STATE_VERSION,
|
version: LAYER_STATE_VERSION,
|
||||||
enabledLayerIds: [],
|
enabledLayerIds: [],
|
||||||
options: Object.fromEntries(OPTION_OWNER_IDS.map((ownerId) => [
|
options: Object.fromEntries(
|
||||||
ownerId,
|
OPTION_OWNER_IDS.map((ownerId) => [ownerId, defaultsForOwner(ownerId)]),
|
||||||
defaultsForOwner(ownerId),
|
),
|
||||||
])),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,20 +470,28 @@ export function createDefaultLayerState() {
|
|||||||
export function normalizeLayerState(candidate) {
|
export function normalizeLayerState(candidate) {
|
||||||
const input = candidate && typeof candidate === 'object' ? candidate : {};
|
const input = candidate && typeof candidate === 'object' ? candidate : {};
|
||||||
const requestedEnabled = new Set(
|
const requestedEnabled = new Set(
|
||||||
Array.isArray(input.enabledLayerIds) ? input.enabledLayerIds.map(String) : [],
|
Array.isArray(input.enabledLayerIds)
|
||||||
|
? input.enabledLayerIds.map(String)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const enabledLayerIds = REGISTERED_LAYER_IDS.filter((id) =>
|
||||||
|
requestedEnabled.has(id),
|
||||||
);
|
);
|
||||||
const enabledLayerIds = REGISTERED_LAYER_IDS.filter((id) => requestedEnabled.has(id));
|
|
||||||
const enabled = new Set(enabledLayerIds);
|
const enabled = new Set(enabledLayerIds);
|
||||||
const options = Object.fromEntries(OPTION_OWNER_IDS.map((ownerId) => [
|
const options = Object.fromEntries(
|
||||||
|
OPTION_OWNER_IDS.map((ownerId) => [
|
||||||
ownerId,
|
ownerId,
|
||||||
normalizeOwnerOptions(ownerId, input.options?.[ownerId]),
|
normalizeOwnerOptions(ownerId, input.options?.[ownerId]),
|
||||||
]));
|
]),
|
||||||
|
);
|
||||||
// A selected entity cannot outlive an explicitly disabled owner layer.
|
// A selected entity cannot outlive an explicitly disabled owner layer.
|
||||||
// Keeping these IDs would resurrect tracking when that layer is enabled
|
// Keeping these IDs would resurrect tracking when that layer is enabled
|
||||||
// later, even though OFF was newer explicit intent.
|
// later, even though OFF was newer explicit intent.
|
||||||
if (!enabled.has('flights')) options.flights.selectedFlightsTrackingId = null;
|
if (!enabled.has('flights')) options.flights.selectedFlightsTrackingId = null;
|
||||||
if (!enabled.has('military')) options.flights.selectedMilitaryTrackingId = null;
|
if (!enabled.has('military'))
|
||||||
if (!enabled.has('satellites')) options.satellites.selectedSatTrackingId = null;
|
options.flights.selectedMilitaryTrackingId = null;
|
||||||
|
if (!enabled.has('satellites'))
|
||||||
|
options.satellites.selectedSatTrackingId = null;
|
||||||
// The codec has no cross-family recency field, so multiple tracking IDs are
|
// The codec has no cross-family recency field, so multiple tracking IDs are
|
||||||
// ambiguous rather than an ordered handoff. Fail closed instead of letting
|
// ambiguous rather than an ordered handoff. Fail closed instead of letting
|
||||||
// asynchronous feed arrival decide which tracker and camera owner wins.
|
// asynchronous feed arrival decide which tracker and camera owner wins.
|
||||||
@@ -414,7 +518,10 @@ export function cloneLayerState(state) {
|
|||||||
...normalized,
|
...normalized,
|
||||||
enabledLayerIds: [...normalized.enabledLayerIds],
|
enabledLayerIds: [...normalized.enabledLayerIds],
|
||||||
options: Object.fromEntries(
|
options: Object.fromEntries(
|
||||||
Object.entries(normalized.options).map(([id, options]) => [id, { ...options }]),
|
Object.entries(normalized.options).map(([id, options]) => [
|
||||||
|
id,
|
||||||
|
{ ...options },
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -423,10 +530,12 @@ export function cloneLayerState(state) {
|
|||||||
export function encodeLayerStateParams(params, state) {
|
export function encodeLayerStateParams(params, state) {
|
||||||
const normalized = normalizeLayerState(state);
|
const normalized = normalizeLayerState(state);
|
||||||
const enabled = new Set(normalized.enabledLayerIds);
|
const enabled = new Set(normalized.enabledLayerIds);
|
||||||
params.set('l', LAYER_STATE_REGISTRY
|
params.set(
|
||||||
.filter((entry) => enabled.has(entry.id))
|
'l',
|
||||||
|
LAYER_STATE_REGISTRY.filter((entry) => enabled.has(entry.id))
|
||||||
.map((entry) => entry.token)
|
.map((entry) => entry.token)
|
||||||
.join('.'));
|
.join('.'),
|
||||||
|
);
|
||||||
const encodedOptions = [];
|
const encodedOptions = [];
|
||||||
for (const ownerId of OPTION_OWNER_IDS) {
|
for (const ownerId of OPTION_OWNER_IDS) {
|
||||||
const ownerEntry = REGISTRY_BY_ID.get(ownerId);
|
const ownerEntry = REGISTRY_BY_ID.get(ownerId);
|
||||||
@@ -436,7 +545,9 @@ export function encodeLayerStateParams(params, state) {
|
|||||||
// current default — those are the same thing for every option whose
|
// current default — those are the same thing for every option whose
|
||||||
// default never moved, and deliberately different for one whose did.
|
// default never moved, and deliberately different for one whose did.
|
||||||
if (ownerOptions[spec.key] === absentTokenValue(spec)) continue;
|
if (ownerOptions[spec.key] === absentTokenValue(spec)) continue;
|
||||||
encodedOptions.push(`${ownerEntry.token}.${spec.token}.${spec.encode(ownerOptions[spec.key])}`);
|
encodedOptions.push(
|
||||||
|
`${ownerEntry.token}.${spec.token}.${spec.encode(ownerOptions[spec.key])}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (encodedOptions.length) params.set('lo', encodedOptions.join('_'));
|
if (encodedOptions.length) params.set('lo', encodedOptions.join('_'));
|
||||||
@@ -446,7 +557,8 @@ export function encodeLayerStateParams(params, state) {
|
|||||||
|
|
||||||
/** Decode v2 fields. Null means that the layer payload is absent. */
|
/** Decode v2 fields. Null means that the layer payload is absent. */
|
||||||
export function decodeLayerStateParams(params) {
|
export function decodeLayerStateParams(params) {
|
||||||
if (params.get('v') !== String(LAYER_STATE_VERSION) || !params.has('l')) return null;
|
if (params.get('v') !== String(LAYER_STATE_VERSION) || !params.has('l'))
|
||||||
|
return null;
|
||||||
const rawLayers = String(params.get('l') || '');
|
const rawLayers = String(params.get('l') || '');
|
||||||
const rawOptionsField = String(params.get('lo') || '');
|
const rawOptionsField = String(params.get('lo') || '');
|
||||||
// Fail closed on an oversized payload rather than decoding a truncated one.
|
// Fail closed on an oversized payload rather than decoding a truncated one.
|
||||||
@@ -457,16 +569,21 @@ export function decodeLayerStateParams(params) {
|
|||||||
// set containing an unknown member rejects the complete layer payload so a
|
// set containing an unknown member rejects the complete layer payload so a
|
||||||
// typo or future token cannot silently become an authoritative empty set.
|
// typo or future token cannot silently become an authoritative empty set.
|
||||||
if (layerTokens.some((token) => !REGISTRY_BY_TOKEN.has(token))) return null;
|
if (layerTokens.some((token) => !REGISTRY_BY_TOKEN.has(token))) return null;
|
||||||
const enabledLayerIds = layerTokens.map((token) => REGISTRY_BY_TOKEN.get(token).id);
|
const enabledLayerIds = layerTokens.map(
|
||||||
|
(token) => REGISTRY_BY_TOKEN.get(token).id,
|
||||||
|
);
|
||||||
const rawOptions = {};
|
const rawOptions = {};
|
||||||
for (const assignment of rawOptionsField.split('_')) {
|
for (const assignment of rawOptionsField.split('_')) {
|
||||||
if (!assignment) continue;
|
if (!assignment) continue;
|
||||||
const [layerToken, optionToken, encodedValue, ...extra] = assignment.split('.');
|
const [layerToken, optionToken, encodedValue, ...extra] =
|
||||||
|
assignment.split('.');
|
||||||
if (extra.length) continue;
|
if (extra.length) continue;
|
||||||
const entry = REGISTRY_BY_TOKEN.get(layerToken);
|
const entry = REGISTRY_BY_TOKEN.get(layerToken);
|
||||||
const ownerId = entry?.optionOwner || null;
|
const ownerId = entry?.optionOwner || null;
|
||||||
if (!ownerId) continue;
|
if (!ownerId) continue;
|
||||||
const spec = optionSpecs(ownerId).find((candidate) => candidate.token === optionToken);
|
const spec = optionSpecs(ownerId).find(
|
||||||
|
(candidate) => candidate.token === optionToken,
|
||||||
|
);
|
||||||
if (!spec) continue;
|
if (!spec) continue;
|
||||||
const decoded = spec.decode(encodedValue);
|
const decoded = spec.decode(encodedValue);
|
||||||
if (decoded === null) continue;
|
if (decoded === null) continue;
|
||||||
@@ -480,7 +597,8 @@ export function decodeLayerStateParams(params) {
|
|||||||
// saying "whatever the default happens to be today". See `absentTokenValue`.
|
// saying "whatever the default happens to be today". See `absentTokenValue`.
|
||||||
for (const ownerId of OPTION_OWNER_IDS) {
|
for (const ownerId of OPTION_OWNER_IDS) {
|
||||||
for (const spec of optionSpecs(ownerId)) {
|
for (const spec of optionSpecs(ownerId)) {
|
||||||
if (rawOptions[ownerId] && Object.hasOwn(rawOptions[ownerId], spec.key)) continue;
|
if (rawOptions[ownerId] && Object.hasOwn(rawOptions[ownerId], spec.key))
|
||||||
|
continue;
|
||||||
if (!rawOptions[ownerId]) rawOptions[ownerId] = {};
|
if (!rawOptions[ownerId]) rawOptions[ownerId] = {};
|
||||||
rawOptions[ownerId][spec.key] = absentTokenValue(spec);
|
rawOptions[ownerId][spec.key] = absentTokenValue(spec);
|
||||||
}
|
}
|
||||||
@@ -502,8 +620,12 @@ export function parseStoredLayerState(raw) {
|
|||||||
if (typeof raw !== 'string' || !raw) return null;
|
if (typeof raw !== 'string' || !raw) return null;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (parsed?.v !== LAYER_STATE_VERSION || !Array.isArray(parsed.l)) return null;
|
if (parsed?.v !== LAYER_STATE_VERSION || !Array.isArray(parsed.l))
|
||||||
return normalizeLayerState({ enabledLayerIds: parsed.l, options: parsed.o });
|
return null;
|
||||||
|
return normalizeLayerState({
|
||||||
|
enabledLayerIds: parsed.l,
|
||||||
|
options: parsed.o,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -517,7 +639,11 @@ export function layerOptionsForRestore(state, layerId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function safeStorage() {
|
function safeStorage() {
|
||||||
try { return globalThis.localStorage || null; } catch { return null; }
|
try {
|
||||||
|
return globalThis.localStorage || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentLayerOutcome(dataManager, layerId) {
|
function currentLayerOutcome(dataManager, layerId) {
|
||||||
@@ -534,7 +660,10 @@ function currentLayerOutcome(dataManager, layerId) {
|
|||||||
* choreography, and coordinates passive post-registration restoration.
|
* choreography, and coordinates passive post-registration restoration.
|
||||||
*/
|
*/
|
||||||
export class LayerStateCoordinator {
|
export class LayerStateCoordinator {
|
||||||
constructor(dataManager, shareLinkManager, {
|
constructor(
|
||||||
|
dataManager,
|
||||||
|
shareLinkManager,
|
||||||
|
{
|
||||||
storage = safeStorage(),
|
storage = safeStorage(),
|
||||||
restoreGate = null,
|
restoreGate = null,
|
||||||
onDurableStateChange = null,
|
onDurableStateChange = null,
|
||||||
@@ -544,9 +673,12 @@ export class LayerStateCoordinator {
|
|||||||
// without sleeping out a 90 s expiry.
|
// without sleeping out a 90 s expiry.
|
||||||
setTimer = (fn, ms) => setTimeout(fn, ms),
|
setTimer = (fn, ms) => setTimeout(fn, ms),
|
||||||
clearTimer = (handle) => clearTimeout(handle),
|
clearTimer = (handle) => clearTimeout(handle),
|
||||||
} = {}) {
|
} = {},
|
||||||
|
) {
|
||||||
if (!dataManager?.registrationsFinalized) {
|
if (!dataManager?.registrationsFinalized) {
|
||||||
throw new Error('Layer state requires finalized data-layer registrations');
|
throw new Error(
|
||||||
|
'Layer state requires finalized data-layer registrations',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.dataManager = dataManager;
|
this.dataManager = dataManager;
|
||||||
this.shareLinkManager = shareLinkManager || null;
|
this.shareLinkManager = shareLinkManager || null;
|
||||||
@@ -566,23 +698,41 @@ export class LayerStateCoordinator {
|
|||||||
this._trackingRestoreGeneration = 0;
|
this._trackingRestoreGeneration = 0;
|
||||||
this._pendingTrackingTimer = null;
|
this._pendingTrackingTimer = null;
|
||||||
this._pendingTrackingContext = null;
|
this._pendingTrackingContext = null;
|
||||||
this._unsubscribe = this.dataManager.subscribe((change) => this._handleManagerChange(change));
|
this._unsubscribe = this.dataManager.subscribe((change) =>
|
||||||
this._unsubscribeVisibilityRequests = this.dataManager.subscribeVisibilityRequests(
|
this._handleManagerChange(change),
|
||||||
(change) => this._handleVisibilityRequest(change),
|
);
|
||||||
|
this._unsubscribeVisibilityRequests =
|
||||||
|
this.dataManager.subscribeVisibilityRequests((change) =>
|
||||||
|
this._handleVisibilityRequest(change),
|
||||||
);
|
);
|
||||||
this.restorePromise = Promise.resolve([]);
|
this.restorePromise = Promise.resolve([]);
|
||||||
this.lastRestoreResults = [];
|
this.lastRestoreResults = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
start({ shareLayerState = null, allowLocalState = true, shareCreatedAtMs = null } = {}) {
|
start({
|
||||||
if (this._destroyed) throw new Error('Layer-state coordinator is destroyed');
|
shareLayerState = null,
|
||||||
let selected = shareLayerState ? normalizeLayerState(shareLayerState) : null;
|
allowLocalState = true,
|
||||||
|
shareCreatedAtMs = null,
|
||||||
|
} = {}) {
|
||||||
|
if (this._destroyed)
|
||||||
|
throw new Error('Layer-state coordinator is destroyed');
|
||||||
|
let selected = shareLayerState
|
||||||
|
? normalizeLayerState(shareLayerState)
|
||||||
|
: null;
|
||||||
if (selected) {
|
if (selected) {
|
||||||
this._source = 'share';
|
this._source = 'share';
|
||||||
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null;
|
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs)
|
||||||
|
? shareCreatedAtMs
|
||||||
|
: null;
|
||||||
} else if (allowLocalState) {
|
} else if (allowLocalState) {
|
||||||
let stored = null;
|
let stored = null;
|
||||||
try { stored = parseStoredLayerState(this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY)); } catch { /* best effort */ }
|
try {
|
||||||
|
stored = parseStoredLayerState(
|
||||||
|
this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
if (stored) {
|
if (stored) {
|
||||||
selected = stored;
|
selected = stored;
|
||||||
this._source = 'local';
|
this._source = 'local';
|
||||||
@@ -594,12 +744,16 @@ export class LayerStateCoordinator {
|
|||||||
this._source = 'legacy-share';
|
this._source = 'legacy-share';
|
||||||
}
|
}
|
||||||
this._durableState = selected || createDefaultLayerState();
|
this._durableState = selected || createDefaultLayerState();
|
||||||
this.shareLinkManager?.setLayerStateProvider?.(() => this.getDurableState());
|
this.shareLinkManager?.setLayerStateProvider?.(() =>
|
||||||
|
this.getDurableState(),
|
||||||
|
);
|
||||||
this.shareLinkManager?.onLayerStateChange?.();
|
this.shareLinkManager?.onLayerStateChange?.();
|
||||||
this._notifyDurableState();
|
this._notifyDurableState();
|
||||||
if (!selected) return this.restorePromise;
|
if (!selected) return this.restorePromise;
|
||||||
this.restorePromise = this._restoreSelectedState(
|
this.restorePromise = this._restoreSelectedState(
|
||||||
this._source === 'share' ? LAYER_RESTORE_ORIGINS.share : LAYER_RESTORE_ORIGINS.local,
|
this._source === 'share'
|
||||||
|
? LAYER_RESTORE_ORIGINS.share
|
||||||
|
: LAYER_RESTORE_ORIGINS.local,
|
||||||
);
|
);
|
||||||
return this.restorePromise;
|
return this.restorePromise;
|
||||||
}
|
}
|
||||||
@@ -613,12 +767,18 @@ export class LayerStateCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_notifyDurableState() {
|
_notifyDurableState() {
|
||||||
try { this.onDurableStateChange?.(this.getDurableState()); } catch { /* UI sync is best effort */ }
|
try {
|
||||||
|
this.onDurableStateChange?.(this.getDurableState());
|
||||||
|
} catch {
|
||||||
|
/* UI sync is best effort */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleVisibilityRequest(change) {
|
_handleVisibilityRequest(change) {
|
||||||
if (!isExplicitLayerStateOrigin(change?.origin)) return;
|
if (!isExplicitLayerStateOrigin(change?.origin)) return;
|
||||||
this._restoreControllers.get(change.layerId)?.abort('superseded-by-explicit-visibility');
|
this._restoreControllers
|
||||||
|
.get(change.layerId)
|
||||||
|
?.abort('superseded-by-explicit-visibility');
|
||||||
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
||||||
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
|
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
|
||||||
}
|
}
|
||||||
@@ -626,7 +786,8 @@ export class LayerStateCoordinator {
|
|||||||
|
|
||||||
/** Revoke every passive restore before explicit navigation can be reclaimed. */
|
/** Revoke every passive restore before explicit navigation can be reclaimed. */
|
||||||
cancelPendingRestores(reason = 'superseded-by-explicit-navigation') {
|
cancelPendingRestores(reason = 'superseded-by-explicit-navigation') {
|
||||||
for (const controller of this._restoreControllers.values()) controller.abort(reason);
|
for (const controller of this._restoreControllers.values())
|
||||||
|
controller.abort(reason);
|
||||||
this._revokePendingTrackingWatch(reason);
|
this._revokePendingTrackingWatch(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,9 +795,10 @@ export class LayerStateCoordinator {
|
|||||||
* Revoke a pending shared Follow. Physical navigation may also clear only
|
* Revoke a pending shared Follow. Physical navigation may also clear only
|
||||||
* the exact passive selection, without writing recipient preferences.
|
* the exact passive selection, without writing recipient preferences.
|
||||||
*/
|
*/
|
||||||
cancelPendingShareTracking(reason = 'superseded-by-explicit-navigation', {
|
cancelPendingShareTracking(
|
||||||
clearSelection = false,
|
reason = 'superseded-by-explicit-navigation',
|
||||||
} = {}) {
|
{ clearSelection = false } = {},
|
||||||
|
) {
|
||||||
this._revokePendingTrackingWatch(reason);
|
this._revokePendingTrackingWatch(reason);
|
||||||
if (!clearSelection) return false;
|
if (!clearSelection) return false;
|
||||||
const selected = this._selectedShareTrackingTarget();
|
const selected = this._selectedShareTrackingTarget();
|
||||||
@@ -649,8 +811,10 @@ export class LayerStateCoordinator {
|
|||||||
// option request may replace passive share options, but it must not abort
|
// option request may replace passive share options, but it must not abort
|
||||||
// the same layer's visibility lifecycle.
|
// the same layer's visibility lifecycle.
|
||||||
if (change.type === 'params-requested') {
|
if (change.type === 'params-requested') {
|
||||||
if (isExplicitLayerStateOrigin(change.origin)
|
if (
|
||||||
&& SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
isExplicitLayerStateOrigin(change.origin) &&
|
||||||
|
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
|
||||||
|
) {
|
||||||
this._revokePendingTrackingWatch('superseded-by-explicit-params');
|
this._revokePendingTrackingWatch('superseded-by-explicit-params');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -658,21 +822,28 @@ export class LayerStateCoordinator {
|
|||||||
// A layer that goes away takes its latch with it, at ANY origin — a
|
// A layer that goes away takes its latch with it, at ANY origin — a
|
||||||
// programmatic disable or teardown never reaches the explicit-intent path
|
// programmatic disable or teardown never reaches the explicit-intent path
|
||||||
// below, so revoke here before that early return.
|
// below, so revoke here before that early return.
|
||||||
if (change.type === 'visibility'
|
if (
|
||||||
&& change.enabled === false
|
change.type === 'visibility' &&
|
||||||
&& SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
change.enabled === false &&
|
||||||
|
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
|
||||||
|
) {
|
||||||
this._revokePendingTrackingWatch('owner-layer-disabled');
|
this._revokePendingTrackingWatch('owner-layer-disabled');
|
||||||
}
|
}
|
||||||
if (!isExplicitLayerStateOrigin(change.origin)) return;
|
if (!isExplicitLayerStateOrigin(change.origin)) return;
|
||||||
if (change.type === 'visibility') {
|
if (change.type === 'visibility') {
|
||||||
this._restoreControllers.get(change.layerId)?.abort('superseded-by-explicit-visibility');
|
this._restoreControllers
|
||||||
|
.get(change.layerId)
|
||||||
|
?.abort('superseded-by-explicit-visibility');
|
||||||
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
||||||
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
|
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
|
||||||
}
|
}
|
||||||
const enabled = new Set(this._durableState.enabledLayerIds);
|
const enabled = new Set(this._durableState.enabledLayerIds);
|
||||||
if (change.enabled) enabled.add(change.layerId);
|
if (change.enabled) enabled.add(change.layerId);
|
||||||
else enabled.delete(change.layerId);
|
else enabled.delete(change.layerId);
|
||||||
this._commitExplicit({ ...this._durableState, enabledLayerIds: [...enabled] });
|
this._commitExplicit({
|
||||||
|
...this._durableState,
|
||||||
|
enabledLayerIds: [...enabled],
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (change.type !== 'params') return;
|
if (change.type !== 'params') return;
|
||||||
@@ -681,7 +852,8 @@ export class LayerStateCoordinator {
|
|||||||
const ownerId = entry.optionOwner;
|
const ownerId = entry.optionOwner;
|
||||||
const nextOwnerOptions = { ...this._durableState.options[ownerId] };
|
const nextOwnerOptions = { ...this._durableState.options[ownerId] };
|
||||||
const requestedParams = change.requestedParams || {};
|
const requestedParams = change.requestedParams || {};
|
||||||
const trackingOptionKey = TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null;
|
const trackingOptionKey =
|
||||||
|
TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null;
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const spec of optionSpecs(ownerId)) {
|
for (const spec of optionSpecs(ownerId)) {
|
||||||
// Only persist keys present in this explicit request. getLayerParams()
|
// Only persist keys present in this explicit request. getLayerParams()
|
||||||
@@ -691,7 +863,8 @@ export class LayerStateCoordinator {
|
|||||||
// in that family, so its wider live value (active ID or null) must replace
|
// in that family, so its wider live value (active ID or null) must replace
|
||||||
// the formerly durable pending ID instead of allowing reload resurrection.
|
// the formerly durable pending ID instead of allowing reload resurrection.
|
||||||
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
|
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
|
||||||
const implicitTrackingSync = !explicitlyRequested && spec.key === trackingOptionKey;
|
const implicitTrackingSync =
|
||||||
|
!explicitlyRequested && spec.key === trackingOptionKey;
|
||||||
if (!explicitlyRequested && !implicitTrackingSync) continue;
|
if (!explicitlyRequested && !implicitTrackingSync) continue;
|
||||||
const value = spec.normalize(change.params[spec.key]);
|
const value = spec.normalize(change.params[spec.key]);
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
@@ -700,7 +873,8 @@ export class LayerStateCoordinator {
|
|||||||
changed = true;
|
changed = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (spec.defaultValue !== null || change.params[spec.key] !== null) continue;
|
if (spec.defaultValue !== null || change.params[spec.key] !== null)
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
nextOwnerOptions[spec.key] = value;
|
nextOwnerOptions[spec.key] = value;
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -719,14 +893,18 @@ export class LayerStateCoordinator {
|
|||||||
if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) {
|
if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) {
|
||||||
this.storage?.setItem?.(LAYER_STATE_STORAGE_KEY, serialized);
|
this.storage?.setItem?.(LAYER_STATE_STORAGE_KEY, serialized);
|
||||||
}
|
}
|
||||||
} catch { /* storage can be unavailable or quota-limited */ }
|
} catch {
|
||||||
|
/* storage can be unavailable or quota-limited */
|
||||||
|
}
|
||||||
this.shareLinkManager?.onLayerStateChange?.();
|
this.shareLinkManager?.onLayerStateChange?.();
|
||||||
this._notifyDurableState();
|
this._notifyDurableState();
|
||||||
}
|
}
|
||||||
|
|
||||||
async _waitForRestoreGate() {
|
async _waitForRestoreGate() {
|
||||||
if (!this.restoreGate) return;
|
if (!this.restoreGate) return;
|
||||||
await (typeof this.restoreGate === 'function' ? this.restoreGate() : this.restoreGate);
|
await (typeof this.restoreGate === 'function'
|
||||||
|
? this.restoreGate()
|
||||||
|
: this.restoreGate);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _restoreSelectedState(origin) {
|
async _restoreSelectedState(origin) {
|
||||||
@@ -736,12 +914,15 @@ export class LayerStateCoordinator {
|
|||||||
try {
|
try {
|
||||||
await this._waitForRestoreGate();
|
await this._waitForRestoreGate();
|
||||||
const enabled = new Set(this._durableState.enabledLayerIds);
|
const enabled = new Set(this._durableState.enabledLayerIds);
|
||||||
const settled = await Promise.allSettled(LAYER_STATE_REGISTRY.map(async (entry) => {
|
const settled = await Promise.allSettled(
|
||||||
|
LAYER_STATE_REGISTRY.map(async (entry) => {
|
||||||
const controller = this._restoreControllers.get(entry.id);
|
const controller = this._restoreControllers.get(entry.id);
|
||||||
const targetEnabled = enabled.has(entry.id);
|
const targetEnabled = enabled.has(entry.id);
|
||||||
const options = layerOptionsForRestore(this._durableState, entry.id);
|
const options = layerOptionsForRestore(this._durableState, entry.id);
|
||||||
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
|
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
|
||||||
for (const trackingKey of Object.values(TRACKING_OPTION_KEY_BY_LAYER)) {
|
for (const trackingKey of Object.values(
|
||||||
|
TRACKING_OPTION_KEY_BY_LAYER,
|
||||||
|
)) {
|
||||||
delete options[trackingKey];
|
delete options[trackingKey];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -762,18 +943,29 @@ export class LayerStateCoordinator {
|
|||||||
// Reserve passive option state before any asynchronous lifecycle work.
|
// Reserve passive option state before any asynchronous lifecycle work.
|
||||||
// A later explicit params intent then wins on its own lane without
|
// A later explicit params intent then wins on its own lane without
|
||||||
// cancelling or being overwritten by the visibility restore.
|
// cancelling or being overwritten by the visibility restore.
|
||||||
const paramsSucceeded = !options || Object.keys(options).length === 0
|
const paramsSucceeded =
|
||||||
|| this.dataManager.setLayerParams(entry.id, options, { origin });
|
!options ||
|
||||||
return this.dataManager.restoreLayerState(entry.id, {
|
Object.keys(options).length === 0 ||
|
||||||
|
this.dataManager.setLayerParams(entry.id, options, { origin });
|
||||||
|
return this.dataManager
|
||||||
|
.restoreLayerState(
|
||||||
|
entry.id,
|
||||||
|
{
|
||||||
enabled: targetEnabled,
|
enabled: targetEnabled,
|
||||||
params: null,
|
params: null,
|
||||||
}, { origin, signal: controller.signal }).then((result) => ({
|
},
|
||||||
|
{ origin, signal: controller.signal },
|
||||||
|
)
|
||||||
|
.then((result) => ({
|
||||||
...result,
|
...result,
|
||||||
appliedOptions: paramsSucceeded && options ? options : {},
|
appliedOptions: paramsSucceeded && options ? options : {},
|
||||||
errorClass: paramsSucceeded ? result.errorClass : 'ParamsRejected',
|
errorClass: paramsSucceeded
|
||||||
|
? result.errorClass
|
||||||
|
: 'ParamsRejected',
|
||||||
succeeded: paramsSucceeded && result.succeeded,
|
succeeded: paramsSucceeded && result.succeeded,
|
||||||
}));
|
}));
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
this.lastRestoreResults = settled.map((result, index) => {
|
this.lastRestoreResults = settled.map((result, index) => {
|
||||||
if (result.status === 'fulfilled') return result.value;
|
if (result.status === 'fulfilled') return result.value;
|
||||||
const entry = LAYER_STATE_REGISTRY[index];
|
const entry = LAYER_STATE_REGISTRY[index];
|
||||||
@@ -800,8 +992,11 @@ export class LayerStateCoordinator {
|
|||||||
|
|
||||||
_selectedShareTrackingTarget() {
|
_selectedShareTrackingTarget() {
|
||||||
if (this._source !== 'share') return null;
|
if (this._source !== 'share') return null;
|
||||||
for (const [layerId, policy] of Object.entries(SHARE_TRACKING_RESTORE_POLICIES)) {
|
for (const [layerId, policy] of Object.entries(
|
||||||
const targetId = this._durableState.options?.[policy.optionOwner]?.[policy.optionKey];
|
SHARE_TRACKING_RESTORE_POLICIES,
|
||||||
|
)) {
|
||||||
|
const targetId =
|
||||||
|
this._durableState.options?.[policy.optionOwner]?.[policy.optionKey];
|
||||||
if (targetId !== null && targetId !== undefined && targetId !== '') {
|
if (targetId !== null && targetId !== undefined && targetId !== '') {
|
||||||
return { layerId, targetId, ...policy };
|
return { layerId, targetId, ...policy };
|
||||||
}
|
}
|
||||||
@@ -810,7 +1005,8 @@ export class LayerStateCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_passivelyClearTrackingSelection(selected) {
|
_passivelyClearTrackingSelection(selected) {
|
||||||
const current = this._durableState.options?.[selected.optionOwner]?.[selected.optionKey];
|
const current =
|
||||||
|
this._durableState.options?.[selected.optionOwner]?.[selected.optionKey];
|
||||||
if (String(current) !== String(selected.targetId)) return false;
|
if (String(current) !== String(selected.targetId)) return false;
|
||||||
const ownerOptions = {
|
const ownerOptions = {
|
||||||
...this._durableState.options[selected.optionOwner],
|
...this._durableState.options[selected.optionOwner],
|
||||||
@@ -850,13 +1046,17 @@ export class LayerStateCoordinator {
|
|||||||
_trackingTargetLatched(selected) {
|
_trackingTargetLatched(selected) {
|
||||||
const params = this.dataManager.getLayerParams?.(selected.layerId);
|
const params = this.dataManager.getLayerParams?.(selected.layerId);
|
||||||
const active = params?.[selected.optionKey];
|
const active = params?.[selected.optionKey];
|
||||||
return active !== null && active !== undefined
|
return (
|
||||||
&& String(active) === String(selected.targetId);
|
active !== null &&
|
||||||
|
active !== undefined &&
|
||||||
|
String(active) === String(selected.targetId)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop watching a pending shared subject without deciding its fate. */
|
/** Stop watching a pending shared subject without deciding its fate. */
|
||||||
_cancelPendingTrackingWatch() {
|
_cancelPendingTrackingWatch() {
|
||||||
if (this._pendingTrackingTimer !== null) this.clearTimer(this._pendingTrackingTimer);
|
if (this._pendingTrackingTimer !== null)
|
||||||
|
this.clearTimer(this._pendingTrackingTimer);
|
||||||
this._pendingTrackingTimer = null;
|
this._pendingTrackingTimer = null;
|
||||||
const pending = this._pendingTrackingContext;
|
const pending = this._pendingTrackingContext;
|
||||||
if (pending?.signal && pending.abortHandler) {
|
if (pending?.signal && pending.abortHandler) {
|
||||||
@@ -867,7 +1067,11 @@ export class LayerStateCoordinator {
|
|||||||
|
|
||||||
/** Publish a share-follow lifecycle update without allowing UI errors to own state. */
|
/** Publish a share-follow lifecycle update without allowing UI errors to own state. */
|
||||||
_publishTrackingRestoreStatus(status) {
|
_publishTrackingRestoreStatus(status) {
|
||||||
try { this.onTrackingRestoreStatus?.(status); } catch { /* status UI is best effort */ }
|
try {
|
||||||
|
this.onTrackingRestoreStatus?.(status);
|
||||||
|
} catch {
|
||||||
|
/* status UI is best effort */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -927,15 +1131,20 @@ export class LayerStateCoordinator {
|
|||||||
let armed = false;
|
let armed = false;
|
||||||
let armError = null;
|
let armError = null;
|
||||||
try {
|
try {
|
||||||
armed = await this.dataManager.setLayerParams?.(
|
armed =
|
||||||
|
(await this.dataManager.setLayerParams?.(
|
||||||
selected.layerId,
|
selected.layerId,
|
||||||
{ [selected.optionKey]: selected.targetId },
|
{ [selected.optionKey]: selected.targetId },
|
||||||
{ origin: LAYER_RESTORE_ORIGINS.share },
|
{ origin: LAYER_RESTORE_ORIGINS.share },
|
||||||
) === true;
|
)) === true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
armError = error;
|
armError = error;
|
||||||
}
|
}
|
||||||
if (this._destroyed || generation !== this._trackingRestoreGeneration || signal?.aborted) {
|
if (
|
||||||
|
this._destroyed ||
|
||||||
|
generation !== this._trackingRestoreGeneration ||
|
||||||
|
signal?.aborted
|
||||||
|
) {
|
||||||
if (armed) {
|
if (armed) {
|
||||||
this.dataManager.cancelPendingLayerRestore?.(selected.layerId, {
|
this.dataManager.cancelPendingLayerRestore?.(selected.layerId, {
|
||||||
origin: LAYER_RESTORE_ORIGINS.share,
|
origin: LAYER_RESTORE_ORIGINS.share,
|
||||||
@@ -957,7 +1166,9 @@ export class LayerStateCoordinator {
|
|||||||
...selected,
|
...selected,
|
||||||
status: 'source-unavailable',
|
status: 'source-unavailable',
|
||||||
classification: 'source-unavailable',
|
classification: 'source-unavailable',
|
||||||
reason: String(armError?.message || armError || 'tracking restore latch rejected'),
|
reason: String(
|
||||||
|
armError?.message || armError || 'tracking restore latch rejected',
|
||||||
|
),
|
||||||
cleared: this._passivelyClearTrackingSelection(selected),
|
cleared: this._passivelyClearTrackingSelection(selected),
|
||||||
};
|
};
|
||||||
this._publishTrackingRestoreStatus(terminal);
|
this._publishTrackingRestoreStatus(terminal);
|
||||||
@@ -972,7 +1183,8 @@ export class LayerStateCoordinator {
|
|||||||
};
|
};
|
||||||
const poll = () => {
|
const poll = () => {
|
||||||
this._pendingTrackingTimer = null;
|
this._pendingTrackingTimer = null;
|
||||||
if (this._destroyed || generation !== this._trackingRestoreGeneration) return;
|
if (this._destroyed || generation !== this._trackingRestoreGeneration)
|
||||||
|
return;
|
||||||
// The layer that owns the latch may have gone away since the last tick
|
// The layer that owns the latch may have gone away since the last tick
|
||||||
// (disable, teardown, replacement). There is nothing left attempting this
|
// (disable, teardown, replacement). There is nothing left attempting this
|
||||||
// restore, so abandon it silently rather than announcing a verdict.
|
// restore, so abandon it silently rather than announcing a verdict.
|
||||||
@@ -981,23 +1193,39 @@ export class LayerStateCoordinator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this._trackingTargetLatched(selected)) {
|
if (this._trackingTargetLatched(selected)) {
|
||||||
settle({ ...probe, ...selected, status: 'found', classification: 'followed', cleared: false });
|
settle({
|
||||||
|
...probe,
|
||||||
|
...selected,
|
||||||
|
status: 'found',
|
||||||
|
classification: 'followed',
|
||||||
|
cleared: false,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.now() >= deadline) {
|
if (this.now() >= deadline) {
|
||||||
// The window really has elapsed — only now does the verdict apply.
|
// The window really has elapsed — only now does the verdict apply.
|
||||||
const classification = probe.status === 'missing'
|
const classification =
|
||||||
|
probe.status === 'missing'
|
||||||
? this._classifyMissingTrackingTarget(selected, absentAtMs)
|
? this._classifyMissingTrackingTarget(selected, absentAtMs)
|
||||||
: 'source-unavailable';
|
: 'source-unavailable';
|
||||||
const cleared = this._passivelyClearTrackingSelection(selected);
|
const cleared = this._passivelyClearTrackingSelection(selected);
|
||||||
settle({ ...probe, ...selected, classification, cleared });
|
settle({ ...probe, ...selected, classification, cleared });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._pendingTrackingTimer = this.setTimer(poll, PENDING_TRACKING_POLL_MS);
|
this._pendingTrackingTimer = this.setTimer(
|
||||||
|
poll,
|
||||||
|
PENDING_TRACKING_POLL_MS,
|
||||||
|
);
|
||||||
this._pendingTrackingTimer?.unref?.();
|
this._pendingTrackingTimer?.unref?.();
|
||||||
};
|
};
|
||||||
this._cancelPendingTrackingWatch();
|
this._cancelPendingTrackingWatch();
|
||||||
const pending = { ...probe, ...selected, status: 'pending', classification: 'pending', cleared: false };
|
const pending = {
|
||||||
|
...probe,
|
||||||
|
...selected,
|
||||||
|
status: 'pending',
|
||||||
|
classification: 'pending',
|
||||||
|
cleared: false,
|
||||||
|
};
|
||||||
const pendingContext = {
|
const pendingContext = {
|
||||||
generation,
|
generation,
|
||||||
selected,
|
selected,
|
||||||
@@ -1010,7 +1238,9 @@ export class LayerStateCoordinator {
|
|||||||
if (this._pendingTrackingContext?.generation !== generation) return;
|
if (this._pendingTrackingContext?.generation !== generation) return;
|
||||||
this._revokePendingTrackingWatch(signal.reason || 'aborted');
|
this._revokePendingTrackingWatch(signal.reason || 'aborted');
|
||||||
};
|
};
|
||||||
signal.addEventListener('abort', pendingContext.abortHandler, { once: true });
|
signal.addEventListener('abort', pendingContext.abortHandler, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
this._pendingTrackingContext = pendingContext;
|
this._pendingTrackingContext = pendingContext;
|
||||||
this._publishTrackingRestoreStatus(pending);
|
this._publishTrackingRestoreStatus(pending);
|
||||||
@@ -1025,7 +1255,8 @@ export class LayerStateCoordinator {
|
|||||||
*/
|
*/
|
||||||
async restoreShareTrackingSelection({ signal = null } = {}) {
|
async restoreShareTrackingSelection({ signal = null } = {}) {
|
||||||
const selected = this._selectedShareTrackingTarget();
|
const selected = this._selectedShareTrackingTarget();
|
||||||
if (!selected || this._destroyed) return { status: 'skipped', reason: 'no-shared-target' };
|
if (!selected || this._destroyed)
|
||||||
|
return { status: 'skipped', reason: 'no-shared-target' };
|
||||||
this._revokePendingTrackingWatch('superseded-by-newer-restore');
|
this._revokePendingTrackingWatch('superseded-by-newer-restore');
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const combinedSignal = signal
|
const combinedSignal = signal
|
||||||
@@ -1042,31 +1273,58 @@ export class LayerStateCoordinator {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
result = combinedSignal.aborted
|
result = combinedSignal.aborted
|
||||||
? { status: 'cancelled', reason: String(combinedSignal.reason || 'aborted') }
|
? {
|
||||||
: { status: 'source-unavailable', reason: String(error?.message || error) };
|
status: 'cancelled',
|
||||||
|
reason: String(combinedSignal.reason || 'aborted'),
|
||||||
}
|
}
|
||||||
if (generation !== this._trackingRestoreGeneration || this._destroyed || combinedSignal.aborted) {
|
: {
|
||||||
if (this._trackingRestoreController === controller) this._trackingRestoreController = null;
|
status: 'source-unavailable',
|
||||||
return { ...result, status: 'cancelled', reason: String(combinedSignal.reason || 'superseded') };
|
reason: String(error?.message || error),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (this._trackingRestoreController === controller) this._trackingRestoreController = null;
|
if (
|
||||||
|
generation !== this._trackingRestoreGeneration ||
|
||||||
|
this._destroyed ||
|
||||||
|
combinedSignal.aborted
|
||||||
|
) {
|
||||||
|
if (this._trackingRestoreController === controller)
|
||||||
|
this._trackingRestoreController = null;
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
status: 'cancelled',
|
||||||
|
reason: String(combinedSignal.reason || 'superseded'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (this._trackingRestoreController === controller)
|
||||||
|
this._trackingRestoreController = null;
|
||||||
|
|
||||||
if (result.status === 'found') {
|
if (result.status === 'found') {
|
||||||
const terminal = { ...result, ...selected, classification: 'followed', cleared: false };
|
const terminal = {
|
||||||
|
...result,
|
||||||
|
...selected,
|
||||||
|
classification: 'followed',
|
||||||
|
cleared: false,
|
||||||
|
};
|
||||||
this._publishTrackingRestoreStatus(terminal);
|
this._publishTrackingRestoreStatus(terminal);
|
||||||
return terminal;
|
return terminal;
|
||||||
}
|
}
|
||||||
if (['cancelled', 'superseded', 'destroyed'].includes(result.status)) return result;
|
if (['cancelled', 'superseded', 'destroyed'].includes(result.status))
|
||||||
|
return result;
|
||||||
|
|
||||||
// A subject that is simply not here YET is not a subject that is gone. Hold
|
// A subject that is simply not here YET is not a subject that is gone. Hold
|
||||||
// it on the layer's own deferred-restore latch for its source-specific
|
// it on the layer's own deferred-restore latch for its source-specific
|
||||||
// window before any verdict is reached or shown. `unsupported` layers have
|
// window before any verdict is reached or shown. `unsupported` layers have
|
||||||
// no latch to arm, so they still decide immediately.
|
// no latch to arm, so they still decide immediately.
|
||||||
if (result.status === 'missing' || result.status === 'source-unavailable') {
|
if (result.status === 'missing' || result.status === 'source-unavailable') {
|
||||||
return this._beginPendingTrackingRestore(selected, result, combinedSignal);
|
return this._beginPendingTrackingRestore(
|
||||||
|
selected,
|
||||||
|
result,
|
||||||
|
combinedSignal,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const classification = result.status === 'missing'
|
const classification =
|
||||||
|
result.status === 'missing'
|
||||||
? this._classifyMissingTrackingTarget(selected)
|
? this._classifyMissingTrackingTarget(selected)
|
||||||
: 'source-unavailable';
|
: 'source-unavailable';
|
||||||
const cleared = this._passivelyClearTrackingSelection(selected);
|
const cleared = this._passivelyClearTrackingSelection(selected);
|
||||||
@@ -1078,7 +1336,8 @@ export class LayerStateCoordinator {
|
|||||||
destroy() {
|
destroy() {
|
||||||
if (this._destroyed) return;
|
if (this._destroyed) return;
|
||||||
this._destroyed = true;
|
this._destroyed = true;
|
||||||
for (const controller of this._restoreControllers.values()) controller.abort('coordinator-destroyed');
|
for (const controller of this._restoreControllers.values())
|
||||||
|
controller.abort('coordinator-destroyed');
|
||||||
this._restoreControllers.clear();
|
this._restoreControllers.clear();
|
||||||
this._revokePendingTrackingWatch('coordinator-destroyed');
|
this._revokePendingTrackingWatch('coordinator-destroyed');
|
||||||
this._trackingRestoreController = null;
|
this._trackingRestoreController = null;
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ export * from './localGeojsonCore.js';
|
|||||||
export function createLocalGeoJsonLayer(options) {
|
export function createLocalGeoJsonLayer(options) {
|
||||||
return createLayer(options, {
|
return createLayer(options, {
|
||||||
...localGeoJsonServices,
|
...localGeoJsonServices,
|
||||||
overlayHost: options.overlayHost === undefined
|
overlayHost:
|
||||||
? localGeoJsonServices.overlayHost : options.overlayHost,
|
options.overlayHost === undefined
|
||||||
|
? localGeoJsonServices.overlayHost
|
||||||
|
: options.overlayHost,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +22,9 @@ export function createLocalGeoJsonLayer(options) {
|
|||||||
export function createLocalInfrastructureOverlayPublisher(options) {
|
export function createLocalInfrastructureOverlayPublisher(options) {
|
||||||
return createPublisher({
|
return createPublisher({
|
||||||
...options,
|
...options,
|
||||||
host: options.host === undefined ? localGeoJsonServices.overlayHost : options.host,
|
host:
|
||||||
|
options.host === undefined
|
||||||
|
? localGeoJsonServices.overlayHost
|
||||||
|
: options.host,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -15,9 +15,4 @@ const fires = createFirmsHeatmapLayer({
|
|||||||
source: 'NASA FIRMS · LIVE',
|
source: 'NASA FIRMS · LIVE',
|
||||||
});
|
});
|
||||||
|
|
||||||
export default [
|
export default [datacenters, dams, submarineCablesLayer, fires];
|
||||||
datacenters,
|
|
||||||
dams,
|
|
||||||
submarineCablesLayer,
|
|
||||||
fires,
|
|
||||||
];
|
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { createMeshFloorSampler } from '../services/meshFloorSampler.js';
|
import { createMeshFloorSampler } from '../services/meshFloorSampler.js';
|
||||||
import * as groundFloor from './groundFloor.js';
|
import * as groundFloor from './groundFloor.js';
|
||||||
const meshFloorSampler = createMeshFloorSampler({ groundFloor, eventTarget: typeof window === 'undefined' ? null : window });
|
const meshFloorSampler = createMeshFloorSampler({
|
||||||
|
groundFloor,
|
||||||
|
eventTarget: typeof window === 'undefined' ? null : window,
|
||||||
|
});
|
||||||
export const { sampleMeshFloorCells, cachedGroundFloor } = meshFloorSampler;
|
export const { sampleMeshFloorCells, cachedGroundFloor } = meshFloorSampler;
|
||||||
@@ -18,10 +18,13 @@ export const AWARENESS_RELATIONSHIP = Object.freeze({
|
|||||||
* @param {{initialRadiusM?: number, maxRadiusM?: number}} [options] Bounds.
|
* @param {{initialRadiusM?: number, maxRadiusM?: number}} [options] Bounds.
|
||||||
* @returns {{candidate: T, radiusM: number}|null} First match and radius used.
|
* @returns {{candidate: T, radiusM: number}|null} First match and radius used.
|
||||||
*/
|
*/
|
||||||
export function findByDoublingRadius(search, {
|
export function findByDoublingRadius(
|
||||||
|
search,
|
||||||
|
{
|
||||||
initialRadiusM = AWARENESS_RADIUS_M,
|
initialRadiusM = AWARENESS_RADIUS_M,
|
||||||
maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
|
maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
|
||||||
} = {}) {
|
} = {},
|
||||||
|
) {
|
||||||
if (typeof search !== 'function') return null;
|
if (typeof search !== 'function') return null;
|
||||||
if (!Number.isFinite(initialRadiusM) || initialRadiusM <= 0) return null;
|
if (!Number.isFinite(initialRadiusM) || initialRadiusM <= 0) return null;
|
||||||
if (!Number.isFinite(maxRadiusM) || maxRadiusM < initialRadiusM) return null;
|
if (!Number.isFinite(maxRadiusM) || maxRadiusM < initialRadiusM) return null;
|
||||||
@@ -39,13 +42,22 @@ export function findByDoublingRadius(search, {
|
|||||||
/** @param {number} meters @returns {string} */
|
/** @param {number} meters @returns {string} */
|
||||||
export function formatAwarenessDistance(meters) {
|
export function formatAwarenessDistance(meters) {
|
||||||
if (!Number.isFinite(meters) || meters < 0) return '—';
|
if (!Number.isFinite(meters) || meters < 0) return '—';
|
||||||
return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
|
return meters < 1000
|
||||||
|
? `${Math.round(meters)} m`
|
||||||
|
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return a stable visible label without leaking missing values into UI text. */
|
/** Return a stable visible label without leaking missing values into UI text. */
|
||||||
export function formatAwarenessLabel(value) {
|
export function formatAwarenessLabel(value) {
|
||||||
if (value && typeof value === 'object') {
|
if (value && typeof value === 'object') {
|
||||||
for (const candidate of [value.callsign, value.name, value.label, value.id, value.icao24, value.mmsi]) {
|
for (const candidate of [
|
||||||
|
value.callsign,
|
||||||
|
value.name,
|
||||||
|
value.label,
|
||||||
|
value.id,
|
||||||
|
value.icao24,
|
||||||
|
value.mmsi,
|
||||||
|
]) {
|
||||||
const label = formatAwarenessLabel(candidate);
|
const label = formatAwarenessLabel(candidate);
|
||||||
if (label !== '—') return label;
|
if (label !== '—') return label;
|
||||||
}
|
}
|
||||||
@@ -61,19 +73,41 @@ export function formatAwarenessLabel(value) {
|
|||||||
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range
|
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range
|
||||||
* claim in this MVP.
|
* claim in this MVP.
|
||||||
*/
|
*/
|
||||||
export function summarizeAwarenessCohort(items, { available = true, stale = false, limit = 3 } = {}) {
|
export function summarizeAwarenessCohort(
|
||||||
if (!available) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed unavailable' };
|
items,
|
||||||
if (stale) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed stale' };
|
{ available = true, stale = false, limit = 3 } = {},
|
||||||
|
) {
|
||||||
|
if (!available)
|
||||||
|
return {
|
||||||
|
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||||
|
count: null,
|
||||||
|
nearest: [],
|
||||||
|
reason: 'feed unavailable',
|
||||||
|
};
|
||||||
|
if (stale)
|
||||||
|
return {
|
||||||
|
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||||
|
count: null,
|
||||||
|
nearest: [],
|
||||||
|
reason: 'feed stale',
|
||||||
|
};
|
||||||
const normalized = (Array.isArray(items) ? items : [])
|
const normalized = (Array.isArray(items) ? items : [])
|
||||||
.filter((item) => Number.isFinite(item?.distanceM ?? item?.distance)
|
.filter(
|
||||||
&& (item.distanceM ?? item.distance) >= 0)
|
(item) =>
|
||||||
|
Number.isFinite(item?.distanceM ?? item?.distance) &&
|
||||||
|
(item.distanceM ?? item.distance) >= 0,
|
||||||
|
)
|
||||||
.map((item) => ({ ...item, distanceM: item.distanceM ?? item.distance }))
|
.map((item) => ({ ...item, distanceM: item.distanceM ?? item.distance }))
|
||||||
.sort((a, b) => a.distanceM - b.distanceM);
|
.sort((a, b) => a.distanceM - b.distanceM);
|
||||||
return {
|
return {
|
||||||
relationship: normalized.length ? AWARENESS_RELATIONSHIP.NEARBY : AWARENESS_RELATIONSHIP.UNKNOWN,
|
relationship: normalized.length
|
||||||
|
? AWARENESS_RELATIONSHIP.NEARBY
|
||||||
|
: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||||
count: normalized.length,
|
count: normalized.length,
|
||||||
nearest: normalized.slice(0, limit),
|
nearest: normalized.slice(0, limit),
|
||||||
reason: normalized.length ? 'observed or mapped nearby context' : 'no observed or mapped objects in current feeds',
|
reason: normalized.length
|
||||||
|
? 'observed or mapped nearby context'
|
||||||
|
: 'no observed or mapped objects in current feeds',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,21 +118,40 @@ export function summarizeAwarenessCohort(items, { available = true, stale = fals
|
|||||||
* @param {Iterable<string>} [visitedKeys] Previously visited layer/id keys.
|
* @param {Iterable<string>} [visitedKeys] Previously visited layer/id keys.
|
||||||
* @returns {Array<{layerId: string, id: string, item: Object}>} Selectable targets.
|
* @returns {Array<{layerId: string, id: string, item: Object}>} Selectable targets.
|
||||||
*/
|
*/
|
||||||
export function getAwarenessNavigationTargets(cohorts, subject = null, visitedKeys = []) {
|
export function getAwarenessNavigationTargets(
|
||||||
|
cohorts,
|
||||||
|
subject = null,
|
||||||
|
visitedKeys = [],
|
||||||
|
) {
|
||||||
const visited = new Set(visitedKeys);
|
const visited = new Set(visitedKeys);
|
||||||
const targets = [];
|
const targets = [];
|
||||||
for (const cohort of Array.isArray(cohorts) ? cohorts : []) {
|
for (const cohort of Array.isArray(cohorts) ? cohorts : []) {
|
||||||
const layerId = cohort?.id;
|
const layerId = cohort?.id;
|
||||||
if (!['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(layerId)) continue;
|
if (
|
||||||
|
![
|
||||||
|
'flights',
|
||||||
|
'military',
|
||||||
|
'ais-live-vessels',
|
||||||
|
'military-installations',
|
||||||
|
].includes(layerId)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
const sourceItems = Array.isArray(cohort?.summary?.navigationNearest)
|
const sourceItems = Array.isArray(cohort?.summary?.navigationNearest)
|
||||||
? cohort.summary.navigationNearest
|
? cohort.summary.navigationNearest
|
||||||
: (Array.isArray(cohort?.summary?.nearest) ? cohort.summary.nearest : []);
|
: Array.isArray(cohort?.summary?.nearest)
|
||||||
|
? cohort.summary.nearest
|
||||||
|
: [];
|
||||||
for (const item of sourceItems) {
|
for (const item of sourceItems) {
|
||||||
const id = item?.icao24 || item?.mmsi || item?.id;
|
const id = item?.icao24 || item?.mmsi || item?.id;
|
||||||
if (!id) continue;
|
if (!id) continue;
|
||||||
const key = `${layerId}:${id}`;
|
const key = `${layerId}:${id}`;
|
||||||
if (key === `${subject?.layerId}:${subject?.id}`) continue;
|
if (key === `${subject?.layerId}:${subject?.id}`) continue;
|
||||||
targets.push({ layerId, id: String(id), item, visited: visited.has(key) });
|
targets.push({
|
||||||
|
layerId,
|
||||||
|
id: String(id),
|
||||||
|
item,
|
||||||
|
visited: visited.has(key),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
targets.sort((a, b) => {
|
targets.sort((a, b) => {
|
||||||
@@ -108,7 +161,9 @@ export function getAwarenessNavigationTargets(cohorts, subject = null, visitedKe
|
|||||||
// reached vessels, installations, or the other flight layer at all.
|
// reached vessels, installations, or the other flight layer at all.
|
||||||
const visitedOrder = Number(a.visited) - Number(b.visited);
|
const visitedOrder = Number(a.visited) - Number(b.visited);
|
||||||
if (visitedOrder) return visitedOrder;
|
if (visitedOrder) return visitedOrder;
|
||||||
const subjectOrder = Number(a.layerId !== subject?.layerId) - Number(b.layerId !== subject?.layerId);
|
const subjectOrder =
|
||||||
|
Number(a.layerId !== subject?.layerId) -
|
||||||
|
Number(b.layerId !== subject?.layerId);
|
||||||
if (subjectOrder) return subjectOrder;
|
if (subjectOrder) return subjectOrder;
|
||||||
return (a.item.distanceM ?? Infinity) - (b.item.distanceM ?? Infinity);
|
return (a.item.distanceM ?? Infinity) - (b.item.distanceM ?? Infinity);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,10 +37,14 @@ const MAX_FOOTPRINT_POINTS = 400;
|
|||||||
* @returns {string} Display label, never empty.
|
* @returns {string} Display label, never empty.
|
||||||
*/
|
*/
|
||||||
export function humanizeInstallationClass(klass) {
|
export function humanizeInstallationClass(klass) {
|
||||||
const key = String(klass || '').trim().toLowerCase();
|
const key = String(klass || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
if (LABEL_BY_CLASS[key]) return LABEL_BY_CLASS[key];
|
if (LABEL_BY_CLASS[key]) return LABEL_BY_CLASS[key];
|
||||||
const words = key.replaceAll('_', ' ').trim();
|
const words = key.replaceAll('_', ' ').trim();
|
||||||
return words ? `${words[0].toUpperCase()}${words.slice(1)}` : 'Mapped installation';
|
return words
|
||||||
|
? `${words[0].toUpperCase()}${words.slice(1)}`
|
||||||
|
: 'Mapped installation';
|
||||||
}
|
}
|
||||||
|
|
||||||
function finiteLatitude(value) {
|
function finiteLatitude(value) {
|
||||||
@@ -54,7 +58,8 @@ function finiteLongitude(value) {
|
|||||||
function pointFrom(element) {
|
function pointFrom(element) {
|
||||||
const lat = Number(element?.lat ?? element?.center?.lat);
|
const lat = Number(element?.lat ?? element?.center?.lat);
|
||||||
const longitude = Number(element?.lon ?? element?.center?.lon);
|
const longitude = Number(element?.lon ?? element?.center?.lon);
|
||||||
if (finiteLatitude(lat) && finiteLongitude(longitude)) return { latitude: lat, longitude };
|
if (finiteLatitude(lat) && finiteLongitude(longitude))
|
||||||
|
return { latitude: lat, longitude };
|
||||||
|
|
||||||
// Bounds midpoint fallback (field test 2026-08-28, Warendorf: nothing drawn).
|
// Bounds midpoint fallback (field test 2026-08-28, Warendorf: nothing drawn).
|
||||||
// The proxy asks for `out center tags geom`, but Overpass takes the LAST
|
// The proxy asks for `out center tags geom`, but Overpass takes the LAST
|
||||||
@@ -67,8 +72,12 @@ function pointFrom(element) {
|
|||||||
// recovers all of them without touching the query or the footprint path.
|
// recovers all of them without touching the query or the footprint path.
|
||||||
const bounds = element?.bounds;
|
const bounds = element?.bounds;
|
||||||
// JSON nulls, booleans, and empty strings must not coerce to a false 0,0 site.
|
// JSON nulls, booleans, and empty strings must not coerce to a false 0,0 site.
|
||||||
if (![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon]
|
if (
|
||||||
.every((value) => typeof value === 'number' && Number.isFinite(value))) return null;
|
![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon].every(
|
||||||
|
(value) => typeof value === 'number' && Number.isFinite(value),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
const south = Number(bounds?.minlat);
|
const south = Number(bounds?.minlat);
|
||||||
const west = Number(bounds?.minlon);
|
const west = Number(bounds?.minlon);
|
||||||
const north = Number(bounds?.maxlat);
|
const north = Number(bounds?.maxlat);
|
||||||
@@ -79,17 +88,28 @@ function pointFrom(element) {
|
|||||||
// A single installation footprint is never wider than the request bbox cap
|
// A single installation footprint is never wider than the request bbox cap
|
||||||
// (10°): an ascending box that spans the antimeridian (minlon -179, maxlon
|
// (10°): an ascending box that spans the antimeridian (minlon -179, maxlon
|
||||||
// 179) passes the ordering check yet midpoints to longitude 0.
|
// 179) passes the ordering check yet midpoints to longitude 0.
|
||||||
if (finiteLatitude(south) && finiteLatitude(north)
|
if (
|
||||||
&& finiteLongitude(west) && finiteLongitude(east)
|
finiteLatitude(south) &&
|
||||||
&& south <= north && west <= east
|
finiteLatitude(north) &&
|
||||||
&& north - south <= 10 && east - west <= 10) {
|
finiteLongitude(west) &&
|
||||||
|
finiteLongitude(east) &&
|
||||||
|
south <= north &&
|
||||||
|
west <= east &&
|
||||||
|
north - south <= 10 &&
|
||||||
|
east - west <= 10
|
||||||
|
) {
|
||||||
return { latitude: (south + north) / 2, longitude: (west + east) / 2 };
|
return { latitude: (south + north) / 2, longitude: (west + east) / 2 };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function footprintFrom(element) {
|
function footprintFrom(element) {
|
||||||
if (!Array.isArray(element?.geometry) || element.geometry.length < 3 || element.geometry.length > MAX_FOOTPRINT_POINTS) return null;
|
if (
|
||||||
|
!Array.isArray(element?.geometry) ||
|
||||||
|
element.geometry.length < 3 ||
|
||||||
|
element.geometry.length > MAX_FOOTPRINT_POINTS
|
||||||
|
)
|
||||||
|
return null;
|
||||||
const points = [];
|
const points = [];
|
||||||
for (const point of element.geometry) {
|
for (const point of element.geometry) {
|
||||||
const latitude = Number(point?.lat);
|
const latitude = Number(point?.lat);
|
||||||
@@ -106,21 +126,30 @@ function footprintFrom(element) {
|
|||||||
* @param {string} [retrievedAt]
|
* @param {string} [retrievedAt]
|
||||||
* @returns {{records: Array, droppedCount: number}}
|
* @returns {{records: Array, droppedCount: number}}
|
||||||
*/
|
*/
|
||||||
export function normalizeMilitaryInstallations(payload, retrievedAt = new Date().toISOString()) {
|
export function normalizeMilitaryInstallations(
|
||||||
|
payload,
|
||||||
|
retrievedAt = new Date().toISOString(),
|
||||||
|
) {
|
||||||
const records = [];
|
const records = [];
|
||||||
const ids = new Set();
|
const ids = new Set();
|
||||||
let droppedCount = 0;
|
let droppedCount = 0;
|
||||||
for (const element of Array.isArray(payload?.elements) ? payload.elements : []) {
|
for (const element of Array.isArray(payload?.elements)
|
||||||
|
? payload.elements
|
||||||
|
: []) {
|
||||||
const type = String(element?.type || '');
|
const type = String(element?.type || '');
|
||||||
const osmId = Number(element?.id);
|
const osmId = Number(element?.id);
|
||||||
if (!['node', 'way', 'relation'].includes(type) || !Number.isSafeInteger(osmId)) {
|
if (
|
||||||
|
!['node', 'way', 'relation'].includes(type) ||
|
||||||
|
!Number.isSafeInteger(osmId)
|
||||||
|
) {
|
||||||
droppedCount += 1;
|
droppedCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const id = `osm:${type}:${osmId}`;
|
const id = `osm:${type}:${osmId}`;
|
||||||
const tags = element?.tags || {};
|
const tags = element?.tags || {};
|
||||||
const klass = CLASS_BY_MILITARY_TAG[String(tags.military || '').toLowerCase()]
|
const klass =
|
||||||
|| (tags.landuse === 'military' ? 'military_land' : null);
|
CLASS_BY_MILITARY_TAG[String(tags.military || '').toLowerCase()] ||
|
||||||
|
(tags.landuse === 'military' ? 'military_land' : null);
|
||||||
const point = pointFrom(element);
|
const point = pointFrom(element);
|
||||||
if (!klass || !point || ids.has(id)) {
|
if (!klass || !point || ids.has(id)) {
|
||||||
droppedCount += 1;
|
droppedCount += 1;
|
||||||
@@ -134,7 +163,9 @@ export function normalizeMilitaryInstallations(payload, retrievedAt = new Date()
|
|||||||
// geometry, while a footprint-less way/relation has unknown extent.
|
// geometry, while a footprint-less way/relation has unknown extent.
|
||||||
osmType: type,
|
osmType: type,
|
||||||
class: klass,
|
class: klass,
|
||||||
name: String(tags.name || tags['name:en'] || '').trim() || humanizeInstallationClass(klass),
|
name:
|
||||||
|
String(tags.name || tags['name:en'] || '').trim() ||
|
||||||
|
humanizeInstallationClass(klass),
|
||||||
...point,
|
...point,
|
||||||
footprint: footprintFrom(element),
|
footprint: footprintFrom(element),
|
||||||
sources: [{ name: 'OpenStreetMap', id: `${type}/${osmId}`, retrievedAt }],
|
sources: [{ name: 'OpenStreetMap', id: `${type}/${osmId}`, retrievedAt }],
|
||||||
@@ -152,6 +183,14 @@ export function isValidInstallationBoundingBox(value) {
|
|||||||
const west = Number(box.west);
|
const west = Number(box.west);
|
||||||
const north = Number(box.north);
|
const north = Number(box.north);
|
||||||
const east = Number(box.east);
|
const east = Number(box.east);
|
||||||
return finiteLatitude(south) && finiteLatitude(north) && finiteLongitude(west) && finiteLongitude(east)
|
return (
|
||||||
&& south < north && west < east && north - south <= 10 && east - west <= 10;
|
finiteLatitude(south) &&
|
||||||
|
finiteLatitude(north) &&
|
||||||
|
finiteLongitude(west) &&
|
||||||
|
finiteLongitude(east) &&
|
||||||
|
south < north &&
|
||||||
|
west < east &&
|
||||||
|
north - south <= 10 &&
|
||||||
|
east - west <= 10
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,9 @@ export const MODEL_TRAIL_ANCHOR_NATIVE = Object.freeze({
|
|||||||
* trail anchor has to ride the same chain or it lands on a different axis than
|
* trail anchor has to ride the same chain or it lands on a different axis than
|
||||||
* the aircraft it is supposed to be attached to. */
|
* the aircraft it is supposed to be attached to. */
|
||||||
const AXIS_CORRECTION = Cesium.Matrix4.multiplyTransformation(
|
const AXIS_CORRECTION = Cesium.Matrix4.multiplyTransformation(
|
||||||
Cesium.Axis.Y_UP_TO_Z_UP, Cesium.Axis.Z_UP_TO_X_UP, new Cesium.Matrix4(),
|
Cesium.Axis.Y_UP_TO_Z_UP,
|
||||||
|
Cesium.Axis.Z_UP_TO_X_UP,
|
||||||
|
new Cesium.Matrix4(),
|
||||||
);
|
);
|
||||||
const _anchorScratch = new Cesium.Cartesian3();
|
const _anchorScratch = new Cesium.Cartesian3();
|
||||||
const _chainScratch = new Cesium.Matrix4();
|
const _chainScratch = new Cesium.Matrix4();
|
||||||
@@ -124,7 +126,11 @@ export function modelAnchorWorld(model, nativeAnchor, result) {
|
|||||||
components || Cesium.Matrix4.IDENTITY,
|
components || Cesium.Matrix4.IDENTITY,
|
||||||
_chainScratch,
|
_chainScratch,
|
||||||
);
|
);
|
||||||
Cesium.Matrix4.multiplyTransformation(_chainScratch, AXIS_CORRECTION, _chainScratch);
|
Cesium.Matrix4.multiplyTransformation(
|
||||||
|
_chainScratch,
|
||||||
|
AXIS_CORRECTION,
|
||||||
|
_chainScratch,
|
||||||
|
);
|
||||||
return Cesium.Matrix4.multiplyByPoint(_chainScratch, _anchorScratch, result);
|
return Cesium.Matrix4.multiplyByPoint(_chainScratch, _anchorScratch, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,9 +226,17 @@ export function trailHeadStart(start, anchor, center, radiusM, result) {
|
|||||||
// a real trail.
|
// a real trail.
|
||||||
if (!anchor || !center || !result) return start;
|
if (!anchor || !center || !result) return start;
|
||||||
if (!Number.isFinite(radiusM) || radiusM <= 0) return start;
|
if (!Number.isFinite(radiusM) || radiusM <= 0) return start;
|
||||||
const startD = Math.hypot(start.x - center.x, start.y - center.y, start.z - center.z);
|
const startD = Math.hypot(
|
||||||
|
start.x - center.x,
|
||||||
|
start.y - center.y,
|
||||||
|
start.z - center.z,
|
||||||
|
);
|
||||||
if (startD >= radiusM) return start;
|
if (startD >= radiusM) return start;
|
||||||
const anchorD = Math.hypot(anchor.x - center.x, anchor.y - center.y, anchor.z - center.z);
|
const anchorD = Math.hypot(
|
||||||
|
anchor.x - center.x,
|
||||||
|
anchor.y - center.y,
|
||||||
|
anchor.z - center.z,
|
||||||
|
);
|
||||||
// A degenerate shell (an anchor at or outside the envelope — no shipped asset
|
// A degenerate shell (an anchor at or outside the envelope — no shipped asset
|
||||||
// has one, since every anchor is a measured hull point) leaves nothing to
|
// has one, since every anchor is a measured hull point) leaves nothing to
|
||||||
// ramp across; fall back to the containment verdict rather than divide by it.
|
// ramp across; fall back to the containment verdict rather than divide by it.
|
||||||
@@ -252,8 +266,20 @@ export function modelVisualAnchor(modelMatrix, nativeCenter, scale, result) {
|
|||||||
const x = (nativeCenter?.[0] || 0) * safeScale;
|
const x = (nativeCenter?.[0] || 0) * safeScale;
|
||||||
const y = (nativeCenter?.[1] || 0) * safeScale;
|
const y = (nativeCenter?.[1] || 0) * safeScale;
|
||||||
const z = (nativeCenter?.[2] || 0) * safeScale;
|
const z = (nativeCenter?.[2] || 0) * safeScale;
|
||||||
result.x = modelMatrix[0] * x + modelMatrix[4] * y + modelMatrix[8] * z + modelMatrix[12];
|
result.x =
|
||||||
result.y = modelMatrix[1] * x + modelMatrix[5] * y + modelMatrix[9] * z + modelMatrix[13];
|
modelMatrix[0] * x +
|
||||||
result.z = modelMatrix[2] * x + modelMatrix[6] * y + modelMatrix[10] * z + modelMatrix[14];
|
modelMatrix[4] * y +
|
||||||
|
modelMatrix[8] * z +
|
||||||
|
modelMatrix[12];
|
||||||
|
result.y =
|
||||||
|
modelMatrix[1] * x +
|
||||||
|
modelMatrix[5] * y +
|
||||||
|
modelMatrix[9] * z +
|
||||||
|
modelMatrix[13];
|
||||||
|
result.z =
|
||||||
|
modelMatrix[2] * x +
|
||||||
|
modelMatrix[6] * y +
|
||||||
|
modelMatrix[10] * z +
|
||||||
|
modelMatrix[14];
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
+102
-27
@@ -29,10 +29,14 @@ export function norm360(deg) {
|
|||||||
export function liftRepeatedGroundFix(newest, candidatePosition, onGround) {
|
export function liftRepeatedGroundFix(newest, candidatePosition, onGround) {
|
||||||
if (!onGround || !newest?.position || !candidatePosition) return false;
|
if (!onGround || !newest?.position || !candidatePosition) return false;
|
||||||
const stored = Cesium.Cartographic.fromCartesian(
|
const stored = Cesium.Cartographic.fromCartesian(
|
||||||
newest.position, Cesium.Ellipsoid.WGS84, _mmCarto,
|
newest.position,
|
||||||
|
Cesium.Ellipsoid.WGS84,
|
||||||
|
_mmCarto,
|
||||||
);
|
);
|
||||||
const candidate = Cesium.Cartographic.fromCartesian(
|
const candidate = Cesium.Cartographic.fromCartesian(
|
||||||
candidatePosition, Cesium.Ellipsoid.WGS84, _mmCandidateCarto,
|
candidatePosition,
|
||||||
|
Cesium.Ellipsoid.WGS84,
|
||||||
|
_mmCandidateCarto,
|
||||||
);
|
);
|
||||||
if (!stored || !candidate || candidate.height <= stored.height) return false;
|
if (!stored || !candidate || candidate.height <= stored.height) return false;
|
||||||
Cesium.Cartesian3.fromRadians(
|
Cesium.Cartesian3.fromRadians(
|
||||||
@@ -62,7 +66,11 @@ export function courseBetweenCartesians(from, to, minChordM = 25) {
|
|||||||
const dy = to.y - from.y;
|
const dy = to.y - from.y;
|
||||||
const dz = to.z - from.z;
|
const dz = to.z - from.z;
|
||||||
if (dx * dx + dy * dy + dz * dz < minChordM * minChordM) return null;
|
if (dx * dx + dy * dy + dz * dz < minChordM * minChordM) return null;
|
||||||
const carto = Cesium.Cartographic.fromCartesian(from, Cesium.Ellipsoid.WGS84, _mmCarto);
|
const carto = Cesium.Cartographic.fromCartesian(
|
||||||
|
from,
|
||||||
|
Cesium.Ellipsoid.WGS84,
|
||||||
|
_mmCarto,
|
||||||
|
);
|
||||||
if (!carto) return null;
|
if (!carto) return null;
|
||||||
const sLat = Math.sin(carto.latitude);
|
const sLat = Math.sin(carto.latitude);
|
||||||
const cLat = Math.cos(carto.latitude);
|
const cLat = Math.cos(carto.latitude);
|
||||||
@@ -110,7 +118,10 @@ export function speedRamp(speedMps) {
|
|||||||
if (!Number.isFinite(speedMps)) return 1;
|
if (!Number.isFinite(speedMps)) return 1;
|
||||||
if (speedMps <= COURSE_TRACK_ONLY_MPS) return 0;
|
if (speedMps <= COURSE_TRACK_ONLY_MPS) return 0;
|
||||||
if (speedMps >= COURSE_CHORD_ONLY_MPS) return 1;
|
if (speedMps >= COURSE_CHORD_ONLY_MPS) return 1;
|
||||||
return (speedMps - COURSE_TRACK_ONLY_MPS) / (COURSE_CHORD_ONLY_MPS - COURSE_TRACK_ONLY_MPS);
|
return (
|
||||||
|
(speedMps - COURSE_TRACK_ONLY_MPS) /
|
||||||
|
(COURSE_CHORD_ONLY_MPS - COURSE_TRACK_ONLY_MPS)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shortest-arc angle lerp: from → to by t (t=0 → from, t=1 → to). Also the
|
/** Shortest-arc angle lerp: from → to by t (t=0 → from, t=1 → to). Also the
|
||||||
@@ -141,10 +152,16 @@ export function displayedKinematics({
|
|||||||
reportedTrackDeg,
|
reportedTrackDeg,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return {
|
return {
|
||||||
speedMps: Number.isFinite(derivedSpeedMps) ? Math.max(0, derivedSpeedMps)
|
speedMps: Number.isFinite(derivedSpeedMps)
|
||||||
: (Number.isFinite(reportedSpeedMps) ? Math.max(0, reportedSpeedMps) : null),
|
? Math.max(0, derivedSpeedMps)
|
||||||
trackDeg: Number.isFinite(derivedTrackDeg) ? derivedTrackDeg
|
: Number.isFinite(reportedSpeedMps)
|
||||||
: (Number.isFinite(reportedTrackDeg) ? reportedTrackDeg : null),
|
? Math.max(0, reportedSpeedMps)
|
||||||
|
: null,
|
||||||
|
trackDeg: Number.isFinite(derivedTrackDeg)
|
||||||
|
? derivedTrackDeg
|
||||||
|
: Number.isFinite(reportedTrackDeg)
|
||||||
|
? reportedTrackDeg
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,10 +184,17 @@ export function staleCoastLimitSeconds({
|
|||||||
maximumSec = 300,
|
maximumSec = 300,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const floor = Math.max(0, Number.isFinite(minimumSec) ? minimumSec : 60);
|
const floor = Math.max(0, Number.isFinite(minimumSec) ? minimumSec : 60);
|
||||||
const ceiling = Math.max(floor, Number.isFinite(maximumSec) ? maximumSec : 300);
|
const ceiling = Math.max(
|
||||||
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(lastContactEpochMs)) return floor;
|
floor,
|
||||||
|
Number.isFinite(maximumSec) ? maximumSec : 300,
|
||||||
|
);
|
||||||
|
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(lastContactEpochMs))
|
||||||
|
return floor;
|
||||||
const contactLeadSec = Math.max(0, (lastContactEpochMs - fixEpochMs) / 1000);
|
const contactLeadSec = Math.max(0, (lastContactEpochMs - fixEpochMs) / 1000);
|
||||||
const grace = Math.max(0, Number.isFinite(contactGraceSec) ? contactGraceSec : 60);
|
const grace = Math.max(
|
||||||
|
0,
|
||||||
|
Number.isFinite(contactGraceSec) ? contactGraceSec : 60,
|
||||||
|
);
|
||||||
return Math.min(ceiling, Math.max(floor, contactLeadSec + grace));
|
return Math.min(ceiling, Math.max(floor, contactLeadSec + grace));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,18 +221,31 @@ export function limitCourseStep(prevDeg, targetDeg, maxDegPerSec, dtSec) {
|
|||||||
* is GPS-vector noise, not a turn (samples without a speed keep the legacy
|
* is GPS-vector noise, not a turn (samples without a speed keep the legacy
|
||||||
* behavior: no way to tell, so they still count).
|
* behavior: no way to tell, so they still count).
|
||||||
*/
|
*/
|
||||||
export function estimateTurnRateDps(samples, noiseFloorDps = 0.4, maxDps = 4, minSpeedMps = 0) {
|
export function estimateTurnRateDps(
|
||||||
|
samples,
|
||||||
|
noiseFloorDps = 0.4,
|
||||||
|
maxDps = 4,
|
||||||
|
minSpeedMps = 0,
|
||||||
|
) {
|
||||||
if (!samples || samples.length < 2) return 0;
|
if (!samples || samples.length < 2) return 0;
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (let i = 1; i < samples.length; i++) {
|
for (let i = 1; i < samples.length; i++) {
|
||||||
const dt = samples[i].tSec - samples[i - 1].tSec;
|
const dt = samples[i].tSec - samples[i - 1].tSec;
|
||||||
if (dt < 2 || dt > 120) continue;
|
if (dt < 2 || dt > 120) continue;
|
||||||
if (!Number.isFinite(samples[i].trackDeg) || !Number.isFinite(samples[i - 1].trackDeg)) continue;
|
if (
|
||||||
|
!Number.isFinite(samples[i].trackDeg) ||
|
||||||
|
!Number.isFinite(samples[i - 1].trackDeg)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
if (minSpeedMps > 0) {
|
if (minSpeedMps > 0) {
|
||||||
const v0 = samples[i - 1].speedMps;
|
const v0 = samples[i - 1].speedMps;
|
||||||
const v1 = samples[i].speedMps;
|
const v1 = samples[i].speedMps;
|
||||||
if ((Number.isFinite(v0) && v0 < minSpeedMps) || (Number.isFinite(v1) && v1 < minSpeedMps)) continue;
|
if (
|
||||||
|
(Number.isFinite(v0) && v0 < minSpeedMps) ||
|
||||||
|
(Number.isFinite(v1) && v1 < minSpeedMps)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
sum += norm180(samples[i].trackDeg - samples[i - 1].trackDeg) / dt;
|
sum += norm180(samples[i].trackDeg - samples[i - 1].trackDeg) / dt;
|
||||||
n += 1;
|
n += 1;
|
||||||
@@ -274,13 +311,31 @@ const _scratchProjectArc = { east: 0, north: 0, endCourseDeg: 0 };
|
|||||||
* @param {number} dtSec - Seconds ahead; 0 or negative returns the start.
|
* @param {number} dtSec - Seconds ahead; 0 or negative returns the start.
|
||||||
* @returns {{lat: number, lon: number}} Projected coordinate, degrees.
|
* @returns {{lat: number, lon: number}} Projected coordinate, degrees.
|
||||||
*/
|
*/
|
||||||
export function projectGroundArcLatLon(lat, lon, courseDeg, speedMps, turnRateDps, dtSec) {
|
export function projectGroundArcLatLon(
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
courseDeg,
|
||||||
|
speedMps,
|
||||||
|
turnRateDps,
|
||||||
|
dtSec,
|
||||||
|
) {
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return { lat, lon };
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return { lat, lon };
|
||||||
if (!Number.isFinite(courseDeg) || !Number.isFinite(speedMps) || !Number.isFinite(dtSec)
|
if (
|
||||||
|| speedMps <= 0 || dtSec <= 0) {
|
!Number.isFinite(courseDeg) ||
|
||||||
|
!Number.isFinite(speedMps) ||
|
||||||
|
!Number.isFinite(dtSec) ||
|
||||||
|
speedMps <= 0 ||
|
||||||
|
dtSec <= 0
|
||||||
|
) {
|
||||||
return { lat, lon };
|
return { lat, lon };
|
||||||
}
|
}
|
||||||
const off = arcOffsetEnu(speedMps, courseDeg, turnRateDps || 0, dtSec, _scratchProjectArc);
|
const off = arcOffsetEnu(
|
||||||
|
speedMps,
|
||||||
|
courseDeg,
|
||||||
|
turnRateDps || 0,
|
||||||
|
dtSec,
|
||||||
|
_scratchProjectArc,
|
||||||
|
);
|
||||||
// Guard the pole: cos(lat) → 0 would send the longitude to infinity.
|
// Guard the pole: cos(lat) → 0 would send the longitude to infinity.
|
||||||
const cosLat = Math.max(Math.cos(lat * DEG), 1e-6);
|
const cosLat = Math.max(Math.cos(lat * DEG), 1e-6);
|
||||||
return {
|
return {
|
||||||
@@ -320,8 +375,15 @@ export function projectGroundArcLatLon(lat, lon, courseDeg, speedMps, turnRateDp
|
|||||||
* @returns {Array<{lat: number, lon: number}>} Polyline, display position first.
|
* @returns {Array<{lat: number, lon: number}>} Polyline, display position first.
|
||||||
*/
|
*/
|
||||||
export function corridorPathLatLon({
|
export function corridorPathLatLon({
|
||||||
extrapolating, displayLat, displayLon, courseDeg, speedMps, turnRateDps = 0,
|
extrapolating,
|
||||||
fixLat, fixLon, lookaheadSec,
|
displayLat,
|
||||||
|
displayLon,
|
||||||
|
courseDeg,
|
||||||
|
speedMps,
|
||||||
|
turnRateDps = 0,
|
||||||
|
fixLat,
|
||||||
|
fixLon,
|
||||||
|
lookaheadSec,
|
||||||
}) {
|
}) {
|
||||||
if (!Number.isFinite(displayLat) || !Number.isFinite(displayLon)) return [];
|
if (!Number.isFinite(displayLat) || !Number.isFinite(displayLon)) return [];
|
||||||
const start = { lat: displayLat, lon: displayLon };
|
const start = { lat: displayLat, lon: displayLon };
|
||||||
@@ -338,9 +400,16 @@ export function corridorPathLatLon({
|
|||||||
const n = Math.max(1, Math.ceil(arcLengthM / CORRIDOR_SAMPLE_SPACING_M));
|
const n = Math.max(1, Math.ceil(arcLengthM / CORRIDOR_SAMPLE_SPACING_M));
|
||||||
const out = [start];
|
const out = [start];
|
||||||
for (let i = 1; i <= n; i++) {
|
for (let i = 1; i <= n; i++) {
|
||||||
out.push(projectGroundArcLatLon(
|
out.push(
|
||||||
displayLat, displayLon, courseDeg, speed, turnRateDps, cappedSec * (i / n),
|
projectGroundArcLatLon(
|
||||||
));
|
displayLat,
|
||||||
|
displayLon,
|
||||||
|
courseDeg,
|
||||||
|
speed,
|
||||||
|
turnRateDps,
|
||||||
|
cappedSec * (i / n),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -385,13 +454,17 @@ const _forwardFixOffset = new Cesium.Cartesian3();
|
|||||||
* velocity: number, track: number}|null} Forward synthetic fix, or null.
|
* velocity: number, track: number}|null} Forward synthetic fix, or null.
|
||||||
*/
|
*/
|
||||||
export function synthesizeForwardKinematicsFix(newest, next = {}) {
|
export function synthesizeForwardKinematicsFix(newest, next = {}) {
|
||||||
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs)) return null;
|
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs))
|
||||||
|
return null;
|
||||||
const startEpochMs = Number.isFinite(newest.epochMs)
|
const startEpochMs = Number.isFinite(newest.epochMs)
|
||||||
? newest.epochMs
|
? newest.epochMs
|
||||||
: Cesium.JulianDate.toDate(newest.time).getTime();
|
: Cesium.JulianDate.toDate(newest.time).getTime();
|
||||||
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs) return null;
|
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs)
|
||||||
|
return null;
|
||||||
|
|
||||||
const previousVelocity = Number.isFinite(newest.velocity) ? Math.max(0, newest.velocity) : 0;
|
const previousVelocity = Number.isFinite(newest.velocity)
|
||||||
|
? Math.max(0, newest.velocity)
|
||||||
|
: 0;
|
||||||
const previousTrack = Number.isFinite(newest.track) ? newest.track : 0;
|
const previousTrack = Number.isFinite(newest.track) ? newest.track : 0;
|
||||||
const dtSec = (next.epochMs - startEpochMs) / 1000;
|
const dtSec = (next.epochMs - startEpochMs) / 1000;
|
||||||
arcOffsetEnu(
|
arcOffsetEnu(
|
||||||
@@ -421,7 +494,9 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
|
|||||||
time: Cesium.JulianDate.fromDate(new Date(next.epochMs)),
|
time: Cesium.JulianDate.fromDate(new Date(next.epochMs)),
|
||||||
epochMs: next.epochMs,
|
epochMs: next.epochMs,
|
||||||
position,
|
position,
|
||||||
velocity: Number.isFinite(next.velocity) ? Math.max(0, next.velocity) : previousVelocity,
|
velocity: Number.isFinite(next.velocity)
|
||||||
|
? Math.max(0, next.velocity)
|
||||||
|
: previousVelocity,
|
||||||
track: Number.isFinite(next.track) ? next.track : previousTrack,
|
track: Number.isFinite(next.track) ? next.track : previousTrack,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,8 @@ export function resolvePickId(picked) {
|
|||||||
if (id === null || id === undefined) return undefined;
|
if (id === null || id === undefined) return undefined;
|
||||||
if (typeof id === 'object') {
|
if (typeof id === 'object') {
|
||||||
// AIS vessel record (id object with .mmsi) or Cesium Entity (.id string)
|
// AIS vessel record (id object with .mmsi) or Cesium Entity (.id string)
|
||||||
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number') return id.mmsi;
|
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number')
|
||||||
|
return id.mmsi;
|
||||||
if (typeof id.id === 'string' || typeof id.id === 'number') return id.id;
|
if (typeof id.id === 'string' || typeof id.id === 'number') return id.id;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -40,7 +41,7 @@ export function resolvePickId(picked) {
|
|||||||
};
|
};
|
||||||
let id = unwrap(picked.id);
|
let id = unwrap(picked.id);
|
||||||
if (id === undefined) id = unwrap(picked.primitive?.id);
|
if (id === undefined) id = unwrap(picked.primitive?.id);
|
||||||
return (typeof id === 'string' || typeof id === 'number') ? String(id) : null;
|
return typeof id === 'string' || typeof id === 'number' ? String(id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+20
-11
@@ -1,6 +1,7 @@
|
|||||||
const RADIO_COUNTRY_MAX_LENGTH = 80;
|
const RADIO_COUNTRY_MAX_LENGTH = 80;
|
||||||
|
|
||||||
const ISO_ALPHA_2_CODES = Object.freeze(`
|
const ISO_ALPHA_2_CODES = Object.freeze(
|
||||||
|
`
|
||||||
AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
|
AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
|
||||||
CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR
|
CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR
|
||||||
GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO
|
GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO
|
||||||
@@ -8,10 +9,14 @@ JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME
|
|||||||
MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO
|
MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO
|
||||||
RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV
|
RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV
|
||||||
TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW
|
TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW
|
||||||
`.trim().split(/\s+/));
|
`
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/),
|
||||||
|
);
|
||||||
|
|
||||||
const ISO_ALPHA_2_SET = new Set(ISO_ALPHA_2_CODES);
|
const ISO_ALPHA_2_SET = new Set(ISO_ALPHA_2_CODES);
|
||||||
const ENGLISH_REGION_NAMES = typeof Intl?.DisplayNames === 'function'
|
const ENGLISH_REGION_NAMES =
|
||||||
|
typeof Intl?.DisplayNames === 'function'
|
||||||
? new Intl.DisplayNames(['en'], { type: 'region' })
|
? new Intl.DisplayNames(['en'], { type: 'region' })
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -29,7 +34,8 @@ function countryKey(value) {
|
|||||||
const COUNTRY_NAME_TO_CODE = new Map();
|
const COUNTRY_NAME_TO_CODE = new Map();
|
||||||
for (const code of ISO_ALPHA_2_CODES) {
|
for (const code of ISO_ALPHA_2_CODES) {
|
||||||
const displayName = ENGLISH_REGION_NAMES?.of(code);
|
const displayName = ENGLISH_REGION_NAMES?.of(code);
|
||||||
if (displayName && displayName !== code) COUNTRY_NAME_TO_CODE.set(countryKey(displayName), code);
|
if (displayName && displayName !== code)
|
||||||
|
COUNTRY_NAME_TO_CODE.set(countryKey(displayName), code);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, code] of Object.entries({
|
for (const [name, code] of Object.entries({
|
||||||
@@ -71,7 +77,8 @@ for (const [name, code] of Object.entries({
|
|||||||
'east timor': 'TL',
|
'east timor': 'TL',
|
||||||
'cabo verde': 'CV',
|
'cabo verde': 'CV',
|
||||||
vatican: 'VA',
|
vatican: 'VA',
|
||||||
})) COUNTRY_NAME_TO_CODE.set(countryKey(name), code);
|
}))
|
||||||
|
COUNTRY_NAME_TO_CODE.set(countryKey(name), code);
|
||||||
|
|
||||||
function canonicalCountryName(code) {
|
function canonicalCountryName(code) {
|
||||||
return ENGLISH_REGION_NAMES?.of(code) || code;
|
return ENGLISH_REGION_NAMES?.of(code) || code;
|
||||||
@@ -90,17 +97,19 @@ export function normalizeRadioCountryInput(value) {
|
|||||||
}
|
}
|
||||||
const trimmed = value.normalize('NFKC').trim().replace(/\s+/g, ' ');
|
const trimmed = value.normalize('NFKC').trim().replace(/\s+/g, ' ');
|
||||||
if (
|
if (
|
||||||
!trimmed
|
!trimmed ||
|
||||||
|| trimmed.length > RADIO_COUNTRY_MAX_LENGTH
|
trimmed.length > RADIO_COUNTRY_MAX_LENGTH ||
|
||||||
|| /[\u0000-\u001f\u007f]/.test(trimmed)
|
/[\u0000-\u001f\u007f]/.test(trimmed) ||
|
||||||
|| !/^[\p{L}\p{M}.&'’()\-\s]+$/u.test(trimmed)
|
!/^[\p{L}\p{M}.&'’()\-\s]+$/u.test(trimmed)
|
||||||
) return Object.freeze({ valid: false, empty: false, code: '', name: '' });
|
)
|
||||||
|
return Object.freeze({ valid: false, empty: false, code: '', name: '' });
|
||||||
|
|
||||||
const possibleCode = trimmed.toUpperCase();
|
const possibleCode = trimmed.toUpperCase();
|
||||||
const code = ISO_ALPHA_2_SET.has(possibleCode)
|
const code = ISO_ALPHA_2_SET.has(possibleCode)
|
||||||
? possibleCode
|
? possibleCode
|
||||||
: COUNTRY_NAME_TO_CODE.get(countryKey(trimmed)) || '';
|
: COUNTRY_NAME_TO_CODE.get(countryKey(trimmed)) || '';
|
||||||
if (!code) return Object.freeze({ valid: false, empty: false, code: '', name: '' });
|
if (!code)
|
||||||
|
return Object.freeze({ valid: false, empty: false, code: '', name: '' });
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
valid: true,
|
valid: true,
|
||||||
empty: false,
|
empty: false,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { applicationServices } from '../services/application.js';
|
|||||||
export * from './regionalModel.js';
|
export * from './regionalModel.js';
|
||||||
/** Fetch a bounded regional brief through the same-origin dev/preview proxy. */
|
/** Fetch a bounded regional brief through the same-origin dev/preview proxy. */
|
||||||
export async function fetchRegionalBrief(latitude, longitude, { signal } = {}) {
|
export async function fetchRegionalBrief(latitude, longitude, { signal } = {}) {
|
||||||
if (![latitude, longitude].every(Number.isFinite)) throw new Error('Valid coordinates are required');
|
if (![latitude, longitude].every(Number.isFinite))
|
||||||
|
throw new Error('Valid coordinates are required');
|
||||||
return applicationServices.regional.getBrief(latitude, longitude, { signal });
|
return applicationServices.regional.getBrief(latitude, longitude, { signal });
|
||||||
}
|
}
|
||||||
+50
-18
@@ -1,13 +1,18 @@
|
|||||||
const MAX_ARTICLES = 5;
|
const MAX_ARTICLES = 5;
|
||||||
|
|
||||||
function cleanText(value, maxLength = 180) {
|
function cleanText(value, maxLength = 180) {
|
||||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxLength);
|
return String(value || '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, maxLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeHttpUrl(value) {
|
function safeHttpUrl(value) {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(String(value || ''));
|
const parsed = new URL(String(value || ''));
|
||||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null;
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||||
|
? parsed.href
|
||||||
|
: null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -17,14 +22,27 @@ function safeHttpUrl(value) {
|
|||||||
export function normalizeRegionalPlace(payload) {
|
export function normalizeRegionalPlace(payload) {
|
||||||
const address = payload?.address || {};
|
const address = payload?.address || {};
|
||||||
const locality = cleanText(
|
const locality = cleanText(
|
||||||
address.city || address.town || address.village || address.municipality
|
address.city ||
|
||||||
|| address.hamlet || address.county,
|
address.town ||
|
||||||
|
address.village ||
|
||||||
|
address.municipality ||
|
||||||
|
address.hamlet ||
|
||||||
|
address.county,
|
||||||
|
90,
|
||||||
|
);
|
||||||
|
const region = cleanText(
|
||||||
|
address.state || address.region || address.county,
|
||||||
90,
|
90,
|
||||||
);
|
);
|
||||||
const region = cleanText(address.state || address.region || address.county, 90);
|
|
||||||
const country = cleanText(address.country, 90);
|
const country = cleanText(address.country, 90);
|
||||||
const label = [locality, region].filter((value, index, values) => value && values.indexOf(value) === index)
|
const label =
|
||||||
.join(', ') || country || cleanText(payload?.display_name, 120);
|
[locality, region]
|
||||||
|
.filter(
|
||||||
|
(value, index, values) => value && values.indexOf(value) === index,
|
||||||
|
)
|
||||||
|
.join(', ') ||
|
||||||
|
country ||
|
||||||
|
cleanText(payload?.display_name, 120);
|
||||||
if (!label) return null;
|
if (!label) return null;
|
||||||
return {
|
return {
|
||||||
label,
|
label,
|
||||||
@@ -51,11 +69,16 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
|
|||||||
const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate);
|
const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate);
|
||||||
const publishedAt = compactDate
|
const publishedAt = compactDate
|
||||||
? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z`
|
? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z`
|
||||||
: Number.isNaN(Date.parse(rawDate)) ? null : new Date(rawDate).toISOString();
|
: Number.isNaN(Date.parse(rawDate))
|
||||||
|
? null
|
||||||
|
: new Date(rawDate).toISOString();
|
||||||
articles.push({
|
articles.push({
|
||||||
title,
|
title,
|
||||||
url,
|
url,
|
||||||
domain: cleanText(row?.domain || new URL(url).hostname.replace(/^www\./, ''), 80),
|
domain: cleanText(
|
||||||
|
row?.domain || new URL(url).hostname.replace(/^www\./, ''),
|
||||||
|
80,
|
||||||
|
),
|
||||||
publishedAt,
|
publishedAt,
|
||||||
sourceCountry: cleanText(row?.sourcecountry, 60) || null,
|
sourceCountry: cleanText(row?.sourcecountry, 60) || null,
|
||||||
});
|
});
|
||||||
@@ -74,11 +97,15 @@ export function normalizeRegionalWeather(payload) {
|
|||||||
};
|
};
|
||||||
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
|
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
|
||||||
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
|
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
|
||||||
const observedRaw = typeof current.time === 'string' && !/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time)
|
const observedRaw =
|
||||||
|
typeof current.time === 'string' &&
|
||||||
|
!/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time)
|
||||||
? `${current.time}Z`
|
? `${current.time}Z`
|
||||||
: current.time;
|
: current.time;
|
||||||
return {
|
return {
|
||||||
observedAt: Number.isNaN(Date.parse(observedRaw)) ? null : new Date(observedRaw).toISOString(),
|
observedAt: Number.isNaN(Date.parse(observedRaw))
|
||||||
|
? null
|
||||||
|
: new Date(observedRaw).toISOString(),
|
||||||
temperatureC: numberOrNull(current.temperature_2m),
|
temperatureC: numberOrNull(current.temperature_2m),
|
||||||
apparentTemperatureC: numberOrNull(current.apparent_temperature),
|
apparentTemperatureC: numberOrNull(current.apparent_temperature),
|
||||||
precipitationMm: numberOrNull(current.precipitation),
|
precipitationMm: numberOrNull(current.precipitation),
|
||||||
@@ -109,14 +136,19 @@ export function weatherCodeLabel(code) {
|
|||||||
|
|
||||||
/** Great-circle distance used to avoid refetching a regional brief every animation frame. */
|
/** Great-circle distance used to avoid refetching a regional brief every animation frame. */
|
||||||
export function regionalDistanceM(from, to) {
|
export function regionalDistanceM(from, to) {
|
||||||
if (![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(Number.isFinite)) {
|
if (
|
||||||
|
![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(
|
||||||
|
Number.isFinite,
|
||||||
|
)
|
||||||
|
) {
|
||||||
return Infinity;
|
return Infinity;
|
||||||
}
|
}
|
||||||
const phi1 = from.latitude * Math.PI / 180;
|
const phi1 = (from.latitude * Math.PI) / 180;
|
||||||
const phi2 = to.latitude * Math.PI / 180;
|
const phi2 = (to.latitude * Math.PI) / 180;
|
||||||
const deltaPhi = (to.latitude - from.latitude) * Math.PI / 180;
|
const deltaPhi = ((to.latitude - from.latitude) * Math.PI) / 180;
|
||||||
const deltaLambda = (to.longitude - from.longitude) * Math.PI / 180;
|
const deltaLambda = ((to.longitude - from.longitude) * Math.PI) / 180;
|
||||||
const a = Math.sin(deltaPhi / 2) ** 2
|
const a =
|
||||||
+ Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
|
Math.sin(deltaPhi / 2) ** 2 +
|
||||||
|
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
|
||||||
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,13 @@
|
|||||||
* none of the above are usable — the caller applies its OWN existing sticky/default
|
* none of the above are usable — the caller applies its OWN existing sticky/default
|
||||||
* fallback (this helper never invents one, so it can't drift from the caller's policy).
|
* fallback (this helper never invents one, so it can't drift from the caller's policy).
|
||||||
*/
|
*/
|
||||||
export function pickRenderAltitudeM({ geoAltM, baroAltM, onGround, surfaceM, geoidN }) {
|
export function pickRenderAltitudeM({
|
||||||
|
geoAltM,
|
||||||
|
baroAltM,
|
||||||
|
onGround,
|
||||||
|
surfaceM,
|
||||||
|
geoidN,
|
||||||
|
}) {
|
||||||
if (onGround && Number.isFinite(surfaceM)) {
|
if (onGround && Number.isFinite(surfaceM)) {
|
||||||
return surfaceM;
|
return surfaceM;
|
||||||
}
|
}
|
||||||
@@ -89,7 +95,12 @@ export function reuseGroundedSurfaceM(currentM, previousM) {
|
|||||||
* @param {number|null|undefined} params.geoidN - Geoid undulation N here.
|
* @param {number|null|undefined} params.geoidN - Geoid undulation N here.
|
||||||
* @returns {number|null} The geoid guess, or null when it must not be used.
|
* @returns {number|null} The geoid guess, or null when it must not be used.
|
||||||
*/
|
*/
|
||||||
export function geoidSurfaceLastResortM({ geoAltM, baroAltM, priorRenderM, geoidN }) {
|
export function geoidSurfaceLastResortM({
|
||||||
|
geoAltM,
|
||||||
|
baroAltM,
|
||||||
|
priorRenderM,
|
||||||
|
geoidN,
|
||||||
|
}) {
|
||||||
if (Number.isFinite(geoAltM) || Number.isFinite(baroAltM)) return null;
|
if (Number.isFinite(geoAltM) || Number.isFinite(baroAltM)) return null;
|
||||||
if (Number.isFinite(priorRenderM)) return null;
|
if (Number.isFinite(priorRenderM)) return null;
|
||||||
return Number.isFinite(geoidN) ? geoidN : null;
|
return Number.isFinite(geoidN) ? geoidN : null;
|
||||||
|
|||||||
@@ -34,11 +34,14 @@ export const RETRY_COOLDOWN_MAX_MS = 300_000;
|
|||||||
* @param {() => number} [options.now] - Clock seam for tests.
|
* @param {() => number} [options.now] - Clock seam for tests.
|
||||||
* @returns {() => Promise<T>} Memoized loader; retries once the cooldown elapses.
|
* @returns {() => Promise<T>} Memoized loader; retries once the cooldown elapses.
|
||||||
*/
|
*/
|
||||||
export function createRetryableLoader(load, {
|
export function createRetryableLoader(
|
||||||
|
load,
|
||||||
|
{
|
||||||
cooldownMs = RETRY_COOLDOWN_MS,
|
cooldownMs = RETRY_COOLDOWN_MS,
|
||||||
maxCooldownMs = RETRY_COOLDOWN_MAX_MS,
|
maxCooldownMs = RETRY_COOLDOWN_MAX_MS,
|
||||||
now = () => Date.now(),
|
now = () => Date.now(),
|
||||||
} = {}) {
|
} = {},
|
||||||
|
) {
|
||||||
/** @type {Promise<T>|null} */
|
/** @type {Promise<T>|null} */
|
||||||
let inflight = null;
|
let inflight = null;
|
||||||
/** @type {*} Last rejection, replayed to callers inside the cooldown. */
|
/** @type {*} Last rejection, replayed to callers inside the cooldown. */
|
||||||
@@ -51,7 +54,10 @@ export function createRetryableLoader(load, {
|
|||||||
return function loadOnce() {
|
return function loadOnce() {
|
||||||
if (inflight) return inflight;
|
if (inflight) return inflight;
|
||||||
if (hasFailure) {
|
if (hasFailure) {
|
||||||
const wait = Math.min(cooldownMs * 2 ** (consecutiveFailures - 1), maxCooldownMs);
|
const wait = Math.min(
|
||||||
|
cooldownMs * 2 ** (consecutiveFailures - 1),
|
||||||
|
maxCooldownMs,
|
||||||
|
);
|
||||||
// Replay the recorded reason rather than re-running a loader that is
|
// Replay the recorded reason rather than re-running a loader that is
|
||||||
// probably still broken. Callers see identical behavior either way.
|
// probably still broken. Callers see identical behavior either way.
|
||||||
if (now() - failedAt < wait) return Promise.reject(failure);
|
if (now() - failedAt < wait) return Promise.reject(failure);
|
||||||
|
|||||||
+42
-10
@@ -19,7 +19,8 @@ export function greatCircleKm(lat1, lon1, lat2, lon2) {
|
|||||||
const p2 = lat2 * D2R;
|
const p2 = lat2 * D2R;
|
||||||
const dp = (lat2 - lat1) * D2R;
|
const dp = (lat2 - lat1) * D2R;
|
||||||
const dl = (lon2 - lon1) * D2R;
|
const dl = (lon2 - lon1) * D2R;
|
||||||
const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
const a =
|
||||||
|
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||||
return R_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
return R_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +29,8 @@ function bearingRad(lat1, lon1, lat2, lon2) {
|
|||||||
const p2 = lat2 * D2R;
|
const p2 = lat2 * D2R;
|
||||||
const dl = (lon2 - lon1) * D2R;
|
const dl = (lon2 - lon1) * D2R;
|
||||||
const y = Math.sin(dl) * Math.cos(p2);
|
const y = Math.sin(dl) * Math.cos(p2);
|
||||||
const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
|
const x =
|
||||||
|
Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
|
||||||
return Math.atan2(y, x);
|
return Math.atan2(y, x);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,16 +57,35 @@ const LOCAL_AIRPORT_KM = 150;
|
|||||||
* @param {{lat:number|null, lon:number|null}|null} [p.destination]
|
* @param {{lat:number|null, lon:number|null}|null} [p.destination]
|
||||||
* @returns {boolean} false ONLY when the route is confidently wrong.
|
* @returns {boolean} false ONLY when the route is confidently wrong.
|
||||||
*/
|
*/
|
||||||
export function routePlausible({ latDeg, lonDeg, altitudeM = null, verticalRateMps = null, origin = null, destination = null }) {
|
export function routePlausible({
|
||||||
|
latDeg,
|
||||||
|
lonDeg,
|
||||||
|
altitudeM = null,
|
||||||
|
verticalRateMps = null,
|
||||||
|
origin = null,
|
||||||
|
destination = null,
|
||||||
|
}) {
|
||||||
const haveO = Number.isFinite(origin?.lat) && Number.isFinite(origin?.lon);
|
const haveO = Number.isFinite(origin?.lat) && Number.isFinite(origin?.lon);
|
||||||
const haveD = Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
|
const haveD =
|
||||||
|
Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
|
||||||
if (!haveO && !haveD) return true; // no coordinates — cannot judge, do not hide
|
if (!haveO && !haveD) return true; // no coordinates — cannot judge, do not hide
|
||||||
|
|
||||||
// (a) Geographic consistency: near an endpoint, or roughly on the path.
|
// (a) Geographic consistency: near an endpoint, or roughly on the path.
|
||||||
const near = (pt) => greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM;
|
const near = (pt) =>
|
||||||
|
greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM;
|
||||||
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
|
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
|
||||||
if (!geomOk && haveO && haveD) {
|
if (!geomOk && haveO && haveD) {
|
||||||
geomOk = Math.abs(crossTrackKm(latDeg, lonDeg, origin.lat, origin.lon, destination.lat, destination.lon)) < CROSS_TRACK_KM;
|
geomOk =
|
||||||
|
Math.abs(
|
||||||
|
crossTrackKm(
|
||||||
|
latDeg,
|
||||||
|
lonDeg,
|
||||||
|
origin.lat,
|
||||||
|
origin.lon,
|
||||||
|
destination.lat,
|
||||||
|
destination.lon,
|
||||||
|
),
|
||||||
|
) < CROSS_TRACK_KM;
|
||||||
} else if (!geomOk) {
|
} else if (!geomOk) {
|
||||||
geomOk = true; // only one endpoint known and not near — cannot judge, allow
|
geomOk = true; // only one endpoint known and not near — cannot judge, allow
|
||||||
}
|
}
|
||||||
@@ -72,13 +93,24 @@ export function routePlausible({ latDeg, lonDeg, altitudeM = null, verticalRateM
|
|||||||
|
|
||||||
// (b) Vertical-trend consistency for low traffic (observer-free adaptation).
|
// (b) Vertical-trend consistency for low traffic (observer-free adaptation).
|
||||||
if (
|
if (
|
||||||
Number.isFinite(altitudeM) && altitudeM < LOW_ALT_M &&
|
Number.isFinite(altitudeM) &&
|
||||||
Number.isFinite(verticalRateMps) && Math.abs(verticalRateMps) > VERT_TREND_MPS
|
altitudeM < LOW_ALT_M &&
|
||||||
|
Number.isFinite(verticalRateMps) &&
|
||||||
|
Math.abs(verticalRateMps) > VERT_TREND_MPS
|
||||||
) {
|
) {
|
||||||
if (verticalRateMps > 0) {
|
if (verticalRateMps > 0) {
|
||||||
if (haveO && greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM) return false; // departing — origin should be local
|
if (
|
||||||
|
haveO &&
|
||||||
|
greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM
|
||||||
|
)
|
||||||
|
return false; // departing — origin should be local
|
||||||
} else {
|
} else {
|
||||||
if (haveD && greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) > LOCAL_AIRPORT_KM) return false; // arriving — destination should be local
|
if (
|
||||||
|
haveD &&
|
||||||
|
greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) >
|
||||||
|
LOCAL_AIRPORT_KM
|
||||||
|
)
|
||||||
|
return false; // arriving — destination should be local
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -69,7 +69,13 @@ export const SATELLITE_CLASSES = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Legend/report order for the classes above. */
|
/** Legend/report order for the classes above. */
|
||||||
export const SATELLITE_CLASS_ORDER = Object.freeze(['station', 'nav', 'geo', 'visual', 'comms']);
|
export const SATELLITE_CLASS_ORDER = Object.freeze([
|
||||||
|
'station',
|
||||||
|
'nav',
|
||||||
|
'geo',
|
||||||
|
'visual',
|
||||||
|
'comms',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CelesTrak group tag → { class, subtype }. Subtype names the specific
|
* CelesTrak group tag → { class, subtype }. Subtype names the specific
|
||||||
@@ -150,8 +156,11 @@ export function satelliteClassLabel(group, { isIss = false } = {}) {
|
|||||||
export function tallySatelliteClasses(entries) {
|
export function tallySatelliteClasses(entries) {
|
||||||
const counts = Object.create(null);
|
const counts = Object.create(null);
|
||||||
for (const entry of entries || []) {
|
for (const entry of entries || []) {
|
||||||
const descriptor = entry && typeof entry === 'object' ? entry : { group: entry };
|
const descriptor =
|
||||||
const { klass } = satelliteClassOf(descriptor.group, { isIss: descriptor.isIss });
|
entry && typeof entry === 'object' ? entry : { group: entry };
|
||||||
|
const { klass } = satelliteClassOf(descriptor.group, {
|
||||||
|
isIss: descriptor.isIss,
|
||||||
|
});
|
||||||
counts[klass] = (counts[klass] || 0) + 1;
|
counts[klass] = (counts[klass] || 0) + 1;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
@@ -171,7 +180,13 @@ export function satelliteClassLegend(counts) {
|
|||||||
const count = counts?.[klass];
|
const count = counts?.[klass];
|
||||||
if (!(count > 0)) continue;
|
if (!(count > 0)) continue;
|
||||||
const spec = SATELLITE_CLASSES[klass];
|
const spec = SATELLITE_CLASSES[klass];
|
||||||
result.push({ klass, label: spec.label, color: spec.color, blurb: spec.blurb, count });
|
result.push({
|
||||||
|
klass,
|
||||||
|
label: spec.label,
|
||||||
|
color: spec.color,
|
||||||
|
blurb: spec.blurb,
|
||||||
|
count,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,8 @@ const MAX_PICK_MAGNITUDE_M = 1_000_000_000;
|
|||||||
export function isPickedWorldPosition(position) {
|
export function isPickedWorldPosition(position) {
|
||||||
if (!position) return false;
|
if (!position) return false;
|
||||||
const { x, y, z } = position;
|
const { x, y, z } = position;
|
||||||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return false;
|
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
|
||||||
|
return false;
|
||||||
const magnitude = Math.hypot(x, y, z);
|
const magnitude = Math.hypot(x, y, z);
|
||||||
return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M;
|
return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M;
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,8 @@ export function restoreSpriteOrder(viewer) {
|
|||||||
if (!viewer || viewer.isDestroyed?.()) return;
|
if (!viewer || viewer.isDestroyed?.()) return;
|
||||||
const scene = viewer.scene;
|
const scene = viewer.scene;
|
||||||
const primitives = scene?.primitives;
|
const primitives = scene?.primitives;
|
||||||
if (!primitives || scene.isDestroyed?.() || primitives.isDestroyed?.()) return;
|
if (!primitives || scene.isDestroyed?.() || primitives.isDestroyed?.())
|
||||||
|
return;
|
||||||
|
|
||||||
for (const layerId of SPRITE_LAYER_ORDER) {
|
for (const layerId of SPRITE_LAYER_ORDER) {
|
||||||
const collection = _collections.get(layerId);
|
const collection = _collections.get(layerId);
|
||||||
@@ -71,6 +72,7 @@ export function restoreSpriteOrderOnEnable(
|
|||||||
viewer,
|
viewer,
|
||||||
restore = restoreSpriteOrder,
|
restore = restoreSpriteOrder,
|
||||||
) {
|
) {
|
||||||
if (!SPRITE_LAYER_ORDER.includes(layerId) || typeof restore !== 'function') return;
|
if (!SPRITE_LAYER_ORDER.includes(layerId) || typeof restore !== 'function')
|
||||||
|
return;
|
||||||
restore(viewer);
|
restore(viewer);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import { createTerrainHeights } from '../services/terrainHeights.js';
|
import { createTerrainHeights } from '../services/terrainHeights.js';
|
||||||
import { applicationServices } from '../services/application.js';
|
import { applicationServices } from '../services/application.js';
|
||||||
const terrainHeights = createTerrainHeights({ source: applicationServices.terrain });
|
const terrainHeights = createTerrainHeights({
|
||||||
export const { cachedEllipsoidalGround, cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrainHeights;
|
source: applicationServices.terrain,
|
||||||
|
});
|
||||||
|
export const {
|
||||||
|
cachedEllipsoidalGround,
|
||||||
|
cachedRealEllipsoidalGround,
|
||||||
|
resolveEllipsoidalGround,
|
||||||
|
} = terrainHeights;
|
||||||
@@ -38,7 +38,9 @@ export const TR3B_CLASS = 'tr3b';
|
|||||||
|
|
||||||
/** @param {*} id Raw contact id. @returns {string} Normalized registry key. */
|
/** @param {*} id Raw contact id. @returns {string} Normalized registry key. */
|
||||||
function key(id) {
|
function key(id) {
|
||||||
return String(id ?? '').trim().toLowerCase();
|
return String(id ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+35
-17
@@ -26,17 +26,23 @@ export function trackedModelScaleForPixelCap({
|
|||||||
maximumPixelSize,
|
maximumPixelSize,
|
||||||
}) {
|
}) {
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(baseScale) || baseScale <= 0
|
!Number.isFinite(baseScale) ||
|
||||||
|| !Number.isFinite(nativeRadiusM) || nativeRadiusM <= 0
|
baseScale <= 0 ||
|
||||||
|| !Number.isFinite(rangeM) || rangeM <= 0
|
!Number.isFinite(nativeRadiusM) ||
|
||||||
|| !Number.isFinite(viewportHeightPx) || viewportHeightPx <= 0
|
nativeRadiusM <= 0 ||
|
||||||
|| !Number.isFinite(fovyRad) || fovyRad <= 0
|
!Number.isFinite(rangeM) ||
|
||||||
|| !Number.isFinite(maximumPixelSize) || maximumPixelSize <= 0
|
rangeM <= 0 ||
|
||||||
) return baseScale;
|
!Number.isFinite(viewportHeightPx) ||
|
||||||
|
viewportHeightPx <= 0 ||
|
||||||
|
!Number.isFinite(fovyRad) ||
|
||||||
|
fovyRad <= 0 ||
|
||||||
|
!Number.isFinite(maximumPixelSize) ||
|
||||||
|
maximumPixelSize <= 0
|
||||||
|
)
|
||||||
|
return baseScale;
|
||||||
const focalLengthPx = viewportHeightPx / (2 * Math.tan(fovyRad / 2));
|
const focalLengthPx = viewportHeightPx / (2 * Math.tan(fovyRad / 2));
|
||||||
const projectedDiameterPx = (
|
const projectedDiameterPx =
|
||||||
2 * nativeRadiusM * baseScale * focalLengthPx
|
(2 * nativeRadiusM * baseScale * focalLengthPx) / rangeM;
|
||||||
) / rangeM;
|
|
||||||
if (projectedDiameterPx <= maximumPixelSize) return baseScale;
|
if (projectedDiameterPx <= maximumPixelSize) return baseScale;
|
||||||
return baseScale * (maximumPixelSize / projectedDiameterPx);
|
return baseScale * (maximumPixelSize / projectedDiameterPx);
|
||||||
}
|
}
|
||||||
@@ -99,17 +105,25 @@ export function clampTrackedCameraPosition(
|
|||||||
previousPosition,
|
previousPosition,
|
||||||
minimumRangeM = MIN_TRACKED_RANGE_M,
|
minimumRangeM = MIN_TRACKED_RANGE_M,
|
||||||
) {
|
) {
|
||||||
const crossedOrigin = Cesium.Cartesian3.dot(camera.position, previousPosition) <= 0;
|
const crossedOrigin =
|
||||||
const forwardDistance = -Cesium.Cartesian3.dot(camera.position, camera.direction);
|
Cesium.Cartesian3.dot(camera.position, previousPosition) <= 0;
|
||||||
|
const forwardDistance = -Cesium.Cartesian3.dot(
|
||||||
|
camera.position,
|
||||||
|
camera.direction,
|
||||||
|
);
|
||||||
const rangeSquared = Cesium.Cartesian3.magnitudeSquared(camera.position);
|
const rangeSquared = Cesium.Cartesian3.magnitudeSquared(camera.position);
|
||||||
if (crossedOrigin) {
|
if (crossedOrigin) {
|
||||||
Cesium.Cartesian3.normalize(previousPosition, camera.position);
|
Cesium.Cartesian3.normalize(previousPosition, camera.position);
|
||||||
Cesium.Cartesian3.multiplyByScalar(camera.position, minimumRangeM, camera.position);
|
Cesium.Cartesian3.multiplyByScalar(
|
||||||
|
camera.position,
|
||||||
|
minimumRangeM,
|
||||||
|
camera.position,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
forwardDistance < minimumRangeM
|
forwardDistance < minimumRangeM ||
|
||||||
|| rangeSquared < minimumRangeM * minimumRangeM
|
rangeSquared < minimumRangeM * minimumRangeM
|
||||||
) {
|
) {
|
||||||
Cesium.Cartesian3.multiplyByScalar(
|
Cesium.Cartesian3.multiplyByScalar(
|
||||||
camera.direction,
|
camera.direction,
|
||||||
@@ -192,10 +206,14 @@ export function applyTrackedCameraFrame(viewer, entity, viewFrom) {
|
|||||||
Cesium.Ellipsoid.WGS84,
|
Cesium.Ellipsoid.WGS84,
|
||||||
trackedTransform,
|
trackedTransform,
|
||||||
);
|
);
|
||||||
const offset = typeof viewFrom.getValue === 'function'
|
const offset =
|
||||||
|
typeof viewFrom.getValue === 'function'
|
||||||
? viewFrom.getValue(viewer.clock.currentTime, resolvedViewFrom)
|
? viewFrom.getValue(viewer.clock.currentTime, resolvedViewFrom)
|
||||||
: Cesium.Cartesian3.clone(viewFrom, resolvedViewFrom);
|
: Cesium.Cartesian3.clone(viewFrom, resolvedViewFrom);
|
||||||
if (!offset || Cesium.Cartesian3.magnitudeSquared(offset) < Cesium.Math.EPSILON12) {
|
if (
|
||||||
|
!offset ||
|
||||||
|
Cesium.Cartesian3.magnitudeSquared(offset) < Cesium.Math.EPSILON12
|
||||||
|
) {
|
||||||
stop();
|
stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,5 +76,8 @@ export const TRACKED_MODEL_EXIT_ALT_M =
|
|||||||
*/
|
*/
|
||||||
export function trackedModelZoomActive(cameraHeightM, wasActive = false) {
|
export function trackedModelZoomActive(cameraHeightM, wasActive = false) {
|
||||||
if (!Number.isFinite(cameraHeightM)) return false;
|
if (!Number.isFinite(cameraHeightM)) return false;
|
||||||
return cameraHeightM < (wasActive ? TRACKED_MODEL_EXIT_ALT_M : TRACKED_MODEL_ENTER_ALT_M);
|
return (
|
||||||
|
cameraHeightM <
|
||||||
|
(wasActive ? TRACKED_MODEL_EXIT_ALT_M : TRACKED_MODEL_ENTER_ALT_M)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
+54
-16
@@ -49,15 +49,22 @@ let _overlayHost = DEFAULT_TRACKED_OVERLAY_HOST;
|
|||||||
* @param {string} accent Source-owned accent color.
|
* @param {string} accent Source-owned accent color.
|
||||||
* @returns {{title:string,details:string[],accent:string}}
|
* @returns {{title:string,details:string[],accent:string}}
|
||||||
*/
|
*/
|
||||||
export function trackedLabelModelFromText(text, accent = WORLD_OVERLAY_STYLE.accent) {
|
export function trackedLabelModelFromText(
|
||||||
|
text,
|
||||||
|
accent = WORLD_OVERLAY_STYLE.accent,
|
||||||
|
) {
|
||||||
const raw = String(text || '').trim();
|
const raw = String(text || '').trim();
|
||||||
if (!raw) return { title: '', details: [], accent };
|
if (!raw) return { title: '', details: [], accent };
|
||||||
let lines;
|
let lines;
|
||||||
if (raw.includes('\n')) {
|
if (raw.includes('\n')) {
|
||||||
lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
|
lines = raw
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
} else {
|
} else {
|
||||||
const parts = raw.split(' · ');
|
const parts = raw.split(' · ');
|
||||||
lines = parts.length > 1
|
lines =
|
||||||
|
parts.length > 1
|
||||||
? [parts[0].trim(), parts.slice(1).join(' · ').trim()]
|
? [parts[0].trim(), parts.slice(1).join(' · ').trim()]
|
||||||
: [raw];
|
: [raw];
|
||||||
}
|
}
|
||||||
@@ -101,7 +108,9 @@ export function cachedTrackedVisualPosition(entity) {
|
|||||||
try {
|
try {
|
||||||
const visual = entity.gevVisualPosition();
|
const visual = entity.gevVisualPosition();
|
||||||
if (visual) return visual;
|
if (visual) return visual;
|
||||||
} catch { /* fall through to the display position */ }
|
} catch {
|
||||||
|
/* fall through to the display position */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return cachedTrackedDisplayPosition(entity);
|
return cachedTrackedDisplayPosition(entity);
|
||||||
}
|
}
|
||||||
@@ -140,7 +149,9 @@ export function createTrackedOverlayEntry(entity) {
|
|||||||
collisionGroup: 'ambient-card',
|
collisionGroup: 'ambient-card',
|
||||||
priority: Number.MAX_SAFE_INTEGER,
|
priority: Number.MAX_SAFE_INTEGER,
|
||||||
title,
|
title,
|
||||||
details: Array.isArray(model.details) ? model.details.map((line) => String(line)) : [],
|
details: Array.isArray(model.details)
|
||||||
|
? model.details.map((line) => String(line))
|
||||||
|
: [],
|
||||||
accent: model.accent || WORLD_OVERLAY_STYLE.accent,
|
accent: model.accent || WORLD_OVERLAY_STYLE.accent,
|
||||||
cardStyle: model.cardStyle,
|
cardStyle: model.cardStyle,
|
||||||
selected: model.selected === true,
|
selected: model.selected === true,
|
||||||
@@ -149,9 +160,14 @@ export function createTrackedOverlayEntry(entity) {
|
|||||||
leaderAnimationStartedAt: model.leaderAnimationStartedAt,
|
leaderAnimationStartedAt: model.leaderAnimationStartedAt,
|
||||||
leaderDrawRatio: model.leaderDrawRatio,
|
leaderDrawRatio: model.leaderDrawRatio,
|
||||||
anchorRadiusPx: Number.isFinite(Number(model.anchorRadiusPx))
|
anchorRadiusPx: Number.isFinite(Number(model.anchorRadiusPx))
|
||||||
? Math.max(0, Number(model.anchorRadiusPx)) : 10,
|
? Math.max(0, Number(model.anchorRadiusPx))
|
||||||
anchorRadiusScale: Object.prototype.hasOwnProperty.call(model, 'anchorRadiusScale')
|
: 10,
|
||||||
? model.anchorRadiusScale : TRACKED_BILLBOARD_SCALE,
|
anchorRadiusScale: Object.prototype.hasOwnProperty.call(
|
||||||
|
model,
|
||||||
|
'anchorRadiusScale',
|
||||||
|
)
|
||||||
|
? model.anchorRadiusScale
|
||||||
|
: TRACKED_BILLBOARD_SCALE,
|
||||||
minAnchorGapPx: 16,
|
minAnchorGapPx: 16,
|
||||||
anchorGapPaddingPx: 10,
|
anchorGapPaddingPx: 10,
|
||||||
verticalOnly: true,
|
verticalOnly: true,
|
||||||
@@ -203,7 +219,10 @@ export function getActiveTrackedReadoutId() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Static-context layers whose click selection publishes the readout card. */
|
/** Static-context layers whose click selection publishes the readout card. */
|
||||||
const READOUT_CONTEXT_LAYERS = new Set(['military-installations', 'alpr-cameras']);
|
const READOUT_CONTEXT_LAYERS = new Set([
|
||||||
|
'military-installations',
|
||||||
|
'alpr-cameras',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the model bridge and selection listeners. No render listener is
|
* Initialize the model bridge and selection listeners. No render listener is
|
||||||
@@ -215,7 +234,8 @@ export function initTrackedReadout(viewer) {
|
|||||||
if (_viewer) destroyTrackedReadout();
|
if (_viewer) destroyTrackedReadout();
|
||||||
_viewer = viewer;
|
_viewer = viewer;
|
||||||
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true);
|
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true);
|
||||||
_trackedEntityChangedRemove = viewer.trackedEntityChanged?.addEventListener?.(() => {
|
_trackedEntityChangedRemove =
|
||||||
|
viewer.trackedEntityChanged?.addEventListener?.(() => {
|
||||||
if (viewer.trackedEntity) _selectedContext = null;
|
if (viewer.trackedEntity) _selectedContext = null;
|
||||||
syncActiveEntity();
|
syncActiveEntity();
|
||||||
}) || null;
|
}) || null;
|
||||||
@@ -232,7 +252,10 @@ export function initTrackedReadout(viewer) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
_contextClearedHandler = (event) => {
|
_contextClearedHandler = (event) => {
|
||||||
if (!_selectedContext || event.detail?.layerId === _selectedContext.layerId) {
|
if (
|
||||||
|
!_selectedContext ||
|
||||||
|
event.detail?.layerId === _selectedContext.layerId
|
||||||
|
) {
|
||||||
_selectedContext = null;
|
_selectedContext = null;
|
||||||
syncActiveEntity();
|
syncActiveEntity();
|
||||||
}
|
}
|
||||||
@@ -242,8 +265,14 @@ export function initTrackedReadout(viewer) {
|
|||||||
if (!_viewer?.trackedEntity) clearTrackedSource();
|
if (!_viewer?.trackedEntity) clearTrackedSource();
|
||||||
};
|
};
|
||||||
window.addEventListener('gev:entity-selected', _contextSelectedHandler);
|
window.addEventListener('gev:entity-selected', _contextSelectedHandler);
|
||||||
window.addEventListener('gev:entity-selection-cleared', _contextClearedHandler);
|
window.addEventListener(
|
||||||
window.addEventListener('gev:awareness-subject-selected', _aircraftSelectedHandler);
|
'gev:entity-selection-cleared',
|
||||||
|
_contextClearedHandler,
|
||||||
|
);
|
||||||
|
window.addEventListener(
|
||||||
|
'gev:awareness-subject-selected',
|
||||||
|
_aircraftSelectedHandler,
|
||||||
|
);
|
||||||
syncActiveEntity();
|
syncActiveEntity();
|
||||||
// Boot-verification contract: the track regression harness asserts this
|
// Boot-verification contract: the track regression harness asserts this
|
||||||
// exact line at init (dropped by the host migration; restored).
|
// exact line at init (dropped by the host migration; restored).
|
||||||
@@ -254,9 +283,18 @@ export function initTrackedReadout(viewer) {
|
|||||||
export function destroyTrackedReadout() {
|
export function destroyTrackedReadout() {
|
||||||
_trackedEntityChangedRemove?.();
|
_trackedEntityChangedRemove?.();
|
||||||
_trackedEntityChangedRemove = null;
|
_trackedEntityChangedRemove = null;
|
||||||
if (_contextSelectedHandler) window.removeEventListener('gev:entity-selected', _contextSelectedHandler);
|
if (_contextSelectedHandler)
|
||||||
if (_contextClearedHandler) window.removeEventListener('gev:entity-selection-cleared', _contextClearedHandler);
|
window.removeEventListener('gev:entity-selected', _contextSelectedHandler);
|
||||||
if (_aircraftSelectedHandler) window.removeEventListener('gev:awareness-subject-selected', _aircraftSelectedHandler);
|
if (_contextClearedHandler)
|
||||||
|
window.removeEventListener(
|
||||||
|
'gev:entity-selection-cleared',
|
||||||
|
_contextClearedHandler,
|
||||||
|
);
|
||||||
|
if (_aircraftSelectedHandler)
|
||||||
|
window.removeEventListener(
|
||||||
|
'gev:awareness-subject-selected',
|
||||||
|
_aircraftSelectedHandler,
|
||||||
|
);
|
||||||
_contextSelectedHandler = null;
|
_contextSelectedHandler = null;
|
||||||
_contextClearedHandler = null;
|
_contextClearedHandler = null;
|
||||||
_aircraftSelectedHandler = null;
|
_aircraftSelectedHandler = null;
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ export function isTrackingClickGesture(gesture = {}) {
|
|||||||
const durationMs = Number.isFinite(gesture.durationMs)
|
const durationMs = Number.isFinite(gesture.durationMs)
|
||||||
? Math.max(0, gesture.durationMs)
|
? Math.max(0, gesture.durationMs)
|
||||||
: Number.POSITIVE_INFINITY;
|
: Number.POSITIVE_INFINITY;
|
||||||
return isTrackingSelectionGesture(gesture)
|
return (
|
||||||
&& durationMs <= MAX_TRACKING_CLICK_DURATION_MS;
|
isTrackingSelectionGesture(gesture) &&
|
||||||
|
durationMs <= MAX_TRACKING_CLICK_DURATION_MS
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,7 +55,12 @@ export function bindTrackingClickGesture(handler, onClick, options = {}) {
|
|||||||
let completedGesture = null;
|
let completedGesture = null;
|
||||||
|
|
||||||
const appendTravel = (position) => {
|
const appendTravel = (position) => {
|
||||||
if (!pressActive || !Number.isFinite(position?.x) || !Number.isFinite(position?.y)) return;
|
if (
|
||||||
|
!pressActive ||
|
||||||
|
!Number.isFinite(position?.x) ||
|
||||||
|
!Number.isFinite(position?.y)
|
||||||
|
)
|
||||||
|
return;
|
||||||
if (previousPosition) {
|
if (previousPosition) {
|
||||||
travelPx += Math.hypot(
|
travelPx += Math.hypot(
|
||||||
position.x - previousPosition.x,
|
position.x - previousPosition.x,
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ export function greatCircleKm(lat1, lon1, lat2, lon2) {
|
|||||||
const p2 = toRad(lat2);
|
const p2 = toRad(lat2);
|
||||||
const dp = toRad(lat2 - lat1);
|
const dp = toRad(lat2 - lat1);
|
||||||
const dl = toRad(lon2 - lon1);
|
const dl = toRad(lon2 - lon1);
|
||||||
const a = Math.sin(dp / 2) ** 2
|
const a =
|
||||||
+ Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,8 @@ function initialBearingRad(lat1, lon1, lat2, lon2) {
|
|||||||
const p2 = toRad(lat2);
|
const p2 = toRad(lat2);
|
||||||
const dl = toRad(lon2 - lon1);
|
const dl = toRad(lon2 - lon1);
|
||||||
const y = Math.sin(dl) * Math.cos(p2);
|
const y = Math.sin(dl) * Math.cos(p2);
|
||||||
const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
|
const x =
|
||||||
|
Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
|
||||||
return Math.atan2(y, x);
|
return Math.atan2(y, x);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,11 +69,14 @@ function destinationPoint(lat, lon, bearingRad, distKm) {
|
|||||||
const p1 = toRad(lat);
|
const p1 = toRad(lat);
|
||||||
const l1 = toRad(lon);
|
const l1 = toRad(lon);
|
||||||
const p2 = Math.asin(
|
const p2 = Math.asin(
|
||||||
Math.sin(p1) * Math.cos(delta) + Math.cos(p1) * Math.sin(delta) * Math.cos(bearingRad)
|
Math.sin(p1) * Math.cos(delta) +
|
||||||
|
Math.cos(p1) * Math.sin(delta) * Math.cos(bearingRad),
|
||||||
);
|
);
|
||||||
const l2 = l1 + Math.atan2(
|
const l2 =
|
||||||
|
l1 +
|
||||||
|
Math.atan2(
|
||||||
Math.sin(bearingRad) * Math.sin(delta) * Math.cos(p1),
|
Math.sin(bearingRad) * Math.sin(delta) * Math.cos(p1),
|
||||||
Math.cos(delta) - Math.sin(p1) * Math.sin(p2)
|
Math.cos(delta) - Math.sin(p1) * Math.sin(p2),
|
||||||
);
|
);
|
||||||
// Normalize longitude to [-180, 180)
|
// Normalize longitude to [-180, 180)
|
||||||
const lonDeg = ((toDeg(l2) + 540) % 360) - 180;
|
const lonDeg = ((toDeg(l2) + 540) % 360) - 180;
|
||||||
@@ -100,7 +104,13 @@ function destinationPoint(lat, lon, bearingRad, distKm) {
|
|||||||
* @returns {{lat:number, lon:number, source:'hit'|'nadir'|'pulled'}}
|
* @returns {{lat:number, lon:number, source:'hit'|'nadir'|'pulled'}}
|
||||||
* The fetch center and which rule produced it.
|
* The fetch center and which rule produced it.
|
||||||
*/
|
*/
|
||||||
export function deriveFetchCenter({ nadirLat, nadirLon, hitLat, hitLon, maxPullKm = 12 }) {
|
export function deriveFetchCenter({
|
||||||
|
nadirLat,
|
||||||
|
nadirLon,
|
||||||
|
hitLat,
|
||||||
|
hitLon,
|
||||||
|
maxPullKm = 12,
|
||||||
|
}) {
|
||||||
if (!Number.isFinite(hitLat) || !Number.isFinite(hitLon)) {
|
if (!Number.isFinite(hitLat) || !Number.isFinite(hitLon)) {
|
||||||
return { lat: nadirLat, lon: nadirLon, source: 'nadir' };
|
return { lat: nadirLat, lon: nadirLon, source: 'nadir' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,13 +49,33 @@ const PROFILE_BY_STYLE = {
|
|||||||
*/
|
*/
|
||||||
const DOT_STYLE = {
|
const DOT_STYLE = {
|
||||||
mono: {
|
mono: {
|
||||||
jam: { rgba: [255, 255, 255, 0.95], sizeDelta: 3, outline: { rgba: [0, 0, 0, 0.9], width: 2 } },
|
jam: {
|
||||||
slow: { rgba: [255, 255, 255, 0.9], sizeDelta: 1, outline: { rgba: [0, 0, 0, 0.85], width: 1 } },
|
rgba: [255, 255, 255, 0.95],
|
||||||
free: { rgba: [255, 255, 255, 0.85], sizeDelta: 0, outline: { rgba: [0, 0, 0, 0.8], width: 1 } },
|
sizeDelta: 3,
|
||||||
|
outline: { rgba: [0, 0, 0, 0.9], width: 2 },
|
||||||
|
},
|
||||||
|
slow: {
|
||||||
|
rgba: [255, 255, 255, 0.9],
|
||||||
|
sizeDelta: 1,
|
||||||
|
outline: { rgba: [0, 0, 0, 0.85], width: 1 },
|
||||||
|
},
|
||||||
|
free: {
|
||||||
|
rgba: [255, 255, 255, 0.85],
|
||||||
|
sizeDelta: 0,
|
||||||
|
outline: { rgba: [0, 0, 0, 0.8], width: 1 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
crt: {
|
crt: {
|
||||||
jam: { rgba: [255, 59, 48, 0.95], sizeDelta: 3, outline: { rgba: [0, 0, 0, 0.9], width: 2 } },
|
jam: {
|
||||||
slow: { rgba: [255, 179, 0, 0.92], sizeDelta: 2, outline: { rgba: [0, 0, 0, 0.85], width: 1 } },
|
rgba: [255, 59, 48, 0.95],
|
||||||
|
sizeDelta: 3,
|
||||||
|
outline: { rgba: [0, 0, 0, 0.9], width: 2 },
|
||||||
|
},
|
||||||
|
slow: {
|
||||||
|
rgba: [255, 179, 0, 0.92],
|
||||||
|
sizeDelta: 2,
|
||||||
|
outline: { rgba: [0, 0, 0, 0.85], width: 1 },
|
||||||
|
},
|
||||||
free: { rgba: [0, 255, 102, 0.9], sizeDelta: 1, outline: null },
|
free: { rgba: [0, 255, 102, 0.9], sizeDelta: 1, outline: null },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -123,7 +143,8 @@ export function presetDotOutline(styleName, bucket) {
|
|||||||
* @returns {string|null} Tier key, or null (no override → stock 'vehicle').
|
* @returns {string|null} Tier key, or null (no override → stock 'vehicle').
|
||||||
*/
|
*/
|
||||||
export function trafficBucketTier(bucket) {
|
export function trafficBucketTier(bucket) {
|
||||||
if (bucket === 'free' || bucket === 'slow' || bucket === 'jam') return `veh_${bucket}`;
|
if (bucket === 'free' || bucket === 'slow' || bucket === 'jam')
|
||||||
|
return `veh_${bucket}`;
|
||||||
if (bucket === 'sim') return 'veh_nodata';
|
if (bucket === 'sim') return 'veh_nodata';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -33,13 +33,14 @@ const GAP_SPREAD_M = 6;
|
|||||||
*/
|
*/
|
||||||
export function queuePlatoons(totalLen, count, rng = Math.random) {
|
export function queuePlatoons(totalLen, count, rng = Math.random) {
|
||||||
const platoons = [];
|
const platoons = [];
|
||||||
if (!Number.isFinite(totalLen) || totalLen <= 0 || !(count > 0)) return platoons;
|
if (!Number.isFinite(totalLen) || totalLen <= 0 || !(count > 0))
|
||||||
|
return platoons;
|
||||||
|
|
||||||
let placed = 0;
|
let placed = 0;
|
||||||
while (placed < count) {
|
while (placed < count) {
|
||||||
const platoonSize = Math.min(
|
const platoonSize = Math.min(
|
||||||
count - placed,
|
count - placed,
|
||||||
PLATOON_MIN + Math.floor(rng() * PLATOON_SPREAD)
|
PLATOON_MIN + Math.floor(rng() * PLATOON_SPREAD),
|
||||||
);
|
);
|
||||||
const anchor = rng() * totalLen;
|
const anchor = rng() * totalLen;
|
||||||
const platoon = [];
|
const platoon = [];
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ import { registerPickOwner } from './pickRegistry.js';
|
|||||||
// plane being tracked. Claiming the 'gev-trail:' id namespace makes
|
// plane being tracked. Claiming the 'gev-trail:' id namespace makes
|
||||||
// isOwnedByOtherLayer() true for every layer — clicking a trail is a no-op
|
// isOwnedByOtherLayer() true for every layer — clicking a trail is a no-op
|
||||||
// everywhere. Registered once at module load; the predicate is pure.
|
// everywhere. Registered once at module load; the predicate is pure.
|
||||||
registerPickOwner('trails', (pickedId) => String(pickedId).startsWith('gev-trail:'));
|
registerPickOwner('trails', (pickedId) =>
|
||||||
|
String(pickedId).startsWith('gev-trail:'),
|
||||||
|
);
|
||||||
|
|
||||||
/** @type {number} Uniquifier for trail entity ids (Cesium requires unique entity ids). */
|
/** @type {number} Uniquifier for trail entity ids (Cesium requires unique entity ids). */
|
||||||
let _trailSeq = 0;
|
let _trailSeq = 0;
|
||||||
@@ -99,7 +101,12 @@ export function createTrail(viewer, { color, width = 2.5 }) {
|
|||||||
for (const position of Array.isArray(cartesians) ? cartesians : []) {
|
for (const position of Array.isArray(cartesians) ? cartesians : []) {
|
||||||
if (!position) continue;
|
if (!position) continue;
|
||||||
const last = positions[positions.length - 1];
|
const last = positions[positions.length - 1];
|
||||||
if (last && Cesium.Cartesian3.distanceSquared(last, position) < MIN_SEGMENT_DISTANCE_SQ) continue;
|
if (
|
||||||
|
last &&
|
||||||
|
Cesium.Cartesian3.distanceSquared(last, position) <
|
||||||
|
MIN_SEGMENT_DISTANCE_SQ
|
||||||
|
)
|
||||||
|
continue;
|
||||||
positions.push(position);
|
positions.push(position);
|
||||||
}
|
}
|
||||||
current = positions.length >= 2 ? positions : [];
|
current = positions.length >= 2 ? positions : [];
|
||||||
@@ -122,7 +129,11 @@ export function createTrail(viewer, { color, width = 2.5 }) {
|
|||||||
destroyed = true;
|
destroyed = true;
|
||||||
current = [];
|
current = [];
|
||||||
if (entity && viewer && !viewer.isDestroyed()) {
|
if (entity && viewer && !viewer.isDestroyed()) {
|
||||||
try { viewer.entities.remove(entity); } catch { /* torn down */ }
|
try {
|
||||||
|
viewer.entities.remove(entity);
|
||||||
|
} catch {
|
||||||
|
/* torn down */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
entity = null;
|
entity = null;
|
||||||
},
|
},
|
||||||
|
|||||||
+51
-12
@@ -20,21 +20,48 @@ export const VESSEL_CARD_FADE_DISTANCE_M = 5_000_000;
|
|||||||
*/
|
*/
|
||||||
const TYPE_STYLES = [
|
const TYPE_STYLES = [
|
||||||
{ pattern: /tanker/i, css: '#ffb347', accent: '255, 179, 71' },
|
{ pattern: /tanker/i, css: '#ffb347', accent: '255, 179, 71' },
|
||||||
{ pattern: /cargo|container|bulk|carrier/i, css: '#39d5ff', accent: '57, 213, 255' },
|
{
|
||||||
{ pattern: /passenger|ferry|cruise/i, css: '#ff7adf', accent: '255, 122, 223' },
|
pattern: /cargo|container|bulk|carrier/i,
|
||||||
|
css: '#39d5ff',
|
||||||
|
accent: '57, 213, 255',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /passenger|ferry|cruise/i,
|
||||||
|
css: '#ff7adf',
|
||||||
|
accent: '255, 122, 223',
|
||||||
|
},
|
||||||
{ pattern: /fishing/i, css: '#7cff9b', accent: '124, 255, 155' },
|
{ pattern: /fishing/i, css: '#7cff9b', accent: '124, 255, 155' },
|
||||||
{ pattern: /tug|tow|pilot|supply|service/i, css: '#f7f0a3', accent: '247, 240, 163' },
|
{
|
||||||
|
pattern: /tug|tow|pilot|supply|service/i,
|
||||||
|
css: '#f7f0a3',
|
||||||
|
accent: '247, 240, 163',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
||||||
|
|
||||||
const NUMERIC_TYPE_SPECIALS = {
|
const NUMERIC_TYPE_SPECIALS = {
|
||||||
30: 'FISHING', 31: 'TOWING', 32: 'TOWING', 33: 'DREDGER', 34: 'DIVE OPS',
|
30: 'FISHING',
|
||||||
35: 'MILITARY', 36: 'SAILING', 37: 'PLEASURE',
|
31: 'TOWING',
|
||||||
50: 'PILOT', 51: 'SAR', 52: 'TUG', 53: 'PORT TENDER', 54: 'ANTI-POLLUTION',
|
32: 'TOWING',
|
||||||
55: 'LAW ENFORCE', 58: 'MEDICAL',
|
33: 'DREDGER',
|
||||||
|
34: 'DIVE OPS',
|
||||||
|
35: 'MILITARY',
|
||||||
|
36: 'SAILING',
|
||||||
|
37: 'PLEASURE',
|
||||||
|
50: 'PILOT',
|
||||||
|
51: 'SAR',
|
||||||
|
52: 'TUG',
|
||||||
|
53: 'PORT TENDER',
|
||||||
|
54: 'ANTI-POLLUTION',
|
||||||
|
55: 'LAW ENFORCE',
|
||||||
|
58: 'MEDICAL',
|
||||||
};
|
};
|
||||||
const NUMERIC_TYPE_FAMILIES = {
|
const NUMERIC_TYPE_FAMILIES = {
|
||||||
4: 'HIGH-SPEED', 6: 'PASSENGER', 7: 'CARGO', 8: 'TANKER', 9: 'OTHER',
|
4: 'HIGH-SPEED',
|
||||||
|
6: 'PASSENGER',
|
||||||
|
7: 'CARGO',
|
||||||
|
8: 'TANKER',
|
||||||
|
9: 'OTHER',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,13 +103,22 @@ function styleForType(type) {
|
|||||||
* @param {number} [rowLimit=900] Configured source row ceiling.
|
* @param {number} [rowLimit=900] Configured source row ceiling.
|
||||||
* @returns {number}
|
* @returns {number}
|
||||||
*/
|
*/
|
||||||
export function vesselOverlayCohortLimit(width, height, rowLimit = VESSEL_DEFAULT_LABEL_LIMIT) {
|
export function vesselOverlayCohortLimit(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rowLimit = VESSEL_DEFAULT_LABEL_LIMIT,
|
||||||
|
) {
|
||||||
const w = Number(width);
|
const w = Number(width);
|
||||||
const h = Number(height);
|
const h = Number(height);
|
||||||
const requested = Number(rowLimit);
|
const requested = Number(rowLimit);
|
||||||
if (!(w > 0) || !(h > 0) || !(requested > 0)) return 0;
|
if (!(w > 0) || !(h > 0) || !(requested > 0)) return 0;
|
||||||
const gridCapacity = Math.ceil(w / VESSEL_LABEL_GRID_PX) * Math.ceil(h / VESSEL_LABEL_GRID_PX);
|
const gridCapacity =
|
||||||
return Math.min(VESSEL_OVERLAY_MAX_COHORT, Math.floor(requested), gridCapacity);
|
Math.ceil(w / VESSEL_LABEL_GRID_PX) * Math.ceil(h / VESSEL_LABEL_GRID_PX);
|
||||||
|
return Math.min(
|
||||||
|
VESSEL_OVERLAY_MAX_COHORT,
|
||||||
|
Math.floor(requested),
|
||||||
|
gridCapacity,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,7 +129,10 @@ export function vesselOverlayCohortLimit(width, height, rowLimit = VESSEL_DEFAUL
|
|||||||
* @param {number} [fadeDistance=5000000] Ambient distance-fade endpoint.
|
* @param {number} [fadeDistance=5000000] Ambient distance-fade endpoint.
|
||||||
* @returns {Object}
|
* @returns {Object}
|
||||||
*/
|
*/
|
||||||
export function applyVesselOverlayPolicy(card, fadeDistance = VESSEL_CARD_FADE_DISTANCE_M) {
|
export function applyVesselOverlayPolicy(
|
||||||
|
card,
|
||||||
|
fadeDistance = VESSEL_CARD_FADE_DISTANCE_M,
|
||||||
|
) {
|
||||||
const selected = card?.selected === true;
|
const selected = card?.selected === true;
|
||||||
const rawGap = Number(card?.gapPx) || 10;
|
const rawGap = Number(card?.gapPx) || 10;
|
||||||
const gapPx = Math.max(12, rawGap + 8);
|
const gapPx = Math.max(12, rawGap + 8);
|
||||||
|
|||||||
+56
-24
@@ -143,7 +143,9 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
|
|||||||
function resolveStore(kind, injected) {
|
function resolveStore(kind, injected) {
|
||||||
if (injected !== undefined) return injected;
|
if (injected !== undefined) return injected;
|
||||||
try {
|
try {
|
||||||
return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;
|
return kind === 'session'
|
||||||
|
? globalThis.sessionStorage
|
||||||
|
: globalThis.localStorage;
|
||||||
} catch {
|
} catch {
|
||||||
// Privacy-restricted storage should not make first launch silent.
|
// Privacy-restricted storage should not make first launch silent.
|
||||||
return null;
|
return null;
|
||||||
@@ -211,8 +213,13 @@ export function shouldShowFirstRun({
|
|||||||
if (params.get('welcome') === '0') return false;
|
if (params.get('welcome') === '0') return false;
|
||||||
// The demo/support escape hatch outranks both suppressions on purpose.
|
// The demo/support escape hatch outranks both suppressions on purpose.
|
||||||
if (params.get('welcome') === '1') return true;
|
if (params.get('welcome') === '1') return true;
|
||||||
if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;
|
if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')
|
||||||
if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;
|
return false;
|
||||||
|
if (
|
||||||
|
readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) ===
|
||||||
|
'dismissed'
|
||||||
|
)
|
||||||
|
return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +260,10 @@ export function rememberFirstRunSessionDismissed(sessionStorageRef) {
|
|||||||
* @param {() => Promise<any>} deps.flyToGlobe
|
* @param {() => Promise<any>} deps.flyToGlobe
|
||||||
* @returns {Promise<{ok: boolean, choice: string, result?: object, failedLayerIds?: string[]}>}
|
* @returns {Promise<{ok: boolean, choice: string, result?: object, failedLayerIds?: string[]}>}
|
||||||
*/
|
*/
|
||||||
export async function runFirstRunChoice(choice, { setContextMode, setLayerEnabled, flyToGlobe }) {
|
export async function runFirstRunChoice(
|
||||||
|
choice,
|
||||||
|
{ setContextMode, setLayerEnabled, flyToGlobe },
|
||||||
|
) {
|
||||||
const mission = FIRST_RUN_MISSIONS[choice];
|
const mission = FIRST_RUN_MISSIONS[choice];
|
||||||
if (!mission) return { ok: false, choice };
|
if (!mission) return { ok: false, choice };
|
||||||
if (mission.kind === 'none') return { ok: true, choice };
|
if (mission.kind === 'none') return { ok: true, choice };
|
||||||
@@ -267,15 +277,19 @@ export async function runFirstRunChoice(choice, { setContextMode, setLayerEnable
|
|||||||
const flight = Promise.resolve()
|
const flight = Promise.resolve()
|
||||||
.then(() => flyToGlobe())
|
.then(() => flyToGlobe())
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
const outcomes = await Promise.all(mission.layerIds.map(async (layerId) => {
|
const outcomes = await Promise.all(
|
||||||
|
mission.layerIds.map(async (layerId) => {
|
||||||
try {
|
try {
|
||||||
return { layerId, ok: (await setLayerEnabled(layerId)) !== false };
|
return { layerId, ok: (await setLayerEnabled(layerId)) !== false };
|
||||||
} catch {
|
} catch {
|
||||||
return { layerId, ok: false };
|
return { layerId, ok: false };
|
||||||
}
|
}
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
await flight;
|
await flight;
|
||||||
const failedLayerIds = outcomes.filter((entry) => !entry.ok).map((entry) => entry.layerId);
|
const failedLayerIds = outcomes
|
||||||
|
.filter((entry) => !entry.ok)
|
||||||
|
.map((entry) => entry.layerId);
|
||||||
return { ok: failedLayerIds.length === 0, choice, failedLayerIds };
|
return { ok: failedLayerIds.length === 0, choice, failedLayerIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,20 +339,25 @@ export function initFirstRunExperience({
|
|||||||
if (!root || root.dataset.initialized === 'true') return null;
|
if (!root || root.dataset.initialized === 'true') return null;
|
||||||
root.dataset.initialized = 'true';
|
root.dataset.initialized = 'true';
|
||||||
|
|
||||||
if (!shouldShowFirstRun({
|
if (
|
||||||
|
!shouldShowFirstRun({
|
||||||
hasShareState: styleManager?.hasShareState,
|
hasShareState: styleManager?.hasShareState,
|
||||||
storage,
|
storage,
|
||||||
sessionStorageRef,
|
sessionStorageRef,
|
||||||
location,
|
location,
|
||||||
})) {
|
})
|
||||||
|
) {
|
||||||
root.remove();
|
root.remove();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The tile name is owner-switchable from one constant, so paint it from the
|
// The tile name is owner-switchable from one constant, so paint it from the
|
||||||
// module rather than trusting the markup to have been edited to match.
|
// module rather than trusting the markup to have been edited to match.
|
||||||
const environmentalTitle = root.querySelector('[data-first-run-environmental-title]');
|
const environmentalTitle = root.querySelector(
|
||||||
if (environmentalTitle) environmentalTitle.textContent = environmentalLabel().title;
|
'[data-first-run-environmental-title]',
|
||||||
|
);
|
||||||
|
if (environmentalTitle)
|
||||||
|
environmentalTitle.textContent = environmentalLabel().title;
|
||||||
|
|
||||||
const status = root.querySelector('[data-first-run-status]');
|
const status = root.querySelector('[data-first-run-status]');
|
||||||
const suppressBox = root.querySelector('[data-first-run-suppress]');
|
const suppressBox = root.querySelector('[data-first-run-suppress]');
|
||||||
@@ -387,10 +406,11 @@ export function initFirstRunExperience({
|
|||||||
* is exactly how every one of those surfaces hides this card; the hit test
|
* is exactly how every one of those surfaces hides this card; the hit test
|
||||||
* then covers the overlays that leave the box intact and simply sit on top.
|
* then covers the overlays that leave the box intact and simply sit on top.
|
||||||
*/
|
*/
|
||||||
const isTopmost = () => root.isConnected
|
const isTopmost = () =>
|
||||||
&& root.classList.contains('visible')
|
root.isConnected &&
|
||||||
&& root.getClientRects().length > 0
|
root.classList.contains('visible') &&
|
||||||
&& !coveredByOverlay();
|
root.getClientRects().length > 0 &&
|
||||||
|
!coveredByOverlay();
|
||||||
|
|
||||||
const dismiss = ({ restoreFocus = true } = {}) => {
|
const dismiss = ({ restoreFocus = true } = {}) => {
|
||||||
if (closing) return;
|
if (closing) return;
|
||||||
@@ -416,10 +436,13 @@ export function initFirstRunExperience({
|
|||||||
root.setAttribute('aria-busy', String(next));
|
root.setAttribute('aria-busy', String(next));
|
||||||
// aria-disabled, not `disabled`: disabling the focused button drops focus to
|
// aria-disabled, not `disabled`: disabling the focused button drops focus to
|
||||||
// <body> mid-flight and strands a keyboard visitor outside the launcher.
|
// <body> mid-flight and strands a keyboard visitor outside the launcher.
|
||||||
for (const button of buttons) button.setAttribute('aria-disabled', String(next));
|
for (const button of buttons)
|
||||||
|
button.setAttribute('aria-disabled', String(next));
|
||||||
if (!status) return;
|
if (!status) return;
|
||||||
if (next) status.textContent = FIRST_RUN_MISSIONS[choice]?.busyText || 'Working…';
|
if (next)
|
||||||
else if (status.dataset.sticky !== 'true') status.textContent = defaultStatus;
|
status.textContent = FIRST_RUN_MISSIONS[choice]?.busyText || 'Working…';
|
||||||
|
else if (status.dataset.sticky !== 'true')
|
||||||
|
status.textContent = defaultStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChoice = async (event) => {
|
const onChoice = async (event) => {
|
||||||
@@ -438,13 +461,16 @@ export function initFirstRunExperience({
|
|||||||
// does not decide panel chrome. This first-run click is an explicit
|
// does not decide panel chrome. This first-run click is an explicit
|
||||||
// visual choice, so reveal the result exactly as the visible
|
// visual choice, so reveal the result exactly as the visible
|
||||||
// Contacts / Space Missions tabs do.
|
// Contacts / Space Missions tabs do.
|
||||||
styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });
|
styleManager.setPanelCollapsed?.('global-context-panel', false, {
|
||||||
|
explicit: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
// `origin: 'user'` on purpose: a mission tile is a real person choosing
|
// `origin: 'user'` on purpose: a mission tile is a real person choosing
|
||||||
// these layers, so it persists exactly as clicking those rows would.
|
// these layers, so it persists exactly as clicking those rows would.
|
||||||
setLayerEnabled: (layerId) => dataManager.setEnabled(layerId, true, { origin: 'user' }),
|
setLayerEnabled: (layerId) =>
|
||||||
|
dataManager.setEnabled(layerId, true, { origin: 'user' }),
|
||||||
flyToGlobe: () => styleManager.resetToGlobeView(),
|
flyToGlobe: () => styleManager.resetToGlobeView(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -460,7 +486,8 @@ export function initFirstRunExperience({
|
|||||||
const failed = outcome?.failedLayerIds?.length
|
const failed = outcome?.failedLayerIds?.length
|
||||||
? outcome.failedLayerIds
|
? outcome.failedLayerIds
|
||||||
: outcome?.result?.failedLayerIds;
|
: outcome?.result?.failedLayerIds;
|
||||||
const detail = Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
|
const detail =
|
||||||
|
Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
|
||||||
if (status) {
|
if (status) {
|
||||||
status.dataset.sticky = 'true';
|
status.dataset.sticky = 'true';
|
||||||
status.textContent = `Could not open that mission${detail}. Retry or explore manually.`;
|
status.textContent = `Could not open that mission${detail}. Retry or explore manually.`;
|
||||||
@@ -479,7 +506,8 @@ export function initFirstRunExperience({
|
|||||||
if (box) box.checked = !wanted;
|
if (box) box.checked = !wanted;
|
||||||
if (!status) return;
|
if (!status) return;
|
||||||
status.dataset.sticky = 'true';
|
status.dataset.sticky = 'true';
|
||||||
status.textContent = 'This browser is blocking storage, so that could not be saved.';
|
status.textContent =
|
||||||
|
'This browser is blocking storage, so that could not be saved.';
|
||||||
};
|
};
|
||||||
|
|
||||||
const keyboard = createSurfaceKeyboard({
|
const keyboard = createSurfaceKeyboard({
|
||||||
@@ -585,13 +613,17 @@ export function initFirstRunExperience({
|
|||||||
const onViewportResize = () => syncScrollAffordance();
|
const onViewportResize = () => syncScrollAffordance();
|
||||||
globalThis.addEventListener?.('resize', onViewportResize);
|
globalThis.addEventListener?.('resize', onViewportResize);
|
||||||
|
|
||||||
const surfaceObserver = typeof globalThis.MutationObserver === 'function'
|
const surfaceObserver =
|
||||||
|
typeof globalThis.MutationObserver === 'function'
|
||||||
? new globalThis.MutationObserver(syncToExclusiveSurfaces)
|
? new globalThis.MutationObserver(syncToExclusiveSurfaces)
|
||||||
: null;
|
: null;
|
||||||
// Attributes only, no subtree: this is a class watch on one element, so it
|
// Attributes only, no subtree: this is a class watch on one element, so it
|
||||||
// costs nothing per frame and never asks the render governor for a frame.
|
// costs nothing per frame and never asks the render governor for a frame.
|
||||||
if (documentRef.body) {
|
if (documentRef.body) {
|
||||||
surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });
|
surfaceObserver?.observe(documentRef.body, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class'],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
syncToExclusiveSurfaces();
|
syncToExclusiveSurfaces();
|
||||||
|
|
||||||
|
|||||||
+92
-27
@@ -18,16 +18,36 @@ import * as Cesium from 'cesium';
|
|||||||
import { forward as toMGRS } from 'mgrs';
|
import { forward as toMGRS } from 'mgrs';
|
||||||
import { CITY_POIS } from './locations.js';
|
import { CITY_POIS } from './locations.js';
|
||||||
import { composeLocalityTag } from './hudLocality.js';
|
import { composeLocalityTag } from './hudLocality.js';
|
||||||
import { ellipsoidalToMslDisplayM, ensureGeoidReady, geoidHeight } from './data/geoid.js';
|
import {
|
||||||
|
ellipsoidalToMslDisplayM,
|
||||||
|
ensureGeoidReady,
|
||||||
|
geoidHeight,
|
||||||
|
} from './data/geoid.js';
|
||||||
import { getBasemapLabelContext } from './voice/gevActions.js';
|
import { getBasemapLabelContext } from './voice/gevActions.js';
|
||||||
import { isHudSummaryUnconfigured } from './hudSummaryResponse.js';
|
import { isHudSummaryUnconfigured } from './hudSummaryResponse.js';
|
||||||
|
|
||||||
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||||
const HUD_COLORS = {
|
const HUD_COLORS = {
|
||||||
surveillance: { main: 'rgba(51, 255, 51, 0.8)', glow: 'rgba(51, 255, 51, 0.5)', border: 'rgba(51, 255, 51, 0.2)' },
|
surveillance: {
|
||||||
thermal: { main: 'rgba(255, 255, 255, 0.7)', glow: 'rgba(255, 255, 255, 0.4)', border: 'rgba(255, 255, 255, 0.15)' },
|
main: 'rgba(51, 255, 51, 0.8)',
|
||||||
retro: { main: 'rgba(255, 170, 0, 0.8)', glow: 'rgba(255, 170, 0, 0.5)', border: 'rgba(255, 170, 0, 0.2)' },
|
glow: 'rgba(51, 255, 51, 0.5)',
|
||||||
_default: { main: 'rgba(0, 255, 255, 0.6)', glow: 'rgba(0, 255, 255, 0.4)', border: 'rgba(0, 255, 255, 0.15)' },
|
border: 'rgba(51, 255, 51, 0.2)',
|
||||||
|
},
|
||||||
|
thermal: {
|
||||||
|
main: 'rgba(255, 255, 255, 0.7)',
|
||||||
|
glow: 'rgba(255, 255, 255, 0.4)',
|
||||||
|
border: 'rgba(255, 255, 255, 0.15)',
|
||||||
|
},
|
||||||
|
retro: {
|
||||||
|
main: 'rgba(255, 170, 0, 0.8)',
|
||||||
|
glow: 'rgba(255, 170, 0, 0.5)',
|
||||||
|
border: 'rgba(255, 170, 0, 0.2)',
|
||||||
|
},
|
||||||
|
_default: {
|
||||||
|
main: 'rgba(0, 255, 255, 0.6)',
|
||||||
|
glow: 'rgba(0, 255, 255, 0.4)',
|
||||||
|
border: 'rgba(0, 255, 255, 0.15)',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Shader modes that automatically show the HUD overlay. */
|
/** Shader modes that automatically show the HUD overlay. */
|
||||||
@@ -45,13 +65,14 @@ const HUD_SUMMARY_INTERVAL_MS = 15000;
|
|||||||
const HUD_GEOID_CELL_DEG = 0.01;
|
const HUD_GEOID_CELL_DEG = 0.01;
|
||||||
|
|
||||||
/** Flattened list of all city POIs for nearest-point lookups. */
|
/** Flattened list of all city POIs for nearest-point lookups. */
|
||||||
const NEARBY_POINTS = Object.values(CITY_POIS)
|
const NEARBY_POINTS = Object.values(CITY_POIS).flatMap((city) =>
|
||||||
.flatMap((city) => city.pois.map((poi) => ({
|
city.pois.map((poi) => ({
|
||||||
city: city.name,
|
city: city.name,
|
||||||
poi: poi.name,
|
poi: poi.name,
|
||||||
lat: poi.lat,
|
lat: poi.lat,
|
||||||
lon: poi.lon,
|
lon: poi.lon,
|
||||||
})));
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen intelligence HUD overlay rendered on top of the Cesium canvas.
|
* Full-screen intelligence HUD overlay rendered on top of the Cesium canvas.
|
||||||
@@ -66,7 +87,15 @@ export class IntelHUD {
|
|||||||
* @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for
|
* @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for
|
||||||
* camera telemetry and coordinate derivation.
|
* camera telemetry and coordinate derivation.
|
||||||
*/
|
*/
|
||||||
constructor(viewer, { placeSearch, summaryPolicy = {}, basemapContext = {}, summaryService = applicationServices.summary } = {}) {
|
constructor(
|
||||||
|
viewer,
|
||||||
|
{
|
||||||
|
placeSearch,
|
||||||
|
summaryPolicy = {},
|
||||||
|
basemapContext = {},
|
||||||
|
summaryService = applicationServices.summary,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
this.summaryService = summaryService;
|
this.summaryService = summaryService;
|
||||||
this.summaryPolicy = summaryPolicy;
|
this.summaryPolicy = summaryPolicy;
|
||||||
this.basemapContext = basemapContext;
|
this.basemapContext = basemapContext;
|
||||||
@@ -227,7 +256,8 @@ export class IntelHUD {
|
|||||||
this._recBlinkInterval = setInterval(() => {
|
this._recBlinkInterval = setInterval(() => {
|
||||||
this._recBlinkState = !this._recBlinkState;
|
this._recBlinkState = !this._recBlinkState;
|
||||||
const dot = document.getElementById('hud-rec-dot');
|
const dot = document.getElementById('hud-rec-dot');
|
||||||
if (dot) dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden';
|
if (dot)
|
||||||
|
dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden';
|
||||||
}, 800);
|
}, 800);
|
||||||
|
|
||||||
// Camera-derived data — 4 updates/second (250ms)
|
// Camera-derived data — 4 updates/second (250ms)
|
||||||
@@ -269,8 +299,12 @@ export class IntelHUD {
|
|||||||
if (!this._geoidRequested) {
|
if (!this._geoidRequested) {
|
||||||
this._geoidRequested = true;
|
this._geoidRequested = true;
|
||||||
ensureGeoidReady()
|
ensureGeoidReady()
|
||||||
.then(() => { this._geoidReady = true; })
|
.then(() => {
|
||||||
.catch(() => { /* readout falls back to the uncorrected height */ });
|
this._geoidReady = true;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* readout falls back to the uncorrected height */
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -331,9 +365,13 @@ export class IntelHUD {
|
|||||||
// Equation (GIQE) simplified form: NIIRS = 10.25 - 3.32 * log10(GSD_inches).
|
// Equation (GIQE) simplified form: NIIRS = 10.25 - 3.32 * log10(GSD_inches).
|
||||||
const gsd = Math.max(0.01, altM * 0.000375);
|
const gsd = Math.max(0.01, altM * 0.000375);
|
||||||
const gsdInches = gsd * 39.37;
|
const gsdInches = gsd * 39.37;
|
||||||
const niirs = Math.max(0, Math.min(9, 10.25 - 3.32 * Math.log10(gsdInches)));
|
const niirs = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(9, 10.25 - 3.32 * Math.log10(gsdInches)),
|
||||||
|
);
|
||||||
const gsdEl = document.getElementById('hud-gsd');
|
const gsdEl = document.getElementById('hud-gsd');
|
||||||
if (gsdEl) gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
|
if (gsdEl)
|
||||||
|
gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
|
||||||
|
|
||||||
// Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
|
// Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
|
||||||
// ellipsoidal camera height, which reads far below zero wherever the geoid
|
// ellipsoidal camera height, which reads far below zero wherever the geoid
|
||||||
@@ -344,7 +382,8 @@ export class IntelHUD {
|
|||||||
const geoidN = this._geoidUndulationM(latDeg, lonDeg);
|
const geoidN = this._geoidUndulationM(latDeg, lonDeg);
|
||||||
const altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
|
const altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
|
||||||
const sunEl = this._estimateSunElevation(latDeg, lonDeg);
|
const sunEl = this._estimateSunElevation(latDeg, lonDeg);
|
||||||
if (altEl) altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
if (altEl)
|
||||||
|
altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
||||||
|
|
||||||
// Collection timestamp
|
// Collection timestamp
|
||||||
const collEl = document.getElementById('hud-coll');
|
const collEl = document.getElementById('hud-coll');
|
||||||
@@ -435,7 +474,10 @@ export class IntelHUD {
|
|||||||
if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S';
|
if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S';
|
||||||
else dir = decimal >= 0 ? 'E' : 'W';
|
else dir = decimal >= 0 ? 'E' : 'W';
|
||||||
|
|
||||||
const degStr = type === 'lon' ? String(deg).padStart(3, '0') : String(deg).padStart(2, '0');
|
const degStr =
|
||||||
|
type === 'lon'
|
||||||
|
? String(deg).padStart(3, '0')
|
||||||
|
: String(deg).padStart(2, '0');
|
||||||
return `${degStr}°${String(min).padStart(2, '0')}'${String(sec).padStart(5, '0')}"${dir}`;
|
return `${degStr}°${String(min).padStart(2, '0')}'${String(sec).padStart(5, '0')}"${dir}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,12 +499,20 @@ export class IntelHUD {
|
|||||||
const solarNoon = 12;
|
const solarNoon = 12;
|
||||||
const hourAngle = (hours - solarNoon) * 15;
|
const hourAngle = (hours - solarNoon) * 15;
|
||||||
// Solar declination approximation (~23.45 deg amplitude sinusoidal over the year)
|
// Solar declination approximation (~23.45 deg amplitude sinusoidal over the year)
|
||||||
const declination = 23.45 * Math.sin(Cesium.Math.toRadians((360 / 365) * (now.getUTCDate() + 30 * now.getUTCMonth() - 81)));
|
const declination =
|
||||||
|
23.45 *
|
||||||
|
Math.sin(
|
||||||
|
Cesium.Math.toRadians(
|
||||||
|
(360 / 365) * (now.getUTCDate() + 30 * now.getUTCMonth() - 81),
|
||||||
|
),
|
||||||
|
);
|
||||||
const latRad = Cesium.Math.toRadians(lat);
|
const latRad = Cesium.Math.toRadians(lat);
|
||||||
const decRad = Cesium.Math.toRadians(declination);
|
const decRad = Cesium.Math.toRadians(declination);
|
||||||
const haRad = Cesium.Math.toRadians(hourAngle);
|
const haRad = Cesium.Math.toRadians(hourAngle);
|
||||||
// Standard formula: sin(el) = sin(lat)*sin(dec) + cos(lat)*cos(dec)*cos(ha)
|
// Standard formula: sin(el) = sin(lat)*sin(dec) + cos(lat)*cos(dec)*cos(ha)
|
||||||
const sinEl = Math.sin(latRad) * Math.sin(decRad) + Math.cos(latRad) * Math.cos(decRad) * Math.cos(haRad);
|
const sinEl =
|
||||||
|
Math.sin(latRad) * Math.sin(decRad) +
|
||||||
|
Math.cos(latRad) * Math.cos(decRad) * Math.cos(haRad);
|
||||||
// Clamp to [-1,1] to guard against floating-point drift before asin
|
// Clamp to [-1,1] to guard against floating-point drift before asin
|
||||||
return Cesium.Math.toDegrees(Math.asin(Math.max(-1, Math.min(1, sinEl))));
|
return Cesium.Math.toDegrees(Math.asin(Math.max(-1, Math.min(1, sinEl))));
|
||||||
}
|
}
|
||||||
@@ -489,8 +539,10 @@ export class IntelHUD {
|
|||||||
_regionLabel(lat, lon) {
|
_regionLabel(lat, lon) {
|
||||||
if (lat > 72) return 'ARCTIC';
|
if (lat > 72) return 'ARCTIC';
|
||||||
if (lat < -60) return 'ANTARCTIC';
|
if (lat < -60) return 'ANTARCTIC';
|
||||||
if (lat >= 5 && lat <= 83 && lon >= -170 && lon <= -50) return 'NORTH AMERICA';
|
if (lat >= 5 && lat <= 83 && lon >= -170 && lon <= -50)
|
||||||
if (lat >= -60 && lat <= 15 && lon >= -90 && lon <= -30) return 'SOUTH AMERICA';
|
return 'NORTH AMERICA';
|
||||||
|
if (lat >= -60 && lat <= 15 && lon >= -90 && lon <= -30)
|
||||||
|
return 'SOUTH AMERICA';
|
||||||
if (lat >= 34 && lat <= 72 && lon >= -25 && lon <= 45) return 'EUROPE';
|
if (lat >= 34 && lat <= 72 && lon >= -25 && lon <= 45) return 'EUROPE';
|
||||||
if (lat >= -35 && lat <= 38 && lon >= -20 && lon <= 55) return 'AFRICA';
|
if (lat >= -35 && lat <= 38 && lon >= -20 && lon <= 55) return 'AFRICA';
|
||||||
if (lat >= 5 && lat <= 80 && lon >= 45 && lon <= 180) return 'ASIA';
|
if (lat >= 5 && lat <= 80 && lon >= 45 && lon <= 180) return 'ASIA';
|
||||||
@@ -519,7 +571,10 @@ export class IntelHUD {
|
|||||||
const latSpan = Math.abs(north - south);
|
const latSpan = Math.abs(north - south);
|
||||||
// 111 km/deg is the approximate surface distance per degree of latitude;
|
// 111 km/deg is the approximate surface distance per degree of latitude;
|
||||||
// longitude distance is scaled by cos(lat) to account for meridian convergence.
|
// longitude distance is scaled by cos(lat) to account for meridian convergence.
|
||||||
const widthKm = Math.max(0, lonSpan * 111 * Math.cos(Cesium.Math.toRadians(latDeg)));
|
const widthKm = Math.max(
|
||||||
|
0,
|
||||||
|
lonSpan * 111 * Math.cos(Cesium.Math.toRadians(latDeg)),
|
||||||
|
);
|
||||||
const heightKm = Math.max(0, latSpan * 111);
|
const heightKm = Math.max(0, latSpan * 111);
|
||||||
return { widthKm, heightKm };
|
return { widthKm, heightKm };
|
||||||
}
|
}
|
||||||
@@ -537,8 +592,9 @@ export class IntelHUD {
|
|||||||
const toRad = (deg) => Cesium.Math.toRadians(deg);
|
const toRad = (deg) => Cesium.Math.toRadians(deg);
|
||||||
const dLat = toRad(lat2 - lat1);
|
const dLat = toRad(lat2 - lat1);
|
||||||
const dLon = toRad(lon2 - lon1);
|
const dLon = toRad(lon2 - lon1);
|
||||||
const a = Math.sin(dLat / 2) ** 2
|
const a =
|
||||||
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,7 +639,8 @@ export class IntelHUD {
|
|||||||
// together, so they must never disagree. The view band above deliberately
|
// together, so they must never disagree. The view band above deliberately
|
||||||
// keeps the ellipsoidal height: its thresholds were tuned against it.
|
// keeps the ellipsoidal height: its thresholds were tuned against it.
|
||||||
const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM;
|
const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM;
|
||||||
const altTag = altDisplayM >= 1000
|
const altTag =
|
||||||
|
altDisplayM >= 1000
|
||||||
? `${(altDisplayM / 1000).toFixed(1)}KM`
|
? `${(altDisplayM / 1000).toFixed(1)}KM`
|
||||||
: `${Math.round(altDisplayM)}M`;
|
: `${Math.round(altDisplayM)}M`;
|
||||||
const winTag = window
|
const winTag = window
|
||||||
@@ -665,7 +722,9 @@ export class IntelHUD {
|
|||||||
this._summaryRequest = controller;
|
this._summaryRequest = controller;
|
||||||
try {
|
try {
|
||||||
this.summaryPolicy.onRequest?.();
|
this.summaryPolicy.onRequest?.();
|
||||||
const response = await this.summaryService.summarize(context, { signal: controller.signal });
|
const response = await this.summaryService.summarize(context, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (revision !== this._summaryRevision) return;
|
if (revision !== this._summaryRevision) return;
|
||||||
if (isHudSummaryUnconfigured(response.status, data)) {
|
if (isHudSummaryUnconfigured(response.status, data)) {
|
||||||
@@ -701,8 +760,14 @@ export class IntelHUD {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _summaryContext() {
|
async _summaryContext() {
|
||||||
const labels = await getBasemapLabelContext(this.viewer, this.placeSearch, this.basemapContext);
|
const labels = await getBasemapLabelContext(
|
||||||
const enabledLayers = this._dataManager?.getAll?.()
|
this.viewer,
|
||||||
|
this.placeSearch,
|
||||||
|
this.basemapContext,
|
||||||
|
);
|
||||||
|
const enabledLayers =
|
||||||
|
this._dataManager
|
||||||
|
?.getAll?.()
|
||||||
?.filter((layer) => layer.enabled)
|
?.filter((layer) => layer.enabled)
|
||||||
.map((layer) => layer.name) || [];
|
.map((layer) => layer.name) || [];
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -23,13 +23,16 @@ export function keylessHudSummaryResponse(apiKey) {
|
|||||||
|
|
||||||
/** Return true only for the deliberate, successful no-key capability response. */
|
/** Return true only for the deliberate, successful no-key capability response. */
|
||||||
export function isHudSummaryUnconfigured(status, data) {
|
export function isHudSummaryUnconfigured(status, data) {
|
||||||
const keys = data !== null && typeof data === 'object' && !Array.isArray(data)
|
const keys =
|
||||||
|
data !== null && typeof data === 'object' && !Array.isArray(data)
|
||||||
? Object.keys(data)
|
? Object.keys(data)
|
||||||
: [];
|
: [];
|
||||||
return keys.length === 4
|
return (
|
||||||
&& status === 200
|
keys.length === 4 &&
|
||||||
&& data?.configured === false
|
status === 200 &&
|
||||||
&& data?.code === HUD_SUMMARY_UNCONFIGURED_CODE
|
data?.configured === false &&
|
||||||
&& data?.error === null
|
data?.code === HUD_SUMMARY_UNCONFIGURED_CODE &&
|
||||||
&& data?.summary === null;
|
data?.error === null &&
|
||||||
|
data?.summary === null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
+68
-25
@@ -19,7 +19,9 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
|||||||
/** Chip label — pure, exported for tests. */
|
/** Chip label — pure, exported for tests. */
|
||||||
export function keySetupChipLabel(status) {
|
export function keySetupChipLabel(status) {
|
||||||
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0));
|
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0));
|
||||||
return missing > 0 ? `POWER UP · ${missing} ${missing === 1 ? 'KEY' : 'KEYS'} WAITING` : 'POWERED UP';
|
return missing > 0
|
||||||
|
? `POWER UP · ${missing} ${missing === 1 ? 'KEY' : 'KEYS'} WAITING`
|
||||||
|
: 'POWERED UP';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,13 +80,17 @@ function buildRow(documentRef, key) {
|
|||||||
const tier = documentRef.createElement('span');
|
const tier = documentRef.createElement('span');
|
||||||
tier.className = 'key-setup-tier';
|
tier.className = 'key-setup-tier';
|
||||||
tier.textContent = TIER_DOTS[key.tier] || '';
|
tier.textContent = TIER_DOTS[key.tier] || '';
|
||||||
tier.title = key.tier === 'metered' ? 'Metered — a billing-enabled account' : 'Free key — register, paste, done';
|
tier.title =
|
||||||
|
key.tier === 'metered'
|
||||||
|
? 'Metered — a billing-enabled account'
|
||||||
|
: 'Free key — register, paste, done';
|
||||||
head.append(led, title, tier);
|
head.append(led, title, tier);
|
||||||
if (key.clientExposed) {
|
if (key.clientExposed) {
|
||||||
const exposed = documentRef.createElement('span');
|
const exposed = documentRef.createElement('span');
|
||||||
exposed.className = 'key-setup-exposed';
|
exposed.className = 'key-setup-exposed';
|
||||||
exposed.textContent = 'browser-side';
|
exposed.textContent = 'browser-side';
|
||||||
exposed.title = 'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)';
|
exposed.title =
|
||||||
|
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)';
|
||||||
head.append(exposed);
|
head.append(exposed);
|
||||||
}
|
}
|
||||||
if (external) {
|
if (external) {
|
||||||
@@ -93,7 +99,8 @@ function buildRow(documentRef, key) {
|
|||||||
const badge = documentRef.createElement('span');
|
const badge = documentRef.createElement('span');
|
||||||
badge.className = 'key-setup-external';
|
badge.className = 'key-setup-external';
|
||||||
badge.textContent = 'configured externally';
|
badge.textContent = 'configured externally';
|
||||||
badge.title = 'Supplied by your environment, Keychain, or launcher — change it where it was set';
|
badge.title =
|
||||||
|
'Supplied by your environment, Keychain, or launcher — change it where it was set';
|
||||||
head.append(badge);
|
head.append(badge);
|
||||||
}
|
}
|
||||||
const get = documentRef.createElement('a');
|
const get = documentRef.createElement('a');
|
||||||
@@ -144,7 +151,11 @@ function buildRow(documentRef, key) {
|
|||||||
* Wire the chip + dialog. Fire-and-forget from main.js; resolves to null when
|
* Wire the chip + dialog. Fire-and-forget from main.js; resolves to null when
|
||||||
* the surface has no business existing (prod build, LAN visitor, no markup).
|
* the surface has no business existing (prod build, LAN visitor, no markup).
|
||||||
*/
|
*/
|
||||||
export async function initKeySetup({ documentRef = globalThis.document, fetchImpl, signal } = {}) {
|
export async function initKeySetup({
|
||||||
|
documentRef = globalThis.document,
|
||||||
|
fetchImpl,
|
||||||
|
signal,
|
||||||
|
} = {}) {
|
||||||
const chip = documentRef?.getElementById?.('key-setup-chip');
|
const chip = documentRef?.getElementById?.('key-setup-chip');
|
||||||
const root = documentRef?.getElementById?.('key-setup');
|
const root = documentRef?.getElementById?.('key-setup');
|
||||||
if (!chip || !root || root.dataset.initialized === 'true') return null;
|
if (!chip || !root || root.dataset.initialized === 'true') return null;
|
||||||
@@ -161,13 +172,19 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
|||||||
chip.remove();
|
chip.remove();
|
||||||
root.remove();
|
root.remove();
|
||||||
};
|
};
|
||||||
if (signal?.aborted) { destroy(); return null; }
|
if (signal?.aborted) {
|
||||||
|
destroy();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
signal?.addEventListener('abort', destroy, { once: true });
|
signal?.addEventListener('abort', destroy, { once: true });
|
||||||
const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis);
|
const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis);
|
||||||
|
|
||||||
let status = null;
|
let status = null;
|
||||||
try {
|
try {
|
||||||
const response = await doFetch('/api/setup/status', { cache: 'no-store', signal: lifetime.signal });
|
const response = await doFetch('/api/setup/status', {
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: lifetime.signal,
|
||||||
|
});
|
||||||
if (!response.ok) throw new Error(String(response.status));
|
if (!response.ok) throw new Error(String(response.status));
|
||||||
status = await response.json();
|
status = await response.json();
|
||||||
if (disposed) return null;
|
if (disposed) return null;
|
||||||
@@ -196,12 +213,14 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
|||||||
chip.hidden = status.setCount >= status.total;
|
chip.hidden = status.setCount >= status.total;
|
||||||
if (!rowsHost) return;
|
if (!rowsHost) return;
|
||||||
rowsHost.textContent = '';
|
rowsHost.textContent = '';
|
||||||
for (const key of status.keys || []) rowsHost.append(buildRow(documentRef, key));
|
for (const key of status.keys || [])
|
||||||
|
rowsHost.append(buildRow(documentRef, key));
|
||||||
};
|
};
|
||||||
|
|
||||||
const visible = () => root.isConnected
|
const visible = () =>
|
||||||
&& root.classList.contains('visible')
|
root.isConnected &&
|
||||||
&& root.getClientRects().length > 0;
|
root.classList.contains('visible') &&
|
||||||
|
root.getClientRects().length > 0;
|
||||||
|
|
||||||
const keyboard = createSurfaceKeyboard({
|
const keyboard = createSurfaceKeyboard({
|
||||||
root,
|
root,
|
||||||
@@ -226,22 +245,29 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
open = false;
|
open = false;
|
||||||
root.classList.remove('visible');
|
root.classList.remove('visible');
|
||||||
const hide = () => { if (!open) root.hidden = true; };
|
const hide = () => {
|
||||||
|
if (!open) root.hidden = true;
|
||||||
|
};
|
||||||
root.addEventListener('transitionend', hide, { once: true });
|
root.addEventListener('transitionend', hide, { once: true });
|
||||||
globalThis.setTimeout?.(hide, 400);
|
globalThis.setTimeout?.(hide, 400);
|
||||||
if (statusLine) statusLine.textContent = defaultStatusText;
|
if (statusLine) statusLine.textContent = defaultStatusText;
|
||||||
keyboard.deactivate({ restoreFocus: true });
|
keyboard.deactivate({ restoreFocus: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const say = (text) => { if (statusLine) statusLine.textContent = text; };
|
const say = (text) => {
|
||||||
|
if (statusLine) statusLine.textContent = text;
|
||||||
|
};
|
||||||
|
|
||||||
const storeLabel = () => (status?.store === 'pinokio-environment'
|
const storeLabel = () =>
|
||||||
|
status?.store === 'pinokio-environment'
|
||||||
? 'your app configuration'
|
? 'your app configuration'
|
||||||
: 'your local .env');
|
: 'your local .env';
|
||||||
|
|
||||||
const submitUpdates = async (updates, doneVerb) => {
|
const submitUpdates = async (updates, doneVerb) => {
|
||||||
if (disposed || busy) return;
|
if (disposed || busy) return;
|
||||||
const googleWasUnset = !status?.keys?.find((key) => key.id === 'google-maps')?.set;
|
const googleWasUnset = !status?.keys?.find(
|
||||||
|
(key) => key.id === 'google-maps',
|
||||||
|
)?.set;
|
||||||
busy = true;
|
busy = true;
|
||||||
applyButton?.setAttribute('aria-disabled', 'true');
|
applyButton?.setAttribute('aria-disabled', 'true');
|
||||||
say('Saving…');
|
say('Saving…');
|
||||||
@@ -258,13 +284,17 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
|||||||
say(payload.error || `Save failed (${response.status}).`);
|
say(payload.error || `Save failed (${response.status}).`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const input of root.querySelectorAll('input[data-env-var]')) input.value = '';
|
for (const input of root.querySelectorAll('input[data-env-var]'))
|
||||||
|
input.value = '';
|
||||||
render(payload.status);
|
render(payload.status);
|
||||||
if (googleWasUnset && payload.saved?.includes('GOOGLE_MAPS_API_KEY')) {
|
if (googleWasUnset && payload.saved?.includes('GOOGLE_MAPS_API_KEY')) {
|
||||||
const strip = () => {
|
const strip = () => {
|
||||||
try {
|
try {
|
||||||
const next = stripKeylessBasemapFromHash(globalThis.location?.hash?.slice(1) || '');
|
const next = stripKeylessBasemapFromHash(
|
||||||
if (next !== null) globalThis.history?.replaceState?.(null, '', `#${next}`);
|
globalThis.location?.hash?.slice(1) || '',
|
||||||
|
);
|
||||||
|
if (next !== null)
|
||||||
|
globalThis.history?.replaceState?.(null, '', `#${next}`);
|
||||||
} catch {
|
} catch {
|
||||||
// Continuity is a nicety, never a blocker.
|
// Continuity is a nicety, never a blocker.
|
||||||
}
|
}
|
||||||
@@ -272,9 +302,14 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
|||||||
strip();
|
strip();
|
||||||
// The live share writer may re-serialize the still-OSM stack before
|
// The live share writer may re-serialize the still-OSM stack before
|
||||||
// the restart's reload lands, so strip again at the door.
|
// the restart's reload lands, so strip again at the door.
|
||||||
globalThis.addEventListener?.('pagehide', strip, { once: true, signal: lifetime.signal });
|
globalThis.addEventListener?.('pagehide', strip, {
|
||||||
|
once: true,
|
||||||
|
signal: lifetime.signal,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
say(`${doneVerb} ${storeLabel()}. Restarting — this page reloads itself.`);
|
say(
|
||||||
|
`${doneVerb} ${storeLabel()}. Restarting — this page reloads itself.`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
say(`Save failed: ${error?.message || error}`);
|
say(`Save failed: ${error?.message || error}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -287,7 +322,10 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
|||||||
if (disposed || busy) return;
|
if (disposed || busy) return;
|
||||||
const inputs = [...root.querySelectorAll('input[data-env-var]')];
|
const inputs = [...root.querySelectorAll('input[data-env-var]')];
|
||||||
const updates = collectKeyUpdates(
|
const updates = collectKeyUpdates(
|
||||||
inputs.map((input) => ({ envVar: input.dataset.envVar, value: input.value })),
|
inputs.map((input) => ({
|
||||||
|
envVar: input.dataset.envVar,
|
||||||
|
value: input.value,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
if (!Object.keys(updates).length) {
|
if (!Object.keys(updates).length) {
|
||||||
say('Paste at least one key first.');
|
say('Paste at least one key first.');
|
||||||
@@ -313,8 +351,9 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
|||||||
// Removal is destructive and — behind a framing defense that should already
|
// Removal is destructive and — behind a framing defense that should already
|
||||||
// stop it — a clickjack target. A confirm turns a single aligned click into
|
// stop it — a clickjack target. A confirm turns a single aligned click into
|
||||||
// a deliberate two-step the lure cannot pre-satisfy.
|
// a deliberate two-step the lure cannot pre-satisfy.
|
||||||
const ok = typeof globalThis.confirm !== 'function'
|
const ok =
|
||||||
|| globalThis.confirm('Remove this key from your saved configuration?');
|
typeof globalThis.confirm !== 'function' ||
|
||||||
|
globalThis.confirm('Remove this key from your saved configuration?');
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
void submitUpdates(
|
void submitUpdates(
|
||||||
Object.fromEntries(envVars.map((name) => [name, null])),
|
Object.fromEntries(envVars.map((name) => [name, null])),
|
||||||
@@ -327,7 +366,11 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
|||||||
// Re-entry for a fully-keyed setup, demos, and support: ?setup=1 opens the
|
// Re-entry for a fully-keyed setup, demos, and support: ?setup=1 opens the
|
||||||
// dialog even though the chip has retired.
|
// dialog even though the chip has retired.
|
||||||
try {
|
try {
|
||||||
if (new URLSearchParams(globalThis.location?.search || '').get('setup') === '1') openDialog();
|
if (
|
||||||
|
new URLSearchParams(globalThis.location?.search || '').get('setup') ===
|
||||||
|
'1'
|
||||||
|
)
|
||||||
|
openDialog();
|
||||||
} catch {
|
} catch {
|
||||||
// An unparsable location never blocks init.
|
// An unparsable location never blocks init.
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-3
@@ -1336,7 +1336,12 @@ function buildingPitch(bounds) {
|
|||||||
return -32;
|
return -32;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveBuildingBounds(lat, lon, query, boundaries = applicationServices.boundaries) {
|
async function resolveBuildingBounds(
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
query,
|
||||||
|
boundaries = applicationServices.boundaries,
|
||||||
|
) {
|
||||||
const overpassQuery = `
|
const overpassQuery = `
|
||||||
[out:json][timeout:10];
|
[out:json][timeout:10];
|
||||||
(
|
(
|
||||||
@@ -1352,8 +1357,15 @@ async function resolveBuildingBounds(lat, lon, query, boundaries = applicationSe
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = window.setTimeout(() => controller.abort(), 6000);
|
const timeout = window.setTimeout(() => controller.abort(), 6000);
|
||||||
try {
|
try {
|
||||||
const elements = await boundaries.query(overpassQuery, { signal: controller.signal });
|
const elements = await boundaries.query(overpassQuery, {
|
||||||
return selectBuildingBounds(Array.isArray(elements) ? elements : [], lat, lon, query);
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
return selectBuildingBounds(
|
||||||
|
Array.isArray(elements) ? elements : [],
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
query,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+38
-8
@@ -13,9 +13,27 @@ const GAZE_EPSILON = 0.08;
|
|||||||
* @param {number} [maxOffset=MAX_GAZE_SVG_UNITS] - Maximum SVG-space translation.
|
* @param {number} [maxOffset=MAX_GAZE_SVG_UNITS] - Maximum SVG-space translation.
|
||||||
* @returns {{x:number, y:number}}
|
* @returns {{x:number, y:number}}
|
||||||
*/
|
*/
|
||||||
export function calculateLogoGaze(clientX, clientY, rect, maxOffset = MAX_GAZE_SVG_UNITS) {
|
export function calculateLogoGaze(
|
||||||
const values = [clientX, clientY, rect?.left, rect?.top, rect?.width, rect?.height, maxOffset];
|
clientX,
|
||||||
if (!values.every(Number.isFinite) || rect.width <= 0 || rect.height <= 0 || maxOffset < 0) {
|
clientY,
|
||||||
|
rect,
|
||||||
|
maxOffset = MAX_GAZE_SVG_UNITS,
|
||||||
|
) {
|
||||||
|
const values = [
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
rect?.left,
|
||||||
|
rect?.top,
|
||||||
|
rect?.width,
|
||||||
|
rect?.height,
|
||||||
|
maxOffset,
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
!values.every(Number.isFinite) ||
|
||||||
|
rect.width <= 0 ||
|
||||||
|
rect.height <= 0 ||
|
||||||
|
maxOffset < 0
|
||||||
|
) {
|
||||||
return { x: 0, y: 0 };
|
return { x: 0, y: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +43,7 @@ export function calculateLogoGaze(clientX, clientY, rect, maxOffset = MAX_GAZE_S
|
|||||||
if (distance === 0) return { x: 0, y: 0 };
|
if (distance === 0) return { x: 0, y: 0 };
|
||||||
|
|
||||||
const strength = Math.min(distance / FULL_GAZE_DISTANCE_PX, 1);
|
const strength = Math.min(distance / FULL_GAZE_DISTANCE_PX, 1);
|
||||||
const scale = maxOffset * strength / distance;
|
const scale = (maxOffset * strength) / distance;
|
||||||
return { x: dx * scale, y: dy * scale };
|
return { x: dx * scale, y: dy * scale };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +61,9 @@ export function initLogoGaze(root = document) {
|
|||||||
const logos = [...root.querySelectorAll('[data-logo-gaze]')];
|
const logos = [...root.querySelectorAll('[data-logo-gaze]')];
|
||||||
if (!logos.length) return () => {};
|
if (!logos.length) return () => {};
|
||||||
|
|
||||||
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
const reducedMotion = window.matchMedia?.(
|
||||||
|
'(prefers-reduced-motion: reduce)',
|
||||||
|
).matches;
|
||||||
const states = logos.map((element) => ({
|
const states = logos.map((element) => ({
|
||||||
element,
|
element,
|
||||||
parts: [],
|
parts: [],
|
||||||
@@ -68,7 +88,10 @@ export function initLogoGaze(root = document) {
|
|||||||
const deltaX = state.targetX - state.currentX;
|
const deltaX = state.targetX - state.currentX;
|
||||||
const deltaY = state.targetY - state.currentY;
|
const deltaY = state.targetY - state.currentY;
|
||||||
|
|
||||||
if (Math.abs(deltaX) <= GAZE_EPSILON && Math.abs(deltaY) <= GAZE_EPSILON) {
|
if (
|
||||||
|
Math.abs(deltaX) <= GAZE_EPSILON &&
|
||||||
|
Math.abs(deltaY) <= GAZE_EPSILON
|
||||||
|
) {
|
||||||
state.currentX = state.targetX;
|
state.currentX = state.targetX;
|
||||||
state.currentY = state.targetY;
|
state.currentY = state.targetY;
|
||||||
} else {
|
} else {
|
||||||
@@ -94,7 +117,10 @@ export function initLogoGaze(root = document) {
|
|||||||
const markup = await response.text();
|
const markup = await response.text();
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
|
||||||
const svgDocument = new DOMParser().parseFromString(markup, 'image/svg+xml');
|
const svgDocument = new DOMParser().parseFromString(
|
||||||
|
markup,
|
||||||
|
'image/svg+xml',
|
||||||
|
);
|
||||||
if (svgDocument.querySelector('parsererror')) return;
|
if (svgDocument.querySelector('parsererror')) return;
|
||||||
const sourceSvg = svgDocument.documentElement;
|
const sourceSvg = svgDocument.documentElement;
|
||||||
|
|
||||||
@@ -122,7 +148,11 @@ export function initLogoGaze(root = document) {
|
|||||||
const onPointerMove = (event) => {
|
const onPointerMove = (event) => {
|
||||||
if (reducedMotion || event.pointerType === 'touch') return;
|
if (reducedMotion || event.pointerType === 'touch') return;
|
||||||
for (const state of states) {
|
for (const state of states) {
|
||||||
const gaze = calculateLogoGaze(event.clientX, event.clientY, state.element.getBoundingClientRect());
|
const gaze = calculateLogoGaze(
|
||||||
|
event.clientX,
|
||||||
|
event.clientY,
|
||||||
|
state.element.getBoundingClientRect(),
|
||||||
|
);
|
||||||
state.targetX = gaze.x;
|
state.targetX = gaze.x;
|
||||||
state.targetY = gaze.y;
|
state.targetY = gaze.y;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -30,12 +30,14 @@ export function announceNavigationAuthority(
|
|||||||
{ eventTarget = globalThis.window, cancelPendingSelection = true } = {},
|
{ eventTarget = globalThis.window, cancelPendingSelection = true } = {},
|
||||||
) {
|
) {
|
||||||
if (typeof eventTarget?.dispatchEvent !== 'function') return false;
|
if (typeof eventTarget?.dispatchEvent !== 'function') return false;
|
||||||
eventTarget.dispatchEvent(new CustomEvent(NAVIGATION_AUTHORITY_EVENT, {
|
eventTarget.dispatchEvent(
|
||||||
|
new CustomEvent(NAVIGATION_AUTHORITY_EVENT, {
|
||||||
detail: {
|
detail: {
|
||||||
reason: String(reason || 'layer-focus'),
|
reason: String(reason || 'layer-focus'),
|
||||||
cancelPendingSelection: Boolean(cancelPendingSelection),
|
cancelPendingSelection: Boolean(cancelPendingSelection),
|
||||||
},
|
},
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +48,12 @@ export function announceNavigationAuthority(
|
|||||||
* @returns {() => void} Idempotent disposer.
|
* @returns {() => void} Idempotent disposer.
|
||||||
*/
|
*/
|
||||||
export function registerNavigationAuthorityListener(eventTarget, listener) {
|
export function registerNavigationAuthorityListener(eventTarget, listener) {
|
||||||
if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener
|
if (
|
||||||
|| typeof listener !== 'function') return () => {};
|
!eventTarget?.addEventListener ||
|
||||||
|
!eventTarget?.removeEventListener ||
|
||||||
|
typeof listener !== 'function'
|
||||||
|
)
|
||||||
|
return () => {};
|
||||||
eventTarget.addEventListener(NAVIGATION_AUTHORITY_EVENT, listener);
|
eventTarget.addEventListener(NAVIGATION_AUTHORITY_EVENT, listener);
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
+5
-2
@@ -1,5 +1,8 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { holdContinuousRender, releaseContinuousRender } from './renderGovernor.js';
|
import {
|
||||||
|
holdContinuousRender,
|
||||||
|
releaseContinuousRender,
|
||||||
|
} from './renderGovernor.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OrbitController — smooth orbit around a target point.
|
* OrbitController — smooth orbit around a target point.
|
||||||
@@ -54,7 +57,7 @@ export class OrbitController {
|
|||||||
const hpr = new Cesium.HeadingPitchRange(
|
const hpr = new Cesium.HeadingPitchRange(
|
||||||
this.angle,
|
this.angle,
|
||||||
Cesium.Math.toRadians(this.pitch),
|
Cesium.Math.toRadians(this.pitch),
|
||||||
this.radius
|
this.radius,
|
||||||
);
|
);
|
||||||
this.viewer.camera.lookAt(this.target, hpr);
|
this.viewer.camera.lookAt(this.target, hpr);
|
||||||
});
|
});
|
||||||
|
|||||||
+523
-181
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. */
|
/** Install the font-loading invalidation hooks once, when the API exists. */
|
||||||
export function installWorldOverlayFontInvalidation() {
|
export function installWorldOverlayFontInvalidation() {
|
||||||
if (_fontInvalidationInstalled || typeof document === 'undefined' || !document.fonts) return;
|
if (
|
||||||
|
_fontInvalidationInstalled ||
|
||||||
|
typeof document === 'undefined' ||
|
||||||
|
!document.fonts
|
||||||
|
)
|
||||||
|
return;
|
||||||
_fontInvalidationInstalled = true;
|
_fontInvalidationInstalled = true;
|
||||||
_observedFontSet = document.fonts;
|
_observedFontSet = document.fonts;
|
||||||
const generation = ++_fontInvalidationGeneration;
|
const generation = ++_fontInvalidationGeneration;
|
||||||
Promise.resolve(document.fonts.ready)
|
Promise.resolve(document.fonts.ready)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (generation === _fontInvalidationGeneration) clearWorldOverlayTextMeasureCache();
|
if (generation === _fontInvalidationGeneration)
|
||||||
|
clearWorldOverlayTextMeasureCache();
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
_observedFontSet.addEventListener?.('loadingdone', clearWorldOverlayTextMeasureCache);
|
_observedFontSet.addEventListener?.(
|
||||||
|
'loadingdone',
|
||||||
|
clearWorldOverlayTextMeasureCache,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove font hooks and cached measurements when the overlay host is destroyed. */
|
/** Remove font hooks and cached measurements when the overlay host is destroyed. */
|
||||||
export function destroyWorldOverlayDraw() {
|
export function destroyWorldOverlayDraw() {
|
||||||
_fontInvalidationGeneration++;
|
_fontInvalidationGeneration++;
|
||||||
_observedFontSet?.removeEventListener?.('loadingdone', clearWorldOverlayTextMeasureCache);
|
_observedFontSet?.removeEventListener?.(
|
||||||
|
'loadingdone',
|
||||||
|
clearWorldOverlayTextMeasureCache,
|
||||||
|
);
|
||||||
_observedFontSet = null;
|
_observedFontSet = null;
|
||||||
_fontInvalidationInstalled = false;
|
_fontInvalidationInstalled = false;
|
||||||
clearWorldOverlayTextMeasureCache();
|
clearWorldOverlayTextMeasureCache();
|
||||||
@@ -95,7 +107,8 @@ export function measureWorldOverlayText(ctx, text, font) {
|
|||||||
}
|
}
|
||||||
fontCache.set(normalizedText, { width, usedAt: ++_textMeasureClock });
|
fontCache.set(normalizedText, { width, usedAt: ++_textMeasureClock });
|
||||||
_textMeasureCacheSize++;
|
_textMeasureCacheSize++;
|
||||||
if (_textMeasureCacheSize > TEXT_MEASURE_CACHE_LIMIT) evictOldestTextMeasureEntry();
|
if (_textMeasureCacheSize > TEXT_MEASURE_CACHE_LIMIT)
|
||||||
|
evictOldestTextMeasureEntry();
|
||||||
return width;
|
return width;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +126,14 @@ export function getWorldOverlayTextMeasureCacheSize() {
|
|||||||
* @param {number} h
|
* @param {number} h
|
||||||
* @param {number} radius
|
* @param {number} radius
|
||||||
*/
|
*/
|
||||||
export function roundedRectPath(path, x, y, w, h, radius = WORLD_OVERLAY_STYLE.radius) {
|
export function roundedRectPath(
|
||||||
|
path,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
radius = WORLD_OVERLAY_STYLE.radius,
|
||||||
|
) {
|
||||||
if (typeof path.roundRect === 'function') {
|
if (typeof path.roundRect === 'function') {
|
||||||
path.roundRect(x, y, w, h, radius);
|
path.roundRect(x, y, w, h, radius);
|
||||||
return;
|
return;
|
||||||
@@ -137,11 +157,14 @@ export function roundedRectPath(path, x, y, w, h, radius = WORLD_OVERLAY_STYLE.r
|
|||||||
* @param {number} [options.fadeStartRatio]
|
* @param {number} [options.fadeStartRatio]
|
||||||
* @returns {number}
|
* @returns {number}
|
||||||
*/
|
*/
|
||||||
export function distanceFade(distanceM, {
|
export function distanceFade(
|
||||||
|
distanceM,
|
||||||
|
{
|
||||||
minDistance = 0,
|
minDistance = 0,
|
||||||
maxDistance = Number.POSITIVE_INFINITY,
|
maxDistance = Number.POSITIVE_INFINITY,
|
||||||
fadeStartRatio = 0.7,
|
fadeStartRatio = 0.7,
|
||||||
} = {}) {
|
} = {},
|
||||||
|
) {
|
||||||
if (!Number.isFinite(distanceM)) return 0;
|
if (!Number.isFinite(distanceM)) return 0;
|
||||||
const min = Number.isFinite(minDistance) ? Math.max(0, minDistance) : 0;
|
const min = Number.isFinite(minDistance) ? Math.max(0, minDistance) : 0;
|
||||||
if (distanceM < min) return 0;
|
if (distanceM < min) return 0;
|
||||||
@@ -192,17 +215,27 @@ export function altitudeScale(altitudeM, curve = null) {
|
|||||||
const end = Number(curve.end);
|
const end = Number(curve.end);
|
||||||
const midValue = Number(curve.midValue);
|
const midValue = Number(curve.midValue);
|
||||||
const endValue = Number(curve.endValue);
|
const endValue = Number(curve.endValue);
|
||||||
if (!Number.isFinite(fullEnd) || !Number.isFinite(midEnd) || !Number.isFinite(end)
|
if (
|
||||||
|| !Number.isFinite(midValue) || !Number.isFinite(endValue)) return 1;
|
!Number.isFinite(fullEnd) ||
|
||||||
|
!Number.isFinite(midEnd) ||
|
||||||
|
!Number.isFinite(end) ||
|
||||||
|
!Number.isFinite(midValue) ||
|
||||||
|
!Number.isFinite(endValue)
|
||||||
|
)
|
||||||
|
return 1;
|
||||||
if (altitudeM <= fullEnd) return 1;
|
if (altitudeM <= fullEnd) return 1;
|
||||||
if (altitudeM <= midEnd) {
|
if (altitudeM <= midEnd) {
|
||||||
const span = Math.max(1, midEnd - fullEnd);
|
const span = Math.max(1, midEnd - fullEnd);
|
||||||
let progress = Math.max(0, Math.min(1, (altitudeM - fullEnd) / span));
|
let progress = Math.max(0, Math.min(1, (altitudeM - fullEnd) / span));
|
||||||
if (curve.smoothToMid === true) progress = progress * progress * (3 - 2 * progress);
|
if (curve.smoothToMid === true)
|
||||||
|
progress = progress * progress * (3 - 2 * progress);
|
||||||
return 1 + (midValue - 1) * progress;
|
return 1 + (midValue - 1) * progress;
|
||||||
}
|
}
|
||||||
if (altitudeM >= end) return endValue;
|
if (altitudeM >= end) return endValue;
|
||||||
const progress = Math.max(0, Math.min(1, (altitudeM - midEnd) / Math.max(1, end - midEnd)));
|
const progress = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(1, (altitudeM - midEnd) / Math.max(1, end - midEnd)),
|
||||||
|
);
|
||||||
return midValue + (endValue - midValue) * progress;
|
return midValue + (endValue - midValue) * progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,11 +248,14 @@ export function altitudeScale(altitudeM, curve = null) {
|
|||||||
* @param {number} [options.fadeEnd]
|
* @param {number} [options.fadeEnd]
|
||||||
* @returns {number}
|
* @returns {number}
|
||||||
*/
|
*/
|
||||||
export function altitudeFade(altitudeM, {
|
export function altitudeFade(
|
||||||
|
altitudeM,
|
||||||
|
{
|
||||||
minAltitude = Number.NEGATIVE_INFINITY,
|
minAltitude = Number.NEGATIVE_INFINITY,
|
||||||
fadeStart = Number.POSITIVE_INFINITY,
|
fadeStart = Number.POSITIVE_INFINITY,
|
||||||
fadeEnd = Number.POSITIVE_INFINITY,
|
fadeEnd = Number.POSITIVE_INFINITY,
|
||||||
} = {}) {
|
} = {},
|
||||||
|
) {
|
||||||
if (!Number.isFinite(altitudeM)) return 1;
|
if (!Number.isFinite(altitudeM)) return 1;
|
||||||
if (Number.isFinite(minAltitude) && altitudeM < minAltitude) return 0;
|
if (Number.isFinite(minAltitude) && altitudeM < minAltitude) return 0;
|
||||||
if (!Number.isFinite(fadeEnd)) return 1;
|
if (!Number.isFinite(fadeEnd)) return 1;
|
||||||
@@ -238,24 +274,32 @@ export function combinedOverlayAlpha({
|
|||||||
altitudeFade: altitudeAlpha = 1,
|
altitudeFade: altitudeAlpha = 1,
|
||||||
keyholeEdgeFade = 1,
|
keyholeEdgeFade = 1,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return clampUnit(sourceAlpha)
|
return (
|
||||||
* clampUnit(temporalFade)
|
clampUnit(sourceAlpha) *
|
||||||
* clampUnit(distanceAlpha)
|
clampUnit(temporalFade) *
|
||||||
* clampUnit(altitudeAlpha)
|
clampUnit(distanceAlpha) *
|
||||||
* clampUnit(keyholeEdgeFade);
|
clampUnit(altitudeAlpha) *
|
||||||
|
clampUnit(keyholeEdgeFade)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampUnit(value) {
|
function clampUnit(value) {
|
||||||
return Number.isFinite(Number(value)) ? Math.max(0, Math.min(1, Number(value))) : 1;
|
return Number.isFinite(Number(value))
|
||||||
|
? Math.max(0, Math.min(1, Number(value)))
|
||||||
|
: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackDisplayText(entry) {
|
function trackDisplayText(entry) {
|
||||||
const title = entry?.title || '';
|
const title = entry?.title || '';
|
||||||
const detail = Array.isArray(entry?.details) ? entry.details[0] || '' : '';
|
const detail = Array.isArray(entry?.details) ? entry.details[0] || '' : '';
|
||||||
if (entry._overlayTrackDisplayTitle !== title || entry._overlayTrackDisplayDetail !== detail) {
|
if (
|
||||||
|
entry._overlayTrackDisplayTitle !== title ||
|
||||||
|
entry._overlayTrackDisplayDetail !== detail
|
||||||
|
) {
|
||||||
entry._overlayTrackDisplayTitle = title;
|
entry._overlayTrackDisplayTitle = title;
|
||||||
entry._overlayTrackDisplayDetail = detail;
|
entry._overlayTrackDisplayDetail = detail;
|
||||||
entry._overlayTrackDisplayText = title && detail ? `${title} · ${detail}` : title || detail;
|
entry._overlayTrackDisplayText =
|
||||||
|
title && detail ? `${title} · ${detail}` : title || detail;
|
||||||
}
|
}
|
||||||
return entry._overlayTrackDisplayText || '';
|
return entry._overlayTrackDisplayText || '';
|
||||||
}
|
}
|
||||||
@@ -268,16 +312,23 @@ function trackDisplayText(entry) {
|
|||||||
* @returns {{w:number,h:number,padX:number,padY:number,titleH:number,lineH:number,thumbW:number,thumbH:number}}
|
* @returns {{w:number,h:number,padX:number,padY:number,titleH:number,lineH:number,thumbW:number,thumbH:number}}
|
||||||
*/
|
*/
|
||||||
export function measureOverlayEntry(ctx, entry, out = {}) {
|
export function measureOverlayEntry(ctx, entry, out = {}) {
|
||||||
const variant = entry?.selected ? 'selected' : String(entry?.variant || 'label');
|
const variant = entry?.selected
|
||||||
|
? 'selected'
|
||||||
|
: String(entry?.variant || 'label');
|
||||||
const details = Array.isArray(entry?.details) ? entry.details : [];
|
const details = Array.isArray(entry?.details) ? entry.details : [];
|
||||||
const selected = variant === 'selected';
|
const selected = variant === 'selected';
|
||||||
const tracked = variant === 'tracked';
|
const tracked = variant === 'tracked';
|
||||||
const tactical = entry?.cardStyle === 'tactical';
|
const tactical = entry?.cardStyle === 'tactical';
|
||||||
const titleFont = tracked
|
const titleFont = tracked
|
||||||
? WORLD_OVERLAY_STYLE.fontTrackedTitle
|
? WORLD_OVERLAY_STYLE.fontTrackedTitle
|
||||||
: selected ? WORLD_OVERLAY_STYLE.fontSelected : WORLD_OVERLAY_STYLE.fontTitle;
|
: selected
|
||||||
let titleWidth = measureWorldOverlayText(ctx, entry?.title || '',
|
? WORLD_OVERLAY_STYLE.fontSelected
|
||||||
variant === 'label' ? WORLD_OVERLAY_STYLE.fontLabel : titleFont);
|
: WORLD_OVERLAY_STYLE.fontTitle;
|
||||||
|
let titleWidth = measureWorldOverlayText(
|
||||||
|
ctx,
|
||||||
|
entry?.title || '',
|
||||||
|
variant === 'label' ? WORLD_OVERLAY_STYLE.fontLabel : titleFont,
|
||||||
|
);
|
||||||
if (variant === 'track' && details[0]) {
|
if (variant === 'track' && details[0]) {
|
||||||
titleWidth = measureWorldOverlayText(
|
titleWidth = measureWorldOverlayText(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -292,16 +343,38 @@ export function measureOverlayEntry(ctx, entry, out = {}) {
|
|||||||
measureWorldOverlayText(
|
measureWorldOverlayText(
|
||||||
ctx,
|
ctx,
|
||||||
details[i],
|
details[i],
|
||||||
tracked ? WORLD_OVERLAY_STYLE.fontTrackedDetail : WORLD_OVERLAY_STYLE.fontDetail,
|
tracked
|
||||||
|
? WORLD_OVERLAY_STYLE.fontTrackedDetail
|
||||||
|
: WORLD_OVERLAY_STYLE.fontDetail,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
out.padX = tracked ? 13 : selected ? 12 : variant === 'label' || variant === 'track' ? 6 : 9;
|
out.padX = tracked
|
||||||
out.padY = tracked ? 9 : selected ? 8 : variant === 'label' || variant === 'track' ? 4 : 6;
|
? 13
|
||||||
|
: selected
|
||||||
|
? 12
|
||||||
|
: variant === 'label' || variant === 'track'
|
||||||
|
? 6
|
||||||
|
: 9;
|
||||||
|
out.padY = tracked
|
||||||
|
? 9
|
||||||
|
: selected
|
||||||
|
? 8
|
||||||
|
: variant === 'label' || variant === 'track'
|
||||||
|
? 4
|
||||||
|
: 6;
|
||||||
out.titleH = tactical
|
out.titleH = tactical
|
||||||
? (selected ? 14 : 12)
|
? selected
|
||||||
: tracked ? 13 : selected ? 15 : variant === 'label' || variant === 'track' ? 11 : 13;
|
? 14
|
||||||
|
: 12
|
||||||
|
: tracked
|
||||||
|
? 13
|
||||||
|
: selected
|
||||||
|
? 15
|
||||||
|
: variant === 'label' || variant === 'track'
|
||||||
|
? 11
|
||||||
|
: 13;
|
||||||
out.lineH = tracked ? 17 : selected ? 15 : 13;
|
out.lineH = tracked ? 17 : selected ? 15 : 13;
|
||||||
out.thumbW = 0;
|
out.thumbW = 0;
|
||||||
out.thumbH = 0;
|
out.thumbH = 0;
|
||||||
@@ -325,7 +398,17 @@ export function measureOverlayEntry(ctx, entry, out = {}) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writePlacement(out, corner, x, y, w, h, anchorX, anchorY, signedLeaderOffset = 0) {
|
function writePlacement(
|
||||||
|
out,
|
||||||
|
corner,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
anchorX,
|
||||||
|
anchorY,
|
||||||
|
signedLeaderOffset = 0,
|
||||||
|
) {
|
||||||
const placement = out || {};
|
const placement = out || {};
|
||||||
placement.corner = corner;
|
placement.corner = corner;
|
||||||
placement.rect ||= {};
|
placement.rect ||= {};
|
||||||
@@ -350,12 +433,17 @@ function writePlacement(out, corner, x, y, w, h, anchorX, anchorY, signedLeaderO
|
|||||||
// pushed the card sideways off its anchor, which reads as "the card isn't
|
// pushed the card sideways off its anchor, which reads as "the card isn't
|
||||||
// attached to that camera".
|
// attached to that camera".
|
||||||
placement.leadToX = anchorX;
|
placement.leadToX = anchorX;
|
||||||
placement.leadToY = corner === 'above' ? placement.rect.y + h : placement.rect.y;
|
placement.leadToY =
|
||||||
|
corner === 'above' ? placement.rect.y + h : placement.rect.y;
|
||||||
} else {
|
} else {
|
||||||
placement.leadFromX = anchorX;
|
placement.leadFromX = anchorX;
|
||||||
placement.leadFromY = anchorY;
|
placement.leadFromY = anchorY;
|
||||||
placement.leadToX = corner === 'left' ? placement.rect.x + w : placement.rect.x;
|
placement.leadToX =
|
||||||
placement.leadToY = Math.max(placement.rect.y, Math.min(anchorY, placement.rect.y + h));
|
corner === 'left' ? placement.rect.x + w : placement.rect.x;
|
||||||
|
placement.leadToY = Math.max(
|
||||||
|
placement.rect.y,
|
||||||
|
Math.min(anchorY, placement.rect.y + h),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return placement;
|
return placement;
|
||||||
}
|
}
|
||||||
@@ -373,7 +461,10 @@ const VERTICAL_PLACEMENT_ORDERS = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function clampPlacementCoordinate(value, size, viewportSize, margin = 4) {
|
function clampPlacementCoordinate(value, size, viewportSize, margin = 4) {
|
||||||
return Math.max(margin, Math.min(value, Math.max(margin, viewportSize - size - margin)));
|
return Math.max(
|
||||||
|
margin,
|
||||||
|
Math.min(value, Math.max(margin, viewportSize - size - margin)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -394,7 +485,8 @@ function clampPlacementCoordinate(value, size, viewportSize, margin = 4) {
|
|||||||
* @param {Array<object>} [out]
|
* @param {Array<object>} [out]
|
||||||
* @returns {Array<object>}
|
* @returns {Array<object>}
|
||||||
*/
|
*/
|
||||||
export function placementVariants({
|
export function placementVariants(
|
||||||
|
{
|
||||||
anchorX,
|
anchorX,
|
||||||
anchorY,
|
anchorY,
|
||||||
width,
|
width,
|
||||||
@@ -406,7 +498,9 @@ export function placementVariants({
|
|||||||
leaderOffset = 0,
|
leaderOffset = 0,
|
||||||
verticalOnly = false,
|
verticalOnly = false,
|
||||||
viewportMargin = 4,
|
viewportMargin = 4,
|
||||||
}, out = []) {
|
},
|
||||||
|
out = [],
|
||||||
|
) {
|
||||||
const automatic = anchorY - gap - height >= 4 ? 'above' : 'below';
|
const automatic = anchorY - gap - height >= 4 ? 'above' : 'below';
|
||||||
const orders = verticalOnly ? VERTICAL_PLACEMENT_ORDERS : PLACEMENT_ORDERS;
|
const orders = verticalOnly ? VERTICAL_PLACEMENT_ORDERS : PLACEMENT_ORDERS;
|
||||||
const order = orders[preferred] || orders[automatic];
|
const order = orders[preferred] || orders[automatic];
|
||||||
@@ -437,7 +531,9 @@ export function placementVariants({
|
|||||||
height,
|
height,
|
||||||
anchorX,
|
anchorX,
|
||||||
anchorY,
|
anchorY,
|
||||||
corner === 'above' || corner === 'left' ? negativeLeaderOffset : positiveLeaderOffset,
|
corner === 'above' || corner === 'left'
|
||||||
|
? negativeLeaderOffset
|
||||||
|
: positiveLeaderOffset,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
out.length = order.length;
|
out.length = order.length;
|
||||||
@@ -454,9 +550,15 @@ function drawLeader(ctx, placement, accent) {
|
|||||||
if (placement.leaderOffset === 0) {
|
if (placement.leaderOffset === 0) {
|
||||||
ctx.moveTo(placement.leadFromX, placement.leadFromY);
|
ctx.moveTo(placement.leadFromX, placement.leadFromY);
|
||||||
} else if (placement.corner === 'above' || placement.corner === 'below') {
|
} else if (placement.corner === 'above' || placement.corner === 'below') {
|
||||||
ctx.moveTo(placement.leadFromX, placement.leadFromY + placement.leaderOffset);
|
ctx.moveTo(
|
||||||
|
placement.leadFromX,
|
||||||
|
placement.leadFromY + placement.leaderOffset,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
ctx.moveTo(placement.leadFromX + placement.leaderOffset, placement.leadFromY);
|
ctx.moveTo(
|
||||||
|
placement.leadFromX + placement.leaderOffset,
|
||||||
|
placement.leadFromY,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ctx.lineTo(placement.leadToX, placement.leadToY);
|
ctx.lineTo(placement.leadToX, placement.leadToY);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
@@ -468,9 +570,13 @@ function drawCardChrome(ctx, entry, placement, selected = false) {
|
|||||||
drawLeader(ctx, placement, accent);
|
drawLeader(ctx, placement, accent);
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
roundedRectPath(ctx, x, y, w, h);
|
roundedRectPath(ctx, x, y, w, h);
|
||||||
ctx.fillStyle = selected ? WORLD_OVERLAY_STYLE.selectedBackground : WORLD_OVERLAY_STYLE.background;
|
ctx.fillStyle = selected
|
||||||
|
? WORLD_OVERLAY_STYLE.selectedBackground
|
||||||
|
: WORLD_OVERLAY_STYLE.background;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.strokeStyle = selected ? WORLD_OVERLAY_STYLE.selectedBorder : WORLD_OVERLAY_STYLE.border;
|
ctx.strokeStyle = selected
|
||||||
|
? WORLD_OVERLAY_STYLE.selectedBorder
|
||||||
|
: WORLD_OVERLAY_STYLE.border;
|
||||||
ctx.lineWidth = selected ? 1.25 : 1;
|
ctx.lineWidth = selected ? 1.25 : 1;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.fillStyle = accent;
|
ctx.fillStyle = accent;
|
||||||
@@ -482,7 +588,9 @@ function drawCardText(ctx, entry, placement, selected = false, topOffset = 0) {
|
|||||||
const x = placement.rect.x + (selected ? 12 : 9);
|
const x = placement.rect.x + (selected ? 12 : 9);
|
||||||
let y = placement.rect.y + (selected ? 8 : 6) + topOffset;
|
let y = placement.rect.y + (selected ? 8 : 6) + topOffset;
|
||||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
|
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
|
||||||
ctx.font = selected ? WORLD_OVERLAY_STYLE.fontSelected : WORLD_OVERLAY_STYLE.fontTitle;
|
ctx.font = selected
|
||||||
|
? WORLD_OVERLAY_STYLE.fontSelected
|
||||||
|
: WORLD_OVERLAY_STYLE.fontTitle;
|
||||||
ctx.textBaseline = 'top';
|
ctx.textBaseline = 'top';
|
||||||
ctx.fillText(String(entry.title || ''), x, y);
|
ctx.fillText(String(entry.title || ''), x, y);
|
||||||
y += selected ? 15 : 13;
|
y += selected ? 15 : 13;
|
||||||
@@ -497,7 +605,8 @@ function drawCardText(ctx, entry, placement, selected = false, topOffset = 0) {
|
|||||||
function colorWithAlpha(color, alpha) {
|
function colorWithAlpha(color, alpha) {
|
||||||
const text = String(color || WORLD_OVERLAY_STYLE.accent).trim();
|
const text = String(color || WORLD_OVERLAY_STYLE.accent).trim();
|
||||||
const triplet = text.match(/^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})$/);
|
const triplet = text.match(/^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})$/);
|
||||||
if (triplet) return `rgba(${triplet[1]}, ${triplet[2]}, ${triplet[3]}, ${alpha})`;
|
if (triplet)
|
||||||
|
return `rgba(${triplet[1]}, ${triplet[2]}, ${triplet[3]}, ${alpha})`;
|
||||||
const hex = text.match(/^#([0-9a-f]{6})$/i);
|
const hex = text.match(/^#([0-9a-f]{6})$/i);
|
||||||
if (hex) {
|
if (hex) {
|
||||||
const value = Number.parseInt(hex[1], 16);
|
const value = Number.parseInt(hex[1], 16);
|
||||||
@@ -534,33 +643,57 @@ function leaderDrawRatio(entry) {
|
|||||||
return Number.isFinite(ratio) ? Math.max(0.1, Math.min(0.9, ratio)) : 0.68;
|
return Number.isFinite(ratio) ? Math.max(0.1, Math.min(0.9, ratio)) : 0.68;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function leaderRevealProgress(entry, timestamp = globalThis.performance?.now?.() ?? Date.now()) {
|
export function leaderRevealProgress(
|
||||||
const linear = Math.min(1, animationLinearProgress(entry, timestamp) / leaderDrawRatio(entry));
|
entry,
|
||||||
|
timestamp = globalThis.performance?.now?.() ?? Date.now(),
|
||||||
|
) {
|
||||||
|
const linear = Math.min(
|
||||||
|
1,
|
||||||
|
animationLinearProgress(entry, timestamp) / leaderDrawRatio(entry),
|
||||||
|
);
|
||||||
return 1 - Math.pow(1 - linear, 3);
|
return 1 - Math.pow(1 - linear, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tacticalCardRevealAlpha(entry, timestamp = globalThis.performance?.now?.() ?? Date.now()) {
|
export function tacticalCardRevealAlpha(
|
||||||
|
entry,
|
||||||
|
timestamp = globalThis.performance?.now?.() ?? Date.now(),
|
||||||
|
) {
|
||||||
if (!(Math.max(0, Number(entry?.leaderAnimationMs) || 0) > 0)) return 1;
|
if (!(Math.max(0, Number(entry?.leaderAnimationMs) || 0) > 0)) return 1;
|
||||||
const start = leaderDrawRatio(entry);
|
const start = leaderDrawRatio(entry);
|
||||||
const linear = Math.max(0, Math.min(1,
|
const linear = Math.max(
|
||||||
(animationLinearProgress(entry, timestamp) - start) / Math.max(0.1, 1 - start)));
|
0,
|
||||||
|
Math.min(
|
||||||
|
1,
|
||||||
|
(animationLinearProgress(entry, timestamp) - start) /
|
||||||
|
Math.max(0.1, 1 - start),
|
||||||
|
),
|
||||||
|
);
|
||||||
return linear * linear * (3 - 2 * linear);
|
return linear * linear * (3 - 2 * linear);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawTacticalLeader(ctx, entry, placement, accent, timestamp) {
|
function drawTacticalLeader(ctx, entry, placement, accent, timestamp) {
|
||||||
ctx.strokeStyle = accent;
|
ctx.strokeStyle = accent;
|
||||||
ctx.lineWidth = (entry.leaderStyle === 'elbow' ? 1.5 : 1) / (placement.paintScale || 1);
|
ctx.lineWidth =
|
||||||
|
(entry.leaderStyle === 'elbow' ? 1.5 : 1) / (placement.paintScale || 1);
|
||||||
ctx.lineJoin = 'round';
|
ctx.lineJoin = 'round';
|
||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
if (entry.leaderStyle !== 'elbow'
|
if (
|
||||||
|| (placement.corner !== 'above' && placement.corner !== 'below')) {
|
entry.leaderStyle !== 'elbow' ||
|
||||||
|
(placement.corner !== 'above' && placement.corner !== 'below')
|
||||||
|
) {
|
||||||
if (placement.leaderOffset === 0) {
|
if (placement.leaderOffset === 0) {
|
||||||
ctx.moveTo(placement.leadFromX, placement.leadFromY);
|
ctx.moveTo(placement.leadFromX, placement.leadFromY);
|
||||||
} else if (placement.corner === 'above' || placement.corner === 'below') {
|
} else if (placement.corner === 'above' || placement.corner === 'below') {
|
||||||
ctx.moveTo(placement.leadFromX, placement.leadFromY + placement.leaderOffset);
|
ctx.moveTo(
|
||||||
|
placement.leadFromX,
|
||||||
|
placement.leadFromY + placement.leaderOffset,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
ctx.moveTo(placement.leadFromX + placement.leaderOffset, placement.leadFromY);
|
ctx.moveTo(
|
||||||
|
placement.leadFromX + placement.leaderOffset,
|
||||||
|
placement.leadFromY,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ctx.lineTo(placement.leadToX, placement.leadToY);
|
ctx.lineTo(placement.leadToX, placement.leadToY);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
@@ -575,7 +708,9 @@ function drawTacticalLeader(ctx, entry, placement, accent, timestamp) {
|
|||||||
const anchorY = placement.leadFromY;
|
const anchorY = placement.leadFromY;
|
||||||
const horizontalLength = Math.abs(anchorX - cardX);
|
const horizontalLength = Math.abs(anchorX - cardX);
|
||||||
const verticalLength = Math.abs(anchorY - cardY);
|
const verticalLength = Math.abs(anchorY - cardY);
|
||||||
let remaining = leaderRevealProgress(entry, timestamp) * (horizontalLength + verticalLength);
|
let remaining =
|
||||||
|
leaderRevealProgress(entry, timestamp) *
|
||||||
|
(horizontalLength + verticalLength);
|
||||||
ctx.moveTo(anchorX, anchorY);
|
ctx.moveTo(anchorX, anchorY);
|
||||||
if (remaining <= horizontalLength) {
|
if (remaining <= horizontalLength) {
|
||||||
const direction = Math.sign(cardX - anchorX) || -1;
|
const direction = Math.sign(cardX - anchorX) || -1;
|
||||||
@@ -584,7 +719,10 @@ function drawTacticalLeader(ctx, entry, placement, accent, timestamp) {
|
|||||||
ctx.lineTo(cardX, anchorY);
|
ctx.lineTo(cardX, anchorY);
|
||||||
remaining -= horizontalLength;
|
remaining -= horizontalLength;
|
||||||
const direction = Math.sign(cardY - anchorY) || -1;
|
const direction = Math.sign(cardY - anchorY) || -1;
|
||||||
ctx.lineTo(cardX, anchorY + direction * Math.min(verticalLength, remaining));
|
ctx.lineTo(
|
||||||
|
cardX,
|
||||||
|
anchorY + direction * Math.min(verticalLength, remaining),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
@@ -600,9 +738,15 @@ export function paintTacticalCard(ctx, entry, placement, alpha = 1) {
|
|||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha = alpha;
|
ctx.globalAlpha = alpha;
|
||||||
|
|
||||||
drawTacticalLeader(ctx, entry, placement, entry.leaderStyle === 'elbow'
|
drawTacticalLeader(
|
||||||
|
ctx,
|
||||||
|
entry,
|
||||||
|
placement,
|
||||||
|
entry.leaderStyle === 'elbow'
|
||||||
? colorWithAlpha(entry.accent, 0.95)
|
? colorWithAlpha(entry.accent, 0.95)
|
||||||
: accentColors.leader, animationTimestamp);
|
: accentColors.leader,
|
||||||
|
animationTimestamp,
|
||||||
|
);
|
||||||
|
|
||||||
ctx.globalAlpha = alpha * tacticalCardRevealAlpha(entry, animationTimestamp);
|
ctx.globalAlpha = alpha * tacticalCardRevealAlpha(entry, animationTimestamp);
|
||||||
|
|
||||||
@@ -625,13 +769,19 @@ export function paintTacticalCard(ctx, entry, placement, alpha = 1) {
|
|||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.textBaseline = 'alphabetic';
|
ctx.textBaseline = 'alphabetic';
|
||||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
|
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
|
||||||
ctx.font = selected ? WORLD_OVERLAY_STYLE.fontSelected : WORLD_OVERLAY_STYLE.fontTitle;
|
ctx.font = selected
|
||||||
|
? WORLD_OVERLAY_STYLE.fontSelected
|
||||||
|
: WORLD_OVERLAY_STYLE.fontTitle;
|
||||||
const titleBaseline = y + layout.padY + layout.titleH - 2;
|
const titleBaseline = y + layout.padY + layout.titleH - 2;
|
||||||
ctx.fillText(String(entry.title || ''), x + layout.padX, titleBaseline);
|
ctx.fillText(String(entry.title || ''), x + layout.padX, titleBaseline);
|
||||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
||||||
ctx.font = WORLD_OVERLAY_STYLE.fontDetail;
|
ctx.font = WORLD_OVERLAY_STYLE.fontDetail;
|
||||||
for (let i = 0; i < details.length; i++) {
|
for (let i = 0; i < details.length; i++) {
|
||||||
ctx.fillText(String(details[i]), x + layout.padX, titleBaseline + (i + 1) * layout.lineH);
|
ctx.fillText(
|
||||||
|
String(details[i]),
|
||||||
|
x + layout.padX,
|
||||||
|
titleBaseline + (i + 1) * layout.lineH,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
return placement.rect;
|
return placement.rect;
|
||||||
@@ -645,7 +795,11 @@ export function paintLabel(ctx, entry, placement, alpha = 1) {
|
|||||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
|
ctx.fillStyle = WORLD_OVERLAY_STYLE.title;
|
||||||
ctx.font = WORLD_OVERLAY_STYLE.fontLabel;
|
ctx.font = WORLD_OVERLAY_STYLE.fontLabel;
|
||||||
ctx.textBaseline = 'top';
|
ctx.textBaseline = 'top';
|
||||||
ctx.fillText(String(entry.title || ''), placement.rect.x + 6, placement.rect.y + 4);
|
ctx.fillText(
|
||||||
|
String(entry.title || ''),
|
||||||
|
placement.rect.x + 6,
|
||||||
|
placement.rect.y + 4,
|
||||||
|
);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
return placement.rect;
|
return placement.rect;
|
||||||
}
|
}
|
||||||
@@ -690,7 +844,11 @@ export function paintThumbnail(ctx, entry, placement, alpha = 1) {
|
|||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha = alpha;
|
ctx.globalAlpha = alpha;
|
||||||
|
|
||||||
drawLeader(ctx, placement, entry.thumbnailLeaderColor || WORLD_OVERLAY_STYLE.leader);
|
drawLeader(
|
||||||
|
ctx,
|
||||||
|
placement,
|
||||||
|
entry.thumbnailLeaderColor || WORLD_OVERLAY_STYLE.leader,
|
||||||
|
);
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
roundedRectPath(ctx, x, y, w, h, Number(entry.thumbnailRadius) || 4);
|
roundedRectPath(ctx, x, y, w, h, Number(entry.thumbnailRadius) || 4);
|
||||||
ctx.fillStyle = entry.thumbnailBackground || WORLD_OVERLAY_STYLE.background;
|
ctx.fillStyle = entry.thumbnailBackground || WORLD_OVERLAY_STYLE.background;
|
||||||
@@ -711,13 +869,17 @@ export function paintThumbnail(ctx, entry, placement, alpha = 1) {
|
|||||||
if (ruleHeight > 0) {
|
if (ruleHeight > 0) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
roundedRectPath(ctx, x, y, w, ruleHeight, Math.min(1, ruleHeight));
|
roundedRectPath(ctx, x, y, w, ruleHeight, Math.min(1, ruleHeight));
|
||||||
ctx.fillStyle = entry.thumbnailRuleColor || entry.accent || WORLD_OVERLAY_STYLE.accent;
|
ctx.fillStyle =
|
||||||
|
entry.thumbnailRuleColor || entry.accent || WORLD_OVERLAY_STYLE.accent;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
const titleChars = Math.max(0, Math.floor(Number(entry.thumbnailTitleChars) || 0));
|
const titleChars = Math.max(
|
||||||
const title = entry._overlayThumbnailTitle
|
0,
|
||||||
|| String(entry.title || '').toUpperCase();
|
Math.floor(Number(entry.thumbnailTitleChars) || 0),
|
||||||
|
);
|
||||||
|
const title =
|
||||||
|
entry._overlayThumbnailTitle || String(entry.title || '').toUpperCase();
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.textBaseline = 'alphabetic';
|
ctx.textBaseline = 'alphabetic';
|
||||||
ctx.fillStyle = entry.thumbnailTitleColor || WORLD_OVERLAY_STYLE.title;
|
ctx.fillStyle = entry.thumbnailTitleColor || WORLD_OVERLAY_STYLE.title;
|
||||||
@@ -769,7 +931,11 @@ export function paintTracked(ctx, entry, placement, alpha = 1) {
|
|||||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
||||||
ctx.font = WORLD_OVERLAY_STYLE.fontTrackedDetail;
|
ctx.font = WORLD_OVERLAY_STYLE.fontTrackedDetail;
|
||||||
for (let i = 0; i < details.length; i++) {
|
for (let i = 0; i < details.length; i++) {
|
||||||
ctx.fillText(String(details[i]), centerX, titleBaseline + (i + 1) * layout.lineH);
|
ctx.fillText(
|
||||||
|
String(details[i]),
|
||||||
|
centerX,
|
||||||
|
titleBaseline + (i + 1) * layout.lineH,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
return placement.rect;
|
return placement.rect;
|
||||||
@@ -842,7 +1008,8 @@ export function paintDetectionCallout(ctx, callout, alpha = 1) {
|
|||||||
ctx.globalAlpha = alpha;
|
ctx.globalAlpha = alpha;
|
||||||
ctx.fillStyle = callout.label;
|
ctx.fillStyle = callout.label;
|
||||||
ctx.font = callout.font;
|
ctx.font = callout.font;
|
||||||
if (callout.primary) ctx.fillText(callout.primary, callout.primaryX, callout.baseline);
|
if (callout.primary)
|
||||||
|
ctx.fillText(callout.primary, callout.primaryX, callout.baseline);
|
||||||
if (callout.micro) {
|
if (callout.micro) {
|
||||||
ctx.globalAlpha = alpha * 0.8;
|
ctx.globalAlpha = alpha * 0.8;
|
||||||
ctx.font = callout.microFont;
|
ctx.font = callout.microFont;
|
||||||
@@ -852,11 +1019,16 @@ export function paintDetectionCallout(ctx, callout, alpha = 1) {
|
|||||||
|
|
||||||
/** Dispatch a normalized entry to its pure variant painter. */
|
/** Dispatch a normalized entry to its pure variant painter. */
|
||||||
export function paintOverlayEntry(ctx, entry, placement, alpha = 1) {
|
export function paintOverlayEntry(ctx, entry, placement, alpha = 1) {
|
||||||
if (entry.cardStyle === 'tactical') return paintTacticalCard(ctx, entry, placement, alpha);
|
if (entry.cardStyle === 'tactical')
|
||||||
if (entry.variant === 'tracked') return paintTracked(ctx, entry, placement, alpha);
|
return paintTacticalCard(ctx, entry, placement, alpha);
|
||||||
if (entry.selected || entry.variant === 'selected') return paintSelected(ctx, entry, placement, alpha);
|
if (entry.variant === 'tracked')
|
||||||
if (entry.variant === 'thumbnail') return paintThumbnail(ctx, entry, placement, alpha);
|
return paintTracked(ctx, entry, placement, alpha);
|
||||||
|
if (entry.selected || entry.variant === 'selected')
|
||||||
|
return paintSelected(ctx, entry, placement, alpha);
|
||||||
|
if (entry.variant === 'thumbnail')
|
||||||
|
return paintThumbnail(ctx, entry, placement, alpha);
|
||||||
if (entry.variant === 'card') return paintCard(ctx, entry, placement, alpha);
|
if (entry.variant === 'card') return paintCard(ctx, entry, placement, alpha);
|
||||||
if (entry.variant === 'track') return paintTrack(ctx, entry, placement, alpha);
|
if (entry.variant === 'track')
|
||||||
|
return paintTrack(ctx, entry, placement, alpha);
|
||||||
return paintLabel(ctx, entry, placement, alpha);
|
return paintLabel(ctx, entry, placement, alpha);
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,8 @@ function applyMode() {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export function installRenderGovernor(viewer) {
|
export function installRenderGovernor(viewer) {
|
||||||
if (!viewer?.scene) throw new TypeError('installRenderGovernor requires a Cesium viewer');
|
if (!viewer?.scene)
|
||||||
|
throw new TypeError('installRenderGovernor requires a Cesium viewer');
|
||||||
_viewer = viewer;
|
_viewer = viewer;
|
||||||
_installed = true;
|
_installed = true;
|
||||||
// Never let Cesium re-render on simulation-time deltas behind our back —
|
// Never let Cesium re-render on simulation-time deltas behind our back —
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ export function shouldExpandGlobalContextPanel({
|
|||||||
restoring = false,
|
restoring = false,
|
||||||
}) {
|
}) {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
GLOBAL_CONTEXT_EXPLICIT_ACTIONS.has(action)
|
GLOBAL_CONTEXT_EXPLICIT_ACTIONS.has(action) &&
|
||||||
&& explicitUserAction
|
explicitUserAction &&
|
||||||
&& succeeded
|
succeeded &&
|
||||||
&& !restoring
|
!restoring,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+209
-62
@@ -60,7 +60,10 @@ function normalizeLayerEntry(entry) {
|
|||||||
if (entry && typeof entry === 'object') {
|
if (entry && typeof entry === 'object') {
|
||||||
return {
|
return {
|
||||||
enabled: !!entry.enabled,
|
enabled: !!entry.enabled,
|
||||||
params: entry.params && typeof entry.params === 'object' ? deepClone(entry.params) : undefined,
|
params:
|
||||||
|
entry.params && typeof entry.params === 'object'
|
||||||
|
? deepClone(entry.params)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { enabled: !!entry };
|
return { enabled: !!entry };
|
||||||
@@ -75,13 +78,18 @@ function normalizeLayerEntry(entry) {
|
|||||||
* @param {number} [options.fallbackIntensity] - Default intensity if not stored
|
* @param {number} [options.fallbackIntensity] - Default intensity if not stored
|
||||||
* @returns {{ enabled: boolean, intensity: number, version: number }}
|
* @returns {{ enabled: boolean, intensity: number, version: number }}
|
||||||
*/
|
*/
|
||||||
function normalizeBloomState(rawBloom = {}, { projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {}) {
|
function normalizeBloomState(
|
||||||
|
rawBloom = {},
|
||||||
|
{ projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {},
|
||||||
|
) {
|
||||||
// Determine which bloom scale the stored value was encoded under.
|
// Determine which bloom scale the stored value was encoded under.
|
||||||
// Older projects (version < PROJECT_VERSION) used scale version 1.
|
// Older projects (version < PROJECT_VERSION) used scale version 1.
|
||||||
const explicitVersion = Number(rawBloom.version);
|
const explicitVersion = Number(rawBloom.version);
|
||||||
const bloomVersion = Number.isFinite(explicitVersion)
|
const bloomVersion = Number.isFinite(explicitVersion)
|
||||||
? explicitVersion
|
? explicitVersion
|
||||||
: (projectVersion >= PROJECT_VERSION ? BLOOM_SCALE_VERSION : 1);
|
: projectVersion >= PROJECT_VERSION
|
||||||
|
? BLOOM_SCALE_VERSION
|
||||||
|
: 1;
|
||||||
|
|
||||||
const rawIntensity = Number.isFinite(Number(rawBloom.intensity))
|
const rawIntensity = Number.isFinite(Number(rawBloom.intensity))
|
||||||
? Number(rawBloom.intensity)
|
? Number(rawBloom.intensity)
|
||||||
@@ -104,7 +112,8 @@ function normalizeBloomState(rawBloom = {}, { projectVersion = PROJECT_VERSION,
|
|||||||
function recipeToScene(recipe) {
|
function recipeToScene(recipe) {
|
||||||
const post = recipe.post || {};
|
const post = recipe.post || {};
|
||||||
const ui = recipe.ui || {};
|
const ui = recipe.ui || {};
|
||||||
const styleParams = post.styleParams && typeof post.styleParams === 'object'
|
const styleParams =
|
||||||
|
post.styleParams && typeof post.styleParams === 'object'
|
||||||
? deepClone(post.styleParams)
|
? deepClone(post.styleParams)
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
@@ -138,13 +147,15 @@ function recipeToScene(recipe) {
|
|||||||
style: recipe.style || 'normal',
|
style: recipe.style || 'normal',
|
||||||
bloom: {
|
bloom: {
|
||||||
enabled: typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom,
|
enabled: typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom,
|
||||||
intensity: typeof post.bloom === 'number'
|
intensity:
|
||||||
|
typeof post.bloom === 'number'
|
||||||
? decodeBloomIntensity(post.bloom, 1)
|
? decodeBloomIntensity(post.bloom, 1)
|
||||||
: BLOOM_INTENSITY_DEFAULT,
|
: BLOOM_INTENSITY_DEFAULT,
|
||||||
version: BLOOM_SCALE_VERSION,
|
version: BLOOM_SCALE_VERSION,
|
||||||
},
|
},
|
||||||
sharpen: {
|
sharpen: {
|
||||||
enabled: typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen,
|
enabled:
|
||||||
|
typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen,
|
||||||
intensity: 65,
|
intensity: 65,
|
||||||
},
|
},
|
||||||
hud: {
|
hud: {
|
||||||
@@ -190,7 +201,11 @@ function createDefaultProject() {
|
|||||||
* @param {number} [options.projectVersion] - Schema version of the enclosing project
|
* @param {number} [options.projectVersion] - Schema version of the enclosing project
|
||||||
* @returns {Object} Fully normalized shot
|
* @returns {Object} Fully normalized shot
|
||||||
*/
|
*/
|
||||||
function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION } = {}) {
|
function normalizeShot(
|
||||||
|
rawShot,
|
||||||
|
index = 0,
|
||||||
|
{ projectVersion = PROJECT_VERSION } = {},
|
||||||
|
) {
|
||||||
const camera = rawShot?.camera || {};
|
const camera = rawShot?.camera || {};
|
||||||
const visual = rawShot?.visual || {};
|
const visual = rawShot?.visual || {};
|
||||||
const bloom = visual.bloom || {};
|
const bloom = visual.bloom || {};
|
||||||
@@ -201,7 +216,10 @@ function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION }
|
|||||||
return {
|
return {
|
||||||
id: rawShot?.id || uid('shot'),
|
id: rawShot?.id || uid('shot'),
|
||||||
title: rawShot?.title || `Shot ${index + 1}`,
|
title: rawShot?.title || `Shot ${index + 1}`,
|
||||||
durationSec: Math.max(0.2, Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC),
|
durationSec: Math.max(
|
||||||
|
0.2,
|
||||||
|
Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC,
|
||||||
|
),
|
||||||
holdSec: Math.max(0, Number(rawShot?.holdSec) || 0),
|
holdSec: Math.max(0, Number(rawShot?.holdSec) || 0),
|
||||||
camera: {
|
camera: {
|
||||||
lat: Number(camera.lat) || 0,
|
lat: Number(camera.lat) || 0,
|
||||||
@@ -213,10 +231,21 @@ function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION }
|
|||||||
},
|
},
|
||||||
visual: {
|
visual: {
|
||||||
style: visual.style || 'normal',
|
style: visual.style || 'normal',
|
||||||
bloom: normalizeBloomState(bloom, { projectVersion, fallbackIntensity: 50 }),
|
bloom: normalizeBloomState(bloom, {
|
||||||
|
projectVersion,
|
||||||
|
fallbackIntensity: 50,
|
||||||
|
}),
|
||||||
sharpen: {
|
sharpen: {
|
||||||
enabled: !!sharpen.enabled,
|
enabled: !!sharpen.enabled,
|
||||||
intensity: Math.max(0, Math.min(100, Number.isFinite(Number(sharpen.intensity)) ? Number(sharpen.intensity) : 65)),
|
intensity: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
100,
|
||||||
|
Number.isFinite(Number(sharpen.intensity))
|
||||||
|
? Number(sharpen.intensity)
|
||||||
|
: 65,
|
||||||
|
),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
hud: {
|
hud: {
|
||||||
visible: typeof hud.visible === 'boolean' ? hud.visible : true,
|
visible: typeof hud.visible === 'boolean' ? hud.visible : true,
|
||||||
@@ -224,12 +253,26 @@ function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION }
|
|||||||
},
|
},
|
||||||
detection: {
|
detection: {
|
||||||
mode: typeof detection.mode === 'string' ? detection.mode : 'OFF',
|
mode: typeof detection.mode === 'string' ? detection.mode : 'OFF',
|
||||||
density: Math.max(0, Math.min(100, Number.isFinite(Number(detection.density)) ? Number(detection.density) : 35)),
|
density: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
100,
|
||||||
|
Number.isFinite(Number(detection.density))
|
||||||
|
? Number(detection.density)
|
||||||
|
: 35,
|
||||||
|
),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
styleParams: visual.styleParams && typeof visual.styleParams === 'object' ? deepClone(visual.styleParams) : {},
|
styleParams:
|
||||||
|
visual.styleParams && typeof visual.styleParams === 'object'
|
||||||
|
? deepClone(visual.styleParams)
|
||||||
|
: {},
|
||||||
},
|
},
|
||||||
layers: Object.fromEntries(
|
layers: Object.fromEntries(
|
||||||
Object.entries(rawShot?.layers || {}).map(([layerId, value]) => [layerId, normalizeLayerEntry(value)])
|
Object.entries(rawShot?.layers || {}).map(([layerId, value]) => [
|
||||||
|
layerId,
|
||||||
|
normalizeLayerEntry(value),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -241,7 +284,8 @@ function normalizeShot(rawShot, index = 0, { projectVersion = PROJECT_VERSION }
|
|||||||
* @returns {Object} Fully normalized project at the current PROJECT_VERSION
|
* @returns {Object} Fully normalized project at the current PROJECT_VERSION
|
||||||
*/
|
*/
|
||||||
function normalizeProject(rawProject) {
|
function normalizeProject(rawProject) {
|
||||||
if (!rawProject || typeof rawProject !== 'object') return createDefaultProject();
|
if (!rawProject || typeof rawProject !== 'object')
|
||||||
|
return createDefaultProject();
|
||||||
const projectVersion = Number.isFinite(Number(rawProject.version))
|
const projectVersion = Number.isFinite(Number(rawProject.version))
|
||||||
? Number(rawProject.version)
|
? Number(rawProject.version)
|
||||||
: 1;
|
: 1;
|
||||||
@@ -250,7 +294,9 @@ function normalizeProject(rawProject) {
|
|||||||
const scenes = scenesRaw
|
const scenes = scenesRaw
|
||||||
.map((scene, sceneIdx) => {
|
.map((scene, sceneIdx) => {
|
||||||
const shotsRaw = Array.isArray(scene?.shots) ? scene.shots : [];
|
const shotsRaw = Array.isArray(scene?.shots) ? scene.shots : [];
|
||||||
const shots = shotsRaw.map((shot, shotIdx) => normalizeShot(shot, shotIdx, { projectVersion }));
|
const shots = shotsRaw.map((shot, shotIdx) =>
|
||||||
|
normalizeShot(shot, shotIdx, { projectVersion }),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
id: scene?.id || uid('scene'),
|
id: scene?.id || uid('scene'),
|
||||||
title: scene?.title || `Scene ${sceneIdx + 1}`,
|
title: scene?.title || `Scene ${sceneIdx + 1}`,
|
||||||
@@ -316,12 +362,21 @@ export class SceneDirector {
|
|||||||
this._selectedSceneId = this._project.scenes[0]?.id || null;
|
this._selectedSceneId = this._project.scenes[0]?.id || null;
|
||||||
this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null;
|
this._selectedShotId = this._project.scenes[0]?.shots[0]?.id || null;
|
||||||
|
|
||||||
this._presentation = { status: 'Ready', progress: 0, runtime: '', playbackActive: false, keyboardEnabled: false };
|
this._presentation = {
|
||||||
this._state = createStateChannel(() => ({ ...this.getPlaybackStatus(), ...this._presentation, hasRun: !!this._lastRunJson }));
|
status: 'Ready',
|
||||||
|
progress: 0,
|
||||||
|
runtime: '',
|
||||||
|
playbackActive: false,
|
||||||
|
keyboardEnabled: false,
|
||||||
|
};
|
||||||
|
this._state = createStateChannel(() => ({
|
||||||
|
...this.getPlaybackStatus(),
|
||||||
|
...this._presentation,
|
||||||
|
hasRun: !!this._lastRunJson,
|
||||||
|
}));
|
||||||
this._initUI();
|
this._initUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
_trackWork(promise) {
|
_trackWork(promise) {
|
||||||
this._pendingWork ||= new Set();
|
this._pendingWork ||= new Set();
|
||||||
this._pendingWork.add(promise);
|
this._pendingWork.add(promise);
|
||||||
@@ -372,7 +427,10 @@ export class SceneDirector {
|
|||||||
// Private browsing / block-all-cookies / quota-exceeded throws here. The
|
// Private browsing / block-all-cookies / quota-exceeded throws here. The
|
||||||
// in-memory project stays usable this session, but persistence failed —
|
// in-memory project stays usable this session, but persistence failed —
|
||||||
// tell the user instead of crashing the caller (M11).
|
// tell the user instead of crashing the caller (M11).
|
||||||
console.warn('[Scenes] Could not persist project (storage unavailable):', e);
|
console.warn(
|
||||||
|
'[Scenes] Could not persist project (storage unavailable):',
|
||||||
|
e,
|
||||||
|
);
|
||||||
this._toastStorageError();
|
this._toastStorageError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,17 +449,30 @@ export class SceneDirector {
|
|||||||
toast.classList.remove('visible');
|
toast.classList.remove('visible');
|
||||||
}, 2600);
|
}, 2600);
|
||||||
}
|
}
|
||||||
} catch { /* toast is best-effort */ }
|
} catch {
|
||||||
|
/* toast is best-effort */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Immutable playback snapshots and completed editing actions. */
|
/** Immutable playback snapshots and completed editing actions. */
|
||||||
subscribe(listener, options) { return this._state.subscribe(listener, options); }
|
subscribe(listener, options) {
|
||||||
|
return this._state.subscribe(listener, options);
|
||||||
|
}
|
||||||
|
|
||||||
_publish(change) { this._state?.publish(change); }
|
_publish(change) {
|
||||||
|
this._state?.publish(change);
|
||||||
|
}
|
||||||
|
|
||||||
_shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) {
|
_shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) {
|
||||||
if (type === 'shot-loaded') this._presentation.status = `Loaded: ${scene.title} / ${shot.title}`;
|
if (type === 'shot-loaded')
|
||||||
this._publish({ type, sceneId: scene.id, sceneTitle: scene.title, shot, index });
|
this._presentation.status = `Loaded: ${scene.title} / ${shot.title}`;
|
||||||
|
this._publish({
|
||||||
|
type,
|
||||||
|
sceneId: scene.id,
|
||||||
|
sceneTitle: scene.title,
|
||||||
|
shot,
|
||||||
|
index,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -424,7 +495,10 @@ export class SceneDirector {
|
|||||||
this._selectedShotId = this._getSelectedScene()?.shots[0]?.id || null;
|
this._selectedShotId = this._getSelectedScene()?.shots[0]?.id || null;
|
||||||
this._renderShotList();
|
this._renderShotList();
|
||||||
},
|
},
|
||||||
selectShot: (id) => { this._selectedShotId = id; this._publish({ type: 'selection-changed' }); },
|
selectShot: (id) => {
|
||||||
|
this._selectedShotId = id;
|
||||||
|
this._publish({ type: 'selection-changed' });
|
||||||
|
},
|
||||||
renameShot: (sceneId, shotId, title) => {
|
renameShot: (sceneId, shotId, title) => {
|
||||||
const { scene, shot } = this._getShot(sceneId, shotId);
|
const { scene, shot } = this._getShot(sceneId, shotId);
|
||||||
if (!shot) return;
|
if (!shot) return;
|
||||||
@@ -450,7 +524,9 @@ export class SceneDirector {
|
|||||||
|
|
||||||
/** Rebuild the scene dropdown options and sync the selected value. */
|
/** Rebuild the scene dropdown options and sync the selected value. */
|
||||||
_renderSceneSelect() {
|
_renderSceneSelect() {
|
||||||
if (!this._project.scenes.some((scene) => scene.id === this._selectedSceneId)) {
|
if (
|
||||||
|
!this._project.scenes.some((scene) => scene.id === this._selectedSceneId)
|
||||||
|
) {
|
||||||
this._selectedSceneId = this._project.scenes[0]?.id || null;
|
this._selectedSceneId = this._project.scenes[0]?.id || null;
|
||||||
}
|
}
|
||||||
this._publish({ type: 'scene-options-changed' });
|
this._publish({ type: 'scene-options-changed' });
|
||||||
@@ -463,7 +539,10 @@ export class SceneDirector {
|
|||||||
*/
|
*/
|
||||||
_renderShotList() {
|
_renderShotList() {
|
||||||
const scene = this._getSelectedScene();
|
const scene = this._getSelectedScene();
|
||||||
if (scene?.shots.length && !scene.shots.some((shot) => shot.id === this._selectedShotId)) {
|
if (
|
||||||
|
scene?.shots.length &&
|
||||||
|
!scene.shots.some((shot) => shot.id === this._selectedShotId)
|
||||||
|
) {
|
||||||
this._selectedShotId = scene.shots[0].id;
|
this._selectedShotId = scene.shots[0].id;
|
||||||
}
|
}
|
||||||
this._publish({ type: 'shots-changed' });
|
this._publish({ type: 'shots-changed' });
|
||||||
@@ -474,7 +553,11 @@ export class SceneDirector {
|
|||||||
* @returns {Object|null} The scene, or null if no valid selection
|
* @returns {Object|null} The scene, or null if no valid selection
|
||||||
*/
|
*/
|
||||||
_getSelectedScene() {
|
_getSelectedScene() {
|
||||||
return this._project.scenes.find((scene) => scene.id === this._selectedSceneId) || null;
|
return (
|
||||||
|
this._project.scenes.find(
|
||||||
|
(scene) => scene.id === this._selectedSceneId,
|
||||||
|
) || null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -511,7 +594,9 @@ export class SceneDirector {
|
|||||||
const scene = this._getSelectedScene();
|
const scene = this._getSelectedScene();
|
||||||
if (!scene) return;
|
if (!scene) return;
|
||||||
|
|
||||||
this._project.scenes = this._project.scenes.filter((item) => item.id !== scene.id);
|
this._project.scenes = this._project.scenes.filter(
|
||||||
|
(item) => item.id !== scene.id,
|
||||||
|
);
|
||||||
// Restore default recipes if the user deleted all scenes
|
// Restore default recipes if the user deleted all scenes
|
||||||
if (!this._project.scenes.length) {
|
if (!this._project.scenes.length) {
|
||||||
this._project = createDefaultProject();
|
this._project = createDefaultProject();
|
||||||
@@ -553,7 +638,8 @@ export class SceneDirector {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shot = normalizeShot({
|
const shot = normalizeShot(
|
||||||
|
{
|
||||||
id: uid('shot'),
|
id: uid('shot'),
|
||||||
title: `Shot ${scene.shots.length + 1}`,
|
title: `Shot ${scene.shots.length + 1}`,
|
||||||
durationSec: DEFAULT_SHOT_DURATION_SEC,
|
durationSec: DEFAULT_SHOT_DURATION_SEC,
|
||||||
@@ -561,7 +647,9 @@ export class SceneDirector {
|
|||||||
camera,
|
camera,
|
||||||
visual: this.styleManager.getVisualState(),
|
visual: this.styleManager.getVisualState(),
|
||||||
layers: this._captureLayerStates(),
|
layers: this._captureLayerStates(),
|
||||||
}, scene.shots.length);
|
},
|
||||||
|
scene.shots.length,
|
||||||
|
);
|
||||||
|
|
||||||
scene.shots.push(shot);
|
scene.shots.push(shot);
|
||||||
this._selectedShotId = shot.id;
|
this._selectedShotId = shot.id;
|
||||||
@@ -628,7 +716,8 @@ export class SceneDirector {
|
|||||||
* @param {number} [options.flyDuration=2.2] - Camera flight duration in seconds
|
* @param {number} [options.flyDuration=2.2] - Camera flight duration in seconds
|
||||||
*/
|
*/
|
||||||
loadShot(sceneId, shotId, options) {
|
loadShot(sceneId, shotId, options) {
|
||||||
if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' });
|
if (this._destroyed)
|
||||||
|
return Promise.resolve({ started: false, reason: 'destroyed' });
|
||||||
return this._trackWork(this._loadShot(sceneId, shotId, options));
|
return this._trackWork(this._loadShot(sceneId, shotId, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,7 +741,9 @@ export class SceneDirector {
|
|||||||
this._renderSceneSelect();
|
this._renderSceneSelect();
|
||||||
this._renderShotList();
|
this._renderShotList();
|
||||||
|
|
||||||
await this.styleManager.applyVisualState(shot.visual, { isCurrent: () => !token.cancelled });
|
await this.styleManager.applyVisualState(shot.visual, {
|
||||||
|
isCurrent: () => !token.cancelled,
|
||||||
|
});
|
||||||
if (token.cancelled) return;
|
if (token.cancelled) return;
|
||||||
await this._applyLayerStates(shot.layers || {}, token);
|
await this._applyLayerStates(shot.layers || {}, token);
|
||||||
if (token.cancelled) return;
|
if (token.cancelled) return;
|
||||||
@@ -693,8 +784,12 @@ export class SceneDirector {
|
|||||||
*/
|
*/
|
||||||
_claimCameraOwnership() {
|
_claimCameraOwnership() {
|
||||||
// Older/headless style managers may predate the facade — proceed then.
|
// Older/headless style managers may predate the facade — proceed then.
|
||||||
if (typeof this.styleManager?.runImmediateNavigation !== 'function') return true;
|
if (typeof this.styleManager?.runImmediateNavigation !== 'function')
|
||||||
const claimed = this.styleManager.runImmediateNavigation('scene', () => true);
|
return true;
|
||||||
|
const claimed = this.styleManager.runImmediateNavigation(
|
||||||
|
'scene',
|
||||||
|
() => true,
|
||||||
|
);
|
||||||
if (claimed === false) {
|
if (claimed === false) {
|
||||||
this._updateStatus('Camera unavailable — exit cockpit first');
|
this._updateStatus('Camera unavailable — exit cockpit first');
|
||||||
return false;
|
return false;
|
||||||
@@ -712,7 +807,10 @@ export class SceneDirector {
|
|||||||
if (!this._project.scenes.length) return [];
|
if (!this._project.scenes.length) return [];
|
||||||
|
|
||||||
// Rotate the scene list so startSceneId comes first
|
// Rotate the scene list so startSceneId comes first
|
||||||
const startIdx = Math.max(0, this._project.scenes.findIndex((scene) => scene.id === startSceneId));
|
const startIdx = Math.max(
|
||||||
|
0,
|
||||||
|
this._project.scenes.findIndex((scene) => scene.id === startSceneId),
|
||||||
|
);
|
||||||
const ordered = single
|
const ordered = single
|
||||||
? this._project.scenes.slice(startIdx, startIdx + 1)
|
? this._project.scenes.slice(startIdx, startIdx + 1)
|
||||||
: [
|
: [
|
||||||
@@ -748,12 +846,17 @@ export class SceneDirector {
|
|||||||
* @returns {{id: string, title: string, shots: number}|null}
|
* @returns {{id: string, title: string, shots: number}|null}
|
||||||
*/
|
*/
|
||||||
findSceneByQuery(query) {
|
findSceneByQuery(query) {
|
||||||
const q = String(query ?? '').trim().toLowerCase();
|
const q = String(query ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
if (!q) return null;
|
if (!q) return null;
|
||||||
const scene = this._project.scenes.find((item) => item.id === query)
|
const scene =
|
||||||
|| this._project.scenes.find((item) => item.title.toLowerCase() === q)
|
this._project.scenes.find((item) => item.id === query) ||
|
||||||
|| this._project.scenes.find((item) => item.title.toLowerCase().includes(q));
|
this._project.scenes.find((item) => item.title.toLowerCase() === q) ||
|
||||||
return scene ? { id: scene.id, title: scene.title, shots: scene.shots.length } : null;
|
this._project.scenes.find((item) => item.title.toLowerCase().includes(q));
|
||||||
|
return scene
|
||||||
|
? { id: scene.id, title: scene.title, shots: scene.shots.length }
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -765,8 +868,12 @@ export class SceneDirector {
|
|||||||
running: this._running,
|
running: this._running,
|
||||||
selectedSceneId: this._selectedSceneId,
|
selectedSceneId: this._selectedSceneId,
|
||||||
selectedShotId: this._selectedShotId,
|
selectedShotId: this._selectedShotId,
|
||||||
elapsedMs: this._activeRun ? Math.max(0, Date.now() - Date.parse(this._activeRun.startedAt)) : null,
|
elapsedMs: this._activeRun
|
||||||
estimatedDurationMs: this._activeRun ? Math.round(this._activeRun.estimatedDurationSec * 1000) : null,
|
? Math.max(0, Date.now() - Date.parse(this._activeRun.startedAt))
|
||||||
|
: null,
|
||||||
|
estimatedDurationMs: this._activeRun
|
||||||
|
? Math.round(this._activeRun.estimatedDurationSec * 1000)
|
||||||
|
: null,
|
||||||
sceneCount: this._project.scenes.length,
|
sceneCount: this._project.scenes.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -792,14 +899,18 @@ export class SceneDirector {
|
|||||||
* @returns {Promise<{started: boolean, reason?: string, shots?: number}>}
|
* @returns {Promise<{started: boolean, reason?: string, shots?: number}>}
|
||||||
*/
|
*/
|
||||||
startScene(sceneId, options) {
|
startScene(sceneId, options) {
|
||||||
if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' });
|
if (this._destroyed)
|
||||||
|
return Promise.resolve({ started: false, reason: 'destroyed' });
|
||||||
return this._trackWork(this._startScene(sceneId, options));
|
return this._trackWork(this._startScene(sceneId, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
async _startScene(sceneId, { single = false } = {}) {
|
async _startScene(sceneId, { single = false } = {}) {
|
||||||
if (this._running) return { started: false, reason: 'already-running' };
|
if (this._running) return { started: false, reason: 'already-running' };
|
||||||
|
|
||||||
const queue = this._buildPlaybackQueue(sceneId || this._selectedSceneId || this._project.scenes[0]?.id, { single });
|
const queue = this._buildPlaybackQueue(
|
||||||
|
sceneId || this._selectedSceneId || this._project.scenes[0]?.id,
|
||||||
|
{ single },
|
||||||
|
);
|
||||||
if (!queue.length) {
|
if (!queue.length) {
|
||||||
this._updateStatus('No shots to run');
|
this._updateStatus('No shots to run');
|
||||||
return { started: false, reason: 'no-shots' };
|
return { started: false, reason: 'no-shots' };
|
||||||
@@ -872,7 +983,9 @@ export class SceneDirector {
|
|||||||
this._renderSceneSelect();
|
this._renderSceneSelect();
|
||||||
this._renderShotList();
|
this._renderShotList();
|
||||||
|
|
||||||
this._updateStatus(`Running ${idx + 1}/${queue.length}: ${scene.title} / ${shot.title}`);
|
this._updateStatus(
|
||||||
|
`Running ${idx + 1}/${queue.length}: ${scene.title} / ${shot.title}`,
|
||||||
|
);
|
||||||
this._updateRuntime(`${scene.title} · ${shot.title}`);
|
this._updateRuntime(`${scene.title} · ${shot.title}`);
|
||||||
|
|
||||||
this._logEvent('shot_start', {
|
this._logEvent('shot_start', {
|
||||||
@@ -897,7 +1010,11 @@ export class SceneDirector {
|
|||||||
if (token.cancelled) break;
|
if (token.cancelled) break;
|
||||||
await this._applyLayerStates(shot.layers || {}, token);
|
await this._applyLayerStates(shot.layers || {}, token);
|
||||||
if (token.cancelled) break;
|
if (token.cancelled) break;
|
||||||
await this._flyCamera(shot.camera, shot.durationSec || DEFAULT_SHOT_DURATION_SEC, token);
|
await this._flyCamera(
|
||||||
|
shot.camera,
|
||||||
|
shot.durationSec || DEFAULT_SHOT_DURATION_SEC,
|
||||||
|
token,
|
||||||
|
);
|
||||||
if (token.cancelled) break;
|
if (token.cancelled) break;
|
||||||
// Hold on the final frame before transitioning to the next shot
|
// Hold on the final frame before transitioning to the next shot
|
||||||
await this._sleep((shot.holdSec || 0) * 1000, token);
|
await this._sleep((shot.holdSec || 0) * 1000, token);
|
||||||
@@ -917,7 +1034,9 @@ export class SceneDirector {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this._updateStatus(`Error: ${error.message || 'run failed'}`);
|
this._updateStatus(`Error: ${error.message || 'run failed'}`);
|
||||||
this._logEvent('scene_run_error', { message: error.message || 'unknown error' });
|
this._logEvent('scene_run_error', {
|
||||||
|
message: error.message || 'unknown error',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this._finishRun();
|
this._finishRun();
|
||||||
}
|
}
|
||||||
@@ -930,13 +1049,17 @@ export class SceneDirector {
|
|||||||
async runNextScene() {
|
async runNextScene() {
|
||||||
if (this._destroyed || this._running) return;
|
if (this._destroyed || this._running) return;
|
||||||
|
|
||||||
const queue = this._buildPlaybackQueue(this._selectedSceneId || this._project.scenes[0]?.id);
|
const queue = this._buildPlaybackQueue(
|
||||||
|
this._selectedSceneId || this._project.scenes[0]?.id,
|
||||||
|
);
|
||||||
if (!queue.length) return;
|
if (!queue.length) return;
|
||||||
|
|
||||||
// Find the shot after the current selection, wrapping to the start
|
// Find the shot after the current selection, wrapping to the start
|
||||||
let next = queue[0];
|
let next = queue[0];
|
||||||
if (this._selectedShotId) {
|
if (this._selectedShotId) {
|
||||||
const idx = queue.findIndex((item) => item.shot.id === this._selectedShotId);
|
const idx = queue.findIndex(
|
||||||
|
(item) => item.shot.id === this._selectedShotId,
|
||||||
|
);
|
||||||
if (idx >= 0) next = queue[(idx + 1) % queue.length];
|
if (idx >= 0) next = queue[(idx + 1) % queue.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1064,13 +1187,24 @@ export class SceneDirector {
|
|||||||
if (token?.cancelled) return abort();
|
if (token?.cancelled) return abort();
|
||||||
|
|
||||||
const signal = token?.signal;
|
const signal = token?.signal;
|
||||||
const registered = new Set(this.dataManager.getAll().map((layer) => layer.id));
|
const registered = new Set(
|
||||||
for (const { id, enabled, params } of sceneLayerPlan(targetStates, registered)) {
|
this.dataManager.getAll().map((layer) => layer.id),
|
||||||
const settled = await this.dataManager.setEnabled(id, enabled, signal ? { signal } : undefined);
|
);
|
||||||
|
for (const { id, enabled, params } of sceneLayerPlan(
|
||||||
|
targetStates,
|
||||||
|
registered,
|
||||||
|
)) {
|
||||||
|
const settled = await this.dataManager.setEnabled(
|
||||||
|
id,
|
||||||
|
enabled,
|
||||||
|
signal ? { signal } : undefined,
|
||||||
|
);
|
||||||
if (token?.cancelled) return abort();
|
if (token?.cancelled) return abort();
|
||||||
if (settled === false) {
|
if (settled === false) {
|
||||||
refused.push(id);
|
refused.push(id);
|
||||||
console.warn(`[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`);
|
console.warn(
|
||||||
|
`[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`,
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
applied.push(id);
|
applied.push(id);
|
||||||
@@ -1105,7 +1239,8 @@ export class SceneDirector {
|
|||||||
*/
|
*/
|
||||||
async _exitIsolatingContextMode() {
|
async _exitIsolatingContextMode() {
|
||||||
// Older/headless style managers may predate the Context facade.
|
// Older/headless style managers may predate the Context facade.
|
||||||
if (typeof this.styleManager?.getContextModeState !== 'function') return false;
|
if (typeof this.styleManager?.getContextModeState !== 'function')
|
||||||
|
return false;
|
||||||
if (typeof this.styleManager?.setContextMode !== 'function') return false;
|
if (typeof this.styleManager?.setContextMode !== 'function') return false;
|
||||||
|
|
||||||
const state = this.styleManager.getContextModeState() || {};
|
const state = this.styleManager.getContextModeState() || {};
|
||||||
@@ -1115,9 +1250,17 @@ export class SceneDirector {
|
|||||||
|
|
||||||
const result = await this.styleManager.setContextMode('off');
|
const result = await this.styleManager.setContextMode('off');
|
||||||
if (result && result.ok === false) {
|
if (result && result.ok === false) {
|
||||||
console.warn(`[Scenes] Could not exit ${mode}:`, result.error || 'unknown reason');
|
console.warn(
|
||||||
this._updateStatus(`Could not exit ${mode} — scene layers may be refused`);
|
`[Scenes] Could not exit ${mode}:`,
|
||||||
this._logEvent('context_mode_exit_failed', { mode, error: result.error || null });
|
result.error || 'unknown reason',
|
||||||
|
);
|
||||||
|
this._updateStatus(
|
||||||
|
`Could not exit ${mode} — scene layers may be refused`,
|
||||||
|
);
|
||||||
|
this._logEvent('context_mode_exit_failed', {
|
||||||
|
mode,
|
||||||
|
error: result.error || null,
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this._logEvent('context_mode_exited', { mode });
|
this._logEvent('context_mode_exited', { mode });
|
||||||
@@ -1140,11 +1283,14 @@ export class SceneDirector {
|
|||||||
// a free-text search the shot has already flown away from.
|
// a free-text search the shot has already flown away from.
|
||||||
this.styleManager?.clearSearchedLocation?.();
|
this.styleManager?.clearSearchedLocation?.();
|
||||||
|
|
||||||
const duration = Math.max(0.2, Number(durationSec) || DEFAULT_SHOT_DURATION_SEC);
|
const duration = Math.max(
|
||||||
|
0.2,
|
||||||
|
Number(durationSec) || DEFAULT_SHOT_DURATION_SEC,
|
||||||
|
);
|
||||||
const destination = Cesium.Cartesian3.fromDegrees(
|
const destination = Cesium.Cartesian3.fromDegrees(
|
||||||
cameraState.lon,
|
cameraState.lon,
|
||||||
cameraState.lat,
|
cameraState.lat,
|
||||||
cameraState.alt
|
cameraState.alt,
|
||||||
);
|
);
|
||||||
|
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
@@ -1244,7 +1390,9 @@ export class SceneDirector {
|
|||||||
* Editing controls are disabled during a run; stop is disabled when idle.
|
* Editing controls are disabled during a run; stop is disabled when idle.
|
||||||
* @param {boolean} isRunning
|
* @param {boolean} isRunning
|
||||||
*/
|
*/
|
||||||
_setButtons(isRunning) { this._publish({ type: 'buttons-changed', running: isRunning }); }
|
_setButtons(isRunning) {
|
||||||
|
this._publish({ type: 'buttons-changed', running: isRunning });
|
||||||
|
}
|
||||||
|
|
||||||
_setPlaybackActive(active) {
|
_setPlaybackActive(active) {
|
||||||
this._presentation.playbackActive = active;
|
this._presentation.playbackActive = active;
|
||||||
@@ -1297,5 +1445,4 @@ export class SceneDirector {
|
|||||||
payload: payload || null,
|
payload: payload || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
+240
-24
@@ -22,11 +22,56 @@ export const SCENE_RECIPES = [
|
|||||||
detectionMode: 'OFF',
|
detectionMode: 'OFF',
|
||||||
},
|
},
|
||||||
cameraPath: [
|
cameraPath: [
|
||||||
{ lat: 20.0, lon: -30.0, alt: 19000000, heading: 25, pitch: -65, roll: 0, duration: 6, hold: 1 },
|
{
|
||||||
{ lat: 46.0, lon: 2.0, alt: 8000000, heading: 40, pitch: -52, roll: 0, duration: 5, hold: 1 },
|
lat: 20.0,
|
||||||
{ lat: 35.0, lon: 139.0, alt: 4200000, heading: 22, pitch: -46, roll: 0, duration: 5, hold: 1 },
|
lon: -30.0,
|
||||||
{ lat: 37.6, lon: -122.4, alt: 1700000, heading: 8, pitch: -40, roll: 0, duration: 5, hold: 1 },
|
alt: 19000000,
|
||||||
{ lat: 0.0, lon: -20.0, alt: 12000000, heading: -10, pitch: -70, roll: 0, duration: 4, hold: 0 },
|
heading: 25,
|
||||||
|
pitch: -65,
|
||||||
|
roll: 0,
|
||||||
|
duration: 6,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 46.0,
|
||||||
|
lon: 2.0,
|
||||||
|
alt: 8000000,
|
||||||
|
heading: 40,
|
||||||
|
pitch: -52,
|
||||||
|
roll: 0,
|
||||||
|
duration: 5,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 35.0,
|
||||||
|
lon: 139.0,
|
||||||
|
alt: 4200000,
|
||||||
|
heading: 22,
|
||||||
|
pitch: -46,
|
||||||
|
roll: 0,
|
||||||
|
duration: 5,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 37.6,
|
||||||
|
lon: -122.4,
|
||||||
|
alt: 1700000,
|
||||||
|
heading: 8,
|
||||||
|
pitch: -40,
|
||||||
|
roll: 0,
|
||||||
|
duration: 5,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 0.0,
|
||||||
|
lon: -20.0,
|
||||||
|
alt: 12000000,
|
||||||
|
heading: -10,
|
||||||
|
pitch: -70,
|
||||||
|
roll: 0,
|
||||||
|
duration: 4,
|
||||||
|
hold: 0,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -55,11 +100,56 @@ export const SCENE_RECIPES = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
cameraPath: [
|
cameraPath: [
|
||||||
{ lat: 28.0, lon: -82.0, alt: 22000000, heading: 0, pitch: -82, roll: 0, duration: 7, hold: 1 },
|
{
|
||||||
{ lat: 10.0, lon: 20.0, alt: 12000000, heading: 18, pitch: -74, roll: 0, duration: 5, hold: 1 },
|
lat: 28.0,
|
||||||
{ lat: 35.7, lon: 139.7, alt: 6500000, heading: 24, pitch: -64, roll: 0, duration: 5, hold: 1 },
|
lon: -82.0,
|
||||||
{ lat: 48.8, lon: 2.3, alt: 3000000, heading: 32, pitch: -58, roll: 0, duration: 5, hold: 1 },
|
alt: 22000000,
|
||||||
{ lat: 30.0, lon: -25.0, alt: 15000000, heading: -20, pitch: -78, roll: 0, duration: 4, hold: 0 },
|
heading: 0,
|
||||||
|
pitch: -82,
|
||||||
|
roll: 0,
|
||||||
|
duration: 7,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 10.0,
|
||||||
|
lon: 20.0,
|
||||||
|
alt: 12000000,
|
||||||
|
heading: 18,
|
||||||
|
pitch: -74,
|
||||||
|
roll: 0,
|
||||||
|
duration: 5,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 35.7,
|
||||||
|
lon: 139.7,
|
||||||
|
alt: 6500000,
|
||||||
|
heading: 24,
|
||||||
|
pitch: -64,
|
||||||
|
roll: 0,
|
||||||
|
duration: 5,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 48.8,
|
||||||
|
lon: 2.3,
|
||||||
|
alt: 3000000,
|
||||||
|
heading: 32,
|
||||||
|
pitch: -58,
|
||||||
|
roll: 0,
|
||||||
|
duration: 5,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 30.0,
|
||||||
|
lon: -25.0,
|
||||||
|
alt: 15000000,
|
||||||
|
heading: -20,
|
||||||
|
pitch: -78,
|
||||||
|
roll: 0,
|
||||||
|
duration: 4,
|
||||||
|
hold: 0,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -88,10 +178,46 @@ export const SCENE_RECIPES = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
cameraPath: [
|
cameraPath: [
|
||||||
{ lat: 37.0, lon: -121.0, alt: 5200000, heading: 20, pitch: -62, roll: 0, duration: 5, hold: 1 },
|
{
|
||||||
{ lat: 35.7, lon: 140.0, alt: 3100000, heading: 5, pitch: -55, roll: 0, duration: 5, hold: 1 },
|
lat: 37.0,
|
||||||
{ lat: -36.8, lon: 174.7, alt: 2600000, heading: -12, pitch: -50, roll: 0, duration: 5, hold: 1 },
|
lon: -121.0,
|
||||||
{ lat: 38.0, lon: -10.0, alt: 9000000, heading: 12, pitch: -70, roll: 0, duration: 4, hold: 0 },
|
alt: 5200000,
|
||||||
|
heading: 20,
|
||||||
|
pitch: -62,
|
||||||
|
roll: 0,
|
||||||
|
duration: 5,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 35.7,
|
||||||
|
lon: 140.0,
|
||||||
|
alt: 3100000,
|
||||||
|
heading: 5,
|
||||||
|
pitch: -55,
|
||||||
|
roll: 0,
|
||||||
|
duration: 5,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: -36.8,
|
||||||
|
lon: 174.7,
|
||||||
|
alt: 2600000,
|
||||||
|
heading: -12,
|
||||||
|
pitch: -50,
|
||||||
|
roll: 0,
|
||||||
|
duration: 5,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 38.0,
|
||||||
|
lon: -10.0,
|
||||||
|
alt: 9000000,
|
||||||
|
heading: 12,
|
||||||
|
pitch: -70,
|
||||||
|
roll: 0,
|
||||||
|
duration: 4,
|
||||||
|
hold: 0,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -120,11 +246,56 @@ export const SCENE_RECIPES = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
cameraPath: [
|
cameraPath: [
|
||||||
{ lat: 40.73, lon: -74.0, alt: 1500000, heading: 25, pitch: -44, roll: 0, duration: 5, hold: 1 },
|
{
|
||||||
{ lat: 40.73, lon: -74.0, alt: 550000, heading: 58, pitch: -36, roll: 0, duration: 4, hold: 1 },
|
lat: 40.73,
|
||||||
{ lat: 40.73, lon: -74.0, alt: 260000, heading: 102, pitch: -33, roll: 0, duration: 4, hold: 1 },
|
lon: -74.0,
|
||||||
{ lat: 34.05, lon: -118.24, alt: 1200000, heading: 45, pitch: -42, roll: 0, duration: 5, hold: 1 },
|
alt: 1500000,
|
||||||
{ lat: 51.5, lon: -0.12, alt: 1200000, heading: 22, pitch: -44, roll: 0, duration: 5, hold: 0 },
|
heading: 25,
|
||||||
|
pitch: -44,
|
||||||
|
roll: 0,
|
||||||
|
duration: 5,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 40.73,
|
||||||
|
lon: -74.0,
|
||||||
|
alt: 550000,
|
||||||
|
heading: 58,
|
||||||
|
pitch: -36,
|
||||||
|
roll: 0,
|
||||||
|
duration: 4,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 40.73,
|
||||||
|
lon: -74.0,
|
||||||
|
alt: 260000,
|
||||||
|
heading: 102,
|
||||||
|
pitch: -33,
|
||||||
|
roll: 0,
|
||||||
|
duration: 4,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 34.05,
|
||||||
|
lon: -118.24,
|
||||||
|
alt: 1200000,
|
||||||
|
heading: 45,
|
||||||
|
pitch: -42,
|
||||||
|
roll: 0,
|
||||||
|
duration: 5,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 51.5,
|
||||||
|
lon: -0.12,
|
||||||
|
alt: 1200000,
|
||||||
|
heading: 22,
|
||||||
|
pitch: -44,
|
||||||
|
roll: 0,
|
||||||
|
duration: 5,
|
||||||
|
hold: 0,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -152,11 +323,56 @@ export const SCENE_RECIPES = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
cameraPath: [
|
cameraPath: [
|
||||||
{ lat: 35.68, lon: 139.76, alt: 280000, heading: 30, pitch: -26, roll: 0, duration: 5, hold: 1 },
|
{
|
||||||
{ lat: 35.68, lon: 139.76, alt: 900000, heading: 26, pitch: -38, roll: 0, duration: 4, hold: 1 },
|
lat: 35.68,
|
||||||
{ lat: 35.68, lon: 139.76, alt: 3800000, heading: 18, pitch: -52, roll: 0, duration: 5, hold: 1 },
|
lon: 139.76,
|
||||||
{ lat: 20.0, lon: 110.0, alt: 9500000, heading: 8, pitch: -66, roll: 0, duration: 5, hold: 1 },
|
alt: 280000,
|
||||||
{ lat: 5.0, lon: 30.0, alt: 18000000, heading: -8, pitch: -78, roll: 0, duration: 6, hold: 0 },
|
heading: 30,
|
||||||
|
pitch: -26,
|
||||||
|
roll: 0,
|
||||||
|
duration: 5,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 35.68,
|
||||||
|
lon: 139.76,
|
||||||
|
alt: 900000,
|
||||||
|
heading: 26,
|
||||||
|
pitch: -38,
|
||||||
|
roll: 0,
|
||||||
|
duration: 4,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 35.68,
|
||||||
|
lon: 139.76,
|
||||||
|
alt: 3800000,
|
||||||
|
heading: 18,
|
||||||
|
pitch: -52,
|
||||||
|
roll: 0,
|
||||||
|
duration: 5,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 20.0,
|
||||||
|
lon: 110.0,
|
||||||
|
alt: 9500000,
|
||||||
|
heading: 8,
|
||||||
|
pitch: -66,
|
||||||
|
roll: 0,
|
||||||
|
duration: 5,
|
||||||
|
hold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lat: 5.0,
|
||||||
|
lon: 30.0,
|
||||||
|
alt: 18000000,
|
||||||
|
heading: -8,
|
||||||
|
pitch: -78,
|
||||||
|
roll: 0,
|
||||||
|
duration: 6,
|
||||||
|
hold: 0,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ export const SCENE_SELECTION_PARAM_PATTERN =
|
|||||||
*/
|
*/
|
||||||
export function stripSceneTrackingParams(params) {
|
export function stripSceneTrackingParams(params) {
|
||||||
if (!params || typeof params !== 'object') return undefined;
|
if (!params || typeof params !== 'object') return undefined;
|
||||||
if (!SCENE_TRACKING_PARAM_KEYS.some((key) => Object.hasOwn(params, key))) return params;
|
if (!SCENE_TRACKING_PARAM_KEYS.some((key) => Object.hasOwn(params, key)))
|
||||||
|
return params;
|
||||||
|
|
||||||
const kept = {};
|
const kept = {};
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
@@ -122,10 +123,12 @@ export const SCENE_EXCLUSIVITY_PROBE_LAYER_ID = '__scene-exclusivity-probe__';
|
|||||||
*/
|
*/
|
||||||
export function sceneRequiresContextModeExit(contextMode) {
|
export function sceneRequiresContextModeExit(contextMode) {
|
||||||
if (!contextMode) return false;
|
if (!contextMode) return false;
|
||||||
return contextLayerEnableBlockReason({
|
return (
|
||||||
|
contextLayerEnableBlockReason({
|
||||||
contextMode,
|
contextMode,
|
||||||
change: { layerId: SCENE_EXCLUSIVITY_PROBE_LAYER_ID, enabled: true },
|
change: { layerId: SCENE_EXCLUSIVITY_PROBE_LAYER_ID, enabled: true },
|
||||||
}) !== null;
|
}) !== null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,9 +147,12 @@ export function sceneRequiresContextModeExit(contextMode) {
|
|||||||
* @returns {Array<{ id: string, enabled: boolean, params: Object|undefined }>}
|
* @returns {Array<{ id: string, enabled: boolean, params: Object|undefined }>}
|
||||||
*/
|
*/
|
||||||
export function sceneLayerPlan(targetStates, registeredIds) {
|
export function sceneLayerPlan(targetStates, registeredIds) {
|
||||||
const known = registeredIds instanceof Set
|
const known =
|
||||||
|
registeredIds instanceof Set
|
||||||
? registeredIds
|
? registeredIds
|
||||||
: (registeredIds ? new Set(registeredIds) : null);
|
: registeredIds
|
||||||
|
? new Set(registeredIds)
|
||||||
|
: null;
|
||||||
|
|
||||||
const plan = [];
|
const plan = [];
|
||||||
for (const [id, target] of Object.entries(targetStates || {})) {
|
for (const [id, target] of Object.entries(targetStates || {})) {
|
||||||
@@ -154,7 +160,10 @@ export function sceneLayerPlan(targetStates, registeredIds) {
|
|||||||
plan.push({
|
plan.push({
|
||||||
id,
|
id,
|
||||||
enabled: !!(target && target.enabled),
|
enabled: !!(target && target.enabled),
|
||||||
params: target && target.params ? stripSceneTrackingParams(target.params) : undefined,
|
params:
|
||||||
|
target && target.params
|
||||||
|
? stripSceneTrackingParams(target.params)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return plan;
|
return plan;
|
||||||
|
|||||||
+65
-22
@@ -133,9 +133,13 @@ export function scopeTerminusAlpha(heightM) {
|
|||||||
if (h >= SCOPE_TERMINUS_FAR_M) return SCOPE_OUTSIDE_ALPHA;
|
if (h >= SCOPE_TERMINUS_FAR_M) return SCOPE_OUTSIDE_ALPHA;
|
||||||
if (h <= SCOPE_TERMINUS_NEAR_M) return SCOPE_TERMINUS_ALPHA_NEAR;
|
if (h <= SCOPE_TERMINUS_NEAR_M) return SCOPE_TERMINUS_ALPHA_NEAR;
|
||||||
// 0 at the far edge → 1 at the near edge, eased so neither end steps visibly.
|
// 0 at the far edge → 1 at the near edge, eased so neither end steps visibly.
|
||||||
const t = (SCOPE_TERMINUS_FAR_M - h) / (SCOPE_TERMINUS_FAR_M - SCOPE_TERMINUS_NEAR_M);
|
const t =
|
||||||
|
(SCOPE_TERMINUS_FAR_M - h) / (SCOPE_TERMINUS_FAR_M - SCOPE_TERMINUS_NEAR_M);
|
||||||
const eased = t * t * (3 - 2 * t);
|
const eased = t * t * (3 - 2 * t);
|
||||||
return SCOPE_OUTSIDE_ALPHA + (SCOPE_TERMINUS_ALPHA_NEAR - SCOPE_OUTSIDE_ALPHA) * eased;
|
return (
|
||||||
|
SCOPE_OUTSIDE_ALPHA +
|
||||||
|
(SCOPE_TERMINUS_ALPHA_NEAR - SCOPE_OUTSIDE_ALPHA) * eased
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,7 +153,11 @@ export function quantizeScopeTerminusAlpha(alpha) {
|
|||||||
// Round the PRODUCT too: n * 0.005 lands on values like 0.9400000000000001,
|
// Round the PRODUCT too: n * 0.005 lands on values like 0.9400000000000001,
|
||||||
// which would ride straight into the rgba() string. The quantum is 3-decimal,
|
// which would ride straight into the rgba() string. The quantum is 3-decimal,
|
||||||
// so 3 decimals is lossless here.
|
// so 3 decimals is lossless here.
|
||||||
return Math.round(Math.round(a / SCOPE_TERMINUS_QUANTUM) * SCOPE_TERMINUS_QUANTUM * 1000) / 1000;
|
return (
|
||||||
|
Math.round(
|
||||||
|
Math.round(a / SCOPE_TERMINUS_QUANTUM) * SCOPE_TERMINUS_QUANTUM * 1000,
|
||||||
|
) / 1000
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,10 +172,14 @@ export function clampScopeTerminusPct(value) {
|
|||||||
// boolean — is ABSENT, not zero. (Number(null) === 0 would otherwise pin the
|
// boolean — is ABSENT, not zero. (Number(null) === 0 would otherwise pin the
|
||||||
// floor on every adaptive write.)
|
// floor on every adaptive write.)
|
||||||
const raw = typeof value === 'string' ? value.trim() : value;
|
const raw = typeof value === 'string' ? value.trim() : value;
|
||||||
if (typeof raw !== 'number' && (typeof raw !== 'string' || raw === '')) return null;
|
if (typeof raw !== 'number' && (typeof raw !== 'string' || raw === ''))
|
||||||
|
return null;
|
||||||
const pct = Number(raw);
|
const pct = Number(raw);
|
||||||
if (!Number.isFinite(pct)) return null;
|
if (!Number.isFinite(pct)) return null;
|
||||||
return Math.max(SCOPE_TERMINUS_MIN_PCT, Math.min(SCOPE_TERMINUS_MAX_PCT, Math.round(pct)));
|
return Math.max(
|
||||||
|
SCOPE_TERMINUS_MIN_PCT,
|
||||||
|
Math.min(SCOPE_TERMINUS_MAX_PCT, Math.round(pct)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -201,7 +213,10 @@ export function updateScopeTerminusForHeight(heightM) {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
function withCoalescedPaint(fn) {
|
function withCoalescedPaint(fn) {
|
||||||
if (_coalescingPaint) { fn(); return; } // already inside a scope
|
if (_coalescingPaint) {
|
||||||
|
fn();
|
||||||
|
return;
|
||||||
|
} // already inside a scope
|
||||||
_coalescingPaint = true;
|
_coalescingPaint = true;
|
||||||
_paintDirty = false;
|
_paintDirty = false;
|
||||||
try {
|
try {
|
||||||
@@ -218,7 +233,9 @@ function withCoalescedPaint(fn) {
|
|||||||
/** @returns {number} Quantized terminus the CURRENT camera + override imply. */
|
/** @returns {number} Quantized terminus the CURRENT camera + override imply. */
|
||||||
function currentTerminusTarget() {
|
function currentTerminusTarget() {
|
||||||
return quantizeScopeTerminusAlpha(
|
return quantizeScopeTerminusAlpha(
|
||||||
_terminusOverride == null ? scopeTerminusAlpha(currentCameraHeightM()) : _terminusOverride,
|
_terminusOverride == null
|
||||||
|
? scopeTerminusAlpha(currentCameraHeightM())
|
||||||
|
: _terminusOverride,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +245,10 @@ export function setScopeTerminusOverride(alpha) {
|
|||||||
} else {
|
} else {
|
||||||
// Same supported band as the `sce` hash key: a sub-globe-scale terminus is
|
// Same supported band as the `sce` hash key: a sub-globe-scale terminus is
|
||||||
// a hole in the mask, not a scope, so every entry point floors it.
|
// a hole in the mask, not a scope, so every entry point floors it.
|
||||||
_terminusOverride = Math.max(SCOPE_OUTSIDE_ALPHA, Math.min(1, Number(alpha)));
|
_terminusOverride = Math.max(
|
||||||
|
SCOPE_OUTSIDE_ALPHA,
|
||||||
|
Math.min(1, Number(alpha)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Re-resolve immediately against the live camera so the override is visible
|
// Re-resolve immediately against the live camera so the override is visible
|
||||||
// without waiting for the next sample.
|
// without waiting for the next sample.
|
||||||
@@ -256,7 +276,9 @@ export function getScopeTerminusRepaintCount() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Backing-store scale actually used by the last draw(). */
|
/** Backing-store scale actually used by the last draw(). */
|
||||||
export function scopeMaskDevicePixelRatio(ratio = (typeof window !== 'undefined' ? window.devicePixelRatio : 1)) {
|
export function scopeMaskDevicePixelRatio(
|
||||||
|
ratio = typeof window !== 'undefined' ? window.devicePixelRatio : 1,
|
||||||
|
) {
|
||||||
return Math.min(2, Number(ratio) || 1);
|
return Math.min(2, Number(ratio) || 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +294,8 @@ export function scopeMaskDevicePixelRatio(ratio = (typeof window !== 'undefined'
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
function watchDevicePixelRatio() {
|
function watchDevicePixelRatio() {
|
||||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
||||||
|
return;
|
||||||
teardownDevicePixelRatioWatch();
|
teardownDevicePixelRatioWatch();
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
let query;
|
let query;
|
||||||
@@ -292,14 +315,18 @@ function watchDevicePixelRatio() {
|
|||||||
draw();
|
draw();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
if (typeof query.addEventListener === 'function') query.addEventListener('change', _dprListener, { once: true });
|
if (typeof query.addEventListener === 'function')
|
||||||
else if (typeof query.addListener === 'function') query.addListener(_dprListener);
|
query.addEventListener('change', _dprListener, { once: true });
|
||||||
|
else if (typeof query.addListener === 'function')
|
||||||
|
query.addListener(_dprListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
function teardownDevicePixelRatioWatch() {
|
function teardownDevicePixelRatioWatch() {
|
||||||
if (_dprQuery && _dprListener) {
|
if (_dprQuery && _dprListener) {
|
||||||
if (typeof _dprQuery.removeEventListener === 'function') _dprQuery.removeEventListener('change', _dprListener);
|
if (typeof _dprQuery.removeEventListener === 'function')
|
||||||
else if (typeof _dprQuery.removeListener === 'function') _dprQuery.removeListener(_dprListener);
|
_dprQuery.removeEventListener('change', _dprListener);
|
||||||
|
else if (typeof _dprQuery.removeListener === 'function')
|
||||||
|
_dprQuery.removeListener(_dprListener);
|
||||||
}
|
}
|
||||||
_dprQuery = null;
|
_dprQuery = null;
|
||||||
_dprListener = null;
|
_dprListener = null;
|
||||||
@@ -326,13 +353,18 @@ export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) {
|
|||||||
centerY: keyhole.centerY,
|
centerY: keyhole.centerY,
|
||||||
innerR: Math.max(0, keyhole.radius - half),
|
innerR: Math.max(0, keyhole.radius - half),
|
||||||
outerR: keyhole.radius + half,
|
outerR: keyhole.radius + half,
|
||||||
maxR: Math.hypot(Math.max(keyhole.centerX, width - keyhole.centerX),
|
maxR: Math.hypot(
|
||||||
Math.max(keyhole.centerY, height - keyhole.centerY)),
|
Math.max(keyhole.centerX, width - keyhole.centerX),
|
||||||
|
Math.max(keyhole.centerY, height - keyhole.centerY),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function draw() {
|
function draw() {
|
||||||
if (_coalescingPaint) { _paintDirty = true; return; } // one paint at scope exit
|
if (_coalescingPaint) {
|
||||||
|
_paintDirty = true;
|
||||||
|
return;
|
||||||
|
} // one paint at scope exit
|
||||||
if (!_canvas || !_container) return;
|
if (!_canvas || !_container) return;
|
||||||
// SCOPE OFF is the cheapest state, not a painted one: bail out BEFORE the
|
// SCOPE OFF is the cheapest state, not a painted one: bail out BEFORE the
|
||||||
// backing-store resize + clear (a full-viewport allocation) that used to run
|
// backing-store resize + clear (a full-viewport allocation) that used to run
|
||||||
@@ -377,8 +409,12 @@ function draw() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const gradient = ctx.createRadialGradient(
|
const gradient = ctx.createRadialGradient(
|
||||||
geo.centerX, geo.centerY, geo.innerR,
|
geo.centerX,
|
||||||
geo.centerX, geo.centerY, geo.outerR,
|
geo.centerY,
|
||||||
|
geo.innerR,
|
||||||
|
geo.centerX,
|
||||||
|
geo.centerY,
|
||||||
|
geo.outerR,
|
||||||
);
|
);
|
||||||
gradient.addColorStop(0, `rgba(${r},${g},${b},0)`);
|
gradient.addColorStop(0, `rgba(${r},${g},${b},0)`);
|
||||||
gradient.addColorStop(1, `rgba(${r},${g},${b},${_terminusAlpha})`);
|
gradient.addColorStop(1, `rgba(${r},${g},${b},${_terminusAlpha})`);
|
||||||
@@ -433,7 +469,8 @@ function watchCameraHeight(viewer) {
|
|||||||
// throttle bookkeeping, no quantize — the listener is a single compare.
|
// throttle bookkeeping, no quantize — the listener is a single compare.
|
||||||
// setScopeMaskEnabled(true) re-syncs the alpha it skipped.
|
// setScopeMaskEnabled(true) re-syncs the alpha it skipped.
|
||||||
if (!_enabled) return;
|
if (!_enabled) return;
|
||||||
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
const now =
|
||||||
|
typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||||
if (now - _lastTerminusSampleMs < SCOPE_TERMINUS_SAMPLE_MS) return;
|
if (now - _lastTerminusSampleMs < SCOPE_TERMINUS_SAMPLE_MS) return;
|
||||||
_lastTerminusSampleMs = now;
|
_lastTerminusSampleMs = now;
|
||||||
updateScopeTerminusForHeight(currentCameraHeightM());
|
updateScopeTerminusForHeight(currentCameraHeightM());
|
||||||
@@ -449,8 +486,14 @@ function watchCameraHeight(viewer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function teardownCameraHeightWatch() {
|
function teardownCameraHeightWatch() {
|
||||||
if (_cameraSampleRemover) { _cameraSampleRemover(); _cameraSampleRemover = null; }
|
if (_cameraSampleRemover) {
|
||||||
if (_cameraMoveEndRemover) { _cameraMoveEndRemover(); _cameraMoveEndRemover = null; }
|
_cameraSampleRemover();
|
||||||
|
_cameraSampleRemover = null;
|
||||||
|
}
|
||||||
|
if (_cameraMoveEndRemover) {
|
||||||
|
_cameraMoveEndRemover();
|
||||||
|
_cameraMoveEndRemover = null;
|
||||||
|
}
|
||||||
_lastTerminusSampleMs = -Infinity;
|
_lastTerminusSampleMs = -Infinity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+151
-62
@@ -5,7 +5,10 @@ import {
|
|||||||
normalizeAllocationStrategy,
|
normalizeAllocationStrategy,
|
||||||
} from './data/detectionPolicy.js';
|
} from './data/detectionPolicy.js';
|
||||||
import { clampScopeTerminusPct } from './scopeMask.js';
|
import { clampScopeTerminusPct } from './scopeMask.js';
|
||||||
import { decodeLayerStateParams, encodeLayerStateParams } from './data/layerState.js';
|
import {
|
||||||
|
decodeLayerStateParams,
|
||||||
|
encodeLayerStateParams,
|
||||||
|
} from './data/layerState.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Share Links — URL Hash State Management
|
* Share Links — URL Hash State Management
|
||||||
@@ -44,12 +47,12 @@ const SHARE_PANEL_STATE_REGISTRY = Object.freeze([
|
|||||||
{ id: 'param-slider-panel', token: 'm', pinnable: false },
|
{ id: 'param-slider-panel', token: 'm', pinnable: false },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze(new Map(
|
const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze(
|
||||||
SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry]),
|
new Map(SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry])),
|
||||||
));
|
);
|
||||||
|
|
||||||
const URL_TO_STYLE = Object.fromEntries(
|
const URL_TO_STYLE = Object.fromEntries(
|
||||||
Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k])
|
Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({
|
const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({
|
||||||
@@ -87,11 +90,10 @@ const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export class ShareLinkManager {
|
export class ShareLinkManager {
|
||||||
constructor(viewer, {
|
constructor(
|
||||||
onRestore,
|
viewer,
|
||||||
isNavigationCurrent,
|
{ onRestore, isNavigationCurrent, cancelOwnedNavigation } = {},
|
||||||
cancelOwnedNavigation,
|
) {
|
||||||
} = {}) {
|
|
||||||
this.viewer = viewer;
|
this.viewer = viewer;
|
||||||
this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void
|
this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void
|
||||||
this._debounceTimer = null;
|
this._debounceTimer = null;
|
||||||
@@ -135,17 +137,21 @@ export class ShareLinkManager {
|
|||||||
this._destroyed = false;
|
this._destroyed = false;
|
||||||
this._restoreGeneration = 0;
|
this._restoreGeneration = 0;
|
||||||
this._activeCameraFlight = null;
|
this._activeCameraFlight = null;
|
||||||
this._isNavigationCurrent = typeof isNavigationCurrent === 'function'
|
this._isNavigationCurrent =
|
||||||
|
typeof isNavigationCurrent === 'function'
|
||||||
? isNavigationCurrent
|
? isNavigationCurrent
|
||||||
: () => true;
|
: () => true;
|
||||||
this._cancelOwnedNavigation = typeof cancelOwnedNavigation === 'function'
|
this._cancelOwnedNavigation =
|
||||||
|
typeof cancelOwnedNavigation === 'function'
|
||||||
? cancelOwnedNavigation
|
? cancelOwnedNavigation
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Listen for camera changes
|
// Listen for camera changes
|
||||||
this._removeCameraChanged = this.viewer.camera.changed.addEventListener(() => {
|
this._removeCameraChanged = this.viewer.camera.changed.addEventListener(
|
||||||
|
() => {
|
||||||
this._scheduleUpdate();
|
this._scheduleUpdate();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -192,10 +198,15 @@ export class ShareLinkManager {
|
|||||||
sharpenIntensity: parseOr(params.get('si'), 49),
|
sharpenIntensity: parseOr(params.get('si'), 49),
|
||||||
hudVariant: params.get('hud') || 'tactical',
|
hudVariant: params.get('hud') || 'tactical',
|
||||||
hudVisible: params.get('hv') === '1',
|
hudVisible: params.get('hv') === '1',
|
||||||
detectionMode: restoredDetection.enabled ? restoredDetection.profile : 'OFF',
|
detectionMode: restoredDetection.enabled
|
||||||
|
? restoredDetection.profile
|
||||||
|
: 'OFF',
|
||||||
detectionDensity: restoredDetection.densityPct,
|
detectionDensity: restoredDetection.densityPct,
|
||||||
detectionAllocation: normalizeAllocationStrategy(params.get('da')),
|
detectionAllocation: normalizeAllocationStrategy(params.get('da')),
|
||||||
detectionFadePct: Math.max(0, Math.min(40, Math.round(parseOr(params.get('kf'), 16)))),
|
detectionFadePct: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(40, Math.round(parseOr(params.get('kf'), 16))),
|
||||||
|
),
|
||||||
// Deliberately still 5 after the 2026-08-23 default moved to 3. Same rule
|
// Deliberately still 5 after the 2026-08-23 default moved to 3. Same rule
|
||||||
// as `scf` below: this is the PARSE fallback for a link that predates
|
// as `scf` below: this is the PARSE fallback for a link that predates
|
||||||
// `ko`, and such a link was authored when 5 was what its author saw. Every
|
// `ko`, and such a link was authored when 5 was what its author saw. Every
|
||||||
@@ -203,7 +214,10 @@ export class ShareLinkManager {
|
|||||||
// the field — so nothing from the 5 % era depends on this number either
|
// the field — so nothing from the 5 % era depends on this number either
|
||||||
// way. The first-run default is a different question, answered in
|
// way. The first-run default is a different question, answered in
|
||||||
// celestialRing.js.
|
// celestialRing.js.
|
||||||
detectionOutsideOpacityPct: Math.max(0, Math.min(100, Math.round(parseOr(params.get('ko'), 5)))),
|
detectionOutsideOpacityPct: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, Math.round(parseOr(params.get('ko'), 5))),
|
||||||
|
),
|
||||||
celestialRing: params.has('cr') ? params.get('cr') === '1' : false,
|
celestialRing: params.has('cr') ? params.get('cr') === '1' : false,
|
||||||
scopeEnabled: params.has('sc') ? params.get('sc') === '1' : true,
|
scopeEnabled: params.has('sc') ? params.get('sc') === '1' : true,
|
||||||
// Deliberately still 35 through both later default moves (0 on
|
// Deliberately still 35 through both later default moves (0 on
|
||||||
@@ -216,7 +230,10 @@ export class ShareLinkManager {
|
|||||||
// (`_scopeFeatherPct` in the constructor tracks the default: that one
|
// (`_scopeFeatherPct` in the constructor tracks the default: that one
|
||||||
// mirrors live state for the link this session generates, so it must match
|
// mirrors live state for the link this session generates, so it must match
|
||||||
// the mask, not the archive.)
|
// the mask, not the archive.)
|
||||||
scopeFeatherPct: Math.max(0, Math.min(100, Math.round(parseOr(params.get('scf'), 35)))),
|
scopeFeatherPct: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, Math.round(parseOr(params.get('scf'), 35))),
|
||||||
|
),
|
||||||
// Absent (or non-numeric) `sce` = adaptive (null), the default behavior;
|
// Absent (or non-numeric) `sce` = adaptive (null), the default behavior;
|
||||||
// a value pins the terminus opacity percent, clamped into the SUPPORTED
|
// a value pins the terminus opacity percent, clamped into the SUPPORTED
|
||||||
// 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in
|
// 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in
|
||||||
@@ -226,9 +243,10 @@ export class ShareLinkManager {
|
|||||||
: null,
|
: null,
|
||||||
mapStack: params.get('map') || 'photoreal',
|
mapStack: params.get('map') || 'photoreal',
|
||||||
layerState: decodedLayerState,
|
layerState: decodedLayerState,
|
||||||
layerStateInvalid: params.get('v') === '2'
|
layerStateInvalid:
|
||||||
&& params.has('l')
|
params.get('v') === '2' &&
|
||||||
&& decodedLayerState === null,
|
params.has('l') &&
|
||||||
|
decodedLayerState === null,
|
||||||
panelState: decodePanelStateParams(params),
|
panelState: decodePanelStateParams(params),
|
||||||
sharedAtMs: decodeShareCreatedAtMs(params),
|
sharedAtMs: decodeShareCreatedAtMs(params),
|
||||||
};
|
};
|
||||||
@@ -247,27 +265,40 @@ export class ShareLinkManager {
|
|||||||
* Apply a parsed state to the viewer + style manager.
|
* Apply a parsed state to the viewer + style manager.
|
||||||
*/
|
*/
|
||||||
async applyState(state, { applyCamera = true, navigationToken = null } = {}) {
|
async applyState(state, { applyCamera = true, navigationToken = null } = {}) {
|
||||||
if (this._destroyed || !state) return { succeeded: false, reason: 'unavailable' };
|
if (this._destroyed || !state)
|
||||||
|
return { succeeded: false, reason: 'unavailable' };
|
||||||
const view = {
|
const view = {
|
||||||
destination: Cesium.Cartesian3.fromDegrees(state.lon, state.lat, state.alt),
|
destination: Cesium.Cartesian3.fromDegrees(
|
||||||
|
state.lon,
|
||||||
|
state.lat,
|
||||||
|
state.alt,
|
||||||
|
),
|
||||||
orientation: {
|
orientation: {
|
||||||
heading: Cesium.Math.toRadians(state.heading),
|
heading: Cesium.Math.toRadians(state.heading),
|
||||||
pitch: Cesium.Math.toRadians(state.pitch),
|
pitch: Cesium.Math.toRadians(state.pitch),
|
||||||
roll: Cesium.Math.toRadians(state.roll),
|
roll: Cesium.Math.toRadians(state.roll),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let cameraPromise = Promise.resolve({ status: applyCamera ? 'superseded' : 'skipped' });
|
let cameraPromise = Promise.resolve({
|
||||||
|
status: applyCamera ? 'superseded' : 'skipped',
|
||||||
|
});
|
||||||
if (applyCamera && this._isNavigationCurrent(navigationToken)) {
|
if (applyCamera && this._isNavigationCurrent(navigationToken)) {
|
||||||
const restoreGeneration = ++this._restoreGeneration;
|
const restoreGeneration = ++this._restoreGeneration;
|
||||||
let settleCamera;
|
let settleCamera;
|
||||||
cameraPromise = new Promise((resolve) => { settleCamera = resolve; });
|
cameraPromise = new Promise((resolve) => {
|
||||||
|
settleCamera = resolve;
|
||||||
|
});
|
||||||
const releaseOwnedFlight = (status = 'cancelled') => {
|
const releaseOwnedFlight = (status = 'cancelled') => {
|
||||||
if (this._activeCameraFlight?.restoreGeneration === restoreGeneration) {
|
if (this._activeCameraFlight?.restoreGeneration === restoreGeneration) {
|
||||||
this._activeCameraFlight = null;
|
this._activeCameraFlight = null;
|
||||||
}
|
}
|
||||||
settleCamera({ status });
|
settleCamera({ status });
|
||||||
};
|
};
|
||||||
this._activeCameraFlight = { restoreGeneration, navigationToken, settle: releaseOwnedFlight };
|
this._activeCameraFlight = {
|
||||||
|
restoreGeneration,
|
||||||
|
navigationToken,
|
||||||
|
settle: releaseOwnedFlight,
|
||||||
|
};
|
||||||
// Re-apply the final pose only while this share restoration still owns
|
// Re-apply the final pose only while this share restoration still owns
|
||||||
// navigation. A later user or voice command wins over delayed restore.
|
// navigation. A later user or voice command wins over delayed restore.
|
||||||
this.viewer.camera.flyTo({
|
this.viewer.camera.flyTo({
|
||||||
@@ -276,9 +307,9 @@ export class ShareLinkManager {
|
|||||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||||
complete: () => {
|
complete: () => {
|
||||||
if (
|
if (
|
||||||
this._destroyed
|
this._destroyed ||
|
||||||
|| restoreGeneration !== this._restoreGeneration
|
restoreGeneration !== this._restoreGeneration ||
|
||||||
|| !this._isNavigationCurrent(navigationToken)
|
!this._isNavigationCurrent(navigationToken)
|
||||||
) {
|
) {
|
||||||
releaseOwnedFlight('superseded');
|
releaseOwnedFlight('superseded');
|
||||||
return;
|
return;
|
||||||
@@ -293,14 +324,17 @@ export class ShareLinkManager {
|
|||||||
|
|
||||||
// Notify the style manager via callback
|
// Notify the style manager via callback
|
||||||
const reserved = state.restoreAuthority || null;
|
const reserved = state.restoreAuthority || null;
|
||||||
const visualCurrent = !reserved || reserved.visual === this._restoreAuthority.visual;
|
const visualCurrent =
|
||||||
|
!reserved || reserved.visual === this._restoreAuthority.visual;
|
||||||
const mapCurrent = !reserved || reserved.map === this._restoreAuthority.map;
|
const mapCurrent = !reserved || reserved.map === this._restoreAuthority.map;
|
||||||
let panelState = state.panelState;
|
let panelState = state.panelState;
|
||||||
if (reserved && panelState?.specs) {
|
if (reserved && panelState?.specs) {
|
||||||
panelState = {
|
panelState = {
|
||||||
specs: panelState.specs.filter((spec) => (
|
specs: panelState.specs.filter(
|
||||||
(reserved.panels?.get(spec.id) || 0) === (this._restoreAuthority.panels.get(spec.id) || 0)
|
(spec) =>
|
||||||
)),
|
(reserved.panels?.get(spec.id) || 0) ===
|
||||||
|
(this._restoreAuthority.panels.get(spec.id) || 0),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
if (panelState.specs.length === 0) panelState = null;
|
if (panelState.specs.length === 0) panelState = null;
|
||||||
}
|
}
|
||||||
@@ -317,9 +351,13 @@ export class ShareLinkManager {
|
|||||||
hudVisible: visualCurrent ? state.hudVisible : undefined,
|
hudVisible: visualCurrent ? state.hudVisible : undefined,
|
||||||
detectionMode: visualCurrent ? state.detectionMode : undefined,
|
detectionMode: visualCurrent ? state.detectionMode : undefined,
|
||||||
detectionDensity: visualCurrent ? state.detectionDensity : undefined,
|
detectionDensity: visualCurrent ? state.detectionDensity : undefined,
|
||||||
detectionAllocation: visualCurrent ? state.detectionAllocation : undefined,
|
detectionAllocation: visualCurrent
|
||||||
|
? state.detectionAllocation
|
||||||
|
: undefined,
|
||||||
detectionFadePct: visualCurrent ? state.detectionFadePct : undefined,
|
detectionFadePct: visualCurrent ? state.detectionFadePct : undefined,
|
||||||
detectionOutsideOpacityPct: visualCurrent ? state.detectionOutsideOpacityPct : undefined,
|
detectionOutsideOpacityPct: visualCurrent
|
||||||
|
? state.detectionOutsideOpacityPct
|
||||||
|
: undefined,
|
||||||
celestialRing: visualCurrent ? state.celestialRing : undefined,
|
celestialRing: visualCurrent ? state.celestialRing : undefined,
|
||||||
scopeEnabled: visualCurrent ? state.scopeEnabled : undefined,
|
scopeEnabled: visualCurrent ? state.scopeEnabled : undefined,
|
||||||
scopeFeatherPct: visualCurrent ? state.scopeFeatherPct : undefined,
|
scopeFeatherPct: visualCurrent ? state.scopeFeatherPct : undefined,
|
||||||
@@ -336,7 +374,11 @@ export class ShareLinkManager {
|
|||||||
camera: camera.status,
|
camera: camera.status,
|
||||||
visual: visualCurrent ? restoreStatus : 'superseded',
|
visual: visualCurrent ? restoreStatus : 'superseded',
|
||||||
map: mapCurrent ? restoreStatus : 'superseded',
|
map: mapCurrent ? restoreStatus : 'superseded',
|
||||||
panels: panelState ? restoreStatus : (state.panelState ? 'superseded' : 'skipped'),
|
panels: panelState
|
||||||
|
? restoreStatus
|
||||||
|
: state.panelState
|
||||||
|
? 'superseded'
|
||||||
|
: 'skipped',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +393,10 @@ export class ShareLinkManager {
|
|||||||
claimRestoreLane(lane, panelId = null) {
|
claimRestoreLane(lane, panelId = null) {
|
||||||
if (!this._initialRestorePending) return;
|
if (!this._initialRestorePending) return;
|
||||||
if (lane === 'panel' && panelId) {
|
if (lane === 'panel' && panelId) {
|
||||||
this._restoreAuthority.panels.set(panelId, (this._restoreAuthority.panels.get(panelId) || 0) + 1);
|
this._restoreAuthority.panels.set(
|
||||||
|
panelId,
|
||||||
|
(this._restoreAuthority.panels.get(panelId) || 0) + 1,
|
||||||
|
);
|
||||||
} else if (lane === 'visual' || lane === 'map') {
|
} else if (lane === 'visual' || lane === 'map') {
|
||||||
this._restoreAuthority[lane] += 1;
|
this._restoreAuthority[lane] += 1;
|
||||||
}
|
}
|
||||||
@@ -369,7 +414,8 @@ export class ShareLinkManager {
|
|||||||
|
|
||||||
/** Install the active visual preset parameter source used by URL generation. */
|
/** Install the active visual preset parameter source used by URL generation. */
|
||||||
setStyleParamStateProvider(provider) {
|
setStyleParamStateProvider(provider) {
|
||||||
this._styleParamStateProvider = typeof provider === 'function' ? provider : null;
|
this._styleParamStateProvider =
|
||||||
|
typeof provider === 'function' ? provider : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called only when the durable layer preference model changes. */
|
/** Called only when the durable layer preference model changes. */
|
||||||
@@ -384,7 +430,11 @@ export class ShareLinkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_encodePanelStateParam(params, panelState) {
|
_encodePanelStateParam(params, panelState) {
|
||||||
if (!panelState || !Array.isArray(panelState.specs) || panelState.specs.length === 0) {
|
if (
|
||||||
|
!panelState ||
|
||||||
|
!Array.isArray(panelState.specs) ||
|
||||||
|
panelState.specs.length === 0
|
||||||
|
) {
|
||||||
params.delete(SHARE_UI_STATE_PARAM);
|
params.delete(SHARE_UI_STATE_PARAM);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -397,7 +447,8 @@ export class ShareLinkManager {
|
|||||||
assignments.push(`${spec.token}.p.${state.pinned ? '1' : '0'}`);
|
assignments.push(`${spec.token}.p.${state.pinned ? '1' : '0'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (assignments.length) params.set(SHARE_UI_STATE_PARAM, assignments.join('_'));
|
if (assignments.length)
|
||||||
|
params.set(SHARE_UI_STATE_PARAM, assignments.join('_'));
|
||||||
else params.delete(SHARE_UI_STATE_PARAM);
|
else params.delete(SHARE_UI_STATE_PARAM);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,18 +461,30 @@ export class ShareLinkManager {
|
|||||||
onToggleChange(bloom, sharpen, extras = {}) {
|
onToggleChange(bloom, sharpen, extras = {}) {
|
||||||
this._bloomEnabled = bloom;
|
this._bloomEnabled = bloom;
|
||||||
this._sharpenEnabled = sharpen;
|
this._sharpenEnabled = sharpen;
|
||||||
if (typeof extras.bloomIntensity === 'number') this._bloomIntensity = extras.bloomIntensity;
|
if (typeof extras.bloomIntensity === 'number')
|
||||||
if (typeof extras.bloomVersion === 'number') this._bloomVersion = extras.bloomVersion;
|
this._bloomIntensity = extras.bloomIntensity;
|
||||||
if (typeof extras.sharpenIntensity === 'number') this._sharpenIntensity = extras.sharpenIntensity;
|
if (typeof extras.bloomVersion === 'number')
|
||||||
if (typeof extras.hudVariant === 'string') this._hudVariant = extras.hudVariant;
|
this._bloomVersion = extras.bloomVersion;
|
||||||
if (typeof extras.hudVisible === 'boolean') this._hudVisible = extras.hudVisible;
|
if (typeof extras.sharpenIntensity === 'number')
|
||||||
if (typeof extras.detectionMode === 'string') this._detectionMode = extras.detectionMode.toUpperCase();
|
this._sharpenIntensity = extras.sharpenIntensity;
|
||||||
if (typeof extras.detectionDensity === 'number') this._detectionDensity = extras.detectionDensity;
|
if (typeof extras.hudVariant === 'string')
|
||||||
|
this._hudVariant = extras.hudVariant;
|
||||||
|
if (typeof extras.hudVisible === 'boolean')
|
||||||
|
this._hudVisible = extras.hudVisible;
|
||||||
|
if (typeof extras.detectionMode === 'string')
|
||||||
|
this._detectionMode = extras.detectionMode.toUpperCase();
|
||||||
|
if (typeof extras.detectionDensity === 'number')
|
||||||
|
this._detectionDensity = extras.detectionDensity;
|
||||||
if (typeof extras.detectionAllocation === 'string') {
|
if (typeof extras.detectionAllocation === 'string') {
|
||||||
this._detectionAllocation = normalizeAllocationStrategy(extras.detectionAllocation);
|
this._detectionAllocation = normalizeAllocationStrategy(
|
||||||
|
extras.detectionAllocation,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (typeof extras.detectionFadePct === 'number') {
|
if (typeof extras.detectionFadePct === 'number') {
|
||||||
this._detectionFadePct = Math.max(0, Math.min(40, Math.round(extras.detectionFadePct)));
|
this._detectionFadePct = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(40, Math.round(extras.detectionFadePct)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (typeof extras.detectionOutsideOpacityPct === 'number') {
|
if (typeof extras.detectionOutsideOpacityPct === 'number') {
|
||||||
this._detectionOutsideOpacityPct = Math.max(
|
this._detectionOutsideOpacityPct = Math.max(
|
||||||
@@ -429,10 +492,15 @@ export class ShareLinkManager {
|
|||||||
Math.min(100, Math.round(extras.detectionOutsideOpacityPct)),
|
Math.min(100, Math.round(extras.detectionOutsideOpacityPct)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (typeof extras.celestialRingEnabled === 'boolean') this._celestialRingEnabled = extras.celestialRingEnabled;
|
if (typeof extras.celestialRingEnabled === 'boolean')
|
||||||
if (typeof extras.scopeEnabled === 'boolean') this._scopeEnabled = extras.scopeEnabled;
|
this._celestialRingEnabled = extras.celestialRingEnabled;
|
||||||
|
if (typeof extras.scopeEnabled === 'boolean')
|
||||||
|
this._scopeEnabled = extras.scopeEnabled;
|
||||||
if (typeof extras.scopeFeatherPct === 'number') {
|
if (typeof extras.scopeFeatherPct === 'number') {
|
||||||
this._scopeFeatherPct = Math.max(0, Math.min(100, Math.round(extras.scopeFeatherPct)));
|
this._scopeFeatherPct = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, Math.round(extras.scopeFeatherPct)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null;
|
if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null;
|
||||||
else if (typeof extras.scopeTerminusPct === 'number') {
|
else if (typeof extras.scopeTerminusPct === 'number') {
|
||||||
@@ -482,9 +550,18 @@ export class ShareLinkManager {
|
|||||||
params.set('lat', Cesium.Math.toDegrees(carto.latitude).toFixed(4));
|
params.set('lat', Cesium.Math.toDegrees(carto.latitude).toFixed(4));
|
||||||
params.set('lon', Cesium.Math.toDegrees(carto.longitude).toFixed(4));
|
params.set('lon', Cesium.Math.toDegrees(carto.longitude).toFixed(4));
|
||||||
params.set('alt', Math.round(carto.height).toString());
|
params.set('alt', Math.round(carto.height).toString());
|
||||||
params.set('heading', Math.round(Cesium.Math.toDegrees(camera.heading)).toString());
|
params.set(
|
||||||
params.set('pitch', Math.round(Cesium.Math.toDegrees(camera.pitch)).toString());
|
'heading',
|
||||||
params.set('roll', Math.round(Cesium.Math.toDegrees(camera.roll)).toString());
|
Math.round(Cesium.Math.toDegrees(camera.heading)).toString(),
|
||||||
|
);
|
||||||
|
params.set(
|
||||||
|
'pitch',
|
||||||
|
Math.round(Cesium.Math.toDegrees(camera.pitch)).toString(),
|
||||||
|
);
|
||||||
|
params.set(
|
||||||
|
'roll',
|
||||||
|
Math.round(Cesium.Math.toDegrees(camera.roll)).toString(),
|
||||||
|
);
|
||||||
params.set('style', STYLE_TO_URL[this._currentStyle] || 'normal');
|
params.set('style', STYLE_TO_URL[this._currentStyle] || 'normal');
|
||||||
params.set('bloom', this._bloomEnabled ? '1' : '0');
|
params.set('bloom', this._bloomEnabled ? '1' : '0');
|
||||||
params.set('sharpen', this._sharpenEnabled ? '1' : '0');
|
params.set('sharpen', this._sharpenEnabled ? '1' : '0');
|
||||||
@@ -527,7 +604,10 @@ export class ShareLinkManager {
|
|||||||
destroy() {
|
destroy() {
|
||||||
if (this._destroyed) return;
|
if (this._destroyed) return;
|
||||||
const activeFlight = this._activeCameraFlight;
|
const activeFlight = this._activeCameraFlight;
|
||||||
if (activeFlight && this._isNavigationCurrent(activeFlight.navigationToken)) {
|
if (
|
||||||
|
activeFlight &&
|
||||||
|
this._isNavigationCurrent(activeFlight.navigationToken)
|
||||||
|
) {
|
||||||
this._cancelOwnedNavigation?.();
|
this._cancelOwnedNavigation?.();
|
||||||
}
|
}
|
||||||
activeFlight?.settle?.('destroyed');
|
activeFlight?.settle?.('destroyed');
|
||||||
@@ -570,18 +650,22 @@ export function encodeStyleParamState(params, styleName, values) {
|
|||||||
const clamped = Math.max(spec.min, Math.min(spec.max, numeric));
|
const clamped = Math.max(spec.min, Math.min(spec.max, numeric));
|
||||||
assignments.push(`${spec.token}.${Math.round(clamped * 100)}`);
|
assignments.push(`${spec.token}.${Math.round(clamped * 100)}`);
|
||||||
}
|
}
|
||||||
if (assignments.length) params.set(SHARE_STYLE_PARAMS_PARAM, assignments.join('_'));
|
if (assignments.length)
|
||||||
|
params.set(SHARE_STYLE_PARAMS_PARAM, assignments.join('_'));
|
||||||
else params.delete(SHARE_STYLE_PARAMS_PARAM);
|
else params.delete(SHARE_STYLE_PARAMS_PARAM);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decode allowlisted parameters for the selected visual preset. */
|
/** Decode allowlisted parameters for the selected visual preset. */
|
||||||
export function decodeStyleParamState(params, styleName) {
|
export function decodeStyleParamState(params, styleName) {
|
||||||
if (params.get('v') !== '2' || !params.has(SHARE_STYLE_PARAMS_PARAM)) return null;
|
if (params.get('v') !== '2' || !params.has(SHARE_STYLE_PARAMS_PARAM))
|
||||||
|
return null;
|
||||||
const registry = SHARE_STYLE_PARAM_REGISTRY[styleName];
|
const registry = SHARE_STYLE_PARAM_REGISTRY[styleName];
|
||||||
if (!registry) return null;
|
if (!registry) return null;
|
||||||
const byToken = new Map(registry.map((spec) => [spec.token, spec]));
|
const byToken = new Map(registry.map((spec) => [spec.token, spec]));
|
||||||
const decoded = {};
|
const decoded = {};
|
||||||
for (const assignment of String(params.get(SHARE_STYLE_PARAMS_PARAM) || '').split('_')) {
|
for (const assignment of String(
|
||||||
|
params.get(SHARE_STYLE_PARAMS_PARAM) || '',
|
||||||
|
).split('_')) {
|
||||||
const [token, scaledRaw, ...extra] = assignment.split('.');
|
const [token, scaledRaw, ...extra] = assignment.split('.');
|
||||||
if (extra.length || !/^-?\d+$/.test(scaledRaw || '')) continue;
|
if (extra.length || !/^-?\d+$/.test(scaledRaw || '')) continue;
|
||||||
const spec = byToken.get(token);
|
const spec = byToken.get(token);
|
||||||
@@ -606,12 +690,17 @@ export function decodePanelStateParams(params) {
|
|||||||
if (!spec || (field !== 'c' && field !== 'p')) continue;
|
if (!spec || (field !== 'c' && field !== 'p')) continue;
|
||||||
if (value !== '0' && value !== '1') continue;
|
if (value !== '0' && value !== '1') continue;
|
||||||
const bool = value === '1';
|
const bool = value === '1';
|
||||||
const current = stateById.get(spec.id) || { id: spec.id, collapsed: null, pinned: null };
|
const current = stateById.get(spec.id) || {
|
||||||
|
id: spec.id,
|
||||||
|
collapsed: null,
|
||||||
|
pinned: null,
|
||||||
|
};
|
||||||
if (field === 'c') current.collapsed = bool;
|
if (field === 'c') current.collapsed = bool;
|
||||||
else if (field === 'p' && spec.pinnable) current.pinned = bool;
|
else if (field === 'p' && spec.pinnable) current.pinned = bool;
|
||||||
stateById.set(spec.id, current);
|
stateById.set(spec.id, current);
|
||||||
}
|
}
|
||||||
const specs = Array.from(stateById.values())
|
const specs = Array.from(stateById.values()).filter(
|
||||||
.filter((entry) => typeof entry.collapsed === 'boolean');
|
(entry) => typeof entry.collapsed === 'boolean',
|
||||||
|
);
|
||||||
return specs.length ? { specs } : null;
|
return specs.length ? { specs } : null;
|
||||||
}
|
}
|
||||||
+1370
-422
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. */
|
/** Compose the standalone action runner with the voice controls. */
|
||||||
export function initGevVoiceCommands(options) {
|
export function initGevVoiceCommands(options) {
|
||||||
return createVoiceCommands({ ...options, runner: createGevActionRunner(options) });
|
return createVoiceCommands({
|
||||||
|
...options,
|
||||||
|
runner: createGevActionRunner(options),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
+609
-258
File diff suppressed because it is too large.
Load diff
+13
-6
@@ -122,7 +122,7 @@ export function isKnownVoiceTier(tier) {
|
|||||||
export function mostExpensiveVoiceModel() {
|
export function mostExpensiveVoiceModel() {
|
||||||
// Rank by audio output — the dominant cost in a speech-to-speech session.
|
// Rank by audio output — the dominant cost in a speech-to-speech session.
|
||||||
return Object.values(VOICE_MODELS).reduce((worst, entry) =>
|
return Object.values(VOICE_MODELS).reduce((worst, entry) =>
|
||||||
entry.rates.audioOutput > worst.rates.audioOutput ? entry : worst
|
entry.rates.audioOutput > worst.rates.audioOutput ? entry : worst,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +191,10 @@ export const VOICE_COST_LIMIT_OFF = 'off';
|
|||||||
*/
|
*/
|
||||||
export function normalizeCostLimits(limits) {
|
export function normalizeCostLimits(limits) {
|
||||||
const clean = (value, fallback) => {
|
const clean = (value, fallback) => {
|
||||||
if (typeof value === 'string' && value.trim().toLowerCase() === VOICE_COST_LIMIT_OFF) {
|
if (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
value.trim().toLowerCase() === VOICE_COST_LIMIT_OFF
|
||||||
|
) {
|
||||||
return Infinity;
|
return Infinity;
|
||||||
}
|
}
|
||||||
if (value === Infinity) return Infinity;
|
if (value === Infinity) return Infinity;
|
||||||
@@ -209,7 +212,8 @@ export function normalizeCostLimits(limits) {
|
|||||||
/** Convert limits to a JSON-safe shape that round-trips a disabled threshold. */
|
/** Convert limits to a JSON-safe shape that round-trips a disabled threshold. */
|
||||||
export function serializeCostLimits(limits) {
|
export function serializeCostLimits(limits) {
|
||||||
const normalized = normalizeCostLimits(limits);
|
const normalized = normalizeCostLimits(limits);
|
||||||
const encode = (value) => (Number.isFinite(value) ? value : VOICE_COST_LIMIT_OFF);
|
const encode = (value) =>
|
||||||
|
Number.isFinite(value) ? value : VOICE_COST_LIMIT_OFF;
|
||||||
return {
|
return {
|
||||||
warnUsd: encode(normalized.warnUsd),
|
warnUsd: encode(normalized.warnUsd),
|
||||||
capUsd: encode(normalized.capUsd),
|
capUsd: encode(normalized.capUsd),
|
||||||
@@ -269,7 +273,7 @@ export function splitUsageTokens(usage) {
|
|||||||
if (!cached && nonNegative(inDetails.cached_tokens) > 0) {
|
if (!cached && nonNegative(inDetails.cached_tokens) > 0) {
|
||||||
audioCached = Math.min(
|
audioCached = Math.min(
|
||||||
nonNegative(inDetails.cached_tokens),
|
nonNegative(inDetails.cached_tokens),
|
||||||
nonNegative(inDetails.audio_tokens)
|
nonNegative(inDetails.audio_tokens),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
textIn = Math.max(0, nonNegative(inDetails.text_tokens) - textCached);
|
textIn = Math.max(0, nonNegative(inDetails.text_tokens) - textCached);
|
||||||
@@ -289,12 +293,15 @@ export function splitUsageTokens(usage) {
|
|||||||
// to audio (priciest) so a partial payload over-estimates, never under.
|
// to audio (priciest) so a partial payload over-estimates, never under.
|
||||||
const inputResidual = Math.max(
|
const inputResidual = Math.max(
|
||||||
0,
|
0,
|
||||||
inputTotal - (textIn + audioIn + imageIn + textCached + audioCached + imageCached)
|
inputTotal -
|
||||||
|
(textIn + audioIn + imageIn + textCached + audioCached + imageCached),
|
||||||
);
|
);
|
||||||
audioIn += inputResidual;
|
audioIn += inputResidual;
|
||||||
|
|
||||||
const textOut = outDetails ? nonNegative(outDetails.text_tokens) : 0;
|
const textOut = outDetails ? nonNegative(outDetails.text_tokens) : 0;
|
||||||
let audioOut = outDetails ? nonNegative(outDetails.audio_tokens) : outputTotal;
|
let audioOut = outDetails
|
||||||
|
? nonNegative(outDetails.audio_tokens)
|
||||||
|
: outputTotal;
|
||||||
const outputResidual = Math.max(0, outputTotal - (textOut + audioOut));
|
const outputResidual = Math.max(0, outputTotal - (textOut + audioOut));
|
||||||
audioOut += outputResidual;
|
audioOut += outputResidual;
|
||||||
|
|
||||||
|
|||||||
@@ -33,10 +33,13 @@ export function deriveWeatherEffectProfile(weather) {
|
|||||||
const precipitation = Math.max(0, Number(weather.precipitationMm) || 0);
|
const precipitation = Math.max(0, Number(weather.precipitationMm) || 0);
|
||||||
const wind = range01(weather.windKph, 4, 90);
|
const wind = range01(weather.windKph, 4, 90);
|
||||||
const visibilityM = Number(weather.visibilityM);
|
const visibilityM = Number(weather.visibilityM);
|
||||||
const visibilityFog = Number.isFinite(visibilityM) ? range01(12000 - visibilityM, 0, 11500) : 0;
|
const visibilityFog = Number.isFinite(visibilityM)
|
||||||
|
? range01(12000 - visibilityM, 0, 11500)
|
||||||
|
: 0;
|
||||||
|
|
||||||
const drizzle = code >= 51 && code <= 57;
|
const drizzle = code >= 51 && code <= 57;
|
||||||
const rainCode = (code >= 61 && code <= 67) || (code >= 80 && code <= 82) || code >= 95;
|
const rainCode =
|
||||||
|
(code >= 61 && code <= 67) || (code >= 80 && code <= 82) || code >= 95;
|
||||||
const snowCode = (code >= 71 && code <= 77) || (code >= 85 && code <= 86);
|
const snowCode = (code >= 71 && code <= 77) || (code >= 85 && code <= 86);
|
||||||
const fogCode = code === 45 || code === 48;
|
const fogCode = code === 45 || code === 48;
|
||||||
const stormCode = code >= 95;
|
const stormCode = code >= 95;
|
||||||
@@ -48,7 +51,13 @@ export function deriveWeatherEffectProfile(weather) {
|
|||||||
const snow = snowCode ? Math.max(snowFloor, observedPrecip) : 0;
|
const snow = snowCode ? Math.max(snowFloor, observedPrecip) : 0;
|
||||||
const fog = Math.max(fogCode ? 0.78 : 0, visibilityFog);
|
const fog = Math.max(fogCode ? 0.78 : 0, visibilityFog);
|
||||||
const storm = stormCode ? Math.max(0.55, observedPrecip) : 0;
|
const storm = stormCode ? Math.max(0.55, observedPrecip) : 0;
|
||||||
const forcedCloud = stormCode ? 0.92 : (rain || snow) ? 0.7 : fogCode ? 0.55 : 0;
|
const forcedCloud = stormCode
|
||||||
|
? 0.92
|
||||||
|
: rain || snow
|
||||||
|
? 0.7
|
||||||
|
: fogCode
|
||||||
|
? 0.55
|
||||||
|
: 0;
|
||||||
const cloud = Math.max(cover, forcedCloud);
|
const cloud = Math.max(cover, forcedCloud);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+24
-11
@@ -16,30 +16,41 @@ export function isValidWorldFocusTarget(detail) {
|
|||||||
if (!detail || !WORLD_FOCUS_FRAMING[detail.kind]) return false;
|
if (!detail || !WORLD_FOCUS_FRAMING[detail.kind]) return false;
|
||||||
if (!String(detail.id || '').trim()) return false;
|
if (!String(detail.id || '').trim()) return false;
|
||||||
const { position } = detail;
|
const { position } = detail;
|
||||||
if (!position
|
if (
|
||||||
|| !Number.isFinite(position.x)
|
!position ||
|
||||||
|| !Number.isFinite(position.y)
|
!Number.isFinite(position.x) ||
|
||||||
|| !Number.isFinite(position.z)) return false;
|
!Number.isFinite(position.y) ||
|
||||||
|
!Number.isFinite(position.z)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
// Vessel and fire targets are surface-anchored Earth positions. Merely
|
// Vessel and fire targets are surface-anchored Earth positions. Merely
|
||||||
// finite coordinates near the ECEF origin cannot be flown to, and must be
|
// finite coordinates near the ECEF origin cannot be flown to, and must be
|
||||||
// rejected before the camera policy releases a current follow owner.
|
// rejected before the camera policy releases a current follow owner.
|
||||||
const magnitude = Cesium.Cartesian3.magnitude(position);
|
const magnitude = Cesium.Cartesian3.magnitude(position);
|
||||||
return Number.isFinite(magnitude)
|
return (
|
||||||
&& magnitude >= Cesium.Ellipsoid.WGS84.minimumRadius * 0.95;
|
Number.isFinite(magnitude) &&
|
||||||
|
magnitude >= Cesium.Ellipsoid.WGS84.minimumRadius * 0.95
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Announce a valid user-click focus request. */
|
/** Announce a valid user-click focus request. */
|
||||||
export function requestWorldFocus(detail, eventTarget = globalThis.window) {
|
export function requestWorldFocus(detail, eventTarget = globalThis.window) {
|
||||||
if (!isValidWorldFocusTarget(detail)) return false;
|
if (!isValidWorldFocusTarget(detail)) return false;
|
||||||
if (typeof eventTarget?.dispatchEvent !== 'function') return false;
|
if (typeof eventTarget?.dispatchEvent !== 'function') return false;
|
||||||
eventTarget.dispatchEvent(new CustomEvent(WORLD_FOCUS_REQUEST_EVENT, { detail }));
|
eventTarget.dispatchEvent(
|
||||||
|
new CustomEvent(WORLD_FOCUS_REQUEST_EVENT, { detail }),
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Register one listener and return an idempotent disposer. */
|
/** Register one listener and return an idempotent disposer. */
|
||||||
export function registerWorldFocusRequestListener(eventTarget, listener) {
|
export function registerWorldFocusRequestListener(eventTarget, listener) {
|
||||||
if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener
|
if (
|
||||||
|| typeof listener !== 'function') return () => {};
|
!eventTarget?.addEventListener ||
|
||||||
|
!eventTarget?.removeEventListener ||
|
||||||
|
typeof listener !== 'function'
|
||||||
|
)
|
||||||
|
return () => {};
|
||||||
eventTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, listener);
|
eventTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, listener);
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -53,7 +64,8 @@ export function registerWorldFocusRequestListener(eventTarget, listener) {
|
|||||||
export function routeWorldFocusRequest(event, runExplicitFocus, fly) {
|
export function routeWorldFocusRequest(event, runExplicitFocus, fly) {
|
||||||
const detail = event?.detail;
|
const detail = event?.detail;
|
||||||
if (!isValidWorldFocusTarget(detail)) return false;
|
if (!isValidWorldFocusTarget(detail)) return false;
|
||||||
if (typeof runExplicitFocus !== 'function' || typeof fly !== 'function') return false;
|
if (typeof runExplicitFocus !== 'function' || typeof fly !== 'function')
|
||||||
|
return false;
|
||||||
return runExplicitFocus(detail, () => fly(detail));
|
return runExplicitFocus(detail, () => fly(detail));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +75,8 @@ export function flyToWorldTarget(viewer, target = {}) {
|
|||||||
const framing = WORLD_FOCUS_FRAMING[target.kind];
|
const framing = WORLD_FOCUS_FRAMING[target.kind];
|
||||||
if (!camera || !framing || !isValidWorldFocusTarget(target)) return false;
|
if (!camera || !framing || !isValidWorldFocusTarget(target)) return false;
|
||||||
const heading = Number.isFinite(camera.heading) ? camera.heading : 0;
|
const heading = Number.isFinite(camera.heading) ? camera.heading : 0;
|
||||||
const duration = target.durationSec > 0
|
const duration =
|
||||||
|
target.durationSec > 0
|
||||||
? target.durationSec
|
? target.durationSec
|
||||||
: WORLD_CLICK_FOCUS_DURATION_SEC;
|
: WORLD_CLICK_FOCUS_DURATION_SEC;
|
||||||
camera.cancelFlight?.();
|
camera.cancelFlight?.();
|
||||||
|
|||||||
Reference in new issue
Block a user