Merge pull request #591 from bilawalsidhu/refactor/complete-runtime-format

build: complete automatic runtime formatting coverage
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-15 07:33:40 -07:00
commit e284a9cd34
111 files changed
+9619 -3455

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Mechanical runtime formatting; executable syntax is unchanged.
2aca7aecbb38c8554d40ddbc415fec37b3a65ec4
b7c2c47b4154c3904337fd86d71caca5d1c602bb
+7 -6
View File
@@ -64,12 +64,13 @@ The highest-leverage places to jump in:
## Formatting and reusable components ## Formatting and reusable components
Run `npm run format` before submitting changes to adopted modules, then Run `npm run format` before submitting changes, then `npm run format:check`
`npm run format:check` and `npm run check:boundaries`. Formatting uses the and `npm run check:boundaries`. Runtime JavaScript under the owned roots in
explicit file list in `scripts/format-scope.json`; add newly extracted modules `scripts/format-runtime.json` is discovered automatically, including new files.
and tests there in a separate mechanical commit. Keep unadopted files consistent Tests and other adopted files remain listed in `scripts/format-scope.json`.
with their surrounding style. CI checks all adopted files and package boundaries Git-ignored files and `.prettierignore` exclusions are not automatically adopted.
on Linux and Windows. Keep mechanical formatting separate from behavioral edits. CI checks formatting
and package boundaries on Linux and Windows.
Reusable package exports own their state and receive application operations Reusable package exports own their state and receive application operations
through explicit callbacks. They must not import the standalone bootstrap or through explicit callbacks. They must not import the standalone bootstrap or
+12 -4
View File
@@ -1,16 +1,19 @@
# Formatting and component boundaries # Formatting and component boundaries
Run `npm run format` to format the files in `scripts/format-scope.json` and Run `npm run format` to format the files in `scripts/format-scope.json` plus
automatically discovered runtime JavaScript in `scripts/format-runtime.json`, and
`npm run format:check` to check that same list without writing. CI checks the `npm run format:check` to check that same list without writing. CI checks the
entire adopted list on Linux and Windows. Prettier is pinned in the development combined scope on Linux and Windows. Prettier is pinned in the development
dependencies; use the installed version so local and CI output agree. The shared dependencies; use the installed version so local and CI output agree. The shared
configuration specifies two spaces, single quotes, semicolons and LF endings. configuration specifies two spaces, single quotes, semicolons and LF endings.
Add new reusable modules and their tests to the list as they are extracted. Owned runtime modules are discovered automatically; add their tests and other
non-runtime files to the explicit list as they are adopted.
Keep mechanical formatting in its own commit after behavior is stable. Existing Keep mechanical formatting in its own commit after behavior is stable. Existing
source-text regression assertions still apply; investigate failures and preserve source-text regression assertions still apply; investigate failures and preserve
their behavioral coverage when a move or line wrap changes a tested shape. their behavioral coverage when a move or line wrap changes a tested shape.
Files outside the list retain their surrounding style until deliberately adopted. Files outside the runtime roots and explicit list retain their surrounding style
until deliberately adopted.
Generated output, local configuration, browser evidence and bundled datasets are Generated output, local configuration, browser evidence and bundled datasets are
excluded. The formatter validates every entry before writing any file. excluded. The formatter validates every entry before writing any file.
@@ -598,3 +601,8 @@ server adapters without importing search composition.
`./sources/overpass` exports the quoted-string/comment lexer. The lexer alone is `./sources/overpass` exports the quoted-string/comment lexer. The lexer alone is
not a query validator: spatial bounds, timeouts and other policy remain in the not a query validator: spatial bounds, timeouts and other policy remain in the
server sanitizer. Existing server imports keep their compatibility exports. server sanitizer. Existing server imports keep their compatibility exports.
Runtime formatting discovers tracked and new non-ignored `.js`, `.mjs` and `.cjs`
files under the configured owned roots. Tests retain explicit adoption. Git and
Prettier exclusions keep local/generated data out; resolved paths are validated
before any writes. New runtime modules do not need another scope-list entry.
+3
View File
@@ -0,0 +1,3 @@
{
"roots": ["src", "server"]
}
+2 -1
View File
@@ -722,5 +722,6 @@
"scripts/qa-transit-scenes.mjs", "scripts/qa-transit-scenes.mjs",
"scripts/qa-transit-controls.mjs", "scripts/qa-transit-controls.mjs",
"src/tooling/transitQa.test.mjs", "src/tooling/transitQa.test.mjs",
"scripts/qa-transit-browser.mjs" "scripts/qa-transit-browser.mjs",
"scripts/format-runtime.json"
] ]
+56 -3
View File
@@ -1,9 +1,58 @@
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { readFile, writeFile, realpath, stat } from 'node:fs/promises'; import { readFile, writeFile, realpath, stat } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import * as prettier from 'prettier'; import * as prettier from 'prettier';
/** Check or format the explicitly adopted files, without scanning other source. */ /** Discover owned runtime code; tests and other files retain explicit adoption. */
export async function discoverRuntimeFormatFiles(root) {
const configFile = path.join(root, 'scripts/format-runtime.json');
if (!existsSync(configFile)) return [];
const { roots } = JSON.parse(await readFile(configFile, 'utf8'));
if (
!Array.isArray(roots) ||
!roots.length ||
new Set(roots).size !== roots.length ||
roots.some(
(name) =>
typeof name !== 'string' ||
!/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(name),
)
)
throw new Error(
'Runtime formatting roots must be unique repository directory paths',
);
const candidates = execFileSync(
'git',
[
'ls-files',
'-z',
'--cached',
'--others',
'--exclude-standard',
'--',
...roots,
],
{ cwd: root, encoding: 'utf8' },
).split('\0');
const files = [];
for (const name of new Set(candidates)) {
if (
!/\.[cm]?js$/.test(name) ||
/\.test\.[cm]?js$/.test(name) ||
!existsSync(path.join(root, name))
)
continue;
const info = await prettier.getFileInfo(path.join(root, name), {
ignorePath: path.join(root, '.prettierignore'),
});
if (!info.ignored) files.push(name);
}
return files.sort();
}
/** Check or format explicit adoption plus automatically discovered runtime code. */
export async function formatAdoptedFiles(root, mode) { export async function formatAdoptedFiles(root, mode) {
if (!['--check', '--write'].includes(mode)) { if (!['--check', '--write'].includes(mode)) {
throw new Error('Usage: node scripts/format.mjs --check|--write'); throw new Error('Usage: node scripts/format.mjs --check|--write');
@@ -22,9 +71,13 @@ export async function formatAdoptedFiles(root, mode) {
); );
} }
const names = [
...new Set([...scope, ...(await discoverRuntimeFormatFiles(root))]),
];
// Validate the whole scope before any write, including resolved symlink paths. // Validate the whole scope before any write, including resolved symlink paths.
const files = []; const files = [];
for (const name of scope) { for (const name of names) {
if ( if (
typeof name !== 'string' || typeof name !== 'string' ||
!name || !name ||
@@ -91,7 +144,7 @@ if (import.meta.url === invoked) {
process.exitCode = 1; process.exitCode = 1;
} else { } else {
console.log( console.log(
`${mode === '--write' ? 'Formatted' : 'Checked'} ${result.count} adopted files.`, `${mode === '--write' ? 'Formatted' : 'Checked'} ${result.count} source files.`,
); );
} }
} catch (error) { } catch (error) {
+61 -37
View File
@@ -51,11 +51,13 @@ function resolveWindowsNativeTools(environment, fileSystem, architecture) {
if (configured.length === 0) return null; if (configured.length === 0) return null;
const roots = configured.map((value) => { const roots = configured.map((value) => {
if (value !== value.trim() || !/^[A-Za-z]:\\Windows\\?$/i.test(value)) return null; if (value !== value.trim() || !/^[A-Za-z]:\\Windows\\?$/i.test(value))
return null;
return value.endsWith('\\') ? value.slice(0, -1) : value; return value.endsWith('\\') ? value.slice(0, -1) : value;
}); });
if (roots.some((root) => !root)) return null; if (roots.some((root) => !root)) return null;
if (roots.some((root) => root.toLowerCase() !== roots[0].toLowerCase())) return null; if (roots.some((root) => root.toLowerCase() !== roots[0].toLowerCase()))
return null;
const systemRoot = roots[0]; const systemRoot = roots[0];
const systemDirectory = architecture === 'ia32' ? 'Sysnative' : 'System32'; const systemDirectory = architecture === 'ia32' ? 'Sysnative' : 'System32';
@@ -80,14 +82,23 @@ function resolveWindowsNativeTools(environment, fileSystem, architecture) {
for (const executable of Object.values(expected)) { for (const executable of Object.values(expected)) {
const entry = fileSystem.lstatSync(executable); const entry = fileSystem.lstatSync(executable);
if (!entry.isFile() || entry.isSymbolicLink()) return null; if (!entry.isFile() || entry.isSymbolicLink()) return null;
const canonicalExecutable = realpath.call(fileSystem.realpathSync, executable); const canonicalExecutable = realpath.call(
fileSystem.realpathSync,
executable,
);
const canonicalCandidates = [executable]; const canonicalCandidates = [executable];
if (architecture === 'ia32') { if (architecture === 'ia32') {
canonicalCandidates.push(executable.replace('\\Sysnative\\', '\\System32\\')); canonicalCandidates.push(
executable.replace('\\Sysnative\\', '\\System32\\'),
);
} }
if (!canonicalCandidates.some( if (
(candidate) => candidate.toLowerCase() === canonicalExecutable.toLowerCase(), !canonicalCandidates.some(
)) return null; (candidate) =>
candidate.toLowerCase() === canonicalExecutable.toLowerCase(),
)
)
return null;
} }
} catch { } catch {
return null; return null;
@@ -99,17 +110,22 @@ function resolveWindowsNativeTools(environment, fileSystem, architecture) {
* Restrict a credential file before any secret is written to it. * Restrict a credential file before any secret is written to it.
* Dependencies are injectable so every fail-closed branch is unit-testable. * Dependencies are injectable so every fail-closed branch is unit-testable.
*/ */
export function hardenCredentialFile(filepath, { export function hardenCredentialFile(
platform = process.platform, filepath,
architecture = process.arch, {
spawn = spawnSync, platform = process.platform,
fileSystem = fs, architecture = process.arch,
environment = process.env, spawn = spawnSync,
} = {}) { fileSystem = fs,
environment = process.env,
} = {},
) {
if (platform !== 'win32') { if (platform !== 'win32') {
try { try {
if (platform === 'darwin') { if (platform === 'darwin') {
const aclRemoval = spawn('chmod', ['-N', filepath], { stdio: 'ignore' }); const aclRemoval = spawn('chmod', ['-N', filepath], {
stdio: 'ignore',
});
if (!commandCompletedSuccessfully(aclRemoval)) return false; if (!commandCompletedSuccessfully(aclRemoval)) return false;
} }
fileSystem.chmodSync(filepath, 0o600); fileSystem.chmodSync(filepath, 0o600);
@@ -119,7 +135,11 @@ export function hardenCredentialFile(filepath, {
} }
} }
const tools = resolveWindowsNativeTools(environment, fileSystem, architecture); const tools = resolveWindowsNativeTools(
environment,
fileSystem,
architecture,
);
if (!tools) return false; if (!tools) return false;
try { try {
@@ -135,14 +155,18 @@ export function hardenCredentialFile(filepath, {
: null; : null;
if (!sid) return false; if (!sid) return false;
const applied = spawn(tools.icacls, [ const applied = spawn(
filepath, tools.icacls,
'/inheritance:r', [
'/grant:r', filepath,
`*${sid}:F`, '/inheritance:r',
'*S-1-5-18:F', '/grant:r',
'*S-1-5-32-544:F', `*${sid}:F`,
], { stdio: 'ignore', windowsHide: true }); '*S-1-5-18:F',
'*S-1-5-32-544:F',
],
{ stdio: 'ignore', windowsHide: true },
);
if (!commandCompletedSuccessfully(applied)) return false; if (!commandCompletedSuccessfully(applied)) return false;
// Command success is not proof of the resulting DACL. Query it back and // Command success is not proof of the resulting DACL. Query it back and
@@ -173,20 +197,20 @@ export function hardenCredentialFile(filepath, {
([name]) => name.toLowerCase() !== 'psmodulepath', ([name]) => name.toLowerCase() !== 'psmodulepath',
), ),
); );
const verified = spawn(tools.powershell, [ const verified = spawn(
'-NoProfile', tools.powershell,
'-NonInteractive', ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_ACL_VERIFY_SCRIPT],
'-Command', WINDOWS_ACL_VERIFY_SCRIPT, {
], { env: {
env: { ...verifyEnvironment,
...verifyEnvironment, GEV_ACL_FILE: filepath,
GEV_ACL_FILE: filepath, GEV_ACL_USER_SID: sid,
GEV_ACL_USER_SID: sid, PSModulePath: powershellModuleDirectory,
PSModulePath: powershellModuleDirectory, },
stdio: 'ignore',
windowsHide: true,
}, },
stdio: 'ignore', );
windowsHide: true,
});
return commandCompletedSuccessfully(verified); return commandCompletedSuccessfully(verified);
} catch { } catch {
return false; return false;
+455 -105
View File
@@ -1,12 +1,20 @@
import { defaultGeospatial } from '../search/defaults.js'; import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; import {
import { isRateLimitedOutcome, resolveAnnotationTarget, sampleGroundHeight } from './annotationResolver.js'; holdContinuousRender,
releaseContinuousRender,
} from '../renderGovernor.js';
import {
isRateLimitedOutcome,
resolveAnnotationTarget,
sampleGroundHeight,
} from './annotationResolver.js';
import { ringCentroid } from './drawMode.js'; import { ringCentroid } from './drawMode.js';
// Dev convenience: expose the app's Cesium instance for console/preview probing // Dev convenience: expose the app's Cesium instance for console/preview probing
// (single shared module instance — avoids dual-Cesium state bugs when testing). // (single shared module instance — avoids dual-Cesium state bugs when testing).
if (typeof window !== 'undefined' && !window.__CESIUM__) window.__CESIUM__ = Cesium; if (typeof window !== 'undefined' && !window.__CESIUM__)
window.__CESIUM__ = Cesium;
/** /**
* Annotation engine — the voice agent's "whiteboard" over the 3D world. * Annotation engine — the voice agent's "whiteboard" over the 3D world.
@@ -25,7 +33,13 @@ const COLORS = new Set(['primary', 'amber', 'cyan', 'green', 'red']);
// Entity FACTS the voice model may attach to an annotation (annotate_map.entityKind) — // Entity FACTS the voice model may attach to an annotation (annotate_map.entityKind) —
// what kind of thing the target IS, routing the resolver (e.g. point_feature keeps a // what kind of thing the target IS, routing the resolver (e.g. point_feature keeps a
// monument point-first). Unknown values are dropped, never guessed. // monument point-first). Unknown values are dropped, never guessed.
const ENTITY_KINDS = new Set(['building', 'compound', 'district', 'street', 'point_feature']); const ENTITY_KINDS = new Set([
'building',
'compound',
'district',
'street',
'point_feature',
]);
const DEFAULT_TTL_MS = 22_000; const DEFAULT_TTL_MS = 22_000;
const FADE_MS = 1200; const FADE_MS = 1200;
// Hard ceiling on simultaneously-live marks. Protects against a runaway voice // Hard ceiling on simultaneously-live marks. Protects against a runaway voice
@@ -51,11 +65,14 @@ const OUTLINE_UPGRADE_CONCURRENCY = 2;
* retrying immediately. A thrown resolver counts as definitive — the pre-retry * retrying immediately. A thrown resolver counts as definitive — the pre-retry
* catch→point behavior. Exported for tests. * catch→point behavior. Exported for tests.
*/ */
export async function resolveOutlineWithRetry(resolveOutline, { export async function resolveOutlineWithRetry(
delaysMs = OUTLINE_RETRY_DELAYS_MS, resolveOutline,
isStale = () => false, {
waitFn = wait, delaysMs = OUTLINE_RETRY_DELAYS_MS,
} = {}) { isStale = () => false,
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(
{ target: 'Palace of Fine Arts, San Francisco' }, [
{ target: 'Crissy Field, San Francisco' }, {
{ target: 'Fort Point, San Francisco' }, type: 'route',
] }], { persist: true }); color: 'amber',
label: 'Crissy Field shoreline',
points: [
{ target: 'Palace of Fine Arts, San Francisco' },
{ target: 'Crissy Field, San Francisco' },
{ target: 'Fort Point, San Francisco' },
],
},
],
{ persist: true },
);
return { ok: true, steps: 6 }; 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,9 +1335,10 @@ 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 anno.fadeStart != null
: anno.createdAt + (anno.ttlMs || 0); ? anno.fadeStart
: anno.createdAt + (anno.ttlMs || 0);
if (now < fadeStart) return fadeIn; if (now < fadeStart) return fadeIn;
const out = 1 - (now - fadeStart) / FADE_MS; const out = 1 - (now - fadeStart) / FADE_MS;
if (out <= 0) anno.expiring = true; if (out <= 0) anno.expiring = true;
@@ -1052,7 +1352,10 @@ function pendingAnimation(anno, now) {
if (now - anno.bornAt < 260) return true; // fading in if (now - anno.bornAt < 260) return true; // fading in
if (anno.expiring) return true; // fading out if (anno.expiring) return true; // fading out
if (anno.ttlMs != null) { if (anno.ttlMs != null) {
const fadeStart = anno.fadeStart != null ? anno.fadeStart : anno.createdAt + (anno.ttlMs || 0); const fadeStart =
anno.fadeStart != null
? anno.fadeStart
: anno.createdAt + (anno.ttlMs || 0);
return now < fadeStart + FADE_MS; // TTL fade still ahead or in progress return now < fadeStart + FADE_MS; // TTL fade still ahead or in progress
} }
return false; // persistent + faded in → stable return false; // persistent + faded in → stable
@@ -1110,16 +1413,30 @@ function round5(n) {
/** A spec whose geometry was supplied by hand (drawTool.js) rather than by a name. */ /** A spec whose geometry was supplied by hand (drawTool.js) rather than by a name. */
function isManualSpec(spec, type) { function isManualSpec(spec, type) {
if (!spec || spec.manual !== true) return false; if (!spec || spec.manual !== true) return false;
if (type === 'route') return Array.isArray(spec.path) && spec.path.length >= 2; if (type === 'route')
return Array.isArray(spec.path) && spec.path.length >= 2;
if (type === 'area') return Array.isArray(spec.ring) && spec.ring.length >= 3; if (type === 'area') return Array.isArray(spec.ring) && spec.ring.length >= 3;
return Number.isFinite(Number(spec.latitude)) && Number.isFinite(Number(spec.longitude)); return (
Number.isFinite(Number(spec.latitude)) &&
Number.isFinite(Number(spec.longitude))
);
} }
/** [lon, lat] pairs or {lon, lat} objects → [lon, lat] pairs, invalid entries dropped. */ /** [lon, lat] pairs or {lon, lat} objects → [lon, lat] pairs, invalid entries dropped. */
function manualPairs(list) { function manualPairs(list) {
return (Array.isArray(list) ? list : []) return (Array.isArray(list) ? list : [])
.map((p) => (Array.isArray(p) ? [Number(p[0]), Number(p[1])] : [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)])) .map((p) =>
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180); Array.isArray(p)
? [Number(p[0]), Number(p[1])]
: [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)],
)
.filter(
([lon, lat]) =>
Number.isFinite(lon) &&
Number.isFinite(lat) &&
Math.abs(lat) <= 90 &&
Math.abs(lon) <= 180,
);
} }
/** /**
@@ -1132,15 +1449,28 @@ function resolveManualSpec(spec, type, viewer) {
// height 0 is a placeholder, not a placement: the world renderer draws a // height 0 is a placeholder, not a placement: the world renderer draws a
// route with clampToGround + CESIUM_3D_TILE classification, so the line is // route with clampToGround + CESIUM_3D_TILE classification, so the line is
// draped onto the photoreal surface whatever this number says. // draped onto the photoreal surface whatever this number says.
const pts = manualPairs(spec.path).map(([lon, lat]) => ({ lon, lat, height: 0 })); const pts = manualPairs(spec.path).map(([lon, lat]) => ({
lon,
lat,
height: 0,
}));
if (pts.length < 2) throw new Error('a drawn line needs at least 2 points'); if (pts.length < 2) throw new Error('a drawn line needs at least 2 points');
let distanceM = 0; let distanceM = 0;
for (let i = 1; i < pts.length; i += 1) distanceM += greatCircleM(pts[i - 1], pts[i]); for (let i = 1; i < pts.length; i += 1)
return { path: pts, distanceM, durationS: null, mode: 'manual', source: 'manual', fallback: false }; distanceM += greatCircleM(pts[i - 1], pts[i]);
return {
path: pts,
distanceM,
durationS: null,
mode: 'manual',
source: 'manual',
fallback: false,
};
} }
if (type === 'area') { if (type === 'area') {
const ring = manualPairs(spec.ring); const ring = manualPairs(spec.ring);
if (ring.length < 3) throw new Error('a drawn area needs at least 3 points'); if (ring.length < 3)
throw new Error('a drawn area needs at least 3 points');
// Averaged through ringCentroid, which unwraps longitudes first: a ring // Averaged through ringCentroid, which unwraps longitudes first: a ring
// straddling the antimeridian has coordinates like [179.999, -179.999], and // straddling the antimeridian has coordinates like [179.999, -179.999], and
// a raw mean of those puts the anchor on the Greenwich meridian, half a // a raw mean of those puts the anchor on the Greenwich meridian, half a
@@ -1151,13 +1481,25 @@ function resolveManualSpec(spec, type, viewer) {
const lon = centre.lon; const lon = centre.lon;
const lat = centre.lat; const lat = centre.lat;
return { return {
lon, lat, height: sampleGroundHeight(viewer, lon, lat), lon,
ring, footprintKind: 'area', buildingHeight: null, synthesized: false, source: 'manual', lat,
height: sampleGroundHeight(viewer, lon, lat),
ring,
footprintKind: 'area',
buildingHeight: null,
synthesized: false,
source: 'manual',
}; };
} }
const lon = Number(spec.longitude); const lon = Number(spec.longitude);
const lat = Number(spec.latitude); const lat = Number(spec.latitude);
return { lon, lat, height: sampleGroundHeight(viewer, lon, lat), ring: null, source: 'manual' }; return {
lon,
lat,
height: sampleGroundHeight(viewer, lon, lat),
ring: null,
source: 'manual',
};
} }
/** The ring's distinct positions: a closing vertex that repeats the first is dropped. */ /** The ring's distinct positions: a closing vertex that repeats the first is dropped. */
@@ -1165,20 +1507,26 @@ function closedRingWithoutRepeat(ring) {
if (ring.length < 2) return ring; if (ring.length < 2) return ring;
const [firstLon, firstLat] = ring[0]; const [firstLon, firstLat] = ring[0];
const [lastLon, lastLat] = ring[ring.length - 1]; const [lastLon, lastLat] = ring[ring.length - 1];
return firstLon === lastLon && firstLat === lastLat ? ring.slice(0, -1) : ring; return firstLon === lastLon && firstLat === lastLat
? ring.slice(0, -1)
: ring;
} }
function normalizeMode(m) { function normalizeMode(m) {
const t = String(m || '').toLowerCase(); const t = String(m || '').toLowerCase();
if (t === 'car' || t === 'drive' || t === 'driving') return 'car'; if (t === 'car' || t === 'drive' || t === 'driving') return 'car';
if (t === 'bike' || t === 'cycle' || t === 'cycling' || t === 'bicycle') return 'bike'; if (t === 'bike' || t === 'cycle' || t === 'cycling' || t === 'bicycle')
return 'bike';
return 'foot'; return 'foot';
} }
/** Route failure remains an explicitly labelled direct line in the caller. */ /** Route failure remains an explicitly labelled direct line in the caller. */
async function fetchRoute(coordPairs, mode, signal, service) { async function fetchRoute(coordPairs, mode, signal, service) {
try { return await service.route?.(coordPairs, mode, { signal }) || null; } try {
catch { return null; } return (await service.route?.(coordPairs, mode, { signal })) || null;
} catch {
return null;
}
} }
function greatCircleM(a, b) { function greatCircleM(a, b) {
@@ -1188,7 +1536,9 @@ function greatCircleM(a, b) {
const dLon = toRad(b.lon - a.lon); const dLon = toRad(b.lon - a.lon);
const la1 = toRad(a.lat); const la1 = toRad(a.lat);
const la2 = toRad(b.lat); const la2 = toRad(b.lat);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2; const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
} }
+15 -2
View File
@@ -1,4 +1,17 @@
import { createAnnotationResolver } from './resolver.js'; import { createAnnotationResolver } from './resolver.js';
import { applicationServices } from '../services/application.js'; import { applicationServices } from '../services/application.js';
const resolver = createAnnotationResolver({ boundarySource: applicationServices.boundaries }); const resolver = createAnnotationResolver({
export const { resolveAnnotationTarget, refineScope, isRateLimitedOutcome, isGroundsLikeAsk, selectFootprint, viewportBias, placesNearViewRecovery, resolveRegionRingForQuery, pickWorldFromScreen, sampleGroundHeight } = resolver; boundarySource: applicationServices.boundaries,
});
export const {
resolveAnnotationTarget,
refineScope,
isRateLimitedOutcome,
isGroundsLikeAsk,
selectFootprint,
viewportBias,
placesNearViewRecovery,
resolveRegionRingForQuery,
pickWorldFromScreen,
sampleGroundHeight,
} = resolver;
+10 -2
View File
@@ -42,7 +42,11 @@ export function createHybridAnnotationRenderer(viewer) {
entry.screenProxy = liveProxy(anno, { type: 'label', ring: null }); entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
screen.add(entry.screenProxy); screen.add(entry.screenProxy);
} }
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) { } else if (
anno.type === 'route' &&
Array.isArray(anno.path) &&
anno.path.length >= 2
) {
// Drape the path on the tiles (world), caption it with a screen callout // Drape the path on the tiles (world), caption it with a screen callout
// at the path midpoint. // at the path midpoint.
entry.worldProxy = liveProxy(anno, { label: null }); entry.worldProxy = liveProxy(anno, { label: null });
@@ -86,7 +90,11 @@ export function createHybridAnnotationRenderer(viewer) {
} else if (anno.label) { } else if (anno.label) {
screen.add(screenProxy); screen.add(screenProxy);
} }
entry.screenProxy = entry.screenProxy ? screenProxy : (anno.label ? screenProxy : null); entry.screenProxy = entry.screenProxy
? screenProxy
: anno.label
? screenProxy
: null;
} }
/** /**
+17 -3
View File
@@ -10,13 +10,27 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
* footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver, * footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver,
* and voice tool wiring are identical to the other two branches. * and voice tool wiring are identical to the other two branches.
*/ */
export function initAnnotations({ viewer, tileset = null, placeSearch, resolver }) { export function initAnnotations({
viewer,
tileset = null,
placeSearch,
resolver,
}) {
// World-space footprint draping; clamped marks can use the photoreal tiles. // World-space footprint draping; clamped marks can use the photoreal tiles.
if (tileset) { if (tileset) {
try { tileset.enableCollision = true; } catch { /* older tileset */ } try {
tileset.enableCollision = true;
} catch {
/* older tileset */
}
} }
const renderer = createHybridAnnotationRenderer(viewer); const renderer = createHybridAnnotationRenderer(viewer);
const engine = createAnnotationEngine({ viewer, renderer, placeSearch, resolveTarget: resolver?.resolveAnnotationTarget }); const engine = createAnnotationEngine({
viewer,
renderer,
placeSearch,
resolveTarget: resolver?.resolveAnnotationTarget,
});
window.__gevAnnotations = engine; window.__gevAnnotations = engine;
return engine; return engine;
} }
+254 -71
View File
@@ -37,9 +37,9 @@ const PALETTE = {
// Altitude-scaled marker geometry. Zoomed in (≤ NEAR_H) reticles are full-size; pulling // Altitude-scaled marker geometry. Zoomed in (≤ NEAR_H) reticles are full-size; pulling
// back shrinks them toward small recon dots (MIN floor) so many world-anchored marks no // back shrinks them toward small recon dots (MIN floor) so many world-anchored marks no
// longer collapse into an overlapping blob when their anchors project close together. // longer collapse into an overlapping blob when their anchors project close together.
const MARK_SCALE_NEAR_H = 800; // m: at/below this, full-size reticles const MARK_SCALE_NEAR_H = 800; // m: at/below this, full-size reticles
const MARK_SCALE_FAR_H = 6000; // m: at/above this, minimum size const MARK_SCALE_FAR_H = 6000; // m: at/above this, minimum size
const MARK_SCALE_MIN = 0.32; // floor so dots never vanish const MARK_SCALE_MIN = 0.32; // floor so dots never vanish
const RING_OUTER_R = 34; const RING_OUTER_R = 34;
const RING_INNER_R = 18; const RING_INNER_R = 18;
const DOT_R = 5; const DOT_R = 5;
@@ -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(
overlayPaintRect = getOverlayPaintRect, viewer,
activeTrackedReadoutId = getActiveTrackedReadoutId, {
} = {}) { overlayPaintRect = getOverlayPaintRect,
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(
if (e.propertyName === 'stroke-dashoffset') { 'transitionend',
drawEl.style.strokeDasharray = 'none'; (e) => {
} if (e.propertyName === 'stroke-dashoffset') {
}, { once: true }); drawEl.style.strokeDasharray = 'none';
}
},
{ 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;
@@ -291,9 +363,9 @@ export function createScreenAnnotationRenderer(viewer, {
// tracked subject's screen FOOTPRINT is faded. The complete card footprint comes // tracked subject's screen FOOTPRINT is faded. The complete card footprint comes
// from the host's ACTUAL painted rectangle after final layout; the billboard extent // from the host's ACTUAL painted rectangle after final layout; the billboard extent
// is unioned for aircraft/satellites that also own a native tracked graphic. // is unioned for aircraft/satellites that also own a native tracked graphic.
const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint
const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1) const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1)
const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop
const _scratchTrackedWin = new Cesium.Cartesian2(); const _scratchTrackedWin = new Cesium.Cartesian2();
// Evaluate a NearFarScalar (billboard scaleByDistance) at a camera distance. // Evaluate a NearFarScalar (billboard scaleByDistance) at a camera distance.
@@ -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 =
? ent.gevDisplayPosition() typeof ent?.gevDisplayPosition === 'function'
: null; ? ent.gevDisplayPosition()
: 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));
+200 -106
View File
@@ -58,7 +58,9 @@ export function createWorldAnnotationRenderer(viewer) {
ensureFlowFabricRegistered(); ensureFlowFabricRegistered();
function colorFor(anno) { function colorFor(anno) {
return Cesium.Color.fromCssColorString(PALETTE[anno.color] || PALETTE.primary); return Cesium.Color.fromCssColorString(
PALETTE[anno.color] || PALETTE.primary,
);
} }
// Target-ring radius (meters) scaled to camera height so it reads at any // Target-ring radius (meters) scaled to camera height so it reads at any
@@ -86,7 +88,11 @@ export function createWorldAnnotationRenderer(viewer) {
const entities = []; const entities = [];
anno._entities = entities; anno._entities = entities;
if (anno.ring && anno.ring.length >= 3 && anno.footprintKind === 'building') { if (
anno.ring &&
anno.ring.length >= 3 &&
anno.footprintKind === 'building'
) {
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION // Single building → the PRIMARY highlight is an extruded CLASSIFICATION
// volume that tints the real photogrammetry mesh (dome, walls, roof — // volume that tints the real photogrammetry mesh (dome, walls, roof —
// everything inside the column) amber and pulses. A flat footprint // everything inside the column) amber and pulses. A flat footprint
@@ -94,93 +100,134 @@ 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(
polygon: { dataSource.entities.add({
hierarchy: new Cesium.PolygonHierarchy(positions), polygon: {
height: baseH, hierarchy: new Cesium.PolygonHierarchy(positions),
extrudedHeight: topH, height: baseH,
perPositionHeight: false, extrudedHeight: topH,
classificationType: CLASSIFY, perPositionHeight: false,
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.45, pulse: true })), classificationType: CLASSIFY,
}, 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(),
polygon: { );
hierarchy: new Cesium.PolygonHierarchy(cagePositions), entities.push(
height: baseH, dataSource.entities.add({
extrudedHeight: topH, polygon: {
perPositionHeight: false, hierarchy: new Cesium.PolygonHierarchy(cagePositions),
fill: false, height: baseH,
outline: true, extrudedHeight: topH,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }), perPositionHeight: false,
}, fill: false,
})); outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}),
);
// 3) Crisp draped base outline so the footprint reads on the ground. // 3) Crisp draped base outline so the footprint reads on the ground.
entities.push(dataSource.entities.add({ entities.push(
polyline: { dataSource.entities.add({
positions, polyline: {
width: 3, positions,
material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.25, color: liveColor(anno, base, { alpha: 0.7 }) }), width: 3,
clampToGround: true, material: new Cesium.PolylineGlowMaterialProperty({
classificationType: CLASSIFY, glowPower: 0.25,
}, color: liveColor(anno, base, { alpha: 0.7 }),
})); }),
clampToGround: true,
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(),
polygon: { );
hierarchy: new Cesium.PolygonHierarchy(fillPositions), entities.push(
// Synthesized (approximate) areas get a fainter fill so they don't read as solid. dataSource.entities.add({
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: anno.synthesized ? 0.10 : 0.20, pulse: true })), polygon: {
classificationType: CLASSIFY, hierarchy: new Cesium.PolygonHierarchy(fillPositions),
}, // Synthesized (approximate) areas get a fainter fill so they don't read as solid.
})); material: new Cesium.ColorMaterialProperty(
entities.push(dataSource.entities.add({ liveColor(anno, base, {
polyline: { alpha: anno.synthesized ? 0.1 : 0.2,
positions: fillPositions, pulse: true,
width: 6, }),
// Synthesized → DASHED outline (signals "approximate, not an authoritative ),
// boundary", research §8.4/§8.6); real footprints → solid glow. classificationType: CLASSIFY,
material: anno.synthesized },
? new Cesium.PolylineDashMaterialProperty({ color: liveColor(anno, base, { alpha: 0.95 }), dashLength: 24 }) }),
: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.35, color: liveColor(anno, base, { alpha: 1 }) }), );
clampToGround: true, entities.push(
classificationType: CLASSIFY, dataSource.entities.add({
}, polyline: {
})); positions: fillPositions,
width: 6,
// Synthesized → DASHED outline (signals "approximate, not an authoritative
// boundary", research §8.4/§8.6); real footprints → solid glow.
material: anno.synthesized
? new Cesium.PolylineDashMaterialProperty({
color: liveColor(anno, base, { alpha: 0.95 }),
dashLength: 24,
})
: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.35,
color: liveColor(anno, base, { alpha: 1 }),
}),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
if (anno.label) entities.push(labelMarker(anno, base, { point: false })); 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(
polyline: { dataSource.entities.add({
positions, polyline: {
width: 9, positions,
material: new FlowMaterialProperty(PALETTE[anno.color] || PALETTE.primary), width: 9,
clampToGround: true, material: new FlowMaterialProperty(
classificationType: CLASSIFY, PALETTE[anno.color] || PALETTE.primary,
}, ),
})); clampToGround: true,
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(
polyline: { dataSource.entities.add({
positions, polyline: {
width: 16, positions,
material: new Cesium.PolylineArrowMaterialProperty(liveColor(anno, base, { alpha: 0.95 })), width: 16,
clampToGround: true, material: new Cesium.PolylineArrowMaterialProperty(
classificationType: CLASSIFY, liveColor(anno, base, { alpha: 0.95 }),
}, ),
})); clampToGround: true,
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(
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat), dataSource.entities.add({
label: labelGraphic(anno, base), position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
})); 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({
ellipse: { position: Cesium.Cartesian3.fromDegrees(
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false), anno.anchor.lon,
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false), anno.anchor.lat,
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.38, pulse: true })), ),
outline: false, ellipse: {
classificationType: CLASSIFY, semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
}, semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
})); material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.38, pulse: true }),
),
outline: false,
classificationType: CLASSIFY,
},
}),
);
} }
entities.push(labelMarker(anno, base, { point: true })); 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, ? {
color: liveColor(anno, base, { alpha: 1 }), pixelSize: anno.type === 'label' ? 8 : 14,
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }), color: liveColor(anno, base, { alpha: 1 }),
outlineWidth: 3, outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
heightReference: CLAMP, outlineWidth: 3,
disableDepthTestDistance: Number.POSITIVE_INFINITY, heightReference: CLAMP,
} : undefined, disableDepthTestDistance: Number.POSITIVE_INFINITY,
}
: 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;
+11 -10
View File
@@ -31,7 +31,8 @@ function body(source, pattern, label) {
function ordered(source, needles, label) { function ordered(source, needles, label) {
let previous = -1; let previous = -1;
for (const needle of needles) { for (const needle of needles) {
const index = source.indexOf(needle, previous + 1); const match = needle instanceof RegExp ? needle.exec(source.slice(previous + 1)) : null;
const index = needle instanceof RegExp ? (match ? previous + 1 + match.index : -1) : source.indexOf(needle, previous + 1);
assert.ok(index >= 0, `${label}: missing ${needle}`); assert.ok(index >= 0, `${label}: missing ${needle}`);
assert.ok(index > previous, `${label}: ${needle} is out of order`); assert.ok(index > previous, `${label}: ${needle} is out of order`);
previous = index; previous = index;
@@ -104,7 +105,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
'Cockpit entry transaction', 'Cockpit entry transaction',
); );
ordered(transaction, [ ordered(transaction, [
'if (!selectedLayer.trackById?.(selectedTarget.id, { origin: selectionOrigin })) {', /if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/,
'if (!entryError) entered = Boolean(cockpitView.enter());', 'if (!entryError) entered = Boolean(cockpitView.enter());',
], 'Cockpit entry transaction'); ], 'Cockpit entry transaction');
// No direct camera control: every mutation goes through a layer tracker or // No direct camera control: every mutation goes through a layer tracker or
@@ -200,11 +201,11 @@ test('accepted navigation releases through PR15-aware ownership before flight',
test('validated voice camera destinations share the UI navigation authority facade', () => { test('validated voice camera destinations share the UI navigation authority facade', () => {
assert.match(ui, /runImmediateNavigation\(noun, navigate, releaseOptions = undefined\) \{\s*return this\._runExplicitNavigation\(noun, navigate, releaseOptions\);/); assert.match(ui, /runImmediateNavigation\(noun, navigate, releaseOptions = undefined\) \{\s*return this\._runExplicitNavigation\(noun, navigate, releaseOptions\);/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager, 'camera', 'move_camera', navigate, releaseOptions/); assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'camera',\s*'move_camera',\s*navigate,\s*releaseOptions/);
assert.match(voice, /runManagedVoiceNavigation\(styleManager, 'route', 'fly_route', navigate/); assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'route',\s*'fly_route',\s*navigate/);
assert.match(voice, /runManagedVoiceNavigation\(styleManager, 'fire', 'track_entity'/); assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'fire',\s*'track_entity'/);
assert.match(voice, /runManagedVoiceNavigation\(styleManager, family\.kind, 'track_entity'/); assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*family\.kind,\s*'track_entity'/);
assert.match(voice, /runManagedVoiceNavigation\(styleManager, 'frame', 'frame_overhead'/); assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'frame',\s*'frame_overhead'/);
const trackedVoice = voice.slice( const trackedVoice = voice.slice(
voice.indexOf('async function trackEntity'), voice.indexOf('async function trackEntity'),
voice.indexOf('async function frameOverhead'), voice.indexOf('async function frameOverhead'),
@@ -224,20 +225,20 @@ test('validated voice camera destinations share the UI navigation authority faca
ordered(move, [ ordered(move, [
"if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))", "if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))",
'const start = () => {', 'const start = () => {',
"const preserveCameraFlight = motion === 'orbit'", /const preserveCameraFlight =\s*motion === 'orbit'/,
'runNavigation(start, { preserveCameraFlight })', 'runNavigation(start, { preserveCameraFlight })',
], 'move validation before handoff'); ], 'move validation before handoff');
const route = body( const route = body(
cameraVerbs, cameraVerbs,
/export function flyRoute\(annoList, args = \{\}, floorFn = null, runNavigation = null, warmFn = null\) \{([\s\S]*?)\n\}/, /export function flyRoute\(\s*annoList,\s*args = \{\},\s*floorFn = null,\s*runNavigation = null,\s*warmFn = null,?\s*\) \{([\s\S]*?)\n\}/,
'fly route', 'fly route',
); );
ordered(route, [ ordered(route, [
'if (!routes.length)', 'if (!routes.length)',
'const pts = route.path.map', 'const pts = route.path.map',
'const start = () => {', 'const start = () => {',
"return typeof runNavigation === 'function' ? runNavigation(start) : start();", /return typeof runNavigation === 'function'\s*\? runNavigation\(start\)\s*: start\(\);/,
], 'route validation before handoff'); ], 'route validation before handoff');
// The corridor warm is injected the same way the floor READ is — the dolly // The corridor warm is injected the same way the floor READ is — the dolly
// never reaches into the data layer itself, and the voice dispatch is the one // never reaches into the data layer itself, and the voice dispatch is the one
+473 -141
View File
File diff suppressed because it is too large. Load diff
+18 -7
View File
@@ -22,9 +22,11 @@ export function activateCctvCameraFromWorldClick(
) { ) {
if (!cameraId || typeof activate !== 'function') return false; if (!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(
detail: { cameraId }, new CustomEvent(CCTV_FOCUS_REQUEST_EVENT, {
})); detail: { cameraId },
}),
);
return true; return true;
} }
@@ -36,8 +38,12 @@ export function activateCctvCameraFromWorldClick(
* @returns {() => void} Listener disposer. * @returns {() => void} Listener disposer.
*/ */
export function registerCctvFocusRequestListener(eventTarget, listener) { export function registerCctvFocusRequestListener(eventTarget, listener) {
if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener if (
|| typeof listener !== 'function') return () => {}; !eventTarget?.addEventListener ||
!eventTarget?.removeEventListener ||
typeof listener !== 'function'
)
return () => {};
eventTarget.addEventListener(CCTV_FOCUS_REQUEST_EVENT, listener); eventTarget.addEventListener(CCTV_FOCUS_REQUEST_EVENT, listener);
let disposed = false; let disposed = false;
return () => { return () => {
@@ -57,11 +63,16 @@ export function registerCctvFocusRequestListener(eventTarget, listener) {
*/ */
export function routeCctvFocusRequest(event, runExplicitFocus, focusCamera) { export function routeCctvFocusRequest(event, runExplicitFocus, focusCamera) {
const cameraId = event?.detail?.cameraId; const cameraId = event?.detail?.cameraId;
if (typeof cameraId !== 'string' || !cameraId || typeof runExplicitFocus !== 'function') { if (
typeof cameraId !== 'string' ||
!cameraId ||
typeof runExplicitFocus !== 'function'
) {
return false; return false;
} }
return runExplicitFocus( return runExplicitFocus(
() => cameraId, () => cameraId,
(selectedId) => focusCamera(selectedId, CCTV_WORLD_CLICK_FOCUS_DURATION_SEC), (selectedId) =>
focusCamera(selectedId, CCTV_WORLD_CLICK_FOCUS_DURATION_SEC),
); );
} }
+186 -56
View File
@@ -90,16 +90,30 @@ export function getKeyholeGeometry(width, height) {
* floor through a band derived from the live keyhole radius. * floor through a band derived from the live keyhole radius.
*/ */
export function keyholeLabelAlpha(labelX, labelY, width, height) { export function keyholeLabelAlpha(labelX, labelY, width, height) {
return keyholeLabelAlphaFromGeometry(labelX, labelY, getKeyholeGeometry(width, height)); return keyholeLabelAlphaFromGeometry(
labelX,
labelY,
getKeyholeGeometry(width, height),
);
} }
/** Compute keyhole opacity from geometry already cached by a hot render loop. */ /** Compute keyhole opacity from geometry already cached by a hot render loop. */
export function keyholeLabelAlphaFromGeometry(labelX, labelY, geometry) { export function keyholeLabelAlphaFromGeometry(labelX, labelY, geometry) {
if (!geometry || !(geometry.radius > 0) || !Number.isFinite(labelX) || !Number.isFinite(labelY)) return 0; if (
!geometry ||
!(geometry.radius > 0) ||
!Number.isFinite(labelX) ||
!Number.isFinite(labelY)
)
return 0;
const feather = geometry.featherPx; const feather = geometry.featherPx;
const distance = Math.hypot(labelX - geometry.centerX, labelY - geometry.centerY); const distance = Math.hypot(
labelX - geometry.centerX,
labelY - geometry.centerY,
);
if (distance <= geometry.radius) return 1; if (distance <= geometry.radius) return 1;
if (!(feather > 0) || distance >= geometry.radius + feather) return keyholeOutsideOpacity; if (!(feather > 0) || distance >= geometry.radius + feather)
return keyholeOutsideOpacity;
const progress = clamp((distance - geometry.radius) / feather, 0, 1); const progress = clamp((distance - geometry.radius) / feather, 0, 1);
return 1 - (1 - keyholeOutsideOpacity) * progress; return 1 - (1 - keyholeOutsideOpacity) * progress;
} }
@@ -131,7 +145,11 @@ export function isCelestialRingStyleSupported(styleName) {
* @param {number} lastAngle - Previous stable angle in radians. * @param {number} lastAngle - Previous stable angle in radians.
* @returns {{angle:number, opacity:number, stable:boolean}} * @returns {{angle:number, opacity:number, stable:boolean}}
*/ */
export function celestialScreenAngle(rightComponent, upComponent, lastAngle = 0) { export function celestialScreenAngle(
rightComponent,
upComponent,
lastAngle = 0,
) {
const planeLength = Math.hypot(rightComponent, upComponent); const planeLength = Math.hypot(rightComponent, upComponent);
if (!Number.isFinite(planeLength) || planeLength < CELESTIAL_PLANE_EPSILON) { if (!Number.isFinite(planeLength) || planeLength < CELESTIAL_PLANE_EPSILON) {
return { return {
@@ -170,26 +188,42 @@ export function isFullGlobeInsideKeyhole(geometry, wasVisible = false) {
keyholeCenterY, keyholeCenterY,
keyholeRadius, keyholeRadius,
} = geometry || {}; } = geometry || {};
const values = [earthCenterX, earthCenterY, earthRadius, keyholeCenterX, keyholeCenterY, keyholeRadius]; const values = [
if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0) return false; earthCenterX,
const offset = Math.hypot(earthCenterX - keyholeCenterX, earthCenterY - keyholeCenterY); earthCenterY,
earthRadius,
keyholeCenterX,
keyholeCenterY,
keyholeRadius,
];
if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0)
return false;
const offset = Math.hypot(
earthCenterX - keyholeCenterX,
earthCenterY - keyholeCenterY,
);
const clearance = keyholeRadius - (offset + earthRadius); const clearance = keyholeRadius - (offset + earthRadius);
return clearance >= (wasVisible ? GLOBE_EXIT_CLEARANCE_PX : GLOBE_ENTER_CLEARANCE_PX); return (
clearance >=
(wasVisible ? GLOBE_EXIT_CLEARANCE_PX : GLOBE_ENTER_CLEARANCE_PX)
);
} }
/** Return the Earth-disc radius in CSS pixels for a perspective camera. */ /** Return the Earth-disc radius in CSS pixels for a perspective camera. */
export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) { export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) {
const earthRadiusM = Cesium.Ellipsoid.WGS84.maximumRadius; const earthRadiusM = Cesium.Ellipsoid.WGS84.maximumRadius;
if ( if (
!Number.isFinite(cameraDistance) !Number.isFinite(cameraDistance) ||
|| cameraDistance <= earthRadiusM cameraDistance <= earthRadiusM ||
|| !(viewportHeight > 0) !(viewportHeight > 0) ||
|| !Number.isFinite(fovy) !Number.isFinite(fovy) ||
|| fovy <= 0 fovy <= 0 ||
|| fovy >= Math.PI fovy >= Math.PI
) return null; )
return null;
const angularRadius = Math.asin(clamp(earthRadiusM / cameraDistance, 0, 1)); const angularRadius = Math.asin(clamp(earthRadiusM / cameraDistance, 0, 1));
const radius = (viewportHeight * 0.5) * Math.tan(angularRadius) / Math.tan(fovy * 0.5); const radius =
(viewportHeight * 0.5 * Math.tan(angularRadius)) / Math.tan(fovy * 0.5);
return Number.isFinite(radius) && radius > 0 ? radius : null; return Number.isFinite(radius) && radius > 0 ? radius : null;
} }
@@ -216,7 +250,11 @@ export function projectEarthDiscToViewport(
if (!camera || !scene || !(width > 0) || !(height > 0)) return null; if (!camera || !scene || !(width > 0) || !(height > 0)) return null;
const distance = Cesium.Cartesian3.magnitude(camera.positionWC); const distance = Cesium.Cartesian3.magnitude(camera.positionWC);
if (!Number.isFinite(distance) || distance <= Cesium.Ellipsoid.WGS84.maximumRadius) return null; if (
!Number.isFinite(distance) ||
distance <= Cesium.Ellipsoid.WGS84.maximumRadius
)
return null;
const toCenter = Cesium.Cartesian3.negate( const toCenter = Cesium.Cartesian3.negate(
camera.positionWC, camera.positionWC,
@@ -229,9 +267,14 @@ export function projectEarthDiscToViewport(
Cesium.Cartesian3.ZERO, Cesium.Cartesian3.ZERO,
scratchCenter, scratchCenter,
); );
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) return null; if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y))
return null;
const earthRadius = earthDiscScreenRadius(distance, height, camera.frustum?.fovy); const earthRadius = earthDiscScreenRadius(
distance,
height,
camera.frustum?.fovy,
);
if (!earthRadius) return null; if (!earthRadius) return null;
const keyhole = getKeyholeGeometry(width, height); const keyhole = getKeyholeGeometry(width, height);
@@ -286,7 +329,12 @@ function drawSunRays(ctx, cx, cy, radius, innerRadius, angle) {
glow.addColorStop(0.58, 'rgba(255, 235, 188, 0.027)'); glow.addColorStop(0.58, 'rgba(255, 235, 188, 0.027)');
glow.addColorStop(1, 'rgba(255, 242, 214, 0)'); glow.addColorStop(1, 'rgba(255, 242, 214, 0)');
ctx.fillStyle = glow; ctx.fillStyle = glow;
ctx.fillRect(cx - radius - 4, cy - radius - 4, (radius + 4) * 2, (radius + 4) * 2); ctx.fillRect(
cx - radius - 4,
cy - radius - 4,
(radius + 4) * 2,
(radius + 4) * 2,
);
ctx.lineCap = 'round'; ctx.lineCap = 'round';
ctx.shadowColor = 'rgba(255, 232, 171, 0.11)'; ctx.shadowColor = 'rgba(255, 232, 171, 0.11)';
@@ -299,7 +347,10 @@ function drawSunRays(ctx, cx, cy, radius, innerRadius, angle) {
const ex = sx + Math.cos(rayAngle) * rayLength; const ex = sx + Math.cos(rayAngle) * rayLength;
const ey = sy + Math.sin(rayAngle) * rayLength; const ey = sy + Math.sin(rayAngle) * rayLength;
const lineGradient = ctx.createLinearGradient(sx, sy, ex, ey); const lineGradient = ctx.createLinearGradient(sx, sy, ex, ey);
lineGradient.addColorStop(0, `rgba(255, 230, 166, ${0.075 - Math.abs(i) * 0.011})`); lineGradient.addColorStop(
0,
`rgba(255, 230, 166, ${0.075 - Math.abs(i) * 0.011})`,
);
lineGradient.addColorStop(1, 'rgba(255, 226, 156, 0)'); lineGradient.addColorStop(1, 'rgba(255, 226, 156, 0)');
ctx.strokeStyle = lineGradient; ctx.strokeStyle = lineGradient;
ctx.lineWidth = i === 0 ? 1.8 : 0.9; ctx.lineWidth = i === 0 ? 1.8 : 0.9;
@@ -340,7 +391,8 @@ export class CelestialRing {
this.viewer = viewer; this.viewer = viewer;
this.enabled = !!enabled; this.enabled = !!enabled;
this.visible = false; this.visible = false;
this._onAutoDisable = typeof onAutoDisable === 'function' ? onAutoDisable : null; this._onAutoDisable =
typeof onAutoDisable === 'function' ? onAutoDisable : null;
this._focusInProgress = false; this._focusInProgress = false;
this._ephemerisDirty = true; this._ephemerisDirty = true;
this._ephemerisUpdateCount = 0; this._ephemerisUpdateCount = 0;
@@ -364,7 +416,9 @@ export class CelestialRing {
this._fixedMatrix = new Cesium.Matrix3(); this._fixedMatrix = new Cesium.Matrix3();
this._buildDOM(); this._buildDOM();
this._removePostRender = viewer.scene.postRender.addEventListener(() => this._draw()); this._removePostRender = viewer.scene.postRender.addEventListener(() =>
this._draw(),
);
// Pre-existing staleness fix (perf wave 2 review): the ephemeris was // Pre-existing staleness fix (perf wave 2 review): the ephemeris was
// sampled once per visible-enable from the FROZEN app clock, so the // sampled once per visible-enable from the FROZEN app clock, so the
// sun/moon markers aged with the app. Resample real wall time each // sun/moon markers aged with the app. Resample real wall time each
@@ -394,18 +448,26 @@ export class CelestialRing {
this._sunCanvas = document.createElement('canvas'); this._sunCanvas = document.createElement('canvas');
this._sunCanvas.className = 'celestial-ring-canvas celestial-sun-canvas'; this._sunCanvas.className = 'celestial-ring-canvas celestial-sun-canvas';
this._sunCtx = this._sunCanvas.getContext('2d', { alpha: true, desynchronized: true }); this._sunCtx = this._sunCanvas.getContext('2d', {
alpha: true,
desynchronized: true,
});
this._moonCanvas = document.createElement('canvas'); this._moonCanvas = document.createElement('canvas');
this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-canvas'; this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-canvas';
this._moonCtx = this._moonCanvas.getContext('2d', { alpha: true, desynchronized: true }); this._moonCtx = this._moonCanvas.getContext('2d', {
alpha: true,
desynchronized: true,
});
this._sunMarker = document.createElement('span'); this._sunMarker = document.createElement('span');
this._sunMarker.className = 'celestial-marker celestial-sun material-symbols-outlined'; this._sunMarker.className =
'celestial-marker celestial-sun material-symbols-outlined';
this._sunMarker.textContent = 'light_mode'; this._sunMarker.textContent = 'light_mode';
this._moonMarker = document.createElement('span'); this._moonMarker = document.createElement('span');
this._moonMarker.className = 'celestial-marker celestial-moon material-symbols-outlined'; this._moonMarker.className =
'celestial-marker celestial-moon material-symbols-outlined';
this._moonMarker.textContent = 'dark_mode'; this._moonMarker.textContent = 'dark_mode';
this._root.append( this._root.append(
@@ -413,7 +475,7 @@ export class CelestialRing {
this._sunCanvas, this._sunCanvas,
this._moonCanvas, this._moonCanvas,
this._sunMarker, this._sunMarker,
this._moonMarker this._moonMarker,
); );
this.viewer.container.appendChild(this._root); this.viewer.container.appendChild(this._root);
} }
@@ -458,15 +520,24 @@ export class CelestialRing {
const height = canvas.clientHeight || canvas.height; const height = canvas.clientHeight || canvas.height;
const cartographic = this.viewer.camera.positionCartographic; const cartographic = this.viewer.camera.positionCartographic;
const fovy = this.viewer.camera.frustum?.fovy; const fovy = this.viewer.camera.frustum?.fovy;
if (!(height > 0) || !cartographic || !Number.isFinite(fovy) || fovy <= 0 || fovy >= Math.PI) { if (
!(height > 0) ||
!cartographic ||
!Number.isFinite(fovy) ||
fovy <= 0 ||
fovy >= Math.PI
) {
return false; return false;
} }
const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius; const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius;
const keyholeRadius = getKeyholeGeometry(canvas.clientWidth || canvas.width, height).radius; const keyholeRadius = getKeyholeGeometry(
canvas.clientWidth || canvas.width,
height,
).radius;
const targetScreenRadius = keyholeRadius * FULL_GLOBE_RADIUS_RATIO; const targetScreenRadius = keyholeRadius * FULL_GLOBE_RADIUS_RATIO;
const angularRadius = Math.atan( const angularRadius = Math.atan(
(targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5) (targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5),
); );
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4); const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius); const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
@@ -480,7 +551,7 @@ export class CelestialRing {
destination: Cesium.Cartesian3.fromRadians( destination: Cesium.Cartesian3.fromRadians(
cartographic.longitude, cartographic.longitude,
cartographic.latitude, cartographic.latitude,
altitude altitude,
), ),
orientation: { orientation: {
heading: this.viewer.camera.heading, heading: this.viewer.camera.heading,
@@ -500,7 +571,10 @@ export class CelestialRing {
/** Clear the backing canvas. */ /** Clear the backing canvas. */
_clear() { _clear() {
for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) { for (const [canvas, ctx] of [
[this._sunCanvas, this._sunCtx],
[this._moonCanvas, this._moonCtx],
]) {
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
} }
this._ringOutline?.style.setProperty('display', 'none'); this._ringOutline?.style.setProperty('display', 'none');
@@ -512,12 +586,23 @@ export class CelestialRing {
/** Resize the canvas backing store while drawing in CSS pixels. */ /** Resize the canvas backing store while drawing in CSS pixels. */
_resize(width, height) { _resize(width, height) {
const nativeDpr = Math.max(1, window.devicePixelRatio || 1); const nativeDpr = Math.max(1, window.devicePixelRatio || 1);
const pixelsScale = Math.sqrt(CELESTIAL_MAX_BACKING_PIXELS / (width * height)); const pixelsScale = Math.sqrt(
const dimensionScale = CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height); CELESTIAL_MAX_BACKING_PIXELS / (width * height),
const dpr = Math.min(nativeDpr, CELESTIAL_MAX_DEVICE_PIXEL_RATIO, pixelsScale, dimensionScale); );
const dimensionScale =
CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height);
const dpr = Math.min(
nativeDpr,
CELESTIAL_MAX_DEVICE_PIXEL_RATIO,
pixelsScale,
dimensionScale,
);
const bw = Math.max(1, Math.round(width * dpr)); const bw = Math.max(1, Math.round(width * dpr));
const bh = Math.max(1, Math.round(height * dpr)); const bh = Math.max(1, Math.round(height * dpr));
for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) { for (const [canvas, ctx] of [
[this._sunCanvas, this._sunCtx],
[this._moonCanvas, this._moonCtx],
]) {
if (canvas.width !== bw || canvas.height !== bh) { if (canvas.width !== bw || canvas.height !== bh) {
canvas.width = bw; canvas.width = bw;
canvas.height = bh; canvas.height = bh;
@@ -536,14 +621,25 @@ export class CelestialRing {
_updateEphemeris(time) { _updateEphemeris(time) {
if (!this._ephemerisDirty) return true; if (!this._ephemerisDirty) return true;
Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(time, this._sunInertial); Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(time, this._moonInertial); time,
const matrix = Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix) this._sunInertial,
|| Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix); );
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(
time,
this._moonInertial,
);
const matrix =
Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix) ||
Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix);
if (!matrix) return false; if (!matrix) return false;
Cesium.Matrix3.multiplyByVector(matrix, this._sunInertial, this._sunFixed); Cesium.Matrix3.multiplyByVector(matrix, this._sunInertial, this._sunFixed);
Cesium.Matrix3.multiplyByVector(matrix, this._moonInertial, this._moonFixed); Cesium.Matrix3.multiplyByVector(
matrix,
this._moonInertial,
this._moonFixed,
);
Cesium.Cartesian3.normalize(this._sunFixed, this._sunFixed); Cesium.Cartesian3.normalize(this._sunFixed, this._sunFixed);
Cesium.Cartesian3.normalize(this._moonFixed, this._moonFixed); Cesium.Cartesian3.normalize(this._moonFixed, this._moonFixed);
this._ephemerisDirty = false; this._ephemerisDirty = false;
@@ -574,8 +670,14 @@ export class CelestialRing {
const cos = Math.cos(angle); const cos = Math.cos(angle);
const sin = Math.sin(angle); const sin = Math.sin(angle);
const margin = 14; const margin = 14;
const maxRadiusX = Math.abs(cos) > 1e-4 ? (cx - margin) / Math.abs(cos) : Number.POSITIVE_INFINITY; const maxRadiusX =
const maxRadiusY = Math.abs(sin) > 1e-4 ? (cy - margin) / Math.abs(sin) : Number.POSITIVE_INFINITY; Math.abs(cos) > 1e-4
? (cx - margin) / Math.abs(cos)
: Number.POSITIVE_INFINITY;
const maxRadiusY =
Math.abs(sin) > 1e-4
? (cy - margin) / Math.abs(sin)
: Number.POSITIVE_INFINITY;
const markerRadius = Math.min(radius, maxRadiusX, maxRadiusY); const markerRadius = Math.min(radius, maxRadiusX, maxRadiusY);
marker.style.left = `${cx + cos * markerRadius}px`; marker.style.left = `${cx + cos * markerRadius}px`;
marker.style.top = `${cy + sin * markerRadius}px`; marker.style.top = `${cy + sin * markerRadius}px`;
@@ -596,14 +698,24 @@ export class CelestialRing {
const sunKey = `${outlineKey}:${Math.round(rayInnerRadius)}`; const sunKey = `${outlineKey}:${Math.round(rayInnerRadius)}`;
if (sunKey !== this._sunRenderKey) { if (sunKey !== this._sunRenderKey) {
this._sunCtx.clearRect(0, 0, this._sunCanvas.width, this._sunCanvas.height); this._sunCtx.clearRect(
0,
0,
this._sunCanvas.width,
this._sunCanvas.height,
);
drawSunRays(this._sunCtx, cx, cy, radius, rayInnerRadius, 0); drawSunRays(this._sunCtx, cx, cy, radius, rayInnerRadius, 0);
drawTaperedArc(this._sunCtx, cx, cy, radius, 0, '222, 190, 89', 0.56); drawTaperedArc(this._sunCtx, cx, cy, radius, 0, '222, 190, 89', 0.56);
this._sunRenderKey = sunKey; this._sunRenderKey = sunKey;
} }
if (outlineKey !== this._moonRenderKey) { if (outlineKey !== this._moonRenderKey) {
this._moonCtx.clearRect(0, 0, this._moonCanvas.width, this._moonCanvas.height); this._moonCtx.clearRect(
0,
0,
this._moonCanvas.width,
this._moonCanvas.height,
);
drawMoonHaze(this._moonCtx, cx, cy, radius, 0); drawMoonHaze(this._moonCtx, cx, cy, radius, 0);
drawTaperedArc(this._moonCtx, cx, cy, radius, 0, '48, 201, 229', 0.42); drawTaperedArc(this._moonCtx, cx, cy, radius, 0, '48, 201, 229', 0.42);
this._moonRenderKey = outlineKey; this._moonRenderKey = outlineKey;
@@ -624,7 +736,9 @@ export class CelestialRing {
const disc = this._projectedEarthDisc(width, height); const disc = this._projectedEarthDisc(width, height);
const wasVisible = this.visible; const wasVisible = this.visible;
const nextVisible = disc ? isFullGlobeInsideKeyhole(disc, wasVisible) : false; const nextVisible = disc
? isFullGlobeInsideKeyhole(disc, wasVisible)
: false;
this.visible = nextVisible; this.visible = nextVisible;
this._root.classList.toggle('visible', nextVisible); this._root.classList.toggle('visible', nextVisible);
this._root.dataset.globeVisible = String(nextVisible); this._root.dataset.globeVisible = String(nextVisible);
@@ -645,12 +759,12 @@ export class CelestialRing {
const sunProjection = celestialScreenAngle( const sunProjection = celestialScreenAngle(
Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC), Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC),
Cesium.Cartesian3.dot(this._sunFixed, camera.upWC), Cesium.Cartesian3.dot(this._sunFixed, camera.upWC),
this._sunAngle this._sunAngle,
); );
const moonProjection = celestialScreenAngle( const moonProjection = celestialScreenAngle(
Cesium.Cartesian3.dot(this._moonFixed, camera.rightWC), Cesium.Cartesian3.dot(this._moonFixed, camera.rightWC),
Cesium.Cartesian3.dot(this._moonFixed, camera.upWC), Cesium.Cartesian3.dot(this._moonFixed, camera.upWC),
this._moonAngle this._moonAngle,
); );
if (sunProjection.stable) this._sunAngle = sunProjection.angle; if (sunProjection.stable) this._sunAngle = sunProjection.angle;
if (moonProjection.stable) this._moonAngle = moonProjection.angle; if (moonProjection.stable) this._moonAngle = moonProjection.angle;
@@ -660,7 +774,10 @@ export class CelestialRing {
const cx = width * 0.5; const cx = width * 0.5;
const cy = height * 0.5; const cy = height * 0.5;
const radius = disc.keyholeRadius - RING_INSET_PX; const radius = disc.keyholeRadius - RING_INSET_PX;
const rayInnerRadius = Math.min(radius - 12, disc.earthRadius + Math.max(38, radius * 0.065)); const rayInnerRadius = Math.min(
radius - 12,
disc.earthRadius + Math.max(38, radius * 0.065),
);
this._renderEffectLayers(cx, cy, radius, rayInnerRadius); this._renderEffectLayers(cx, cy, radius, rayInnerRadius);
this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`; this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`;
this._sunCanvas.style.opacity = String(this._sunOpacity); this._sunCanvas.style.opacity = String(this._sunOpacity);
@@ -671,16 +788,24 @@ export class CelestialRing {
// stroke. Near conjunction (such as a new moon), keep the true bearings but // stroke. Near conjunction (such as a new moon), keep the true bearings but
// move the moon into a second radial lane so both bodies remain legible. // move the moon into a second radial lane so both bodies remain legible.
const markerRadius = radius - MARKER_INSET_PX; const markerRadius = radius - MARKER_INSET_PX;
const markersCollide = circularAngleDistance(this._sunAngle, this._moonAngle) const markersCollide =
< MARKER_COLLISION_ANGLE; circularAngleDistance(this._sunAngle, this._moonAngle) <
this._positionMarker(this._sunMarker, cx, cy, markerRadius, this._sunAngle, this._sunOpacity); MARKER_COLLISION_ANGLE;
this._positionMarker(
this._sunMarker,
cx,
cy,
markerRadius,
this._sunAngle,
this._sunOpacity,
);
this._positionMarker( this._positionMarker(
this._moonMarker, this._moonMarker,
cx, cx,
cy, cy,
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0), markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
this._moonAngle, this._moonAngle,
this._moonOpacity this._moonOpacity,
); );
this._debug = { this._debug = {
enabled: true, enabled: true,
@@ -698,7 +823,12 @@ export class CelestialRing {
/** Read-only geometry snapshot for browser QA. */ /** Read-only geometry snapshot for browser QA. */
getDebugState() { getDebugState() {
return this._debug ? { ...this._debug, disc: this._debug.disc ? { ...this._debug.disc } : null } : null; return this._debug
? {
...this._debug,
disc: this._debug.disc ? { ...this._debug.disc } : null,
}
: null;
} }
/** Detach the render hook and remove all overlay DOM. */ /** Detach the render hook and remove all overlay DOM. */
+133 -47
View File
@@ -1,6 +1,9 @@
import { applicationServices } from './services/application.js'; import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { deriveWeatherEffectProfile, weatherAltitudeFactors } from './weatherEffectsMath.js'; import {
deriveWeatherEffectProfile,
weatherAltitudeFactors,
} from './weatherEffectsMath.js';
const WEATHER_REFRESH_MS = 5 * 60_000; const WEATHER_REFRESH_MS = 5 * 60_000;
const CLOUD_FRAME_MS = 1000 / 12; const CLOUD_FRAME_MS = 1000 / 12;
@@ -117,28 +120,47 @@ function compileShader(gl, type, source) {
gl.shaderSource(shader, source); gl.shaderSource(shader, source);
gl.compileShader(shader); gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader; if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
const error = gl.getShaderInfoLog(shader) || 'Unknown shader compilation error'; const error =
gl.getShaderInfoLog(shader) || 'Unknown shader compilation error';
gl.deleteShader(shader); gl.deleteShader(shader);
throw new Error(error); throw new Error(error);
} }
function greatCircleM(a, b) { function greatCircleM(a, b) {
if (![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(Number.isFinite)) return Infinity; if (
![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(
Number.isFinite,
)
)
return Infinity;
const latitudeA = Cesium.Math.toRadians(a.latitude); const latitudeA = Cesium.Math.toRadians(a.latitude);
const latitudeB = Cesium.Math.toRadians(b.latitude); const latitudeB = Cesium.Math.toRadians(b.latitude);
const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude); const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude);
const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude); const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude);
const haversine = Math.sin(latitudeDelta / 2) ** 2 const haversine =
+ Math.cos(latitudeA) * Math.cos(latitudeB) * Math.sin(longitudeDelta / 2) ** 2; Math.sin(latitudeDelta / 2) ** 2 +
return 6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)); Math.cos(latitudeA) *
Math.cos(latitudeB) *
Math.sin(longitudeDelta / 2) ** 2;
return (
6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine))
);
} }
/** Return whether cockpit weather must refresh for elapsed time or movement. */ /** Return whether cockpit weather must refresh for elapsed time or movement. */
export function cockpitWeatherRefreshDue({ nowMs, fetchedAt, anchor, point, hasWeather }) { export function cockpitWeatherRefreshDue({
nowMs,
fetchedAt,
anchor,
point,
hasWeather,
}) {
if (!hasWeather) return true; if (!hasWeather) return true;
if (!Number.isFinite(nowMs) || !Number.isFinite(fetchedAt)) return true; if (!Number.isFinite(nowMs) || !Number.isFinite(fetchedAt)) return true;
return nowMs - fetchedAt >= WEATHER_REFRESH_MS return (
|| greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M; nowMs - fetchedAt >= WEATHER_REFRESH_MS ||
greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M
);
} }
/** Returns the capped framebuffer size used by the cockpit cloud pass. */ /** Returns the capped framebuffer size used by the cockpit cloud pass. */
@@ -192,18 +214,24 @@ export class CockpitCloudEffectsController {
this.pending = null; this.pending = null;
this.abort = null; this.abort = null;
this.destroyed = false; this.destroyed = false;
this.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; this.reducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
this.enabled = this.readEnabledPreference(); this.enabled = this.readEnabledPreference();
this.onResize = () => this.resize(); this.onResize = () => this.resize();
this.onCockpitMode = (event) => { this.onCockpitMode = (event) => {
const active = event?.detail?.active ?? document.body.classList.contains('cockpit-mode'); const active =
event?.detail?.active ??
document.body.classList.contains('cockpit-mode');
if (active && this.enabled) this.start(); if (active && this.enabled) this.start();
else this.stop(); else this.stop();
}; };
this.onEnabledChange = (event) => { this.onEnabledChange = (event) => {
const requested = event?.detail?.enabled; const requested = event?.detail?.enabled;
this.setEnabled(typeof requested === 'boolean' ? requested : !this.enabled); this.setEnabled(
typeof requested === 'boolean' ? requested : !this.enabled,
);
}; };
window.addEventListener('resize', this.onResize); window.addEventListener('resize', this.onResize);
window.addEventListener('gev:cockpit-mode-changed', this.onCockpitMode); window.addEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
@@ -222,9 +250,11 @@ export class CockpitCloudEffectsController {
} }
emitEnabledState() { emitEnabledState() {
window.dispatchEvent(new CustomEvent('gev:cockpit-weather-state', { window.dispatchEvent(
detail: { enabled: this.enabled }, new CustomEvent('gev:cockpit-weather-state', {
})); 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 (
nowMs: Date.now(), point &&
fetchedAt: this.fetchedAt, cockpitWeatherRefreshDue({
anchor: this.anchor, nowMs: Date.now(),
point, fetchedAt: this.fetchedAt,
hasWeather: Boolean(this.weather), anchor: this.anchor,
})) void this.refresh(); point,
hasWeather: Boolean(this.weather),
})
)
void this.refresh();
} }
const blend = this.reducedMotion ? 1 : 0.08; const blend = this.reducedMotion ? 1 : 0.08;
this.strength += (this.targetStrength - this.strength) * blend; this.strength += (this.targetStrength - this.strength) * blend;
const visible = this.strength > 0.035; const visible = this.strength > 0.035;
this.canvas.classList.toggle('active', visible); this.canvas.classList.toggle('active', visible);
if (visible && (this.reducedMotion || timeMs - this.lastFrameMs >= CLOUD_FRAME_MS)) { if (
visible &&
(this.reducedMotion || timeMs - this.lastFrameMs >= CLOUD_FRAME_MS)
) {
this.lastFrameMs = timeMs; this.lastFrameMs = timeMs;
this.render(timeMs / 1000); this.render(timeMs / 1000);
} else if (!visible) { } else if (!visible) {
@@ -468,9 +544,16 @@ export class CockpitCloudEffectsController {
const windRadians = Cesium.Math.toRadians(this.windDirectionDeg); const windRadians = Cesium.Math.toRadians(this.windDirectionDeg);
const windScale = 0.008 + this.windStrength * 0.022; const windScale = 0.008 + this.windStrength * 0.022;
gl.useProgram(this.program); gl.useProgram(this.program);
gl.uniform2f(this.locations.resolution, this.canvas.width, this.canvas.height); gl.uniform2f(
this.locations.resolution,
this.canvas.width,
this.canvas.height,
);
gl.uniform1f(this.locations.time, timeSec); gl.uniform1f(this.locations.time, timeSec);
gl.uniform1f(this.locations.strength, Math.min(1, Math.max(0, this.strength))); gl.uniform1f(
this.locations.strength,
Math.min(1, Math.max(0, this.strength)),
);
gl.uniform2f( gl.uniform2f(
this.locations.wind, this.locations.wind,
Math.sin(windRadians) * windScale, Math.sin(windRadians) * windScale,
@@ -517,7 +600,10 @@ export class CockpitCloudEffectsController {
this.stop(); this.stop();
window.removeEventListener('resize', this.onResize); window.removeEventListener('resize', this.onResize);
window.removeEventListener('gev:cockpit-mode-changed', this.onCockpitMode); window.removeEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
window.removeEventListener('gev:cockpit-weather-toggle', this.onEnabledChange); window.removeEventListener(
'gev:cockpit-weather-toggle',
this.onEnabledChange,
);
if (this.program && this.gl) this.gl.deleteProgram(this.program); if (this.program && this.gl) this.gl.deleteProgram(this.program);
this.canvas.remove(); this.canvas.remove();
} }
+32 -15
View File
@@ -8,7 +8,11 @@
*/ */
/** Force the prior aircraft layer to reacquire Cesium and durable tracking ownership. */ /** Force the prior aircraft layer to reacquire Cesium and durable tracking ownership. */
export function restoreAircraftTrackingOwner(layer, id, { origin = 'programmatic' } = {}) { export function restoreAircraftTrackingOwner(
layer,
id,
{ origin = 'programmatic' } = {},
) {
if (!layer?.trackById || !id) return false; if (!layer?.trackById || !id) return false;
layer.stopTracking?.({ origin }); layer.stopTracking?.({ origin });
return Boolean(layer.trackById(id, { origin })); return Boolean(layer.trackById(id, { origin }));
@@ -33,11 +37,13 @@ export function enterCockpitWithTracking({
rollbackTarget = undefined, rollbackTarget = undefined,
selectionOrigin = 'programmatic', selectionOrigin = 'programmatic',
}) { }) {
const currentTarget = aircraftTrackingTarget(cockpitView?.readAircraftInfo?.()); const currentTarget = aircraftTrackingTarget(
const restoreTarget = rollbackTarget === undefined ? currentTarget : rollbackTarget; cockpitView?.readAircraftInfo?.(),
const key = (target) => target?.layerId && target?.id );
? `${target.layerId}:${target.id}` const restoreTarget =
: null; rollbackTarget === undefined ? currentTarget : rollbackTarget;
const key = (target) =>
target?.layerId && target?.id ? `${target.layerId}:${target.id}` : null;
let activeLayer = currentLayer; let activeLayer = currentLayer;
let activeTarget = currentTarget; let activeTarget = currentTarget;
let entryError = null; let entryError = null;
@@ -49,8 +55,14 @@ export function enterCockpitWithTracking({
if (selectingTarget) { if (selectingTarget) {
activeLayer = selectedLayer; activeLayer = selectedLayer;
activeTarget = selectedTarget; activeTarget = selectedTarget;
if (!selectedLayer.trackById?.(selectedTarget.id, { origin: selectionOrigin })) { if (
entryError = new Error('Selected aircraft could not be tracked for Cockpit entry'); !selectedLayer.trackById?.(selectedTarget.id, {
origin: selectionOrigin,
})
) {
entryError = new Error(
'Selected aircraft could not be tracked for Cockpit entry',
);
} }
} }
if (!entryError) entered = Boolean(cockpitView.enter()); if (!entryError) entered = Boolean(cockpitView.enter());
@@ -66,12 +78,15 @@ export function enterCockpitWithTracking({
// programmatic rollback would fix the camera while leaving the failed // programmatic rollback would fix the camera while leaving the failed
// target durable in local/share state. // target durable in local/share state.
activeLayer?.stopTracking?.({ origin: selectionOrigin }); activeLayer?.stopTracking?.({ origin: selectionOrigin });
if (restoreTarget && !restoreAircraftTrackingOwner( if (
rollbackLayer, restoreTarget &&
restoreTarget.id, !restoreAircraftTrackingOwner(rollbackLayer, restoreTarget.id, {
{ origin: selectionOrigin }, origin: selectionOrigin,
)) { })
entryError ||= new Error('Prior aircraft tracking could not be restored'); ) {
entryError ||= new Error(
'Prior aircraft tracking could not be restored',
);
} }
} catch (error) { } catch (error) {
entryError ||= error instanceof Error ? error : new Error(String(error)); entryError ||= error instanceof Error ? error : new Error(String(error));
@@ -87,6 +102,8 @@ export function enterCockpitWithTracking({
return { return {
entered, entered,
error: entered ? null : entryError?.message || 'Cockpit entry was unavailable', error: entered
? null
: entryError?.message || 'Cockpit entry was unavailable',
}; };
} }
+15 -4
View File
@@ -75,11 +75,16 @@ export function contactsDetectionEnterPlan(current, restore) {
* @returns {?{mode: string, densityPct: ?number}} State to replay, or null when * @returns {?{mode: string, densityPct: ?number}} State to replay, or null when
* there is nothing to do. * there is nothing to do.
*/ */
export function contactsDetectionExitPlan(restore, current, styleOwnsDetection = false) { export function contactsDetectionExitPlan(
restore,
current,
styleOwnsDetection = false,
) {
if (!restore || styleOwnsDetection) return null; if (!restore || styleOwnsDetection) return null;
const saved = normalizeDetectionState(restore); const saved = normalizeDetectionState(restore);
const now = normalizeDetectionState(current); const now = normalizeDetectionState(current);
if (saved.mode === now.mode && saved.densityPct === now.densityPct) return null; if (saved.mode === now.mode && saved.densityPct === now.densityPct)
return null;
return saved; return saved;
} }
@@ -119,7 +124,11 @@ export function applyContactsDetection({
if (plan.turnOn) applyPreset(); if (plan.turnOn) applyPreset();
return { restore: plan.restore, changed: plan.turnOn }; return { restore: plan.restore, changed: plan.turnOn };
} }
const plan = contactsDetectionExitPlan(restore, getState(), styleOwnsDetection); const plan = contactsDetectionExitPlan(
restore,
getState(),
styleOwnsDetection,
);
if (plan) restoreState(plan); if (plan) restoreState(plan);
return { restore: null, changed: Boolean(plan) }; return { restore: null, changed: Boolean(plan) };
} }
@@ -145,7 +154,9 @@ export function shareableDetectionState({ owned, liveMode, liveDensityPct }) {
if (!owned) return { mode: liveMode, densityPct: liveDensityPct }; if (!owned) return { mode: liveMode, densityPct: liveDensityPct };
return { return {
mode: owned.mode ?? liveMode, mode: owned.mode ?? liveMode,
densityPct: Number.isFinite(owned.densityPct) ? owned.densityPct : liveDensityPct, densityPct: Number.isFinite(owned.densityPct)
? owned.densityPct
: liveDensityPct,
}; };
} }
+125 -72
View File
@@ -1,5 +1,11 @@
const CONTEXT_DEPENDENCIES = Object.freeze({ const CONTEXT_DEPENDENCIES = Object.freeze({
flights: new Set(['military-awareness', 'flights', 'military', 'ais-live-vessels', 'military-installations']), flights: new Set([
'military-awareness',
'flights',
'military',
'ais-live-vessels',
'military-installations',
]),
'space-missions': new Set(['rocket-launches', 'satellites']), 'space-missions': new Set(['rocket-launches', 'satellites']),
}); });
const CONTEXT_COMPANIONS = new Set(['radio']); const CONTEXT_COMPANIONS = new Set(['radio']);
@@ -11,11 +17,13 @@ export function isExplicitUserIntentOrigin(origin, layerId = null) {
/** Preserve explicit layer intent that completes while a stale restore is queued. */ /** Preserve explicit layer intent that completes while a stale restore is queued. */
export function recordContextRestoreExplicitChange({ restoreState, change }) { export function recordContextRestoreExplicitChange({ restoreState, change }) {
if ( if (
!restoreState?.enabledLayerIds !restoreState?.enabledLayerIds ||
|| change?.type !== 'visibility' change?.type !== 'visibility' ||
|| !isExplicitUserIntentOrigin(change.origin, change.layerId) !isExplicitUserIntentOrigin(change.origin, change.layerId)
) return false; )
if (!restoreState.explicitLayerStates) restoreState.explicitLayerStates = new Map(); return false;
if (!restoreState.explicitLayerStates)
restoreState.explicitLayerStates = new Map();
restoreState.explicitLayerStates.set(change.layerId, Boolean(change.enabled)); restoreState.explicitLayerStates.set(change.layerId, Boolean(change.enabled));
if (change.enabled) restoreState.enabledLayerIds.add(change.layerId); if (change.enabled) restoreState.enabledLayerIds.add(change.layerId);
else restoreState.enabledLayerIds.delete(change.layerId); else restoreState.enabledLayerIds.delete(change.layerId);
@@ -23,30 +31,41 @@ export function recordContextRestoreExplicitChange({ restoreState, change }) {
} }
/** Settle every explicit Context-restore replay without losing semantic failures. */ /** Settle every explicit Context-restore replay without losing semantic failures. */
export async function settleContextIntentReplay({ restoreState, setEnabled, notificationToken = null }) { export async function settleContextIntentReplay({
restoreState,
setEnabled,
notificationToken = null,
}) {
if (restoreState?.cancelled) return null; if (restoreState?.cancelled) return null;
const entries = [...(restoreState?.explicitLayerStates || [])]; const entries = [...(restoreState?.explicitLayerStates || [])];
const results = await Promise.allSettled(entries.map(([layerId, enabled]) => ( const results = await Promise.allSettled(
setEnabled(layerId, enabled, { entries.map(([layerId, enabled]) =>
origin: 'context-intent-replay', setEnabled(layerId, enabled, {
...(notificationToken ? { notificationToken } : {}), origin: 'context-intent-replay',
}) ...(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 rejected?.reason instanceof Error
: new Error(`Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`); ? rejected.reason
error.failedLayerIds = [...new Set([ : new Error(
...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []), `Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`,
...failedLayerIds, );
])]; error.failedLayerIds = [
...new Set([
...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []),
...failedLayerIds,
]),
];
return error; return error;
} }
@@ -54,18 +73,29 @@ export async function settleContextIntentReplay({ restoreState, setEnabled, noti
export function mergeContextTransitionErrors(primaryError, secondaryError) { export function mergeContextTransitionErrors(primaryError, secondaryError) {
if (!primaryError) return secondaryError || null; if (!primaryError) return secondaryError || null;
if (!secondaryError) return primaryError; if (!secondaryError) return primaryError;
primaryError.failedLayerIds = [...new Set([ primaryError.failedLayerIds = [
...(Array.isArray(primaryError.failedLayerIds) ? primaryError.failedLayerIds : []), ...new Set([
...(Array.isArray(secondaryError.failedLayerIds) ? secondaryError.failedLayerIds : []), ...(Array.isArray(primaryError.failedLayerIds)
])]; ? primaryError.failedLayerIds
: []),
...(Array.isArray(secondaryError.failedLayerIds)
? secondaryError.failedLayerIds
: []),
]),
];
return primaryError; return primaryError;
} }
/** Settle a user-facing Context action and convert every failure form to false. */ /** Settle a user-facing Context action and convert every failure form to false. */
export async function settleUserFacingContextAction({ operation, onFailure, falseIsFailure = true }) { export async function settleUserFacingContextAction({
operation,
onFailure,
falseIsFailure = true,
}) {
try { try {
const result = await operation(); const result = await operation();
if (falseIsFailure && result === false) throw new Error('Context transition did not complete'); if (falseIsFailure && result === false)
throw new Error('Context transition did not complete');
return result; return result;
} catch (error) { } catch (error) {
try { try {
@@ -83,7 +113,10 @@ export async function settleUserFacingContextAction({ operation, onFailure, fals
* one into `userAdded` makes exit restoration re-enable the mode layer the * one into `userAdded` makes exit restoration re-enable the mode layer the
* user just turned off. * user just turned off.
*/ */
export const CONTEXT_ENTRY_LAYER_IDS = Object.freeze(['military-awareness', 'rocket-launches']); export const CONTEXT_ENTRY_LAYER_IDS = Object.freeze([
'military-awareness',
'rocket-launches',
]);
/** /**
* Leave a Context transaction: publish the settled coordination flag, then * Leave a Context transaction: publish the settled coordination flag, then
@@ -108,7 +141,8 @@ export function settleContextModeChange(owner, changing = false) {
if (!owner) return; if (!owner) return;
const wasChanging = Boolean(owner._contextModeChanging); const wasChanging = Boolean(owner._contextModeChanging);
owner._contextModeChanging = Boolean(changing); owner._contextModeChanging = Boolean(changing);
if (wasChanging && !owner._contextModeChanging) owner._syncContextModeButtons?.(); if (wasChanging && !owner._contextModeChanging)
owner._syncContextModeButtons?.();
} }
/** /**
@@ -142,19 +176,24 @@ export async function runWithContextModeChanging(owner, operation) {
* @param {object|null} input.change DataLayerManager visibility notification. * @param {object|null} input.change DataLayerManager visibility notification.
* @returns {boolean} Whether the active Context bundle should be disabled. * @returns {boolean} Whether the active Context bundle should be disabled.
*/ */
export function shouldExitContextForLayerChange({ contextMode, globalContextEnabled, change }) { export function shouldExitContextForLayerChange({
contextMode,
globalContextEnabled,
change,
}) {
if ( if (
change?.type !== 'visibility' change?.type !== 'visibility' ||
|| !isExplicitUserIntentOrigin(change.origin, change.layerId) !isExplicitUserIntentOrigin(change.origin, change.layerId) ||
|| change.layerId === 'military-awareness' change.layerId === 'military-awareness' ||
|| CONTEXT_COMPANIONS.has(change.layerId) CONTEXT_COMPANIONS.has(change.layerId)
) { ) {
return false; return false;
} }
const active = Boolean(globalContextEnabled || contextMode); const active = Boolean(globalContextEnabled || contextMode);
if (!active) return false; if (!active) return false;
const dependencies = CONTEXT_DEPENDENCIES[contextMode] || new Set(['military-awareness']); const dependencies =
CONTEXT_DEPENDENCIES[contextMode] || new Set(['military-awareness']);
if (change.enabled) return false; if (change.enabled) return false;
return Boolean(contextMode && dependencies.has(change.layerId)); return Boolean(contextMode && dependencies.has(change.layerId));
} }
@@ -181,10 +220,10 @@ export function contextAllowedLayerIds(contextMode) {
*/ */
export function shouldCaptureContextSession(change) { export function shouldCaptureContextSession(change) {
return Boolean( return Boolean(
['visibility-requested', 'visibility-will-change'].includes(change?.type) ['visibility-requested', 'visibility-will-change'].includes(change?.type) &&
&& isExplicitUserIntentOrigin(change.origin, change.layerId) isExplicitUserIntentOrigin(change.origin, change.layerId) &&
&& change.enabled change.enabled &&
&& CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId) CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId),
); );
} }
@@ -194,12 +233,12 @@ export function shouldCaptureContextSession(change) {
*/ */
export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) { export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
return Boolean( return Boolean(
clearInFlight clearInFlight &&
&& change?.type === 'visibility-requested' change?.type === 'visibility-requested' &&
&& change.layerId === 'rocket-launches' change.layerId === 'rocket-launches' &&
&& change.enabled === true change.enabled === true &&
&& isExplicitUserIntentOrigin(change.origin, change.layerId) isExplicitUserIntentOrigin(change.origin, change.layerId) &&
&& Number.isInteger(change.intentEpoch) Number.isInteger(change.intentEpoch),
); );
} }
@@ -218,11 +257,15 @@ export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
* @param {string|null} [input.effectiveContextMode] Committed or entering mode. * @param {string|null} [input.effectiveContextMode] Committed or entering mode.
* @returns {boolean} Whether the snapshot's userAdded set was modified. * @returns {boolean} Whether the snapshot's userAdded set was modified.
*/ */
export function recordContextSessionUserChange({ snapshot, change, effectiveContextMode = null }) { export function recordContextSessionUserChange({
snapshot,
change,
effectiveContextMode = null,
}) {
if ( if (
!snapshot?.userAdded !snapshot?.userAdded ||
|| change?.type !== 'visibility' change?.type !== 'visibility' ||
|| !isExplicitUserIntentOrigin(change.origin, change.layerId) !isExplicitUserIntentOrigin(change.origin, change.layerId)
) { ) {
return false; return false;
} }
@@ -233,7 +276,11 @@ export function recordContextSessionUserChange({ snapshot, change, effectiveCont
snapshot.userAdded.add(change.layerId); snapshot.userAdded.add(change.layerId);
return true; return true;
} }
if (contextAllowedLayerIds(effectiveContextMode).has(change.layerId) && !isCompanion) return false; if (
contextAllowedLayerIds(effectiveContextMode).has(change.layerId) &&
!isCompanion
)
return false;
snapshot.userAdded.add(change.layerId); snapshot.userAdded.add(change.layerId);
if (isCompanion) snapshot.userRemoved?.delete(change.layerId); if (isCompanion) snapshot.userRemoved?.delete(change.layerId);
return true; return true;
@@ -276,11 +323,15 @@ export function contextSnapshotLayerIds(
* @param {string|null} [input.layerName] User-facing layer name. * @param {string|null} [input.layerName] User-facing layer name.
* @returns {string|null} Honest user-facing refusal reason, or null when allowed. * @returns {string|null} Honest user-facing refusal reason, or null when allowed.
*/ */
export function contextLayerEnableBlockReason({ contextMode, change, layerName = null }) { export function contextLayerEnableBlockReason({
contextMode,
change,
layerName = null,
}) {
if ( if (
contextMode !== 'space-missions' contextMode !== 'space-missions' ||
|| change?.enabled !== true change?.enabled !== true ||
|| contextAllowedLayerIds('space-missions').has(change.layerId) contextAllowedLayerIds('space-missions').has(change.layerId)
) { ) {
return null; return null;
} }
@@ -297,20 +348,20 @@ export function contextLayerEnableBlockReason({ contextMode, change, layerName =
* @param {object|null} input.change Manager visibility notification. * @param {object|null} input.change Manager visibility notification.
* @returns {'ignore'|'replacement'|'restore'} Cancellation disposition. * @returns {'ignore'|'replacement'|'restore'} Cancellation disposition.
*/ */
export function spaceMissionEntryCancellationDisposition({ export function spaceMissionEntryCancellationDisposition({ change }) {
change,
}) {
if ( if (
change?.type !== 'visibility-cancelled' change?.type !== 'visibility-cancelled' ||
|| change.layerId !== 'rocket-launches' change.layerId !== 'rocket-launches' ||
|| change.enabled !== true change.enabled !== true
) return 'ignore'; )
return 'ignore';
if ( if (
change.cancellationReason === 'superseded' change.cancellationReason === 'superseded' &&
&& Number.isInteger(change.successorIntentEpoch) Number.isInteger(change.successorIntentEpoch) &&
&& change.successorIntentEpoch > change.intentEpoch change.successorIntentEpoch > change.intentEpoch &&
&& change.successorEnabled === true change.successorEnabled === true
) return 'replacement'; )
return 'replacement';
return 'restore'; return 'restore';
} }
@@ -348,10 +399,12 @@ export function cockpitEntryAllowed({
flightsEnabled, flightsEnabled,
militaryEnabled, militaryEnabled,
}) { }) {
return contextMode === 'flights' return (
&& !contextModeChanging contextMode === 'flights' &&
&& Boolean(flightsEnabled) !contextModeChanging &&
&& Boolean(militaryEnabled); Boolean(flightsEnabled) &&
Boolean(militaryEnabled)
);
} }
/** /**
+6 -2
View File
@@ -3,7 +3,9 @@
* Cesium retains ownership of showing and hiding its required credit lightbox. * Cesium retains ownership of showing and hiding its required credit lightbox.
*/ */
export function configureCreditKeyboardAccess(root = document) { export function configureCreditKeyboardAccess(root = document) {
const expand = root?.querySelector?.('#cesium-credits .cesium-credit-expand-link'); const expand = root?.querySelector?.(
'#cesium-credits .cesium-credit-expand-link',
);
const lightbox = root?.querySelector?.('.cesium-credit-lightbox'); const lightbox = root?.querySelector?.('.cesium-credit-lightbox');
const close = lightbox?.querySelector?.('.cesium-credit-lightbox-close'); const close = lightbox?.querySelector?.('.cesium-credit-lightbox-close');
if (!expand || !lightbox || !close) return false; if (!expand || !lightbox || !close) return false;
@@ -44,7 +46,9 @@ export function configureCreditKeyboardAccess(root = document) {
// A long Space hold may hand control to push-to-talk and move focus away. // A long Space hold may hand control to push-to-talk and move focus away.
// Cancelling the armed release prevents that eventual keyup from also // Cancelling the armed release prevents that eventual keyup from also
// activating the attribution control. // activating the attribution control.
control.addEventListener('blur', () => { spacePressed = false; }); control.addEventListener('blur', () => {
spacePressed = false;
});
}; };
const closeAndRestoreFocus = () => { const closeAndRestoreFocus = () => {
expand.setAttribute('aria-expanded', 'false'); expand.setAttribute('aria-expanded', 'false');
+414 -67
View File
@@ -8,90 +8,415 @@
*/ */
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. */
const OPENSKY_CATEGORY = { const OPENSKY_CATEGORY = {
2: 'light', // Light (< 15 500 lbs) 2: 'light', // Light (< 15 500 lbs)
3: 'airliner', // Small 3: 'airliner', // Small
4: 'airliner', // Large 4: 'airliner', // Large
5: 'airliner', // High-vortex large (B757) 5: 'airliner', // High-vortex large (B757)
6: 'widebody', // Heavy 6: 'widebody', // Heavy
7: 'fastjet', // High performance (>5g, >400 kt) 7: 'fastjet', // High performance (>5g, >400 kt)
8: 'helicopter', // Rotorcraft 8: 'helicopter', // Rotorcraft
9: 'glider', 9: 'glider',
}; };
/** 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 —
@@ -148,10 +495,10 @@ export const CLASS_MODEL_URL = {
* pinned by modelScale.test.mjs. Classes NOT listed (airliner, quadjet, * pinned by modelScale.test.mjs. Classes NOT listed (airliner, quadjet,
* glider, fastjet) still render the shared airplane.glb via CLASS_MODEL_URL. */ * glider, fastjet) still render the shared airplane.glb via CLASS_MODEL_URL. */
export const CLASS_MODEL_REAL = { export const CLASS_MODEL_REAL = {
helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 }, helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 },
light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 }, light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 },
bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 }, bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 },
uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 }, uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 },
widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 }, widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 },
turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 }, turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 },
}; };
+7 -3
View File
@@ -35,9 +35,11 @@ const VIEW = 96;
const C = VIEW / 2; // 48 — glyph centre const C = VIEW / 2; // 48 — glyph centre
// Hairline dark edge (scaled for the 96 box: ~1.5u ≈ the old 0.5u at 32). // Hairline dark edge (scaled for the 96 box: ~1.5u ≈ the old 0.5u at 32).
const STROKE = 'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"'; const STROKE =
'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"';
// Heavier edge for the chart-symbol ("bold") glyphs adopted 2026-08-15. // Heavier edge for the chart-symbol ("bold") glyphs adopted 2026-08-15.
const STROKE_BOLD = 'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"'; const STROKE_BOLD =
'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"';
// Softer white for translucent detail (prop discs, rotor disc) — still white so // Softer white for translucent detail (prop discs, rotor disc) — still white so
// the tint multiplies cleanly; only the alpha differs. // the tint multiplies cleanly; only the alpha differs.
const DISC = 'fill="white" fill-opacity="0.5"'; const DISC = 'fill="white" fill-opacity="0.5"';
@@ -239,7 +241,9 @@ const BODIES = {
const _iconCache = new Map(); const _iconCache = new Map();
const _b64 = (s) => const _b64 = (s) =>
typeof btoa === 'function' ? btoa(s) : Buffer.from(s, 'utf8').toString('base64'); typeof btoa === 'function'
? btoa(s)
: Buffer.from(s, 'utf8').toString('base64');
/** Fleet raster: billboards render at ~40–58 DEVICE px (width 20–24 CSS × /** Fleet raster: billboards render at ~40–58 DEVICE px (width 20–24 CSS ×
* Retina × class scale). Cesium's billboard atlas has no mipmaps, so a big * Retina × class scale). Cesium's billboard atlas has no mipmaps, so a big
+3 -1
View File
@@ -11,5 +11,7 @@ export function aircraftIncludedInNearby({
modelRendering = false, modelRendering = false,
includeHidden = false, includeHidden = false,
} = {}) { } = {}) {
return Boolean(includeHidden || isTracked || billboardShown || modelRendering); return Boolean(
includeHidden || isTracked || billboardShown || modelRendering,
);
} }
+50 -19
View File
@@ -10,7 +10,7 @@ export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
startLimbRatio: 0.5, startLimbRatio: 0.5,
scaleFloor: 0.45, scaleFloor: 0.45,
alphaFloor: 0.35, alphaFloor: 0.35,
combinedAlphaFloor: 0.20, combinedAlphaFloor: 0.2,
globeViewBlendStartM: 3_500_000, globeViewBlendStartM: 3_500_000,
globeViewBlendEndM: 4_500_000, globeViewBlendEndM: 4_500_000,
earthRadiusM: 6_378_137, earthRadiusM: 6_378_137,
@@ -36,10 +36,19 @@ function resolvedParams(overrides) {
return { ..._params, ...overrides }; return { ..._params, ...overrides };
} }
function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning, result) { function aircraftRecessionFactorsResolved(
if (!Number.isFinite(cameraDistanceM) || cameraDistanceM < 0 cameraDistanceM,
|| !Number.isFinite(cameraHeightM) || cameraHeightM <= 0 cameraHeightM,
|| cameraHeightM >= tuning.globeViewBlendEndM) { tuning,
result,
) {
if (
!Number.isFinite(cameraDistanceM) ||
cameraDistanceM < 0 ||
!Number.isFinite(cameraHeightM) ||
cameraHeightM <= 0 ||
cameraHeightM >= tuning.globeViewBlendEndM
) {
result.scale = 1; result.scale = 1;
result.alpha = 1; result.alpha = 1;
result.limbRatio = null; result.limbRatio = null;
@@ -60,11 +69,13 @@ function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning
return result; return result;
} }
const rawT = (limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio); const rawT =
(limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio);
const t = clamp(rawT, 0, 1); const t = clamp(rawT, 0, 1);
const limbEase = t * t * (3 - 2 * t); // smoothstep: zero slope at both band edges const limbEase = t * t * (3 - 2 * t); // smoothstep: zero slope at both band edges
const globeRawT = (cameraHeightM - tuning.globeViewBlendStartM) const globeRawT =
/ (tuning.globeViewBlendEndM - tuning.globeViewBlendStartM); (cameraHeightM - tuning.globeViewBlendStartM) /
(tuning.globeViewBlendEndM - tuning.globeViewBlendStartM);
const globeT = clamp(globeRawT, 0, 1); const globeT = clamp(globeRawT, 0, 1);
const globeEase = globeT * globeT * (3 - 2 * globeT); const globeEase = globeT * globeT * (3 - 2 * globeT);
const strength = 1 - globeEase; const strength = 1 - globeEase;
@@ -84,8 +95,10 @@ export function setAircraftRecessionParams(patch = {}) {
if (Number.isFinite(patch.startLimbRatio)) { if (Number.isFinite(patch.startLimbRatio)) {
next.startLimbRatio = clamp(patch.startLimbRatio, 0, 0.99); next.startLimbRatio = clamp(patch.startLimbRatio, 0, 0.99);
} }
if (Number.isFinite(patch.scaleFloor)) next.scaleFloor = clamp(patch.scaleFloor, 0.05, 1); if (Number.isFinite(patch.scaleFloor))
if (Number.isFinite(patch.alphaFloor)) next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1); next.scaleFloor = clamp(patch.scaleFloor, 0.05, 1);
if (Number.isFinite(patch.alphaFloor))
next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1);
if (Number.isFinite(patch.combinedAlphaFloor)) { if (Number.isFinite(patch.combinedAlphaFloor)) {
next.combinedAlphaFloor = clamp(patch.combinedAlphaFloor, 0.05, 1); next.combinedAlphaFloor = clamp(patch.combinedAlphaFloor, 0.05, 1);
} }
@@ -109,8 +122,10 @@ export function setAircraftRecessionParams(patch = {}) {
if (next.globeViewBlendStartM >= next.globeViewBlendEndM) { if (next.globeViewBlendStartM >= next.globeViewBlendEndM) {
next.globeViewBlendStartM = Math.max(1, next.globeViewBlendEndM - 1); next.globeViewBlendStartM = Math.max(1, next.globeViewBlendEndM - 1);
} }
if (Number.isFinite(patch.earthRadiusM)) next.earthRadiusM = Math.max(1, patch.earthRadiusM); if (Number.isFinite(patch.earthRadiusM))
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon); next.earthRadiusM = Math.max(1, patch.earthRadiusM);
if (Number.isFinite(patch.writeEpsilon))
next.writeEpsilon = Math.max(0, patch.writeEpsilon);
_params = next; _params = next;
return { ..._params }; return { ..._params };
} }
@@ -149,7 +164,10 @@ export function cameraLimbDistanceM(
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [params] * @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [params]
* @returns {{scale:number,alpha:number,limbRatio:number|null}} * @returns {{scale:number,alpha:number,limbRatio:number|null}}
*/ */
export function aircraftRecessionFactors({ cameraDistanceM, cameraHeightM }, params) { export function aircraftRecessionFactors(
{ cameraDistanceM, cameraHeightM },
params,
) {
const tuning = resolvedParams(params); const tuning = resolvedParams(params);
return aircraftRecessionFactorsResolved( return aircraftRecessionFactorsResolved(
cameraDistanceM, cameraDistanceM,
@@ -202,12 +220,17 @@ export function applyAircraftBillboardTreatment({
const alpha = baseAlpha * composedTreatment; const alpha = baseAlpha * composedTreatment;
let scaleWrites = 0; let scaleWrites = 0;
let alphaWrites = 0; let alphaWrites = 0;
if (!Number.isFinite(billboard.scale) || Math.abs(billboard.scale - scale) > tuning.writeEpsilon) { if (
!Number.isFinite(billboard.scale) ||
Math.abs(billboard.scale - scale) > tuning.writeEpsilon
) {
billboard.scale = scale; billboard.scale = scale;
scaleWrites = 1; scaleWrites = 1;
} }
if (!Number.isFinite(billboard.color?.alpha) if (
|| Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon) { !Number.isFinite(billboard.color?.alpha) ||
Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon
) {
billboard.color = baseColor.withAlpha(alpha); billboard.color = baseColor.withAlpha(alpha);
alphaWrites = 1; alphaWrites = 1;
} }
@@ -228,11 +251,19 @@ export function applyAircraftBillboardTreatment({
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params] * @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
* @returns {number} One when a color write occurred, otherwise zero. * @returns {number} One when a color write occurred, otherwise zero.
*/ */
export function applyAircraftModelTreatment({ model, baseColor, alpha, params }) { export function applyAircraftModelTreatment({
model,
baseColor,
alpha,
params,
}) {
const tuning = resolvedParams(params); const tuning = resolvedParams(params);
if (!model || !baseColor || !Number.isFinite(alpha)) return 0; if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
if (Number.isFinite(model.color?.alpha) if (
&& Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon) return 0; Number.isFinite(model.color?.alpha) &&
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon
)
return 0;
model.color = baseColor.withAlpha(alpha); model.color = baseColor.withAlpha(alpha);
return 1; return 1;
} }
+110 -40
View File
@@ -28,11 +28,39 @@ import { pointInRing } from './naturalEarthRegions.js';
/** Layers the engine understands, with the fields queries may reference. */ /** Layers the engine understands, with the fields queries may reference. */
export const ANALYST_LAYERS = { export const ANALYST_LAYERS = {
flights: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass'], flags: ['military', 'onGround'] }, flights: {
military: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'], flags: ['military', 'onGround'] }, numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
'ais-live-vessels': { numeric: ['speedKts', 'courseDeg'], text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'], flags: [] }, text: [
'local-firms': { numeric: ['frp'], text: ['confidence', 'satellite'], flags: [] }, 'callsign',
earthquakes: { numeric: ['magnitude', 'depthKm'], text: ['place'], flags: [] }, 'icao24',
'originCountry',
'operator',
'routeOrigin',
'routeDestination',
'aircraftClass',
],
flags: ['military', 'onGround'],
},
military: {
numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'],
flags: ['military', 'onGround'],
},
'ais-live-vessels': {
numeric: ['speedKts', 'courseDeg'],
text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'],
flags: [],
},
'local-firms': {
numeric: ['frp'],
text: ['confidence', 'satellite'],
flags: [],
},
earthquakes: {
numeric: ['magnitude', 'depthKm'],
text: ['place'],
flags: [],
},
}; };
const EARTH_R_KM = 6371; const EARTH_R_KM = 6371;
@@ -42,8 +70,9 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
const d2r = Math.PI / 180; const d2r = Math.PI / 180;
const dLat = (lat2 - lat1) * d2r; const dLat = (lat2 - lat1) * d2r;
const dLon = (lon2 - lon1) * d2r; const dLon = (lon2 - lon1) * d2r;
const a = Math.sin(dLat / 2) ** 2 const a =
+ Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2; Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a))); return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
} }
@@ -55,17 +84,25 @@ export function applyFilter(records, filter) {
const got = r[field]; const got = r[field];
if (got === null || got === undefined) return false; if (got === null || got === undefined) return false;
switch (op) { switch (op) {
case 'gt': return Number(got) > Number(value); case 'gt':
case 'gte': return Number(got) >= Number(value); return Number(got) > Number(value);
case 'lt': return Number(got) < Number(value); case 'gte':
case 'lte': return Number(got) <= Number(value); return Number(got) >= Number(value);
case 'lt':
return Number(got) < Number(value);
case 'lte':
return Number(got) <= Number(value);
case 'eq': { case 'eq': {
if (typeof got === 'boolean' || typeof value === 'boolean') return Boolean(got) === Boolean(value); if (typeof got === 'boolean' || typeof value === 'boolean')
return Boolean(got) === Boolean(value);
return String(got).toLowerCase() === String(value).toLowerCase(); return String(got).toLowerCase() === String(value).toLowerCase();
} }
case 'neq': return String(got).toLowerCase() !== String(value).toLowerCase(); case 'neq':
case 'contains': return String(got).toLowerCase().includes(String(value).toLowerCase()); return String(got).toLowerCase() !== String(value).toLowerCase();
default: return true; case 'contains':
return String(got).toLowerCase().includes(String(value).toLowerCase());
default:
return true;
} }
}); });
} }
@@ -74,15 +111,23 @@ export function applyFilter(records, filter) {
export function applyScope(records, scope, resolved) { export function applyScope(records, scope, resolved) {
if (!scope || scope.kind === 'anywhere') return records; if (!scope || scope.kind === 'anywhere') return records;
if (scope.kind === 'region' && resolved?.ring) { if (scope.kind === 'region' && resolved?.ring) {
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon) return records.filter(
&& pointInRing(resolved.ring, r.lat, r.lon)); (r) =>
Number.isFinite(r.lat) &&
Number.isFinite(r.lon) &&
pointInRing(resolved.ring, r.lat, r.lon),
);
} }
if (scope.kind === 'radius' || scope.kind === 'view') { if (scope.kind === 'radius' || scope.kind === 'view') {
const c = resolved?.center; const c = resolved?.center;
const km = resolved?.km; const km = resolved?.km;
if (!c || !Number.isFinite(km)) return records; if (!c || !Number.isFinite(km)) return records;
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon) return records.filter(
&& haversineKm(c.lat, c.lon, r.lat, r.lon) <= km); (r) =>
Number.isFinite(r.lat) &&
Number.isFinite(r.lon) &&
haversineKm(c.lat, c.lon, r.lat, r.lon) <= km,
);
} }
return records; return records;
} }
@@ -108,9 +153,12 @@ export function createAnalystEngine(providers) {
let lastResult = null; let lastResult = null;
async function query(spec = {}) { async function query(spec = {}) {
const layers = (spec.followUp && lastResult) const layers =
? null // follow-up: re-filter the remembered set, no re-snapshot spec.followUp && lastResult
: (Array.isArray(spec.layers) && spec.layers.length ? spec.layers : ['flights']); ? null // follow-up: re-filter the remembered set, no re-snapshot
: 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 =
? { lat: subject.lat, lon: subject.lon } Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
: null; ? { lat: subject.lat, lon: subject.lon }
const center = explicitCenter || subjectCenter || { lat: view.lat, lon: view.lon }; : null;
const center = explicitCenter ||
subjectCenter || { lat: view.lat, lon: view.lon };
resolvedScope = { center, km: Number(scope.km) || 100 }; resolvedScope = { center, km: Number(scope.km) || 100 };
if (subjectCenter) resolvedScope.centeredOn = subject.label || null; if (subjectCenter) resolvedScope.centeredOn = subject.label || null;
scopeNote = resolvedScope.centeredOn scopeNote = resolvedScope.centeredOn
@@ -188,7 +239,10 @@ export function createAnalystEngine(providers) {
: `within ${resolvedScope.km} km`; : `within ${resolvedScope.km} km`;
} else if (scope.kind === 'view') { } else if (scope.kind === 'view') {
const view = providers.getViewContext(); const view = providers.getViewContext();
resolvedScope = { center: { lat: view.lat, lon: view.lon }, km: view.viewRadiusKm }; resolvedScope = {
center: { lat: view.lat, lon: view.lon },
km: view.viewRadiusKm,
};
scopeNote = `view:${Math.round(view.viewRadiusKm)}km`; scopeNote = `view:${Math.round(view.viewRadiusKm)}km`;
scopeLabel = 'in view'; scopeLabel = 'in view';
} else { } else {
@@ -205,15 +259,22 @@ export function createAnalystEngine(providers) {
if (sortBy === 'distance') { if (sortBy === 'distance') {
const ref = resolvedScope?.center || providers.getViewContext(); const ref = resolvedScope?.center || providers.getViewContext();
for (const it of items) { for (const it of items) {
it.distanceKm = (Number.isFinite(it.lat) && Number.isFinite(it.lon)) it.distanceKm =
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) / 10 : null; Number.isFinite(it.lat) && Number.isFinite(it.lon)
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) /
10
: null;
} }
} }
if (sortBy) { if (sortBy) {
const dir = spec.sortDir === 'asc' ? 1 : -1; const dir = spec.sortDir === 'asc' ? 1 : -1;
items.sort((a, b) => (Number(a[sortBy]) - Number(b[sortBy])) * dir items.sort(
|| String(a.id).localeCompare(String(b.id))); (a, b) =>
if (sortBy === 'distance') items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9)); (Number(a[sortBy]) - Number(b[sortBy])) * dir ||
String(a.id).localeCompare(String(b.id)),
);
if (sortBy === 'distance')
items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9));
} }
const limit = Math.max(1, Math.min(50, Number(spec.limit) || 10)); const limit = Math.max(1, Math.min(50, Number(spec.limit) || 10));
const top = items.slice(0, limit); const top = items.slice(0, limit);
@@ -223,7 +284,10 @@ export function createAnalystEngine(providers) {
count: items.length, count: items.length,
items: top, items: top,
truncated: items.length > top.length, truncated: items.length > top.length,
summary: summarize(items, sortBy && sortBy !== 'distance' ? sortBy : null), summary: summarize(
items,
sortBy && sortBy !== 'distance' ? sortBy : null,
),
scopeLabel, scopeLabel,
coverage: { coverage: {
layersQueried, layersQueried,
@@ -233,7 +297,9 @@ export function createAnalystEngine(providers) {
}, },
// Surfaced so the narration can name the centre it measured from rather // Surfaced so the narration can name the centre it measured from rather
// than implying a view-centred answer. // than implying a view-centred answer.
...(resolvedScope?.centeredOn ? { centeredOn: resolvedScope.centeredOn } : {}), ...(resolvedScope?.centeredOn
? { centeredOn: resolvedScope.centeredOn }
: {}),
}; };
lastResult = { items, coverage: result.coverage }; lastResult = { items, coverage: result.coverage };
return result; return result;
@@ -241,7 +307,11 @@ export function createAnalystEngine(providers) {
return { return {
query, query,
reset() { lastResult = null; }, reset() {
hasMemory() { return Boolean(lastResult); }, lastResult = null;
},
hasMemory() {
return Boolean(lastResult);
},
}; };
} }
+67 -19
View File
@@ -110,8 +110,12 @@ export const CCTV_THUMBNAIL_ALTITUDE_SCALE = Object.freeze({
*/ */
export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) { export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
if (pinned) return Number.isFinite(sy); if (pinned) return Number.isFinite(sy);
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0) return false; if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0)
const safeTop = Math.min(CCTV_CARD_SAFE_TOP_MAX_PX, viewH * CCTV_CARD_SAFE_TOP_RATIO); return false;
const safeTop = Math.min(
CCTV_CARD_SAFE_TOP_MAX_PX,
viewH * CCTV_CARD_SAFE_TOP_RATIO,
);
return sy >= safeTop; return sy >= safeTop;
} }
@@ -126,11 +130,24 @@ export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
* @param {number} [options.limit] * @param {number} [options.limit]
* @returns {string[]} Accepted ids, nearest-first. * @returns {string[]} Accepted ids, nearest-first.
*/ */
export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {}) { export function declutterCctvCards(
candidates,
{ minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {},
) {
const valid = (Array.isArray(candidates) ? candidates : []) const valid = (Array.isArray(candidates) ? candidates : [])
.filter((c) => c && typeof c.id === 'string' && c.id .filter(
&& Number.isFinite(c.sx) && Number.isFinite(c.sy)) (c) =>
.sort((a, b) => (a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) || a.id.localeCompare(b.id)); c &&
typeof c.id === 'string' &&
c.id &&
Number.isFinite(c.sx) &&
Number.isFinite(c.sy),
)
.sort(
(a, b) =>
(a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) ||
a.id.localeCompare(b.id),
);
const minSq = minSepPx * minSepPx; const minSq = minSepPx * minSepPx;
const accepted = []; const accepted = [];
const acceptedIds = []; const acceptedIds = [];
@@ -140,7 +157,10 @@ export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX
for (let i = 0; i < accepted.length; i++) { for (let i = 0; i < accepted.length; i++) {
const dx = candidate.sx - accepted[i].sx; const dx = candidate.sx - accepted[i].sx;
const dy = candidate.sy - accepted[i].sy; const dy = candidate.sy - accepted[i].sy;
if (dx * dx + dy * dy < minSq) { clear = false; break; } if (dx * dx + dy * dy < minSq) {
clear = false;
break;
}
} }
if (!clear) continue; if (!clear) continue;
accepted.push(candidate); accepted.push(candidate);
@@ -165,15 +185,26 @@ export function cardScaleForAltitude(cameraHeightM) {
const h = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : 0; const h = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : 0;
if (h <= CCTV_CARD_SCALE_FULL_M) return { scale: 1, alpha: 1 }; if (h <= CCTV_CARD_SCALE_FULL_M) return { scale: 1, alpha: 1 };
if (h <= CCTV_CARD_SCALE_MID_M) { if (h <= CCTV_CARD_SCALE_MID_M) {
const t = (h - CCTV_CARD_SCALE_FULL_M) / (CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M); const t =
(h - CCTV_CARD_SCALE_FULL_M) /
(CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M);
const s = t * t * (3 - 2 * t); // smoothstep — no visible kink at either end const s = t * t * (3 - 2 * t); // smoothstep — no visible kink at either end
return { scale: 1 + (CCTV_CARD_SCALE_AT_MID - 1) * s, alpha: 1 }; return { scale: 1 + (CCTV_CARD_SCALE_AT_MID - 1) * s, alpha: 1 };
} }
const t = Math.min(1, (h - CCTV_CARD_SCALE_MID_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M)); const t = Math.min(
const scale = CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t; 1,
if (h >= CCTV_CARD_FADE_END_M) return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 }; (h - CCTV_CARD_SCALE_MID_M) /
(CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M),
);
const scale =
CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t;
if (h >= CCTV_CARD_FADE_END_M)
return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 };
if (h <= CCTV_CARD_FADE_START_M) return { scale, alpha: 1 }; if (h <= CCTV_CARD_FADE_START_M) return { scale, alpha: 1 };
const alpha = 1 - (h - CCTV_CARD_FADE_START_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M); const alpha =
1 -
(h - CCTV_CARD_FADE_START_M) /
(CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
return { scale, alpha }; return { scale, alpha };
} }
@@ -192,17 +223,23 @@ export function cardScaleForAltitude(cameraHeightM) {
* @param {number} [input.sinceLastLaunchMs] - Ms since the last fetch launch. * @param {number} [input.sinceLastLaunchMs] - Ms since the last fetch launch.
* @returns {{mode:('burst'|'steady'), launch:boolean}} * @returns {{mode:('burst'|'steady'), launch:boolean}}
*/ */
export function cardFetchPolicy({ coldFill = false, inFlight = 0, sinceLastLaunchMs = Infinity } = {}) { export function cardFetchPolicy({
coldFill = false,
inFlight = 0,
sinceLastLaunchMs = Infinity,
} = {}) {
if (coldFill) { if (coldFill) {
return { return {
mode: 'burst', mode: 'burst',
launch: inFlight < CCTV_CARD_FETCH_BURST_LIMIT launch:
&& sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS, inFlight < CCTV_CARD_FETCH_BURST_LIMIT &&
sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
}; };
} }
return { return {
mode: 'steady', mode: 'steady',
launch: inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS, launch:
inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
}; };
} }
@@ -230,7 +267,12 @@ export function createFrameSlot() {
export function applyFrameResult(prev, result, nowMs) { export function applyFrameResult(prev, result, nowMs) {
const base = prev || createFrameSlot(); const base = prev || createFrameSlot();
if (result?.ok && result.frame) { if (result?.ok && result.frame) {
return { frame: result.frame, stamp: nowMs, failCount: 0, lastAttemptAt: nowMs }; return {
frame: result.frame,
stamp: nowMs,
failCount: 0,
lastAttemptAt: nowMs,
};
} }
return { return {
frame: base.frame, frame: base.frame,
@@ -278,11 +320,17 @@ export function frameFetchDue(slot, refreshMs, nowMs) {
* @param {number} [cap] * @param {number} [cap]
* @returns {string[]} Ids to drop from the cache. * @returns {string[]} Ids to drop from the cache.
*/ */
export function planFrameCachePrune(slots, keepIds, cap = CCTV_FRAME_CACHE_MAX) { export function planFrameCachePrune(
slots,
keepIds,
cap = CCTV_FRAME_CACHE_MAX,
) {
const keep = new Set(keepIds); const keep = new Set(keepIds);
const spare = (Array.isArray(slots) ? slots : []) const spare = (Array.isArray(slots) ? slots : [])
.filter((slot) => slot && typeof slot.id === 'string' && !keep.has(slot.id)) .filter((slot) => slot && typeof slot.id === 'string' && !keep.has(slot.id))
.sort((a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id)); .sort(
(a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id),
);
const capacity = Math.max(0, cap - keep.size); const capacity = Math.max(0, cap - keep.size);
return spare.slice(capacity).map((slot) => slot.id); return spare.slice(capacity).map((slot) => slot.id);
} }
+173 -44
View File
@@ -74,7 +74,11 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
const toPlane = Cesium.Cartesian3.subtract(planeOrigin, rayOrigin, scratchW); const toPlane = Cesium.Cartesian3.subtract(planeOrigin, rayOrigin, scratchW);
const s = Cesium.Cartesian3.dot(toPlane, planeNormal) / denom; const s = Cesium.Cartesian3.dot(toPlane, planeNormal) / denom;
if (s < 0) return null; if (s < 0) return null;
const hit = Cesium.Cartesian3.multiplyByScalar(rayDir, s, new Cesium.Cartesian3()); const hit = Cesium.Cartesian3.multiplyByScalar(
rayDir,
s,
new Cesium.Cartesian3(),
);
return Cesium.Cartesian3.add(rayOrigin, hit, hit); return Cesium.Cartesian3.add(rayOrigin, hit, hit);
} }
@@ -89,7 +93,10 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
*/ */
export function ringAngle(hitPoint, center, basisA, basisB) { export function ringAngle(hitPoint, center, basisA, basisB) {
const v = Cesium.Cartesian3.subtract(hitPoint, center, scratchW); const v = Cesium.Cartesian3.subtract(hitPoint, center, scratchW);
return Math.atan2(Cesium.Cartesian3.dot(v, basisB), Cesium.Cartesian3.dot(v, basisA)); return Math.atan2(
Cesium.Cartesian3.dot(v, basisB),
Cesium.Cartesian3.dot(v, basisA),
);
} }
/** /**
@@ -150,7 +157,11 @@ function enuAxes(position) {
/** a*sa + b*sb (fresh Cartesian3). */ /** a*sa + b*sb (fresh Cartesian3). */
function combine2(a, sa, b, sb) { function combine2(a, sa, b, sb) {
const out = Cesium.Cartesian3.multiplyByScalar(a, sa, new Cesium.Cartesian3()); const out = Cesium.Cartesian3.multiplyByScalar(
a,
sa,
new Cesium.Cartesian3(),
);
const t = Cesium.Cartesian3.multiplyByScalar(b, sb, new Cesium.Cartesian3()); const t = Cesium.Cartesian3.multiplyByScalar(b, sb, new Cesium.Cartesian3());
return Cesium.Cartesian3.add(out, t, out); return Cesium.Cartesian3.add(out, t, out);
} }
@@ -165,7 +176,12 @@ function combine2(a, sa, b, sb) {
function viewAxesFor(headingDeg, pitchDeg, axes) { function viewAxesFor(headingDeg, pitchDeg, axes) {
const h = toRadians(headingDeg); const h = toRadians(headingDeg);
const p = toRadians(pitchDeg); const p = toRadians(pitchDeg);
const forwardHoriz = combine2(axes.east, Math.sin(h), axes.north, Math.cos(h)); const forwardHoriz = combine2(
axes.east,
Math.sin(h),
axes.north,
Math.cos(h),
);
const right = combine2(axes.east, Math.cos(h), axes.north, -Math.sin(h)); const right = combine2(axes.east, Math.cos(h), axes.north, -Math.sin(h));
const view = combine2(forwardHoriz, Math.cos(p), axes.up, Math.sin(p)); const view = combine2(forwardHoriz, Math.cos(p), axes.up, Math.sin(p));
return { forwardHoriz, right, view }; return { forwardHoriz, right, view };
@@ -176,7 +192,12 @@ function ringPositions(center, radius, basisA, basisB) {
const positions = []; const positions = [];
for (let i = 0; i <= RING_SEGMENTS; i++) { for (let i = 0; i <= RING_SEGMENTS; i++) {
const angle = (i / RING_SEGMENTS) * 2 * Math.PI; const angle = (i / RING_SEGMENTS) * 2 * Math.PI;
const offset = combine2(basisA, Math.cos(angle) * radius, basisB, Math.sin(angle) * radius); const offset = combine2(
basisA,
Math.cos(angle) * radius,
basisB,
Math.sin(angle) * radius,
);
positions.push(Cesium.Cartesian3.add(center, offset, offset)); positions.push(Cesium.Cartesian3.add(center, offset, offset));
} }
return positions; return positions;
@@ -202,7 +223,12 @@ function ringPositions(center, radius, basisA, basisB) {
* destroy: function(): void, isDragging: function(): boolean, * destroy: function(): void, isDragging: function(): boolean,
* isEnabled: function(): boolean}} * isEnabled: function(): boolean}}
*/ */
export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, endPatch }) { export function createCalibrationGizmo({
viewer,
getActiveRecord,
applyPatch,
endPatch,
}) {
let enabled = false; let enabled = false;
let drag = null; // { part, startCal, basePose, refs... } let drag = null; // { part, startCal, basePose, refs... }
let hoveredId = null; let hoveredId = null;
@@ -225,20 +251,24 @@ 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, ? {
point: { position: Cesium.Cartesian3.ZERO,
pixelSize: 14, point: {
color, pixelSize: 14,
outlineColor: Cesium.Color.BLACK.withAlpha(0.7), color,
outlineWidth: 2, outlineColor: Cesium.Color.BLACK.withAlpha(0.7),
disableDepthTestDistance: Number.POSITIVE_INFINITY, outlineWidth: 2,
}, disableDepthTestDistance: Number.POSITIVE_INFINITY,
} : {}), },
}
: {}),
polyline: { polyline: {
positions: [], positions: [],
width, width,
material: arrow ? new Cesium.PolylineArrowMaterialProperty(color) : color.withAlpha(0.9), material: arrow
? new Cesium.PolylineArrowMaterialProperty(color)
: color.withAlpha(0.9),
// Standard gizmo convention: parts behind geometry stay clearly // Standard gizmo convention: parts behind geometry stay clearly
// visible, just dimmed (a street-level mount buries half the heading // visible, just dimmed (a street-level mount buries half the heading
// ring in sloped photogrammetry tiles — smoke-tested 2026-07-05). // ring in sloped photogrammetry tiles — smoke-tested 2026-07-05).
@@ -286,16 +316,42 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
} }
const mount = positions.mount; const mount = positions.mount;
const axes = enuAxes(mount); const axes = enuAxes(mount);
const { forwardHoriz, right, view } = viewAxesFor(camera.headingDeg, camera.pitchDeg, axes); const { forwardHoriz, right, view } = viewAxesFor(
camera.headingDeg,
camera.pitchDeg,
axes,
);
const rangeM = Math.max(1, Number(camera.rangeM) || 1); const rangeM = Math.max(1, Number(camera.rangeM) || 1);
const radius = Math.min(RING_RADIUS_MAX_M, Math.max(RING_RADIUS_MIN_M, rangeM * RING_RADIUS_FACTOR)); const radius = Math.min(
RING_RADIUS_MAX_M,
Math.max(RING_RADIUS_MIN_M, rangeM * RING_RADIUS_FACTOR),
);
const arrowLen = radius * ARROW_LENGTH_FACTOR; const arrowLen = radius * ARROW_LENGTH_FACTOR;
entities.get('ring-heading').polyline.positions = ringPositions(mount, radius, axes.north, axes.east); entities.get('ring-heading').polyline.positions = ringPositions(
entities.get('ring-pitch').polyline.positions = ringPositions(mount, radius, forwardHoriz, axes.up); mount,
const arrowTo = (dir) => [mount, Cesium.Cartesian3.add( radius,
mount, Cesium.Cartesian3.multiplyByScalar(dir, arrowLen, new Cesium.Cartesian3()), new Cesium.Cartesian3() axes.north,
)]; axes.east,
);
entities.get('ring-pitch').polyline.positions = ringPositions(
mount,
radius,
forwardHoriz,
axes.up,
);
const arrowTo = (dir) => [
mount,
Cesium.Cartesian3.add(
mount,
Cesium.Cartesian3.multiplyByScalar(
dir,
arrowLen,
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
),
];
const eastArrow = arrowTo(axes.east); const eastArrow = arrowTo(axes.east);
const northArrow = arrowTo(axes.north); const northArrow = arrowTo(axes.north);
const upArrow = arrowTo(axes.up); const upArrow = arrowTo(axes.up);
@@ -306,8 +362,16 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
entities.get('move-up').polyline.positions = upArrow; entities.get('move-up').polyline.positions = upArrow;
entities.get('move-up').position = upArrow[1]; entities.get('move-up').position = upArrow[1];
entities.get('handle-range').position = positions.capCenter; entities.get('handle-range').position = positions.capCenter;
entities.get('handle-fov-l').position = Cesium.Cartesian3.midpoint(positions.tl, positions.bl, new Cesium.Cartesian3()); entities.get('handle-fov-l').position = Cesium.Cartesian3.midpoint(
entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint(positions.tr, positions.br, new Cesium.Cartesian3()); positions.tl,
positions.bl,
new Cesium.Cartesian3(),
);
entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint(
positions.tr,
positions.br,
new Cesium.Cartesian3(),
);
for (const entity of entities.values()) entity.show = true; for (const entity of entities.values()) entity.show = true;
} }
@@ -343,8 +407,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
return null; return null;
} }
if (typeof window !== 'undefined' && window.__gevGizmoDebug) { if (typeof window !== 'undefined' && window.__gevGizmoDebug) {
console.debug('[CCTV:gizmo] drillPick @', windowPosition?.x, windowPosition?.y, '→', console.debug(
JSON.stringify(results.map((r) => String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name)))); '[CCTV:gizmo] drillPick @',
windowPosition?.x,
windowPosition?.y,
'→',
JSON.stringify(
results.map((r) =>
String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name),
),
),
);
} }
for (const picked of results) { for (const picked of results) {
const part = gizmoPartFrom(picked); const part = gizmoPartFrom(picked);
@@ -379,7 +452,8 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
for (const [name, entity] of entities.entries()) { for (const [name, entity] of entities.entries()) {
if (!entity.point) continue; if (!entity.point) continue;
const hot = hoveredId === `${GIZMO_ID_PREFIX}${name}`; const hot = hoveredId === `${GIZMO_ID_PREFIX}${name}`;
entity.point.pixelSize = (name === 'handle-range' ? 13 : 10) + (hot ? 4 : 0); entity.point.pixelSize =
(name === 'handle-range' ? 13 : 10) + (hot ? 4 : 0);
} }
setCursor(hoveredId ? 'grab' : ''); setCursor(hoveredId ? 'grab' : '');
} }
@@ -397,7 +471,11 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
const mount = Cesium.Cartesian3.clone(positions.mount); const mount = Cesium.Cartesian3.clone(positions.mount);
const capCenter = Cesium.Cartesian3.clone(positions.capCenter); const capCenter = Cesium.Cartesian3.clone(positions.capCenter);
const axes = enuAxes(mount); const axes = enuAxes(mount);
const { forwardHoriz, right, view } = viewAxesFor(camera.headingDeg, camera.pitchDeg, axes); const { forwardHoriz, right, view } = viewAxesFor(
camera.headingDeg,
camera.pitchDeg,
axes,
);
const startCal = { ...camera.calibration }; const startCal = { ...camera.calibration };
const basePose = { ...camera.basePose }; const basePose = { ...camera.basePose };
const state = { const state = {
@@ -405,7 +483,15 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
// mid-drag active-camera switch (voice select / auto-hop) onto a // mid-drag active-camera switch (voice select / auto-hop) onto a
// different camera — its basePose makes the captured offsets wrong. // different camera — its basePose makes the captured offsets wrong.
record, record,
part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view, part,
startCal,
basePose,
mount,
capCenter,
axes,
forwardHoriz,
right,
view,
effectiveRangeM: geometry.rangeM, effectiveRangeM: geometry.rangeM,
startAngle: null, startAngle: null,
startParam: null, startParam: null,
@@ -419,8 +505,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right); const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
if (!hit) return false; if (!hit) return false;
state.startAngle = ringAngle(hit, mount, forwardHoriz, axes.up); // 0 = level, + = up state.startAngle = ringAngle(hit, mount, forwardHoriz, axes.up); // 0 = level, + = up
} else if (part === 'move-east' || part === 'move-north' || part === 'move-up') { } else if (
const dir = part === 'move-east' ? axes.east : part === 'move-north' ? axes.north : axes.up; part === 'move-east' ||
part === 'move-north' ||
part === 'move-up'
) {
const dir =
part === 'move-east'
? axes.east
: part === 'move-north'
? axes.north
: axes.up;
state.axisDir = dir; state.axisDir = dir;
const t = closestParamOnAxis(ray.origin, ray.direction, mount, dir); const t = closestParamOnAxis(ray.origin, ray.direction, mount, dir);
if (t === null) return false; if (t === null) return false;
@@ -448,26 +543,49 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
function dragPatch(windowPosition) { function dragPatch(windowPosition) {
const ray = pickRay(windowPosition); const ray = pickRay(windowPosition);
if (!ray || !drag) return null; if (!ray || !drag) return null;
const { part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view } = drag; const {
part,
startCal,
basePose,
mount,
capCenter,
axes,
forwardHoriz,
right,
view,
} = drag;
if (part === 'ring-heading') { if (part === 'ring-heading') {
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, axes.up); const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, axes.up);
if (!hit) return null; if (!hit) return null;
const angle = ringAngle(hit, mount, axes.north, axes.east); const angle = ringAngle(hit, mount, axes.north, axes.east);
return { headingDeg: startCal.headingDeg + toDeg(signedAngleDelta(drag.startAngle, angle)) }; return {
headingDeg:
startCal.headingDeg + toDeg(signedAngleDelta(drag.startAngle, angle)),
};
} }
if (part === 'ring-pitch') { if (part === 'ring-pitch') {
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right); const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
if (!hit) return null; if (!hit) return null;
const angle = ringAngle(hit, mount, forwardHoriz, axes.up); const angle = ringAngle(hit, mount, forwardHoriz, axes.up);
return { pitchDeg: startCal.pitchDeg + toDeg(signedAngleDelta(drag.startAngle, angle)) }; return {
pitchDeg:
startCal.pitchDeg + toDeg(signedAngleDelta(drag.startAngle, angle)),
};
} }
if (part === 'move-east' || part === 'move-north' || part === 'move-up') { if (part === 'move-east' || part === 'move-north' || part === 'move-up') {
const t = closestParamOnAxis(ray.origin, ray.direction, mount, drag.axisDir); const t = closestParamOnAxis(
ray.origin,
ray.direction,
mount,
drag.axisDir,
);
if (t === null) return null; if (t === null) return null;
const delta = t - drag.startParam; const delta = t - drag.startParam;
if (part === 'move-east') return { offsetEastM: startCal.offsetEastM + delta }; if (part === 'move-east')
if (part === 'move-north') return { offsetNorthM: startCal.offsetNorthM + delta }; return { offsetEastM: startCal.offsetEastM + delta };
if (part === 'move-north')
return { offsetNorthM: startCal.offsetNorthM + delta };
return { heightM: startCal.heightM + delta }; return { heightM: startCal.heightM + delta };
} }
if (part === 'handle-range') { if (part === 'handle-range') {
@@ -478,11 +596,16 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
if (part === 'handle-fov-l' || part === 'handle-fov-r') { if (part === 'handle-fov-l' || part === 'handle-fov-r') {
const hit = rayPlaneIntersect(ray.origin, ray.direction, capCenter, view); const hit = rayPlaneIntersect(ray.origin, ray.direction, capCenter, view);
if (!hit) return null; if (!hit) return null;
const lateral = Math.abs(Cesium.Cartesian3.dot( const lateral = Math.abs(
Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()), right Cesium.Cartesian3.dot(
)); Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()),
right,
),
);
const halfW = Math.max(0.5, lateral); const halfW = Math.max(0.5, lateral);
const fovDeg = toDeg(2 * Math.atan(halfW / Math.max(1, drag.effectiveRangeM))); const fovDeg = toDeg(
2 * Math.atan(halfW / Math.max(1, drag.effectiveRangeM)),
);
return { fovDeg: fovDeg - basePose.fovDeg }; return { fovDeg: fovDeg - basePose.fovDeg };
} }
return null; return null;
@@ -531,7 +654,13 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
return; return;
} }
const patch = dragPatch(event.endPosition); const patch = dragPatch(event.endPosition);
debugLog('dragPatch', drag.part, event.endPosition, '→', patch ? JSON.stringify(patch) : null); debugLog(
'dragPatch',
drag.part,
event.endPosition,
'→',
patch ? JSON.stringify(patch) : null,
);
if (patch) applyPatch(patch, drag.record); if (patch) applyPatch(patch, drag.record);
return; return;
} }
+9 -8
View File
@@ -46,7 +46,7 @@ export function cameraHue(index) {
* @returns {{fill: Cesium.Color, fillActive: Cesium.Color, line: Cesium.Color, lineActive: Cesium.Color}} * @returns {{fill: Cesium.Color, fillActive: Cesium.Color, line: Cesium.Color, lineActive: Cesium.Color}}
*/ */
export function viewshedColors(hueDeg) { export function viewshedColors(hueDeg) {
const hue = ((Number(hueDeg) % 360) + 360) % 360 / 360; const hue = (((Number(hueDeg) % 360) + 360) % 360) / 360;
return { return {
fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE), fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
fillActive: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE), fillActive: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE),
@@ -66,7 +66,13 @@ export function viewshedColors(hueDeg) {
* @returns {{positions: Float64Array, indices: Uint16Array}} * @returns {{positions: Float64Array, indices: Uint16Array}}
*/ */
export function frustumVolumeGeometryData(positions) { export function frustumVolumeGeometryData(positions) {
const pts = [positions.mount, positions.tl, positions.tr, positions.br, positions.bl]; const pts = [
positions.mount,
positions.tl,
positions.tr,
positions.br,
positions.bl,
];
const flat = new Float64Array(15); const flat = new Float64Array(15);
pts.forEach((p, i) => { pts.forEach((p, i) => {
flat[i * 3] = p.x; flat[i * 3] = p.x;
@@ -75,12 +81,7 @@ export function frustumVolumeGeometryData(positions) {
}); });
// apex=0, tl=1, tr=2, br=3, bl=4 — 4 side faces + far cap (2 triangles). // apex=0, tl=1, tr=2, br=3, bl=4 — 4 side faces + far cap (2 triangles).
const indices = new Uint16Array([ const indices = new Uint16Array([
0, 1, 2, 0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1, 1, 2, 3, 1, 3, 4,
0, 2, 3,
0, 3, 4,
0, 4, 1,
1, 2, 3,
1, 3, 4,
]); ]);
return { positions: flat, indices }; return { positions: flat, indices };
} }
+1 -2
View File
@@ -21,7 +21,7 @@ export function nextCockpitNearContacts(
) { ) {
const previous = previousIds instanceof Set ? previousIds : new Set(); const previous = previousIds instanceof Set ? previousIds : new Set();
const addSq = Math.max(0, Number(addDistanceM) || 0) ** 2; const addSq = Math.max(0, Number(addDistanceM) || 0) ** 2;
const keepSq = Math.max(addSq, (Math.max(0, Number(keepDistanceM) || 0) ** 2)); const keepSq = Math.max(addSq, Math.max(0, Number(keepDistanceM) || 0) ** 2);
const next = new Set(); const next = new Set();
for (const [id, distanceSq] of distancesSquared || []) { for (const [id, distanceSq] of distancesSquared || []) {
@@ -32,4 +32,3 @@ export function nextCockpitNearContacts(
return next; return next;
} }
+27 -8
View File
@@ -42,7 +42,9 @@ export const CONTACT_MATCH_TIER = Object.freeze({
* @returns {string} Canonical form, or '' when there is nothing to compare. * @returns {string} Canonical form, or '' when there is nothing to compare.
*/ */
export function canonicalizeContactId(value) { export function canonicalizeContactId(value) {
return String(value ?? '').replace(/[^a-z0-9]/gi, '').toUpperCase(); return String(value ?? '')
.replace(/[^a-z0-9]/gi, '')
.toUpperCase();
} }
/** /**
@@ -57,16 +59,31 @@ export function canonicalizeContactId(value) {
* @param {string} [params.registration] Contact's registration, if any. * @param {string} [params.registration] Contact's registration, if any.
* @returns {number} A {@link CONTACT_MATCH_TIER} value; `NONE` when no match. * @returns {number} A {@link CONTACT_MATCH_TIER} value; `NONE` when no match.
*/ */
export function rankContactMatch({ query, hex = '', callsign = '', registration = '' }) { export function rankContactMatch({
const rawQuery = String(query ?? '').trim().toLowerCase(); query,
hex = '',
callsign = '',
registration = '',
}) {
const rawQuery = String(query ?? '')
.trim()
.toLowerCase();
if (!rawQuery) return CONTACT_MATCH_TIER.NONE; if (!rawQuery) return CONTACT_MATCH_TIER.NONE;
if (String(hex ?? '').trim().toLowerCase() === rawQuery) return CONTACT_MATCH_TIER.HEX_EXACT; if (
String(hex ?? '')
.trim()
.toLowerCase() === rawQuery
)
return CONTACT_MATCH_TIER.HEX_EXACT;
const callsignText = String(callsign ?? '').trim().toLowerCase(); const callsignText = String(callsign ?? '')
.trim()
.toLowerCase();
// Callsigns carry no separators, so they compare raw — canonicalizing them // Callsigns carry no separators, so they compare raw — canonicalizing them
// would silently widen the long-standing callsign contract. // would silently widen the long-standing callsign contract.
if (callsignText && callsignText === rawQuery) return CONTACT_MATCH_TIER.CALLSIGN_EXACT; if (callsignText && callsignText === rawQuery)
return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
const canonicalQuery = canonicalizeContactId(rawQuery); const canonicalQuery = canonicalizeContactId(rawQuery);
const canonicalRegistration = canonicalizeContactId(registration); const canonicalRegistration = canonicalizeContactId(registration);
@@ -74,11 +91,13 @@ export function rankContactMatch({ query, hex = '', callsign = '', registration
return CONTACT_MATCH_TIER.REGISTRATION_EXACT; return CONTACT_MATCH_TIER.REGISTRATION_EXACT;
} }
if (callsignText && callsignText.startsWith(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_PREFIX; if (callsignText && callsignText.startsWith(rawQuery))
return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
if (canonicalQuery && canonicalRegistration.startsWith(canonicalQuery)) { if (canonicalQuery && canonicalRegistration.startsWith(canonicalQuery)) {
return CONTACT_MATCH_TIER.REGISTRATION_PREFIX; return CONTACT_MATCH_TIER.REGISTRATION_PREFIX;
} }
if (callsignText && callsignText.includes(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING; if (callsignText && callsignText.includes(rawQuery))
return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
if (canonicalQuery && canonicalRegistration.includes(canonicalQuery)) { if (canonicalQuery && canonicalRegistration.includes(canonicalQuery)) {
return CONTACT_MATCH_TIER.REGISTRATION_SUBSTRING; return CONTACT_MATCH_TIER.REGISTRATION_SUBSTRING;
} }
+23 -11
View File
@@ -48,7 +48,9 @@ export function selectEntityContext(entity) {
store.selectedEntityId = contextId; store.selectedEntityId = contextId;
store.selectedAt = Date.now(); store.selectedAt = Date.now();
const record = store.entities.get(contextId); const record = store.entities.get(contextId);
window.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: record })); window.dispatchEvent(
new CustomEvent('gev:entity-selected', { detail: record }),
);
return record; return record;
} }
@@ -78,7 +80,8 @@ export function selectTrackedSubjectContext(metadata) {
const store = getContextStore(); const store = getContextStore();
const id = String(metadata.id); const id = String(metadata.id);
for (const [key, record] of store.entities) { for (const [key, record] of store.entities) {
if (record?.layerId === metadata.layerId && key !== id) store.entities.delete(key); if (record?.layerId === metadata.layerId && key !== id)
store.entities.delete(key);
} }
// Reuse the existing carrier so a per-poll refresh does not churn identity. // Reuse the existing carrier so a per-poll refresh does not churn identity.
const carrier = store.entities.get(id)?.entity || { __gevContextId: id }; const carrier = store.entities.get(id)?.entity || { __gevContextId: id };
@@ -146,16 +149,21 @@ export function getSelectedEntityContext({ dataManager = null } = {}) {
* rather than being deselected. Readouts that stay on screen hold their * rather than being deselected. Readouts that stay on screen hold their
* last-known values for an eviction and only tear down on a deliberate clear. * last-known values for an eviction and only tear down on a deliberate clear.
*/ */
export function clearSelectedEntityContextForLayer(layerId, { evicted = false } = {}) { export function clearSelectedEntityContextForLayer(
layerId,
{ evicted = false } = {},
) {
const store = getContextStore(); const store = getContextStore();
if (!store.selectedEntityId) return; if (!store.selectedEntityId) return;
const record = store.entities.get(store.selectedEntityId); const record = store.entities.get(store.selectedEntityId);
if (record?.layerId === layerId) { if (record?.layerId === layerId) {
store.selectedEntityId = null; store.selectedEntityId = null;
store.selectedAt = null; store.selectedAt = null;
window.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', { window.dispatchEvent(
detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' }, new CustomEvent('gev:entity-selection-cleared', {
})); detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' },
}),
);
} }
} }
@@ -166,16 +174,19 @@ export function clearSelectedEntityContextForLayer(layerId, { evicted = false }
export function removeEntityContextsForLayer(layerId, { retainIds } = {}) { 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(
detail: { layerId, reason: 'evicted' }, new CustomEvent('gev:entity-selection-cleared', {
})); detail: { layerId, reason: 'evicted' },
}),
);
} }
} }
@@ -183,6 +194,7 @@ export function isContextRecordActive(record, dataManager = null) {
if (!record) return false; if (!record) 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;
} }
+7 -7
View File
@@ -185,8 +185,7 @@ export const DATA_CREDITS = [
}, },
{ {
key: 'gtfs-rt', key: 'gtfs-rt',
html: html: 'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
}, },
{ {
key: 'radio-browser', key: 'radio-browser',
@@ -299,11 +298,12 @@ 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) =>
.replaceAll('&', '&amp;') String(text)
.replaceAll('<', '&lt;') .replaceAll('&', '&amp;')
.replaceAll('>', '&gt;') .replaceAll('<', '&lt;')
.replaceAll('"', '&quot;'); .replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
return { return {
key: `transit-${feed.id}`, key: `transit-${feed.id}`,
html: html:
+219 -70
View File
@@ -279,7 +279,10 @@ let _cockpitModeListener = null;
*/ */
export function initDetection(viewer, layers, onModeChange) { export function initDetection(viewer, layers, onModeChange) {
if (_cockpitModeListener && typeof window !== 'undefined') { if (_cockpitModeListener && typeof window !== 'undefined') {
window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener); window.removeEventListener(
'gev:cockpit-mode-changed',
_cockpitModeListener,
);
} }
_hostLane?.unregister?.(); _hostLane?.unregister?.();
_calloutLane?.unregister?.(); _calloutLane?.unregister?.();
@@ -323,7 +326,10 @@ export function initDetection(viewer, layers, onModeChange) {
/** Release the host lane and all retained detection runtime state. */ /** Release the host lane and all retained detection runtime state. */
export function destroyDetection() { export function destroyDetection() {
if (_cockpitModeListener && typeof window !== 'undefined') { if (_cockpitModeListener && typeof window !== 'undefined') {
window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener); window.removeEventListener(
'gev:cockpit-mode-changed',
_cockpitModeListener,
);
} }
_cockpitModeListener = null; _cockpitModeListener = null;
_cockpitActive = false; _cockpitActive = false;
@@ -397,7 +403,8 @@ export function setMode(modeLabel) {
_mode = MODE_OFF; _mode = MODE_OFF;
} else { } else {
const requestedDensity = defaultDensityForProfile(target); const requestedDensity = defaultDensityForProfile(target);
if (profileForDensity(_densityPct) !== target) _densityPct = requestedDensity; if (profileForDensity(_densityPct) !== target)
_densityPct = requestedDensity;
_mode = _modeForDensity(); _mode = _modeForDensity();
_lastNonOffMode = _mode; _lastNonOffMode = _mode;
} }
@@ -462,7 +469,10 @@ export function isDetectionSuspended() {
*/ */
export function setDetectionTuning(options = {}) { export function setDetectionTuning(options = {}) {
let modeChanged = false; let modeChanged = false;
if (typeof options.densityPct === 'number' && Number.isFinite(options.densityPct)) { if (
typeof options.densityPct === 'number' &&
Number.isFinite(options.densityPct)
) {
const nextDensity = canonicalizeDensity(options.densityPct); const nextDensity = canonicalizeDensity(options.densityPct);
if (nextDensity !== _densityPct) { if (nextDensity !== _densityPct) {
_densityPct = nextDensity; _densityPct = nextDensity;
@@ -477,7 +487,10 @@ export function setDetectionTuning(options = {}) {
} }
} }
if (options.allocationStrategy != null) { if (options.allocationStrategy != null) {
const nextStrategy = normalizeAllocationStrategy(options.allocationStrategy, _allocationStrategy); const nextStrategy = normalizeAllocationStrategy(
options.allocationStrategy,
_allocationStrategy,
);
if (nextStrategy !== _allocationStrategy) { if (nextStrategy !== _allocationStrategy) {
_allocationStrategy = nextStrategy; _allocationStrategy = nextStrategy;
_labelSolveDirty = true; _labelSolveDirty = true;
@@ -526,7 +539,9 @@ export function getDetectionDiagnostics() {
const result = JSON.parse(JSON.stringify(_lastDiagnostics)); const result = JSON.parse(JSON.stringify(_lastDiagnostics));
// Readback only: expose painted plates so pixel QA can exclude occluded // Readback only: expose painted plates so pixel QA can exclude occluded
// sprites/strokes. Nothing is copied on the production paint path. // sprites/strokes. Nothing is copied on the production paint path.
result.calloutRects = _calloutPool.slice(0, _calloutCount).map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha })); result.calloutRects = _calloutPool
.slice(0, _calloutCount)
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
return result; return result;
} }
@@ -535,19 +550,30 @@ function _publishDiagnostics() {
const dataset = _hostCanvas.dataset; const dataset = _hostCanvas.dataset;
dataset.profile = _lastDiagnostics.profile || 'OFF'; dataset.profile = _lastDiagnostics.profile || 'OFF';
dataset.densityPct = String(_lastDiagnostics.densityPct ?? _densityPct); dataset.densityPct = String(_lastDiagnostics.densityPct ?? _densityPct);
dataset.allocationStrategy = _lastDiagnostics.allocationStrategy || _allocationStrategy; dataset.allocationStrategy =
_lastDiagnostics.allocationStrategy || _allocationStrategy;
dataset.solveRevision = String(_lastDiagnostics.solveRevision ?? 0); dataset.solveRevision = String(_lastDiagnostics.solveRevision ?? 0);
dataset.collectiveLabelBudget = String(_lastDiagnostics.collectiveLabelBudget ?? 0); dataset.collectiveLabelBudget = String(
_lastDiagnostics.collectiveLabelBudget ?? 0,
);
dataset.labelsByLayer = JSON.stringify(_lastDiagnostics.labelsByLayer || {}); dataset.labelsByLayer = JSON.stringify(_lastDiagnostics.labelsByLayer || {});
dataset.entitlementByLayer = JSON.stringify(_lastDiagnostics.entitlementByLayer || {}); dataset.entitlementByLayer = JSON.stringify(
_lastDiagnostics.entitlementByLayer || {},
);
dataset.labeledKeys = JSON.stringify(_lastDiagnostics.labeledKeys || []); dataset.labeledKeys = JSON.stringify(_lastDiagnostics.labeledKeys || []);
dataset.labelAlphaByKey = JSON.stringify(_lastDiagnostics.labelAlphaByKey || {}); dataset.labelAlphaByKey = JSON.stringify(
dataset.bracketOpacityCounts = JSON.stringify(_lastDiagnostics.bracketOpacityCounts || {}); _lastDiagnostics.labelAlphaByKey || {},
);
dataset.bracketOpacityCounts = JSON.stringify(
_lastDiagnostics.bracketOpacityCounts || {},
);
dataset.keyholeRadius = String(_lastDiagnostics.keyholeRadius ?? 0); dataset.keyholeRadius = String(_lastDiagnostics.keyholeRadius ?? 0);
dataset.keyholeFeatherPx = String(_lastDiagnostics.keyholeFeatherPx ?? 0); dataset.keyholeFeatherPx = String(_lastDiagnostics.keyholeFeatherPx ?? 0);
dataset.demandByLayer = JSON.stringify(_lastDiagnostics.demandByLayer || {}); dataset.demandByLayer = JSON.stringify(_lastDiagnostics.demandByLayer || {});
dataset.cohortByLayer = JSON.stringify(_lastDiagnostics.cohortByLayer || {}); dataset.cohortByLayer = JSON.stringify(_lastDiagnostics.cohortByLayer || {});
dataset.placementBuildCount = String(_lastDiagnostics.placementBuildCount ?? 0); dataset.placementBuildCount = String(
_lastDiagnostics.placementBuildCount ?? 0,
);
dataset.observationCount = String(_lastDiagnostics.observationCount ?? 0); dataset.observationCount = String(_lastDiagnostics.observationCount ?? 0);
dataset.selectedCount = String(_lastDiagnostics.selectedCount ?? 0); dataset.selectedCount = String(_lastDiagnostics.selectedCount ?? 0);
dataset.fadingCount = String(_lastDiagnostics.fadingCount ?? 0); dataset.fadingCount = String(_lastDiagnostics.fadingCount ?? 0);
@@ -632,7 +658,11 @@ function _applyModeState() {
_labelArbiter.clear(); _labelArbiter.clear();
_lastLabelSolveAt = 0; _lastLabelSolveAt = 0;
_labelSolveDirty = true; _labelSolveDirty = true;
_lastSolveSnapshot = { demandByLayer: {}, cohortByLayer: {}, cohortCount: 0 }; _lastSolveSnapshot = {
demandByLayer: {},
cohortByLayer: {},
cohortCount: 0,
};
} else { } else {
_enableTime = _nowMs(); // restart the subtle fade-in on (re)activation _enableTime = _nowMs(); // restart the subtle fade-in on (re)activation
_syncSurfaceVisibility(); _syncSurfaceVisibility();
@@ -665,8 +695,10 @@ function _shouldPaintDetectionLane(frame) {
}); });
if (decision.skip) { if (decision.skip) {
_throttleSkipCount++; _throttleSkipCount++;
if (_lastDiagnostics) _lastDiagnostics.throttleSkipCount = _throttleSkipCount; if (_lastDiagnostics)
if (decision.requestFollowUp) governorRequestRender('detection-paint-skipped'); _lastDiagnostics.throttleSkipCount = _throttleSkipCount;
if (decision.requestFollowUp)
governorRequestRender('detection-paint-skipped');
return false; return false;
} }
return true; return true;
@@ -687,8 +719,13 @@ function _paintDetectionLane(frame) {
_labelSolveDirty = true; _labelSolveDirty = true;
} }
const start = performance.now(); const start = performance.now();
const result = _drawOverlay(frame) const result = _drawOverlay(frame) || {
|| { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false }; didSolve: false,
solveMs: 0,
fadingCount: 0,
animatingCount: 0,
solvePending: false,
};
_lastRenderMs = performance.now() - start; _lastRenderMs = performance.now() - start;
_lastSolveMs = result.solveMs || 0; _lastSolveMs = result.solveMs || 0;
_lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs); _lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs);
@@ -705,17 +742,19 @@ 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 (
active: _mode !== MODE_OFF && !_suspended, detectionNeedsFollowUpFrame({
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here active: _mode !== MODE_OFF && !_suspended,
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at // The frame's OWN timestamp, not a fresh sample — re-reading the clock here
// 220 ms, and the settled alpha never painted). // is what dropped the terminal frame of a fade (paint at 219 ms, policy at
nowMs: Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(), // 220 ms, and the settled alpha never painted).
enabledAtMs: _enableTime, nowMs: Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(),
fadeMs: FADE_MS, enabledAtMs: _enableTime,
animatingLabelCount: result.animatingCount || 0, fadeMs: FADE_MS,
solvePending: result.solvePending === true, animatingLabelCount: result.animatingCount || 0,
})) { 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(
entry.candidate.key, renderEntries.map((entry) => [
entry.temporalAlpha * keyholeLabelAlphaFromGeometry( entry.candidate.key,
entry.placement.centerX, entry.temporalAlpha *
entry.placement.centerY, keyholeLabelAlphaFromGeometry(
keyhole, entry.placement.centerX,
), entry.placement.centerY,
])), keyhole,
),
]),
),
solveRevision: _labelArbiter.solveRevision, solveRevision: _labelArbiter.solveRevision,
keyholeRadius: keyhole.radius, keyholeRadius: keyhole.radius,
keyholeFeatherPx: keyhole.featherPx, keyholeFeatherPx: keyhole.featherPx,
@@ -1451,5 +1594,11 @@ function _drawOverlay(frame) {
_drawModeBanner(visibleCount, sampledCount); _drawModeBanner(visibleCount, sampledCount);
// `_labelSolveDirty` surviving a paint means the solve was owed and did not // `_labelSolveDirty` surviving a paint means the solve was owed and did not
// run — the frame that carried the request cannot be the last one. // run — the frame that carried the request cannot be the last one.
return { didSolve, solveMs, fadingCount, animatingCount, solvePending: _labelSolveDirty }; return {
didSolve,
solveMs,
fadingCount,
animatingCount,
solvePending: _labelSolveDirty,
};
} }
+41 -12
View File
@@ -19,11 +19,14 @@ export function stableIdentityHash(layerId, sourceId) {
/** Negative means a is a better deterministic contender than b. */ /** Negative means a is a better deterministic contender than b. */
export function compareCohortContenders(a, b) { export function compareCohortContenders(a, b) {
const priorityDelta = (Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0); const priorityDelta =
(Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
if (priorityDelta) return priorityDelta; if (priorityDelta) return priorityDelta;
const bandDelta = (Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0); const bandDelta =
(Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
if (bandDelta) return bandDelta; if (bandDelta) return bandDelta;
const hashDelta = (Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0); const hashDelta =
(Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
if (hashDelta) return hashDelta; if (hashDelta) return hashDelta;
return String(a?._cohortSourceId).localeCompare(String(b?._cohortSourceId)); return String(a?._cohortSourceId).localeCompare(String(b?._cohortSourceId));
} }
@@ -39,8 +42,14 @@ function isWorse(a, b) {
*/ */
export class BoundedCohort { export class BoundedCohort {
constructor(maxSize = DEFAULT_MAX_COHORT, hardMax = DEFAULT_MAX_COHORT) { constructor(maxSize = DEFAULT_MAX_COHORT, hardMax = DEFAULT_MAX_COHORT) {
const ceiling = Math.max(1, Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT)); const ceiling = Math.max(
this.maxSize = Math.max(1, Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT))); 1,
Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT),
);
this.maxSize = Math.max(
1,
Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT)),
);
this.incumbents = []; this.incumbents = [];
this.heap = []; this.heap = [];
} }
@@ -62,11 +71,20 @@ export class BoundedCohort {
} }
values(limit = this.maxSize) { values(limit = this.maxSize) {
const cap = Math.max(0, Math.min(this.maxSize, Math.floor(Number(limit) || 0))); const cap = Math.max(
const incumbents = this.incumbents.slice().sort(compareCohortContenders).slice(0, cap); 0,
Math.min(this.maxSize, Math.floor(Number(limit) || 0)),
);
const incumbents = this.incumbents
.slice()
.sort(compareCohortContenders)
.slice(0, cap);
const remaining = Math.max(0, cap - incumbents.length); const remaining = Math.max(0, cap - incumbents.length);
if (remaining === 0) return incumbents; if (remaining === 0) return incumbents;
const contenders = this.heap.slice().sort(compareCohortContenders).slice(0, remaining); const contenders = this.heap
.slice()
.sort(compareCohortContenders)
.slice(0, remaining);
return incumbents.concat(contenders); return incumbents.concat(contenders);
} }
@@ -75,7 +93,10 @@ export class BoundedCohort {
while (current > 0) { while (current > 0) {
const parent = Math.floor((current - 1) / 2); const parent = Math.floor((current - 1) / 2);
if (!isWorse(this.heap[current], this.heap[parent])) break; if (!isWorse(this.heap[current], this.heap[parent])) break;
[this.heap[current], this.heap[parent]] = [this.heap[parent], this.heap[current]]; [this.heap[current], this.heap[parent]] = [
this.heap[parent],
this.heap[current],
];
current = parent; current = parent;
} }
} }
@@ -86,10 +107,18 @@ export class BoundedCohort {
const left = current * 2 + 1; const left = current * 2 + 1;
const right = left + 1; const right = left + 1;
let worst = current; let worst = current;
if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst])) worst = left; if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst]))
if (right < this.heap.length && isWorse(this.heap[right], this.heap[worst])) worst = right; worst = left;
if (
right < this.heap.length &&
isWorse(this.heap[right], this.heap[worst])
)
worst = right;
if (worst === current) return; if (worst === current) return;
[this.heap[current], this.heap[worst]] = [this.heap[worst], this.heap[current]]; [this.heap[current], this.heap[worst]] = [
this.heap[worst],
this.heap[current],
];
current = worst; current = worst;
} }
} }
+15 -8
View File
@@ -211,11 +211,14 @@ export function measureTrackLabel(primary, micro, charWidth) {
*/ */
export function rectIntersectsAny(rect, obstacles, padding = 0) { export function rectIntersectsAny(rect, obstacles, padding = 0) {
if (!rect || !Array.isArray(obstacles)) return false; if (!rect || !Array.isArray(obstacles)) return false;
return obstacles.some((obstacle) => obstacle return obstacles.some(
&& rect.x < obstacle.x + obstacle.w + padding (obstacle) =>
&& rect.x + rect.w + padding > obstacle.x obstacle &&
&& rect.y < obstacle.y + obstacle.h + padding rect.x < obstacle.x + obstacle.w + padding &&
&& rect.y + rect.h + padding > obstacle.y); rect.x + rect.w + padding > obstacle.x &&
rect.y < obstacle.y + obstacle.h + padding &&
rect.y + rect.h + padding > obstacle.y,
);
} }
/** /**
@@ -238,7 +241,6 @@ export function nearFarScale(distance, near, nearValue, far, farValue) {
return nearValue + t * (farValue - nearValue); return nearValue + t * (farValue - nearValue);
} }
/** Transit keeps literal mode colour on the normal-composite surface. */ /** Transit keeps literal mode colour on the normal-composite surface. */
export function paintTransitBracket(ctx, path, color, alpha = 1) { export function paintTransitBracket(ctx, path, color, alpha = 1) {
ctx.save?.(); ctx.save?.();
@@ -255,6 +257,11 @@ export function paintTransitBracket(ctx, path, color, alpha = 1) {
/** Centre 1.25 px strokes on pixels, so their cores are fully covered. */ /** Centre 1.25 px strokes on pixels, so their cores are fully covered. */
export function appendTransitBracket(sink, sx, sy, halfW, halfH) { export function appendTransitBracket(sink, sx, sy, halfW, halfH) {
appendCornerBracket(sink, Math.floor(sx) + 0.5, Math.floor(sy) + 0.5, appendCornerBracket(
Math.round(halfW), Math.round(halfH)); sink,
Math.floor(sx) + 0.5,
Math.floor(sy) + 0.5,
Math.round(halfW),
Math.round(halfH),
);
} }
+66 -20
View File
@@ -8,7 +8,10 @@
export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]); export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]);
export const ALLOCATION_ELASTIC = 'ELASTIC'; export const ALLOCATION_ELASTIC = 'ELASTIC';
export const ALLOCATION_WEIGHTED = 'WEIGHTED'; export const ALLOCATION_WEIGHTED = 'WEIGHTED';
export const ALLOCATION_STRATEGIES = Object.freeze([ALLOCATION_ELASTIC, ALLOCATION_WEIGHTED]); export const ALLOCATION_STRATEGIES = Object.freeze([
ALLOCATION_ELASTIC,
ALLOCATION_WEIGHTED,
]);
// The keyhole's default 1% outside opacity is appropriate for text, but it // The keyhole's default 1% outside opacity is appropriate for text, but it
// made side aircraft brackets effectively disappear while the same contacts // made side aircraft brackets effectively disappear while the same contacts
@@ -71,10 +74,16 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
: AIRCRAFT_BRACKET_FLOOR_ANCHOR; : AIRCRAFT_BRACKET_FLOOR_ANCHOR;
if (outside <= 0) return 0; if (outside <= 0) return 0;
if (outside <= AIRCRAFT_BRACKET_FLOOR_ANCHOR) { if (outside <= AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
return AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR); return (
AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR)
);
} }
const progress = (outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) / (1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR); const progress =
return AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress; (outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) /
(1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR);
return (
AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress
);
} }
/** /**
@@ -85,7 +94,11 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
* the default, so a caller that does not know it reproduces the shipped look. * the default, so a caller that does not know it reproduces the shipped look.
* @returns {number} Paint alpha, 0-1. * @returns {number} Paint alpha, 0-1.
*/ */
export function detectionBracketAlpha(type, keyholeAlpha, outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR) { export function detectionBracketAlpha(
type,
keyholeAlpha,
outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR,
) {
const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0)); const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0));
if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha; if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha;
return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha); return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha);
@@ -97,7 +110,7 @@ export function detectionHorizontalSector(screenX, viewportWidth) {
const x = Number(screenX); const x = Number(screenX);
if (!(width > 0) || !Number.isFinite(x)) return 'front'; if (!(width > 0) || !Number.isFinite(x)) return 'front';
if (x < width / 3) return 'left'; if (x < width / 3) return 'left';
if (x > width * 2 / 3) return 'right'; if (x > (width * 2) / 3) return 'right';
return 'front'; return 'front';
} }
@@ -140,19 +153,29 @@ export function defaultDensityForProfile(profile) {
/** Normalize current and legacy profile labels. OFF remains an enabled-state value. */ /** Normalize current and legacy profile labels. OFF remains an enabled-state value. */
export function normalizeProfile(profile) { export function normalizeProfile(profile) {
const raw = String(profile || '').trim().toUpperCase(); const raw = String(profile || '')
.trim()
.toUpperCase();
if (raw === 'OFF') return 'OFF'; if (raw === 'OFF') return 'OFF';
if (raw === 'SPARSE' || raw === 'SURVEY') return 'SPARSE'; if (raw === 'SPARSE' || raw === 'SURVEY') return 'SPARSE';
if (raw === 'BALANCED' || raw === 'NORMAL') return 'BALANCED'; if (raw === 'BALANCED' || raw === 'NORMAL') return 'BALANCED';
if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON') return 'DENSE'; if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON')
return 'DENSE';
return null; return null;
} }
/** Normalize the user-selectable layer allocation strategy. */ /** Normalize the user-selectable layer allocation strategy. */
export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELASTIC) { export function normalizeAllocationStrategy(
const raw = String(strategy || '').trim().toUpperCase(); strategy,
fallback = ALLOCATION_ELASTIC,
) {
const raw = String(strategy || '')
.trim()
.toUpperCase();
if (ALLOCATION_STRATEGIES.includes(raw)) return raw; if (ALLOCATION_STRATEGIES.includes(raw)) return raw;
const normalizedFallback = String(fallback || '').trim().toUpperCase(); const normalizedFallback = String(fallback || '')
.trim()
.toUpperCase();
return ALLOCATION_STRATEGIES.includes(normalizedFallback) return ALLOCATION_STRATEGIES.includes(normalizedFallback)
? normalizedFallback ? normalizedFallback
: ALLOCATION_ELASTIC; : ALLOCATION_ELASTIC;
@@ -160,7 +183,9 @@ export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELAS
/** Classify camera altitude into the shared label-budget view scale. */ /** Classify camera altitude into the shared label-budget view scale. */
export function viewScaleForAltitude(altitudeM) { export function viewScaleForAltitude(altitudeM) {
const altitude = Number.isFinite(Number(altitudeM)) ? Math.max(0, Number(altitudeM)) : 1e9; const altitude = Number.isFinite(Number(altitudeM))
? Math.max(0, Number(altitudeM))
: 1e9;
if (altitude < 1200) return 'street'; if (altitude < 1200) return 'street';
if (altitude < 4500) return 'city'; if (altitude < 4500) return 'city';
if (altitude < 20000) return 'metro'; if (altitude < 20000) return 'metro';
@@ -181,25 +206,46 @@ export function labelBudgetFor(altitudeM, densityPct) {
* density was below 75; legacy Sparse remains inside the Sparse band. * density was below 75; legacy Sparse remains inside the Sparse band.
*/ */
export function migrateDetectionState(mode, densityPct, fallbackDensity = 50) { export function migrateDetectionState(mode, densityPct, fallbackDensity = 50) {
const rawMode = String(mode || '').trim().toUpperCase(); const rawMode = String(mode || '')
.trim()
.toUpperCase();
const normalized = normalizeProfile(rawMode); const normalized = normalizeProfile(rawMode);
if (normalized === 'OFF') { if (normalized === 'OFF') {
const supplied = Number.isFinite(Number(densityPct)) ? densityPct : fallbackDensity; const supplied = Number.isFinite(Number(densityPct))
? densityPct
: fallbackDensity;
const stop = canonicalizeDensity(supplied, fallbackDensity); const stop = canonicalizeDensity(supplied, fallbackDensity);
return { enabled: false, profile: profileForDensity(stop), densityPct: stop }; return {
enabled: false,
profile: profileForDensity(stop),
densityPct: stop,
};
} }
if (rawMode === 'PANOPTIC' || rawMode === 'GOD' || rawMode === 'ON') { if (rawMode === 'PANOPTIC' || rawMode === 'GOD' || rawMode === 'ON') {
const density = Number.isFinite(Number(densityPct)) ? Math.max(75, Number(densityPct)) : 75; const density = Number.isFinite(Number(densityPct))
return { enabled: true, profile: 'DENSE', densityPct: canonicalizeDensity(density) }; ? Math.max(75, Number(densityPct))
: 75;
return {
enabled: true,
profile: 'DENSE',
densityPct: canonicalizeDensity(density),
};
} }
if (rawMode === 'SPARSE' || rawMode === 'SURVEY') { if (rawMode === 'SPARSE' || rawMode === 'SURVEY') {
const density = Number.isFinite(Number(densityPct)) ? Math.min(25, Number(densityPct)) : 25; const density = Number.isFinite(Number(densityPct))
? Math.min(25, Number(densityPct))
: 25;
const stop = canonicalizeDensity(density, 25); const stop = canonicalizeDensity(density, 25);
return { enabled: true, profile: 'SPARSE', densityPct: stop }; return { enabled: true, profile: 'SPARSE', densityPct: stop };
} }
if (normalized === 'BALANCED' || normalized === 'DENSE') { if (normalized === 'BALANCED' || normalized === 'DENSE') {
const supplied = Number.isFinite(Number(densityPct)) ? canonicalizeDensity(densityPct) : defaultDensityForProfile(normalized); const supplied = Number.isFinite(Number(densityPct))
const stop = profileForDensity(supplied) === normalized ? supplied : defaultDensityForProfile(normalized); ? canonicalizeDensity(densityPct)
: defaultDensityForProfile(normalized);
const stop =
profileForDensity(supplied) === normalized
? supplied
: defaultDensityForProfile(normalized);
return { enabled: true, profile: normalized, densityPct: stop }; return { enabled: true, profile: normalized, densityPct: stop };
} }
const stop = canonicalizeDensity(densityPct, fallbackDensity); const stop = canonicalizeDensity(densityPct, fallbackDensity);
-1
View File
@@ -10,4 +10,3 @@ export const COCKPIT_BRACKET_OPACITY = 0.45;
export function detectionBracketOpacity(cockpitActive) { export function detectionBracketOpacity(cockpitActive) {
return cockpitActive === true ? COCKPIT_BRACKET_OPACITY : 1; return cockpitActive === true ? COCKPIT_BRACKET_OPACITY : 1;
} }
+1 -1
View File
@@ -17,7 +17,7 @@ test('Cockpit reduces only the bracket presentation multiplier', () => {
test('detection owns and releases the Cockpit lifecycle listener', async () => { test('detection owns and releases the Cockpit lifecycle listener', async () => {
const source = await readFile(new URL('./detection.js', import.meta.url), 'utf8'); const source = await readFile(new URL('./detection.js', import.meta.url), 'utf8');
assert.match(source, /addEventListener\('gev:cockpit-mode-changed', _cockpitModeListener\)/); assert.match(source, /addEventListener\('gev:cockpit-mode-changed', _cockpitModeListener\)/);
assert.match(source, /removeEventListener\('gev:cockpit-mode-changed', _cockpitModeListener\)/); assert.match(source, /removeEventListener\(\s*'gev:cockpit-mode-changed',\s*_cockpitModeListener,?\s*\)/);
assert.match( assert.match(
source, source,
/fade \* entry\.alpha \* bracketPresentationOpacity/, /fade \* entry\.alpha \* bracketPresentationOpacity/,
+4 -3
View File
@@ -158,9 +158,10 @@ export function detectionPaintSkipDecision({
frameCount, frameCount,
thresholdMs = DETECTION_PAINT_SKIP_THRESHOLD_MS, thresholdMs = DETECTION_PAINT_SKIP_THRESHOLD_MS,
} = {}) { } = {}) {
const skip = !layoutChanged const skip =
&& Number(lastPaintMs) > thresholdMs !layoutChanged &&
&& Number(frameCount) % 2 !== 0; Number(lastPaintMs) > thresholdMs &&
Number(frameCount) % 2 !== 0;
return { skip, requestFollowUp: skip }; return { skip, requestFollowUp: skip };
} }
+4 -4
View File
@@ -277,7 +277,7 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
assert.match(source, /governorRequestRender\('detection-visibility'\)/, assert.match(source, /governorRequestRender\('detection-visibility'\)/,
'mode/suspend transitions request their own repaint'); 'mode/suspend transitions request their own repaint');
assert.match(source, /if \(detectionNeedsFollowUpFrame\(\{[\s\S]*?\}\)\) \{\s*\n\s*governorRequestRender\('detection-animation'\);/, assert.match(source, /if \(\s*detectionNeedsFollowUpFrame\(\{[\s\S]*?\}\)\s*\) \{\s*\n\s*governorRequestRender\('detection-animation'\);/,
'the follow-up frame is gated on the policy, never unconditional'); 'the follow-up frame is gated on the policy, never unconditional');
// The demand call must read the FRAME's timestamp, not a fresh sample. // The demand call must read the FRAME's timestamp, not a fresh sample.
@@ -306,7 +306,7 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
// the emptiest possible scene. (Caught by the governor gate at 301 renders/5 s // the emptiest possible scene. (Caught by the governor gate at 301 renders/5 s
// with zero layers; the fix settles the solve there, because with nothing // with zero layers; the fix settles the solve there, because with nothing
// detectable it is vacuously complete.) // detectable it is vacuously complete.)
const emptyExit = /\/\/ Drop the replay buffer with it[\s\S]*?return \{ didSolve: false[^\n]*\n/.exec(source)?.[0]; const emptyExit = /\/\/ Drop the replay buffer with it[\s\S]*?return \{\s*didSolve: false[\s\S]*?\};/.exec(source)?.[0];
assert.ok(emptyExit, 'the zero-objects exit is still identifiable'); assert.ok(emptyExit, 'the zero-objects exit is still identifiable');
assert.match(emptyExit, /_labelSolveDirty = false;/, assert.match(emptyExit, /_labelSolveDirty = false;/,
'the zero-objects exit settles the solve instead of carrying it forward'); 'the zero-objects exit settles the solve instead of carrying it forward');
@@ -318,7 +318,7 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
assert.ok(drawOverlay, 'detection.js still has a draw pass'); assert.ok(drawOverlay, 'detection.js still has a draw pass');
assert.doesNotMatch(drawOverlay, /Date\.now\(\)/, assert.doesNotMatch(drawOverlay, /Date\.now\(\)/,
'the draw pass must run on the frame timestamp, not a wall clock'); 'the draw pass must run on the frame timestamp, not a wall clock');
assert.match(drawOverlay, /const now = Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\);/); assert.match(drawOverlay, /const now =\s*Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\);/);
assert.match(source, /_enableTime = _nowMs\(\);/, assert.match(source, /_enableTime = _nowMs\(\);/,
'the enable stamp shares the monotonic clock the fade is measured against'); 'the enable stamp shares the monotonic clock the fade is measured against');
@@ -331,7 +331,7 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
'the valve asks the shared policy instead of inlining its own threshold'); 'the valve asks the shared policy instead of inlining its own threshold');
assert.doesNotMatch(shouldPaint, /_lastPaintMs > 22/, assert.doesNotMatch(shouldPaint, /_lastPaintMs > 22/,
'the old inline threshold must be gone, not shadowing the policy'); 'the old inline threshold must be gone, not shadowing the policy');
assert.match(shouldPaint, /if \(decision\.requestFollowUp\) governorRequestRender\('detection-paint-skipped'\)/, assert.match(shouldPaint, /if \(decision\.requestFollowUp\)\s*governorRequestRender\('detection-paint-skipped'\)/,
'skipping a paint must hand the request forward, not swallow it'); 'skipping a paint must hand the request forward, not swallow it');
// The scanline must not go back to the frame counter: that is what made a // The scanline must not go back to the frame counter: that is what made a
+3 -1
View File
@@ -24,7 +24,9 @@
* @returns {number} Heading in degrees [0..360), or NaN if unrecognized. * @returns {number} Heading in degrees [0..360), or NaN if unrecognized.
*/ */
export function directionToHeading(value, allowBare = false) { export function directionToHeading(value, allowBare = false) {
const text = String(value || '').trim().toUpperCase(); const text = String(value || '')
.trim()
.toUpperCase();
if (!text) return NaN; if (!text) return NaN;
// Explicit travel/intercardinal forms — safe on free-form text (a street // Explicit travel/intercardinal forms — safe on free-form text (a street
// name almost never contains "NORTHBOUND" or a lone "NB" token). // name almost never contains "NORTHBOUND" or a lone "NB" token).
+6 -2
View File
@@ -79,8 +79,12 @@ export function parseAcquisitionMs(date, time, cache = new Map()) {
const hhmm = String(time ?? '0000').padStart(4, '0'); const hhmm = String(time ?? '0000').padStart(4, '0');
const hours = Number(hhmm.slice(0, 2)); const hours = Number(hhmm.slice(0, 2));
const minutes = Number(hhmm.slice(2, 4)); const minutes = Number(hhmm.slice(2, 4));
const valid = Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day) const valid =
&& Number.isFinite(hours) && Number.isFinite(minutes); Number.isFinite(year) &&
Number.isFinite(month) &&
Number.isFinite(day) &&
Number.isFinite(hours) &&
Number.isFinite(minutes);
const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0; const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
cache.set(key, ms); cache.set(key, ms);
return ms; return ms;
+11 -5
View File
@@ -12,10 +12,14 @@ export const FIRMS_OVERLAY_SOURCE_ID = 'firms';
export function fireDetectionKey(fire) { export function fireDetectionKey(fire) {
const lat = Number.isFinite(fire?.lat) ? fire.lat.toFixed(4) : 'x'; const lat = Number.isFinite(fire?.lat) ? fire.lat.toFixed(4) : 'x';
const lon = Number.isFinite(fire?.lon) ? fire.lon.toFixed(4) : 'x'; const lon = Number.isFinite(fire?.lon) ? fire.lon.toFixed(4) : 'x';
const acq = Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? String(fire.acqMs) : '0'; const acq =
const source = satelliteShortName(fire?.satellite) Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? String(fire.acqMs) : '0';
|| String(fire?.sensor || '').trim().toUpperCase() const source =
|| 'x'; satelliteShortName(fire?.satellite) ||
String(fire?.sensor || '')
.trim()
.toUpperCase() ||
'x';
return `firms:${lat}:${lon}:${acq}:${source}`; return `firms:${lat}:${lon}:${acq}:${source}`;
} }
@@ -37,7 +41,9 @@ export function accentForSeverity(stopName) {
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */ /** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
export function satelliteShortName(satellite) { export function satelliteShortName(satellite) {
const s = String(satellite || '').trim().toUpperCase(); const s = String(satellite || '')
.trim()
.toUpperCase();
if (s === 'N20' || s === 'NOAA-20') return 'N20'; if (s === 'N20' || s === 'NOAA-20') return 'N20';
if (s === 'N21' || s === 'NOAA-21') return 'N21'; if (s === 'N21' || s === 'NOAA-21') return 'N21';
if (s === 'N' || s === 'NPP' || s === 'SUOMI NPP') return 'SNPP'; if (s === 'N' || s === 'NPP' || s === 'SUOMI NPP') return 'SNPP';
+29 -9
View File
@@ -45,7 +45,9 @@ export function median(values) {
if (!Array.isArray(values) || values.length === 0) return null; if (!Array.isArray(values) || values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b); const sorted = [...values].sort((a, b) => a - b);
const mid = sorted.length >> 1; const mid = sorted.length >> 1;
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; return sorted.length % 2 === 1
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
} }
/** Bearing (degrees, 0 = north, clockwise) of a projected dx/dy vector. */ /** Bearing (degrees, 0 = north, clockwise) of a projected dx/dy vector. */
@@ -98,13 +100,19 @@ export function matchFlowToRoads(roads, flowSegments) {
const roadCount = Array.isArray(roads) ? roads.length : 0; const roadCount = Array.isArray(roads) ? roads.length : 0;
const matches = new Array(roadCount).fill(null); const matches = new Array(roadCount).fill(null);
const empty = { matches, matchedCount: 0, candidateCount: 0 }; const empty = { matches, matchedCount: 0, candidateCount: 0 };
if (roadCount === 0 || !Array.isArray(flowSegments) || flowSegments.length === 0) { if (
roadCount === 0 ||
!Array.isArray(flowSegments) ||
flowSegments.length === 0
) {
return empty; return empty;
} }
// Local equirectangular projection anchored at the first flow coordinate — // Local equirectangular projection anchored at the first flow coordinate —
// over a ≤0.05° fetch box the distortion is negligible. // over a ≤0.05° fetch box the distortion is negligible.
const anchor = flowSegments.find((f) => Array.isArray(f?.coords) && f.coords.length >= 2); const anchor = flowSegments.find(
(f) => Array.isArray(f?.coords) && f.coords.length >= 2,
);
if (!anchor) return empty; if (!anchor) return empty;
const [refLon, refLat] = anchor.coords[0]; const [refLon, refLat] = anchor.coords[0];
const mPerDegLon = M_PER_DEG_LAT * Math.cos((refLat * Math.PI) / 180); const mPerDegLon = M_PER_DEG_LAT * Math.cos((refLat * Math.PI) / 180);
@@ -114,7 +122,8 @@ export function matchFlowToRoads(roads, flowSegments) {
// ── 1. Spatial hash of flow segments (midpoint-keyed 100 m cells) ── // ── 1. Spatial hash of flow segments (midpoint-keyed 100 m cells) ──
/** @type {Map<string, Array<{ax:number,ay:number,bx:number,by:number,bearing:number,level:number,closure:boolean}>>} */ /** @type {Map<string, Array<{ax:number,ay:number,bx:number,by:number,bearing:number,level:number,closure:boolean}>>} */
const grid = new Map(); const grid = new Map();
const cellOf = (x, y) => `${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`; const cellOf = (x, y) =>
`${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
for (const flow of flowSegments) { for (const flow of flowSegments) {
const coords = flow?.coords; const coords = flow?.coords;
@@ -173,7 +182,8 @@ export function matchFlowToRoads(roads, flowSegments) {
for (let i = 0; i < coords.length; i++) { for (let i = 0; i < coords.length; i++) {
xs[i] = projX(coords[i][0]); xs[i] = projX(coords[i][0]);
ys[i] = projY(coords[i][1]); ys[i] = projY(coords[i][1]);
if (i > 0) cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]); if (i > 0)
cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
} }
const totalLen = cum[coords.length - 1]; const totalLen = cum[coords.length - 1];
if (!(totalLen > 0)) continue; if (!(totalLen > 0)) continue;
@@ -188,10 +198,14 @@ export function matchFlowToRoads(roads, flowSegments) {
// cross-street flow lines meet at intersections. // cross-street flow lines meet at intersections.
const target = (totalLen * (s + 0.5)) / ROAD_SAMPLES; const target = (totalLen * (s + 0.5)) / ROAD_SAMPLES;
while (cursor < coords.length - 1 && cum[cursor] < target) cursor++; while (cursor < coords.length - 1 && cum[cursor] < target) cursor++;
const segT = (target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1); const segT =
(target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
const px = xs[cursor - 1] + (xs[cursor] - xs[cursor - 1]) * segT; const px = xs[cursor - 1] + (xs[cursor] - xs[cursor - 1]) * segT;
const py = ys[cursor - 1] + (ys[cursor] - ys[cursor - 1]) * segT; const py = ys[cursor - 1] + (ys[cursor] - ys[cursor - 1]) * segT;
const sampleBearing = bearingDeg(xs[cursor] - xs[cursor - 1], ys[cursor] - ys[cursor - 1]); const sampleBearing = bearingDeg(
xs[cursor] - xs[cursor - 1],
ys[cursor] - ys[cursor - 1],
);
// 3×3 cell probe around the sample. // 3×3 cell probe around the sample.
const cx = Math.floor(px / CELL_SIZE_M); const cx = Math.floor(px / CELL_SIZE_M);
@@ -206,7 +220,11 @@ export function matchFlowToRoads(roads, flowSegments) {
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by); const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
if (d2 > radius2) continue; if (d2 > radius2) continue;
hadCandidate = true; // within radius, bearing not yet checked hadCandidate = true; // within radius, bearing not yet checked
if (bearingDiffDeg(seg.bearing, sampleBearing) >= BEARING_TOLERANCE_DEG) continue; if (
bearingDiffDeg(seg.bearing, sampleBearing) >=
BEARING_TOLERANCE_DEG
)
continue;
if (d2 <= bestDist2) { if (d2 <= bestDist2) {
bestDist2 = d2; bestDist2 = d2;
best = seg; best = seg;
@@ -221,7 +239,9 @@ export function matchFlowToRoads(roads, flowSegments) {
} }
if (hadCandidate) candidateCount++; if (hadCandidate) candidateCount++;
if (matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)) { if (
matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)
) {
matches[r] = { level: median(matchedLevels), closure: matchedClosure }; matches[r] = { level: median(matchedLevels), closure: matchedClosure };
matchedCount++; matchedCount++;
} }
+114 -50
View File
@@ -59,24 +59,35 @@ function focusTargetEmphasisResolved(
previouslyFarther, previouslyFarther,
) { ) {
if (!target?.screenRect || !spriteScreenPosition) return 1; if (!target?.screenRect || !spriteScreenPosition) return 1;
if (!Number.isFinite(spriteScreenPosition.x) || !Number.isFinite(spriteScreenPosition.y)) return 1; if (
!Number.isFinite(spriteScreenPosition.x) ||
!Number.isFinite(spriteScreenPosition.y)
)
return 1;
// Published rects already contain their publication-time padding. The // Published rects already contain their publication-time padding. The
// delta keeps direct pure-function calls and runtime A/B overrides honest. // delta keeps direct pure-function calls and runtime A/B overrides honest.
// The ambient extent makes overlap mean visual overlap, not center-point // The ambient extent makes overlap mean visual overlap, not center-point
// containment: a class-sized icon whose edge intrudes into the focus rect // containment: a class-sized icon whose edge intrudes into the focus rect
// yields even when its center remains just outside it. // yields even when its center remains just outside it.
const paddingDelta = tuning.paddingPx - (Number.isFinite(target.paddingPx) ? target.paddingPx : 0); const paddingDelta =
tuning.paddingPx -
(Number.isFinite(target.paddingPx) ? target.paddingPx : 0);
const hysteresis = previouslyCompeting ? tuning.hysteresisPx : 0; const hysteresis = previouslyCompeting ? tuning.hysteresisPx : 0;
const expansionX = paddingDelta + hysteresis const expansionX =
+ Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0); paddingDelta +
const expansionY = paddingDelta + hysteresis hysteresis +
+ Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0); Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0);
const expansionY =
paddingDelta +
hysteresis +
Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0);
const rect = target.screenRect; const rect = target.screenRect;
const inside = spriteScreenPosition.x >= rect.left - expansionX const inside =
&& spriteScreenPosition.x <= rect.right + expansionX spriteScreenPosition.x >= rect.left - expansionX &&
&& spriteScreenPosition.y >= rect.top - expansionY spriteScreenPosition.x <= rect.right + expansionX &&
&& spriteScreenPosition.y <= rect.bottom + expansionY; spriteScreenPosition.y >= rect.top - expansionY &&
spriteScreenPosition.y <= rect.bottom + expansionY;
if (!inside) return 1; if (!inside) return 1;
const floor = clamp(tuning.dimFloor, 0.01, 1); const floor = clamp(tuning.dimFloor, 0.01, 1);
@@ -88,24 +99,33 @@ function focusTargetEmphasisResolved(
const fartherThreshold = Number.isFinite(target.cameraDistance) const fartherThreshold = Number.isFinite(target.cameraDistance)
? target.cameraDistance + (previouslyFarther ? -distanceBand : distanceBand) ? target.cameraDistance + (previouslyFarther ? -distanceBand : distanceBand)
: Number.NaN; : Number.NaN;
const farther = !Number.isFinite(spriteCameraDistance) const farther =
|| !Number.isFinite(fartherThreshold) !Number.isFinite(spriteCameraDistance) ||
|| spriteCameraDistance >= fartherThreshold; !Number.isFinite(fartherThreshold) ||
spriteCameraDistance >= fartherThreshold;
if (farther) return floor; if (farther) return floor;
if (tuning.nearerBehavior === 'partial') return floor + (1 - floor) * 0.5; if (tuning.nearerBehavior === 'partial') return floor + (1 - floor) * 0.5;
return 1; return 1;
} }
function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) { function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
const from = clamp(Number.isFinite(current) ? current : 1, tuning.dimFloor, 1); const from = clamp(
Number.isFinite(current) ? current : 1,
tuning.dimFloor,
1,
);
const to = clamp(Number.isFinite(target) ? target : 1, tuning.dimFloor, 1); const to = clamp(Number.isFinite(target) ? target : 1, tuning.dimFloor, 1);
if (Math.abs(to - from) < Number.EPSILON) return to; if (Math.abs(to - from) < Number.EPSILON) return to;
const duration = to < from ? tuning.attackMs : tuning.releaseMs; const duration = to < from ? tuning.attackMs : tuning.releaseMs;
if (duration <= 0) return to; if (duration <= 0) return to;
const t = clamp((Number.isFinite(elapsedMs) ? elapsedMs : 0) / duration, 0, 1); const t = clamp(
(Number.isFinite(elapsedMs) ? elapsedMs : 0) / duration,
0,
1,
);
// Cubic ease-out reaches the exact endpoint at the configured duration and // Cubic ease-out reaches the exact endpoint at the configured duration and
// stays continuous when a moving contact reverses direction mid-transition. // stays continuous when a moving contact reverses direction mid-transition.
const eased = 1 - ((1 - t) ** 3); const eased = 1 - (1 - t) ** 3;
return clamp(from + (to - from) * eased, tuning.dimFloor, 1); return clamp(from + (to - from) * eased, tuning.dimFloor, 1);
} }
@@ -118,18 +138,24 @@ function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
*/ */
export function setFocusDeemphasisParams(patch = {}) { export function setFocusDeemphasisParams(patch = {}) {
const next = { ..._params }; const next = { ..._params };
if (Number.isFinite(patch.paddingPx)) next.paddingPx = Math.max(0, patch.paddingPx); if (Number.isFinite(patch.paddingPx))
if (Number.isFinite(patch.dimFloor)) next.dimFloor = clamp(patch.dimFloor, 0.01, 1); next.paddingPx = Math.max(0, patch.paddingPx);
if (Number.isFinite(patch.dimFloor))
next.dimFloor = clamp(patch.dimFloor, 0.01, 1);
if (['allow', 'dim', 'partial'].includes(patch.nearerBehavior)) { if (['allow', 'dim', 'partial'].includes(patch.nearerBehavior)) {
next.nearerBehavior = patch.nearerBehavior; next.nearerBehavior = patch.nearerBehavior;
} }
if (Number.isFinite(patch.hysteresisPx)) next.hysteresisPx = Math.max(0, patch.hysteresisPx); if (Number.isFinite(patch.hysteresisPx))
next.hysteresisPx = Math.max(0, patch.hysteresisPx);
if (Number.isFinite(patch.distanceHysteresisRatio)) { if (Number.isFinite(patch.distanceHysteresisRatio)) {
next.distanceHysteresisRatio = clamp(patch.distanceHysteresisRatio, 0, 0.5); next.distanceHysteresisRatio = clamp(patch.distanceHysteresisRatio, 0, 0.5);
} }
if (Number.isFinite(patch.attackMs)) next.attackMs = Math.max(0, patch.attackMs); if (Number.isFinite(patch.attackMs))
if (Number.isFinite(patch.releaseMs)) next.releaseMs = Math.max(0, patch.releaseMs); next.attackMs = Math.max(0, patch.attackMs);
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon); if (Number.isFinite(patch.releaseMs))
next.releaseMs = Math.max(0, patch.releaseMs);
if (Number.isFinite(patch.writeEpsilon))
next.writeEpsilon = Math.max(0, patch.writeEpsilon);
_params = next; _params = next;
return { ..._params }; return { ..._params };
} }
@@ -171,11 +197,22 @@ export function publishFocusTargetFromCachedPosition({
heightPx = 24, heightPx = 24,
params, params,
}) { }) {
if (!ownerLayer || id === null || id === undefined || !scene || !camera || !displayPosition) { if (
!ownerLayer ||
id === null ||
id === undefined ||
!scene ||
!camera ||
!displayPosition
) {
clearFocusTarget(ownerLayer, id); clearFocusTarget(ownerLayer, id);
return null; return null;
} }
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(scene, displayPosition, _scratchScreen); const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
displayPosition,
_scratchScreen,
);
if (!screen || !Number.isFinite(screen.x) || !Number.isFinite(screen.y)) { if (!screen || !Number.isFinite(screen.x) || !Number.isFinite(screen.y)) {
clearFocusTarget(ownerLayer, id); clearFocusTarget(ownerLayer, id);
return null; return null;
@@ -195,12 +232,19 @@ export function publishFocusTargetFromCachedPosition({
bottom: screen.y + halfHeight + padding, bottom: screen.y + halfHeight + padding,
}, },
paddingPx: padding, paddingPx: padding,
cameraDistance: Cesium.Cartesian3.distance(camera.positionWC, displayPosition), cameraDistance: Cesium.Cartesian3.distance(
camera.positionWC,
displayPosition,
),
frameNumber: scene.frameState?.frameNumber ?? -1, frameNumber: scene.frameState?.frameNumber ?? -1,
}; };
if (appeared) { if (appeared) {
for (const listener of _focusAppearListeners) { for (const listener of _focusAppearListeners) {
try { listener(); } catch (e) { console.warn('[focus] appear listener error:', e); } try {
listener();
} catch (e) {
console.warn('[focus] appear listener error:', e);
}
} }
} }
return _focusTarget; return _focusTarget;
@@ -311,15 +355,18 @@ 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(
screenPosition, sprite,
cameraDistance, {
nowMs, screenPosition,
target = _focusTarget, cameraDistance,
params, nowMs,
spriteHalfWidthPx = 0, target = _focusTarget,
spriteHalfHeightPx = spriteHalfWidthPx, params,
}) { spriteHalfWidthPx = 0,
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,12 +479,18 @@ 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
: null; ? Cesium.SceneTransforms.worldToWindowCoordinates(
const cameraDistance = position && camera?.positionWC scene,
? Cesium.Cartesian3.distance(camera.positionWC, position) position,
: Number.NaN; _scratchScreen,
)
: null;
const cameraDistance =
position && camera?.positionWC
? Cesium.Cartesian3.distance(camera.positionWC, position)
: Number.NaN;
return advanceSpriteFocus(sprite, { return advanceSpriteFocus(sprite, {
screenPosition: screen, screenPosition: screen,
cameraDistance, cameraDistance,
@@ -451,8 +510,10 @@ export function advanceProjectedSpriteFocus(
* @returns {boolean} * @returns {boolean}
*/ */
export function focusPassIsNeeded(target, activeCount) { export function focusPassIsNeeded(target, activeCount) {
return target !== null && target !== undefined return (
|| (Number.isFinite(activeCount) && activeCount > 0); (target !== null && target !== undefined) ||
(Number.isFinite(activeCount) && activeCount > 0)
);
} }
/** /**
@@ -466,8 +527,9 @@ export function nearFarScalarValueAtDistance(scalar, cameraDistanceM) {
if (!scalar || !Number.isFinite(cameraDistanceM)) return 1; if (!scalar || !Number.isFinite(cameraDistanceM)) return 1;
const nearSq = scalar.near * scalar.near; const nearSq = scalar.near * scalar.near;
const farSq = scalar.far * scalar.far; const farSq = scalar.far * scalar.far;
if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq) return 1; if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq)
const rawT = ((cameraDistanceM * cameraDistanceM) - nearSq) / (farSq - nearSq); return 1;
const rawT = (cameraDistanceM * cameraDistanceM - nearSq) / (farSq - nearSq);
const t = Math.pow(clamp(rawT, 0, 1), 0.2); const t = Math.pow(clamp(rawT, 0, 1), 0.2);
return scalar.nearValue + (scalar.farValue - scalar.nearValue) * t; return scalar.nearValue + (scalar.farValue - scalar.nearValue) * t;
} }
@@ -507,6 +569,8 @@ export function forgetSpriteFocus(sprite) {
*/ */
export function focusAlphaNeedsWrite(currentAlpha, nextAlpha, params) { export function focusAlphaNeedsWrite(currentAlpha, nextAlpha, params) {
const tuning = resolvedParams(params); const tuning = resolvedParams(params);
return !Number.isFinite(currentAlpha) return (
|| Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon; !Number.isFinite(currentAlpha) ||
Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon
);
} }
+1 -1
View File
@@ -51,7 +51,7 @@ export function geoidHeight(latDeg, lonDeg) {
if (!egm96Module) { if (!egm96Module) {
throw new Error( throw new Error(
'geoid.js: geoidHeight() called before ensureGeoidReady() resolved — ' + 'geoid.js: geoidHeight() called before ensureGeoidReady() resolved — ' +
'await ensureGeoidReady() first.' 'await ensureGeoidReady() first.',
); );
} }
return egm96Module.meanSeaLevel(latDeg, lonDeg); return egm96Module.meanSeaLevel(latDeg, lonDeg);
+29 -1
View File
@@ -1,4 +1,32 @@
import { createGroundFloor } from '../services/groundFloor.js'; import { createGroundFloor } from '../services/groundFloor.js';
import * as terrain from './terrainHeights.js'; import * as terrain from './terrainHeights.js';
const groundFloor = createGroundFloor({ terrain }); const groundFloor = createGroundFloor({ terrain });
export const { GROUND_FLOOR_LIFT_M, MESH_FLOOR_BELOW_PRIOR_M, MESH_FLOOR_ABOVE_PRIOR_M, coarseFloorCoord, floorAltitudeM, displayFloorHeightM, FLOOR_CELL_DEG, NEIGHBOR_FLOOR_MIN_SAMPLES, neighborFloorM, CORRIDOR_MAX_CELLS, CORRIDOR_WALK_STEP_DEG, CELL_HYSTERESIS_DEG, stickyFloorCell, corridorFloorCells, allocateCorridorCells, setMeshFloorPreferred, meshFloorPreferred, reportMeshFloorCell, meshFloorSampleWithinPrior, reportValidatedMeshFloorCell, cachedMeshFloor, _clearMeshFloorCellsForTest, cachedGroundFloor, FLOOR_RESOLVE_DEADLINE_MS, resolveGroundFloorCellsBounded, resolveGroundFloorCells, warmGroundFloor } = groundFloor; export const {
GROUND_FLOOR_LIFT_M,
MESH_FLOOR_BELOW_PRIOR_M,
MESH_FLOOR_ABOVE_PRIOR_M,
coarseFloorCoord,
floorAltitudeM,
displayFloorHeightM,
FLOOR_CELL_DEG,
NEIGHBOR_FLOOR_MIN_SAMPLES,
neighborFloorM,
CORRIDOR_MAX_CELLS,
CORRIDOR_WALK_STEP_DEG,
CELL_HYSTERESIS_DEG,
stickyFloorCell,
corridorFloorCells,
allocateCorridorCells,
setMeshFloorPreferred,
meshFloorPreferred,
reportMeshFloorCell,
meshFloorSampleWithinPrior,
reportValidatedMeshFloorCell,
cachedMeshFloor,
_clearMeshFloorCellsForTest,
cachedGroundFloor,
FLOOR_RESOLVE_DEADLINE_MS,
resolveGroundFloorCellsBounded,
resolveGroundFloorCells,
warmGroundFloor,
} = groundFloor;
+3 -1
View File
@@ -1,4 +1,6 @@
import { createGroundSnap as createSnap } from '../services/groundSnap.js'; import { createGroundSnap as createSnap } from '../services/groundSnap.js';
import * as groundFloor from './groundFloor.js'; import * as groundFloor from './groundFloor.js';
/** Compatibility constructor using the default floor cache. */ /** Compatibility constructor using the default floor cache. */
export function createGroundSnap() { return createSnap({ groundFloor }); } export function createGroundSnap() {
return createSnap({ groundFloor });
}
+25 -6
View File
@@ -29,8 +29,14 @@ export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
} }
export function findNextIssPass({ export function findNextIssPass({
satrec, latDeg, lonDeg, fromMs, satrec,
minElevDeg = 10, horizonHours = 24, coarseStepSec = 30, fineStepSec = 5, latDeg,
lonDeg,
fromMs,
minElevDeg = 10,
horizonHours = 24,
coarseStepSec = 30,
fineStepSec = 5,
}) { }) {
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90; const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
const horizonMs = fromMs + horizonHours * 3600_000; const horizonMs = fromMs + horizonHours * 3600_000;
@@ -41,13 +47,17 @@ export function findNextIssPass({
// pass, that's still "the next pass" for a voice answer — accept it. // pass, that's still "the next pass" for a voice answer — accept it.
let hit = null; let hit = null;
for (let t = fromMs; t <= horizonMs; t += coarse) { for (let t = fromMs; t <= horizonMs; t += coarse) {
if (elev(t) >= minElevDeg) { hit = t; break; } if (elev(t) >= minElevDeg) {
hit = t;
break;
}
} }
if (hit == null) return null; if (hit == null) return null;
// Refine rise: walk back in fine steps to the first sample ≥ threshold. // Refine rise: walk back in fine steps to the first sample ≥ threshold.
let riseMs = hit; let riseMs = hit;
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg) riseMs -= fine; while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg)
riseMs -= fine;
// Walk forward through the pass tracking the peak until we drop below. // Walk forward through the pass tracking the peak until we drop below.
let maxElevDeg = -90; let maxElevDeg = -90;
@@ -56,11 +66,20 @@ export function findNextIssPass({
while (t <= horizonMs) { while (t <= horizonMs) {
const e = elev(t); const e = elev(t);
if (e < minElevDeg && t > riseMs) break; if (e < minElevDeg && t > riseMs) break;
if (e > maxElevDeg) { maxElevDeg = e; maxElevMs = t; } if (e > maxElevDeg) {
maxElevDeg = e;
maxElevMs = t;
}
t += fine; t += fine;
} }
const setMs = t; const setMs = t;
const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg); const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
return { riseMs, setMs, maxElevDeg, maxElevMs, riseAzDeg: rise ? rise.azDeg : 0 }; return {
riseMs,
setMs,
maxElevDeg,
maxElevMs,
riseAzDeg: rise ? rise.azDeg : 0,
};
} }
+175 -72
View File
@@ -16,11 +16,15 @@ function clampInt(value, min, max) {
} }
function demandEntries(demandByLayer) { function demandEntries(demandByLayer) {
const entries = demandByLayer instanceof Map const entries =
? Array.from(demandByLayer.entries()) demandByLayer instanceof Map
: Object.entries(demandByLayer || {}); ? Array.from(demandByLayer.entries())
: 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,25 +1106,28 @@ 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, ? {
capacity, strategy,
selectedCount, capacity,
quotas: Object.fromEntries(quotas), selectedCount,
demand: Object.fromEntries(demand), quotas: Object.fromEntries(quotas),
labelsByLayer, demand: Object.fromEntries(demand),
solveRevision: this.solveRevision, labelsByLayer,
spatialQueueBuildCount, solveRevision: this.solveRevision,
spatialQueueNextCount, spatialQueueBuildCount,
eligibleIncumbents, spatialQueueNextCount,
retainedIncumbents, eligibleIncumbents,
} : null; retainedIncumbents,
}
: 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 currentCandidates instanceof Map
: new Map((currentCandidates || []).map((candidate) => [candidate.key, candidate])); ? currentCandidates
: 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;
+438 -179
View File
File diff suppressed because it is too large. Load diff
+8 -3
View File
@@ -11,8 +11,10 @@ export * from './localGeojsonCore.js';
export function createLocalGeoJsonLayer(options) { export function createLocalGeoJsonLayer(options) {
return createLayer(options, { return createLayer(options, {
...localGeoJsonServices, ...localGeoJsonServices,
overlayHost: options.overlayHost === undefined overlayHost:
? localGeoJsonServices.overlayHost : options.overlayHost, options.overlayHost === undefined
? localGeoJsonServices.overlayHost
: options.overlayHost,
}); });
} }
@@ -20,6 +22,9 @@ export function createLocalGeoJsonLayer(options) {
export function createLocalInfrastructureOverlayPublisher(options) { export function createLocalInfrastructureOverlayPublisher(options) {
return createPublisher({ return createPublisher({
...options, ...options,
host: options.host === undefined ? localGeoJsonServices.overlayHost : options.host, host:
options.host === undefined
? localGeoJsonServices.overlayHost
: options.host,
}); });
} }
+1 -6
View File
@@ -15,9 +15,4 @@ const fires = createFirmsHeatmapLayer({
source: 'NASA FIRMS · LIVE', source: 'NASA FIRMS · LIVE',
}); });
export default [ export default [datacenters, dams, submarineCablesLayer, fires];
datacenters,
dams,
submarineCablesLayer,
fires,
];
+4 -1
View File
@@ -1,4 +1,7 @@
import { createMeshFloorSampler } from '../services/meshFloorSampler.js'; import { createMeshFloorSampler } from '../services/meshFloorSampler.js';
import * as groundFloor from './groundFloor.js'; import * as groundFloor from './groundFloor.js';
const meshFloorSampler = createMeshFloorSampler({ groundFloor, eventTarget: typeof window === 'undefined' ? null : window }); const meshFloorSampler = createMeshFloorSampler({
groundFloor,
eventTarget: typeof window === 'undefined' ? null : window,
});
export const { sampleMeshFloorCells, cachedGroundFloor } = meshFloorSampler; export const { sampleMeshFloorCells, cachedGroundFloor } = meshFloorSampler;
+73 -18
View File
@@ -18,10 +18,13 @@ export const AWARENESS_RELATIONSHIP = Object.freeze({
* @param {{initialRadiusM?: number, maxRadiusM?: number}} [options] Bounds. * @param {{initialRadiusM?: number, maxRadiusM?: number}} [options] Bounds.
* @returns {{candidate: T, radiusM: number}|null} First match and radius used. * @returns {{candidate: T, radiusM: number}|null} First match and radius used.
*/ */
export function findByDoublingRadius(search, { export function findByDoublingRadius(
initialRadiusM = AWARENESS_RADIUS_M, search,
maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M, {
} = {}) { initialRadiusM = AWARENESS_RADIUS_M,
maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
} = {},
) {
if (typeof search !== 'function') return null; if (typeof search !== 'function') return null;
if (!Number.isFinite(initialRadiusM) || initialRadiusM <= 0) return null; if (!Number.isFinite(initialRadiusM) || initialRadiusM <= 0) return null;
if (!Number.isFinite(maxRadiusM) || maxRadiusM < initialRadiusM) return null; if (!Number.isFinite(maxRadiusM) || maxRadiusM < initialRadiusM) return null;
@@ -39,13 +42,22 @@ export function findByDoublingRadius(search, {
/** @param {number} meters @returns {string} */ /** @param {number} meters @returns {string} */
export function formatAwarenessDistance(meters) { export function formatAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—'; if (!Number.isFinite(meters) || meters < 0) return '—';
return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`; return meters < 1000
? `${Math.round(meters)} m`
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
} }
/** Return a stable visible label without leaking missing values into UI text. */ /** Return a stable visible label without leaking missing values into UI text. */
export function formatAwarenessLabel(value) { export function formatAwarenessLabel(value) {
if (value && typeof value === 'object') { if (value && typeof value === 'object') {
for (const candidate of [value.callsign, value.name, value.label, value.id, value.icao24, value.mmsi]) { for (const candidate of [
value.callsign,
value.name,
value.label,
value.id,
value.icao24,
value.mmsi,
]) {
const label = formatAwarenessLabel(candidate); const label = formatAwarenessLabel(candidate);
if (label !== '—') return label; if (label !== '—') return label;
} }
@@ -61,19 +73,41 @@ export function formatAwarenessLabel(value) {
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range * negative finding. `UNKNOWN` is an input-data state, never an out-of-range
* claim in this MVP. * claim in this MVP.
*/ */
export function summarizeAwarenessCohort(items, { available = true, stale = false, limit = 3 } = {}) { export function summarizeAwarenessCohort(
if (!available) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed unavailable' }; items,
if (stale) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed stale' }; { available = true, stale = false, limit = 3 } = {},
) {
if (!available)
return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: 'feed unavailable',
};
if (stale)
return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: 'feed stale',
};
const normalized = (Array.isArray(items) ? items : []) const normalized = (Array.isArray(items) ? items : [])
.filter((item) => Number.isFinite(item?.distanceM ?? item?.distance) .filter(
&& (item.distanceM ?? item.distance) >= 0) (item) =>
Number.isFinite(item?.distanceM ?? item?.distance) &&
(item.distanceM ?? item.distance) >= 0,
)
.map((item) => ({ ...item, distanceM: item.distanceM ?? item.distance })) .map((item) => ({ ...item, distanceM: item.distanceM ?? item.distance }))
.sort((a, b) => a.distanceM - b.distanceM); .sort((a, b) => a.distanceM - b.distanceM);
return { return {
relationship: normalized.length ? AWARENESS_RELATIONSHIP.NEARBY : AWARENESS_RELATIONSHIP.UNKNOWN, relationship: normalized.length
? AWARENESS_RELATIONSHIP.NEARBY
: AWARENESS_RELATIONSHIP.UNKNOWN,
count: normalized.length, count: normalized.length,
nearest: normalized.slice(0, limit), nearest: normalized.slice(0, limit),
reason: normalized.length ? 'observed or mapped nearby context' : 'no observed or mapped objects in current feeds', reason: normalized.length
? 'observed or mapped nearby context'
: 'no observed or mapped objects in current feeds',
}; };
} }
@@ -84,21 +118,40 @@ export function summarizeAwarenessCohort(items, { available = true, stale = fals
* @param {Iterable<string>} [visitedKeys] Previously visited layer/id keys. * @param {Iterable<string>} [visitedKeys] Previously visited layer/id keys.
* @returns {Array<{layerId: string, id: string, item: Object}>} Selectable targets. * @returns {Array<{layerId: string, id: string, item: Object}>} Selectable targets.
*/ */
export function getAwarenessNavigationTargets(cohorts, subject = null, visitedKeys = []) { export function getAwarenessNavigationTargets(
cohorts,
subject = null,
visitedKeys = [],
) {
const visited = new Set(visitedKeys); const visited = new Set(visitedKeys);
const targets = []; const targets = [];
for (const cohort of Array.isArray(cohorts) ? cohorts : []) { for (const cohort of Array.isArray(cohorts) ? cohorts : []) {
const layerId = cohort?.id; const layerId = cohort?.id;
if (!['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(layerId)) continue; if (
![
'flights',
'military',
'ais-live-vessels',
'military-installations',
].includes(layerId)
)
continue;
const sourceItems = Array.isArray(cohort?.summary?.navigationNearest) const sourceItems = Array.isArray(cohort?.summary?.navigationNearest)
? cohort.summary.navigationNearest ? cohort.summary.navigationNearest
: (Array.isArray(cohort?.summary?.nearest) ? cohort.summary.nearest : []); : Array.isArray(cohort?.summary?.nearest)
? cohort.summary.nearest
: [];
for (const item of sourceItems) { for (const item of sourceItems) {
const id = item?.icao24 || item?.mmsi || item?.id; const id = item?.icao24 || item?.mmsi || item?.id;
if (!id) continue; if (!id) continue;
const key = `${layerId}:${id}`; const key = `${layerId}:${id}`;
if (key === `${subject?.layerId}:${subject?.id}`) continue; if (key === `${subject?.layerId}:${subject?.id}`) continue;
targets.push({ layerId, id: String(id), item, visited: visited.has(key) }); targets.push({
layerId,
id: String(id),
item,
visited: visited.has(key),
});
} }
} }
targets.sort((a, b) => { targets.sort((a, b) => {
@@ -108,7 +161,9 @@ export function getAwarenessNavigationTargets(cohorts, subject = null, visitedKe
// reached vessels, installations, or the other flight layer at all. // reached vessels, installations, or the other flight layer at all.
const visitedOrder = Number(a.visited) - Number(b.visited); const visitedOrder = Number(a.visited) - Number(b.visited);
if (visitedOrder) return visitedOrder; if (visitedOrder) return visitedOrder;
const subjectOrder = Number(a.layerId !== subject?.layerId) - Number(b.layerId !== subject?.layerId); const subjectOrder =
Number(a.layerId !== subject?.layerId) -
Number(b.layerId !== subject?.layerId);
if (subjectOrder) return subjectOrder; if (subjectOrder) return subjectOrder;
return (a.item.distanceM ?? Infinity) - (b.item.distanceM ?? Infinity); return (a.item.distanceM ?? Infinity) - (b.item.distanceM ?? Infinity);
}); });
+57 -18
View File
@@ -37,10 +37,14 @@ const MAX_FOOTPRINT_POINTS = 400;
* @returns {string} Display label, never empty. * @returns {string} Display label, never empty.
*/ */
export function humanizeInstallationClass(klass) { export function humanizeInstallationClass(klass) {
const key = String(klass || '').trim().toLowerCase(); const key = String(klass || '')
.trim()
.toLowerCase();
if (LABEL_BY_CLASS[key]) return LABEL_BY_CLASS[key]; if (LABEL_BY_CLASS[key]) return LABEL_BY_CLASS[key];
const words = key.replaceAll('_', ' ').trim(); const words = key.replaceAll('_', ' ').trim();
return words ? `${words[0].toUpperCase()}${words.slice(1)}` : 'Mapped installation'; return words
? `${words[0].toUpperCase()}${words.slice(1)}`
: 'Mapped installation';
} }
function finiteLatitude(value) { function finiteLatitude(value) {
@@ -54,7 +58,8 @@ function finiteLongitude(value) {
function pointFrom(element) { function pointFrom(element) {
const lat = Number(element?.lat ?? element?.center?.lat); const lat = Number(element?.lat ?? element?.center?.lat);
const longitude = Number(element?.lon ?? element?.center?.lon); const longitude = Number(element?.lon ?? element?.center?.lon);
if (finiteLatitude(lat) && finiteLongitude(longitude)) return { latitude: lat, longitude }; if (finiteLatitude(lat) && finiteLongitude(longitude))
return { latitude: lat, longitude };
// Bounds midpoint fallback (field test 2026-08-28, Warendorf: nothing drawn). // Bounds midpoint fallback (field test 2026-08-28, Warendorf: nothing drawn).
// The proxy asks for `out center tags geom`, but Overpass takes the LAST // The proxy asks for `out center tags geom`, but Overpass takes the LAST
@@ -67,8 +72,12 @@ function pointFrom(element) {
// recovers all of them without touching the query or the footprint path. // recovers all of them without touching the query or the footprint path.
const bounds = element?.bounds; const bounds = element?.bounds;
// JSON nulls, booleans, and empty strings must not coerce to a false 0,0 site. // JSON nulls, booleans, and empty strings must not coerce to a false 0,0 site.
if (![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon] if (
.every((value) => typeof value === 'number' && Number.isFinite(value))) return null; ![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon].every(
(value) => typeof value === 'number' && Number.isFinite(value),
)
)
return null;
const south = Number(bounds?.minlat); const south = Number(bounds?.minlat);
const west = Number(bounds?.minlon); const west = Number(bounds?.minlon);
const north = Number(bounds?.maxlat); const north = Number(bounds?.maxlat);
@@ -79,17 +88,28 @@ function pointFrom(element) {
// A single installation footprint is never wider than the request bbox cap // A single installation footprint is never wider than the request bbox cap
// (10°): an ascending box that spans the antimeridian (minlon -179, maxlon // (10°): an ascending box that spans the antimeridian (minlon -179, maxlon
// 179) passes the ordering check yet midpoints to longitude 0. // 179) passes the ordering check yet midpoints to longitude 0.
if (finiteLatitude(south) && finiteLatitude(north) if (
&& finiteLongitude(west) && finiteLongitude(east) finiteLatitude(south) &&
&& south <= north && west <= east finiteLatitude(north) &&
&& north - south <= 10 && east - west <= 10) { finiteLongitude(west) &&
finiteLongitude(east) &&
south <= north &&
west <= east &&
north - south <= 10 &&
east - west <= 10
) {
return { latitude: (south + north) / 2, longitude: (west + east) / 2 }; return { latitude: (south + north) / 2, longitude: (west + east) / 2 };
} }
return null; return null;
} }
function footprintFrom(element) { function footprintFrom(element) {
if (!Array.isArray(element?.geometry) || element.geometry.length < 3 || element.geometry.length > MAX_FOOTPRINT_POINTS) return null; if (
!Array.isArray(element?.geometry) ||
element.geometry.length < 3 ||
element.geometry.length > MAX_FOOTPRINT_POINTS
)
return null;
const points = []; const points = [];
for (const point of element.geometry) { for (const point of element.geometry) {
const latitude = Number(point?.lat); const latitude = Number(point?.lat);
@@ -106,21 +126,30 @@ function footprintFrom(element) {
* @param {string} [retrievedAt] * @param {string} [retrievedAt]
* @returns {{records: Array, droppedCount: number}} * @returns {{records: Array, droppedCount: number}}
*/ */
export function normalizeMilitaryInstallations(payload, retrievedAt = new Date().toISOString()) { export function normalizeMilitaryInstallations(
payload,
retrievedAt = new Date().toISOString(),
) {
const records = []; const records = [];
const ids = new Set(); const ids = new Set();
let droppedCount = 0; let droppedCount = 0;
for (const element of Array.isArray(payload?.elements) ? payload.elements : []) { for (const element of Array.isArray(payload?.elements)
? payload.elements
: []) {
const type = String(element?.type || ''); const type = String(element?.type || '');
const osmId = Number(element?.id); const osmId = Number(element?.id);
if (!['node', 'way', 'relation'].includes(type) || !Number.isSafeInteger(osmId)) { if (
!['node', 'way', 'relation'].includes(type) ||
!Number.isSafeInteger(osmId)
) {
droppedCount += 1; droppedCount += 1;
continue; continue;
} }
const id = `osm:${type}:${osmId}`; const id = `osm:${type}:${osmId}`;
const tags = element?.tags || {}; const tags = element?.tags || {};
const klass = CLASS_BY_MILITARY_TAG[String(tags.military || '').toLowerCase()] const klass =
|| (tags.landuse === 'military' ? 'military_land' : null); CLASS_BY_MILITARY_TAG[String(tags.military || '').toLowerCase()] ||
(tags.landuse === 'military' ? 'military_land' : null);
const point = pointFrom(element); const point = pointFrom(element);
if (!klass || !point || ids.has(id)) { if (!klass || !point || ids.has(id)) {
droppedCount += 1; droppedCount += 1;
@@ -134,7 +163,9 @@ export function normalizeMilitaryInstallations(payload, retrievedAt = new Date()
// geometry, while a footprint-less way/relation has unknown extent. // geometry, while a footprint-less way/relation has unknown extent.
osmType: type, osmType: type,
class: klass, class: klass,
name: String(tags.name || tags['name:en'] || '').trim() || humanizeInstallationClass(klass), name:
String(tags.name || tags['name:en'] || '').trim() ||
humanizeInstallationClass(klass),
...point, ...point,
footprint: footprintFrom(element), footprint: footprintFrom(element),
sources: [{ name: 'OpenStreetMap', id: `${type}/${osmId}`, retrievedAt }], sources: [{ name: 'OpenStreetMap', id: `${type}/${osmId}`, retrievedAt }],
@@ -152,6 +183,14 @@ export function isValidInstallationBoundingBox(value) {
const west = Number(box.west); const west = Number(box.west);
const north = Number(box.north); const north = Number(box.north);
const east = Number(box.east); const east = Number(box.east);
return finiteLatitude(south) && finiteLatitude(north) && finiteLongitude(west) && finiteLongitude(east) return (
&& south < north && west < east && north - south <= 10 && east - west <= 10; finiteLatitude(south) &&
finiteLatitude(north) &&
finiteLongitude(west) &&
finiteLongitude(east) &&
south < north &&
west < east &&
north - south <= 10 &&
east - west <= 10
);
} }
+33 -7
View File
@@ -77,7 +77,9 @@ export const MODEL_TRAIL_ANCHOR_NATIVE = Object.freeze({
* trail anchor has to ride the same chain or it lands on a different axis than * trail anchor has to ride the same chain or it lands on a different axis than
* the aircraft it is supposed to be attached to. */ * the aircraft it is supposed to be attached to. */
const AXIS_CORRECTION = Cesium.Matrix4.multiplyTransformation( const AXIS_CORRECTION = Cesium.Matrix4.multiplyTransformation(
Cesium.Axis.Y_UP_TO_Z_UP, Cesium.Axis.Z_UP_TO_X_UP, new Cesium.Matrix4(), Cesium.Axis.Y_UP_TO_Z_UP,
Cesium.Axis.Z_UP_TO_X_UP,
new Cesium.Matrix4(),
); );
const _anchorScratch = new Cesium.Cartesian3(); const _anchorScratch = new Cesium.Cartesian3();
const _chainScratch = new Cesium.Matrix4(); const _chainScratch = new Cesium.Matrix4();
@@ -124,7 +126,11 @@ export function modelAnchorWorld(model, nativeAnchor, result) {
components || Cesium.Matrix4.IDENTITY, components || Cesium.Matrix4.IDENTITY,
_chainScratch, _chainScratch,
); );
Cesium.Matrix4.multiplyTransformation(_chainScratch, AXIS_CORRECTION, _chainScratch); Cesium.Matrix4.multiplyTransformation(
_chainScratch,
AXIS_CORRECTION,
_chainScratch,
);
return Cesium.Matrix4.multiplyByPoint(_chainScratch, _anchorScratch, result); return Cesium.Matrix4.multiplyByPoint(_chainScratch, _anchorScratch, result);
} }
@@ -220,9 +226,17 @@ export function trailHeadStart(start, anchor, center, radiusM, result) {
// a real trail. // a real trail.
if (!anchor || !center || !result) return start; if (!anchor || !center || !result) return start;
if (!Number.isFinite(radiusM) || radiusM <= 0) return start; if (!Number.isFinite(radiusM) || radiusM <= 0) return start;
const startD = Math.hypot(start.x - center.x, start.y - center.y, start.z - center.z); const startD = Math.hypot(
start.x - center.x,
start.y - center.y,
start.z - center.z,
);
if (startD >= radiusM) return start; if (startD >= radiusM) return start;
const anchorD = Math.hypot(anchor.x - center.x, anchor.y - center.y, anchor.z - center.z); const anchorD = Math.hypot(
anchor.x - center.x,
anchor.y - center.y,
anchor.z - center.z,
);
// A degenerate shell (an anchor at or outside the envelope — no shipped asset // A degenerate shell (an anchor at or outside the envelope — no shipped asset
// has one, since every anchor is a measured hull point) leaves nothing to // has one, since every anchor is a measured hull point) leaves nothing to
// ramp across; fall back to the containment verdict rather than divide by it. // ramp across; fall back to the containment verdict rather than divide by it.
@@ -252,8 +266,20 @@ export function modelVisualAnchor(modelMatrix, nativeCenter, scale, result) {
const x = (nativeCenter?.[0] || 0) * safeScale; const x = (nativeCenter?.[0] || 0) * safeScale;
const y = (nativeCenter?.[1] || 0) * safeScale; const y = (nativeCenter?.[1] || 0) * safeScale;
const z = (nativeCenter?.[2] || 0) * safeScale; const z = (nativeCenter?.[2] || 0) * safeScale;
result.x = modelMatrix[0] * x + modelMatrix[4] * y + modelMatrix[8] * z + modelMatrix[12]; result.x =
result.y = modelMatrix[1] * x + modelMatrix[5] * y + modelMatrix[9] * z + modelMatrix[13]; modelMatrix[0] * x +
result.z = modelMatrix[2] * x + modelMatrix[6] * y + modelMatrix[10] * z + modelMatrix[14]; modelMatrix[4] * y +
modelMatrix[8] * z +
modelMatrix[12];
result.y =
modelMatrix[1] * x +
modelMatrix[5] * y +
modelMatrix[9] * z +
modelMatrix[13];
result.z =
modelMatrix[2] * x +
modelMatrix[6] * y +
modelMatrix[10] * z +
modelMatrix[14];
return result; return result;
} }
+102 -27
View File
@@ -29,10 +29,14 @@ export function norm360(deg) {
export function liftRepeatedGroundFix(newest, candidatePosition, onGround) { export function liftRepeatedGroundFix(newest, candidatePosition, onGround) {
if (!onGround || !newest?.position || !candidatePosition) return false; if (!onGround || !newest?.position || !candidatePosition) return false;
const stored = Cesium.Cartographic.fromCartesian( const stored = Cesium.Cartographic.fromCartesian(
newest.position, Cesium.Ellipsoid.WGS84, _mmCarto, newest.position,
Cesium.Ellipsoid.WGS84,
_mmCarto,
); );
const candidate = Cesium.Cartographic.fromCartesian( const candidate = Cesium.Cartographic.fromCartesian(
candidatePosition, Cesium.Ellipsoid.WGS84, _mmCandidateCarto, candidatePosition,
Cesium.Ellipsoid.WGS84,
_mmCandidateCarto,
); );
if (!stored || !candidate || candidate.height <= stored.height) return false; if (!stored || !candidate || candidate.height <= stored.height) return false;
Cesium.Cartesian3.fromRadians( Cesium.Cartesian3.fromRadians(
@@ -62,7 +66,11 @@ export function courseBetweenCartesians(from, to, minChordM = 25) {
const dy = to.y - from.y; const dy = to.y - from.y;
const dz = to.z - from.z; const dz = to.z - from.z;
if (dx * dx + dy * dy + dz * dz < minChordM * minChordM) return null; if (dx * dx + dy * dy + dz * dz < minChordM * minChordM) return null;
const carto = Cesium.Cartographic.fromCartesian(from, Cesium.Ellipsoid.WGS84, _mmCarto); const carto = Cesium.Cartographic.fromCartesian(
from,
Cesium.Ellipsoid.WGS84,
_mmCarto,
);
if (!carto) return null; if (!carto) return null;
const sLat = Math.sin(carto.latitude); const sLat = Math.sin(carto.latitude);
const cLat = Math.cos(carto.latitude); const cLat = Math.cos(carto.latitude);
@@ -110,7 +118,10 @@ export function speedRamp(speedMps) {
if (!Number.isFinite(speedMps)) return 1; if (!Number.isFinite(speedMps)) return 1;
if (speedMps <= COURSE_TRACK_ONLY_MPS) return 0; if (speedMps <= COURSE_TRACK_ONLY_MPS) return 0;
if (speedMps >= COURSE_CHORD_ONLY_MPS) return 1; if (speedMps >= COURSE_CHORD_ONLY_MPS) return 1;
return (speedMps - COURSE_TRACK_ONLY_MPS) / (COURSE_CHORD_ONLY_MPS - COURSE_TRACK_ONLY_MPS); return (
(speedMps - COURSE_TRACK_ONLY_MPS) /
(COURSE_CHORD_ONLY_MPS - COURSE_TRACK_ONLY_MPS)
);
} }
/** Shortest-arc angle lerp: from → to by t (t=0 → from, t=1 → to). Also the /** Shortest-arc angle lerp: from → to by t (t=0 → from, t=1 → to). Also the
@@ -141,10 +152,16 @@ export function displayedKinematics({
reportedTrackDeg, reportedTrackDeg,
} = {}) { } = {}) {
return { return {
speedMps: Number.isFinite(derivedSpeedMps) ? Math.max(0, derivedSpeedMps) speedMps: Number.isFinite(derivedSpeedMps)
: (Number.isFinite(reportedSpeedMps) ? Math.max(0, reportedSpeedMps) : null), ? Math.max(0, derivedSpeedMps)
trackDeg: Number.isFinite(derivedTrackDeg) ? derivedTrackDeg : Number.isFinite(reportedSpeedMps)
: (Number.isFinite(reportedTrackDeg) ? reportedTrackDeg : null), ? Math.max(0, reportedSpeedMps)
: null,
trackDeg: Number.isFinite(derivedTrackDeg)
? derivedTrackDeg
: Number.isFinite(reportedTrackDeg)
? reportedTrackDeg
: null,
}; };
} }
@@ -167,10 +184,17 @@ export function staleCoastLimitSeconds({
maximumSec = 300, maximumSec = 300,
} = {}) { } = {}) {
const floor = Math.max(0, Number.isFinite(minimumSec) ? minimumSec : 60); const floor = Math.max(0, Number.isFinite(minimumSec) ? minimumSec : 60);
const ceiling = Math.max(floor, Number.isFinite(maximumSec) ? maximumSec : 300); const ceiling = Math.max(
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(lastContactEpochMs)) return floor; floor,
Number.isFinite(maximumSec) ? maximumSec : 300,
);
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(lastContactEpochMs))
return floor;
const contactLeadSec = Math.max(0, (lastContactEpochMs - fixEpochMs) / 1000); const contactLeadSec = Math.max(0, (lastContactEpochMs - fixEpochMs) / 1000);
const grace = Math.max(0, Number.isFinite(contactGraceSec) ? contactGraceSec : 60); const grace = Math.max(
0,
Number.isFinite(contactGraceSec) ? contactGraceSec : 60,
);
return Math.min(ceiling, Math.max(floor, contactLeadSec + grace)); return Math.min(ceiling, Math.max(floor, contactLeadSec + grace));
} }
@@ -197,18 +221,31 @@ export function limitCourseStep(prevDeg, targetDeg, maxDegPerSec, dtSec) {
* is GPS-vector noise, not a turn (samples without a speed keep the legacy * is GPS-vector noise, not a turn (samples without a speed keep the legacy
* behavior: no way to tell, so they still count). * behavior: no way to tell, so they still count).
*/ */
export function estimateTurnRateDps(samples, noiseFloorDps = 0.4, maxDps = 4, minSpeedMps = 0) { export function estimateTurnRateDps(
samples,
noiseFloorDps = 0.4,
maxDps = 4,
minSpeedMps = 0,
) {
if (!samples || samples.length < 2) return 0; if (!samples || samples.length < 2) return 0;
let sum = 0; let sum = 0;
let n = 0; let n = 0;
for (let i = 1; i < samples.length; i++) { for (let i = 1; i < samples.length; i++) {
const dt = samples[i].tSec - samples[i - 1].tSec; const dt = samples[i].tSec - samples[i - 1].tSec;
if (dt < 2 || dt > 120) continue; if (dt < 2 || dt > 120) continue;
if (!Number.isFinite(samples[i].trackDeg) || !Number.isFinite(samples[i - 1].trackDeg)) continue; if (
!Number.isFinite(samples[i].trackDeg) ||
!Number.isFinite(samples[i - 1].trackDeg)
)
continue;
if (minSpeedMps > 0) { if (minSpeedMps > 0) {
const v0 = samples[i - 1].speedMps; const v0 = samples[i - 1].speedMps;
const v1 = samples[i].speedMps; const v1 = samples[i].speedMps;
if ((Number.isFinite(v0) && v0 < minSpeedMps) || (Number.isFinite(v1) && v1 < minSpeedMps)) continue; if (
(Number.isFinite(v0) && v0 < minSpeedMps) ||
(Number.isFinite(v1) && v1 < minSpeedMps)
)
continue;
} }
sum += norm180(samples[i].trackDeg - samples[i - 1].trackDeg) / dt; sum += norm180(samples[i].trackDeg - samples[i - 1].trackDeg) / dt;
n += 1; n += 1;
@@ -274,13 +311,31 @@ const _scratchProjectArc = { east: 0, north: 0, endCourseDeg: 0 };
* @param {number} dtSec - Seconds ahead; 0 or negative returns the start. * @param {number} dtSec - Seconds ahead; 0 or negative returns the start.
* @returns {{lat: number, lon: number}} Projected coordinate, degrees. * @returns {{lat: number, lon: number}} Projected coordinate, degrees.
*/ */
export function projectGroundArcLatLon(lat, lon, courseDeg, speedMps, turnRateDps, dtSec) { export function projectGroundArcLatLon(
lat,
lon,
courseDeg,
speedMps,
turnRateDps,
dtSec,
) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return { lat, lon }; if (!Number.isFinite(lat) || !Number.isFinite(lon)) return { lat, lon };
if (!Number.isFinite(courseDeg) || !Number.isFinite(speedMps) || !Number.isFinite(dtSec) if (
|| speedMps <= 0 || dtSec <= 0) { !Number.isFinite(courseDeg) ||
!Number.isFinite(speedMps) ||
!Number.isFinite(dtSec) ||
speedMps <= 0 ||
dtSec <= 0
) {
return { lat, lon }; return { lat, lon };
} }
const off = arcOffsetEnu(speedMps, courseDeg, turnRateDps || 0, dtSec, _scratchProjectArc); const off = arcOffsetEnu(
speedMps,
courseDeg,
turnRateDps || 0,
dtSec,
_scratchProjectArc,
);
// Guard the pole: cos(lat) → 0 would send the longitude to infinity. // Guard the pole: cos(lat) → 0 would send the longitude to infinity.
const cosLat = Math.max(Math.cos(lat * DEG), 1e-6); const cosLat = Math.max(Math.cos(lat * DEG), 1e-6);
return { return {
@@ -320,8 +375,15 @@ export function projectGroundArcLatLon(lat, lon, courseDeg, speedMps, turnRateDp
* @returns {Array<{lat: number, lon: number}>} Polyline, display position first. * @returns {Array<{lat: number, lon: number}>} Polyline, display position first.
*/ */
export function corridorPathLatLon({ export function corridorPathLatLon({
extrapolating, displayLat, displayLon, courseDeg, speedMps, turnRateDps = 0, extrapolating,
fixLat, fixLon, lookaheadSec, displayLat,
displayLon,
courseDeg,
speedMps,
turnRateDps = 0,
fixLat,
fixLon,
lookaheadSec,
}) { }) {
if (!Number.isFinite(displayLat) || !Number.isFinite(displayLon)) return []; if (!Number.isFinite(displayLat) || !Number.isFinite(displayLon)) return [];
const start = { lat: displayLat, lon: displayLon }; const start = { lat: displayLat, lon: displayLon };
@@ -338,9 +400,16 @@ export function corridorPathLatLon({
const n = Math.max(1, Math.ceil(arcLengthM / CORRIDOR_SAMPLE_SPACING_M)); const n = Math.max(1, Math.ceil(arcLengthM / CORRIDOR_SAMPLE_SPACING_M));
const out = [start]; const out = [start];
for (let i = 1; i <= n; i++) { for (let i = 1; i <= n; i++) {
out.push(projectGroundArcLatLon( out.push(
displayLat, displayLon, courseDeg, speed, turnRateDps, cappedSec * (i / n), projectGroundArcLatLon(
)); displayLat,
displayLon,
courseDeg,
speed,
turnRateDps,
cappedSec * (i / n),
),
);
} }
return out; return out;
} }
@@ -385,13 +454,17 @@ const _forwardFixOffset = new Cesium.Cartesian3();
* velocity: number, track: number}|null} Forward synthetic fix, or null. * velocity: number, track: number}|null} Forward synthetic fix, or null.
*/ */
export function synthesizeForwardKinematicsFix(newest, next = {}) { export function synthesizeForwardKinematicsFix(newest, next = {}) {
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs)) return null; if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs))
return null;
const startEpochMs = Number.isFinite(newest.epochMs) const startEpochMs = Number.isFinite(newest.epochMs)
? newest.epochMs ? newest.epochMs
: Cesium.JulianDate.toDate(newest.time).getTime(); : Cesium.JulianDate.toDate(newest.time).getTime();
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs) return null; if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs)
return null;
const previousVelocity = Number.isFinite(newest.velocity) ? Math.max(0, newest.velocity) : 0; const previousVelocity = Number.isFinite(newest.velocity)
? Math.max(0, newest.velocity)
: 0;
const previousTrack = Number.isFinite(newest.track) ? newest.track : 0; const previousTrack = Number.isFinite(newest.track) ? newest.track : 0;
const dtSec = (next.epochMs - startEpochMs) / 1000; const dtSec = (next.epochMs - startEpochMs) / 1000;
arcOffsetEnu( arcOffsetEnu(
@@ -421,7 +494,9 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
time: Cesium.JulianDate.fromDate(new Date(next.epochMs)), time: Cesium.JulianDate.fromDate(new Date(next.epochMs)),
epochMs: next.epochMs, epochMs: next.epochMs,
position, position,
velocity: Number.isFinite(next.velocity) ? Math.max(0, next.velocity) : previousVelocity, velocity: Number.isFinite(next.velocity)
? Math.max(0, next.velocity)
: previousVelocity,
track: Number.isFinite(next.track) ? next.track : previousTrack, track: Number.isFinite(next.track) ? next.track : previousTrack,
}; };
} }
+3 -2
View File
@@ -32,7 +32,8 @@ export function resolvePickId(picked) {
if (id === null || id === undefined) return undefined; if (id === null || id === undefined) return undefined;
if (typeof id === 'object') { if (typeof id === 'object') {
// AIS vessel record (id object with .mmsi) or Cesium Entity (.id string) // AIS vessel record (id object with .mmsi) or Cesium Entity (.id string)
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number') return id.mmsi; if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number')
return id.mmsi;
if (typeof id.id === 'string' || typeof id.id === 'number') return id.id; if (typeof id.id === 'string' || typeof id.id === 'number') return id.id;
return undefined; return undefined;
} }
@@ -40,7 +41,7 @@ export function resolvePickId(picked) {
}; };
let id = unwrap(picked.id); let id = unwrap(picked.id);
if (id === undefined) id = unwrap(picked.primitive?.id); if (id === undefined) id = unwrap(picked.primitive?.id);
return (typeof id === 'string' || typeof id === 'number') ? String(id) : null; return typeof id === 'string' || typeof id === 'number' ? String(id) : null;
} }
/** /**
+22 -13
View File
@@ -1,6 +1,7 @@
const RADIO_COUNTRY_MAX_LENGTH = 80; const RADIO_COUNTRY_MAX_LENGTH = 80;
const ISO_ALPHA_2_CODES = Object.freeze(` const ISO_ALPHA_2_CODES = Object.freeze(
`
AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR
GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO
@@ -8,12 +9,16 @@ JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME
MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO 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 =
? new Intl.DisplayNames(['en'], { type: 'region' }) typeof Intl?.DisplayNames === 'function'
: null; ? new Intl.DisplayNames(['en'], { type: 'region' })
: null;
function countryKey(value) { function countryKey(value) {
return String(value || '') return String(value || '')
@@ -29,7 +34,8 @@ function countryKey(value) {
const COUNTRY_NAME_TO_CODE = new Map(); const COUNTRY_NAME_TO_CODE = new Map();
for (const code of ISO_ALPHA_2_CODES) { for (const code of ISO_ALPHA_2_CODES) {
const displayName = ENGLISH_REGION_NAMES?.of(code); const displayName = ENGLISH_REGION_NAMES?.of(code);
if (displayName && displayName !== code) COUNTRY_NAME_TO_CODE.set(countryKey(displayName), code); if (displayName && displayName !== code)
COUNTRY_NAME_TO_CODE.set(countryKey(displayName), code);
} }
for (const [name, code] of Object.entries({ for (const [name, code] of Object.entries({
@@ -71,7 +77,8 @@ for (const [name, code] of Object.entries({
'east timor': 'TL', 'east timor': 'TL',
'cabo verde': 'CV', 'cabo verde': 'CV',
vatican: 'VA', vatican: 'VA',
})) COUNTRY_NAME_TO_CODE.set(countryKey(name), code); }))
COUNTRY_NAME_TO_CODE.set(countryKey(name), code);
function canonicalCountryName(code) { function canonicalCountryName(code) {
return ENGLISH_REGION_NAMES?.of(code) || code; return ENGLISH_REGION_NAMES?.of(code) || code;
@@ -90,17 +97,19 @@ export function normalizeRadioCountryInput(value) {
} }
const trimmed = value.normalize('NFKC').trim().replace(/\s+/g, ' '); const trimmed = value.normalize('NFKC').trim().replace(/\s+/g, ' ');
if ( if (
!trimmed !trimmed ||
|| trimmed.length > RADIO_COUNTRY_MAX_LENGTH trimmed.length > RADIO_COUNTRY_MAX_LENGTH ||
|| /[\u0000-\u001f\u007f]/.test(trimmed) /[\u0000-\u001f\u007f]/.test(trimmed) ||
|| !/^[\p{L}\p{M}.&'’()\-\s]+$/u.test(trimmed) !/^[\p{L}\p{M}.&'’()\-\s]+$/u.test(trimmed)
) return Object.freeze({ valid: false, empty: false, code: '', name: '' }); )
return Object.freeze({ valid: false, empty: false, code: '', name: '' });
const possibleCode = trimmed.toUpperCase(); const possibleCode = trimmed.toUpperCase();
const code = ISO_ALPHA_2_SET.has(possibleCode) const code = ISO_ALPHA_2_SET.has(possibleCode)
? possibleCode ? possibleCode
: COUNTRY_NAME_TO_CODE.get(countryKey(trimmed)) || ''; : COUNTRY_NAME_TO_CODE.get(countryKey(trimmed)) || '';
if (!code) return Object.freeze({ valid: false, empty: false, code: '', name: '' }); if (!code)
return Object.freeze({ valid: false, empty: false, code: '', name: '' });
return Object.freeze({ return Object.freeze({
valid: true, valid: true,
empty: false, empty: false,
+2 -1
View File
@@ -2,6 +2,7 @@ import { applicationServices } from '../services/application.js';
export * from './regionalModel.js'; export * from './regionalModel.js';
/** Fetch a bounded regional brief through the same-origin dev/preview proxy. */ /** Fetch a bounded regional brief through the same-origin dev/preview proxy. */
export async function fetchRegionalBrief(latitude, longitude, { signal } = {}) { export async function fetchRegionalBrief(latitude, longitude, { signal } = {}) {
if (![latitude, longitude].every(Number.isFinite)) throw new Error('Valid coordinates are required'); if (![latitude, longitude].every(Number.isFinite))
throw new Error('Valid coordinates are required');
return applicationServices.regional.getBrief(latitude, longitude, { signal }); return applicationServices.regional.getBrief(latitude, longitude, { signal });
} }
+52 -20
View File
@@ -1,13 +1,18 @@
const MAX_ARTICLES = 5; const MAX_ARTICLES = 5;
function cleanText(value, maxLength = 180) { function cleanText(value, maxLength = 180) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxLength); return String(value || '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLength);
} }
function safeHttpUrl(value) { function safeHttpUrl(value) {
try { try {
const parsed = new URL(String(value || '')); const parsed = new URL(String(value || ''));
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null; return parsed.protocol === 'http:' || parsed.protocol === 'https:'
? parsed.href
: null;
} catch { } catch {
return null; return null;
} }
@@ -17,14 +22,27 @@ function safeHttpUrl(value) {
export function normalizeRegionalPlace(payload) { export function normalizeRegionalPlace(payload) {
const address = payload?.address || {}; const address = payload?.address || {};
const locality = cleanText( const locality = cleanText(
address.city || address.town || address.village || address.municipality address.city ||
|| address.hamlet || address.county, address.town ||
address.village ||
address.municipality ||
address.hamlet ||
address.county,
90,
);
const region = cleanText(
address.state || address.region || address.county,
90, 90,
); );
const region = cleanText(address.state || address.region || address.county, 90);
const country = cleanText(address.country, 90); const country = cleanText(address.country, 90);
const label = [locality, region].filter((value, index, values) => value && values.indexOf(value) === index) const label =
.join(', ') || country || cleanText(payload?.display_name, 120); [locality, region]
.filter(
(value, index, values) => value && values.indexOf(value) === index,
)
.join(', ') ||
country ||
cleanText(payload?.display_name, 120);
if (!label) return null; if (!label) return null;
return { return {
label, label,
@@ -51,11 +69,16 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate); const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate);
const publishedAt = compactDate const publishedAt = compactDate
? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z` ? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z`
: Number.isNaN(Date.parse(rawDate)) ? null : new Date(rawDate).toISOString(); : Number.isNaN(Date.parse(rawDate))
? null
: new Date(rawDate).toISOString();
articles.push({ articles.push({
title, title,
url, url,
domain: cleanText(row?.domain || new URL(url).hostname.replace(/^www\./, ''), 80), domain: cleanText(
row?.domain || new URL(url).hostname.replace(/^www\./, ''),
80,
),
publishedAt, publishedAt,
sourceCountry: cleanText(row?.sourcecountry, 60) || null, sourceCountry: cleanText(row?.sourcecountry, 60) || null,
}); });
@@ -74,11 +97,15 @@ export function normalizeRegionalWeather(payload) {
}; };
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC, // Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly. // but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
const observedRaw = typeof current.time === 'string' && !/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time) const observedRaw =
? `${current.time}Z` typeof current.time === 'string' &&
: current.time; !/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time)
? `${current.time}Z`
: current.time;
return { return {
observedAt: Number.isNaN(Date.parse(observedRaw)) ? null : new Date(observedRaw).toISOString(), observedAt: Number.isNaN(Date.parse(observedRaw))
? null
: new Date(observedRaw).toISOString(),
temperatureC: numberOrNull(current.temperature_2m), temperatureC: numberOrNull(current.temperature_2m),
apparentTemperatureC: numberOrNull(current.apparent_temperature), apparentTemperatureC: numberOrNull(current.apparent_temperature),
precipitationMm: numberOrNull(current.precipitation), precipitationMm: numberOrNull(current.precipitation),
@@ -109,14 +136,19 @@ export function weatherCodeLabel(code) {
/** Great-circle distance used to avoid refetching a regional brief every animation frame. */ /** Great-circle distance used to avoid refetching a regional brief every animation frame. */
export function regionalDistanceM(from, to) { export function regionalDistanceM(from, to) {
if (![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(Number.isFinite)) { if (
![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(
Number.isFinite,
)
) {
return Infinity; return Infinity;
} }
const phi1 = from.latitude * Math.PI / 180; const phi1 = (from.latitude * Math.PI) / 180;
const phi2 = to.latitude * Math.PI / 180; const phi2 = (to.latitude * Math.PI) / 180;
const deltaPhi = (to.latitude - from.latitude) * Math.PI / 180; const deltaPhi = ((to.latitude - from.latitude) * Math.PI) / 180;
const deltaLambda = (to.longitude - from.longitude) * Math.PI / 180; const deltaLambda = ((to.longitude - from.longitude) * Math.PI) / 180;
const a = Math.sin(deltaPhi / 2) ** 2 const a =
+ Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2; Math.sin(deltaPhi / 2) ** 2 +
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
} }
+13 -2
View File
@@ -26,7 +26,13 @@
* none of the above are usable — the caller applies its OWN existing sticky/default * none of the above are usable — the caller applies its OWN existing sticky/default
* fallback (this helper never invents one, so it can't drift from the caller's policy). * fallback (this helper never invents one, so it can't drift from the caller's policy).
*/ */
export function pickRenderAltitudeM({ geoAltM, baroAltM, onGround, surfaceM, geoidN }) { export function pickRenderAltitudeM({
geoAltM,
baroAltM,
onGround,
surfaceM,
geoidN,
}) {
if (onGround && Number.isFinite(surfaceM)) { if (onGround && Number.isFinite(surfaceM)) {
return surfaceM; return surfaceM;
} }
@@ -89,7 +95,12 @@ export function reuseGroundedSurfaceM(currentM, previousM) {
* @param {number|null|undefined} params.geoidN - Geoid undulation N here. * @param {number|null|undefined} params.geoidN - Geoid undulation N here.
* @returns {number|null} The geoid guess, or null when it must not be used. * @returns {number|null} The geoid guess, or null when it must not be used.
*/ */
export function geoidSurfaceLastResortM({ geoAltM, baroAltM, priorRenderM, geoidN }) { export function geoidSurfaceLastResortM({
geoAltM,
baroAltM,
priorRenderM,
geoidN,
}) {
if (Number.isFinite(geoAltM) || Number.isFinite(baroAltM)) return null; if (Number.isFinite(geoAltM) || Number.isFinite(baroAltM)) return null;
if (Number.isFinite(priorRenderM)) return null; if (Number.isFinite(priorRenderM)) return null;
return Number.isFinite(geoidN) ? geoidN : null; return Number.isFinite(geoidN) ? geoidN : null;
+12 -6
View File
@@ -34,11 +34,14 @@ export const RETRY_COOLDOWN_MAX_MS = 300_000;
* @param {() => number} [options.now] - Clock seam for tests. * @param {() => number} [options.now] - Clock seam for tests.
* @returns {() => Promise<T>} Memoized loader; retries once the cooldown elapses. * @returns {() => Promise<T>} Memoized loader; retries once the cooldown elapses.
*/ */
export function createRetryableLoader(load, { export function createRetryableLoader(
cooldownMs = RETRY_COOLDOWN_MS, load,
maxCooldownMs = RETRY_COOLDOWN_MAX_MS, {
now = () => Date.now(), cooldownMs = RETRY_COOLDOWN_MS,
} = {}) { maxCooldownMs = RETRY_COOLDOWN_MAX_MS,
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);
+44 -12
View File
@@ -19,7 +19,8 @@ export function greatCircleKm(lat1, lon1, lat2, lon2) {
const p2 = lat2 * D2R; const p2 = lat2 * D2R;
const dp = (lat2 - lat1) * D2R; const dp = (lat2 - lat1) * D2R;
const dl = (lon2 - lon1) * D2R; const dl = (lon2 - lon1) * D2R;
const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2; const a =
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
return R_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
} }
@@ -28,7 +29,8 @@ function bearingRad(lat1, lon1, lat2, lon2) {
const p2 = lat2 * D2R; const p2 = lat2 * D2R;
const dl = (lon2 - lon1) * D2R; const dl = (lon2 - lon1) * D2R;
const y = Math.sin(dl) * Math.cos(p2); const y = Math.sin(dl) * Math.cos(p2);
const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl); const x =
Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
return Math.atan2(y, x); return Math.atan2(y, x);
} }
@@ -42,8 +44,8 @@ export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) {
const NEAR_ENDPOINT_KM = 130; const NEAR_ENDPOINT_KM = 130;
const CROSS_TRACK_KM = 200; const CROSS_TRACK_KM = 200;
const LOW_ALT_M = 3700; // ~12 000 ft const LOW_ALT_M = 3700; // ~12 000 ft
const VERT_TREND_MPS = 2; // ~400 fpm const VERT_TREND_MPS = 2; // ~400 fpm
const LOCAL_AIRPORT_KM = 150; const LOCAL_AIRPORT_KM = 150;
/** /**
@@ -55,16 +57,35 @@ const LOCAL_AIRPORT_KM = 150;
* @param {{lat:number|null, lon:number|null}|null} [p.destination] * @param {{lat:number|null, lon:number|null}|null} [p.destination]
* @returns {boolean} false ONLY when the route is confidently wrong. * @returns {boolean} false ONLY when the route is confidently wrong.
*/ */
export function routePlausible({ latDeg, lonDeg, altitudeM = null, verticalRateMps = null, origin = null, destination = null }) { export function routePlausible({
latDeg,
lonDeg,
altitudeM = null,
verticalRateMps = null,
origin = null,
destination = null,
}) {
const haveO = Number.isFinite(origin?.lat) && Number.isFinite(origin?.lon); const haveO = Number.isFinite(origin?.lat) && Number.isFinite(origin?.lon);
const haveD = Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon); const haveD =
Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
if (!haveO && !haveD) return true; // no coordinates — cannot judge, do not hide if (!haveO && !haveD) return true; // no coordinates — cannot judge, do not hide
// (a) Geographic consistency: near an endpoint, or roughly on the path. // (a) Geographic consistency: near an endpoint, or roughly on the path.
const near = (pt) => greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM; const near = (pt) =>
greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM;
let geomOk = (haveO && near(origin)) || (haveD && near(destination)); let geomOk = (haveO && near(origin)) || (haveD && near(destination));
if (!geomOk && haveO && haveD) { if (!geomOk && haveO && haveD) {
geomOk = Math.abs(crossTrackKm(latDeg, lonDeg, origin.lat, origin.lon, destination.lat, destination.lon)) < CROSS_TRACK_KM; geomOk =
Math.abs(
crossTrackKm(
latDeg,
lonDeg,
origin.lat,
origin.lon,
destination.lat,
destination.lon,
),
) < CROSS_TRACK_KM;
} else if (!geomOk) { } else if (!geomOk) {
geomOk = true; // only one endpoint known and not near — cannot judge, allow geomOk = true; // only one endpoint known and not near — cannot judge, allow
} }
@@ -72,13 +93,24 @@ export function routePlausible({ latDeg, lonDeg, altitudeM = null, verticalRateM
// (b) Vertical-trend consistency for low traffic (observer-free adaptation). // (b) Vertical-trend consistency for low traffic (observer-free adaptation).
if ( if (
Number.isFinite(altitudeM) && altitudeM < LOW_ALT_M && Number.isFinite(altitudeM) &&
Number.isFinite(verticalRateMps) && Math.abs(verticalRateMps) > VERT_TREND_MPS altitudeM < LOW_ALT_M &&
Number.isFinite(verticalRateMps) &&
Math.abs(verticalRateMps) > VERT_TREND_MPS
) { ) {
if (verticalRateMps > 0) { if (verticalRateMps > 0) {
if (haveO && greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM) return false; // departing — origin should be local if (
haveO &&
greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM
)
return false; // departing — origin should be local
} else { } else {
if (haveD && greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) > LOCAL_AIRPORT_KM) return false; // arriving — destination should be local if (
haveD &&
greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) >
LOCAL_AIRPORT_KM
)
return false; // arriving — destination should be local
} }
} }
return true; return true;
+19 -4
View File
@@ -69,7 +69,13 @@ export const SATELLITE_CLASSES = Object.freeze({
}); });
/** Legend/report order for the classes above. */ /** Legend/report order for the classes above. */
export const SATELLITE_CLASS_ORDER = Object.freeze(['station', 'nav', 'geo', 'visual', 'comms']); export const SATELLITE_CLASS_ORDER = Object.freeze([
'station',
'nav',
'geo',
'visual',
'comms',
]);
/** /**
* CelesTrak group tag → { class, subtype }. Subtype names the specific * CelesTrak group tag → { class, subtype }. Subtype names the specific
@@ -150,8 +156,11 @@ export function satelliteClassLabel(group, { isIss = false } = {}) {
export function tallySatelliteClasses(entries) { export function tallySatelliteClasses(entries) {
const counts = Object.create(null); const counts = Object.create(null);
for (const entry of entries || []) { for (const entry of entries || []) {
const descriptor = entry && typeof entry === 'object' ? entry : { group: entry }; const descriptor =
const { klass } = satelliteClassOf(descriptor.group, { isIss: descriptor.isIss }); entry && typeof entry === 'object' ? entry : { group: entry };
const { klass } = satelliteClassOf(descriptor.group, {
isIss: descriptor.isIss,
});
counts[klass] = (counts[klass] || 0) + 1; counts[klass] = (counts[klass] || 0) + 1;
} }
return counts; return counts;
@@ -171,7 +180,13 @@ export function satelliteClassLegend(counts) {
const count = counts?.[klass]; const count = counts?.[klass];
if (!(count > 0)) continue; if (!(count > 0)) continue;
const spec = SATELLITE_CLASSES[klass]; const spec = SATELLITE_CLASSES[klass];
result.push({ klass, label: spec.label, color: spec.color, blurb: spec.blurb, count }); result.push({
klass,
label: spec.label,
color: spec.color,
blurb: spec.blurb,
count,
});
} }
return result; return result;
} }
+2 -1
View File
@@ -42,7 +42,8 @@ const MAX_PICK_MAGNITUDE_M = 1_000_000_000;
export function isPickedWorldPosition(position) { export function isPickedWorldPosition(position) {
if (!position) return false; if (!position) return false;
const { x, y, z } = position; const { x, y, z } = position;
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return false; if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
return false;
const magnitude = Math.hypot(x, y, z); const magnitude = Math.hypot(x, y, z);
return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M; return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M;
} }
+4 -2
View File
@@ -47,7 +47,8 @@ export function restoreSpriteOrder(viewer) {
if (!viewer || viewer.isDestroyed?.()) return; if (!viewer || viewer.isDestroyed?.()) return;
const scene = viewer.scene; const scene = viewer.scene;
const primitives = scene?.primitives; const primitives = scene?.primitives;
if (!primitives || scene.isDestroyed?.() || primitives.isDestroyed?.()) return; if (!primitives || scene.isDestroyed?.() || primitives.isDestroyed?.())
return;
for (const layerId of SPRITE_LAYER_ORDER) { for (const layerId of SPRITE_LAYER_ORDER) {
const collection = _collections.get(layerId); const collection = _collections.get(layerId);
@@ -71,6 +72,7 @@ export function restoreSpriteOrderOnEnable(
viewer, viewer,
restore = restoreSpriteOrder, restore = restoreSpriteOrder,
) { ) {
if (!SPRITE_LAYER_ORDER.includes(layerId) || typeof restore !== 'function') return; if (!SPRITE_LAYER_ORDER.includes(layerId) || typeof restore !== 'function')
return;
restore(viewer); restore(viewer);
} }
+8 -2
View File
@@ -1,4 +1,10 @@
import { createTerrainHeights } from '../services/terrainHeights.js'; import { createTerrainHeights } from '../services/terrainHeights.js';
import { applicationServices } from '../services/application.js'; import { applicationServices } from '../services/application.js';
const terrainHeights = createTerrainHeights({ source: applicationServices.terrain }); const terrainHeights = createTerrainHeights({
export const { cachedEllipsoidalGround, cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrainHeights; source: applicationServices.terrain,
});
export const {
cachedEllipsoidalGround,
cachedRealEllipsoidalGround,
resolveEllipsoidalGround,
} = terrainHeights;
+3 -1
View File
@@ -38,7 +38,9 @@ export const TR3B_CLASS = 'tr3b';
/** @param {*} id Raw contact id. @returns {string} Normalized registry key. */ /** @param {*} id Raw contact id. @returns {string} Normalized registry key. */
function key(id) { function key(id) {
return String(id ?? '').trim().toLowerCase(); return String(id ?? '')
.trim()
.toLowerCase();
} }
/** /**
+37 -19
View File
@@ -26,17 +26,23 @@ export function trackedModelScaleForPixelCap({
maximumPixelSize, maximumPixelSize,
}) { }) {
if ( if (
!Number.isFinite(baseScale) || baseScale <= 0 !Number.isFinite(baseScale) ||
|| !Number.isFinite(nativeRadiusM) || nativeRadiusM <= 0 baseScale <= 0 ||
|| !Number.isFinite(rangeM) || rangeM <= 0 !Number.isFinite(nativeRadiusM) ||
|| !Number.isFinite(viewportHeightPx) || viewportHeightPx <= 0 nativeRadiusM <= 0 ||
|| !Number.isFinite(fovyRad) || fovyRad <= 0 !Number.isFinite(rangeM) ||
|| !Number.isFinite(maximumPixelSize) || maximumPixelSize <= 0 rangeM <= 0 ||
) return baseScale; !Number.isFinite(viewportHeightPx) ||
viewportHeightPx <= 0 ||
!Number.isFinite(fovyRad) ||
fovyRad <= 0 ||
!Number.isFinite(maximumPixelSize) ||
maximumPixelSize <= 0
)
return baseScale;
const focalLengthPx = viewportHeightPx / (2 * Math.tan(fovyRad / 2)); const focalLengthPx = viewportHeightPx / (2 * Math.tan(fovyRad / 2));
const projectedDiameterPx = ( const projectedDiameterPx =
2 * nativeRadiusM * baseScale * focalLengthPx (2 * nativeRadiusM * baseScale * focalLengthPx) / rangeM;
) / rangeM;
if (projectedDiameterPx <= maximumPixelSize) return baseScale; if (projectedDiameterPx <= maximumPixelSize) return baseScale;
return baseScale * (maximumPixelSize / projectedDiameterPx); return baseScale * (maximumPixelSize / projectedDiameterPx);
} }
@@ -99,17 +105,25 @@ export function clampTrackedCameraPosition(
previousPosition, previousPosition,
minimumRangeM = MIN_TRACKED_RANGE_M, minimumRangeM = MIN_TRACKED_RANGE_M,
) { ) {
const crossedOrigin = Cesium.Cartesian3.dot(camera.position, previousPosition) <= 0; const crossedOrigin =
const forwardDistance = -Cesium.Cartesian3.dot(camera.position, camera.direction); Cesium.Cartesian3.dot(camera.position, previousPosition) <= 0;
const forwardDistance = -Cesium.Cartesian3.dot(
camera.position,
camera.direction,
);
const rangeSquared = Cesium.Cartesian3.magnitudeSquared(camera.position); const rangeSquared = Cesium.Cartesian3.magnitudeSquared(camera.position);
if (crossedOrigin) { if (crossedOrigin) {
Cesium.Cartesian3.normalize(previousPosition, camera.position); Cesium.Cartesian3.normalize(previousPosition, camera.position);
Cesium.Cartesian3.multiplyByScalar(camera.position, minimumRangeM, camera.position); Cesium.Cartesian3.multiplyByScalar(
camera.position,
minimumRangeM,
camera.position,
);
return true; return true;
} }
if ( if (
forwardDistance < minimumRangeM forwardDistance < minimumRangeM ||
|| rangeSquared < minimumRangeM * minimumRangeM rangeSquared < minimumRangeM * minimumRangeM
) { ) {
Cesium.Cartesian3.multiplyByScalar( Cesium.Cartesian3.multiplyByScalar(
camera.direction, camera.direction,
@@ -192,10 +206,14 @@ export function applyTrackedCameraFrame(viewer, entity, viewFrom) {
Cesium.Ellipsoid.WGS84, Cesium.Ellipsoid.WGS84,
trackedTransform, trackedTransform,
); );
const offset = typeof viewFrom.getValue === 'function' const offset =
? viewFrom.getValue(viewer.clock.currentTime, resolvedViewFrom) typeof viewFrom.getValue === 'function'
: Cesium.Cartesian3.clone(viewFrom, resolvedViewFrom); ? viewFrom.getValue(viewer.clock.currentTime, resolvedViewFrom)
if (!offset || Cesium.Cartesian3.magnitudeSquared(offset) < Cesium.Math.EPSILON12) { : Cesium.Cartesian3.clone(viewFrom, resolvedViewFrom);
if (
!offset ||
Cesium.Cartesian3.magnitudeSquared(offset) < Cesium.Math.EPSILON12
) {
stop(); stop();
return; return;
} }
+4 -1
View File
@@ -76,5 +76,8 @@ export const TRACKED_MODEL_EXIT_ALT_M =
*/ */
export function trackedModelZoomActive(cameraHeightM, wasActive = false) { export function trackedModelZoomActive(cameraHeightM, wasActive = false) {
if (!Number.isFinite(cameraHeightM)) return false; if (!Number.isFinite(cameraHeightM)) return false;
return cameraHeightM < (wasActive ? TRACKED_MODEL_EXIT_ALT_M : TRACKED_MODEL_ENTER_ALT_M); return (
cameraHeightM <
(wasActive ? TRACKED_MODEL_EXIT_ALT_M : TRACKED_MODEL_ENTER_ALT_M)
);
} }
+59 -21
View File
@@ -49,17 +49,24 @@ 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[0].trim(), parts.slice(1).join(' · ').trim()] parts.length > 1
: [raw]; ? [parts[0].trim(), parts.slice(1).join(' · ').trim()]
: [raw];
} }
return { title: lines[0] || '', details: lines.slice(1), accent }; return { title: lines[0] || '', details: lines.slice(1), accent };
} }
@@ -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,10 +234,11 @@ 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 =
if (viewer.trackedEntity) _selectedContext = null; viewer.trackedEntityChanged?.addEventListener?.(() => {
syncActiveEntity(); if (viewer.trackedEntity) _selectedContext = null;
}) || null; syncActiveEntity();
}) || null;
_contextSelectedHandler = (event) => { _contextSelectedHandler = (event) => {
const record = event.detail; const record = event.detail;
if (READOUT_CONTEXT_LAYERS.has(record?.layerId)) { if (READOUT_CONTEXT_LAYERS.has(record?.layerId)) {
@@ -232,7 +252,10 @@ export function initTrackedReadout(viewer) {
} }
}; };
_contextClearedHandler = (event) => { _contextClearedHandler = (event) => {
if (!_selectedContext || event.detail?.layerId === _selectedContext.layerId) { if (
!_selectedContext ||
event.detail?.layerId === _selectedContext.layerId
) {
_selectedContext = null; _selectedContext = null;
syncActiveEntity(); syncActiveEntity();
} }
@@ -242,8 +265,14 @@ export function initTrackedReadout(viewer) {
if (!_viewer?.trackedEntity) clearTrackedSource(); if (!_viewer?.trackedEntity) clearTrackedSource();
}; };
window.addEventListener('gev:entity-selected', _contextSelectedHandler); window.addEventListener('gev:entity-selected', _contextSelectedHandler);
window.addEventListener('gev:entity-selection-cleared', _contextClearedHandler); window.addEventListener(
window.addEventListener('gev:awareness-subject-selected', _aircraftSelectedHandler); 'gev:entity-selection-cleared',
_contextClearedHandler,
);
window.addEventListener(
'gev:awareness-subject-selected',
_aircraftSelectedHandler,
);
syncActiveEntity(); syncActiveEntity();
// Boot-verification contract: the track regression harness asserts this // Boot-verification contract: the track regression harness asserts this
// exact line at init (dropped by the host migration; restored). // exact line at init (dropped by the host migration; restored).
@@ -254,9 +283,18 @@ export function initTrackedReadout(viewer) {
export function destroyTrackedReadout() { export function destroyTrackedReadout() {
_trackedEntityChangedRemove?.(); _trackedEntityChangedRemove?.();
_trackedEntityChangedRemove = null; _trackedEntityChangedRemove = null;
if (_contextSelectedHandler) window.removeEventListener('gev:entity-selected', _contextSelectedHandler); if (_contextSelectedHandler)
if (_contextClearedHandler) window.removeEventListener('gev:entity-selection-cleared', _contextClearedHandler); window.removeEventListener('gev:entity-selected', _contextSelectedHandler);
if (_aircraftSelectedHandler) window.removeEventListener('gev:awareness-subject-selected', _aircraftSelectedHandler); if (_contextClearedHandler)
window.removeEventListener(
'gev:entity-selection-cleared',
_contextClearedHandler,
);
if (_aircraftSelectedHandler)
window.removeEventListener(
'gev:awareness-subject-selected',
_aircraftSelectedHandler,
);
_contextSelectedHandler = null; _contextSelectedHandler = null;
_contextClearedHandler = null; _contextClearedHandler = null;
_aircraftSelectedHandler = null; _aircraftSelectedHandler = null;
+10 -3
View File
@@ -28,8 +28,10 @@ export function isTrackingClickGesture(gesture = {}) {
const durationMs = Number.isFinite(gesture.durationMs) const durationMs = Number.isFinite(gesture.durationMs)
? Math.max(0, gesture.durationMs) ? Math.max(0, gesture.durationMs)
: Number.POSITIVE_INFINITY; : Number.POSITIVE_INFINITY;
return isTrackingSelectionGesture(gesture) return (
&& durationMs <= MAX_TRACKING_CLICK_DURATION_MS; isTrackingSelectionGesture(gesture) &&
durationMs <= MAX_TRACKING_CLICK_DURATION_MS
);
} }
/** /**
@@ -53,7 +55,12 @@ export function bindTrackingClickGesture(handler, onClick, options = {}) {
let completedGesture = null; let completedGesture = null;
const appendTravel = (position) => { const appendTravel = (position) => {
if (!pressActive || !Number.isFinite(position?.x) || !Number.isFinite(position?.y)) return; if (
!pressActive ||
!Number.isFinite(position?.x) ||
!Number.isFinite(position?.y)
)
return;
if (previousPosition) { if (previousPosition) {
travelPx += Math.hypot( travelPx += Math.hypot(
position.x - previousPosition.x, position.x - previousPosition.x,
+19 -9
View File
@@ -33,8 +33,8 @@ export function greatCircleKm(lat1, lon1, lat2, lon2) {
const p2 = toRad(lat2); const p2 = toRad(lat2);
const dp = toRad(lat2 - lat1); const dp = toRad(lat2 - lat1);
const dl = toRad(lon2 - lon1); const dl = toRad(lon2 - lon1);
const a = Math.sin(dp / 2) ** 2 const a =
+ Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2; Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a))); return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
} }
@@ -49,7 +49,8 @@ function initialBearingRad(lat1, lon1, lat2, lon2) {
const p2 = toRad(lat2); const p2 = toRad(lat2);
const dl = toRad(lon2 - lon1); const dl = toRad(lon2 - lon1);
const y = Math.sin(dl) * Math.cos(p2); const y = Math.sin(dl) * Math.cos(p2);
const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl); const x =
Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
return Math.atan2(y, x); return Math.atan2(y, x);
} }
@@ -68,12 +69,15 @@ 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(
Math.sin(bearingRad) * Math.sin(delta) * Math.cos(p1),
Math.cos(delta) - Math.sin(p1) * Math.sin(p2)
); );
const l2 =
l1 +
Math.atan2(
Math.sin(bearingRad) * Math.sin(delta) * Math.cos(p1),
Math.cos(delta) - Math.sin(p1) * Math.sin(p2),
);
// Normalize longitude to [-180, 180) // Normalize longitude to [-180, 180)
const lonDeg = ((toDeg(l2) + 540) % 360) - 180; const lonDeg = ((toDeg(l2) + 540) % 360) - 180;
return { lat: toDeg(p2), lon: lonDeg }; return { lat: toDeg(p2), lon: lonDeg };
@@ -100,7 +104,13 @@ function destinationPoint(lat, lon, bearingRad, distKm) {
* @returns {{lat:number, lon:number, source:'hit'|'nadir'|'pulled'}} * @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' };
} }
+30 -9
View File
@@ -36,9 +36,9 @@
/** @const {Object<string,'mono'|'crt'>} Style name → non-normal profile. */ /** @const {Object<string,'mono'|'crt'>} Style name → non-normal profile. */
const PROFILE_BY_STYLE = { const PROFILE_BY_STYLE = {
surveillance: 'mono', // NVG — P43 phosphor × luma surveillance: 'mono', // NVG — P43 phosphor × luma
thermal: 'mono', // FLIR — grayscale/ironbow × luma thermal: 'mono', // FLIR — grayscale/ironbow × luma
noir: 'mono', // full desaturation noir: 'mono', // full desaturation
retro: 'crt', // CRT — hue survives, small dots don't retro: 'crt', // CRT — hue survives, small dots don't
}; };
/** /**
@@ -49,13 +49,33 @@ const PROFILE_BY_STYLE = {
*/ */
const DOT_STYLE = { const DOT_STYLE = {
mono: { mono: {
jam: { rgba: [255, 255, 255, 0.95], sizeDelta: 3, outline: { rgba: [0, 0, 0, 0.9], width: 2 } }, jam: {
slow: { rgba: [255, 255, 255, 0.9], sizeDelta: 1, outline: { rgba: [0, 0, 0, 0.85], width: 1 } }, rgba: [255, 255, 255, 0.95],
free: { rgba: [255, 255, 255, 0.85], sizeDelta: 0, outline: { rgba: [0, 0, 0, 0.8], width: 1 } }, sizeDelta: 3,
outline: { rgba: [0, 0, 0, 0.9], width: 2 },
},
slow: {
rgba: [255, 255, 255, 0.9],
sizeDelta: 1,
outline: { rgba: [0, 0, 0, 0.85], width: 1 },
},
free: {
rgba: [255, 255, 255, 0.85],
sizeDelta: 0,
outline: { rgba: [0, 0, 0, 0.8], width: 1 },
},
}, },
crt: { crt: {
jam: { rgba: [255, 59, 48, 0.95], sizeDelta: 3, outline: { rgba: [0, 0, 0, 0.9], width: 2 } }, jam: {
slow: { rgba: [255, 179, 0, 0.92], sizeDelta: 2, outline: { rgba: [0, 0, 0, 0.85], width: 1 } }, rgba: [255, 59, 48, 0.95],
sizeDelta: 3,
outline: { rgba: [0, 0, 0, 0.9], width: 2 },
},
slow: {
rgba: [255, 179, 0, 0.92],
sizeDelta: 2,
outline: { rgba: [0, 0, 0, 0.85], width: 1 },
},
free: { rgba: [0, 255, 102, 0.9], sizeDelta: 1, outline: null }, free: { rgba: [0, 255, 102, 0.9], sizeDelta: 1, outline: null },
}, },
}; };
@@ -123,7 +143,8 @@ export function presetDotOutline(styleName, bucket) {
* @returns {string|null} Tier key, or null (no override → stock 'vehicle'). * @returns {string|null} Tier key, or null (no override → stock 'vehicle').
*/ */
export function trafficBucketTier(bucket) { export function trafficBucketTier(bucket) {
if (bucket === 'free' || bucket === 'slow' || bucket === 'jam') return `veh_${bucket}`; if (bucket === 'free' || bucket === 'slow' || bucket === 'jam')
return `veh_${bucket}`;
if (bucket === 'sim') return 'veh_nodata'; if (bucket === 'sim') return 'veh_nodata';
return null; return null;
} }
+3 -2
View File
@@ -33,13 +33,14 @@ const GAP_SPREAD_M = 6;
*/ */
export function queuePlatoons(totalLen, count, rng = Math.random) { export function queuePlatoons(totalLen, count, rng = Math.random) {
const platoons = []; const platoons = [];
if (!Number.isFinite(totalLen) || totalLen <= 0 || !(count > 0)) return platoons; if (!Number.isFinite(totalLen) || totalLen <= 0 || !(count > 0))
return platoons;
let placed = 0; let placed = 0;
while (placed < count) { while (placed < count) {
const platoonSize = Math.min( const platoonSize = Math.min(
count - placed, count - placed,
PLATOON_MIN + Math.floor(rng() * PLATOON_SPREAD) PLATOON_MIN + Math.floor(rng() * PLATOON_SPREAD),
); );
const anchor = rng() * totalLen; const anchor = rng() * totalLen;
const platoon = []; const platoon = [];
+14 -3
View File
@@ -28,7 +28,9 @@ import { registerPickOwner } from './pickRegistry.js';
// plane being tracked. Claiming the 'gev-trail:' id namespace makes // plane being tracked. Claiming the 'gev-trail:' id namespace makes
// isOwnedByOtherLayer() true for every layer — clicking a trail is a no-op // isOwnedByOtherLayer() true for every layer — clicking a trail is a no-op
// everywhere. Registered once at module load; the predicate is pure. // everywhere. Registered once at module load; the predicate is pure.
registerPickOwner('trails', (pickedId) => String(pickedId).startsWith('gev-trail:')); registerPickOwner('trails', (pickedId) =>
String(pickedId).startsWith('gev-trail:'),
);
/** @type {number} Uniquifier for trail entity ids (Cesium requires unique entity ids). */ /** @type {number} Uniquifier for trail entity ids (Cesium requires unique entity ids). */
let _trailSeq = 0; let _trailSeq = 0;
@@ -99,7 +101,12 @@ export function createTrail(viewer, { color, width = 2.5 }) {
for (const position of Array.isArray(cartesians) ? cartesians : []) { for (const position of Array.isArray(cartesians) ? cartesians : []) {
if (!position) continue; if (!position) continue;
const last = positions[positions.length - 1]; const last = positions[positions.length - 1];
if (last && Cesium.Cartesian3.distanceSquared(last, position) < MIN_SEGMENT_DISTANCE_SQ) continue; if (
last &&
Cesium.Cartesian3.distanceSquared(last, position) <
MIN_SEGMENT_DISTANCE_SQ
)
continue;
positions.push(position); positions.push(position);
} }
current = positions.length >= 2 ? positions : []; current = positions.length >= 2 ? positions : [];
@@ -122,7 +129,11 @@ export function createTrail(viewer, { color, width = 2.5 }) {
destroyed = true; destroyed = true;
current = []; current = [];
if (entity && viewer && !viewer.isDestroyed()) { if (entity && viewer && !viewer.isDestroyed()) {
try { viewer.entities.remove(entity); } catch { /* torn down */ } try {
viewer.entities.remove(entity);
} catch {
/* torn down */
}
} }
entity = null; entity = null;
}, },
+51 -12
View File
@@ -20,21 +20,48 @@ export const VESSEL_CARD_FADE_DISTANCE_M = 5_000_000;
*/ */
const TYPE_STYLES = [ const TYPE_STYLES = [
{ pattern: /tanker/i, css: '#ffb347', accent: '255, 179, 71' }, { pattern: /tanker/i, css: '#ffb347', accent: '255, 179, 71' },
{ pattern: /cargo|container|bulk|carrier/i, css: '#39d5ff', accent: '57, 213, 255' }, {
{ pattern: /passenger|ferry|cruise/i, css: '#ff7adf', accent: '255, 122, 223' }, pattern: /cargo|container|bulk|carrier/i,
css: '#39d5ff',
accent: '57, 213, 255',
},
{
pattern: /passenger|ferry|cruise/i,
css: '#ff7adf',
accent: '255, 122, 223',
},
{ pattern: /fishing/i, css: '#7cff9b', accent: '124, 255, 155' }, { pattern: /fishing/i, css: '#7cff9b', accent: '124, 255, 155' },
{ pattern: /tug|tow|pilot|supply|service/i, css: '#f7f0a3', accent: '247, 240, 163' }, {
pattern: /tug|tow|pilot|supply|service/i,
css: '#f7f0a3',
accent: '247, 240, 163',
},
]; ];
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' }; const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
const NUMERIC_TYPE_SPECIALS = { const NUMERIC_TYPE_SPECIALS = {
30: 'FISHING', 31: 'TOWING', 32: 'TOWING', 33: 'DREDGER', 34: 'DIVE OPS', 30: 'FISHING',
35: 'MILITARY', 36: 'SAILING', 37: 'PLEASURE', 31: 'TOWING',
50: 'PILOT', 51: 'SAR', 52: 'TUG', 53: 'PORT TENDER', 54: 'ANTI-POLLUTION', 32: 'TOWING',
55: 'LAW ENFORCE', 58: 'MEDICAL', 33: 'DREDGER',
34: 'DIVE OPS',
35: 'MILITARY',
36: 'SAILING',
37: 'PLEASURE',
50: 'PILOT',
51: 'SAR',
52: 'TUG',
53: 'PORT TENDER',
54: 'ANTI-POLLUTION',
55: 'LAW ENFORCE',
58: 'MEDICAL',
}; };
const NUMERIC_TYPE_FAMILIES = { const NUMERIC_TYPE_FAMILIES = {
4: 'HIGH-SPEED', 6: 'PASSENGER', 7: 'CARGO', 8: 'TANKER', 9: 'OTHER', 4: 'HIGH-SPEED',
6: 'PASSENGER',
7: 'CARGO',
8: 'TANKER',
9: 'OTHER',
}; };
/** /**
@@ -76,13 +103,22 @@ function styleForType(type) {
* @param {number} [rowLimit=900] Configured source row ceiling. * @param {number} [rowLimit=900] Configured source row ceiling.
* @returns {number} * @returns {number}
*/ */
export function vesselOverlayCohortLimit(width, height, rowLimit = VESSEL_DEFAULT_LABEL_LIMIT) { export function vesselOverlayCohortLimit(
width,
height,
rowLimit = VESSEL_DEFAULT_LABEL_LIMIT,
) {
const w = Number(width); const w = Number(width);
const h = Number(height); const h = Number(height);
const requested = Number(rowLimit); const requested = Number(rowLimit);
if (!(w > 0) || !(h > 0) || !(requested > 0)) return 0; if (!(w > 0) || !(h > 0) || !(requested > 0)) return 0;
const gridCapacity = Math.ceil(w / VESSEL_LABEL_GRID_PX) * Math.ceil(h / VESSEL_LABEL_GRID_PX); const gridCapacity =
return Math.min(VESSEL_OVERLAY_MAX_COHORT, Math.floor(requested), gridCapacity); Math.ceil(w / VESSEL_LABEL_GRID_PX) * Math.ceil(h / VESSEL_LABEL_GRID_PX);
return Math.min(
VESSEL_OVERLAY_MAX_COHORT,
Math.floor(requested),
gridCapacity,
);
} }
/** /**
@@ -93,7 +129,10 @@ export function vesselOverlayCohortLimit(width, height, rowLimit = VESSEL_DEFAUL
* @param {number} [fadeDistance=5000000] Ambient distance-fade endpoint. * @param {number} [fadeDistance=5000000] Ambient distance-fade endpoint.
* @returns {Object} * @returns {Object}
*/ */
export function applyVesselOverlayPolicy(card, fadeDistance = VESSEL_CARD_FADE_DISTANCE_M) { export function applyVesselOverlayPolicy(
card,
fadeDistance = VESSEL_CARD_FADE_DISTANCE_M,
) {
const selected = card?.selected === true; const selected = card?.selected === true;
const rawGap = Number(card?.gapPx) || 10; const rawGap = Number(card?.gapPx) || 10;
const gapPx = Math.max(12, rawGap + 8); const gapPx = Math.max(12, rawGap + 8);
+67 -35
View File
@@ -143,7 +143,9 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
function resolveStore(kind, injected) { function resolveStore(kind, injected) {
if (injected !== undefined) return injected; if (injected !== undefined) return injected;
try { try {
return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage; return kind === 'session'
? globalThis.sessionStorage
: globalThis.localStorage;
} catch { } catch {
// Privacy-restricted storage should not make first launch silent. // Privacy-restricted storage should not make first launch silent.
return null; return null;
@@ -211,8 +213,13 @@ export function shouldShowFirstRun({
if (params.get('welcome') === '0') return false; if (params.get('welcome') === '0') return false;
// The demo/support escape hatch outranks both suppressions on purpose. // The demo/support escape hatch outranks both suppressions on purpose.
if (params.get('welcome') === '1') return true; if (params.get('welcome') === '1') return true;
if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false; if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')
if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false; return false;
if (
readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) ===
'dismissed'
)
return false;
return true; return true;
} }
@@ -253,7 +260,10 @@ export function rememberFirstRunSessionDismissed(sessionStorageRef) {
* @param {() => Promise<any>} deps.flyToGlobe * @param {() => Promise<any>} deps.flyToGlobe
* @returns {Promise<{ok: boolean, choice: string, result?: object, failedLayerIds?: string[]}>} * @returns {Promise<{ok: boolean, choice: string, result?: object, failedLayerIds?: string[]}>}
*/ */
export async function runFirstRunChoice(choice, { setContextMode, setLayerEnabled, flyToGlobe }) { export async function runFirstRunChoice(
choice,
{ setContextMode, setLayerEnabled, flyToGlobe },
) {
const mission = FIRST_RUN_MISSIONS[choice]; const mission = FIRST_RUN_MISSIONS[choice];
if (!mission) return { ok: false, choice }; if (!mission) return { ok: false, choice };
if (mission.kind === 'none') return { ok: true, choice }; if (mission.kind === 'none') return { ok: true, choice };
@@ -267,15 +277,19 @@ export async function runFirstRunChoice(choice, { setContextMode, setLayerEnable
const flight = Promise.resolve() const flight = Promise.resolve()
.then(() => flyToGlobe()) .then(() => flyToGlobe())
.catch(() => null); .catch(() => null);
const outcomes = await Promise.all(mission.layerIds.map(async (layerId) => { const outcomes = await Promise.all(
try { mission.layerIds.map(async (layerId) => {
return { layerId, ok: (await setLayerEnabled(layerId)) !== false }; try {
} catch { return { layerId, ok: (await setLayerEnabled(layerId)) !== false };
return { layerId, ok: false }; } catch {
} 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 (
hasShareState: styleManager?.hasShareState, !shouldShowFirstRun({
storage, hasShareState: styleManager?.hasShareState,
sessionStorageRef, storage,
location, sessionStorageRef,
})) { 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 =
? new globalThis.MutationObserver(syncToExclusiveSurfaces) typeof globalThis.MutationObserver === 'function'
: null; ? new globalThis.MutationObserver(syncToExclusiveSurfaces)
: 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();
+4 -4
View File
@@ -222,8 +222,8 @@ test('the key handler refuses to act for a card that is not really on screen', (
assert.match(module, /isActive: \(\) => !closing && isTopmost\(\)/); assert.match(module, /isActive: \(\) => !closing && isTopmost\(\)/);
// Real visibility, not just the class: the class survives while CSS hides the // Real visibility, not just the class: the class survives while CSS hides the
// card, which is precisely how a Scene left an invisible ESC handler armed. // card, which is precisely how a Scene left an invisible ESC handler armed.
assert.match(module, /const isTopmost = \(\) => root\.isConnected/); assert.match(module, /const isTopmost = \(\) =>\s*root\.isConnected/);
assert.match(module, /&& root\.getClientRects\(\)\.length > 0\s*\n\s*&& !coveredByOverlay\(\);/); assert.match(module, /&&\s*root\.getClientRects\(\)\.length > 0 &&\s*!coveredByOverlay\(\);/);
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8'); const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {')); const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
assert.match( assert.match(
@@ -373,7 +373,7 @@ test('the launcher yields on engage and waits when a surface is already up', ()
assert.match(module, /dismiss\(\{ restoreFocus: false \}\)/); assert.match(module, /dismiss\(\{ restoreFocus: false \}\)/);
// A cheap attribute watch, not a per-frame poll — the render governor must // A cheap attribute watch, not a per-frame poll — the render governor must
// not see a new hold because of onboarding chrome. // not see a new hold because of onboarding chrome.
assert.match(module, /attributes: true, attributeFilter: \['class'\]/); assert.match(module, /attributes: true,\s*attributeFilter: \['class'\]/);
assert.match(module, /surfaceObserver\?\.disconnect\(\)/); assert.match(module, /surfaceObserver\?\.disconnect\(\)/);
assert.doesNotMatch(module, /setInterval|requestAnimationFrame\(function poll/); assert.doesNotMatch(module, /setInterval|requestAnimationFrame\(function poll/);
}); });
@@ -536,7 +536,7 @@ test('no mission writes a preference the visitor did not choose by picking it',
const panelWrites = code.match(/setPanelCollapsed/g) || []; const panelWrites = code.match(/setPanelCollapsed/g) || [];
assert.equal(panelWrites.length, 1, 'exactly one panel reveal, on the Context path'); assert.equal(panelWrites.length, 1, 'exactly one panel reveal, on the Context path');
const contextPath = code.slice(code.indexOf('setContextMode: async (mode)'), code.indexOf('setLayerEnabled:')); const contextPath = code.slice(code.indexOf('setContextMode: async (mode)'), code.indexOf('setLayerEnabled:'));
assert.match(contextPath, /result\?\.ok[\s\S]*?setPanelCollapsed\?\.\('global-context-panel', false, \{ explicit: true \}\)/); assert.match(contextPath, /result\?\.ok[\s\S]*?setPanelCollapsed\?\.\('global-context-panel', false, \{\s*explicit: true,?\s*\}\)/);
}); });
test('the decision table is written down where the next editor will read it', () => { test('the decision table is written down where the next editor will read it', () => {
+96 -31
View File
@@ -18,16 +18,36 @@ import * as Cesium from 'cesium';
import { forward as toMGRS } from 'mgrs'; import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './locations.js'; import { CITY_POIS } from './locations.js';
import { composeLocalityTag } from './hudLocality.js'; import { composeLocalityTag } from './hudLocality.js';
import { ellipsoidalToMslDisplayM, ensureGeoidReady, geoidHeight } from './data/geoid.js'; import {
ellipsoidalToMslDisplayM,
ensureGeoidReady,
geoidHeight,
} from './data/geoid.js';
import { getBasemapLabelContext } from './voice/gevActions.js'; import { getBasemapLabelContext } from './voice/gevActions.js';
import { isHudSummaryUnconfigured } from './hudSummaryResponse.js'; import { isHudSummaryUnconfigured } from './hudSummaryResponse.js';
/** Color palettes keyed by shader mode; applied as CSS custom properties. */ /** Color palettes keyed by shader mode; applied as CSS custom properties. */
const HUD_COLORS = { const HUD_COLORS = {
surveillance: { main: 'rgba(51, 255, 51, 0.8)', glow: 'rgba(51, 255, 51, 0.5)', border: 'rgba(51, 255, 51, 0.2)' }, surveillance: {
thermal: { main: 'rgba(255, 255, 255, 0.7)', glow: 'rgba(255, 255, 255, 0.4)', border: 'rgba(255, 255, 255, 0.15)' }, main: 'rgba(51, 255, 51, 0.8)',
retro: { main: 'rgba(255, 170, 0, 0.8)', glow: 'rgba(255, 170, 0, 0.5)', border: 'rgba(255, 170, 0, 0.2)' }, glow: 'rgba(51, 255, 51, 0.5)',
_default: { main: 'rgba(0, 255, 255, 0.6)', glow: 'rgba(0, 255, 255, 0.4)', border: 'rgba(0, 255, 255, 0.15)' }, border: 'rgba(51, 255, 51, 0.2)',
},
thermal: {
main: 'rgba(255, 255, 255, 0.7)',
glow: 'rgba(255, 255, 255, 0.4)',
border: 'rgba(255, 255, 255, 0.15)',
},
retro: {
main: 'rgba(255, 170, 0, 0.8)',
glow: 'rgba(255, 170, 0, 0.5)',
border: 'rgba(255, 170, 0, 0.2)',
},
_default: {
main: 'rgba(0, 255, 255, 0.6)',
glow: 'rgba(0, 255, 255, 0.4)',
border: 'rgba(0, 255, 255, 0.15)',
},
}; };
/** Shader modes that automatically show the HUD overlay. */ /** Shader modes that automatically show the HUD overlay. */
@@ -45,13 +65,14 @@ const HUD_SUMMARY_INTERVAL_MS = 15000;
const HUD_GEOID_CELL_DEG = 0.01; const HUD_GEOID_CELL_DEG = 0.01;
/** Flattened list of all city POIs for nearest-point lookups. */ /** Flattened list of all city POIs for nearest-point lookups. */
const NEARBY_POINTS = Object.values(CITY_POIS) const NEARBY_POINTS = Object.values(CITY_POIS).flatMap((city) =>
.flatMap((city) => city.pois.map((poi) => ({ city.pois.map((poi) => ({
city: city.name, city: city.name,
poi: poi.name, poi: poi.name,
lat: poi.lat, lat: poi.lat,
lon: poi.lon, lon: poi.lon,
}))); })),
);
/** /**
* Full-screen intelligence HUD overlay rendered on top of the Cesium canvas. * Full-screen intelligence HUD overlay rendered on top of the Cesium canvas.
@@ -66,7 +87,15 @@ export class IntelHUD {
* @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for * @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for
* camera telemetry and coordinate derivation. * camera telemetry and coordinate derivation.
*/ */
constructor(viewer, { placeSearch, summaryPolicy = {}, basemapContext = {}, summaryService = applicationServices.summary } = {}) { constructor(
viewer,
{
placeSearch,
summaryPolicy = {},
basemapContext = {},
summaryService = applicationServices.summary,
} = {},
) {
this.summaryService = summaryService; this.summaryService = summaryService;
this.summaryPolicy = summaryPolicy; this.summaryPolicy = summaryPolicy;
this.basemapContext = basemapContext; this.basemapContext = basemapContext;
@@ -227,7 +256,8 @@ export class IntelHUD {
this._recBlinkInterval = setInterval(() => { this._recBlinkInterval = setInterval(() => {
this._recBlinkState = !this._recBlinkState; this._recBlinkState = !this._recBlinkState;
const dot = document.getElementById('hud-rec-dot'); const dot = document.getElementById('hud-rec-dot');
if (dot) dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden'; if (dot)
dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden';
}, 800); }, 800);
// Camera-derived data — 4 updates/second (250ms) // Camera-derived data — 4 updates/second (250ms)
@@ -269,8 +299,12 @@ export class IntelHUD {
if (!this._geoidRequested) { if (!this._geoidRequested) {
this._geoidRequested = true; this._geoidRequested = true;
ensureGeoidReady() ensureGeoidReady()
.then(() => { this._geoidReady = true; }) .then(() => {
.catch(() => { /* readout falls back to the uncorrected height */ }); this._geoidReady = true;
})
.catch(() => {
/* readout falls back to the uncorrected height */
});
} }
return null; return null;
} }
@@ -331,9 +365,13 @@ export class IntelHUD {
// Equation (GIQE) simplified form: NIIRS = 10.25 - 3.32 * log10(GSD_inches). // Equation (GIQE) simplified form: NIIRS = 10.25 - 3.32 * log10(GSD_inches).
const gsd = Math.max(0.01, altM * 0.000375); const gsd = Math.max(0.01, altM * 0.000375);
const gsdInches = gsd * 39.37; const gsdInches = gsd * 39.37;
const niirs = Math.max(0, Math.min(9, 10.25 - 3.32 * Math.log10(gsdInches))); const niirs = Math.max(
0,
Math.min(9, 10.25 - 3.32 * Math.log10(gsdInches)),
);
const gsdEl = document.getElementById('hud-gsd'); const gsdEl = document.getElementById('hud-gsd');
if (gsdEl) gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`; if (gsdEl)
gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
// Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw // Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
// ellipsoidal camera height, which reads far below zero wherever the geoid // ellipsoidal camera height, which reads far below zero wherever the geoid
@@ -344,7 +382,8 @@ export class IntelHUD {
const geoidN = this._geoidUndulationM(latDeg, lonDeg); const geoidN = this._geoidUndulationM(latDeg, lonDeg);
const altMslM = ellipsoidalToMslDisplayM(altM, geoidN); const altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
const sunEl = this._estimateSunElevation(latDeg, lonDeg); const sunEl = this._estimateSunElevation(latDeg, lonDeg);
if (altEl) altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`; if (altEl)
altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
// Collection timestamp // Collection timestamp
const collEl = document.getElementById('hud-coll'); const collEl = document.getElementById('hud-coll');
@@ -435,7 +474,10 @@ export class IntelHUD {
if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S'; if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S';
else dir = decimal >= 0 ? 'E' : 'W'; else dir = decimal >= 0 ? 'E' : 'W';
const degStr = type === 'lon' ? String(deg).padStart(3, '0') : String(deg).padStart(2, '0'); const degStr =
type === 'lon'
? String(deg).padStart(3, '0')
: String(deg).padStart(2, '0');
return `${degStr}°${String(min).padStart(2, '0')}'${String(sec).padStart(5, '0')}"${dir}`; return `${degStr}°${String(min).padStart(2, '0')}'${String(sec).padStart(5, '0')}"${dir}`;
} }
@@ -457,12 +499,20 @@ export class IntelHUD {
const solarNoon = 12; const solarNoon = 12;
const hourAngle = (hours - solarNoon) * 15; const hourAngle = (hours - solarNoon) * 15;
// Solar declination approximation (~23.45 deg amplitude sinusoidal over the year) // Solar declination approximation (~23.45 deg amplitude sinusoidal over the year)
const declination = 23.45 * Math.sin(Cesium.Math.toRadians((360 / 365) * (now.getUTCDate() + 30 * now.getUTCMonth() - 81))); const declination =
23.45 *
Math.sin(
Cesium.Math.toRadians(
(360 / 365) * (now.getUTCDate() + 30 * now.getUTCMonth() - 81),
),
);
const latRad = Cesium.Math.toRadians(lat); const latRad = Cesium.Math.toRadians(lat);
const decRad = Cesium.Math.toRadians(declination); const decRad = Cesium.Math.toRadians(declination);
const haRad = Cesium.Math.toRadians(hourAngle); const haRad = Cesium.Math.toRadians(hourAngle);
// Standard formula: sin(el) = sin(lat)*sin(dec) + cos(lat)*cos(dec)*cos(ha) // Standard formula: sin(el) = sin(lat)*sin(dec) + cos(lat)*cos(dec)*cos(ha)
const sinEl = Math.sin(latRad) * Math.sin(decRad) + Math.cos(latRad) * Math.cos(decRad) * Math.cos(haRad); const sinEl =
Math.sin(latRad) * Math.sin(decRad) +
Math.cos(latRad) * Math.cos(decRad) * Math.cos(haRad);
// Clamp to [-1,1] to guard against floating-point drift before asin // Clamp to [-1,1] to guard against floating-point drift before asin
return Cesium.Math.toDegrees(Math.asin(Math.max(-1, Math.min(1, sinEl)))); return Cesium.Math.toDegrees(Math.asin(Math.max(-1, Math.min(1, sinEl))));
} }
@@ -489,8 +539,10 @@ export class IntelHUD {
_regionLabel(lat, lon) { _regionLabel(lat, lon) {
if (lat > 72) return 'ARCTIC'; if (lat > 72) return 'ARCTIC';
if (lat < -60) return 'ANTARCTIC'; if (lat < -60) return 'ANTARCTIC';
if (lat >= 5 && lat <= 83 && lon >= -170 && lon <= -50) return 'NORTH AMERICA'; if (lat >= 5 && lat <= 83 && lon >= -170 && lon <= -50)
if (lat >= -60 && lat <= 15 && lon >= -90 && lon <= -30) return 'SOUTH AMERICA'; return 'NORTH AMERICA';
if (lat >= -60 && lat <= 15 && lon >= -90 && lon <= -30)
return 'SOUTH AMERICA';
if (lat >= 34 && lat <= 72 && lon >= -25 && lon <= 45) return 'EUROPE'; if (lat >= 34 && lat <= 72 && lon >= -25 && lon <= 45) return 'EUROPE';
if (lat >= -35 && lat <= 38 && lon >= -20 && lon <= 55) return 'AFRICA'; if (lat >= -35 && lat <= 38 && lon >= -20 && lon <= 55) return 'AFRICA';
if (lat >= 5 && lat <= 80 && lon >= 45 && lon <= 180) return 'ASIA'; if (lat >= 5 && lat <= 80 && lon >= 45 && lon <= 180) return 'ASIA';
@@ -519,7 +571,10 @@ export class IntelHUD {
const latSpan = Math.abs(north - south); const latSpan = Math.abs(north - south);
// 111 km/deg is the approximate surface distance per degree of latitude; // 111 km/deg is the approximate surface distance per degree of latitude;
// longitude distance is scaled by cos(lat) to account for meridian convergence. // longitude distance is scaled by cos(lat) to account for meridian convergence.
const widthKm = Math.max(0, lonSpan * 111 * Math.cos(Cesium.Math.toRadians(latDeg))); const widthKm = Math.max(
0,
lonSpan * 111 * Math.cos(Cesium.Math.toRadians(latDeg)),
);
const heightKm = Math.max(0, latSpan * 111); const heightKm = Math.max(0, latSpan * 111);
return { widthKm, heightKm }; return { widthKm, heightKm };
} }
@@ -537,8 +592,9 @@ export class IntelHUD {
const toRad = (deg) => Cesium.Math.toRadians(deg); const toRad = (deg) => Cesium.Math.toRadians(deg);
const dLat = toRad(lat2 - lat1); const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1); const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 const a =
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
} }
@@ -583,9 +639,10 @@ 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).toFixed(1)}KM` altDisplayM >= 1000
: `${Math.round(altDisplayM)}M`; ? `${(altDisplayM / 1000).toFixed(1)}KM`
: `${Math.round(altDisplayM)}M`;
const winTag = window const winTag = window
? `${Math.max(1, Math.round(window.widthKm))}x${Math.max(1, Math.round(window.heightKm))}KM` ? `${Math.max(1, Math.round(window.widthKm))}x${Math.max(1, Math.round(window.heightKm))}KM`
: 'N/A'; : 'N/A';
@@ -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,10 +760,16 @@ 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,
?.filter((layer) => layer.enabled) this.placeSearch,
.map((layer) => layer.name) || []; this.basemapContext,
);
const enabledLayers =
this._dataManager
?.getAll?.()
?.filter((layer) => layer.enabled)
.map((layer) => layer.name) || [];
return { return {
placeLabels: labels.placeLabels, placeLabels: labels.placeLabels,
streetLabels: labels.streetLabels, streetLabels: labels.streetLabels,
+1 -1
View File
@@ -108,7 +108,7 @@ test('hud.js corrects the camera height to MSL through the geoid module', () =>
'the ~2.7 MB grid must be requested once, on demand — not at HUD construction', 'the ~2.7 MB grid must be requested once, on demand — not at HUD construction',
); );
assert.equal( assert.equal(
has(/\.catch\(\(\) => \{ \/\* readout falls back to the uncorrected height \*\/ \}\)/), has(/\.catch\(\(\) => \{\s*\/\* readout falls back to the uncorrected height \*\/\s*\}\)/),
true, true,
'a failed geoid load must leave the readout uncorrected, not unhandled', 'a failed geoid load must leave the readout uncorrected, not unhandled',
); );
+12 -9
View File
@@ -23,13 +23,16 @@ export function keylessHudSummaryResponse(apiKey) {
/** Return true only for the deliberate, successful no-key capability response. */ /** Return true only for the deliberate, successful no-key capability response. */
export function isHudSummaryUnconfigured(status, data) { export function isHudSummaryUnconfigured(status, data) {
const keys = data !== null && typeof data === 'object' && !Array.isArray(data) const keys =
? Object.keys(data) data !== null && typeof data === 'object' && !Array.isArray(data)
: []; ? Object.keys(data)
return keys.length === 4 : [];
&& status === 200 return (
&& data?.configured === false keys.length === 4 &&
&& data?.code === HUD_SUMMARY_UNCONFIGURED_CODE status === 200 &&
&& data?.error === null data?.configured === false &&
&& data?.summary === null; data?.code === HUD_SUMMARY_UNCONFIGURED_CODE &&
data?.error === null &&
data?.summary === null
);
} }
+69 -26
View File
@@ -19,7 +19,9 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
/** Chip label — pure, exported for tests. */ /** 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 = () =>
? 'your app configuration' status?.store === 'pinokio-environment'
: 'your local .env'); ? 'your app configuration'
: '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.
} }
+108 -32
View File
@@ -18,7 +18,8 @@ export const KEY_SETUP_VALUE_LIMIT = 512;
export const KEY_SETUP_UPDATE_LIMIT = 16; export const KEY_SETUP_UPDATE_LIMIT = 16;
/** Header line written above keys the panel appends to a .env file. */ /** Header line written above keys the panel appends to a .env file. */
export const KEY_SETUP_APPEND_HEADER = '# Keys added by the in-app POWER UP panel'; export const KEY_SETUP_APPEND_HEADER =
'# Keys added by the in-app POWER UP panel';
/** /**
* Provider credentials, in display order — most magic per * Provider credentials, in display order — most magic per
@@ -42,7 +43,8 @@ export const KEY_SETUP_KEYS = Object.freeze([
id: 'google-maps-server', id: 'google-maps-server',
title: 'GOOGLE MAPS — SERVER', title: 'GOOGLE MAPS — SERVER',
unlocks: 'Places context + Street View fallback; optional separate key', unlocks: 'Places context + Street View fallback; optional separate key',
getUrl: 'https://developers.google.com/maps/documentation/places/web-service/get-api-key', getUrl:
'https://developers.google.com/maps/documentation/places/web-service/get-api-key',
envVars: Object.freeze(['GOOGLE_MAPS_SERVER_API_KEY']), envVars: Object.freeze(['GOOGLE_MAPS_SERVER_API_KEY']),
tier: 'metered', tier: 'metered',
hidden: true, hidden: true,
@@ -113,12 +115,17 @@ const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
/** Parse an exact local request authority from a Host header. */ /** Parse an exact local request authority from a Host header. */
function localAuthority(hostHeader, protocol) { function localAuthority(hostHeader, protocol) {
const raw = String(hostHeader || '').trim().toLowerCase(); const raw = String(hostHeader || '')
.trim()
.toLowerCase();
const scheme = String(protocol || '').toLowerCase(); const scheme = String(protocol || '').toLowerCase();
if (!raw || !['http:', 'https:'].includes(scheme) || /[\s/?#@]/.test(raw)) return null; if (!raw || !['http:', 'https:'].includes(scheme) || /[\s/?#@]/.test(raw))
return null;
try { try {
const parsed = new URL(`${scheme}//${raw}`); const parsed = new URL(`${scheme}//${raw}`);
return LOCAL_HOSTNAMES.has(parsed.hostname.toLowerCase()) ? parsed.origin : null; return LOCAL_HOSTNAMES.has(parsed.hostname.toLowerCase())
? parsed.origin
: null;
} catch { } catch {
return null; return null;
} }
@@ -131,7 +138,9 @@ export function commandCompletedSuccessfully(result) {
/** Parse one RFC-4180-shaped CSV record, sufficient for `whoami /fo csv`. */ /** Parse one RFC-4180-shaped CSV record, sufficient for `whoami /fo csv`. */
function parseCsvRecord(text) { function parseCsvRecord(text) {
const source = String(text || '').replace(/^\uFEFF/, '').trim(); const source = String(text || '')
.replace(/^\uFEFF/, '')
.trim();
if (!source || /[\r\n]/.test(source)) return null; if (!source || /[\r\n]/.test(source)) return null;
const fields = []; const fields = [];
let field = ''; let field = '';
@@ -210,9 +219,25 @@ export function admitKeySetupRequest({
// on this machine, whatever its socket says. Refuse them outright as defense // on this machine, whatever its socket says. Refuse them outright as defense
// in depth — the shipped tunnel (Pinokio) is force-closed at boot, so these // in depth — the shipped tunnel (Pinokio) is force-closed at boot, so these
// only appear when someone has deliberately fronted the dev server. // only appear when someone has deliberately fronted the dev server.
const PROXY_SIGNALS = ['forwarded', 'via', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-port', 'x-forwarded-proto', 'x-real-ip', 'cf-connecting-ip', 'cf-ray']; const PROXY_SIGNALS = [
if (PROXY_SIGNALS.some((name) => String(proxyHeaders[name] || '').trim() !== '')) { 'forwarded',
return { ok: false, status: 403, error: 'Provider Settings does not answer proxied requests' }; 'via',
'x-forwarded-for',
'x-forwarded-host',
'x-forwarded-port',
'x-forwarded-proto',
'x-real-ip',
'cf-connecting-ip',
'cf-ray',
];
if (
PROXY_SIGNALS.some((name) => String(proxyHeaders[name] || '').trim() !== '')
) {
return {
ok: false,
status: 403,
error: 'Provider Settings does not answer proxied requests',
};
} }
// Every sharing signal the launcher recognizes (scripts/pinokio-preflight.mjs) // Every sharing signal the launcher recognizes (scripts/pinokio-preflight.mjs)
// also disables this surface — so the gate's set is complete, not a subset the // also disables this surface — so the gate's set is complete, not a subset the
@@ -225,21 +250,42 @@ export function admitKeySetupRequest({
// below independently refuse LAN/tunnel traffic, and under Pinokio the launcher // below independently refuse LAN/tunnel traffic, and under Pinokio the launcher
// refuses to boot at all when sharing is genuinely on. // refuses to boot at all when sharing is genuinely on.
const shareVar = String(env.PINOKIO_SHARE_VAR ?? '').trim(); const shareVar = String(env.PINOKIO_SHARE_VAR ?? '').trim();
const sharingEnabled = ['PINOKIO_SHARE_CLOUDFLARE', 'PINOKIO_SHARE_LOCAL'] const sharingEnabled =
.some((name) => /^(1|true)$/i.test(String(env[name] || '').trim())) ['PINOKIO_SHARE_CLOUDFLARE', 'PINOKIO_SHARE_LOCAL'].some((name) =>
|| (shareVar !== '' && shareVar !== '__gev_sharing_disabled__'); /^(1|true)$/i.test(String(env[name] || '').trim()),
) ||
(shareVar !== '' && shareVar !== '__gev_sharing_disabled__');
if (sharingEnabled) { if (sharingEnabled) {
return { ok: false, status: 403, error: 'Provider Settings is disabled while sharing is enabled' }; return {
ok: false,
status: 403,
error: 'Provider Settings is disabled while sharing is enabled',
};
} }
if (!LOOPBACK_ADDRESSES.has(String(remoteAddress || ''))) { if (!LOOPBACK_ADDRESSES.has(String(remoteAddress || ''))) {
return { ok: false, status: 403, error: 'Provider Settings answers only the machine running the server' }; return {
ok: false,
status: 403,
error: 'Provider Settings answers only the machine running the server',
};
} }
const authority = localAuthority(hostHeader, protocol); const authority = localAuthority(hostHeader, protocol);
if (!authority) { if (!authority) {
return { ok: false, status: 403, error: 'Provider Settings answers only local hostnames' }; return {
ok: false,
status: 403,
error: 'Provider Settings answers only local hostnames',
};
} }
if (method === 'POST' && (origin === undefined || origin === null || origin === '')) { if (
return { ok: false, status: 403, error: 'Provider Settings requires an exact local Origin' }; method === 'POST' &&
(origin === undefined || origin === null || origin === '')
) {
return {
ok: false,
status: 403,
error: 'Provider Settings requires an exact local Origin',
};
} }
if (origin !== undefined && origin !== null && origin !== '') { if (origin !== undefined && origin !== null && origin !== '') {
let parsedOrigin; let parsedOrigin;
@@ -248,18 +294,32 @@ export function admitKeySetupRequest({
} catch { } catch {
return { ok: false, status: 403, error: 'Unrecognized Origin refused' }; return { ok: false, status: 403, error: 'Unrecognized Origin refused' };
} }
const exactOrigin = parsedOrigin.username === '' const exactOrigin =
&& parsedOrigin.password === '' parsedOrigin.username === '' &&
&& parsedOrigin.pathname === '/' parsedOrigin.password === '' &&
&& parsedOrigin.search === '' parsedOrigin.pathname === '/' &&
&& parsedOrigin.hash === '' parsedOrigin.search === '' &&
&& parsedOrigin.origin === authority; parsedOrigin.hash === '' &&
parsedOrigin.origin === authority;
if (!exactOrigin) { if (!exactOrigin) {
return { ok: false, status: 403, error: 'Cross-origin requests are refused' }; return {
ok: false,
status: 403,
error: 'Cross-origin requests are refused',
};
} }
} }
if (method === 'POST' && !String(contentType || '').toLowerCase().startsWith('application/json')) { if (
return { ok: false, status: 415, error: 'Content-Type must be application/json' }; method === 'POST' &&
!String(contentType || '')
.toLowerCase()
.startsWith('application/json')
) {
return {
ok: false,
status: 415,
error: 'Content-Type must be application/json',
};
} }
return { ok: true }; return { ok: true };
} }
@@ -337,12 +397,18 @@ export function keySetupStatus(env = {}) {
*/ */
export function validateKeySetupUpdates(body) { export function validateKeySetupUpdates(body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) { if (!body || typeof body !== 'object' || Array.isArray(body)) {
return { ok: false, error: 'Body must be a JSON object of {ENV_VAR: value}' }; return {
ok: false,
error: 'Body must be a JSON object of {ENV_VAR: value}',
};
} }
const entries = Object.entries(body); const entries = Object.entries(body);
if (entries.length === 0) return { ok: false, error: 'No keys provided' }; if (entries.length === 0) return { ok: false, error: 'No keys provided' };
if (entries.length > KEY_SETUP_UPDATE_LIMIT) { if (entries.length > KEY_SETUP_UPDATE_LIMIT) {
return { ok: false, error: `At most ${KEY_SETUP_UPDATE_LIMIT} keys per save` }; return {
ok: false,
error: `At most ${KEY_SETUP_UPDATE_LIMIT} keys per save`,
};
} }
const known = knownKeySetupEnvVars(); const known = knownKeySetupEnvVars();
const updates = {}; const updates = {};
@@ -352,14 +418,21 @@ export function validateKeySetupUpdates(body) {
updates[name] = null; updates[name] = null;
continue; continue;
} }
if (typeof raw !== 'string') return { ok: false, error: `${name} must be a string` }; if (typeof raw !== 'string')
return { ok: false, error: `${name} must be a string` };
const value = raw.trim(); const value = raw.trim();
if (!value) return { ok: false, error: `${name} is empty` }; if (!value) return { ok: false, error: `${name} is empty` };
if (value.length > KEY_SETUP_VALUE_LIMIT) { if (value.length > KEY_SETUP_VALUE_LIMIT) {
return { ok: false, error: `${name} is longer than any real key (${KEY_SETUP_VALUE_LIMIT} max)` }; return {
ok: false,
error: `${name} is longer than any real key (${KEY_SETUP_VALUE_LIMIT} max)`,
};
} }
if (!/^[\x21-\x7e]+$/.test(value)) { if (!/^[\x21-\x7e]+$/.test(value)) {
return { ok: false, error: `${name} may only contain printable characters with no spaces` }; return {
ok: false,
error: `${name} may only contain printable characters with no spaces`,
};
} }
// Reject the dotenv metacharacters that would round-trip WRONG when written // Reject the dotenv metacharacters that would round-trip WRONG when written
// unquoted (# starts a comment, quotes redelimit, $ expands, backslash and // unquoted (# starts a comment, quotes redelimit, $ expands, backslash and
@@ -367,7 +440,10 @@ export function validateKeySetupUpdates(body) {
// parseEnv and Vite's expansion read back. Real provider keys never contain // parseEnv and Vite's expansion read back. Real provider keys never contain
// these; they are base64url / hex / JWT alphabets. // these; they are base64url / hex / JWT alphabets.
if (/[#"'$\\`]/.test(value)) { if (/[#"'$\\`]/.test(value)) {
return { ok: false, error: `${name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)` }; return {
ok: false,
error: `${name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)`,
};
} }
updates[name] = value; updates[name] = value;
} }
+15 -3
View File
@@ -1336,7 +1336,12 @@ function buildingPitch(bounds) {
return -32; return -32;
} }
async function resolveBuildingBounds(lat, lon, query, boundaries = applicationServices.boundaries) { async function resolveBuildingBounds(
lat,
lon,
query,
boundaries = applicationServices.boundaries,
) {
const overpassQuery = ` const overpassQuery = `
[out:json][timeout:10]; [out:json][timeout:10];
( (
@@ -1352,8 +1357,15 @@ async function resolveBuildingBounds(lat, lon, query, boundaries = applicationSe
const controller = new AbortController(); const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 6000); const timeout = window.setTimeout(() => controller.abort(), 6000);
try { try {
const elements = await boundaries.query(overpassQuery, { signal: controller.signal }); const elements = await boundaries.query(overpassQuery, {
return selectBuildingBounds(Array.isArray(elements) ? elements : [], lat, lon, query); signal: controller.signal,
});
return selectBuildingBounds(
Array.isArray(elements) ? elements : [],
lat,
lon,
query,
);
} catch { } catch {
return null; return null;
} finally { } finally {
+38 -8
View File
@@ -13,9 +13,27 @@ const GAZE_EPSILON = 0.08;
* @param {number} [maxOffset=MAX_GAZE_SVG_UNITS] - Maximum SVG-space translation. * @param {number} [maxOffset=MAX_GAZE_SVG_UNITS] - Maximum SVG-space translation.
* @returns {{x:number, y:number}} * @returns {{x:number, y:number}}
*/ */
export function calculateLogoGaze(clientX, clientY, rect, maxOffset = MAX_GAZE_SVG_UNITS) { export function calculateLogoGaze(
const values = [clientX, clientY, rect?.left, rect?.top, rect?.width, rect?.height, maxOffset]; clientX,
if (!values.every(Number.isFinite) || rect.width <= 0 || rect.height <= 0 || maxOffset < 0) { clientY,
rect,
maxOffset = MAX_GAZE_SVG_UNITS,
) {
const values = [
clientX,
clientY,
rect?.left,
rect?.top,
rect?.width,
rect?.height,
maxOffset,
];
if (
!values.every(Number.isFinite) ||
rect.width <= 0 ||
rect.height <= 0 ||
maxOffset < 0
) {
return { x: 0, y: 0 }; return { x: 0, y: 0 };
} }
@@ -25,7 +43,7 @@ export function calculateLogoGaze(clientX, clientY, rect, maxOffset = MAX_GAZE_S
if (distance === 0) return { x: 0, y: 0 }; if (distance === 0) return { x: 0, y: 0 };
const strength = Math.min(distance / FULL_GAZE_DISTANCE_PX, 1); const strength = Math.min(distance / FULL_GAZE_DISTANCE_PX, 1);
const scale = maxOffset * strength / distance; const scale = (maxOffset * strength) / distance;
return { x: dx * scale, y: dy * scale }; return { x: dx * scale, y: dy * scale };
} }
@@ -43,7 +61,9 @@ export function initLogoGaze(root = document) {
const logos = [...root.querySelectorAll('[data-logo-gaze]')]; const logos = [...root.querySelectorAll('[data-logo-gaze]')];
if (!logos.length) return () => {}; if (!logos.length) return () => {};
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; const reducedMotion = window.matchMedia?.(
'(prefers-reduced-motion: reduce)',
).matches;
const states = logos.map((element) => ({ const states = logos.map((element) => ({
element, element,
parts: [], parts: [],
@@ -68,7 +88,10 @@ export function initLogoGaze(root = document) {
const deltaX = state.targetX - state.currentX; const deltaX = state.targetX - state.currentX;
const deltaY = state.targetY - state.currentY; const deltaY = state.targetY - state.currentY;
if (Math.abs(deltaX) <= GAZE_EPSILON && Math.abs(deltaY) <= GAZE_EPSILON) { if (
Math.abs(deltaX) <= GAZE_EPSILON &&
Math.abs(deltaY) <= GAZE_EPSILON
) {
state.currentX = state.targetX; state.currentX = state.targetX;
state.currentY = state.targetY; state.currentY = state.targetY;
} else { } else {
@@ -94,7 +117,10 @@ export function initLogoGaze(root = document) {
const markup = await response.text(); const markup = await response.text();
if (disposed) return; if (disposed) return;
const svgDocument = new DOMParser().parseFromString(markup, 'image/svg+xml'); const svgDocument = new DOMParser().parseFromString(
markup,
'image/svg+xml',
);
if (svgDocument.querySelector('parsererror')) return; if (svgDocument.querySelector('parsererror')) return;
const sourceSvg = svgDocument.documentElement; const sourceSvg = svgDocument.documentElement;
@@ -122,7 +148,11 @@ export function initLogoGaze(root = document) {
const onPointerMove = (event) => { const onPointerMove = (event) => {
if (reducedMotion || event.pointerType === 'touch') return; if (reducedMotion || event.pointerType === 'touch') return;
for (const state of states) { for (const state of states) {
const gaze = calculateLogoGaze(event.clientX, event.clientY, state.element.getBoundingClientRect()); const gaze = calculateLogoGaze(
event.clientX,
event.clientY,
state.element.getBoundingClientRect(),
);
state.targetX = gaze.x; state.targetX = gaze.x;
state.targetY = gaze.y; state.targetY = gaze.y;
} }
+14 -8
View File
@@ -30,12 +30,14 @@ export function announceNavigationAuthority(
{ eventTarget = globalThis.window, cancelPendingSelection = true } = {}, { eventTarget = globalThis.window, cancelPendingSelection = true } = {},
) { ) {
if (typeof eventTarget?.dispatchEvent !== 'function') return false; if (typeof eventTarget?.dispatchEvent !== 'function') return false;
eventTarget.dispatchEvent(new CustomEvent(NAVIGATION_AUTHORITY_EVENT, { eventTarget.dispatchEvent(
detail: { new CustomEvent(NAVIGATION_AUTHORITY_EVENT, {
reason: String(reason || 'layer-focus'), detail: {
cancelPendingSelection: Boolean(cancelPendingSelection), reason: String(reason || 'layer-focus'),
}, cancelPendingSelection: Boolean(cancelPendingSelection),
})); },
}),
);
return true; return true;
} }
@@ -46,8 +48,12 @@ export function announceNavigationAuthority(
* @returns {() => void} Idempotent disposer. * @returns {() => void} Idempotent disposer.
*/ */
export function registerNavigationAuthorityListener(eventTarget, listener) { export function registerNavigationAuthorityListener(eventTarget, listener) {
if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener if (
|| typeof listener !== 'function') return () => {}; !eventTarget?.addEventListener ||
!eventTarget?.removeEventListener ||
typeof listener !== 'function'
)
return () => {};
eventTarget.addEventListener(NAVIGATION_AUTHORITY_EVENT, listener); eventTarget.addEventListener(NAVIGATION_AUTHORITY_EVENT, listener);
let disposed = false; let disposed = false;
return () => { return () => {
+5 -2
View File
@@ -1,5 +1,8 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { holdContinuousRender, releaseContinuousRender } from './renderGovernor.js'; import {
holdContinuousRender,
releaseContinuousRender,
} from './renderGovernor.js';
/** /**
* OrbitController — smooth orbit around a target point. * OrbitController — smooth orbit around a target point.
@@ -54,7 +57,7 @@ export class OrbitController {
const hpr = new Cesium.HeadingPitchRange( const hpr = new Cesium.HeadingPitchRange(
this.angle, this.angle,
Cesium.Math.toRadians(this.pitch), Cesium.Math.toRadians(this.pitch),
this.radius this.radius,
); );
this.viewer.camera.lookAt(this.target, hpr); this.viewer.camera.lookAt(this.target, hpr);
}); });
File diff suppressed because it is too large. Load diff
+388 -199
View File
@@ -103,7 +103,8 @@ const WARMUP_FRAMES = Number(process.env.GEV_ALLOC_WARMUP) || 3000;
// tightest sound estimate of the steady rate. // tightest sound estimate of the steady rate.
const CHUNK_FRAMES = Number(process.env.GEV_ALLOC_CHUNK) || 48; const CHUNK_FRAMES = Number(process.env.GEV_ALLOC_CHUNK) || 48;
const CHUNK_COUNT = Number(process.env.GEV_ALLOC_CHUNKS) || 10; const CHUNK_COUNT = Number(process.env.GEV_ALLOC_CHUNKS) || 10;
const STABILIZATION_CHUNKS = Number(process.env.GEV_ALLOC_STABILIZATION_CHUNKS) || 6; const STABILIZATION_CHUNKS =
Number(process.env.GEV_ALLOC_STABILIZATION_CHUNKS) || 6;
const FRAME_MS = 16; const FRAME_MS = 16;
const VIEWPORT_WIDTH = 1600; const VIEWPORT_WIDTH = 1600;
const VIEWPORT_HEIGHT = 900; const VIEWPORT_HEIGHT = 900;
@@ -124,7 +125,9 @@ function silentContext() {
filter: 'none', filter: 'none',
globalAlpha: 1, globalAlpha: 1,
globalCompositeOperation: 'source-over', globalCompositeOperation: 'source-over',
measureText(text) { return { width: String(text).length * 6 }; }, measureText(text) {
return { width: String(text).length * 6 };
},
setTransform() {}, setTransform() {},
clearRect() {}, clearRect() {},
save() {}, save() {},
@@ -188,7 +191,9 @@ function installMockEnvironment({ width, height, dpr }) {
return child; return child;
} }
insertBefore(child) { return this.appendChild(child); } insertBefore(child) {
return this.appendChild(child);
}
setAttribute(name, value) { setAttribute(name, value) {
if (name === 'id') this.id = String(value); if (name === 'id') this.id = String(value);
@@ -196,9 +201,13 @@ function installMockEnvironment({ width, height, dpr }) {
if (this.id) byId.set(this.id, this); if (this.id) byId.set(this.id, this);
} }
getBoundingClientRect() { return this._rect; } getBoundingClientRect() {
return this._rect;
}
getContext() { return this.tagName === 'CANVAS' ? ctx : null; } getContext() {
return this.tagName === 'CANVAS' ? ctx : null;
}
querySelector(selector) { querySelector(selector) {
const wanted = selector.slice(1); const wanted = selector.slice(1);
@@ -208,24 +217,40 @@ function installMockEnvironment({ width, height, dpr }) {
remove() { remove() {
if (this.parentElement) { if (this.parentElement) {
this.parentElement.children = this.parentElement.children.filter((child) => child !== this); this.parentElement.children = this.parentElement.children.filter(
(child) => child !== this,
);
} }
if (this.id) byId.delete(this.id); if (this.id) byId.delete(this.id);
this.parentElement = null; this.parentElement = null;
} }
get nextSibling() { return null; } get nextSibling() {
return null;
}
} }
const body = new MockElement('body'); const body = new MockElement('body');
body.classList = { contains() { return false; } }; body.classList = {
contains() {
return false;
},
};
const document = { const document = {
body, body,
createElement(tagName) { return new MockElement(tagName); }, createElement(tagName) {
getElementById(id) { return byId.get(id) || null; }, return new MockElement(tagName);
querySelector(selector) { return byId.get(selector.slice(1)) || null; }, },
getElementById(id) {
return byId.get(id) || null;
},
querySelector(selector) {
return byId.get(selector.slice(1)) || null;
},
querySelectorAll(selector) { querySelectorAll(selector) {
const element = selector.startsWith('#') ? byId.get(selector.slice(1)) : null; const element = selector.startsWith('#')
? byId.get(selector.slice(1))
: null;
return element ? [element] : []; return element ? [element] : [];
}, },
}; };
@@ -234,11 +259,19 @@ function installMockEnvironment({ width, height, dpr }) {
devicePixelRatio: dpr, devicePixelRatio: dpr,
addEventListener() {}, addEventListener() {},
removeEventListener() {}, removeEventListener() {},
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; }, getComputedStyle() {
return { display: 'block', visibility: 'visible', opacity: '1' };
},
}; };
globalThis.ResizeObserver = class { observe() {} disconnect() {} }; globalThis.ResizeObserver = class {
globalThis.MutationObserver = class { observe() {} disconnect() {} }; observe() {}
disconnect() {}
};
globalThis.MutationObserver = class {
observe() {}
disconnect() {}
};
globalThis.document = document; globalThis.document = document;
globalThis.window = window; globalThis.window = window;
@@ -249,11 +282,15 @@ function installMockEnvironment({ width, height, dpr }) {
// Array-backed so raising a frame allocates nothing: the harness floor has // Array-backed so raising a frame allocates nothing: the harness floor has
// to stay far below the budget the gate asserts. // to stay far below the budget the gate asserts.
class MockEvent { class MockEvent {
constructor() { this.listeners = []; } constructor() {
this.listeners = [];
}
addEventListener(listener) { addEventListener(listener) {
this.listeners.push(listener); this.listeners.push(listener);
return () => { this.listeners.length = 0; }; return () => {
this.listeners.length = 0;
};
} }
raise() { raise() {
@@ -269,7 +306,9 @@ function installMockEnvironment({ width, height, dpr }) {
positionWC: new Cesium.Cartesian3(0, 0, 10_000_000), positionWC: new Cesium.Cartesian3(0, 0, 10_000_000),
positionCartographic: { height: 1000 }, positionCartographic: { height: 1000 },
viewMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), viewMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
frustum: { projectionMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY) }, frustum: {
projectionMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
},
moveEnd: new MockEvent(), moveEnd: new MockEvent(),
}, },
scene: { postRender, requestRender() {} }, scene: { postRender, requestRender() {} },
@@ -278,7 +317,9 @@ function installMockEnvironment({ width, height, dpr }) {
return { return {
viewer, viewer,
postRender, postRender,
advanceTime(ms) { currentTime += ms; }, advanceTime(ms) {
currentTime += ms;
},
}; };
} }
@@ -334,16 +375,18 @@ function buildLocalInfrastructureWorkload(count) {
id: `${isDatacenter ? 'dc' : 'dam'}-${index}`, id: `${isDatacenter ? 'dc' : 'dam'}-${index}`,
layerId: sourceId, layerId: sourceId,
position: workload.entries[index].position, position: workload.entries[index].position,
properties: isDatacenter ? { properties: isDatacenter
tags: { ? {
name: `DC ${1000 + index}`, tags: {
operator: `Operator ${index % 17}`, name: `DC ${1000 + index}`,
'capacity:it_load': `${20 + index % 40} MW`, operator: `Operator ${index % 17}`,
}, 'capacity:it_load': `${20 + (index % 40)} MW`,
} : { },
name: `Dam ${1000 + index}`, }
tags: { associated_river: `River ${index % 23}` }, : {
}, name: `Dam ${1000 + index}`,
tags: { associated_river: `River ${index % 23}` },
},
priority: index % 7, priority: index % 7,
accent: isDatacenter ? '#00ffff' : '#0088ff', accent: isDatacenter ? '#00ffff' : '#0088ff',
}); });
@@ -360,12 +403,18 @@ function buildLocalInfrastructureWorkload(count) {
{ {
sourceId: 'local-datacenters', sourceId: 'local-datacenters',
entries: datacenters, entries: datacenters,
options: { collisionCapacity: 96, cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT }, options: {
collisionCapacity: 96,
cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT,
},
}, },
{ {
sourceId: 'local-dams', sourceId: 'local-dams',
entries: dams, entries: dams,
options: { collisionCapacity: 96, cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT }, options: {
collisionCapacity: 96,
cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT,
},
}, },
], ],
}; };
@@ -374,7 +423,8 @@ function buildLocalInfrastructureWorkload(count) {
function buildPhase3FirmsWorkload(count) { function buildPhase3FirmsWorkload(count) {
const localCount = LOCAL_OVERLAY_COHORT_LIMIT * 2; const localCount = LOCAL_OVERLAY_COHORT_LIMIT * 2;
const expectedCount = localCount + FIRMS_AMBIENT_COHORT_LIMIT; const expectedCount = localCount + FIRMS_AMBIENT_COHORT_LIMIT;
if (count !== expectedCount) throw new Error(`phase3-firms requires ${expectedCount} entries`); if (count !== expectedCount)
throw new Error(`phase3-firms requires ${expectedCount} entries`);
const workload = buildWorkload(count); const workload = buildWorkload(count);
const datacenters = []; const datacenters = [];
const dams = []; const dams = [];
@@ -389,8 +439,13 @@ function buildPhase3FirmsWorkload(count) {
layerId: sourceId, layerId: sourceId,
position, position,
properties: isDatacenter properties: isDatacenter
? { tags: { name: `DC ${index}`, operator: `Operator ${index % 17}` } } ? {
: { name: `Dam ${index}`, tags: { associated_river: `River ${index % 23}` } }, tags: { name: `DC ${index}`, operator: `Operator ${index % 17}` },
}
: {
name: `Dam ${index}`,
tags: { associated_river: `River ${index % 23}` },
},
priority: index % 7, priority: index % 7,
accent: isDatacenter ? '#00ffff' : '#0088ff', accent: isDatacenter ? '#00ffff' : '#0088ff',
}); });
@@ -399,16 +454,21 @@ function buildPhase3FirmsWorkload(count) {
continue; continue;
} }
const fireIndex = index - localCount; const fireIndex = index - localCount;
firms.push(applyFirmsOverlayPolicy({ firms.push(
id: `fire:${fireIndex}`, applyFirmsOverlayPolicy(
position, {
gapPx: 10, id: `fire:${fireIndex}`,
accent: '224, 82, 82', position,
title: `▲ ${50 + fireIndex} MW`, gapPx: 10,
details: ['high · 2h · N20'], accent: '224, 82, 82',
selected: false, title: `▲ ${50 + fireIndex} MW`,
priority: 100 - fireIndex, details: ['high · 2h · N20'],
}, 12_000_000)); selected: false,
priority: 100 - fireIndex,
},
12_000_000,
),
);
firms.at(-1).horizonCull = false; firms.at(-1).horizonCull = false;
} }
return { return {
@@ -417,12 +477,18 @@ function buildPhase3FirmsWorkload(count) {
{ {
sourceId: 'local-datacenters', sourceId: 'local-datacenters',
entries: datacenters, entries: datacenters,
options: { collisionCapacity: 96, cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT }, options: {
collisionCapacity: 96,
cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT,
},
}, },
{ {
sourceId: 'local-dams', sourceId: 'local-dams',
entries: dams, entries: dams,
options: { collisionCapacity: 96, cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT }, options: {
collisionCapacity: 96,
cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT,
},
}, },
{ {
sourceId: FIRMS_OVERLAY_SOURCE_ID, sourceId: FIRMS_OVERLAY_SOURCE_ID,
@@ -439,9 +505,14 @@ function buildPhase3FirmsWorkload(count) {
function buildPhase3VesselsWorkload(count) { function buildPhase3VesselsWorkload(count) {
const localCount = LOCAL_OVERLAY_COHORT_LIMIT * 2; const localCount = LOCAL_OVERLAY_COHORT_LIMIT * 2;
const vesselAmbientCount = vesselOverlayCohortLimit(VIEWPORT_WIDTH, VIEWPORT_HEIGHT); const vesselAmbientCount = vesselOverlayCohortLimit(
const expectedCount = localCount + FIRMS_AMBIENT_COHORT_LIMIT + vesselAmbientCount + 1; VIEWPORT_WIDTH,
if (count !== expectedCount) throw new Error(`phase3-vessels requires ${expectedCount} entries`); VIEWPORT_HEIGHT,
);
const expectedCount =
localCount + FIRMS_AMBIENT_COHORT_LIMIT + vesselAmbientCount + 1;
if (count !== expectedCount)
throw new Error(`phase3-vessels requires ${expectedCount} entries`);
const workload = buildWorkload(count); const workload = buildWorkload(count);
const datacenters = []; const datacenters = [];
const dams = []; const dams = [];
@@ -457,8 +528,13 @@ function buildPhase3VesselsWorkload(count) {
layerId: sourceId, layerId: sourceId,
position, position,
properties: isDatacenter properties: isDatacenter
? { tags: { name: `DC ${index}`, operator: `Operator ${index % 17}` } } ? {
: { name: `Dam ${index}`, tags: { associated_river: `River ${index % 23}` } }, tags: { name: `DC ${index}`, operator: `Operator ${index % 17}` },
}
: {
name: `Dam ${index}`,
tags: { associated_river: `River ${index % 23}` },
},
priority: index % 7, priority: index % 7,
accent: isDatacenter ? '#00ffff' : '#0088ff', accent: isDatacenter ? '#00ffff' : '#0088ff',
}); });
@@ -469,34 +545,40 @@ function buildPhase3VesselsWorkload(count) {
const firmsEnd = localCount + FIRMS_AMBIENT_COHORT_LIMIT; const firmsEnd = localCount + FIRMS_AMBIENT_COHORT_LIMIT;
if (index < firmsEnd) { if (index < firmsEnd) {
const fireIndex = index - localCount; const fireIndex = index - localCount;
const entry = applyFirmsOverlayPolicy({ const entry = applyFirmsOverlayPolicy(
id: `fire:${fireIndex}`, {
position, id: `fire:${fireIndex}`,
gapPx: 10, position,
accent: '224, 82, 82', gapPx: 10,
title: `▲ ${50 + fireIndex} MW`, accent: '224, 82, 82',
details: ['high · 2h · N20'], title: `▲ ${50 + fireIndex} MW`,
selected: false, details: ['high · 2h · N20'],
priority: 100 - fireIndex, selected: false,
}, 12_000_000); priority: 100 - fireIndex,
},
12_000_000,
);
entry.horizonCull = false; entry.horizonCull = false;
firms.push(entry); firms.push(entry);
continue; continue;
} }
const vesselIndex = index - firmsEnd; const vesselIndex = index - firmsEnd;
const selected = vesselIndex === vesselAmbientCount; const selected = vesselIndex === vesselAmbientCount;
const entry = applyVesselOverlayPolicy({ const entry = applyVesselOverlayPolicy(
id: selected ? 'vessel:selected' : `vessel:${vesselIndex}`, {
position, id: selected ? 'vessel:selected' : `vessel:${vesselIndex}`,
gapPx: selected ? 12 : 10, position,
accent: '57, 213, 255', gapPx: selected ? 12 : 10,
title: selected ? 'SELECTED VESSEL' : `VESSEL ${vesselIndex}`, accent: '57, 213, 255',
details: selected title: selected ? 'SELECTED VESSEL' : `VESSEL ${vesselIndex}`,
? ['CARGO · 14.5KT · 231°', 'MMSI 353136000 · POS: LIVE'] details: selected
: ['CARGO · 14.5KT · 231°'], ? ['CARGO · 14.5KT · 231°', 'MMSI 353136000 · POS: LIVE']
selected, : ['CARGO · 14.5KT · 231°'],
priority: selected ? 100000 : vesselAmbientCount - vesselIndex, selected,
}, 12_000_000); priority: selected ? 100000 : vesselAmbientCount - vesselIndex,
},
12_000_000,
);
entry.horizonCull = false; entry.horizonCull = false;
vessels.push(entry); vessels.push(entry);
} }
@@ -506,12 +588,18 @@ function buildPhase3VesselsWorkload(count) {
{ {
sourceId: 'local-datacenters', sourceId: 'local-datacenters',
entries: datacenters, entries: datacenters,
options: { collisionCapacity: 96, cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT }, options: {
collisionCapacity: 96,
cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT,
},
}, },
{ {
sourceId: 'local-dams', sourceId: 'local-dams',
entries: dams, entries: dams,
options: { collisionCapacity: 96, cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT }, options: {
collisionCapacity: 96,
cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT,
},
}, },
{ {
sourceId: FIRMS_OVERLAY_SOURCE_ID, sourceId: FIRMS_OVERLAY_SOURCE_ID,
@@ -524,7 +612,10 @@ function buildPhase3VesselsWorkload(count) {
{ {
sourceId: VESSEL_OVERLAY_SOURCE_ID, sourceId: VESSEL_OVERLAY_SOURCE_ID,
entries: vessels, entries: vessels,
options: { collisionCapacity: vesselAmbientCount, cohortLimit: vesselAmbientCount }, options: {
collisionCapacity: vesselAmbientCount,
cohortLimit: vesselAmbientCount,
},
}, },
], ],
ambientCardCapacity: AMBIENT_CARD_COLLISION_CAPACITY, ambientCardCapacity: AMBIENT_CARD_COLLISION_CAPACITY,
@@ -533,14 +624,21 @@ function buildPhase3VesselsWorkload(count) {
function buildPhase3TrackedWorkload(count) { function buildPhase3TrackedWorkload(count) {
const localCount = LOCAL_OVERLAY_COHORT_LIMIT * 2; const localCount = LOCAL_OVERLAY_COHORT_LIMIT * 2;
const vesselAmbientCount = vesselOverlayCohortLimit(VIEWPORT_WIDTH, VIEWPORT_HEIGHT); const vesselAmbientCount = vesselOverlayCohortLimit(
const expectedCount = localCount + FIRMS_AMBIENT_COHORT_LIMIT + vesselAmbientCount + 1; VIEWPORT_WIDTH,
if (count !== expectedCount) throw new Error(`phase3-tracked requires ${expectedCount} entries`); VIEWPORT_HEIGHT,
);
const expectedCount =
localCount + FIRMS_AMBIENT_COHORT_LIMIT + vesselAmbientCount + 1;
if (count !== expectedCount)
throw new Error(`phase3-tracked requires ${expectedCount} entries`);
const workload = buildPhase3VesselsWorkload(count); const workload = buildPhase3VesselsWorkload(count);
const vesselRegistration = workload.registrations.find( const vesselRegistration = workload.registrations.find(
({ sourceId }) => sourceId === VESSEL_OVERLAY_SOURCE_ID, ({ sourceId }) => sourceId === VESSEL_OVERLAY_SOURCE_ID,
); );
const selectedIndex = vesselRegistration.entries.findIndex(({ selected }) => selected); const selectedIndex = vesselRegistration.entries.findIndex(
({ selected }) => selected,
);
const selected = vesselRegistration.entries.splice(selectedIndex, 1)[0]; const selected = vesselRegistration.entries.splice(selectedIndex, 1)[0];
const trackedEntity = { const trackedEntity = {
gevTrackedId: 'flights:allocation-probe', gevTrackedId: 'flights:allocation-probe',
@@ -566,10 +664,14 @@ function buildPhase3TrackedWorkload(count) {
} }
function buildPhase4CctvWorkload(count) { function buildPhase4CctvWorkload(count) {
const phase3Count = LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT const phase3Count =
+ vesselOverlayCohortLimit(VIEWPORT_WIDTH, VIEWPORT_HEIGHT) + 1; LOCAL_OVERLAY_COHORT_LIMIT * 2 +
FIRMS_AMBIENT_COHORT_LIMIT +
vesselOverlayCohortLimit(VIEWPORT_WIDTH, VIEWPORT_HEIGHT) +
1;
const expectedCount = phase3Count + CCTV_AMBIENT_CARD_MAX + 1; const expectedCount = phase3Count + CCTV_AMBIENT_CARD_MAX + 1;
if (count !== expectedCount) throw new Error(`phase4-cctv requires ${expectedCount} entries`); if (count !== expectedCount)
throw new Error(`phase4-cctv requires ${expectedCount} entries`);
const workload = buildPhase3TrackedWorkload(phase3Count); const workload = buildPhase3TrackedWorkload(phase3Count);
const cctv = []; const cctv = [];
for (let index = 0; index <= CCTV_AMBIENT_CARD_MAX; index++) { for (let index = 0; index <= CCTV_AMBIENT_CARD_MAX; index++) {
@@ -616,11 +718,16 @@ function buildPhase4CctvWorkload(count) {
} }
function buildPhase5EarthquakesWorkload(count) { function buildPhase5EarthquakesWorkload(count) {
const phase4Count = LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT const phase4Count =
+ vesselOverlayCohortLimit(VIEWPORT_WIDTH, VIEWPORT_HEIGHT) + 1 LOCAL_OVERLAY_COHORT_LIMIT * 2 +
+ CCTV_AMBIENT_CARD_MAX + 1; FIRMS_AMBIENT_COHORT_LIMIT +
vesselOverlayCohortLimit(VIEWPORT_WIDTH, VIEWPORT_HEIGHT) +
1 +
CCTV_AMBIENT_CARD_MAX +
1;
const expectedCount = phase4Count + EARTHQUAKE_OVERLAY_COHORT_LIMIT; const expectedCount = phase4Count + EARTHQUAKE_OVERLAY_COHORT_LIMIT;
if (count !== expectedCount) throw new Error(`phase5-earthquakes requires ${expectedCount} entries`); if (count !== expectedCount)
throw new Error(`phase5-earthquakes requires ${expectedCount} entries`);
const workload = buildPhase4CctvWorkload(phase4Count); const workload = buildPhase4CctvWorkload(phase4Count);
const earthquakes = []; const earthquakes = [];
for (let index = 0; index < EARTHQUAKE_OVERLAY_COHORT_LIMIT; index++) { for (let index = 0; index < EARTHQUAKE_OVERLAY_COHORT_LIMIT; index++) {
@@ -640,7 +747,8 @@ function buildPhase5EarthquakesWorkload(count) {
id: `quake-${index}`, id: `quake-${index}`,
position, position,
magnitude: 2.5 + (index % 45) / 10, magnitude: 2.5 + (index % 45) / 10,
accent: index % 3 === 0 ? '#ff0000' : index % 3 === 1 ? '#ffa500' : '#ffff00', accent:
index % 3 === 0 ? '#ff0000' : index % 3 === 1 ? '#ffa500' : '#ffff00',
}); });
entry.horizonCull = false; entry.horizonCull = false;
earthquakes.push(entry); earthquakes.push(entry);
@@ -657,11 +765,17 @@ function buildPhase5EarthquakesWorkload(count) {
} }
function buildPhase5BikeshareWorkload(count) { function buildPhase5BikeshareWorkload(count) {
const earthquakeCount = LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT const earthquakeCount =
+ vesselOverlayCohortLimit(VIEWPORT_WIDTH, VIEWPORT_HEIGHT) + 1 LOCAL_OVERLAY_COHORT_LIMIT * 2 +
+ CCTV_AMBIENT_CARD_MAX + 1 + EARTHQUAKE_OVERLAY_COHORT_LIMIT; FIRMS_AMBIENT_COHORT_LIMIT +
vesselOverlayCohortLimit(VIEWPORT_WIDTH, VIEWPORT_HEIGHT) +
1 +
CCTV_AMBIENT_CARD_MAX +
1 +
EARTHQUAKE_OVERLAY_COHORT_LIMIT;
const expectedCount = earthquakeCount + 1; const expectedCount = earthquakeCount + 1;
if (count !== expectedCount) throw new Error(`phase5-bikeshare requires ${expectedCount} entries`); if (count !== expectedCount)
throw new Error(`phase5-bikeshare requires ${expectedCount} entries`);
const workload = buildPhase5EarthquakesWorkload(earthquakeCount); const workload = buildPhase5EarthquakesWorkload(earthquakeCount);
const position = new Cesium.Cartesian3(0.12, -0.08, 0); const position = new Cesium.Cartesian3(0.12, -0.08, 0);
workload.positions.push(position); workload.positions.push(position);
@@ -687,11 +801,18 @@ function buildPhase5BikeshareWorkload(count) {
} }
function buildPhase5SatellitesWorkload(count) { function buildPhase5SatellitesWorkload(count) {
const bikeshareCount = LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT const bikeshareCount =
+ vesselOverlayCohortLimit(VIEWPORT_WIDTH, VIEWPORT_HEIGHT) + 1 LOCAL_OVERLAY_COHORT_LIMIT * 2 +
+ CCTV_AMBIENT_CARD_MAX + 1 + EARTHQUAKE_OVERLAY_COHORT_LIMIT + 1; FIRMS_AMBIENT_COHORT_LIMIT +
vesselOverlayCohortLimit(VIEWPORT_WIDTH, VIEWPORT_HEIGHT) +
1 +
CCTV_AMBIENT_CARD_MAX +
1 +
EARTHQUAKE_OVERLAY_COHORT_LIMIT +
1;
const expectedCount = bikeshareCount + 1; const expectedCount = bikeshareCount + 1;
if (count !== expectedCount) throw new Error(`phase5-satellites requires ${expectedCount} entries`); if (count !== expectedCount)
throw new Error(`phase5-satellites requires ${expectedCount} entries`);
const workload = buildPhase5BikeshareWorkload(bikeshareCount); const workload = buildPhase5BikeshareWorkload(bikeshareCount);
const position = new Cesium.Cartesian3(-0.42, 0.54, 0); const position = new Cesium.Cartesian3(-0.42, 0.54, 0);
workload.positions.push(position); workload.positions.push(position);
@@ -707,9 +828,15 @@ function buildPhase5SatellitesWorkload(count) {
} }
function buildPhase5CctvProjectionWorkload(count) { function buildPhase5CctvProjectionWorkload(count) {
const satelliteCount = LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT const satelliteCount =
+ vesselOverlayCohortLimit(VIEWPORT_WIDTH, VIEWPORT_HEIGHT) + 1 LOCAL_OVERLAY_COHORT_LIMIT * 2 +
+ CCTV_AMBIENT_CARD_MAX + 1 + EARTHQUAKE_OVERLAY_COHORT_LIMIT + 2; FIRMS_AMBIENT_COHORT_LIMIT +
vesselOverlayCohortLimit(VIEWPORT_WIDTH, VIEWPORT_HEIGHT) +
1 +
CCTV_AMBIENT_CARD_MAX +
1 +
EARTHQUAKE_OVERLAY_COHORT_LIMIT +
2;
const expectedCount = satelliteCount + 1; const expectedCount = satelliteCount + 1;
if (count !== expectedCount) { if (count !== expectedCount) {
throw new Error(`phase5-cctv-projection requires ${expectedCount} entries`); throw new Error(`phase5-cctv-projection requires ${expectedCount} entries`);
@@ -738,7 +865,8 @@ function buildPhase5CivilWorkload(count) {
({ sourceId }) => sourceId === TRACKED_OVERLAY_SOURCE_ID, ({ sourceId }) => sourceId === TRACKED_OVERLAY_SOURCE_ID,
); );
const entry = registration?.entries[0]; const entry = registration?.entries[0];
if (!entry) throw new Error('phase5-civil requires the protected tracked entry'); if (!entry)
throw new Error('phase5-civil requires the protected tracked entry');
entry.title = 'ALLOC01 · FL350 · 451 kts'; entry.title = 'ALLOC01 · FL350 · 451 kts';
entry.details = ['TEST AIR · A320', 'AUS → LAX']; entry.details = ['TEST AIR · A320', 'AUS → LAX'];
return workload; return workload;
@@ -750,7 +878,8 @@ function buildPhase5MilitaryWorkload(count) {
({ sourceId }) => sourceId === TRACKED_OVERLAY_SOURCE_ID, ({ sourceId }) => sourceId === TRACKED_OVERLAY_SOURCE_ID,
); );
const entry = registration?.entries[0]; const entry = registration?.entries[0];
if (!entry) throw new Error('phase5-military requires the protected tracked entry'); if (!entry)
throw new Error('phase5-military requires the protected tracked entry');
entry.id = 'military:allocation-probe'; entry.id = 'military:allocation-probe';
entry.title = 'RCH451'; entry.title = 'RCH451';
entry.details = ['C17 · 05-8152', 'USAF · 28000 ft · 400 kt']; entry.details = ['C17 · 05-8152', 'USAF · 28000 ft · 400 kt'];
@@ -773,12 +902,17 @@ function appendRocketMissionAmbientWorkload(workload, count) {
phase: index * 0.33, phase: index * 0.33,
rate: 0.3 + (index % 6) * 0.06, rate: 0.3 + (index % 6) * 0.06,
}); });
const entry = createRocketMissionMarkerOverlayEntry({ const entry = createRocketMissionMarkerOverlayEntry(
id: `allocation-${index}`, {
name: `MISSION ${1000 + index} | PAYLOAD`, id: `allocation-${index}`,
launchSite: `Launch Complex ${index}`, name: `MISSION ${1000 + index} | PAYLOAD`,
launchTime: new Date(Date.UTC(2026, 6, 31) - index * 60_000).toISOString(), launchSite: `Launch Complex ${index}`,
}, () => position); launchTime: new Date(
Date.UTC(2026, 6, 31) - index * 60_000,
).toISOString(),
},
() => position,
);
entry.horizonCull = false; entry.horizonCull = false;
entries.push(entry); entries.push(entry);
} }
@@ -799,19 +933,29 @@ function buildRocketMissionAmbientWorkload(count) {
`rocket-missions requires ${ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT} entries`, `rocket-missions requires ${ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT} entries`,
); );
} }
return appendRocketMissionAmbientWorkload({ return appendRocketMissionAmbientWorkload(
entries: [], {
positions: [], entries: [],
drifts: [], positions: [],
registrations: [], drifts: [],
}, count); registrations: [],
},
count,
);
} }
function buildPhase5RocketMissionWorkload(count) { function buildPhase5RocketMissionWorkload(count) {
const phase5Count = LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT const phase5Count =
+ vesselOverlayCohortLimit(VIEWPORT_WIDTH, VIEWPORT_HEIGHT) + 1 LOCAL_OVERLAY_COHORT_LIMIT * 2 +
+ CCTV_AMBIENT_CARD_MAX + 1 + EARTHQUAKE_OVERLAY_COHORT_LIMIT + 3; FIRMS_AMBIENT_COHORT_LIMIT +
const expectedCount = phase5Count + ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT; vesselOverlayCohortLimit(VIEWPORT_WIDTH, VIEWPORT_HEIGHT) +
1 +
CCTV_AMBIENT_CARD_MAX +
1 +
EARTHQUAKE_OVERLAY_COHORT_LIMIT +
3;
const expectedCount =
phase5Count + ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT;
if (count !== expectedCount) { if (count !== expectedCount) {
throw new Error(`phase5-rockets requires ${expectedCount} entries`); throw new Error(`phase5-rockets requires ${expectedCount} entries`);
} }
@@ -822,23 +966,42 @@ function buildPhase5RocketMissionWorkload(count) {
} }
function buildAllLiveRadioWorkload(count) { function buildAllLiveRadioWorkload(count) {
const phase5Count = LOCAL_OVERLAY_COHORT_LIMIT * 2 + FIRMS_AMBIENT_COHORT_LIMIT const phase5Count =
+ vesselOverlayCohortLimit(VIEWPORT_WIDTH, VIEWPORT_HEIGHT) + 1 LOCAL_OVERLAY_COHORT_LIMIT * 2 +
+ CCTV_AMBIENT_CARD_MAX + 1 + EARTHQUAKE_OVERLAY_COHORT_LIMIT + 3 FIRMS_AMBIENT_COHORT_LIMIT +
+ ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT; vesselOverlayCohortLimit(VIEWPORT_WIDTH, VIEWPORT_HEIGHT) +
1 +
CCTV_AMBIENT_CARD_MAX +
1 +
EARTHQUAKE_OVERLAY_COHORT_LIMIT +
3 +
ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT;
// 2026-08-18: "every shared-host source" includes the migrated // 2026-08-18: "every shared-host source" includes the migrated
// submarine-cable cohort, so the aggregate exercises cable labels // submarine-cable cohort, so the aggregate exercises cable labels
// interacting with Radio/earthquake/mission quotas and allocation. // interacting with Radio/earthquake/mission quotas and allocation.
const expectedCount = phase5Count + RADIO_OVERLAY_COHORT_LIMIT + 1 const expectedCount =
+ CABLE_REFERENCE_LABEL_WINNER_CAP; phase5Count +
if (count !== expectedCount) throw new Error(`all-live-radio requires ${expectedCount} entries`); RADIO_OVERLAY_COHORT_LIMIT +
1 +
CABLE_REFERENCE_LABEL_WINNER_CAP;
if (count !== expectedCount)
throw new Error(`all-live-radio requires ${expectedCount} entries`);
const workload = buildPhase5RocketMissionWorkload(phase5Count); const workload = buildPhase5RocketMissionWorkload(phase5Count);
appendSubmarineCableWorkload(workload, CABLE_REFERENCE_LABEL_WINNER_CAP); appendSubmarineCableWorkload(workload, CABLE_REFERENCE_LABEL_WINNER_CAP);
const entries = []; const entries = [];
for (let index = 0; index < RADIO_OVERLAY_COHORT_LIMIT; index += 1) { for (let index = 0; index < RADIO_OVERLAY_COHORT_LIMIT; index += 1) {
const position = new Cesium.Cartesian3(-0.72 + (index % 8) * 0.18, -0.64 + Math.floor(index / 8) * 0.16, 0); const position = new Cesium.Cartesian3(
-0.72 + (index % 8) * 0.18,
-0.64 + Math.floor(index / 8) * 0.16,
0,
);
workload.positions.push(position); workload.positions.push(position);
workload.drifts.push({ baseX: position.x, baseY: position.y, phase: index * 0.21, rate: 0.35 }); workload.drifts.push({
baseX: position.x,
baseY: position.y,
phase: index * 0.21,
rate: 0.35,
});
const entry = createRadioClusterOverlayEntry({ const entry = createRadioClusterOverlayEntry({
id: `cluster-${index}`, id: `cluster-${index}`,
position: () => position, position: () => position,
@@ -851,12 +1014,20 @@ function buildAllLiveRadioWorkload(count) {
} }
const selectedPosition = new Cesium.Cartesian3(0.08, 0.12, 0); const selectedPosition = new Cesium.Cartesian3(0.08, 0.12, 0);
workload.positions.push(selectedPosition); workload.positions.push(selectedPosition);
workload.drifts.push({ baseX: selectedPosition.x, baseY: selectedPosition.y, phase: 0.5, rate: 0.4 }); workload.drifts.push({
const selectedEntry = createRadioSelectedOverlayEntry({ baseX: selectedPosition.x,
id: 'selected', baseY: selectedPosition.y,
name: 'ALLOCATION RADIO', phase: 0.5,
tags: ['news'], rate: 0.4,
}, () => selectedPosition); });
const selectedEntry = createRadioSelectedOverlayEntry(
{
id: 'selected',
name: 'ALLOCATION RADIO',
tags: ['news'],
},
() => selectedPosition,
);
selectedEntry.horizonCull = false; selectedEntry.horizonCull = false;
entries.push(selectedEntry); entries.push(selectedEntry);
workload.registrations.push({ workload.registrations.push({
@@ -895,7 +1066,8 @@ function appendSubmarineCableWorkload(workload, count) {
const entry = createCableOverlayEntry({ const entry = createCableOverlayEntry({
id: `${kind}-reference-${index}-alloc`, id: `${kind}-reference-${index}-alloc`,
kind, kind,
label: kind === 'cable' ? `Cable System ${index}` : `Landing Point ${index}`, label:
kind === 'cable' ? `Cable System ${index}` : `Landing Point ${index}`,
tip: position, tip: position,
distanceM: 200_000 + index * 40_000, distanceM: 200_000 + index * 40_000,
}); });
@@ -919,7 +1091,9 @@ function appendSubmarineCableWorkload(workload, count) {
function buildSubmarineCablesWorkload(count) { function buildSubmarineCablesWorkload(count) {
if (count !== CABLE_REFERENCE_LABEL_WINNER_CAP) { if (count !== CABLE_REFERENCE_LABEL_WINNER_CAP) {
throw new Error(`submarine-cables requires ${CABLE_REFERENCE_LABEL_WINNER_CAP} entries`); throw new Error(
`submarine-cables requires ${CABLE_REFERENCE_LABEL_WINNER_CAP} entries`,
);
} }
return appendSubmarineCableWorkload( return appendSubmarineCableWorkload(
{ entries: [], positions: [], drifts: [], registrations: [] }, { entries: [], positions: [], drifts: [], registrations: [] },
@@ -943,8 +1117,8 @@ function buildPhase6DetectionWorkload(count) {
const row = Math.floor(index / columns); const row = Math.floor(index / columns);
const baseX = -700_000 + column * spanX; const baseX = -700_000 + column * spanX;
const baseY = -525_000 + row * spanY; const baseY = -525_000 + row * spanY;
const normalizedHorizontal = (baseX * baseX + baseY * baseY) const normalizedHorizontal =
/ (equatorialRadiusM * equatorialRadiusM); (baseX * baseX + baseY * baseY) / (equatorialRadiusM * equatorialRadiusM);
const z = polarRadiusM * Math.sqrt(Math.max(0, 1 - normalizedHorizontal)); const z = polarRadiusM * Math.sqrt(Math.max(0, 1 - normalizedHorizontal));
const position = new Cesium.Cartesian3(baseX, baseY, z); const position = new Cesium.Cartesian3(baseX, baseY, z);
positions.push(position); positions.push(position);
@@ -959,7 +1133,10 @@ function buildPhase6DetectionWorkload(count) {
position, position,
sourceId: `detect-${index}`, sourceId: `detect-${index}`,
id: `D${String(index).padStart(4, '0')}`, id: `D${String(index).padStart(4, '0')}`,
metric: index % 5 === 0 ? '' : `FL${String(180 + (index % 220)).padStart(3, '0')}`, metric:
index % 5 === 0
? ''
: `FL${String(180 + (index % 220)).padStart(3, '0')}`,
type: ['AIR', 'SAT', 'SEA', 'VEH'][index % 4], type: ['AIR', 'SAT', 'SEA', 'VEH'][index % 4],
tier: index % 17 === 0 ? 'military' : undefined, tier: index % 17 === 0 ? 'military' : undefined,
}); });
@@ -970,7 +1147,9 @@ function buildPhase6DetectionWorkload(count) {
drifts, drifts,
detectionLayer: { detectionLayer: {
id: 'flights', id: 'flights',
getDetectableObjects() { return observations; }, getDetectableObjects() {
return observations;
},
}, },
}; };
} }
@@ -996,39 +1175,40 @@ function main() {
dpr: 2, dpr: 2,
}); });
initWorldOverlay(env.viewer); initWorldOverlay(env.viewer);
const workload = PROFILE === 'phase6-detection' const workload =
? buildPhase6DetectionWorkload(ENTRY_COUNT) PROFILE === 'phase6-detection'
: PROFILE === 'local-infrastructure' ? buildPhase6DetectionWorkload(ENTRY_COUNT)
? buildLocalInfrastructureWorkload(ENTRY_COUNT) : PROFILE === 'local-infrastructure'
: PROFILE === 'phase3-firms' ? buildLocalInfrastructureWorkload(ENTRY_COUNT)
? buildPhase3FirmsWorkload(ENTRY_COUNT) : PROFILE === 'phase3-firms'
: PROFILE === 'phase3-vessels' ? buildPhase3FirmsWorkload(ENTRY_COUNT)
? buildPhase3VesselsWorkload(ENTRY_COUNT) : PROFILE === 'phase3-vessels'
: PROFILE === 'phase3-tracked' ? buildPhase3VesselsWorkload(ENTRY_COUNT)
? buildPhase3TrackedWorkload(ENTRY_COUNT) : PROFILE === 'phase3-tracked'
: PROFILE === 'phase4-cctv' ? buildPhase3TrackedWorkload(ENTRY_COUNT)
? buildPhase4CctvWorkload(ENTRY_COUNT) : PROFILE === 'phase4-cctv'
: PROFILE === 'phase5-earthquakes' ? buildPhase4CctvWorkload(ENTRY_COUNT)
? buildPhase5EarthquakesWorkload(ENTRY_COUNT) : PROFILE === 'phase5-earthquakes'
: PROFILE === 'phase5-bikeshare' ? buildPhase5EarthquakesWorkload(ENTRY_COUNT)
? buildPhase5BikeshareWorkload(ENTRY_COUNT) : PROFILE === 'phase5-bikeshare'
: PROFILE === 'phase5-satellites' ? buildPhase5BikeshareWorkload(ENTRY_COUNT)
? buildPhase5SatellitesWorkload(ENTRY_COUNT) : PROFILE === 'phase5-satellites'
: PROFILE === 'phase5-cctv-projection' ? buildPhase5SatellitesWorkload(ENTRY_COUNT)
? buildPhase5CctvProjectionWorkload(ENTRY_COUNT) : PROFILE === 'phase5-cctv-projection'
: PROFILE === 'phase5-civil' ? buildPhase5CctvProjectionWorkload(ENTRY_COUNT)
? buildPhase5CivilWorkload(ENTRY_COUNT) : PROFILE === 'phase5-civil'
: PROFILE === 'phase5-military' ? buildPhase5CivilWorkload(ENTRY_COUNT)
? buildPhase5MilitaryWorkload(ENTRY_COUNT) : PROFILE === 'phase5-military'
: PROFILE === 'rocket-missions' ? buildPhase5MilitaryWorkload(ENTRY_COUNT)
? buildRocketMissionAmbientWorkload(ENTRY_COUNT) : PROFILE === 'rocket-missions'
: PROFILE === 'phase5-rockets' ? buildRocketMissionAmbientWorkload(ENTRY_COUNT)
? buildPhase5RocketMissionWorkload(ENTRY_COUNT) : PROFILE === 'phase5-rockets'
: PROFILE === 'all-live-radio' ? buildPhase5RocketMissionWorkload(ENTRY_COUNT)
? buildAllLiveRadioWorkload(ENTRY_COUNT) : PROFILE === 'all-live-radio'
: PROFILE === 'submarine-cables' ? buildAllLiveRadioWorkload(ENTRY_COUNT)
? buildSubmarineCablesWorkload(ENTRY_COUNT) : PROFILE === 'submarine-cables'
: buildWorkload(ENTRY_COUNT); ? buildSubmarineCablesWorkload(ENTRY_COUNT)
: buildWorkload(ENTRY_COUNT);
const { entries, positions, drifts } = workload; const { entries, positions, drifts } = workload;
const solveIntervalMs = Number(process.env.GEV_ALLOC_SOLVE_MS) || 125; const solveIntervalMs = Number(process.env.GEV_ALLOC_SOLVE_MS) || 125;
const detectionActive = !!workload.detectionLayer; const detectionActive = !!workload.detectionLayer;
@@ -1080,9 +1260,15 @@ function main() {
} }
const warm = getWorldOverlayDiagnostics(); const warm = getWorldOverlayDiagnostics();
const detectionWarm = detectionActive ? getDetectionDiagnostics() : null; const detectionWarm = detectionActive ? getDetectionDiagnostics() : null;
const painted = detectionActive ? detectionWarm.visibleCount : warm.paintedCount; const painted = detectionActive
const candidates = detectionActive ? detectionWarm.observationCount : warm.candidateCount; ? detectionWarm.visibleCount
const solveRevisionBefore = detectionActive ? detectionWarm.solveRevision : warm.solveRevision; : warm.paintedCount;
const candidates = detectionActive
? detectionWarm.observationCount
: warm.candidateCount;
const solveRevisionBefore = detectionActive
? detectionWarm.solveRevision
: warm.solveRevision;
const chunkRates = []; const chunkRates = [];
for (let chunk = 0; chunk < CHUNK_COUNT; chunk++) { for (let chunk = 0; chunk < CHUNK_COUNT; chunk++) {
@@ -1101,31 +1287,34 @@ function main() {
const medianBytesPerFrame = sorted[Math.floor(sorted.length / 2)]; const medianBytesPerFrame = sorted[Math.floor(sorted.length / 2)];
const perCandidate = (value) => value / Math.max(1, candidates); const perCandidate = (value) => value / Math.max(1, candidates);
process.stdout.write(`${JSON.stringify({ process.stdout.write(
ok: true, `${JSON.stringify({
profile: PROFILE, ok: true,
ambientCardCapacity: workload.ambientCardCapacity ?? null, profile: PROFILE,
entryCount: ENTRY_COUNT, ambientCardCapacity: workload.ambientCardCapacity ?? null,
warmupFrames: WARMUP_FRAMES, entryCount: ENTRY_COUNT,
stabilizationChunks: STABILIZATION_CHUNKS, warmupFrames: WARMUP_FRAMES,
chunkFrames: CHUNK_FRAMES, stabilizationChunks: STABILIZATION_CHUNKS,
chunkCount: CHUNK_COUNT, chunkFrames: CHUNK_FRAMES,
measuredFrames: CHUNK_FRAMES * CHUNK_COUNT, chunkCount: CHUNK_COUNT,
paintedCount: painted, measuredFrames: CHUNK_FRAMES * CHUNK_COUNT,
paintedBySource: warm.paintedBySource, paintedCount: painted,
candidateCount: candidates, paintedBySource: warm.paintedBySource,
detectionSelectedCount: detectionWarm?.selectedCount ?? null, candidateCount: candidates,
detectionCollectiveLabelBudget: detectionWarm?.collectiveLabelBudget ?? null, detectionSelectedCount: detectionWarm?.selectedCount ?? null,
solveCount: solveRevisionAfter - solveRevisionBefore, detectionCollectiveLabelBudget:
// Frame cost is the primary signal; the per-candidate rate is the detectionWarm?.collectiveLabelBudget ?? null,
// scale-invariant one. Neither is normalized by paintedCount, which solveCount: solveRevisionAfter - solveRevisionBefore,
// saturates at the domain collision capacity. // Frame cost is the primary signal; the per-candidate rate is the
maxBytesPerFrame, // scale-invariant one. Neither is normalized by paintedCount, which
medianBytesPerFrame, // saturates at the domain collision capacity.
maxBytesPerCandidatePerFrame: perCandidate(maxBytesPerFrame), maxBytesPerFrame,
medianBytesPerCandidatePerFrame: perCandidate(medianBytesPerFrame), medianBytesPerFrame,
chunkBytesPerFrame: chunkRates, maxBytesPerCandidatePerFrame: perCandidate(maxBytesPerFrame),
})}\n`); medianBytesPerCandidatePerFrame: perCandidate(medianBytesPerFrame),
chunkBytesPerFrame: chunkRates,
})}\n`,
);
if (detectionActive) destroyDetection(); if (detectionActive) destroyDetection();
destroyWorldOverlay(); destroyWorldOverlay();
+264 -92
View File
@@ -49,22 +49,34 @@ export function clearWorldOverlayTextMeasureCache() {
/** Install the font-loading invalidation hooks once, when the API exists. */ /** Install the font-loading invalidation hooks once, when the API exists. */
export function installWorldOverlayFontInvalidation() { export function installWorldOverlayFontInvalidation() {
if (_fontInvalidationInstalled || typeof document === 'undefined' || !document.fonts) return; if (
_fontInvalidationInstalled ||
typeof document === 'undefined' ||
!document.fonts
)
return;
_fontInvalidationInstalled = true; _fontInvalidationInstalled = true;
_observedFontSet = document.fonts; _observedFontSet = document.fonts;
const generation = ++_fontInvalidationGeneration; const generation = ++_fontInvalidationGeneration;
Promise.resolve(document.fonts.ready) Promise.resolve(document.fonts.ready)
.then(() => { .then(() => {
if (generation === _fontInvalidationGeneration) clearWorldOverlayTextMeasureCache(); if (generation === _fontInvalidationGeneration)
clearWorldOverlayTextMeasureCache();
}) })
.catch(() => {}); .catch(() => {});
_observedFontSet.addEventListener?.('loadingdone', clearWorldOverlayTextMeasureCache); _observedFontSet.addEventListener?.(
'loadingdone',
clearWorldOverlayTextMeasureCache,
);
} }
/** Remove font hooks and cached measurements when the overlay host is destroyed. */ /** Remove font hooks and cached measurements when the overlay host is destroyed. */
export function destroyWorldOverlayDraw() { export function destroyWorldOverlayDraw() {
_fontInvalidationGeneration++; _fontInvalidationGeneration++;
_observedFontSet?.removeEventListener?.('loadingdone', clearWorldOverlayTextMeasureCache); _observedFontSet?.removeEventListener?.(
'loadingdone',
clearWorldOverlayTextMeasureCache,
);
_observedFontSet = null; _observedFontSet = null;
_fontInvalidationInstalled = false; _fontInvalidationInstalled = false;
clearWorldOverlayTextMeasureCache(); clearWorldOverlayTextMeasureCache();
@@ -95,7 +107,8 @@ export function measureWorldOverlayText(ctx, text, font) {
} }
fontCache.set(normalizedText, { width, usedAt: ++_textMeasureClock }); fontCache.set(normalizedText, { width, usedAt: ++_textMeasureClock });
_textMeasureCacheSize++; _textMeasureCacheSize++;
if (_textMeasureCacheSize > TEXT_MEASURE_CACHE_LIMIT) evictOldestTextMeasureEntry(); if (_textMeasureCacheSize > TEXT_MEASURE_CACHE_LIMIT)
evictOldestTextMeasureEntry();
return width; return width;
} }
@@ -113,7 +126,14 @@ export function getWorldOverlayTextMeasureCacheSize() {
* @param {number} h * @param {number} h
* @param {number} radius * @param {number} radius
*/ */
export function roundedRectPath(path, x, y, w, h, radius = WORLD_OVERLAY_STYLE.radius) { export function roundedRectPath(
path,
x,
y,
w,
h,
radius = WORLD_OVERLAY_STYLE.radius,
) {
if (typeof path.roundRect === 'function') { if (typeof path.roundRect === 'function') {
path.roundRect(x, y, w, h, radius); path.roundRect(x, y, w, h, radius);
return; return;
@@ -137,11 +157,14 @@ export function roundedRectPath(path, x, y, w, h, radius = WORLD_OVERLAY_STYLE.r
* @param {number} [options.fadeStartRatio] * @param {number} [options.fadeStartRatio]
* @returns {number} * @returns {number}
*/ */
export function distanceFade(distanceM, { export function distanceFade(
minDistance = 0, distanceM,
maxDistance = Number.POSITIVE_INFINITY, {
fadeStartRatio = 0.7, minDistance = 0,
} = {}) { maxDistance = Number.POSITIVE_INFINITY,
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(
minAltitude = Number.NEGATIVE_INFINITY, altitudeM,
fadeStart = Number.POSITIVE_INFINITY, {
fadeEnd = Number.POSITIVE_INFINITY, minAltitude = Number.NEGATIVE_INFINITY,
} = {}) { fadeStart = 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,19 +485,22 @@ 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, {
anchorY, anchorX,
width, anchorY,
height, width,
viewportWidth, height,
viewportHeight, viewportWidth,
gap = 12, viewportHeight,
preferred = 'auto', gap = 12,
leaderOffset = 0, preferred = 'auto',
verticalOnly = false, leaderOffset = 0,
viewportMargin = 4, verticalOnly = false,
}, out = []) { viewportMargin = 4,
},
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(
? colorWithAlpha(entry.accent, 0.95) ctx,
: accentColors.leader, animationTimestamp); entry,
placement,
entry.leaderStyle === 'elbow'
? colorWithAlpha(entry.accent, 0.95)
: 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);
} }
+1 -1
View File
@@ -349,5 +349,5 @@ test('successful explicit user playback hands the speaker from voice to Radio',
assert.match(radioBindings, /togglePlayback\(\{ origin: 'user' \}\)/); assert.match(radioBindings, /togglePlayback\(\{ origin: 'user' \}\)/);
assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/); assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/);
assert.match(radioBindings, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/); assert.match(radioBindings, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/);
assert.match(realtime, /event\.origin === 'user' && event\.action === 'play' && this\.isActive\(\)[\s\S]*?this\.stop\(\{ preserveRadioPlayback: true \}\)/); assert.match(realtime, /event\.origin === 'user' &&\s*event\.action === 'play' &&\s*this\.isActive\(\)[\s\S]*?this\.stop\(\{ preserveRadioPlayback: true \}\)/);
}); });
+2 -1
View File
@@ -61,7 +61,8 @@ function applyMode() {
* @returns {void} * @returns {void}
*/ */
export function installRenderGovernor(viewer) { export function installRenderGovernor(viewer) {
if (!viewer?.scene) throw new TypeError('installRenderGovernor requires a Cesium viewer'); if (!viewer?.scene)
throw new TypeError('installRenderGovernor requires a Cesium viewer');
_viewer = viewer; _viewer = viewer;
_installed = true; _installed = true;
// Never let Cesium re-render on simulation-time deltas behind our back — // Never let Cesium re-render on simulation-time deltas behind our back —
+4 -4
View File
@@ -29,9 +29,9 @@ export function shouldExpandGlobalContextPanel({
restoring = false, restoring = false,
}) { }) {
return Boolean( return Boolean(
GLOBAL_CONTEXT_EXPLICIT_ACTIONS.has(action) GLOBAL_CONTEXT_EXPLICIT_ACTIONS.has(action) &&
&& explicitUserAction explicitUserAction &&
&& succeeded succeeded &&
&& !restoring !restoring,
); );
} }
+223 -76
View File
@@ -60,7 +60,10 @@ function normalizeLayerEntry(entry) {
if (entry && typeof entry === 'object') { if (entry && typeof entry === 'object') {
return { return {
enabled: !!entry.enabled, enabled: !!entry.enabled,
params: entry.params && typeof entry.params === 'object' ? deepClone(entry.params) : undefined, params:
entry.params && typeof entry.params === 'object'
? deepClone(entry.params)
: undefined,
}; };
} }
return { enabled: !!entry }; return { enabled: !!entry };
@@ -75,13 +78,18 @@ function normalizeLayerEntry(entry) {
* @param {number} [options.fallbackIntensity] - Default intensity if not stored * @param {number} [options.fallbackIntensity] - Default intensity if not stored
* @returns {{ enabled: boolean, intensity: number, version: number }} * @returns {{ enabled: boolean, intensity: number, version: number }}
*/ */
function normalizeBloomState(rawBloom = {}, { projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {}) { function normalizeBloomState(
rawBloom = {},
{ projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {},
) {
// Determine which bloom scale the stored value was encoded under. // Determine which bloom scale the stored value was encoded under.
// Older projects (version < PROJECT_VERSION) used scale version 1. // Older projects (version < PROJECT_VERSION) used scale version 1.
const explicitVersion = Number(rawBloom.version); const explicitVersion = Number(rawBloom.version);
const bloomVersion = Number.isFinite(explicitVersion) const bloomVersion = Number.isFinite(explicitVersion)
? explicitVersion ? explicitVersion
: (projectVersion >= PROJECT_VERSION ? BLOOM_SCALE_VERSION : 1); : projectVersion >= PROJECT_VERSION
? BLOOM_SCALE_VERSION
: 1;
const rawIntensity = Number.isFinite(Number(rawBloom.intensity)) const rawIntensity = Number.isFinite(Number(rawBloom.intensity))
? Number(rawBloom.intensity) ? Number(rawBloom.intensity)
@@ -104,9 +112,10 @@ 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 =
? deepClone(post.styleParams) post.styleParams && typeof post.styleParams === 'object'
: {}; ? deepClone(post.styleParams)
: {};
// Normalize layer targets from the recipe into canonical form // Normalize layer targets from the recipe into canonical form
const layers = {}; const layers = {};
@@ -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:
? decodeBloomIntensity(post.bloom, 1) typeof post.bloom === 'number'
: BLOOM_INTENSITY_DEFAULT, ? decodeBloomIntensity(post.bloom, 1)
: 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,15 +638,18 @@ export class SceneDirector {
return; return;
} }
const shot = normalizeShot({ const shot = normalizeShot(
id: uid('shot'), {
title: `Shot ${scene.shots.length + 1}`, id: uid('shot'),
durationSec: DEFAULT_SHOT_DURATION_SEC, title: `Shot ${scene.shots.length + 1}`,
holdSec: DEFAULT_HOLD_SEC, durationSec: DEFAULT_SHOT_DURATION_SEC,
camera, holdSec: DEFAULT_HOLD_SEC,
visual: this.styleManager.getVisualState(), camera,
layers: this._captureLayerStates(), visual: this.styleManager.getVisualState(),
}, scene.shots.length); layers: this._captureLayerStates(),
},
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,13 +807,16 @@ 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)
: [ : [
...this._project.scenes.slice(startIdx), ...this._project.scenes.slice(startIdx),
...this._project.scenes.slice(0, startIdx), ...this._project.scenes.slice(0, startIdx),
]; ];
// Flatten scenes into a sequential shot queue // Flatten scenes into a sequential shot queue
const queue = []; const queue = [];
@@ -748,12 +846,17 @@ export class SceneDirector {
* @returns {{id: string, title: string, shots: number}|null} * @returns {{id: string, title: string, shots: number}|null}
*/ */
findSceneByQuery(query) { findSceneByQuery(query) {
const q = String(query ?? '').trim().toLowerCase(); const q = String(query ?? '')
.trim()
.toLowerCase();
if (!q) return null; if (!q) return null;
const scene = this._project.scenes.find((item) => item.id === query) const scene =
|| this._project.scenes.find((item) => item.title.toLowerCase() === q) this._project.scenes.find((item) => item.id === query) ||
|| this._project.scenes.find((item) => item.title.toLowerCase().includes(q)); this._project.scenes.find((item) => item.title.toLowerCase() === q) ||
return scene ? { id: scene.id, title: scene.title, shots: scene.shots.length } : null; this._project.scenes.find((item) => item.title.toLowerCase().includes(q));
return scene
? { id: scene.id, title: scene.title, shots: scene.shots.length }
: null;
} }
/** /**
@@ -765,8 +868,12 @@ export class SceneDirector {
running: this._running, running: this._running,
selectedSceneId: this._selectedSceneId, selectedSceneId: this._selectedSceneId,
selectedShotId: this._selectedShotId, selectedShotId: this._selectedShotId,
elapsedMs: this._activeRun ? Math.max(0, Date.now() - Date.parse(this._activeRun.startedAt)) : null, elapsedMs: this._activeRun
estimatedDurationMs: this._activeRun ? Math.round(this._activeRun.estimatedDurationSec * 1000) : null, ? Math.max(0, Date.now() - Date.parse(this._activeRun.startedAt))
: null,
estimatedDurationMs: this._activeRun
? Math.round(this._activeRun.estimatedDurationSec * 1000)
: null,
sceneCount: this._project.scenes.length, sceneCount: this._project.scenes.length,
}; };
} }
@@ -792,14 +899,18 @@ export class SceneDirector {
* @returns {Promise<{started: boolean, reason?: string, shots?: number}>} * @returns {Promise<{started: boolean, reason?: string, shots?: number}>}
*/ */
startScene(sceneId, options) { startScene(sceneId, options) {
if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' }); if (this._destroyed)
return Promise.resolve({ started: false, reason: 'destroyed' });
return this._trackWork(this._startScene(sceneId, options)); return this._trackWork(this._startScene(sceneId, options));
} }
async _startScene(sceneId, { single = false } = {}) { async _startScene(sceneId, { single = false } = {}) {
if (this._running) return { started: false, reason: 'already-running' }; if (this._running) return { started: false, reason: 'already-running' };
const queue = this._buildPlaybackQueue(sceneId || this._selectedSceneId || this._project.scenes[0]?.id, { single }); const queue = this._buildPlaybackQueue(
sceneId || this._selectedSceneId || this._project.scenes[0]?.id,
{ single },
);
if (!queue.length) { if (!queue.length) {
this._updateStatus('No shots to run'); this._updateStatus('No shots to run');
return { started: false, reason: 'no-shots' }; return { started: false, reason: 'no-shots' };
@@ -872,7 +983,9 @@ export class SceneDirector {
this._renderSceneSelect(); this._renderSceneSelect();
this._renderShotList(); this._renderShotList();
this._updateStatus(`Running ${idx + 1}/${queue.length}: ${scene.title} / ${shot.title}`); this._updateStatus(
`Running ${idx + 1}/${queue.length}: ${scene.title} / ${shot.title}`,
);
this._updateRuntime(`${scene.title} · ${shot.title}`); this._updateRuntime(`${scene.title} · ${shot.title}`);
this._logEvent('shot_start', { this._logEvent('shot_start', {
@@ -897,7 +1010,11 @@ export class SceneDirector {
if (token.cancelled) break; if (token.cancelled) break;
await this._applyLayerStates(shot.layers || {}, token); await this._applyLayerStates(shot.layers || {}, token);
if (token.cancelled) break; if (token.cancelled) break;
await this._flyCamera(shot.camera, shot.durationSec || DEFAULT_SHOT_DURATION_SEC, token); await this._flyCamera(
shot.camera,
shot.durationSec || DEFAULT_SHOT_DURATION_SEC,
token,
);
if (token.cancelled) break; if (token.cancelled) break;
// Hold on the final frame before transitioning to the next shot // Hold on the final frame before transitioning to the next shot
await this._sleep((shot.holdSec || 0) * 1000, token); await this._sleep((shot.holdSec || 0) * 1000, token);
@@ -917,7 +1034,9 @@ export class SceneDirector {
} }
} catch (error) { } catch (error) {
this._updateStatus(`Error: ${error.message || 'run failed'}`); this._updateStatus(`Error: ${error.message || 'run failed'}`);
this._logEvent('scene_run_error', { message: error.message || 'unknown error' }); this._logEvent('scene_run_error', {
message: error.message || 'unknown error',
});
} finally { } finally {
this._finishRun(); this._finishRun();
} }
@@ -930,13 +1049,17 @@ export class SceneDirector {
async runNextScene() { async runNextScene() {
if (this._destroyed || this._running) return; if (this._destroyed || this._running) return;
const queue = this._buildPlaybackQueue(this._selectedSceneId || this._project.scenes[0]?.id); const queue = this._buildPlaybackQueue(
this._selectedSceneId || this._project.scenes[0]?.id,
);
if (!queue.length) return; if (!queue.length) return;
// Find the shot after the current selection, wrapping to the start // Find the shot after the current selection, wrapping to the start
let next = queue[0]; let next = queue[0];
if (this._selectedShotId) { if (this._selectedShotId) {
const idx = queue.findIndex((item) => item.shot.id === this._selectedShotId); const idx = queue.findIndex(
(item) => item.shot.id === this._selectedShotId,
);
if (idx >= 0) next = queue[(idx + 1) % queue.length]; if (idx >= 0) next = queue[(idx + 1) % queue.length];
} }
@@ -1064,13 +1187,24 @@ export class SceneDirector {
if (token?.cancelled) return abort(); if (token?.cancelled) return abort();
const signal = token?.signal; const signal = token?.signal;
const registered = new Set(this.dataManager.getAll().map((layer) => layer.id)); const registered = new Set(
for (const { id, enabled, params } of sceneLayerPlan(targetStates, registered)) { this.dataManager.getAll().map((layer) => layer.id),
const settled = await this.dataManager.setEnabled(id, enabled, signal ? { signal } : undefined); );
for (const { id, enabled, params } of sceneLayerPlan(
targetStates,
registered,
)) {
const settled = await this.dataManager.setEnabled(
id,
enabled,
signal ? { signal } : undefined,
);
if (token?.cancelled) return abort(); if (token?.cancelled) return abort();
if (settled === false) { if (settled === false) {
refused.push(id); refused.push(id);
console.warn(`[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`); console.warn(
`[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`,
);
continue; continue;
} }
applied.push(id); applied.push(id);
@@ -1105,7 +1239,8 @@ export class SceneDirector {
*/ */
async _exitIsolatingContextMode() { async _exitIsolatingContextMode() {
// Older/headless style managers may predate the Context facade. // Older/headless style managers may predate the Context facade.
if (typeof this.styleManager?.getContextModeState !== 'function') return false; if (typeof this.styleManager?.getContextModeState !== 'function')
return false;
if (typeof this.styleManager?.setContextMode !== 'function') return false; if (typeof this.styleManager?.setContextMode !== 'function') return false;
const state = this.styleManager.getContextModeState() || {}; const state = this.styleManager.getContextModeState() || {};
@@ -1115,9 +1250,17 @@ export class SceneDirector {
const result = await this.styleManager.setContextMode('off'); const result = await this.styleManager.setContextMode('off');
if (result && result.ok === false) { if (result && result.ok === false) {
console.warn(`[Scenes] Could not exit ${mode}:`, result.error || 'unknown reason'); console.warn(
this._updateStatus(`Could not exit ${mode} — scene layers may be refused`); `[Scenes] Could not exit ${mode}:`,
this._logEvent('context_mode_exit_failed', { mode, error: result.error || null }); result.error || 'unknown reason',
);
this._updateStatus(
`Could not exit ${mode} — scene layers may be refused`,
);
this._logEvent('context_mode_exit_failed', {
mode,
error: result.error || null,
});
return false; return false;
} }
this._logEvent('context_mode_exited', { mode }); this._logEvent('context_mode_exited', { mode });
@@ -1140,11 +1283,14 @@ export class SceneDirector {
// a free-text search the shot has already flown away from. // a free-text search the shot has already flown away from.
this.styleManager?.clearSearchedLocation?.(); this.styleManager?.clearSearchedLocation?.();
const duration = Math.max(0.2, Number(durationSec) || DEFAULT_SHOT_DURATION_SEC); const duration = Math.max(
0.2,
Number(durationSec) || DEFAULT_SHOT_DURATION_SEC,
);
const destination = Cesium.Cartesian3.fromDegrees( const destination = Cesium.Cartesian3.fromDegrees(
cameraState.lon, cameraState.lon,
cameraState.lat, cameraState.lat,
cameraState.alt cameraState.alt,
); );
await new Promise((resolve) => { await new Promise((resolve) => {
@@ -1244,7 +1390,9 @@ export class SceneDirector {
* Editing controls are disabled during a run; stop is disabled when idle. * Editing controls are disabled during a run; stop is disabled when idle.
* @param {boolean} isRunning * @param {boolean} isRunning
*/ */
_setButtons(isRunning) { this._publish({ type: 'buttons-changed', running: isRunning }); } _setButtons(isRunning) {
this._publish({ type: 'buttons-changed', running: isRunning });
}
_setPlaybackActive(active) { _setPlaybackActive(active) {
this._presentation.playbackActive = active; this._presentation.playbackActive = active;
@@ -1297,5 +1445,4 @@ export class SceneDirector {
payload: payload || null, payload: payload || null,
}); });
} }
} }
+240 -24
View File
@@ -22,11 +22,56 @@ export const SCENE_RECIPES = [
detectionMode: 'OFF', detectionMode: 'OFF',
}, },
cameraPath: [ cameraPath: [
{ lat: 20.0, lon: -30.0, alt: 19000000, heading: 25, pitch: -65, roll: 0, duration: 6, hold: 1 }, {
{ lat: 46.0, lon: 2.0, alt: 8000000, heading: 40, pitch: -52, roll: 0, duration: 5, hold: 1 }, lat: 20.0,
{ lat: 35.0, lon: 139.0, alt: 4200000, heading: 22, pitch: -46, roll: 0, duration: 5, hold: 1 }, lon: -30.0,
{ lat: 37.6, lon: -122.4, alt: 1700000, heading: 8, pitch: -40, roll: 0, duration: 5, hold: 1 }, alt: 19000000,
{ lat: 0.0, lon: -20.0, alt: 12000000, heading: -10, pitch: -70, roll: 0, duration: 4, hold: 0 }, heading: 25,
pitch: -65,
roll: 0,
duration: 6,
hold: 1,
},
{
lat: 46.0,
lon: 2.0,
alt: 8000000,
heading: 40,
pitch: -52,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 35.0,
lon: 139.0,
alt: 4200000,
heading: 22,
pitch: -46,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 37.6,
lon: -122.4,
alt: 1700000,
heading: 8,
pitch: -40,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 0.0,
lon: -20.0,
alt: 12000000,
heading: -10,
pitch: -70,
roll: 0,
duration: 4,
hold: 0,
},
], ],
}, },
{ {
@@ -55,11 +100,56 @@ export const SCENE_RECIPES = [
}, },
}, },
cameraPath: [ cameraPath: [
{ lat: 28.0, lon: -82.0, alt: 22000000, heading: 0, pitch: -82, roll: 0, duration: 7, hold: 1 }, {
{ lat: 10.0, lon: 20.0, alt: 12000000, heading: 18, pitch: -74, roll: 0, duration: 5, hold: 1 }, lat: 28.0,
{ lat: 35.7, lon: 139.7, alt: 6500000, heading: 24, pitch: -64, roll: 0, duration: 5, hold: 1 }, lon: -82.0,
{ lat: 48.8, lon: 2.3, alt: 3000000, heading: 32, pitch: -58, roll: 0, duration: 5, hold: 1 }, alt: 22000000,
{ lat: 30.0, lon: -25.0, alt: 15000000, heading: -20, pitch: -78, roll: 0, duration: 4, hold: 0 }, heading: 0,
pitch: -82,
roll: 0,
duration: 7,
hold: 1,
},
{
lat: 10.0,
lon: 20.0,
alt: 12000000,
heading: 18,
pitch: -74,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 35.7,
lon: 139.7,
alt: 6500000,
heading: 24,
pitch: -64,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 48.8,
lon: 2.3,
alt: 3000000,
heading: 32,
pitch: -58,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 30.0,
lon: -25.0,
alt: 15000000,
heading: -20,
pitch: -78,
roll: 0,
duration: 4,
hold: 0,
},
], ],
}, },
{ {
@@ -88,10 +178,46 @@ export const SCENE_RECIPES = [
}, },
}, },
cameraPath: [ cameraPath: [
{ lat: 37.0, lon: -121.0, alt: 5200000, heading: 20, pitch: -62, roll: 0, duration: 5, hold: 1 }, {
{ lat: 35.7, lon: 140.0, alt: 3100000, heading: 5, pitch: -55, roll: 0, duration: 5, hold: 1 }, lat: 37.0,
{ lat: -36.8, lon: 174.7, alt: 2600000, heading: -12, pitch: -50, roll: 0, duration: 5, hold: 1 }, lon: -121.0,
{ lat: 38.0, lon: -10.0, alt: 9000000, heading: 12, pitch: -70, roll: 0, duration: 4, hold: 0 }, alt: 5200000,
heading: 20,
pitch: -62,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 35.7,
lon: 140.0,
alt: 3100000,
heading: 5,
pitch: -55,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: -36.8,
lon: 174.7,
alt: 2600000,
heading: -12,
pitch: -50,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 38.0,
lon: -10.0,
alt: 9000000,
heading: 12,
pitch: -70,
roll: 0,
duration: 4,
hold: 0,
},
], ],
}, },
{ {
@@ -120,11 +246,56 @@ export const SCENE_RECIPES = [
}, },
}, },
cameraPath: [ cameraPath: [
{ lat: 40.73, lon: -74.0, alt: 1500000, heading: 25, pitch: -44, roll: 0, duration: 5, hold: 1 }, {
{ lat: 40.73, lon: -74.0, alt: 550000, heading: 58, pitch: -36, roll: 0, duration: 4, hold: 1 }, lat: 40.73,
{ lat: 40.73, lon: -74.0, alt: 260000, heading: 102, pitch: -33, roll: 0, duration: 4, hold: 1 }, lon: -74.0,
{ lat: 34.05, lon: -118.24, alt: 1200000, heading: 45, pitch: -42, roll: 0, duration: 5, hold: 1 }, alt: 1500000,
{ lat: 51.5, lon: -0.12, alt: 1200000, heading: 22, pitch: -44, roll: 0, duration: 5, hold: 0 }, heading: 25,
pitch: -44,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 40.73,
lon: -74.0,
alt: 550000,
heading: 58,
pitch: -36,
roll: 0,
duration: 4,
hold: 1,
},
{
lat: 40.73,
lon: -74.0,
alt: 260000,
heading: 102,
pitch: -33,
roll: 0,
duration: 4,
hold: 1,
},
{
lat: 34.05,
lon: -118.24,
alt: 1200000,
heading: 45,
pitch: -42,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 51.5,
lon: -0.12,
alt: 1200000,
heading: 22,
pitch: -44,
roll: 0,
duration: 5,
hold: 0,
},
], ],
}, },
{ {
@@ -152,11 +323,56 @@ export const SCENE_RECIPES = [
}, },
}, },
cameraPath: [ cameraPath: [
{ lat: 35.68, lon: 139.76, alt: 280000, heading: 30, pitch: -26, roll: 0, duration: 5, hold: 1 }, {
{ lat: 35.68, lon: 139.76, alt: 900000, heading: 26, pitch: -38, roll: 0, duration: 4, hold: 1 }, lat: 35.68,
{ lat: 35.68, lon: 139.76, alt: 3800000, heading: 18, pitch: -52, roll: 0, duration: 5, hold: 1 }, lon: 139.76,
{ lat: 20.0, lon: 110.0, alt: 9500000, heading: 8, pitch: -66, roll: 0, duration: 5, hold: 1 }, alt: 280000,
{ lat: 5.0, lon: 30.0, alt: 18000000, heading: -8, pitch: -78, roll: 0, duration: 6, hold: 0 }, heading: 30,
pitch: -26,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 35.68,
lon: 139.76,
alt: 900000,
heading: 26,
pitch: -38,
roll: 0,
duration: 4,
hold: 1,
},
{
lat: 35.68,
lon: 139.76,
alt: 3800000,
heading: 18,
pitch: -52,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 20.0,
lon: 110.0,
alt: 9500000,
heading: 8,
pitch: -66,
roll: 0,
duration: 5,
hold: 1,
},
{
lat: 5.0,
lon: 30.0,
alt: 18000000,
heading: -8,
pitch: -78,
roll: 0,
duration: 6,
hold: 0,
},
], ],
}, },
]; ];
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more