mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Merge current main into Nepal flood contribution
This commit is contained in:
commit
1a936ebac1
136 files changed
+11006
-4580
No files matched your search
@@ -0,0 +1,4 @@
|
||||
|
||||
# Mechanical runtime formatting; executable syntax is unchanged.
|
||||
2aca7aecbb38c8554d40ddbc415fec37b3a65ec4
|
||||
b7c2c47b4154c3904337fd86d71caca5d1c602bb
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
- Preserve Nepal shot camera and map ownership through the public layer lifecycle; passive restoration does not start standalone playback.
|
||||
|
||||
- Separate transit snapshot/history acquisition from the layer and expose its bounded request service independently of Vite. Preserve feed selection, playback, cache policy and compatibility exports.
|
||||
|
||||
- Widen opaque sensor halos to 3 display pixels with a 30 px fleet core while retaining at least 60% opaque core coverage, adding contrast margin through thermal blur and bloom on bright roofs.
|
||||
|
||||
- Exclude Noir's intentionally vignetted outer field from transit core measurements and add separate Boston live oblique selection/readability coverage.
|
||||
|
||||
+7
-6
@@ -64,12 +64,13 @@ The highest-leverage places to jump in:
|
||||
|
||||
## Formatting and reusable components
|
||||
|
||||
Run `npm run format` before submitting changes to adopted modules, then
|
||||
`npm run format:check` and `npm run check:boundaries`. Formatting uses the
|
||||
explicit file list in `scripts/format-scope.json`; add newly extracted modules
|
||||
and tests there in a separate mechanical commit. Keep unadopted files consistent
|
||||
with their surrounding style. CI checks all adopted files and package boundaries
|
||||
on Linux and Windows.
|
||||
Run `npm run format` before submitting changes, then `npm run format:check`
|
||||
and `npm run check:boundaries`. Runtime JavaScript under the owned roots in
|
||||
`scripts/format-runtime.json` is discovered automatically, including new files.
|
||||
Tests and other adopted files remain listed in `scripts/format-scope.json`.
|
||||
Git-ignored files and `.prettierignore` exclusions are not automatically adopted.
|
||||
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
|
||||
through explicit callbacks. They must not import the standalone bootstrap or
|
||||
|
||||
+12
-4
@@ -1,16 +1,19 @@
|
||||
# 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
|
||||
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
|
||||
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
|
||||
source-text regression assertions still apply; investigate failures and preserve
|
||||
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
|
||||
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
|
||||
not a query validator: spatial bounds, timeouts and other policy remain in the
|
||||
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.
|
||||
@@ -24,6 +24,8 @@ Repeated map-stack requests retain the live imagery layer when its provider is u
|
||||
|
||||
Nepal Flood Incident is available in Scenes, with a 25-shot sequence, geographic labels, an event panel, scene scrubbing and shot playback. Comparison shots use Esri beneath Vantor imagery. Other Nepal shots use Google 3D when its tileset is available and fall back to Esri otherwise; without an ion token, the existing map stack uses Re:Earth terrain. Saved shots retain their authored camera and map preferences across keyed and keyless runtimes. The bundled pack contains historical 2021 and post-event 2026 imagery, not an isolated before/after measurement; the animated flood path is schematic, not a hazard model. Witness media opens or embeds original sources without bundled clips. See `public/events/bhote-koshi-2026/README.md` for provenance and non-commercial data terms.
|
||||
|
||||
Transit snapshot and selected-history reads use an explicit source interface. The standalone source selects the existing routes; rendering, playback and selection retain their existing owners. A portable request service owns registered-feed admission, bounded decoding, cache/revalidation, backoff and history; development and preview use the same thin adapter. Compatibility exports and source attribution are preserved.
|
||||
|
||||
Place search accepts an explicit Nominatim provider with independently configured search and reverse endpoints. The default offline/Google/Photon/local-fallback order is unchanged when no provider is selected. Provider adapters share normalized coordinates, viewport framing and reverse labels; roads and boundary geometry remain separate services. Portable capped-response and Overpass lexical helpers are exported independently of the Node server.
|
||||
|
||||
Reference feed construction is exported through `sources/reference`; the cable source also has a dedicated `layers/submarine-cables/source` entry. Standalone catalog compatibility remains available. Source choices, data and attribution are unchanged.
|
||||
|
||||
+3
-1
@@ -237,6 +237,8 @@
|
||||
},
|
||||
"./sources/transit": "./src/data/transitProxy.js",
|
||||
"./layers/transit": "./src/layers/transit/index.js",
|
||||
"./layers/transit/standalone": "./src/data/transit.js"
|
||||
"./layers/transit/standalone": "./src/data/transit.js",
|
||||
"./layers/transit/source": "./src/layers/transit/source.js",
|
||||
"./sources/transit-service": "./src/sources/transitService.js"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"roots": ["src", "server"]
|
||||
}
|
||||
@@ -722,5 +722,7 @@
|
||||
"scripts/qa-transit-scenes.mjs",
|
||||
"scripts/qa-transit-controls.mjs",
|
||||
"src/tooling/transitQa.test.mjs",
|
||||
"scripts/qa-transit-browser.mjs"
|
||||
"scripts/qa-transit-browser.mjs",
|
||||
"scripts/format-runtime.json",
|
||||
"src/tooling/transitService.test.mjs"
|
||||
]
|
||||
+56
-3
@@ -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 path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
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) {
|
||||
if (!['--check', '--write'].includes(mode)) {
|
||||
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.
|
||||
const files = [];
|
||||
for (const name of scope) {
|
||||
for (const name of names) {
|
||||
if (
|
||||
typeof name !== 'string' ||
|
||||
!name ||
|
||||
@@ -91,7 +144,7 @@ if (import.meta.url === invoked) {
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`${mode === '--write' ? 'Formatted' : 'Checked'} ${result.count} adopted files.`,
|
||||
`${mode === '--write' ? 'Formatted' : 'Checked'} ${result.count} source files.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -68,7 +68,8 @@
|
||||
"scripts/google-server-key.mjs",
|
||||
"src/data/placeProviderPayloads.js",
|
||||
"src/data/routeSteps.js",
|
||||
"src/sources/httpBody.js"
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/rateLimit.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -205,7 +206,8 @@
|
||||
"src/data/placeProviderPayloads.js",
|
||||
"src/data/routeSteps.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/overpass.js"
|
||||
"src/sources/overpass.js",
|
||||
"src/sources/rateLimit.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -223,7 +225,8 @@
|
||||
"server/providers/overpass/constants.js",
|
||||
"server/providers/overpass/geometry.js",
|
||||
"server/providers/overpass/transport.js",
|
||||
"src/sources/httpBody.js"
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/rateLimit.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -244,7 +247,8 @@
|
||||
"server/providers/regional/weather.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/nominatimGeocode.js",
|
||||
"src/sources/httpBody.js"
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/rateLimit.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -266,7 +270,8 @@
|
||||
"src/hudSummaryResponse.js",
|
||||
"src/voice/voiceCost.js",
|
||||
"src/voice/actionSchemas.js",
|
||||
"server/providers/openai/toolDescriptions.js"
|
||||
"server/providers/openai/toolDescriptions.js",
|
||||
"src/sources/rateLimit.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -1678,7 +1683,9 @@
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/layers/transit/trails.js",
|
||||
"src/layers/transit/movement.js",
|
||||
"src/layers/transit/qaMetrics.js"
|
||||
"src/layers/transit/qaMetrics.js",
|
||||
"src/layers/transit/source.js",
|
||||
"src/sources/transitHistory.js"
|
||||
],
|
||||
"external": [
|
||||
"@mapbox/vector-tile",
|
||||
@@ -2065,7 +2072,9 @@
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/layers/transit/trails.js",
|
||||
"src/layers/transit/movement.js",
|
||||
"src/layers/transit/qaMetrics.js"
|
||||
"src/layers/transit/qaMetrics.js",
|
||||
"src/layers/transit/source.js",
|
||||
"src/sources/transitHistory.js"
|
||||
],
|
||||
"external": [
|
||||
"cesium",
|
||||
@@ -2398,7 +2407,11 @@
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/sources/httpBody.js"
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/transitHistory.js",
|
||||
"src/sources/transitService.js",
|
||||
"src/sources/transitHistoryStore.js",
|
||||
"src/sources/rateLimit.js"
|
||||
],
|
||||
"external": ["pbf"]
|
||||
},
|
||||
@@ -2407,7 +2420,8 @@
|
||||
"modules": [
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/transitProxy.js"
|
||||
"src/data/transitProxy.js",
|
||||
"src/sources/transitHistory.js"
|
||||
],
|
||||
"external": ["pbf"]
|
||||
},
|
||||
@@ -2461,7 +2475,9 @@
|
||||
"src/services/meshFloorSampler.js",
|
||||
"src/services/requests.js",
|
||||
"src/services/terrainHeights.js",
|
||||
"src/sources/sourceSlot.js"
|
||||
"src/sources/sourceSlot.js",
|
||||
"src/layers/transit/source.js",
|
||||
"src/sources/transitHistory.js"
|
||||
],
|
||||
"external": ["cesium", "egm96-universal", "pbf"]
|
||||
},
|
||||
@@ -2496,8 +2512,32 @@
|
||||
"src/layers/transit/qaMetrics.test.mjs",
|
||||
"scripts/qa-transit-scenes.mjs",
|
||||
"scripts/qa-transit-controls.mjs",
|
||||
"scripts/qa-transit-browser.mjs"
|
||||
"scripts/qa-transit-browser.mjs",
|
||||
"src/layers/transit/source.js",
|
||||
"src/sources/transitHistory.js"
|
||||
],
|
||||
"external": ["cesium", "pbf"]
|
||||
},
|
||||
"transit-source-factory": {
|
||||
"exports": ["./layers/transit/source"],
|
||||
"modules": [
|
||||
"src/layers/transit/source.js",
|
||||
"src/sources/transitHistory.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"transit-service": {
|
||||
"exports": ["./sources/transit-service"],
|
||||
"modules": [
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/transitHistory.js",
|
||||
"src/sources/transitService.js",
|
||||
"src/sources/transitHistoryStore.js",
|
||||
"src/sources/rateLimit.js"
|
||||
],
|
||||
"external": ["pbf"]
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,10 @@
|
||||
export {
|
||||
readResponseTextCapped,
|
||||
readResponseJsonCapped,
|
||||
readResponseBytesCapped,
|
||||
coalesceProxyRequest,
|
||||
} from '../../../src/sources/httpBody.js';
|
||||
|
||||
/**
|
||||
* Read a fetch() Response body as bytes with the same hard cap as
|
||||
* readResponseTextCapped — for protobuf upstreams (GTFS-Realtime).
|
||||
* Throws { code:'RESPONSE_TOO_LARGE' }.
|
||||
*/
|
||||
export async function readResponseBytesCapped(response, maxBytes) {
|
||||
const tooLarge = () => {
|
||||
const err = new Error('Upstream response too large');
|
||||
err.code = 'RESPONSE_TOO_LARGE';
|
||||
return err;
|
||||
};
|
||||
const declared = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > maxBytes) throw tooLarge();
|
||||
const reader = response.body?.getReader?.();
|
||||
if (!reader) {
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength > maxBytes) throw tooLarge();
|
||||
return bytes;
|
||||
}
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
throw tooLarge();
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the existing promise for a cache key, or create one and remove it
|
||||
* only when that exact promise settles.
|
||||
*/
|
||||
export function coalesceProxyRequest(inFlight, key, create) {
|
||||
const existing = inFlight.get(key);
|
||||
if (existing) return { promise: existing, shared: true };
|
||||
let promise;
|
||||
promise = Promise.resolve()
|
||||
.then(create)
|
||||
.finally(() => {
|
||||
if (inFlight.get(key) === promise) inFlight.delete(key);
|
||||
});
|
||||
inFlight.set(key, promise);
|
||||
return { promise, shared: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a fetch Response body as text while enforcing a hard byte cap during
|
||||
* the read — so a malicious or buggy upstream that streams an unbounded body
|
||||
|
||||
@@ -1,38 +1,5 @@
|
||||
/**
|
||||
* Minimal fixed-window per-key rate limiter for the dev proxies. Not a hard
|
||||
* security boundary (dev-only), just a backstop so a runaway client can't hammer
|
||||
* the public Overpass / OSRM mirrors or exhaust this process.
|
||||
*/
|
||||
const RATE_LIMITER_MAX_KEYS = 2000;
|
||||
|
||||
export function makeRateLimiter({ windowMs, max, globalMax }) {
|
||||
const hits = new Map(); // key -> number[] (timestamps within window)
|
||||
let globalTimes = []; // all hits in window, for the global backstop
|
||||
return function allow(key) {
|
||||
const now = Date.now();
|
||||
globalTimes = globalTimes.filter((t) => now - t < windowMs);
|
||||
if (globalMax && globalTimes.length >= globalMax) return false; // global backstop
|
||||
const recent = (hits.get(key) || []).filter((t) => now - t < windowMs);
|
||||
if (recent.length >= max) {
|
||||
hits.set(key, recent);
|
||||
return false;
|
||||
}
|
||||
recent.push(now);
|
||||
hits.set(key, recent);
|
||||
globalTimes.push(now);
|
||||
// Hard key cap so a key-rotating caller can't grow the map without bound.
|
||||
if (hits.size > RATE_LIMITER_MAX_KEYS) {
|
||||
const oldest = hits.keys().next().value;
|
||||
if (oldest !== undefined) hits.delete(oldest);
|
||||
}
|
||||
if (hits.size > 256) {
|
||||
for (const [k, v] of hits) {
|
||||
if (!v.length || now - v[v.length - 1] > windowMs) hits.delete(k);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
||||
export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
||||
|
||||
/**
|
||||
* Opt-in per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google).
|
||||
|
||||
+17
-420
@@ -1,427 +1,24 @@
|
||||
import { createTransitHistory } from './transitHistory.js';
|
||||
/**
|
||||
* GTFS-Realtime transit proxy for the Transit data layer.
|
||||
*
|
||||
* Only URLs registered in `src/data/transitFeeds.js` are ever fetched — the
|
||||
* browser names a registered id, never a URL. Pure request/cache mechanics
|
||||
* live in `src/data/transitProxy.js` so the offline suite covers them.
|
||||
*/
|
||||
import { createTransitService } from '../../src/sources/transitService.js';
|
||||
export { fetchTransitFeed } from '../../src/sources/transitService.js';
|
||||
|
||||
import {
|
||||
coalesceProxyRequest,
|
||||
readResponseBytesCapped,
|
||||
} from './common/http.js';
|
||||
import { makeRateLimiter } from './common/rate-limit.js';
|
||||
import { publicTransitCatalog } from '../../src/data/transitFeeds.js';
|
||||
import {
|
||||
TRANSIT_ADMISSION_MAX_GLOBAL,
|
||||
TRANSIT_ADMISSION_MAX_PER_FEED,
|
||||
TRANSIT_ADMISSION_WINDOW_MS,
|
||||
TRANSIT_MAX_REDIRECTS,
|
||||
TRANSIT_PROXY_MAX_BODY_BYTES,
|
||||
TRANSIT_PROXY_TIMEOUT_MS,
|
||||
TRANSIT_PROXY_TTL_MS,
|
||||
TransitFeedShapeError,
|
||||
buildTransitSnapshot,
|
||||
isAcceptableTransitUpstreamUrl,
|
||||
isTransitRedirectStatus,
|
||||
nextTransitBackoffMs,
|
||||
resolveTransitRoute,
|
||||
transitCacheState,
|
||||
transitRedirectDecision,
|
||||
transitResponseHeaders,
|
||||
transitUpstreamHeaders,
|
||||
} from '../../src/data/transitProxy.js';
|
||||
|
||||
/**
|
||||
* Fetch a registered feed, resolving each redirect hop BEFORE following it.
|
||||
*
|
||||
* `redirect: 'follow'` hands the hop to the runtime and only lets us inspect
|
||||
* where it landed — by which point a disallowed host has already been
|
||||
* contacted and has already answered. Each hop is therefore requested with
|
||||
* `redirect: 'manual'` and its `Location` checked against the feed's own https
|
||||
* origin first, the rule the CCTV frame proxy applies to camera images.
|
||||
*
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {AbortSignal} signal Abort signal for the whole hop chain.
|
||||
* @param {typeof fetch} [fetchImpl] Injected for tests.
|
||||
* @param {{etag?: string|null, lastModified?: string|null}} [validators] Cached validators.
|
||||
* @returns {Promise<{response: Response, finalUrl: string}>}
|
||||
*/
|
||||
export async function fetchTransitFeed(
|
||||
feed,
|
||||
signal,
|
||||
fetchImpl = fetch,
|
||||
validators = null,
|
||||
) {
|
||||
let current = feed.url;
|
||||
for (let hop = 0; hop <= TRANSIT_MAX_REDIRECTS; hop += 1) {
|
||||
const response = await fetchImpl(current, {
|
||||
signal,
|
||||
headers: transitUpstreamHeaders(feed, validators),
|
||||
redirect: 'manual',
|
||||
/** Connect the reusable transit request service to development and preview. */
|
||||
export function transitProxy(options = {}) {
|
||||
const service = createTransitService(options);
|
||||
function install(server) {
|
||||
server.middlewares.use('/api/transit', async (req, res) => {
|
||||
const response = await service.handle({
|
||||
url: `http://localhost/api/transit${req.url || '/'}`,
|
||||
method: req.method,
|
||||
});
|
||||
res.writeHead(response.status, Object.fromEntries(response.headers));
|
||||
res.end(await response.text());
|
||||
});
|
||||
// Only a real redirect is followed. A 304 is the successful answer to our
|
||||
// own conditional request and has no Location; sending it round this loop
|
||||
// fails a feed that is simply unchanged.
|
||||
if (!isTransitRedirectStatus(response?.status)) {
|
||||
return { response, finalUrl: current };
|
||||
}
|
||||
const decision = transitRedirectDecision(
|
||||
feed.url,
|
||||
current,
|
||||
response.headers.get('location'),
|
||||
);
|
||||
try {
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
if (!decision.ok) throw new Error(`upstream ${decision.reason}`);
|
||||
if (hop === TRANSIT_MAX_REDIRECTS) {
|
||||
throw new Error('upstream redirected too many times');
|
||||
}
|
||||
current = decision.url;
|
||||
}
|
||||
throw new Error('upstream redirected too many times');
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin: GTFS-Realtime VehiclePositions proxy for the Transit layer.
|
||||
*
|
||||
* GET /api/transit/feeds → public catalog (coverage + credit)
|
||||
* GET /api/transit/vehicles/<feedId> → decoded snapshot as JSON
|
||||
*
|
||||
* Only URLs in `src/data/transitFeeds.js` are ever fetched — the browser
|
||||
* names a registered id, never a URL (SECURITY.md). Redirects are resolved hop
|
||||
* by hop and must stay on the feed's own https origin. Bytes are capped at
|
||||
* TRANSIT_PROXY_MAX_BODY_BYTES and decoded server-side, so the browser never
|
||||
* parses protobuf. Per feed: 15 s memory cache, single-flight refresh, and
|
||||
* serve-stale-on-failure for up to 10 minutes (the launch-library pattern).
|
||||
* No disk cache — transit positions are worthless after a few minutes.
|
||||
*
|
||||
* Two backstops sit in front of every upstream request. A fixed-window
|
||||
* admission limiter bounds how often this process may ask any one operator,
|
||||
* and all of them together, no matter how many browser tabs are open. A
|
||||
* per-feed cooldown ladder then keeps a feed that is genuinely down from being
|
||||
* re-asked once per poll for a whole session: consecutive failures push the
|
||||
* next permitted attempt out to five minutes, and one success resets it.
|
||||
*
|
||||
* @param {{fetchImpl?: typeof fetch}} [options]
|
||||
* @returns {object} Vite plugin.
|
||||
*/
|
||||
export function transitProxy({ fetchImpl = fetch } = {}) {
|
||||
/** @type {Map<string, {at:number, body:string, host:string}>} feedId → snapshot */
|
||||
const cache = new Map();
|
||||
const history = createTransitHistory();
|
||||
const admitHistory = makeRateLimiter({
|
||||
windowMs: 60000,
|
||||
max: 30,
|
||||
globalMax: 120,
|
||||
});
|
||||
let closed = false;
|
||||
const inFlight = new Map();
|
||||
const controllers = new Set();
|
||||
/** @type {Map<string, {failures:number, nextAttemptAt:number, reason:string}>} */
|
||||
const cooldown = new Map();
|
||||
const admit = makeRateLimiter({
|
||||
windowMs: TRANSIT_ADMISSION_WINDOW_MS,
|
||||
max: TRANSIT_ADMISSION_MAX_PER_FEED,
|
||||
globalMax: TRANSIT_ADMISSION_MAX_GLOBAL,
|
||||
});
|
||||
const ttlSeconds = Math.ceil(TRANSIT_PROXY_TTL_MS / 1000);
|
||||
|
||||
function send(res, status, body, headers) {
|
||||
res.writeHead(status, headers);
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function noteFailure(feedId, reason, now) {
|
||||
const previous = cooldown.get(feedId);
|
||||
const failures = (previous?.failures || 0) + 1;
|
||||
const wait = nextTransitBackoffMs(failures);
|
||||
cooldown.set(feedId, { failures, nextAttemptAt: now + wait, reason });
|
||||
return wait;
|
||||
}
|
||||
|
||||
async function refresh(feed, now) {
|
||||
const controller = new AbortController();
|
||||
controllers.add(controller);
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
TRANSIT_PROXY_TIMEOUT_MS,
|
||||
);
|
||||
const previous = cache.get(feed.id);
|
||||
try {
|
||||
const { response, finalUrl } = await fetchTransitFeed(
|
||||
feed,
|
||||
controller.signal,
|
||||
fetchImpl,
|
||||
previous
|
||||
? { etag: previous.etag, lastModified: previous.lastModified }
|
||||
: null,
|
||||
);
|
||||
if (closed) throw new Error('Transit provider closed');
|
||||
if (!isAcceptableTransitUpstreamUrl(finalUrl)) {
|
||||
throw new Error('upstream is not https');
|
||||
}
|
||||
// Nothing changed upstream: keep the snapshot we have and restart its
|
||||
// freshness window rather than asking for a body we already hold.
|
||||
if (response.status === 304 && previous) {
|
||||
try {
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
// `at` restarts the freshness window; `contactedAt` records that the
|
||||
// operator answered just now. They are different facts: the body still
|
||||
// carries the fetch time of the payload it holds, and reading that as
|
||||
// "when did we last hear from them" would age a feed that is answering
|
||||
// every single request into silence.
|
||||
const revalidated = { ...previous, at: now, contactedAt: now };
|
||||
cache.set(feed.id, revalidated);
|
||||
cooldown.delete(feed.id);
|
||||
return revalidated;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = new Error(`upstream HTTP ${response.status}`);
|
||||
error.upstreamStatus = response.status;
|
||||
throw error;
|
||||
}
|
||||
const bytes = await readResponseBytesCapped(
|
||||
response,
|
||||
TRANSIT_PROXY_MAX_BODY_BYTES,
|
||||
);
|
||||
const snapshot = buildTransitSnapshot(feed, bytes, now);
|
||||
if (closed) throw new Error('Transit provider closed');
|
||||
history.ingest(feed, snapshot.vehicles, Date.now());
|
||||
const entry = {
|
||||
at: snapshot.fetchedAt,
|
||||
contactedAt: snapshot.fetchedAt,
|
||||
body: JSON.stringify(snapshot),
|
||||
host: new URL(finalUrl).hostname,
|
||||
etag: response.headers.get('etag'),
|
||||
lastModified: response.headers.get('last-modified'),
|
||||
};
|
||||
cache.set(feed.id, entry);
|
||||
cooldown.delete(feed.id);
|
||||
return entry;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
controller.abort();
|
||||
controllers.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/transit', async (req, res) => {
|
||||
if (req.method !== 'GET') {
|
||||
send(
|
||||
res,
|
||||
405,
|
||||
JSON.stringify({ error: 'Method Not Allowed' }),
|
||||
transitResponseHeaders('NONE'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const route = resolveTransitRoute(req.url);
|
||||
if (!route) {
|
||||
send(res, 404, JSON.stringify({ error: 'Unknown transit feed' }), {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (route.route === 'feeds') {
|
||||
send(res, 200, JSON.stringify({ feeds: publicTransitCatalog() }), {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { feed } = route;
|
||||
const now = Date.now();
|
||||
if (route.route === 'trail') {
|
||||
if (!admitHistory(feed.id)) {
|
||||
send(
|
||||
res,
|
||||
429,
|
||||
JSON.stringify({ error: 'Transit history read limit reached' }),
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'Retry-After': '60',
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
send(
|
||||
res,
|
||||
200,
|
||||
JSON.stringify(history.get(feed.id, route.vehicleId, now)),
|
||||
{ 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const cached = cache.get(feed.id);
|
||||
const state = transitCacheState(cached, now);
|
||||
if (state === 'fresh') {
|
||||
send(
|
||||
res,
|
||||
200,
|
||||
cached.body,
|
||||
transitResponseHeaders('HIT', cached.host, cached.contactedAt),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Answer without contacting the operator: the cached snapshot when there
|
||||
// is one, otherwise an honest "unavailable, come back in N seconds".
|
||||
const answerWithoutUpstream = (reason, retryAfterS) => {
|
||||
if (state === 'stale' && cached) {
|
||||
// The contact time stays as old as it is: a feed being served from
|
||||
// cache during an outage has NOT answered, and saying otherwise
|
||||
// would keep a dead feed on the globe.
|
||||
send(
|
||||
res,
|
||||
200,
|
||||
cached.body,
|
||||
transitResponseHeaders(
|
||||
'STALE-ERROR',
|
||||
cached.host,
|
||||
cached.contactedAt,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
send(
|
||||
res,
|
||||
503,
|
||||
JSON.stringify({
|
||||
error: 'Transit feed unavailable',
|
||||
feedId: feed.id,
|
||||
retryInSec: retryAfterS,
|
||||
}),
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-GEV-Cache': 'NONE',
|
||||
'Retry-After': String(retryAfterS),
|
||||
'X-Transit-Backoff': reason,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const waiting = cooldown.get(feed.id);
|
||||
if (waiting && waiting.nextAttemptAt > now && !inFlight.has(feed.id)) {
|
||||
answerWithoutUpstream(
|
||||
'cooldown',
|
||||
Math.max(1, Math.ceil((waiting.nextAttemptAt - now) / 1000)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!inFlight.has(feed.id) && !admit(feed.id)) {
|
||||
answerWithoutUpstream('rate-limited', ttlSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = coalesceProxyRequest(inFlight, feed.id, () =>
|
||||
refresh(feed, now),
|
||||
);
|
||||
try {
|
||||
const fresh = await request.promise;
|
||||
send(
|
||||
res,
|
||||
200,
|
||||
fresh.body,
|
||||
transitResponseHeaders(
|
||||
request.shared ? 'INFLIGHT' : 'MISS',
|
||||
fresh.host,
|
||||
fresh.contactedAt,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const differential = error instanceof TransitFeedShapeError;
|
||||
// A shape fault condemns what we already hold. The cached copy was
|
||||
// decoded under the assumption this feed is a full snapshot, and that
|
||||
// assumption has just been shown false — leaving it in place would let
|
||||
// the very next request serve it back as a 200.
|
||||
if (differential) cache.delete(feed.id);
|
||||
if (!request.shared) {
|
||||
const wait = noteFailure(
|
||||
feed.id,
|
||||
error?.message || String(error),
|
||||
Date.now(),
|
||||
);
|
||||
console.warn(
|
||||
`[transit-proxy] ${feed.id} unavailable: ${error?.message || error} — next attempt in ${Math.round(wait / 1000)}s`,
|
||||
);
|
||||
}
|
||||
// A differential feed is a shape fault, not an outage: serving the old
|
||||
// snapshot would hide a feed this decoder must not render at all.
|
||||
if (state === 'stale' && cached && !differential) {
|
||||
// The contact time is the last SUCCESSFUL one. This request failed,
|
||||
// so it does not advance — otherwise a dead feed would look like one
|
||||
// answering on time.
|
||||
send(
|
||||
res,
|
||||
200,
|
||||
cached.body,
|
||||
transitResponseHeaders(
|
||||
'STALE-ERROR',
|
||||
cached.host,
|
||||
cached.contactedAt,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const pending = cooldown.get(feed.id);
|
||||
const retryAfterS = pending
|
||||
? Math.max(1, Math.ceil((pending.nextAttemptAt - Date.now()) / 1000))
|
||||
: ttlSeconds;
|
||||
send(
|
||||
res,
|
||||
differential ||
|
||||
error?.code === 'RESPONSE_TOO_LARGE' ||
|
||||
Number.isInteger(error?.upstreamStatus)
|
||||
? 502
|
||||
: 504,
|
||||
JSON.stringify({
|
||||
error: differential
|
||||
? 'Transit feed is differential and is not supported'
|
||||
: 'Transit feed unavailable',
|
||||
feedId: feed.id,
|
||||
retryInSec: retryAfterS,
|
||||
}),
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-GEV-Cache': 'NONE',
|
||||
'Retry-After': String(retryAfterS),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function close() {
|
||||
closed = true;
|
||||
for (const controller of controllers) controller.abort();
|
||||
controllers.clear();
|
||||
history.clear();
|
||||
cache.clear();
|
||||
cooldown.clear();
|
||||
server.httpServer?.once('close', service.close);
|
||||
}
|
||||
return {
|
||||
closeBundle: close,
|
||||
name: 'transit-proxy',
|
||||
configureServer(server) {
|
||||
install(server.middlewares);
|
||||
server.httpServer?.once('close', close);
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
install(server.middlewares);
|
||||
server.httpServer?.once('close', close);
|
||||
},
|
||||
closeBundle: service.close,
|
||||
configureServer: install,
|
||||
configurePreviewServer: install,
|
||||
};
|
||||
}
|
||||
@@ -1,493 +1,5 @@
|
||||
import { validTransitIdentifier } from '../../src/data/transitProxy.js';
|
||||
export { validTransitIdentifier } from '../../src/data/transitProxy.js';
|
||||
/** Bounded, process-local operator position history. No upstream or scene access. */
|
||||
export const TRANSIT_HISTORY_LIMITS = Object.freeze({
|
||||
retentionMs: 900000,
|
||||
fixes: 128,
|
||||
feedBytes: 16 * 1024 * 1024,
|
||||
processBytes: 64 * 1024 * 1024,
|
||||
keys: 15000,
|
||||
identifierBytes: 256,
|
||||
responseBytes: 32768,
|
||||
});
|
||||
const FIX_BYTES = 32,
|
||||
CHUNK = 8,
|
||||
EPOCH_SLOTS = 8,
|
||||
EPOCH_BYTES = 544;
|
||||
const META_BYTES = 64;
|
||||
const encoder = new TextEncoder(),
|
||||
decoder = new TextDecoder();
|
||||
// All stores in this process share the hard process allocation ceiling.
|
||||
const processStorage = { bytes: 0, keys: 0, stores: new Set() };
|
||||
|
||||
function contextOf(record, mode) {
|
||||
const trip = typeof record.tripId === 'string' ? record.tripId : '';
|
||||
const route = typeof record.routeId === 'string' ? record.routeId : '';
|
||||
if (encoder.encode(trip).length > 256 || encoder.encode(route).length > 256)
|
||||
return null;
|
||||
return { trip, route, mode: mode || 'unknown' };
|
||||
}
|
||||
function contextAt(entry, slot) {
|
||||
const offset = 0,
|
||||
view = entry.contexts[slot];
|
||||
const bytes = new Uint8Array(view.buffer);
|
||||
const length = view.getUint16(offset + 4),
|
||||
routeLength = view.getUint16(offset + 6);
|
||||
return {
|
||||
id: view.getUint32(offset),
|
||||
trip: decoder.decode(bytes.subarray(offset + 16, offset + 16 + length)),
|
||||
route: decoder.decode(
|
||||
bytes.subarray(offset + 272, offset + 272 + routeLength),
|
||||
),
|
||||
mode: decoder.decode(
|
||||
bytes.subarray(offset + 528, offset + 528 + view.getUint8(offset + 8)),
|
||||
),
|
||||
};
|
||||
}
|
||||
function storeContext(entry, context, id) {
|
||||
const slot = (id - 1) % EPOCH_SLOTS,
|
||||
offset = 0;
|
||||
const trip = encoder.encode(context.trip),
|
||||
route = encoder.encode(context.route),
|
||||
mode = encoder.encode(context.mode).subarray(0, 16);
|
||||
const view = entry.contexts[slot],
|
||||
bytes = new Uint8Array(view.buffer);
|
||||
view.setUint32(offset, id);
|
||||
view.setUint16(offset + 4, trip.length);
|
||||
view.setUint16(offset + 6, route.length);
|
||||
view.setUint8(offset + 8, mode.length);
|
||||
bytes.set(trip, offset + 16);
|
||||
bytes.set(route, offset + 272);
|
||||
bytes.set(mode, offset + 528);
|
||||
}
|
||||
function offset(entry, index) {
|
||||
return ((entry.head + index) % entry.capacity) * FIX_BYTES;
|
||||
}
|
||||
function read(entry, index) {
|
||||
const at = offset(entry, index),
|
||||
v = entry.fixes;
|
||||
return [
|
||||
v.getFloat64(at),
|
||||
v.getFloat64(at + 8),
|
||||
v.getFloat64(at + 16),
|
||||
v.getUint32(at + 24),
|
||||
v.getUint32(at + 28),
|
||||
];
|
||||
}
|
||||
function firstTime(entry) {
|
||||
return entry.count ? entry.fixes.getFloat64(offset(entry, 0)) : Infinity;
|
||||
}
|
||||
function drop(entry) {
|
||||
entry.head = (entry.head + 1) % entry.capacity;
|
||||
entry.count--;
|
||||
entry.truncated = true;
|
||||
}
|
||||
|
||||
// Indexed eviction queues keep removal and receipt-order updates logarithmic.
|
||||
function evictionQueue() {
|
||||
const items = [],
|
||||
positions = new Map();
|
||||
const less = (a, b) =>
|
||||
a.observedAt < b.observedAt ||
|
||||
(a.observedAt === b.observedAt && a.key < b.key);
|
||||
function swap(a, b) {
|
||||
[items[a], items[b]] = [items[b], items[a]];
|
||||
positions.set(items[a], a);
|
||||
positions.set(items[b], b);
|
||||
}
|
||||
function repair(index) {
|
||||
while (index > 0) {
|
||||
const parent = (index - 1) >>> 1;
|
||||
if (!less(items[index], items[parent])) break;
|
||||
swap(index, parent);
|
||||
index = parent;
|
||||
}
|
||||
for (;;) {
|
||||
let child = index * 2 + 1;
|
||||
if (child >= items.length) break;
|
||||
if (child + 1 < items.length && less(items[child + 1], items[child]))
|
||||
child++;
|
||||
if (!less(items[child], items[index])) break;
|
||||
swap(index, child);
|
||||
index = child;
|
||||
}
|
||||
}
|
||||
function remove(entry) {
|
||||
const index = positions.get(entry);
|
||||
if (index === undefined) return;
|
||||
positions.delete(entry);
|
||||
const last = items.pop();
|
||||
if (index < items.length) {
|
||||
items[index] = last;
|
||||
positions.set(last, index);
|
||||
repair(index);
|
||||
}
|
||||
}
|
||||
return {
|
||||
remove,
|
||||
add(entry) {
|
||||
remove(entry);
|
||||
positions.set(entry, items.length);
|
||||
items.push(entry);
|
||||
repair(items.length - 1);
|
||||
},
|
||||
oldest(except) {
|
||||
if (items[0] !== except) return items[0] || null;
|
||||
if (!items[1]) return null;
|
||||
return items[2] && less(items[2], items[1]) ? items[2] : items[1];
|
||||
},
|
||||
get size() {
|
||||
return items.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createTransitHistory({
|
||||
limits = {},
|
||||
storage = processStorage,
|
||||
sweep = true,
|
||||
} = {}) {
|
||||
const policy = { ...TRANSIT_HISTORY_LIMITS };
|
||||
for (const [key, limit] of Object.entries(limits))
|
||||
if (key in policy && Number.isFinite(limit) && limit > 0)
|
||||
policy[key] = Math.min(policy[key], Math.floor(limit));
|
||||
const entries = new Map(),
|
||||
feedBytes = new Map(),
|
||||
eviction = evictionQueue(),
|
||||
feedEviction = new Map();
|
||||
storage.feedBytes ||= new Map();
|
||||
storage.lossUntil ||= new Map();
|
||||
let protectedEntries = null;
|
||||
function queue(entry) {
|
||||
if (!feedEviction.has(entry.feed))
|
||||
feedEviction.set(entry.feed, evictionQueue());
|
||||
eviction.add(entry);
|
||||
feedEviction.get(entry.feed).add(entry);
|
||||
}
|
||||
function unqueue(entry) {
|
||||
eviction.remove(entry);
|
||||
const feedQueue = feedEviction.get(entry.feed);
|
||||
feedQueue?.remove(entry);
|
||||
if (feedQueue?.size === 0) feedEviction.delete(entry.feed);
|
||||
}
|
||||
function recordLoss(feed, until) {
|
||||
storage.lossUntil.set(
|
||||
feed,
|
||||
Math.max(storage.lossUntil.get(feed) || 0, until),
|
||||
);
|
||||
}
|
||||
let prunedAt = -Infinity;
|
||||
let allocatedBytes = 0,
|
||||
timer = null,
|
||||
closed = false;
|
||||
function account(feed, delta) {
|
||||
allocatedBytes += delta;
|
||||
storage.bytes += delta;
|
||||
storage.feedBytes.set(feed, (storage.feedBytes.get(feed) || 0) + delta);
|
||||
if (storage.feedBytes.get(feed) === 0) storage.feedBytes.delete(feed);
|
||||
feedBytes.set(feed, (feedBytes.get(feed) || 0) + delta);
|
||||
if (feedBytes.get(feed) === 0) feedBytes.delete(feed);
|
||||
}
|
||||
function remove(entry) {
|
||||
unqueue(entry);
|
||||
entries.delete(entry.key);
|
||||
storage.keys--;
|
||||
account(entry.feed, -entry.bytes);
|
||||
}
|
||||
function canAllocate(feed, bytes) {
|
||||
return (
|
||||
storage.bytes + bytes <= policy.processBytes &&
|
||||
(storage.feedBytes.get(feed) || 0) + bytes <= policy.feedBytes
|
||||
);
|
||||
}
|
||||
function resize(entry, capacity) {
|
||||
const next = new Uint8Array(capacity * FIX_BYTES),
|
||||
before = new Uint8Array(entry.fixes.buffer);
|
||||
for (let i = 0; i < entry.count; i++)
|
||||
next.set(
|
||||
before.subarray(offset(entry, i), offset(entry, i) + FIX_BYTES),
|
||||
i * FIX_BYTES,
|
||||
);
|
||||
const delta = next.byteLength - entry.fixes.byteLength;
|
||||
entry.fixes = new DataView(next.buffer);
|
||||
entry.head = 0;
|
||||
entry.capacity = capacity;
|
||||
entry.bytes += delta;
|
||||
account(entry.feed, delta);
|
||||
}
|
||||
function prune(now) {
|
||||
if (now === prunedAt) return;
|
||||
prunedAt = now;
|
||||
for (const [feed, until] of storage.lossUntil)
|
||||
if (until <= now) storage.lossUntil.delete(feed);
|
||||
for (const entry of entries.values()) {
|
||||
while (entry.count && firstTime(entry) < now - policy.retentionMs)
|
||||
drop(entry);
|
||||
if (!entry.count || now - entry.observedAt >= policy.retentionMs)
|
||||
remove(entry);
|
||||
else if (
|
||||
entry.capacity > CHUNK &&
|
||||
entry.count <= entry.capacity / 2 &&
|
||||
canAllocate(
|
||||
entry.feed,
|
||||
Math.max(CHUNK, Math.ceil(entry.count / CHUNK) * CHUNK) * FIX_BYTES,
|
||||
)
|
||||
)
|
||||
resize(entry, Math.max(CHUNK, Math.ceil(entry.count / CHUNK) * CHUNK));
|
||||
}
|
||||
}
|
||||
function oldest(feed = null, except = null) {
|
||||
return (feed ? feedEviction.get(feed) : eviction)?.oldest(except) || null;
|
||||
}
|
||||
function evictOne(feed = null, except = null) {
|
||||
const entry = oldest(feed, except);
|
||||
if (!entry) return false;
|
||||
recordLoss(entry.feed, entry.observedAt + policy.retentionMs);
|
||||
if (entry.count > CHUNK) {
|
||||
const target = Math.max(CHUNK, entry.capacity - CHUNK);
|
||||
if (!canAllocate(entry.feed, target * FIX_BYTES)) {
|
||||
remove(entry);
|
||||
return true;
|
||||
}
|
||||
while (entry.count > target) drop(entry);
|
||||
resize(entry, target);
|
||||
} else remove(entry);
|
||||
return true;
|
||||
}
|
||||
const api = {
|
||||
prune,
|
||||
evictOne,
|
||||
oldest,
|
||||
get allocatedBytes() {
|
||||
return allocatedBytes;
|
||||
},
|
||||
};
|
||||
storage.stores.add(api);
|
||||
function reserve(feed, bytes, except = null, newKey = false) {
|
||||
while ((storage.feedBytes.get(feed) || 0) + bytes > policy.feedBytes) {
|
||||
let owner = null,
|
||||
candidate = null;
|
||||
for (const store of storage.stores) {
|
||||
const entry = store.oldest(feed, except);
|
||||
if (entry && (!candidate || entry.observedAt < candidate.observedAt)) {
|
||||
owner = store;
|
||||
candidate = entry;
|
||||
}
|
||||
}
|
||||
if (!owner || !owner.evictOne(feed, except)) return false;
|
||||
}
|
||||
while (
|
||||
storage.bytes + bytes > policy.processBytes ||
|
||||
(newKey && storage.keys >= policy.keys)
|
||||
) {
|
||||
let owner = null,
|
||||
candidate = null;
|
||||
for (const store of storage.stores) {
|
||||
const entry = store.oldest(null, except);
|
||||
if (entry && (!candidate || entry.observedAt < candidate.observedAt)) {
|
||||
owner = store;
|
||||
candidate = entry;
|
||||
}
|
||||
}
|
||||
if (!owner || !owner.evictOne(null, except)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function ingest(feed, records, now) {
|
||||
if (
|
||||
closed ||
|
||||
feed.historyRetention !== true ||
|
||||
!validTransitIdentifier(feed.id)
|
||||
)
|
||||
return;
|
||||
prune(now);
|
||||
// Pin all existing snapshot members before admitting any new key. A later
|
||||
// row must not lose its retained path to an earlier row in the same feed.
|
||||
protectedEntries = new Set();
|
||||
for (const record of records) {
|
||||
const entry = entries.get(`${feed.id}\u0000${record.id}`);
|
||||
if (entry) {
|
||||
protectedEntries.add(entry);
|
||||
unqueue(entry);
|
||||
}
|
||||
}
|
||||
try {
|
||||
for (const record of records) {
|
||||
if (
|
||||
!validTransitIdentifier(record.id) ||
|
||||
!Number.isFinite(record.lat) ||
|
||||
Math.abs(record.lat) > 90 ||
|
||||
!Number.isFinite(record.lon) ||
|
||||
Math.abs(record.lon) > 180
|
||||
)
|
||||
continue;
|
||||
const t = record.timestamp * 1000;
|
||||
if (
|
||||
!Number.isFinite(t) ||
|
||||
t < now - policy.retentionMs ||
|
||||
t > now + 10000
|
||||
)
|
||||
continue;
|
||||
const mode =
|
||||
feed.routeMode?.(record.routeId) || feed.defaultMode || 'unknown';
|
||||
const context = contextOf(record, mode);
|
||||
if (!context) continue;
|
||||
const key = `${feed.id}\u0000${record.id}`;
|
||||
let entry = entries.get(key);
|
||||
if (entry) {
|
||||
const last = read(entry, entry.count - 1);
|
||||
if (t <= last[0]) continue;
|
||||
} else {
|
||||
const encodedKey = encoder.encode(key),
|
||||
bytes =
|
||||
META_BYTES +
|
||||
EPOCH_BYTES +
|
||||
encodedKey.byteLength +
|
||||
CHUNK * FIX_BYTES;
|
||||
if (!reserve(feed.id, bytes, null, true)) {
|
||||
recordLoss(feed.id, now + policy.retentionMs);
|
||||
continue;
|
||||
}
|
||||
entry = {
|
||||
key,
|
||||
encodedKey,
|
||||
feed: feed.id,
|
||||
vehicle: record.id,
|
||||
metadata: new DataView(new ArrayBuffer(META_BYTES)),
|
||||
contexts: [new DataView(new ArrayBuffer(EPOCH_BYTES))],
|
||||
fixes: new DataView(new ArrayBuffer(CHUNK * FIX_BYTES)),
|
||||
capacity: CHUNK,
|
||||
head: 0,
|
||||
count: 0,
|
||||
epoch: 0,
|
||||
observedAt: now,
|
||||
truncated: (storage.lossUntil.get(feed.id) || 0) > now,
|
||||
bytes,
|
||||
};
|
||||
entries.set(key, entry);
|
||||
protectedEntries.add(entry);
|
||||
storage.keys++;
|
||||
account(feed.id, bytes);
|
||||
}
|
||||
let flags =
|
||||
record.timestampSource === 'vehicle'
|
||||
? 1
|
||||
: record.timestampSource === 'header'
|
||||
? 2
|
||||
: 4;
|
||||
const previous = entry.epoch
|
||||
? contextAt(entry, (entry.epoch - 1) % EPOCH_SLOTS)
|
||||
: null;
|
||||
if (
|
||||
!previous ||
|
||||
previous.trip !== context.trip ||
|
||||
previous.route !== context.route ||
|
||||
previous.mode !== context.mode
|
||||
) {
|
||||
const slot = entry.epoch % EPOCH_SLOTS;
|
||||
if (!entry.contexts[slot]) {
|
||||
if (!reserve(feed.id, EPOCH_BYTES, entry)) {
|
||||
entry.truncated = true;
|
||||
recordLoss(feed.id, now + policy.retentionMs);
|
||||
continue;
|
||||
}
|
||||
entry.contexts[slot] = new DataView(new ArrayBuffer(EPOCH_BYTES));
|
||||
entry.bytes += EPOCH_BYTES;
|
||||
account(feed.id, EPOCH_BYTES);
|
||||
}
|
||||
entry.epoch++;
|
||||
// Expiring epoch metadata also expires its positions; never label a
|
||||
// retained point with a reused context slot.
|
||||
const expiredEpoch = entry.epoch - EPOCH_SLOTS;
|
||||
while (entry.count && read(entry, 0)[4] <= expiredEpoch) drop(entry);
|
||||
storeContext(entry, context, entry.epoch);
|
||||
flags |= 16;
|
||||
}
|
||||
if (entry.count === entry.capacity) {
|
||||
const next = Math.min(policy.fixes, entry.capacity + CHUNK);
|
||||
if (
|
||||
next > entry.capacity &&
|
||||
reserve(feed.id, next * FIX_BYTES, entry)
|
||||
)
|
||||
resize(entry, next);
|
||||
else drop(entry);
|
||||
}
|
||||
const at = offset(entry, entry.count++),
|
||||
v = entry.fixes;
|
||||
v.setFloat64(at, t);
|
||||
v.setFloat64(at + 8, record.lat);
|
||||
v.setFloat64(at + 16, record.lon);
|
||||
v.setUint32(at + 24, flags);
|
||||
v.setUint32(at + 28, entry.epoch);
|
||||
entry.observedAt = now;
|
||||
}
|
||||
} finally {
|
||||
for (const entry of protectedEntries) queue(entry);
|
||||
protectedEntries = null;
|
||||
}
|
||||
}
|
||||
function get(feedId, vehicleId, now) {
|
||||
prune(now);
|
||||
const entry = entries.get(`${feedId}\u0000${vehicleId}`);
|
||||
const fixes = [],
|
||||
epochs = [];
|
||||
if (entry) {
|
||||
const used = new Set();
|
||||
for (let i = 0; i < entry.count; i++) {
|
||||
const fix = read(entry, i);
|
||||
fixes.push(fix);
|
||||
used.add(fix[4]);
|
||||
}
|
||||
for (const id of used)
|
||||
epochs.push(contextAt(entry, (id - 1) % EPOCH_SLOTS));
|
||||
}
|
||||
const payload = {
|
||||
version: 1,
|
||||
feedId,
|
||||
vehicleId,
|
||||
oldestT: fixes[0]?.[0] ?? null,
|
||||
newestT: fixes.at(-1)?.[0] ?? null,
|
||||
truncated:
|
||||
entry?.truncated ||
|
||||
(!entry && (storage.lossUntil.get(feedId) || 0) > now),
|
||||
epochs,
|
||||
fixes,
|
||||
};
|
||||
// The normal bound is smaller; keep a byte guard for worst-case Unicode.
|
||||
while (
|
||||
encoder.encode(JSON.stringify(payload)).length > policy.responseBytes &&
|
||||
payload.fixes.length
|
||||
) {
|
||||
payload.fixes.shift();
|
||||
payload.oldestT = payload.fixes[0]?.[0] ?? null;
|
||||
payload.truncated = true;
|
||||
const used = new Set(payload.fixes.map((f) => f[4]));
|
||||
payload.epochs = payload.epochs.filter((e) => used.has(e.id));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
function clear() {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
closed = true;
|
||||
for (const entry of entries.values()) remove(entry);
|
||||
storage.stores.delete(api);
|
||||
}
|
||||
if (sweep) {
|
||||
timer = setInterval(() => prune(Date.now()), 60000);
|
||||
timer.unref?.();
|
||||
}
|
||||
return {
|
||||
ingest,
|
||||
get,
|
||||
prune,
|
||||
clear,
|
||||
diagnostics: () => ({
|
||||
allocatedBytes,
|
||||
processBytes: storage.bytes,
|
||||
keys: entries.size,
|
||||
feedBytes: Object.fromEntries(feedBytes),
|
||||
sweepActive: timer !== null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
export {
|
||||
createTransitHistory,
|
||||
TRANSIT_HISTORY_LIMITS,
|
||||
validTransitIdentifier,
|
||||
} from '../../src/sources/transitHistoryStore.js';
|
||||
@@ -51,11 +51,13 @@ function resolveWindowsNativeTools(environment, fileSystem, architecture) {
|
||||
if (configured.length === 0) return null;
|
||||
|
||||
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;
|
||||
});
|
||||
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 systemDirectory = architecture === 'ia32' ? 'Sysnative' : 'System32';
|
||||
@@ -80,14 +82,23 @@ function resolveWindowsNativeTools(environment, fileSystem, architecture) {
|
||||
for (const executable of Object.values(expected)) {
|
||||
const entry = fileSystem.lstatSync(executable);
|
||||
if (!entry.isFile() || entry.isSymbolicLink()) return null;
|
||||
const canonicalExecutable = realpath.call(fileSystem.realpathSync, executable);
|
||||
const canonicalExecutable = realpath.call(
|
||||
fileSystem.realpathSync,
|
||||
executable,
|
||||
);
|
||||
const canonicalCandidates = [executable];
|
||||
if (architecture === 'ia32') {
|
||||
canonicalCandidates.push(executable.replace('\\Sysnative\\', '\\System32\\'));
|
||||
canonicalCandidates.push(
|
||||
executable.replace('\\Sysnative\\', '\\System32\\'),
|
||||
);
|
||||
}
|
||||
if (!canonicalCandidates.some(
|
||||
(candidate) => candidate.toLowerCase() === canonicalExecutable.toLowerCase(),
|
||||
)) return null;
|
||||
if (
|
||||
!canonicalCandidates.some(
|
||||
(candidate) =>
|
||||
candidate.toLowerCase() === canonicalExecutable.toLowerCase(),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
@@ -99,17 +110,22 @@ function resolveWindowsNativeTools(environment, fileSystem, architecture) {
|
||||
* Restrict a credential file before any secret is written to it.
|
||||
* Dependencies are injectable so every fail-closed branch is unit-testable.
|
||||
*/
|
||||
export function hardenCredentialFile(filepath, {
|
||||
platform = process.platform,
|
||||
architecture = process.arch,
|
||||
spawn = spawnSync,
|
||||
fileSystem = fs,
|
||||
environment = process.env,
|
||||
} = {}) {
|
||||
export function hardenCredentialFile(
|
||||
filepath,
|
||||
{
|
||||
platform = process.platform,
|
||||
architecture = process.arch,
|
||||
spawn = spawnSync,
|
||||
fileSystem = fs,
|
||||
environment = process.env,
|
||||
} = {},
|
||||
) {
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
const aclRemoval = spawn('chmod', ['-N', filepath], { stdio: 'ignore' });
|
||||
const aclRemoval = spawn('chmod', ['-N', filepath], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (!commandCompletedSuccessfully(aclRemoval)) return false;
|
||||
}
|
||||
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;
|
||||
|
||||
try {
|
||||
@@ -135,14 +155,18 @@ export function hardenCredentialFile(filepath, {
|
||||
: null;
|
||||
if (!sid) return false;
|
||||
|
||||
const applied = spawn(tools.icacls, [
|
||||
filepath,
|
||||
'/inheritance:r',
|
||||
'/grant:r',
|
||||
`*${sid}:F`,
|
||||
'*S-1-5-18:F',
|
||||
'*S-1-5-32-544:F',
|
||||
], { stdio: 'ignore', windowsHide: true });
|
||||
const applied = spawn(
|
||||
tools.icacls,
|
||||
[
|
||||
filepath,
|
||||
'/inheritance:r',
|
||||
'/grant:r',
|
||||
`*${sid}:F`,
|
||||
'*S-1-5-18:F',
|
||||
'*S-1-5-32-544:F',
|
||||
],
|
||||
{ stdio: 'ignore', windowsHide: true },
|
||||
);
|
||||
if (!commandCompletedSuccessfully(applied)) return false;
|
||||
|
||||
// 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',
|
||||
),
|
||||
);
|
||||
const verified = spawn(tools.powershell, [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command', WINDOWS_ACL_VERIFY_SCRIPT,
|
||||
], {
|
||||
env: {
|
||||
...verifyEnvironment,
|
||||
GEV_ACL_FILE: filepath,
|
||||
GEV_ACL_USER_SID: sid,
|
||||
PSModulePath: powershellModuleDirectory,
|
||||
const verified = spawn(
|
||||
tools.powershell,
|
||||
['-NoProfile', '-NonInteractive', '-Command', WINDOWS_ACL_VERIFY_SCRIPT],
|
||||
{
|
||||
env: {
|
||||
...verifyEnvironment,
|
||||
GEV_ACL_FILE: filepath,
|
||||
GEV_ACL_USER_SID: sid,
|
||||
PSModulePath: powershellModuleDirectory,
|
||||
},
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
},
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
);
|
||||
return commandCompletedSuccessfully(verified);
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
+455
-105
@@ -1,12 +1,20 @@
|
||||
import { defaultGeospatial } from '../search/defaults.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js';
|
||||
import { isRateLimitedOutcome, resolveAnnotationTarget, sampleGroundHeight } from './annotationResolver.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
} from '../renderGovernor.js';
|
||||
import {
|
||||
isRateLimitedOutcome,
|
||||
resolveAnnotationTarget,
|
||||
sampleGroundHeight,
|
||||
} from './annotationResolver.js';
|
||||
import { ringCentroid } from './drawMode.js';
|
||||
|
||||
// Dev convenience: expose the app's Cesium instance for console/preview probing
|
||||
// (single shared module instance — avoids dual-Cesium state bugs when testing).
|
||||
if (typeof window !== 'undefined' && !window.__CESIUM__) window.__CESIUM__ = Cesium;
|
||||
if (typeof window !== 'undefined' && !window.__CESIUM__)
|
||||
window.__CESIUM__ = Cesium;
|
||||
|
||||
/**
|
||||
* Annotation engine — the voice agent's "whiteboard" over the 3D world.
|
||||
@@ -25,7 +33,13 @@ const COLORS = new Set(['primary', 'amber', 'cyan', 'green', 'red']);
|
||||
// Entity FACTS the voice model may attach to an annotation (annotate_map.entityKind) —
|
||||
// what kind of thing the target IS, routing the resolver (e.g. point_feature keeps a
|
||||
// monument point-first). Unknown values are dropped, never guessed.
|
||||
const ENTITY_KINDS = new Set(['building', 'compound', 'district', 'street', 'point_feature']);
|
||||
const ENTITY_KINDS = new Set([
|
||||
'building',
|
||||
'compound',
|
||||
'district',
|
||||
'street',
|
||||
'point_feature',
|
||||
]);
|
||||
const DEFAULT_TTL_MS = 22_000;
|
||||
const FADE_MS = 1200;
|
||||
// Hard ceiling on simultaneously-live marks. Protects against a runaway voice
|
||||
@@ -51,11 +65,14 @@ const OUTLINE_UPGRADE_CONCURRENCY = 2;
|
||||
* retrying immediately. A thrown resolver counts as definitive — the pre-retry
|
||||
* catch→point behavior. Exported for tests.
|
||||
*/
|
||||
export async function resolveOutlineWithRetry(resolveOutline, {
|
||||
delaysMs = OUTLINE_RETRY_DELAYS_MS,
|
||||
isStale = () => false,
|
||||
waitFn = wait,
|
||||
} = {}) {
|
||||
export async function resolveOutlineWithRetry(
|
||||
resolveOutline,
|
||||
{
|
||||
delaysMs = OUTLINE_RETRY_DELAYS_MS,
|
||||
isStale = () => false,
|
||||
waitFn = wait,
|
||||
} = {},
|
||||
) {
|
||||
let retriedRateLimit = false;
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
let fp;
|
||||
@@ -68,7 +85,9 @@ export async function resolveOutlineWithRetry(resolveOutline, {
|
||||
// A throttle gets one deliberately spaced retry. If that retry is throttled
|
||||
// too, stop this mark's outline task instead of replaying the batch storm.
|
||||
if (retriedRateLimit || attempt >= delaysMs.length) return undefined;
|
||||
const retryAfterMs = Number.isFinite(fp.retryAfterMs) ? fp.retryAfterMs : 0;
|
||||
const retryAfterMs = Number.isFinite(fp.retryAfterMs)
|
||||
? fp.retryAfterMs
|
||||
: 0;
|
||||
await waitFn(Math.max(retryAfterMs, delaysMs[attempt]));
|
||||
if (isStale()) return undefined;
|
||||
retriedRateLimit = true;
|
||||
@@ -89,7 +108,9 @@ export async function resolveOutlineWithRetry(resolveOutline, {
|
||||
* geometry is unknown. Exported for tests.
|
||||
*/
|
||||
export function normalizeTargetKey(target) {
|
||||
const raw = String(target ?? '').trim().toLowerCase();
|
||||
const raw = String(target ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!raw) return null;
|
||||
const head = raw.split(',')[0].trim();
|
||||
return head || raw;
|
||||
@@ -123,12 +144,22 @@ export function createAnnotationEngine({
|
||||
|
||||
function emitOutlineEvent(evt) {
|
||||
for (const listener of outlineListeners) {
|
||||
try { listener(evt); } catch { /* a listener error must never break the upgrade */ }
|
||||
try {
|
||||
listener(evt);
|
||||
} catch {
|
||||
/* a listener error must never break the upgrade */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function abortPending() {
|
||||
for (const c of activeControllers) { try { c.abort(); } catch { /* no-op */ } }
|
||||
for (const c of activeControllers) {
|
||||
try {
|
||||
c.abort();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
activeControllers.clear();
|
||||
}
|
||||
|
||||
@@ -174,7 +205,9 @@ export function createAnnotationEngine({
|
||||
function rollbackRendererState(anno) {
|
||||
try {
|
||||
renderer.remove(anno);
|
||||
} catch { /* the renderer is already in a bad way; the original error wins */ }
|
||||
} catch {
|
||||
/* the renderer is already in a bad way; the original error wins */
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTicking() {
|
||||
@@ -226,7 +259,15 @@ export function createAnnotationEngine({
|
||||
* @returns {Promise<{ok, drawn, failed, ids, results}>}
|
||||
*/
|
||||
async function annotate(requests, opts = {}) {
|
||||
if (destroyed) return { ok: false, drawn: 0, failed: 0, ids: [], results: [], error: 'destroyed' };
|
||||
if (destroyed)
|
||||
return {
|
||||
ok: false,
|
||||
drawn: 0,
|
||||
failed: 0,
|
||||
ids: [],
|
||||
results: [],
|
||||
error: 'destroyed',
|
||||
};
|
||||
const list = Array.isArray(requests) ? requests : [requests];
|
||||
if (opts.clearPrevious) clear(); // bumps generation + aborts older pending work
|
||||
|
||||
@@ -247,7 +288,11 @@ export function createAnnotationEngine({
|
||||
// allSettled gives per-item error isolation (one failed item never aborts the batch); the
|
||||
// mutation pass below then runs in ORDER, so de-dup, the synchronous live-cap check, and output
|
||||
// order are all preserved exactly as the old serial loop had them.
|
||||
const settled = await Promise.allSettled(list.map((spec) => resolveSpec(spec, controller.signal, opts.flyTo === true)));
|
||||
const settled = await Promise.allSettled(
|
||||
list.map((spec) =>
|
||||
resolveSpec(spec, controller.signal, opts.flyTo === true),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
@@ -270,8 +315,15 @@ export function createAnnotationEngine({
|
||||
// stacked. De-dup runs BEFORE the cap so a re-narration still refreshes when full.
|
||||
const dup = findDuplicate(anno);
|
||||
if (dup) {
|
||||
const labelChanged = String(dup.label || '').trim().toLowerCase() !== String(anno.label || '').trim().toLowerCase();
|
||||
const colorChanged = (dup.color || 'primary') !== (anno.color || 'primary');
|
||||
const labelChanged =
|
||||
String(dup.label || '')
|
||||
.trim()
|
||||
.toLowerCase() !==
|
||||
String(anno.label || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const colorChanged =
|
||||
(dup.color || 'primary') !== (anno.color || 'primary');
|
||||
if (labelChanged || colorChanged) {
|
||||
// Same place + shape, but a NEW label and/or color ("Marina" → "Marina District",
|
||||
// or a recolor) → replace in place so the latest caption/color wins (count
|
||||
@@ -305,13 +357,26 @@ export function createAnnotationEngine({
|
||||
ids.push(anno.id);
|
||||
if (!firstAnchor) firstAnchor = anno;
|
||||
if (typeof resolved.resolveOutline === 'function') {
|
||||
startOutlineUpgrade(anno, resolved.resolveOutline, myGen, controller);
|
||||
startOutlineUpgrade(
|
||||
anno,
|
||||
resolved.resolveOutline,
|
||||
myGen,
|
||||
controller,
|
||||
);
|
||||
}
|
||||
results.push({ ...okResult(anno, resolved, anno.id), duplicate: true });
|
||||
results.push({
|
||||
...okResult(anno, resolved, anno.id),
|
||||
duplicate: true,
|
||||
});
|
||||
} else {
|
||||
// Identical re-narration → refresh the existing mark's lifecycle in place (keep
|
||||
// it visible whether persistent or TTL-based, and revive a fading-out one).
|
||||
if (persist) { dup.ttlMs = null; } else { dup.createdAt = performance.now(); dup.ttlMs = anno.ttlMs; }
|
||||
if (persist) {
|
||||
dup.ttlMs = null;
|
||||
} else {
|
||||
dup.createdAt = performance.now();
|
||||
dup.ttlMs = anno.ttlMs;
|
||||
}
|
||||
dup.expiring = false;
|
||||
dup.fadeStart = null;
|
||||
ids.push(dup.id);
|
||||
@@ -319,11 +384,19 @@ export function createAnnotationEngine({
|
||||
// A re-narration is also a RETRY: if the existing mark never got its outline
|
||||
// (transient Overpass miss), attach this call's deferred resolver to it.
|
||||
if (!dup.ring && typeof resolved.resolveOutline === 'function') {
|
||||
startOutlineUpgrade(dup, resolved.resolveOutline, myGen, controller);
|
||||
startOutlineUpgrade(
|
||||
dup,
|
||||
resolved.resolveOutline,
|
||||
myGen,
|
||||
controller,
|
||||
);
|
||||
}
|
||||
// Same result builder as the fresh path (full metadata) but the existing id —
|
||||
// route fallback / approximate honesty fields survive a re-narration.
|
||||
results.push({ ...okResult(anno, resolved, dup.id), duplicate: true });
|
||||
results.push({
|
||||
...okResult(anno, resolved, dup.id),
|
||||
duplicate: true,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -350,12 +423,23 @@ export function createAnnotationEngine({
|
||||
if (!firstAnchor) firstAnchor = anno;
|
||||
// Progressive: the point is on the board; trace the outline behind the narration.
|
||||
if (typeof resolved.resolveOutline === 'function') {
|
||||
startOutlineUpgrade(anno, resolved.resolveOutline, myGen, controller);
|
||||
startOutlineUpgrade(
|
||||
anno,
|
||||
resolved.resolveOutline,
|
||||
myGen,
|
||||
controller,
|
||||
);
|
||||
}
|
||||
results.push(okResult(anno, resolved, anno.id));
|
||||
} catch (error) {
|
||||
if (superseded()) break;
|
||||
results.push(failResult(spec, error?.message || 'annotation failed', Array.isArray(error?.failedTargets) ? error.failedTargets : null));
|
||||
results.push(
|
||||
failResult(
|
||||
spec,
|
||||
error?.message || 'annotation failed',
|
||||
Array.isArray(error?.failedTargets) ? error.failedTargets : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -365,7 +449,14 @@ export function createAnnotationEngine({
|
||||
// A clear/new-topic landed mid-flight: drop everything this call produced and
|
||||
// do not touch the renderer or camera — the board now belongs to a newer call.
|
||||
if (superseded()) {
|
||||
return { ok: false, drawn: 0, failed: results.length, ids: [], results, aborted: true };
|
||||
return {
|
||||
ok: false,
|
||||
drawn: 0,
|
||||
failed: results.length,
|
||||
ids: [],
|
||||
results,
|
||||
aborted: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (ids.length) {
|
||||
@@ -393,7 +484,8 @@ export function createAnnotationEngine({
|
||||
if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);
|
||||
if (type === 'route') {
|
||||
const points = Array.isArray(spec.points) ? spec.points : [];
|
||||
if (points.length < 2) throw new Error('a route needs at least 2 waypoints');
|
||||
if (points.length < 2)
|
||||
throw new Error('a route needs at least 2 waypoints');
|
||||
const resolvedPts = [];
|
||||
const failed = [];
|
||||
for (const pt of points) {
|
||||
@@ -425,7 +517,12 @@ export function createAnnotationEngine({
|
||||
}
|
||||
// Real street-following route (OSM/OSRM), mode-aware.
|
||||
const mode = normalizeMode(spec.mode);
|
||||
const routed = await fetchRoute(resolvedPts.map((p) => [p.lon, p.lat]), mode, signal, placeSearch);
|
||||
const routed = await fetchRoute(
|
||||
resolvedPts.map((p) => [p.lon, p.lat]),
|
||||
mode,
|
||||
signal,
|
||||
placeSearch,
|
||||
);
|
||||
if (routed) {
|
||||
return {
|
||||
path: routed.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
||||
@@ -439,8 +536,16 @@ export function createAnnotationEngine({
|
||||
// Routing unavailable: do NOT pass off straight segments as a real route. Draw
|
||||
// a clearly-labeled direct line with great-circle distance and NO travel time.
|
||||
let straight = 0;
|
||||
for (let i = 1; i < resolvedPts.length; i += 1) straight += greatCircleM(resolvedPts[i - 1], resolvedPts[i]);
|
||||
return { path: resolvedPts, distanceM: straight, durationS: null, mode, source: resolvedPts[0].source, fallback: true };
|
||||
for (let i = 1; i < resolvedPts.length; i += 1)
|
||||
straight += greatCircleM(resolvedPts[i - 1], resolvedPts[i]);
|
||||
return {
|
||||
path: resolvedPts,
|
||||
distanceM: straight,
|
||||
durationS: null,
|
||||
mode,
|
||||
source: resolvedPts[0].source,
|
||||
fallback: true,
|
||||
};
|
||||
}
|
||||
if (type === 'arrow') {
|
||||
const from = await resolveTarget({
|
||||
@@ -479,9 +584,15 @@ export function createAnnotationEngine({
|
||||
err.failedTargets = failed;
|
||||
throw err;
|
||||
}
|
||||
return { from, to, distanceM: greatCircleM(from, to), source: from.source };
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
distanceM: greatCircleM(from, to),
|
||||
source: from.source,
|
||||
};
|
||||
}
|
||||
const wantFootprint = type === 'area' ? spec.footprint !== false : Boolean(spec.footprint);
|
||||
const wantFootprint =
|
||||
type === 'area' ? spec.footprint !== false : Boolean(spec.footprint);
|
||||
return resolveTarget({
|
||||
placeSearch,
|
||||
viewer,
|
||||
@@ -491,7 +602,8 @@ export function createAnnotationEngine({
|
||||
screenX: spec.screenX,
|
||||
screenY: spec.screenY,
|
||||
footprint: wantFootprint,
|
||||
intent: spec.intent === 'around_the_thing' ? 'around_the_thing' : 'the_thing',
|
||||
intent:
|
||||
spec.intent === 'around_the_thing' ? 'around_the_thing' : 'the_thing',
|
||||
entityKind: ENTITY_KINDS.has(spec?.entityKind) ? spec.entityKind : null,
|
||||
// The label often carries the ask's true shape when the target omits it
|
||||
// ("target: Texas State Capitol" + "label: Capitol grounds") — a resolver HINT only.
|
||||
@@ -525,11 +637,15 @@ export function createAnnotationEngine({
|
||||
}
|
||||
|
||||
function drainOutlineQueue() {
|
||||
while (activeOutlineUpgrades < OUTLINE_UPGRADE_CONCURRENCY && outlineQueue.length) {
|
||||
while (
|
||||
activeOutlineUpgrades < OUTLINE_UPGRADE_CONCURRENCY &&
|
||||
outlineQueue.length
|
||||
) {
|
||||
const task = outlineQueue.shift();
|
||||
const stale = task.myGen !== generation
|
||||
|| task.controller.signal.aborted
|
||||
|| !annotations.has(task.anno.id);
|
||||
const stale =
|
||||
task.myGen !== generation ||
|
||||
task.controller.signal.aborted ||
|
||||
!annotations.has(task.anno.id);
|
||||
if (stale) {
|
||||
if (task.anno._outlineTask === task) task.anno._outlineTask = null;
|
||||
releaseController(task.controller);
|
||||
@@ -549,7 +665,10 @@ export function createAnnotationEngine({
|
||||
// proxy caches the late completion, so the retry is usually a warm cache hit.
|
||||
fp = await resolveOutlineWithRetry(resolveOutline, {
|
||||
delaysMs: outlineRetryDelaysMs,
|
||||
isStale: () => myGen !== generation || controller.signal.aborted || !annotations.has(anno.id),
|
||||
isStale: () =>
|
||||
myGen !== generation ||
|
||||
controller.signal.aborted ||
|
||||
!annotations.has(anno.id),
|
||||
});
|
||||
if (myGen !== generation || controller.signal.aborted) return; // board superseded
|
||||
if (!annotations.has(anno.id)) return; // mark replaced/removed while resolving
|
||||
@@ -577,11 +696,22 @@ export function createAnnotationEngine({
|
||||
// wins, exactly like the add-time replace path). Not an auto-clear — it removes
|
||||
// a second representation of the same mark, never a distinct one.
|
||||
for (const other of annotations.values()) {
|
||||
if (other.id === anno.id || other.type !== 'area' || other.pendingOutline) continue;
|
||||
if (!other.anchor || Math.abs(other.anchor.lon - anno.anchor.lon) >= 5e-4
|
||||
|| Math.abs(other.anchor.lat - anno.anchor.lat) >= 5e-4) continue;
|
||||
if (Boolean(other.synthesized) !== Boolean(anno.synthesized)) continue;
|
||||
if ((other.footprintKind || null) !== (anno.footprintKind || null)) continue;
|
||||
if (
|
||||
other.id === anno.id ||
|
||||
other.type !== 'area' ||
|
||||
other.pendingOutline
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
!other.anchor ||
|
||||
Math.abs(other.anchor.lon - anno.anchor.lon) >= 5e-4 ||
|
||||
Math.abs(other.anchor.lat - anno.anchor.lat) >= 5e-4
|
||||
)
|
||||
continue;
|
||||
if (Boolean(other.synthesized) !== Boolean(anno.synthesized))
|
||||
continue;
|
||||
if ((other.footprintKind || null) !== (anno.footprintKind || null))
|
||||
continue;
|
||||
if (!ringsEqual(other.ring, anno.ring)) continue;
|
||||
annotations.delete(other.id);
|
||||
renderer.remove(other);
|
||||
@@ -629,7 +759,12 @@ export function createAnnotationEngine({
|
||||
// Route-specific signal so the voice layer can be honest about an OSRM outage:
|
||||
// fallback=true means a straight direct line, not a real route.
|
||||
...(anno.type === 'route'
|
||||
? { fallback: Boolean(anno.fallback), mode: anno.mode, distanceM: anno.distanceM, durationS: anno.durationS }
|
||||
? {
|
||||
fallback: Boolean(anno.fallback),
|
||||
mode: anno.mode,
|
||||
distanceM: anno.distanceM,
|
||||
durationS: anno.durationS,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -643,13 +778,18 @@ export function createAnnotationEngine({
|
||||
* degenerate geocode stacks on one point (several Capitol monuments) would erase each other. */
|
||||
function findDuplicate(anno) {
|
||||
if (!anno.anchor) return null;
|
||||
const near = (a, b) => a && b && Math.abs(a.lon - b.lon) < 5e-4 && Math.abs(a.lat - b.lat) < 5e-4;
|
||||
const near = (a, b) =>
|
||||
a &&
|
||||
b &&
|
||||
Math.abs(a.lon - b.lon) < 5e-4 &&
|
||||
Math.abs(a.lat - b.lat) < 5e-4;
|
||||
// FULL per-vertex geometry comparison (the live cap is only 120 and this runs solely for
|
||||
// candidates that already matched type+label+anchor, so it's cheap) — so two DIFFERENT
|
||||
// same-length rings/paths at the same anchor are never falsely merged.
|
||||
const ringSame = ringsEqual;
|
||||
const pathSame = (a, b) => {
|
||||
const la = a ? a.length : 0; const lb = b ? b.length : 0;
|
||||
const la = a ? a.length : 0;
|
||||
const lb = b ? b.length : 0;
|
||||
if (la !== lb) return false;
|
||||
for (let i = 0; i < la; i++) if (!near(a[i], b[i])) return false;
|
||||
return true;
|
||||
@@ -674,10 +814,12 @@ export function createAnnotationEngine({
|
||||
// the identity; the target is only the stand-in while geometry is unknown.
|
||||
if (!anno.targetKey || !ex.targetKey) continue;
|
||||
if (anno.targetKey !== ex.targetKey) continue;
|
||||
if ((ex.intentKey || 'thing') !== (anno.intentKey || 'thing')) continue;
|
||||
if ((ex.intentKey || 'thing') !== (anno.intentKey || 'thing'))
|
||||
continue;
|
||||
} else {
|
||||
if (Boolean(ex.synthesized) !== Boolean(anno.synthesized)) continue;
|
||||
if ((ex.footprintKind || null) !== (anno.footprintKind || null)) continue;
|
||||
if ((ex.footprintKind || null) !== (anno.footprintKind || null))
|
||||
continue;
|
||||
if (!ringSame(ex.ring, anno.ring)) continue;
|
||||
}
|
||||
} else if (anno.type === 'arrow') {
|
||||
@@ -691,7 +833,13 @@ export function createAnnotationEngine({
|
||||
// point (e.g. several Texas Capitol monuments all geocoding to the dome centroid) — NOT a
|
||||
// duplicate. Only collapse a genuine re-narration of the SAME point (same label); distinct
|
||||
// labels stay as separate marks (their callouts de-collide in the renderer).
|
||||
const sameLabel = String(ex.label || '').trim().toLowerCase() === String(anno.label || '').trim().toLowerCase();
|
||||
const sameLabel =
|
||||
String(ex.label || '')
|
||||
.trim()
|
||||
.toLowerCase() ===
|
||||
String(anno.label || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!sameLabel) continue;
|
||||
}
|
||||
return ex;
|
||||
@@ -703,7 +851,11 @@ export function createAnnotationEngine({
|
||||
const type = normalizeType(spec?.type);
|
||||
const id = `anno-${++_seq}`;
|
||||
const color = COLORS.has(spec?.color) ? spec.color : 'primary';
|
||||
const label = cleanLabel(spec?.label) || resolved?.label || resolved?.from?.label || null;
|
||||
const label =
|
||||
cleanLabel(spec?.label) ||
|
||||
resolved?.label ||
|
||||
resolved?.from?.label ||
|
||||
null;
|
||||
const now = performance.now();
|
||||
|
||||
const base = {
|
||||
@@ -712,17 +864,27 @@ export function createAnnotationEngine({
|
||||
color,
|
||||
label,
|
||||
createdAt: now,
|
||||
ttlMs: persist ? null : (Number(spec?.ttlMs) || DEFAULT_TTL_MS),
|
||||
ttlMs: persist ? null : Number(spec?.ttlMs) || DEFAULT_TTL_MS,
|
||||
alpha: 0, // animate in
|
||||
bornAt: now,
|
||||
expiring: false,
|
||||
};
|
||||
|
||||
if (type === 'route') {
|
||||
const path = resolved.path.map((p) => ({ lon: p.lon, lat: p.lat, height: p.height }));
|
||||
const path = resolved.path.map((p) => ({
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
height: p.height,
|
||||
}));
|
||||
return {
|
||||
...base,
|
||||
label: composeRouteLabel(base.label, resolved.distanceM, resolved.durationS, resolved.mode, resolved.fallback),
|
||||
label: composeRouteLabel(
|
||||
base.label,
|
||||
resolved.distanceM,
|
||||
resolved.durationS,
|
||||
resolved.mode,
|
||||
resolved.fallback,
|
||||
),
|
||||
anchor: path[0],
|
||||
to: null,
|
||||
path,
|
||||
@@ -738,8 +900,16 @@ export function createAnnotationEngine({
|
||||
return {
|
||||
...base,
|
||||
label: appendDistance(base.label, resolved.distanceM),
|
||||
anchor: { lon: resolved.from.lon, lat: resolved.from.lat, height: resolved.from.height },
|
||||
to: { lon: resolved.to.lon, lat: resolved.to.lat, height: resolved.to.height },
|
||||
anchor: {
|
||||
lon: resolved.from.lon,
|
||||
lat: resolved.from.lat,
|
||||
height: resolved.from.height,
|
||||
},
|
||||
to: {
|
||||
lon: resolved.to.lon,
|
||||
lat: resolved.to.lat,
|
||||
height: resolved.to.height,
|
||||
},
|
||||
ring: null,
|
||||
};
|
||||
}
|
||||
@@ -814,13 +984,17 @@ export function createAnnotationEngine({
|
||||
if (m.to) points.push(m.to);
|
||||
}
|
||||
if (!points.length || points.some((p) => isPointOnScreen(p))) return;
|
||||
console.log(`[Annotations] auto-framing ${marks.length} off-screen mark(s)`);
|
||||
console.log(
|
||||
`[Annotations] auto-framing ${marks.length} off-screen mark(s)`,
|
||||
);
|
||||
assistFlightUntil = performance.now() + 2600; // ~flight duration + settle
|
||||
if (marks.length === 1) {
|
||||
frameAnnotation(marks[0]);
|
||||
return;
|
||||
}
|
||||
const cart = points.map((p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0));
|
||||
const cart = points.map((p) =>
|
||||
Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0),
|
||||
);
|
||||
const sphere = Cesium.BoundingSphere.fromPoints(cart);
|
||||
viewer.camera.flyToBoundingSphere(sphere, {
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
@@ -841,9 +1015,20 @@ export function createAnnotationEngine({
|
||||
try {
|
||||
const camera = viewer.camera;
|
||||
const pos = Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0);
|
||||
const cv = camera.frustum.computeCullingVolume(camera.position, camera.direction, camera.up);
|
||||
if (cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) === Cesium.Intersect.OUTSIDE) return false;
|
||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, camera.position);
|
||||
const cv = camera.frustum.computeCullingVolume(
|
||||
camera.position,
|
||||
camera.direction,
|
||||
camera.up,
|
||||
);
|
||||
if (
|
||||
cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) ===
|
||||
Cesium.Intersect.OUTSIDE
|
||||
)
|
||||
return false;
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
camera.position,
|
||||
);
|
||||
return occluder.isPointVisible(pos);
|
||||
} catch {
|
||||
return true;
|
||||
@@ -853,15 +1038,24 @@ export function createAnnotationEngine({
|
||||
function frameAnnotation(anno) {
|
||||
try {
|
||||
const target = anno.to
|
||||
? { lon: (anno.anchor.lon + anno.to.lon) / 2, lat: (anno.anchor.lat + anno.to.lat) / 2 }
|
||||
? {
|
||||
lon: (anno.anchor.lon + anno.to.lon) / 2,
|
||||
lat: (anno.anchor.lat + anno.to.lat) / 2,
|
||||
}
|
||||
: anno.anchor;
|
||||
// No ring yet (progressive outline still resolving) → size the flight from the
|
||||
// Places viewport box when we have one, so a big compound isn't framed at
|
||||
// building scale while its outline is traced. Never re-fly when the ring lands.
|
||||
const range = anno.ring ? ringRange(anno.ring) : (viewportRange(anno.viewport) || 600);
|
||||
const range = anno.ring
|
||||
? ringRange(anno.ring)
|
||||
: viewportRange(anno.viewport) || 600;
|
||||
viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
Cesium.Cartesian3.fromDegrees(target.lon, target.lat, anno.anchor.height || 0),
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
target.lon,
|
||||
target.lat,
|
||||
anno.anchor.height || 0,
|
||||
),
|
||||
range,
|
||||
),
|
||||
{
|
||||
@@ -884,7 +1078,9 @@ export function createAnnotationEngine({
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
try { clear(); } finally {
|
||||
try {
|
||||
clear();
|
||||
} finally {
|
||||
if (tickHandle != null) cancelAnimationFrame(tickHandle);
|
||||
tickHandle = null;
|
||||
outlineListeners.clear();
|
||||
@@ -912,12 +1108,36 @@ export function createAnnotationEngine({
|
||||
* Lays down a small San Francisco "tour" the way the voice agent would.
|
||||
*/
|
||||
async demo() {
|
||||
return annotate([
|
||||
{ type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' },
|
||||
{ type: 'area', target: 'Presidio of San Francisco', label: 'The Presidio (former Army base)', color: 'green', footprint: true },
|
||||
{ type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'cyan' },
|
||||
{ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina District, San Francisco', label: 'next to the Marina' },
|
||||
], { flyTo: true, clearPrevious: true, persist: true });
|
||||
return annotate(
|
||||
[
|
||||
{
|
||||
type: 'highlight',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
label: 'Palace of Fine Arts',
|
||||
color: 'amber',
|
||||
},
|
||||
{
|
||||
type: 'area',
|
||||
target: 'Presidio of San Francisco',
|
||||
label: 'The Presidio (former Army base)',
|
||||
color: 'green',
|
||||
footprint: true,
|
||||
},
|
||||
{
|
||||
type: 'pin',
|
||||
target: 'Letterman Digital Arts Center, San Francisco',
|
||||
label: 'ILM / Lucasfilm',
|
||||
color: 'cyan',
|
||||
},
|
||||
{
|
||||
type: 'arrow',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
toTarget: 'Marina District, San Francisco',
|
||||
label: 'next to the Marina',
|
||||
},
|
||||
],
|
||||
{ flyTo: true, clearPrevious: true, persist: true },
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -927,34 +1147,107 @@ export function createAnnotationEngine({
|
||||
*/
|
||||
async tour() {
|
||||
clear();
|
||||
flyTo({ lon: -122.4486, lat: 37.7960, height: 520, heading: 0, pitch: -26, duration: 3 });
|
||||
flyTo({
|
||||
lon: -122.4486,
|
||||
lat: 37.796,
|
||||
height: 520,
|
||||
heading: 0,
|
||||
pitch: -26,
|
||||
duration: 3,
|
||||
});
|
||||
if (destroyed) return { ok: false };
|
||||
await wait(3200);
|
||||
await annotate([{ type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' }], { persist: true });
|
||||
await annotate(
|
||||
[
|
||||
{
|
||||
type: 'highlight',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
label: 'Palace of Fine Arts',
|
||||
color: 'amber',
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
if (destroyed) return { ok: false };
|
||||
await wait(2600);
|
||||
await annotate([{ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina Green, San Francisco', label: 'next to the Marina', color: 'cyan' }], { persist: true });
|
||||
await annotate(
|
||||
[
|
||||
{
|
||||
type: 'arrow',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
toTarget: 'Marina Green, San Francisco',
|
||||
label: 'next to the Marina',
|
||||
color: 'cyan',
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
if (destroyed) return { ok: false };
|
||||
await wait(2600);
|
||||
flyTo({ lon: -122.4545, lat: 37.7880, height: 1500, heading: 18, pitch: -32, duration: 3 });
|
||||
flyTo({
|
||||
lon: -122.4545,
|
||||
lat: 37.788,
|
||||
height: 1500,
|
||||
heading: 18,
|
||||
pitch: -32,
|
||||
duration: 3,
|
||||
});
|
||||
if (destroyed) return { ok: false };
|
||||
await wait(3200);
|
||||
await annotate([{ type: 'area', target: 'Presidio of San Francisco', label: 'The Presidio — a former Army base', color: 'green', footprint: true }], { persist: true });
|
||||
await annotate(
|
||||
[
|
||||
{
|
||||
type: 'area',
|
||||
target: 'Presidio of San Francisco',
|
||||
label: 'The Presidio — a former Army base',
|
||||
color: 'green',
|
||||
footprint: true,
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
if (destroyed) return { ok: false };
|
||||
await wait(2800);
|
||||
await annotate([{ type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'red' }], { persist: true });
|
||||
await annotate(
|
||||
[
|
||||
{
|
||||
type: 'pin',
|
||||
target: 'Letterman Digital Arts Center, San Francisco',
|
||||
label: 'ILM / Lucasfilm',
|
||||
color: 'red',
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
if (destroyed) return { ok: false };
|
||||
await wait(2600);
|
||||
await annotate([{ type: 'route', color: 'amber', label: 'Crissy Field shoreline', points: [
|
||||
{ target: 'Palace of Fine Arts, San Francisco' },
|
||||
{ target: 'Crissy Field, San Francisco' },
|
||||
{ target: 'Fort Point, San Francisco' },
|
||||
] }], { persist: true });
|
||||
await annotate(
|
||||
[
|
||||
{
|
||||
type: 'route',
|
||||
color: 'amber',
|
||||
label: 'Crissy Field shoreline',
|
||||
points: [
|
||||
{ target: 'Palace of Fine Arts, San Francisco' },
|
||||
{ target: 'Crissy Field, San Francisco' },
|
||||
{ target: 'Fort Point, San Francisco' },
|
||||
],
|
||||
},
|
||||
],
|
||||
{ persist: true },
|
||||
);
|
||||
return { ok: true, steps: 6 };
|
||||
},
|
||||
};
|
||||
|
||||
function flyTo({ lon, lat, height, heading = 0, pitch = -30, duration = 2.5 }) {
|
||||
function flyTo({
|
||||
lon,
|
||||
lat,
|
||||
height,
|
||||
heading = 0,
|
||||
pitch = -30,
|
||||
duration = 2.5,
|
||||
}) {
|
||||
if (destroyed) return;
|
||||
try {
|
||||
viewer.camera.flyTo({
|
||||
@@ -984,7 +1277,11 @@ function ringsEqual(a, b) {
|
||||
const lb = b ? b.length : 0;
|
||||
if (la !== lb) return false;
|
||||
for (let i = 0; i < la; i++) {
|
||||
if (Math.abs(a[i][0] - b[i][0]) >= 1e-5 || Math.abs(a[i][1] - b[i][1]) >= 1e-5) return false;
|
||||
if (
|
||||
Math.abs(a[i][0] - b[i][0]) >= 1e-5 ||
|
||||
Math.abs(a[i][1] - b[i][1]) >= 1e-5
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1010,7 +1307,8 @@ function specTargets(spec) {
|
||||
* arrow endpoint) when known; otherwise it derives them from the spec.
|
||||
*/
|
||||
function failResult(spec, error, failedTargets) {
|
||||
const targets = (failedTargets && failedTargets.length) ? failedTargets : specTargets(spec);
|
||||
const targets =
|
||||
failedTargets && failedTargets.length ? failedTargets : specTargets(spec);
|
||||
return {
|
||||
ok: false,
|
||||
label: spec?.label || spec?.target || targets[0] || null,
|
||||
@@ -1022,7 +1320,8 @@ function failResult(spec, error, failedTargets) {
|
||||
|
||||
function normalizeType(type) {
|
||||
const t = String(type || '').toLowerCase();
|
||||
if (t === 'area' || t === 'polygon' || t === 'outline' || t === 'compound') return 'area';
|
||||
if (t === 'area' || t === 'polygon' || t === 'outline' || t === 'compound')
|
||||
return 'area';
|
||||
if (t === 'route' || t === 'path' || t === 'line') return 'route';
|
||||
if (t === 'arrow' || t === 'vector' || t === 'connector') return 'arrow';
|
||||
if (t === 'label' || t === 'callout' || t === 'text') return 'label';
|
||||
@@ -1036,9 +1335,10 @@ function computeAlpha(anno, now) {
|
||||
|
||||
if (anno.ttlMs == null && !anno.expiring) return fadeIn;
|
||||
|
||||
const fadeStart = anno.fadeStart != null
|
||||
? anno.fadeStart
|
||||
: anno.createdAt + (anno.ttlMs || 0);
|
||||
const fadeStart =
|
||||
anno.fadeStart != null
|
||||
? anno.fadeStart
|
||||
: anno.createdAt + (anno.ttlMs || 0);
|
||||
if (now < fadeStart) return fadeIn;
|
||||
const out = 1 - (now - fadeStart) / FADE_MS;
|
||||
if (out <= 0) anno.expiring = true;
|
||||
@@ -1052,7 +1352,10 @@ function pendingAnimation(anno, now) {
|
||||
if (now - anno.bornAt < 260) return true; // fading in
|
||||
if (anno.expiring) return true; // fading out
|
||||
if (anno.ttlMs != null) {
|
||||
const fadeStart = anno.fadeStart != null ? anno.fadeStart : anno.createdAt + (anno.ttlMs || 0);
|
||||
const fadeStart =
|
||||
anno.fadeStart != null
|
||||
? anno.fadeStart
|
||||
: anno.createdAt + (anno.ttlMs || 0);
|
||||
return now < fadeStart + FADE_MS; // TTL fade still ahead or in progress
|
||||
}
|
||||
return false; // persistent + faded in → stable
|
||||
@@ -1110,16 +1413,30 @@ function round5(n) {
|
||||
/** A spec whose geometry was supplied by hand (drawTool.js) rather than by a name. */
|
||||
function isManualSpec(spec, type) {
|
||||
if (!spec || spec.manual !== true) return false;
|
||||
if (type === 'route') return Array.isArray(spec.path) && spec.path.length >= 2;
|
||||
if (type === 'route')
|
||||
return Array.isArray(spec.path) && spec.path.length >= 2;
|
||||
if (type === 'area') return Array.isArray(spec.ring) && spec.ring.length >= 3;
|
||||
return Number.isFinite(Number(spec.latitude)) && Number.isFinite(Number(spec.longitude));
|
||||
return (
|
||||
Number.isFinite(Number(spec.latitude)) &&
|
||||
Number.isFinite(Number(spec.longitude))
|
||||
);
|
||||
}
|
||||
|
||||
/** [lon, lat] pairs or {lon, lat} objects → [lon, lat] pairs, invalid entries dropped. */
|
||||
function manualPairs(list) {
|
||||
return (Array.isArray(list) ? list : [])
|
||||
.map((p) => (Array.isArray(p) ? [Number(p[0]), Number(p[1])] : [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)]))
|
||||
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180);
|
||||
.map((p) =>
|
||||
Array.isArray(p)
|
||||
? [Number(p[0]), Number(p[1])]
|
||||
: [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)],
|
||||
)
|
||||
.filter(
|
||||
([lon, lat]) =>
|
||||
Number.isFinite(lon) &&
|
||||
Number.isFinite(lat) &&
|
||||
Math.abs(lat) <= 90 &&
|
||||
Math.abs(lon) <= 180,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1132,15 +1449,28 @@ function resolveManualSpec(spec, type, viewer) {
|
||||
// height 0 is a placeholder, not a placement: the world renderer draws a
|
||||
// route with clampToGround + CESIUM_3D_TILE classification, so the line is
|
||||
// draped onto the photoreal surface whatever this number says.
|
||||
const pts = manualPairs(spec.path).map(([lon, lat]) => ({ lon, lat, height: 0 }));
|
||||
const pts = manualPairs(spec.path).map(([lon, lat]) => ({
|
||||
lon,
|
||||
lat,
|
||||
height: 0,
|
||||
}));
|
||||
if (pts.length < 2) throw new Error('a drawn line needs at least 2 points');
|
||||
let distanceM = 0;
|
||||
for (let i = 1; i < pts.length; i += 1) distanceM += greatCircleM(pts[i - 1], pts[i]);
|
||||
return { path: pts, distanceM, durationS: null, mode: 'manual', source: 'manual', fallback: false };
|
||||
for (let i = 1; i < pts.length; i += 1)
|
||||
distanceM += greatCircleM(pts[i - 1], pts[i]);
|
||||
return {
|
||||
path: pts,
|
||||
distanceM,
|
||||
durationS: null,
|
||||
mode: 'manual',
|
||||
source: 'manual',
|
||||
fallback: false,
|
||||
};
|
||||
}
|
||||
if (type === 'area') {
|
||||
const ring = manualPairs(spec.ring);
|
||||
if (ring.length < 3) throw new Error('a drawn area needs at least 3 points');
|
||||
if (ring.length < 3)
|
||||
throw new Error('a drawn area needs at least 3 points');
|
||||
// Averaged through ringCentroid, which unwraps longitudes first: a ring
|
||||
// straddling the antimeridian has coordinates like [179.999, -179.999], and
|
||||
// a raw mean of those puts the anchor on the Greenwich meridian, half a
|
||||
@@ -1151,13 +1481,25 @@ function resolveManualSpec(spec, type, viewer) {
|
||||
const lon = centre.lon;
|
||||
const lat = centre.lat;
|
||||
return {
|
||||
lon, lat, height: sampleGroundHeight(viewer, lon, lat),
|
||||
ring, footprintKind: 'area', buildingHeight: null, synthesized: false, source: 'manual',
|
||||
lon,
|
||||
lat,
|
||||
height: sampleGroundHeight(viewer, lon, lat),
|
||||
ring,
|
||||
footprintKind: 'area',
|
||||
buildingHeight: null,
|
||||
synthesized: false,
|
||||
source: 'manual',
|
||||
};
|
||||
}
|
||||
const lon = Number(spec.longitude);
|
||||
const lat = Number(spec.latitude);
|
||||
return { lon, lat, height: sampleGroundHeight(viewer, lon, lat), ring: null, source: 'manual' };
|
||||
return {
|
||||
lon,
|
||||
lat,
|
||||
height: sampleGroundHeight(viewer, lon, lat),
|
||||
ring: null,
|
||||
source: 'manual',
|
||||
};
|
||||
}
|
||||
|
||||
/** The ring's distinct positions: a closing vertex that repeats the first is dropped. */
|
||||
@@ -1165,20 +1507,26 @@ function closedRingWithoutRepeat(ring) {
|
||||
if (ring.length < 2) return ring;
|
||||
const [firstLon, firstLat] = ring[0];
|
||||
const [lastLon, lastLat] = ring[ring.length - 1];
|
||||
return firstLon === lastLon && firstLat === lastLat ? ring.slice(0, -1) : ring;
|
||||
return firstLon === lastLon && firstLat === lastLat
|
||||
? ring.slice(0, -1)
|
||||
: ring;
|
||||
}
|
||||
|
||||
function normalizeMode(m) {
|
||||
const t = String(m || '').toLowerCase();
|
||||
if (t === 'car' || t === 'drive' || t === 'driving') return 'car';
|
||||
if (t === 'bike' || t === 'cycle' || t === 'cycling' || t === 'bicycle') return 'bike';
|
||||
if (t === 'bike' || t === 'cycle' || t === 'cycling' || t === 'bicycle')
|
||||
return 'bike';
|
||||
return 'foot';
|
||||
}
|
||||
|
||||
/** Route failure remains an explicitly labelled direct line in the caller. */
|
||||
async function fetchRoute(coordPairs, mode, signal, service) {
|
||||
try { return await service.route?.(coordPairs, mode, { signal }) || null; }
|
||||
catch { return null; }
|
||||
try {
|
||||
return (await service.route?.(coordPairs, mode, { signal })) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function greatCircleM(a, b) {
|
||||
@@ -1188,7 +1536,9 @@ function greatCircleM(a, b) {
|
||||
const dLon = toRad(b.lon - a.lon);
|
||||
const la1 = toRad(a.lat);
|
||||
const la2 = toRad(b.lat);
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
import { applicationServices } from '../services/application.js';
|
||||
const resolver = createAnnotationResolver({ boundarySource: applicationServices.boundaries });
|
||||
export const { resolveAnnotationTarget, refineScope, isRateLimitedOutcome, isGroundsLikeAsk, selectFootprint, viewportBias, placesNearViewRecovery, resolveRegionRingForQuery, pickWorldFromScreen, sampleGroundHeight } = resolver;
|
||||
const resolver = createAnnotationResolver({
|
||||
boundarySource: applicationServices.boundaries,
|
||||
});
|
||||
export const {
|
||||
resolveAnnotationTarget,
|
||||
refineScope,
|
||||
isRateLimitedOutcome,
|
||||
isGroundsLikeAsk,
|
||||
selectFootprint,
|
||||
viewportBias,
|
||||
placesNearViewRecovery,
|
||||
resolveRegionRingForQuery,
|
||||
pickWorldFromScreen,
|
||||
sampleGroundHeight,
|
||||
} = resolver;
|
||||
@@ -42,7 +42,11 @@ export function createHybridAnnotationRenderer(viewer) {
|
||||
entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
|
||||
screen.add(entry.screenProxy);
|
||||
}
|
||||
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
Array.isArray(anno.path) &&
|
||||
anno.path.length >= 2
|
||||
) {
|
||||
// Drape the path on the tiles (world), caption it with a screen callout
|
||||
// at the path midpoint.
|
||||
entry.worldProxy = liveProxy(anno, { label: null });
|
||||
@@ -86,7 +90,11 @@ export function createHybridAnnotationRenderer(viewer) {
|
||||
} else if (anno.label) {
|
||||
screen.add(screenProxy);
|
||||
}
|
||||
entry.screenProxy = entry.screenProxy ? screenProxy : (anno.label ? screenProxy : null);
|
||||
entry.screenProxy = entry.screenProxy
|
||||
? screenProxy
|
||||
: anno.label
|
||||
? screenProxy
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,13 +10,27 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
||||
* footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver,
|
||||
* and voice tool wiring are identical to the other two branches.
|
||||
*/
|
||||
export function initAnnotations({ viewer, tileset = null, placeSearch, resolver }) {
|
||||
export function initAnnotations({
|
||||
viewer,
|
||||
tileset = null,
|
||||
placeSearch,
|
||||
resolver,
|
||||
}) {
|
||||
// World-space footprint draping; clamped marks can use the photoreal tiles.
|
||||
if (tileset) {
|
||||
try { tileset.enableCollision = true; } catch { /* older tileset */ }
|
||||
try {
|
||||
tileset.enableCollision = true;
|
||||
} catch {
|
||||
/* older tileset */
|
||||
}
|
||||
}
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const engine = createAnnotationEngine({ viewer, renderer, placeSearch, resolveTarget: resolver?.resolveAnnotationTarget });
|
||||
const engine = createAnnotationEngine({
|
||||
viewer,
|
||||
renderer,
|
||||
placeSearch,
|
||||
resolveTarget: resolver?.resolveAnnotationTarget,
|
||||
});
|
||||
window.__gevAnnotations = engine;
|
||||
return engine;
|
||||
}
|
||||
@@ -37,9 +37,9 @@ const PALETTE = {
|
||||
// Altitude-scaled marker geometry. Zoomed in (≤ NEAR_H) reticles are full-size; pulling
|
||||
// back shrinks them toward small recon dots (MIN floor) so many world-anchored marks no
|
||||
// longer collapse into an overlapping blob when their anchors project close together.
|
||||
const MARK_SCALE_NEAR_H = 800; // m: at/below this, full-size reticles
|
||||
const MARK_SCALE_FAR_H = 6000; // m: at/above this, minimum size
|
||||
const MARK_SCALE_MIN = 0.32; // floor so dots never vanish
|
||||
const MARK_SCALE_NEAR_H = 800; // m: at/below this, full-size reticles
|
||||
const MARK_SCALE_FAR_H = 6000; // m: at/above this, minimum size
|
||||
const MARK_SCALE_MIN = 0.32; // floor so dots never vanish
|
||||
const RING_OUTER_R = 34;
|
||||
const RING_INNER_R = 18;
|
||||
const DOT_R = 5;
|
||||
@@ -51,16 +51,22 @@ function markScale(h) {
|
||||
return 1 - t * (1 - MARK_SCALE_MIN);
|
||||
}
|
||||
|
||||
export function createScreenAnnotationRenderer(viewer, {
|
||||
overlayPaintRect = getOverlayPaintRect,
|
||||
activeTrackedReadoutId = getActiveTrackedReadoutId,
|
||||
} = {}) {
|
||||
export function createScreenAnnotationRenderer(
|
||||
viewer,
|
||||
{
|
||||
overlayPaintRect = getOverlayPaintRect,
|
||||
activeTrackedReadoutId = getActiveTrackedReadoutId,
|
||||
} = {},
|
||||
) {
|
||||
injectStyles();
|
||||
const { layer, svg, defs } = buildOverlay();
|
||||
document.body.appendChild(layer);
|
||||
|
||||
const scene = viewer.scene;
|
||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, scene.camera.positionWC);
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
scene.camera.positionWC,
|
||||
);
|
||||
const records = new Map(); // anno.id -> { anno, group, parts }
|
||||
const scratch = new Cesium.Cartesian2();
|
||||
const scratchDir = new Cesium.Cartesian3();
|
||||
@@ -112,8 +118,13 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
return cached.h;
|
||||
}
|
||||
try {
|
||||
if (scene.clampToHeightSupported && typeof scene.clampToHeight === 'function') {
|
||||
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
|
||||
if (
|
||||
scene.clampToHeightSupported &&
|
||||
typeof scene.clampToHeight === 'function'
|
||||
) {
|
||||
const c = scene.clampToHeight(
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat, 0),
|
||||
);
|
||||
if (c) {
|
||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
||||
if (Number.isFinite(h) && h > -430 && h < 9000) {
|
||||
@@ -123,7 +134,9 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* tiles not ready */ }
|
||||
} catch {
|
||||
/* tiles not ready */
|
||||
}
|
||||
const fallback = cached ? cached.h : 0;
|
||||
heightCache.set(key, { h: fallback, settled: false, gen: projGen });
|
||||
trimHeightCache();
|
||||
@@ -140,7 +153,8 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
// "draw-on" gesture, so they never masquerade as an authoritative boundary.
|
||||
parts.poly = svgEl('polygon', {
|
||||
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw',
|
||||
fill: c, stroke: c,
|
||||
fill: c,
|
||||
stroke: c,
|
||||
'fill-opacity': anno.synthesized ? '0.08' : '0.14',
|
||||
'stroke-width': '2.5',
|
||||
...(anno.synthesized ? { 'stroke-dasharray': '9 7' } : {}),
|
||||
@@ -151,25 +165,41 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
parts.path = svgEl('path', {
|
||||
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c,
|
||||
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||
class: 'gev-anno-arrow gev-draw',
|
||||
fill: 'none',
|
||||
stroke: c,
|
||||
'stroke-width': '3',
|
||||
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
||||
group.appendChild(parts.path);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
Array.isArray(anno.path) &&
|
||||
anno.path.length >= 2
|
||||
) {
|
||||
// Multi-waypoint path: a drawn-on polyline with a dot at each waypoint.
|
||||
parts.poly = svgEl('polyline', {
|
||||
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c,
|
||||
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||
class: 'gev-anno-arrow gev-draw',
|
||||
fill: 'none',
|
||||
stroke: c,
|
||||
'stroke-width': '3',
|
||||
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
ensureArrowMarker(defs, anno.color || 'primary', c);
|
||||
group.appendChild(parts.poly);
|
||||
parts.dots = anno.path.map(() => {
|
||||
const dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: '4' });
|
||||
const dot = svgEl('circle', {
|
||||
class: 'gev-anno-dot',
|
||||
fill: c,
|
||||
stroke: '#06121c',
|
||||
'stroke-width': '2',
|
||||
r: '4',
|
||||
});
|
||||
group.appendChild(dot);
|
||||
return dot;
|
||||
});
|
||||
@@ -178,14 +208,39 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
} else {
|
||||
// pin / highlight / label — pulsing target rings + a marker dot + callout
|
||||
if (anno.type !== 'label') {
|
||||
parts.ringOuter = svgEl('circle', { class: 'gev-anno-ring gev-pulse', fill: 'none', stroke: c, 'stroke-width': '2', r: '34' });
|
||||
parts.ringInner = svgEl('circle', { class: 'gev-anno-ring', fill: c, 'fill-opacity': '0.12', stroke: c, 'stroke-width': '2.5', r: '18', filter: 'url(#gev-sketch)' });
|
||||
parts.ringOuter = svgEl('circle', {
|
||||
class: 'gev-anno-ring gev-pulse',
|
||||
fill: 'none',
|
||||
stroke: c,
|
||||
'stroke-width': '2',
|
||||
r: '34',
|
||||
});
|
||||
parts.ringInner = svgEl('circle', {
|
||||
class: 'gev-anno-ring',
|
||||
fill: c,
|
||||
'fill-opacity': '0.12',
|
||||
stroke: c,
|
||||
'stroke-width': '2.5',
|
||||
r: '18',
|
||||
filter: 'url(#gev-sketch)',
|
||||
});
|
||||
group.appendChild(parts.ringOuter);
|
||||
group.appendChild(parts.ringInner);
|
||||
}
|
||||
parts.dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: anno.type === 'label' ? '4' : '5' });
|
||||
parts.dot = svgEl('circle', {
|
||||
class: 'gev-anno-dot',
|
||||
fill: c,
|
||||
stroke: '#06121c',
|
||||
'stroke-width': '2',
|
||||
r: anno.type === 'label' ? '4' : '5',
|
||||
});
|
||||
group.appendChild(parts.dot);
|
||||
parts.leader = svgEl('line', { class: 'gev-anno-leader', stroke: c, 'stroke-width': '1.5', 'stroke-opacity': '0.7' });
|
||||
parts.leader = svgEl('line', {
|
||||
class: 'gev-anno-leader',
|
||||
stroke: c,
|
||||
'stroke-width': '1.5',
|
||||
'stroke-opacity': '0.7',
|
||||
});
|
||||
group.appendChild(parts.leader);
|
||||
parts.label = makeCallout(anno.label, c);
|
||||
if (parts.label) group.appendChild(parts.label.node);
|
||||
@@ -209,18 +264,26 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
// any length. Guarded on the specific property so an unrelated transition
|
||||
// (none here, but defensive) doesn't clear it early.
|
||||
for (const drawEl of group.querySelectorAll('.gev-draw')) {
|
||||
drawEl.addEventListener('transitionend', (e) => {
|
||||
if (e.propertyName === 'stroke-dashoffset') {
|
||||
drawEl.style.strokeDasharray = 'none';
|
||||
}
|
||||
}, { once: true });
|
||||
drawEl.addEventListener(
|
||||
'transitionend',
|
||||
(e) => {
|
||||
if (e.propertyName === 'stroke-dashoffset') {
|
||||
drawEl.style.strokeDasharray = 'none';
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
projectAll();
|
||||
} catch (error) {
|
||||
// Hard, immediate unwind — a mark that never finished has nothing to
|
||||
// fade out, and the caller (engine rollback) must find a clean board.
|
||||
records.delete(anno.id);
|
||||
try { group.remove(); } catch { /* never inserted */ }
|
||||
try {
|
||||
group.remove();
|
||||
} catch {
|
||||
/* never inserted */
|
||||
}
|
||||
if (records.size === 0) heightCache.clear();
|
||||
throw error;
|
||||
}
|
||||
@@ -269,13 +332,22 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
// Anchor at the real surface height (cached + validated), so marks sit on
|
||||
// the ground/building instead of at sea level. The settle-once cache keeps
|
||||
// it stable (no per-frame jitter that earlier broke nearby projections).
|
||||
const world = Cesium.Cartesian3.fromDegrees(lon, lat, groundHeight(lon, lat));
|
||||
const world = Cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
groundHeight(lon, lat),
|
||||
);
|
||||
// Reject points behind the camera (they produce wrapped/extreme coords).
|
||||
Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir);
|
||||
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0) return null;
|
||||
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0)
|
||||
return null;
|
||||
// Reject points beyond the globe horizon.
|
||||
if (!occluder.isPointVisible(world)) return null;
|
||||
const win = Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, scratch);
|
||||
const win = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
world,
|
||||
scratch,
|
||||
);
|
||||
if (!win || !Number.isFinite(win.x) || !Number.isFinite(win.y)) return null;
|
||||
// Reject absurd off-screen projections (anchor not in view).
|
||||
const w = scene.canvas.clientWidth || scene.canvas.width;
|
||||
@@ -291,9 +363,9 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
// tracked subject's screen FOOTPRINT is faded. The complete card footprint comes
|
||||
// from the host's ACTUAL painted rectangle after final layout; the billboard extent
|
||||
// is unioned for aircraft/satellites that also own a native tracked graphic.
|
||||
const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint
|
||||
const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1)
|
||||
const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop
|
||||
const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint
|
||||
const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1)
|
||||
const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop
|
||||
const _scratchTrackedWin = new Cesium.Cartesian2();
|
||||
|
||||
// Evaluate a NearFarScalar (billboard scaleByDistance) at a camera distance.
|
||||
@@ -317,11 +389,16 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
|
||||
const ent = viewer.trackedEntity;
|
||||
const now = Cesium.JulianDate.now();
|
||||
const world = typeof ent?.gevDisplayPosition === 'function'
|
||||
? ent.gevDisplayPosition()
|
||||
: null;
|
||||
const world =
|
||||
typeof ent?.gevDisplayPosition === 'function'
|
||||
? ent.gevDisplayPosition()
|
||||
: null;
|
||||
const win = world
|
||||
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, _scratchTrackedWin)
|
||||
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
world,
|
||||
_scratchTrackedWin,
|
||||
)
|
||||
: null;
|
||||
|
||||
// Billboard box — centered on the anchor, magnified by scaleByDistance.
|
||||
@@ -331,8 +408,13 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
const baseH = bb.height?.getValue?.(now) ?? 28;
|
||||
let scale = 1;
|
||||
const sbd = bb.scaleByDistance?.getValue?.(now);
|
||||
if (sbd) scale = nearFarValue(sbd, Cesium.Cartesian3.distance(scene.camera.positionWC, world));
|
||||
const hw = (baseW * scale) / 2; const hh = (baseH * scale) / 2;
|
||||
if (sbd)
|
||||
scale = nearFarValue(
|
||||
sbd,
|
||||
Cesium.Cartesian3.distance(scene.camera.positionWC, world),
|
||||
);
|
||||
const hw = (baseW * scale) / 2;
|
||||
const hh = (baseH * scale) / 2;
|
||||
const bbLeft = win.x - hw;
|
||||
const bbRight = win.x + hw;
|
||||
const bbTop = win.y - hh;
|
||||
@@ -345,7 +427,12 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
if (![left, right, top, bottom].every(Number.isFinite)) return null;
|
||||
|
||||
const m = TRACKED_BBOX_MARGIN;
|
||||
return { left: left - m, right: right + m, top: top - m, bottom: bottom + m };
|
||||
return {
|
||||
left: left - m,
|
||||
right: right + m,
|
||||
top: top - m,
|
||||
bottom: bottom + m,
|
||||
};
|
||||
}
|
||||
|
||||
function projectAll() {
|
||||
@@ -357,7 +444,9 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
const w = scene.canvas.clientWidth || scene.canvas.width;
|
||||
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
||||
// Shrink point-marker geometry as the camera pulls back (declutter the blob).
|
||||
const mScale = markScale(viewer.camera.positionCartographic?.height ?? 1000);
|
||||
const mScale = markScale(
|
||||
viewer.camera.positionCartographic?.height ?? 1000,
|
||||
);
|
||||
|
||||
for (const rec of records.values()) {
|
||||
const { anno, group, parts } = rec;
|
||||
@@ -369,17 +458,22 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
|
||||
if (anno.type === 'area' && parts.poly) {
|
||||
const pts = [];
|
||||
let cx = 0; let cy = 0; let n = 0;
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
let n = 0;
|
||||
for (const [lon, lat] of anno.ring) {
|
||||
const p = project(lon, lat);
|
||||
if (!p) continue;
|
||||
pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
|
||||
cx += p.x; cy += p.y; n++;
|
||||
cx += p.x;
|
||||
cy += p.y;
|
||||
n++;
|
||||
}
|
||||
if (pts.length >= 3) {
|
||||
parts.poly.setAttribute('points', pts.join(' '));
|
||||
group.style.display = '';
|
||||
if (parts.label) positionCallout(parts.label, cx / n, cy / n - 6, true);
|
||||
if (parts.label)
|
||||
positionCallout(parts.label, cx / n, cy / n - 6, true);
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
}
|
||||
@@ -388,16 +482,27 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
const b = project(anno.to.lon, anno.to.lat);
|
||||
if (a && b) {
|
||||
// gentle arc so the connector reads as a drawn gesture, not a ruler line
|
||||
const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2;
|
||||
const dx = b.x - a.x; const dy = b.y - a.y;
|
||||
const mx = (a.x + b.x) / 2;
|
||||
const my = (a.y + b.y) / 2;
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const bow = Math.min(60, len * 0.18);
|
||||
const ctrlX = mx - (dy / len) * bow;
|
||||
const ctrlY = my + (dx / len) * bow;
|
||||
parts.path.setAttribute('d', `M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`);
|
||||
parts.path.setAttribute(
|
||||
'd',
|
||||
`M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`,
|
||||
);
|
||||
group.style.display = '';
|
||||
// Anchor the label to the (stable) endpoint midpoint, lifted toward the bow.
|
||||
if (parts.label) positionCallout(parts.label, mx + (ctrlX - mx) * 0.5, my + (ctrlY - my) * 0.5 - 8, true);
|
||||
if (parts.label)
|
||||
positionCallout(
|
||||
parts.label,
|
||||
mx + (ctrlX - mx) * 0.5,
|
||||
my + (ctrlY - my) * 0.5 - 8,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
}
|
||||
@@ -412,13 +517,22 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
// dots at each resolvable waypoint; hide the rest
|
||||
parts.dots.forEach((dot, i) => {
|
||||
const p = projected[i];
|
||||
if (p) { dot.setAttribute('cx', p.x.toFixed(1)); dot.setAttribute('cy', p.y.toFixed(1)); dot.style.display = ''; }
|
||||
else { dot.style.display = 'none'; }
|
||||
if (p) {
|
||||
dot.setAttribute('cx', p.x.toFixed(1));
|
||||
dot.setAttribute('cy', p.y.toFixed(1));
|
||||
dot.style.display = '';
|
||||
} else {
|
||||
dot.style.display = 'none';
|
||||
}
|
||||
});
|
||||
group.style.display = '';
|
||||
// label at the middle waypoint
|
||||
const mid = projected.filter(Boolean)[Math.floor(projected.filter(Boolean).length / 2)];
|
||||
if (parts.label && mid) positionCallout(parts.label, mid.x, mid.y - 12, true);
|
||||
const mid =
|
||||
projected.filter(Boolean)[
|
||||
Math.floor(projected.filter(Boolean).length / 2)
|
||||
];
|
||||
if (parts.label && mid)
|
||||
positionCallout(parts.label, mid.x, mid.y - 12, true);
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
}
|
||||
@@ -427,16 +541,40 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
if (p) {
|
||||
group.style.display = '';
|
||||
const baseDotR = anno.type === 'label' ? LABEL_DOT_R : DOT_R;
|
||||
if (parts.dot) { parts.dot.setAttribute('cx', p.x.toFixed(1)); parts.dot.setAttribute('cy', p.y.toFixed(1)); parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1)); }
|
||||
if (parts.ringOuter) { parts.ringOuter.setAttribute('cx', p.x.toFixed(1)); parts.ringOuter.setAttribute('cy', p.y.toFixed(1)); parts.ringOuter.setAttribute('r', (RING_OUTER_R * mScale).toFixed(1)); }
|
||||
if (parts.ringInner) { parts.ringInner.setAttribute('cx', p.x.toFixed(1)); parts.ringInner.setAttribute('cy', p.y.toFixed(1)); parts.ringInner.setAttribute('r', (RING_INNER_R * mScale).toFixed(1)); }
|
||||
if (parts.dot) {
|
||||
parts.dot.setAttribute('cx', p.x.toFixed(1));
|
||||
parts.dot.setAttribute('cy', p.y.toFixed(1));
|
||||
parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1));
|
||||
}
|
||||
if (parts.ringOuter) {
|
||||
parts.ringOuter.setAttribute('cx', p.x.toFixed(1));
|
||||
parts.ringOuter.setAttribute('cy', p.y.toFixed(1));
|
||||
parts.ringOuter.setAttribute(
|
||||
'r',
|
||||
(RING_OUTER_R * mScale).toFixed(1),
|
||||
);
|
||||
}
|
||||
if (parts.ringInner) {
|
||||
parts.ringInner.setAttribute('cx', p.x.toFixed(1));
|
||||
parts.ringInner.setAttribute('cy', p.y.toFixed(1));
|
||||
parts.ringInner.setAttribute(
|
||||
'r',
|
||||
(RING_INNER_R * mScale).toFixed(1),
|
||||
);
|
||||
}
|
||||
if (parts.label) {
|
||||
// Keep the callout tucked near the (now smaller) reticle by scaling its offset.
|
||||
const lx = p.x + 18 * mScale; const ly = p.y - 34 * mScale;
|
||||
const lx = p.x + 18 * mScale;
|
||||
const ly = p.y - 34 * mScale;
|
||||
positionCallout(parts.label, lx, ly, false);
|
||||
if (parts.leader) {
|
||||
parts.leader.setAttribute('x1', p.x.toFixed(1)); parts.leader.setAttribute('y1', p.y.toFixed(1));
|
||||
parts.leader.setAttribute('x2', lx.toFixed(1)); parts.leader.setAttribute('y2', (ly + parts.label.height).toFixed(1));
|
||||
parts.leader.setAttribute('x1', p.x.toFixed(1));
|
||||
parts.leader.setAttribute('y1', p.y.toFixed(1));
|
||||
parts.leader.setAttribute('x2', lx.toFixed(1));
|
||||
parts.leader.setAttribute(
|
||||
'y2',
|
||||
(ly + parts.label.height).toFixed(1),
|
||||
);
|
||||
parts.label._leader = parts.leader; // so de-collision can re-point it
|
||||
}
|
||||
}
|
||||
@@ -450,7 +588,12 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
const placed = [];
|
||||
for (const rec of records.values()) {
|
||||
const cal = rec.parts.label;
|
||||
if (cal && cal.sized && rec.group.style.display !== 'none' && Number.isFinite(cal._x)) {
|
||||
if (
|
||||
cal &&
|
||||
cal.sized &&
|
||||
rec.group.style.display !== 'none' &&
|
||||
Number.isFinite(cal._x)
|
||||
) {
|
||||
placed.push(cal);
|
||||
}
|
||||
}
|
||||
@@ -471,7 +614,11 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
let overlapping = false;
|
||||
if (trackedRect) {
|
||||
let bb = null;
|
||||
try { bb = group.getBBox(); } catch { bb = null; }
|
||||
try {
|
||||
bb = group.getBBox();
|
||||
} catch {
|
||||
bb = null;
|
||||
}
|
||||
if (bb && (bb.width !== 0 || bb.height !== 0)) {
|
||||
// HYSTERESIS: a mark that's already hidden must clear the rect by
|
||||
// TRACKED_HYSTERESIS_PX before it starts showing again; one that's visible fades
|
||||
@@ -479,8 +626,11 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
// frame-to-frame (the pulsing reticle stroke + de-collided cards keep the bbox in
|
||||
// motion), which was the source of the flicker.
|
||||
const m = cur < 0.5 ? TRACKED_HYSTERESIS_PX : 0;
|
||||
overlapping = bb.x <= trackedRect.right + m && bb.x + bb.width >= trackedRect.left - m
|
||||
&& bb.y <= trackedRect.bottom + m && bb.y + bb.height >= trackedRect.top - m;
|
||||
overlapping =
|
||||
bb.x <= trackedRect.right + m &&
|
||||
bb.x + bb.width >= trackedRect.left - m &&
|
||||
bb.y <= trackedRect.bottom + m &&
|
||||
bb.y + bb.height >= trackedRect.top - m;
|
||||
}
|
||||
}
|
||||
const target = overlapping ? 0 : 1;
|
||||
@@ -490,7 +640,10 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
// (The CSS `transition: opacity` was removed from .gev-anno so this per-frame JS ease
|
||||
// is the ONLY easing — previously the two fought and produced an opacity oscillation.)
|
||||
if (rec._trackedFade !== 1) {
|
||||
group.setAttribute('opacity', String((anno.alpha ?? 1) * rec._trackedFade));
|
||||
group.setAttribute(
|
||||
'opacity',
|
||||
String((anno.alpha ?? 1) * rec._trackedFade),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -501,7 +654,13 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
rec.group.classList.remove('gev-in');
|
||||
rec.group.classList.add('gev-out');
|
||||
const node = rec.group;
|
||||
window.setTimeout(() => { try { node.remove(); } catch { /* gone */ } }, 360);
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
node.remove();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}, 360);
|
||||
records.delete(anno.id);
|
||||
// Board emptied → drop accumulated height samples (natural reset point).
|
||||
if (records.size === 0) heightCache.clear();
|
||||
@@ -513,8 +672,16 @@ export function createScreenAnnotationRenderer(viewer, {
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
try { scene.postRender.removeEventListener(onPostRender); } catch { /* torn down */ }
|
||||
try { layer.remove(); } catch { /* gone */ }
|
||||
try {
|
||||
scene.postRender.removeEventListener(onPostRender);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
try {
|
||||
layer.remove();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
records.clear();
|
||||
heightCache.clear();
|
||||
}
|
||||
@@ -562,7 +729,15 @@ function makeCallout(text, c) {
|
||||
node.appendChild(rect);
|
||||
node.appendChild(accent);
|
||||
node.appendChild(t);
|
||||
const callout = { node, rect, text: t, accent, width: 0, height: 26, sized: false };
|
||||
const callout = {
|
||||
node,
|
||||
rect,
|
||||
text: t,
|
||||
accent,
|
||||
width: 0,
|
||||
height: 26,
|
||||
sized: false,
|
||||
};
|
||||
// size the card to the text once it is in the DOM
|
||||
requestAnimationFrame(() => sizeCallout(callout));
|
||||
return callout;
|
||||
@@ -581,7 +756,9 @@ function sizeCallout(callout) {
|
||||
callout.width = w;
|
||||
callout.height = h;
|
||||
callout.sized = true;
|
||||
} catch { /* not laid out yet */ }
|
||||
} catch {
|
||||
/* not laid out yet */
|
||||
}
|
||||
}
|
||||
|
||||
function positionCallout(callout, x, y, center) {
|
||||
@@ -589,7 +766,10 @@ function positionCallout(callout, x, y, center) {
|
||||
const tx = center ? x - callout.width / 2 : x;
|
||||
callout._x = tx;
|
||||
callout._y = y;
|
||||
callout.node.setAttribute('transform', `translate(${tx.toFixed(1)}, ${y.toFixed(1)})`);
|
||||
callout.node.setAttribute(
|
||||
'transform',
|
||||
`translate(${tx.toFixed(1)}, ${y.toFixed(1)})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -607,7 +787,10 @@ function decollideCallouts(callouts) {
|
||||
const overlapY = a._y < b._y + b.height && a._y + a.height > b._y;
|
||||
if (overlapX && overlapY) {
|
||||
a._y = b._y + b.height + 5;
|
||||
a.node.setAttribute('transform', `translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`);
|
||||
a.node.setAttribute(
|
||||
'transform',
|
||||
`translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`,
|
||||
);
|
||||
if (a._leader) {
|
||||
a._leader.setAttribute('x2', a._x.toFixed(1));
|
||||
a._leader.setAttribute('y2', a._y.toFixed(1));
|
||||
|
||||
@@ -58,7 +58,9 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
ensureFlowFabricRegistered();
|
||||
|
||||
function colorFor(anno) {
|
||||
return Cesium.Color.fromCssColorString(PALETTE[anno.color] || PALETTE.primary);
|
||||
return Cesium.Color.fromCssColorString(
|
||||
PALETTE[anno.color] || PALETTE.primary,
|
||||
);
|
||||
}
|
||||
|
||||
// Target-ring radius (meters) scaled to camera height so it reads at any
|
||||
@@ -86,7 +88,11 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
const entities = [];
|
||||
anno._entities = entities;
|
||||
|
||||
if (anno.ring && anno.ring.length >= 3 && anno.footprintKind === 'building') {
|
||||
if (
|
||||
anno.ring &&
|
||||
anno.ring.length >= 3 &&
|
||||
anno.footprintKind === 'building'
|
||||
) {
|
||||
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION
|
||||
// volume that tints the real photogrammetry mesh (dome, walls, roof —
|
||||
// everything inside the column) amber and pulses. A flat footprint
|
||||
@@ -94,93 +100,134 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
// actual tiles. A faint wireframe cage rides on top as a secondary cue.
|
||||
const buffered = bufferRing(anno.ring, 3);
|
||||
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
|
||||
const groundH = sampleGroundOutside(viewer.scene, anno.ring)
|
||||
?? (Number.isFinite(anno.anchor.height) ? anno.anchor.height - (anno.buildingHeight || 25) : 0);
|
||||
const groundH =
|
||||
sampleGroundOutside(viewer.scene, anno.ring) ??
|
||||
(Number.isFinite(anno.anchor.height)
|
||||
? anno.anchor.height - (anno.buildingHeight || 25)
|
||||
: 0);
|
||||
const baseH = groundH - 3;
|
||||
// Tall headroom is free: classification only colors where tiles exist, so
|
||||
// empty air above the roof isn't tinted — this just guarantees we cover
|
||||
// under-tagged building heights.
|
||||
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
|
||||
// 1) Classification volume — tints the mesh amber, pulsing.
|
||||
entities.push(dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(positions),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
classificationType: CLASSIFY,
|
||||
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.45, pulse: true })),
|
||||
},
|
||||
}));
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(positions),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
classificationType: CLASSIFY,
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
liveColor(anno, base, { alpha: 0.45, pulse: true }),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
|
||||
// the footprint so the extruded outline draws ~14 clean verticals, not one
|
||||
// per (often 100+) ring vertex.
|
||||
const cagePositions = Cesium.Cartesian3.fromDegreesArray(decimateRing(buffered, 14).flat());
|
||||
entities.push(dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
fill: false,
|
||||
outline: true,
|
||||
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
|
||||
},
|
||||
}));
|
||||
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
|
||||
decimateRing(buffered, 14).flat(),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
fill: false,
|
||||
outline: true,
|
||||
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
// 3) Crisp draped base outline so the footprint reads on the ground.
|
||||
entities.push(dataSource.entities.add({
|
||||
polyline: {
|
||||
positions,
|
||||
width: 3,
|
||||
material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.25, color: liveColor(anno, base, { alpha: 0.7 }) }),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions,
|
||||
width: 3,
|
||||
material: new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.25,
|
||||
color: liveColor(anno, base, { alpha: 0.7 }),
|
||||
}),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
} else if (anno.ring && anno.ring.length >= 3) {
|
||||
// Larger area (district / compound / park) → flat fill draped on the tiles
|
||||
// + a glowing outline. Draping is right here: you can't extrude a whole
|
||||
// neighbourhood, and the GIS overlay shows the boundary clearly.
|
||||
const fillPositions = Cesium.Cartesian3.fromDegreesArray(anno.ring.flat());
|
||||
entities.push(dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
|
||||
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
||||
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: anno.synthesized ? 0.10 : 0.20, pulse: true })),
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
entities.push(dataSource.entities.add({
|
||||
polyline: {
|
||||
positions: fillPositions,
|
||||
width: 6,
|
||||
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
||||
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
||||
material: anno.synthesized
|
||||
? new Cesium.PolylineDashMaterialProperty({ color: liveColor(anno, base, { alpha: 0.95 }), dashLength: 24 })
|
||||
: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.35, color: liveColor(anno, base, { alpha: 1 }) }),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
const fillPositions = Cesium.Cartesian3.fromDegreesArray(
|
||||
anno.ring.flat(),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
|
||||
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
liveColor(anno, base, {
|
||||
alpha: anno.synthesized ? 0.1 : 0.2,
|
||||
pulse: true,
|
||||
}),
|
||||
),
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions: fillPositions,
|
||||
width: 6,
|
||||
// Synthesized → DASHED outline (signals "approximate, not an authoritative
|
||||
// boundary", research §8.4/§8.6); real footprints → solid glow.
|
||||
material: anno.synthesized
|
||||
? new Cesium.PolylineDashMaterialProperty({
|
||||
color: liveColor(anno, base, { alpha: 0.95 }),
|
||||
dashLength: 24,
|
||||
})
|
||||
: new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.35,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
}),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
|
||||
} else if (
|
||||
anno.type === 'route' &&
|
||||
Array.isArray(anno.path) &&
|
||||
anno.path.length >= 2
|
||||
) {
|
||||
// A real path (street-following) draped on the 3D tiles, with dashes that
|
||||
// FLOW toward the destination (custom st.s + time material; works on the
|
||||
// clamped/classified ground polyline).
|
||||
const positions = Cesium.Cartesian3.fromDegreesArray(
|
||||
anno.path.flatMap((p) => [p.lon, p.lat]),
|
||||
);
|
||||
entities.push(dataSource.entities.add({
|
||||
polyline: {
|
||||
positions,
|
||||
width: 9,
|
||||
material: new FlowMaterialProperty(PALETTE[anno.color] || PALETTE.primary),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions,
|
||||
width: 9,
|
||||
material: new FlowMaterialProperty(
|
||||
PALETTE[anno.color] || PALETTE.primary,
|
||||
),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
|
||||
} else if (anno.type === 'arrow' && anno.to) {
|
||||
// Connector draped across the ground from origin to destination.
|
||||
@@ -188,24 +235,30 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
||||
Cesium.Cartesian3.fromDegrees(anno.to.lon, anno.to.lat),
|
||||
];
|
||||
entities.push(dataSource.entities.add({
|
||||
polyline: {
|
||||
positions,
|
||||
width: 16,
|
||||
material: new Cesium.PolylineArrowMaterialProperty(liveColor(anno, base, { alpha: 0.95 })),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions,
|
||||
width: 16,
|
||||
material: new Cesium.PolylineArrowMaterialProperty(
|
||||
liveColor(anno, base, { alpha: 0.95 }),
|
||||
),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (anno.label) {
|
||||
const mid = {
|
||||
lon: (anno.anchor.lon + anno.to.lon) / 2,
|
||||
lat: (anno.anchor.lat + anno.to.lat) / 2,
|
||||
};
|
||||
entities.push(dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
|
||||
label: labelGraphic(anno, base),
|
||||
}));
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
|
||||
label: labelGraphic(anno, base),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// pin / highlight / label — a camera-proportional target ring + a marker.
|
||||
@@ -213,16 +266,23 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
// Radius scales with camera height so the ring reads at any altitude.
|
||||
// semiMajor === semiMinor is required AND must hold every frame, so both
|
||||
// axes read the SAME ringRadius() (camera height is constant per frame).
|
||||
entities.push(dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
||||
ellipse: {
|
||||
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
|
||||
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
|
||||
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.38, pulse: true })),
|
||||
outline: false,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}));
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
anno.anchor.lon,
|
||||
anno.anchor.lat,
|
||||
),
|
||||
ellipse: {
|
||||
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
|
||||
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
liveColor(anno, base, { alpha: 0.38, pulse: true }),
|
||||
),
|
||||
outline: false,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
entities.push(labelMarker(anno, base, { point: true }));
|
||||
}
|
||||
@@ -232,14 +292,16 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
function labelMarker(anno, base, { point }) {
|
||||
return dataSource.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
|
||||
point: point ? {
|
||||
pixelSize: anno.type === 'label' ? 8 : 14,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
||||
outlineWidth: 3,
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
} : undefined,
|
||||
point: point
|
||||
? {
|
||||
pixelSize: anno.type === 'label' ? 8 : 14,
|
||||
color: liveColor(anno, base, { alpha: 1 }),
|
||||
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
|
||||
outlineWidth: 3,
|
||||
heightReference: CLAMP,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
}
|
||||
: undefined,
|
||||
label: anno.label ? labelGraphic(anno, base) : undefined,
|
||||
});
|
||||
}
|
||||
@@ -256,7 +318,10 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
pixelOffset: new Cesium.Cartesian2(0, -16),
|
||||
showBackground: true,
|
||||
backgroundColor: new Cesium.CallbackProperty(
|
||||
() => Cesium.Color.fromCssColorString('#0b1622').withAlpha(0.72 * (anno.alpha ?? 1)),
|
||||
() =>
|
||||
Cesium.Color.fromCssColorString('#0b1622').withAlpha(
|
||||
0.72 * (anno.alpha ?? 1),
|
||||
),
|
||||
false,
|
||||
),
|
||||
backgroundPadding: new Cesium.Cartesian2(8, 5),
|
||||
@@ -357,8 +422,16 @@ export function FlowMaterialProperty(colorCss) {
|
||||
this._definitionChanged = new Cesium.Event();
|
||||
}
|
||||
Object.defineProperties(FlowMaterialProperty.prototype, {
|
||||
isConstant: { get() { return false; } }, // re-evaluated each frame → it animates
|
||||
definitionChanged: { get() { return this._definitionChanged; } },
|
||||
isConstant: {
|
||||
get() {
|
||||
return false;
|
||||
},
|
||||
}, // re-evaluated each frame → it animates
|
||||
definitionChanged: {
|
||||
get() {
|
||||
return this._definitionChanged;
|
||||
},
|
||||
},
|
||||
});
|
||||
FlowMaterialProperty.prototype.getType = function getType() {
|
||||
return 'GevRouteFlow';
|
||||
@@ -394,10 +467,17 @@ function decimateRing(ring, n) {
|
||||
* tiles under those points aren't loaded yet.
|
||||
*/
|
||||
function sampleGroundOutside(scene, ring) {
|
||||
if (!scene?.clampToHeightSupported || typeof scene.clampToHeight !== 'function') return null;
|
||||
if (
|
||||
!scene?.clampToHeightSupported ||
|
||||
typeof scene.clampToHeight !== 'function'
|
||||
)
|
||||
return null;
|
||||
let clon = 0;
|
||||
let clat = 0;
|
||||
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
|
||||
for (const [lon, lat] of ring) {
|
||||
clon += lon;
|
||||
clat += lat;
|
||||
}
|
||||
clon /= ring.length;
|
||||
clat /= ring.length;
|
||||
const latS = 111320;
|
||||
@@ -407,7 +487,16 @@ function sampleGroundOutside(scene, ring) {
|
||||
maxR = Math.max(maxR, Math.hypot((lon - clon) * lonS, (lat - clat) * latS));
|
||||
}
|
||||
const out = maxR * 1.5 + 12;
|
||||
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7, 0.7], [-0.7, -0.7], [0.7, -0.7], [-0.7, 0.7]];
|
||||
const dirs = [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1],
|
||||
[0.7, 0.7],
|
||||
[-0.7, -0.7],
|
||||
[0.7, -0.7],
|
||||
[-0.7, 0.7],
|
||||
];
|
||||
const samples = [];
|
||||
for (const [dx, dy] of dirs) {
|
||||
const lon = clon + (dx * out) / lonS;
|
||||
@@ -418,7 +507,9 @@ function sampleGroundOutside(scene, ring) {
|
||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
||||
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
|
||||
}
|
||||
} catch { /* tile not ready */ }
|
||||
} catch {
|
||||
/* tile not ready */
|
||||
}
|
||||
}
|
||||
if (!samples.length) return null;
|
||||
samples.sort((a, b) => a - b);
|
||||
@@ -433,7 +524,10 @@ function sampleGroundOutside(scene, ring) {
|
||||
function bufferRing(ring, meters) {
|
||||
let clon = 0;
|
||||
let clat = 0;
|
||||
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
|
||||
for (const [lon, lat] of ring) {
|
||||
clon += lon;
|
||||
clat += lat;
|
||||
}
|
||||
clon /= ring.length;
|
||||
clat /= ring.length;
|
||||
const latScale = 111320;
|
||||
|
||||
@@ -119,7 +119,7 @@ export function createApplicationCatalog({
|
||||
createApplicationRadio({ surface, source: sources.radio }),
|
||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
||||
createApplicationDirections(),
|
||||
createApplicationTransit({ surface }),
|
||||
createApplicationTransit({ surface, source: sources.transit }),
|
||||
vessels,
|
||||
installations,
|
||||
createApplicationAwareness({
|
||||
|
||||
@@ -22,11 +22,12 @@ import {
|
||||
* @param {{surface: object}} options
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createApplicationTransit({ surface }) {
|
||||
export function createApplicationTransit({ surface, source }) {
|
||||
if (!surface?.groundFloor || !surface?.meshFloor)
|
||||
throw new TypeError('Transit needs the application surface services');
|
||||
const { groundFloor, meshFloor } = surface;
|
||||
return createTransitLayer({
|
||||
source,
|
||||
services: {
|
||||
render,
|
||||
sprites,
|
||||
|
||||
+11
-10
@@ -31,7 +31,8 @@ function body(source, pattern, label) {
|
||||
function ordered(source, needles, label) {
|
||||
let previous = -1;
|
||||
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 > previous, `${label}: ${needle} is out of order`);
|
||||
previous = index;
|
||||
@@ -104,7 +105,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
|
||||
'Cockpit entry 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());',
|
||||
], 'Cockpit entry transaction');
|
||||
// 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', () => {
|
||||
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\(styleManager, 'route', 'fly_route', navigate/);
|
||||
assert.match(voice, /runManagedVoiceNavigation\(styleManager, 'fire', 'track_entity'/);
|
||||
assert.match(voice, /runManagedVoiceNavigation\(styleManager, family\.kind, 'track_entity'/);
|
||||
assert.match(voice, /runManagedVoiceNavigation\(styleManager, 'frame', 'frame_overhead'/);
|
||||
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'camera',\s*'move_camera',\s*navigate,\s*releaseOptions/);
|
||||
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'route',\s*'fly_route',\s*navigate/);
|
||||
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'fire',\s*'track_entity'/);
|
||||
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*family\.kind,\s*'track_entity'/);
|
||||
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'frame',\s*'frame_overhead'/);
|
||||
const trackedVoice = voice.slice(
|
||||
voice.indexOf('async function trackEntity'),
|
||||
voice.indexOf('async function frameOverhead'),
|
||||
@@ -224,20 +225,20 @@ test('validated voice camera destinations share the UI navigation authority faca
|
||||
ordered(move, [
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))",
|
||||
'const start = () => {',
|
||||
"const preserveCameraFlight = motion === 'orbit'",
|
||||
/const preserveCameraFlight =\s*motion === 'orbit'/,
|
||||
'runNavigation(start, { preserveCameraFlight })',
|
||||
], 'move validation before handoff');
|
||||
|
||||
const route = body(
|
||||
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',
|
||||
);
|
||||
ordered(route, [
|
||||
'if (!routes.length)',
|
||||
'const pts = route.path.map',
|
||||
'const start = () => {',
|
||||
"return typeof runNavigation === 'function' ? runNavigation(start) : start();",
|
||||
/return typeof runNavigation === 'function'\s*\? runNavigation\(start\)\s*: start\(\);/,
|
||||
], 'route validation before handoff');
|
||||
// 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
|
||||
|
||||
+473
-141
File diff suppressed because it is too large.
Load diff
+18
-7
@@ -22,9 +22,11 @@ export function activateCctvCameraFromWorldClick(
|
||||
) {
|
||||
if (!cameraId || typeof activate !== 'function') return false;
|
||||
if (activate(cameraId) !== CCTV_ACTIVATION_RESULT.ACTIVATED) return false;
|
||||
eventTarget.dispatchEvent(new CustomEvent(CCTV_FOCUS_REQUEST_EVENT, {
|
||||
detail: { cameraId },
|
||||
}));
|
||||
eventTarget.dispatchEvent(
|
||||
new CustomEvent(CCTV_FOCUS_REQUEST_EVENT, {
|
||||
detail: { cameraId },
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -36,8 +38,12 @@ export function activateCctvCameraFromWorldClick(
|
||||
* @returns {() => void} Listener disposer.
|
||||
*/
|
||||
export function registerCctvFocusRequestListener(eventTarget, listener) {
|
||||
if (!eventTarget?.addEventListener || !eventTarget?.removeEventListener
|
||||
|| typeof listener !== 'function') return () => {};
|
||||
if (
|
||||
!eventTarget?.addEventListener ||
|
||||
!eventTarget?.removeEventListener ||
|
||||
typeof listener !== 'function'
|
||||
)
|
||||
return () => {};
|
||||
eventTarget.addEventListener(CCTV_FOCUS_REQUEST_EVENT, listener);
|
||||
let disposed = false;
|
||||
return () => {
|
||||
@@ -57,11 +63,16 @@ export function registerCctvFocusRequestListener(eventTarget, listener) {
|
||||
*/
|
||||
export function routeCctvFocusRequest(event, runExplicitFocus, focusCamera) {
|
||||
const cameraId = event?.detail?.cameraId;
|
||||
if (typeof cameraId !== 'string' || !cameraId || typeof runExplicitFocus !== 'function') {
|
||||
if (
|
||||
typeof cameraId !== 'string' ||
|
||||
!cameraId ||
|
||||
typeof runExplicitFocus !== 'function'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return runExplicitFocus(
|
||||
() => cameraId,
|
||||
(selectedId) => focusCamera(selectedId, CCTV_WORLD_CLICK_FOCUS_DURATION_SEC),
|
||||
(selectedId) =>
|
||||
focusCamera(selectedId, CCTV_WORLD_CLICK_FOCUS_DURATION_SEC),
|
||||
);
|
||||
}
|
||||
+186
-56
@@ -90,16 +90,30 @@ export function getKeyholeGeometry(width, height) {
|
||||
* floor through a band derived from the live keyhole radius.
|
||||
*/
|
||||
export function keyholeLabelAlpha(labelX, labelY, width, height) {
|
||||
return keyholeLabelAlphaFromGeometry(labelX, labelY, getKeyholeGeometry(width, height));
|
||||
return keyholeLabelAlphaFromGeometry(
|
||||
labelX,
|
||||
labelY,
|
||||
getKeyholeGeometry(width, height),
|
||||
);
|
||||
}
|
||||
|
||||
/** Compute keyhole opacity from geometry already cached by a hot render loop. */
|
||||
export function keyholeLabelAlphaFromGeometry(labelX, labelY, geometry) {
|
||||
if (!geometry || !(geometry.radius > 0) || !Number.isFinite(labelX) || !Number.isFinite(labelY)) return 0;
|
||||
if (
|
||||
!geometry ||
|
||||
!(geometry.radius > 0) ||
|
||||
!Number.isFinite(labelX) ||
|
||||
!Number.isFinite(labelY)
|
||||
)
|
||||
return 0;
|
||||
const feather = geometry.featherPx;
|
||||
const distance = Math.hypot(labelX - geometry.centerX, labelY - geometry.centerY);
|
||||
const distance = Math.hypot(
|
||||
labelX - geometry.centerX,
|
||||
labelY - geometry.centerY,
|
||||
);
|
||||
if (distance <= geometry.radius) return 1;
|
||||
if (!(feather > 0) || distance >= geometry.radius + feather) return keyholeOutsideOpacity;
|
||||
if (!(feather > 0) || distance >= geometry.radius + feather)
|
||||
return keyholeOutsideOpacity;
|
||||
const progress = clamp((distance - geometry.radius) / feather, 0, 1);
|
||||
return 1 - (1 - keyholeOutsideOpacity) * progress;
|
||||
}
|
||||
@@ -131,7 +145,11 @@ export function isCelestialRingStyleSupported(styleName) {
|
||||
* @param {number} lastAngle - Previous stable angle in radians.
|
||||
* @returns {{angle:number, opacity:number, stable:boolean}}
|
||||
*/
|
||||
export function celestialScreenAngle(rightComponent, upComponent, lastAngle = 0) {
|
||||
export function celestialScreenAngle(
|
||||
rightComponent,
|
||||
upComponent,
|
||||
lastAngle = 0,
|
||||
) {
|
||||
const planeLength = Math.hypot(rightComponent, upComponent);
|
||||
if (!Number.isFinite(planeLength) || planeLength < CELESTIAL_PLANE_EPSILON) {
|
||||
return {
|
||||
@@ -170,26 +188,42 @@ export function isFullGlobeInsideKeyhole(geometry, wasVisible = false) {
|
||||
keyholeCenterY,
|
||||
keyholeRadius,
|
||||
} = geometry || {};
|
||||
const values = [earthCenterX, earthCenterY, earthRadius, keyholeCenterX, keyholeCenterY, keyholeRadius];
|
||||
if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0) return false;
|
||||
const offset = Math.hypot(earthCenterX - keyholeCenterX, earthCenterY - keyholeCenterY);
|
||||
const values = [
|
||||
earthCenterX,
|
||||
earthCenterY,
|
||||
earthRadius,
|
||||
keyholeCenterX,
|
||||
keyholeCenterY,
|
||||
keyholeRadius,
|
||||
];
|
||||
if (!values.every(Number.isFinite) || earthRadius <= 0 || keyholeRadius <= 0)
|
||||
return false;
|
||||
const offset = Math.hypot(
|
||||
earthCenterX - keyholeCenterX,
|
||||
earthCenterY - keyholeCenterY,
|
||||
);
|
||||
const clearance = keyholeRadius - (offset + earthRadius);
|
||||
return clearance >= (wasVisible ? GLOBE_EXIT_CLEARANCE_PX : GLOBE_ENTER_CLEARANCE_PX);
|
||||
return (
|
||||
clearance >=
|
||||
(wasVisible ? GLOBE_EXIT_CLEARANCE_PX : GLOBE_ENTER_CLEARANCE_PX)
|
||||
);
|
||||
}
|
||||
|
||||
/** Return the Earth-disc radius in CSS pixels for a perspective camera. */
|
||||
export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) {
|
||||
const earthRadiusM = Cesium.Ellipsoid.WGS84.maximumRadius;
|
||||
if (
|
||||
!Number.isFinite(cameraDistance)
|
||||
|| cameraDistance <= earthRadiusM
|
||||
|| !(viewportHeight > 0)
|
||||
|| !Number.isFinite(fovy)
|
||||
|| fovy <= 0
|
||||
|| fovy >= Math.PI
|
||||
) return null;
|
||||
!Number.isFinite(cameraDistance) ||
|
||||
cameraDistance <= earthRadiusM ||
|
||||
!(viewportHeight > 0) ||
|
||||
!Number.isFinite(fovy) ||
|
||||
fovy <= 0 ||
|
||||
fovy >= Math.PI
|
||||
)
|
||||
return null;
|
||||
const angularRadius = Math.asin(clamp(earthRadiusM / cameraDistance, 0, 1));
|
||||
const radius = (viewportHeight * 0.5) * Math.tan(angularRadius) / Math.tan(fovy * 0.5);
|
||||
const radius =
|
||||
(viewportHeight * 0.5 * Math.tan(angularRadius)) / Math.tan(fovy * 0.5);
|
||||
return Number.isFinite(radius) && radius > 0 ? radius : null;
|
||||
}
|
||||
|
||||
@@ -216,7 +250,11 @@ export function projectEarthDiscToViewport(
|
||||
if (!camera || !scene || !(width > 0) || !(height > 0)) return null;
|
||||
|
||||
const distance = Cesium.Cartesian3.magnitude(camera.positionWC);
|
||||
if (!Number.isFinite(distance) || distance <= Cesium.Ellipsoid.WGS84.maximumRadius) return null;
|
||||
if (
|
||||
!Number.isFinite(distance) ||
|
||||
distance <= Cesium.Ellipsoid.WGS84.maximumRadius
|
||||
)
|
||||
return null;
|
||||
|
||||
const toCenter = Cesium.Cartesian3.negate(
|
||||
camera.positionWC,
|
||||
@@ -229,9 +267,14 @@ export function projectEarthDiscToViewport(
|
||||
Cesium.Cartesian3.ZERO,
|
||||
scratchCenter,
|
||||
);
|
||||
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) return null;
|
||||
if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y))
|
||||
return null;
|
||||
|
||||
const earthRadius = earthDiscScreenRadius(distance, height, camera.frustum?.fovy);
|
||||
const earthRadius = earthDiscScreenRadius(
|
||||
distance,
|
||||
height,
|
||||
camera.frustum?.fovy,
|
||||
);
|
||||
if (!earthRadius) return null;
|
||||
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
@@ -286,7 +329,12 @@ function drawSunRays(ctx, cx, cy, radius, innerRadius, angle) {
|
||||
glow.addColorStop(0.58, 'rgba(255, 235, 188, 0.027)');
|
||||
glow.addColorStop(1, 'rgba(255, 242, 214, 0)');
|
||||
ctx.fillStyle = glow;
|
||||
ctx.fillRect(cx - radius - 4, cy - radius - 4, (radius + 4) * 2, (radius + 4) * 2);
|
||||
ctx.fillRect(
|
||||
cx - radius - 4,
|
||||
cy - radius - 4,
|
||||
(radius + 4) * 2,
|
||||
(radius + 4) * 2,
|
||||
);
|
||||
|
||||
ctx.lineCap = 'round';
|
||||
ctx.shadowColor = 'rgba(255, 232, 171, 0.11)';
|
||||
@@ -299,7 +347,10 @@ function drawSunRays(ctx, cx, cy, radius, innerRadius, angle) {
|
||||
const ex = sx + Math.cos(rayAngle) * rayLength;
|
||||
const ey = sy + Math.sin(rayAngle) * rayLength;
|
||||
const lineGradient = ctx.createLinearGradient(sx, sy, ex, ey);
|
||||
lineGradient.addColorStop(0, `rgba(255, 230, 166, ${0.075 - Math.abs(i) * 0.011})`);
|
||||
lineGradient.addColorStop(
|
||||
0,
|
||||
`rgba(255, 230, 166, ${0.075 - Math.abs(i) * 0.011})`,
|
||||
);
|
||||
lineGradient.addColorStop(1, 'rgba(255, 226, 156, 0)');
|
||||
ctx.strokeStyle = lineGradient;
|
||||
ctx.lineWidth = i === 0 ? 1.8 : 0.9;
|
||||
@@ -340,7 +391,8 @@ export class CelestialRing {
|
||||
this.viewer = viewer;
|
||||
this.enabled = !!enabled;
|
||||
this.visible = false;
|
||||
this._onAutoDisable = typeof onAutoDisable === 'function' ? onAutoDisable : null;
|
||||
this._onAutoDisable =
|
||||
typeof onAutoDisable === 'function' ? onAutoDisable : null;
|
||||
this._focusInProgress = false;
|
||||
this._ephemerisDirty = true;
|
||||
this._ephemerisUpdateCount = 0;
|
||||
@@ -364,7 +416,9 @@ export class CelestialRing {
|
||||
this._fixedMatrix = new Cesium.Matrix3();
|
||||
|
||||
this._buildDOM();
|
||||
this._removePostRender = viewer.scene.postRender.addEventListener(() => this._draw());
|
||||
this._removePostRender = viewer.scene.postRender.addEventListener(() =>
|
||||
this._draw(),
|
||||
);
|
||||
// Pre-existing staleness fix (perf wave 2 review): the ephemeris was
|
||||
// sampled once per visible-enable from the FROZEN app clock, so the
|
||||
// sun/moon markers aged with the app. Resample real wall time each
|
||||
@@ -394,18 +448,26 @@ export class CelestialRing {
|
||||
|
||||
this._sunCanvas = document.createElement('canvas');
|
||||
this._sunCanvas.className = 'celestial-ring-canvas celestial-sun-canvas';
|
||||
this._sunCtx = this._sunCanvas.getContext('2d', { alpha: true, desynchronized: true });
|
||||
this._sunCtx = this._sunCanvas.getContext('2d', {
|
||||
alpha: true,
|
||||
desynchronized: true,
|
||||
});
|
||||
|
||||
this._moonCanvas = document.createElement('canvas');
|
||||
this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-canvas';
|
||||
this._moonCtx = this._moonCanvas.getContext('2d', { alpha: true, desynchronized: true });
|
||||
this._moonCtx = this._moonCanvas.getContext('2d', {
|
||||
alpha: true,
|
||||
desynchronized: true,
|
||||
});
|
||||
|
||||
this._sunMarker = document.createElement('span');
|
||||
this._sunMarker.className = 'celestial-marker celestial-sun material-symbols-outlined';
|
||||
this._sunMarker.className =
|
||||
'celestial-marker celestial-sun material-symbols-outlined';
|
||||
this._sunMarker.textContent = 'light_mode';
|
||||
|
||||
this._moonMarker = document.createElement('span');
|
||||
this._moonMarker.className = 'celestial-marker celestial-moon material-symbols-outlined';
|
||||
this._moonMarker.className =
|
||||
'celestial-marker celestial-moon material-symbols-outlined';
|
||||
this._moonMarker.textContent = 'dark_mode';
|
||||
|
||||
this._root.append(
|
||||
@@ -413,7 +475,7 @@ export class CelestialRing {
|
||||
this._sunCanvas,
|
||||
this._moonCanvas,
|
||||
this._sunMarker,
|
||||
this._moonMarker
|
||||
this._moonMarker,
|
||||
);
|
||||
this.viewer.container.appendChild(this._root);
|
||||
}
|
||||
@@ -458,15 +520,24 @@ export class CelestialRing {
|
||||
const height = canvas.clientHeight || canvas.height;
|
||||
const cartographic = this.viewer.camera.positionCartographic;
|
||||
const fovy = this.viewer.camera.frustum?.fovy;
|
||||
if (!(height > 0) || !cartographic || !Number.isFinite(fovy) || fovy <= 0 || fovy >= Math.PI) {
|
||||
if (
|
||||
!(height > 0) ||
|
||||
!cartographic ||
|
||||
!Number.isFinite(fovy) ||
|
||||
fovy <= 0 ||
|
||||
fovy >= Math.PI
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius;
|
||||
const keyholeRadius = getKeyholeGeometry(canvas.clientWidth || canvas.width, height).radius;
|
||||
const keyholeRadius = getKeyholeGeometry(
|
||||
canvas.clientWidth || canvas.width,
|
||||
height,
|
||||
).radius;
|
||||
const targetScreenRadius = keyholeRadius * FULL_GLOBE_RADIUS_RATIO;
|
||||
const angularRadius = Math.atan(
|
||||
(targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5)
|
||||
(targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5),
|
||||
);
|
||||
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
|
||||
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
|
||||
@@ -480,7 +551,7 @@ export class CelestialRing {
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
cartographic.longitude,
|
||||
cartographic.latitude,
|
||||
altitude
|
||||
altitude,
|
||||
),
|
||||
orientation: {
|
||||
heading: this.viewer.camera.heading,
|
||||
@@ -500,7 +571,10 @@ export class CelestialRing {
|
||||
|
||||
/** Clear the backing canvas. */
|
||||
_clear() {
|
||||
for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) {
|
||||
for (const [canvas, ctx] of [
|
||||
[this._sunCanvas, this._sunCtx],
|
||||
[this._moonCanvas, this._moonCtx],
|
||||
]) {
|
||||
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
this._ringOutline?.style.setProperty('display', 'none');
|
||||
@@ -512,12 +586,23 @@ export class CelestialRing {
|
||||
/** Resize the canvas backing store while drawing in CSS pixels. */
|
||||
_resize(width, height) {
|
||||
const nativeDpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
const pixelsScale = Math.sqrt(CELESTIAL_MAX_BACKING_PIXELS / (width * height));
|
||||
const dimensionScale = CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height);
|
||||
const dpr = Math.min(nativeDpr, CELESTIAL_MAX_DEVICE_PIXEL_RATIO, pixelsScale, dimensionScale);
|
||||
const pixelsScale = Math.sqrt(
|
||||
CELESTIAL_MAX_BACKING_PIXELS / (width * height),
|
||||
);
|
||||
const dimensionScale =
|
||||
CELESTIAL_MAX_BACKING_DIMENSION / Math.max(width, height);
|
||||
const dpr = Math.min(
|
||||
nativeDpr,
|
||||
CELESTIAL_MAX_DEVICE_PIXEL_RATIO,
|
||||
pixelsScale,
|
||||
dimensionScale,
|
||||
);
|
||||
const bw = Math.max(1, Math.round(width * dpr));
|
||||
const bh = Math.max(1, Math.round(height * dpr));
|
||||
for (const [canvas, ctx] of [[this._sunCanvas, this._sunCtx], [this._moonCanvas, this._moonCtx]]) {
|
||||
for (const [canvas, ctx] of [
|
||||
[this._sunCanvas, this._sunCtx],
|
||||
[this._moonCanvas, this._moonCtx],
|
||||
]) {
|
||||
if (canvas.width !== bw || canvas.height !== bh) {
|
||||
canvas.width = bw;
|
||||
canvas.height = bh;
|
||||
@@ -536,14 +621,25 @@ export class CelestialRing {
|
||||
_updateEphemeris(time) {
|
||||
if (!this._ephemerisDirty) return true;
|
||||
|
||||
Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(time, this._sunInertial);
|
||||
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(time, this._moonInertial);
|
||||
const matrix = Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix)
|
||||
|| Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix);
|
||||
Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(
|
||||
time,
|
||||
this._sunInertial,
|
||||
);
|
||||
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(
|
||||
time,
|
||||
this._moonInertial,
|
||||
);
|
||||
const matrix =
|
||||
Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix) ||
|
||||
Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix);
|
||||
if (!matrix) return false;
|
||||
|
||||
Cesium.Matrix3.multiplyByVector(matrix, this._sunInertial, this._sunFixed);
|
||||
Cesium.Matrix3.multiplyByVector(matrix, this._moonInertial, this._moonFixed);
|
||||
Cesium.Matrix3.multiplyByVector(
|
||||
matrix,
|
||||
this._moonInertial,
|
||||
this._moonFixed,
|
||||
);
|
||||
Cesium.Cartesian3.normalize(this._sunFixed, this._sunFixed);
|
||||
Cesium.Cartesian3.normalize(this._moonFixed, this._moonFixed);
|
||||
this._ephemerisDirty = false;
|
||||
@@ -574,8 +670,14 @@ export class CelestialRing {
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const margin = 14;
|
||||
const maxRadiusX = Math.abs(cos) > 1e-4 ? (cx - margin) / Math.abs(cos) : Number.POSITIVE_INFINITY;
|
||||
const maxRadiusY = Math.abs(sin) > 1e-4 ? (cy - margin) / Math.abs(sin) : Number.POSITIVE_INFINITY;
|
||||
const maxRadiusX =
|
||||
Math.abs(cos) > 1e-4
|
||||
? (cx - margin) / Math.abs(cos)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const maxRadiusY =
|
||||
Math.abs(sin) > 1e-4
|
||||
? (cy - margin) / Math.abs(sin)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const markerRadius = Math.min(radius, maxRadiusX, maxRadiusY);
|
||||
marker.style.left = `${cx + cos * markerRadius}px`;
|
||||
marker.style.top = `${cy + sin * markerRadius}px`;
|
||||
@@ -596,14 +698,24 @@ export class CelestialRing {
|
||||
|
||||
const sunKey = `${outlineKey}:${Math.round(rayInnerRadius)}`;
|
||||
if (sunKey !== this._sunRenderKey) {
|
||||
this._sunCtx.clearRect(0, 0, this._sunCanvas.width, this._sunCanvas.height);
|
||||
this._sunCtx.clearRect(
|
||||
0,
|
||||
0,
|
||||
this._sunCanvas.width,
|
||||
this._sunCanvas.height,
|
||||
);
|
||||
drawSunRays(this._sunCtx, cx, cy, radius, rayInnerRadius, 0);
|
||||
drawTaperedArc(this._sunCtx, cx, cy, radius, 0, '222, 190, 89', 0.56);
|
||||
this._sunRenderKey = sunKey;
|
||||
}
|
||||
|
||||
if (outlineKey !== this._moonRenderKey) {
|
||||
this._moonCtx.clearRect(0, 0, this._moonCanvas.width, this._moonCanvas.height);
|
||||
this._moonCtx.clearRect(
|
||||
0,
|
||||
0,
|
||||
this._moonCanvas.width,
|
||||
this._moonCanvas.height,
|
||||
);
|
||||
drawMoonHaze(this._moonCtx, cx, cy, radius, 0);
|
||||
drawTaperedArc(this._moonCtx, cx, cy, radius, 0, '48, 201, 229', 0.42);
|
||||
this._moonRenderKey = outlineKey;
|
||||
@@ -624,7 +736,9 @@ export class CelestialRing {
|
||||
|
||||
const disc = this._projectedEarthDisc(width, height);
|
||||
const wasVisible = this.visible;
|
||||
const nextVisible = disc ? isFullGlobeInsideKeyhole(disc, wasVisible) : false;
|
||||
const nextVisible = disc
|
||||
? isFullGlobeInsideKeyhole(disc, wasVisible)
|
||||
: false;
|
||||
this.visible = nextVisible;
|
||||
this._root.classList.toggle('visible', nextVisible);
|
||||
this._root.dataset.globeVisible = String(nextVisible);
|
||||
@@ -645,12 +759,12 @@ export class CelestialRing {
|
||||
const sunProjection = celestialScreenAngle(
|
||||
Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC),
|
||||
Cesium.Cartesian3.dot(this._sunFixed, camera.upWC),
|
||||
this._sunAngle
|
||||
this._sunAngle,
|
||||
);
|
||||
const moonProjection = celestialScreenAngle(
|
||||
Cesium.Cartesian3.dot(this._moonFixed, camera.rightWC),
|
||||
Cesium.Cartesian3.dot(this._moonFixed, camera.upWC),
|
||||
this._moonAngle
|
||||
this._moonAngle,
|
||||
);
|
||||
if (sunProjection.stable) this._sunAngle = sunProjection.angle;
|
||||
if (moonProjection.stable) this._moonAngle = moonProjection.angle;
|
||||
@@ -660,7 +774,10 @@ export class CelestialRing {
|
||||
const cx = width * 0.5;
|
||||
const cy = height * 0.5;
|
||||
const radius = disc.keyholeRadius - RING_INSET_PX;
|
||||
const rayInnerRadius = Math.min(radius - 12, disc.earthRadius + Math.max(38, radius * 0.065));
|
||||
const rayInnerRadius = Math.min(
|
||||
radius - 12,
|
||||
disc.earthRadius + Math.max(38, radius * 0.065),
|
||||
);
|
||||
this._renderEffectLayers(cx, cy, radius, rayInnerRadius);
|
||||
this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`;
|
||||
this._sunCanvas.style.opacity = String(this._sunOpacity);
|
||||
@@ -671,16 +788,24 @@ export class CelestialRing {
|
||||
// stroke. Near conjunction (such as a new moon), keep the true bearings but
|
||||
// move the moon into a second radial lane so both bodies remain legible.
|
||||
const markerRadius = radius - MARKER_INSET_PX;
|
||||
const markersCollide = circularAngleDistance(this._sunAngle, this._moonAngle)
|
||||
< MARKER_COLLISION_ANGLE;
|
||||
this._positionMarker(this._sunMarker, cx, cy, markerRadius, this._sunAngle, this._sunOpacity);
|
||||
const markersCollide =
|
||||
circularAngleDistance(this._sunAngle, this._moonAngle) <
|
||||
MARKER_COLLISION_ANGLE;
|
||||
this._positionMarker(
|
||||
this._sunMarker,
|
||||
cx,
|
||||
cy,
|
||||
markerRadius,
|
||||
this._sunAngle,
|
||||
this._sunOpacity,
|
||||
);
|
||||
this._positionMarker(
|
||||
this._moonMarker,
|
||||
cx,
|
||||
cy,
|
||||
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
|
||||
this._moonAngle,
|
||||
this._moonOpacity
|
||||
this._moonOpacity,
|
||||
);
|
||||
this._debug = {
|
||||
enabled: true,
|
||||
@@ -698,7 +823,12 @@ export class CelestialRing {
|
||||
|
||||
/** Read-only geometry snapshot for browser QA. */
|
||||
getDebugState() {
|
||||
return this._debug ? { ...this._debug, disc: this._debug.disc ? { ...this._debug.disc } : null } : null;
|
||||
return this._debug
|
||||
? {
|
||||
...this._debug,
|
||||
disc: this._debug.disc ? { ...this._debug.disc } : null,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Detach the render hook and remove all overlay DOM. */
|
||||
|
||||
+133
-47
@@ -1,6 +1,9 @@
|
||||
import { applicationServices } from './services/application.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { deriveWeatherEffectProfile, weatherAltitudeFactors } from './weatherEffectsMath.js';
|
||||
import {
|
||||
deriveWeatherEffectProfile,
|
||||
weatherAltitudeFactors,
|
||||
} from './weatherEffectsMath.js';
|
||||
|
||||
const WEATHER_REFRESH_MS = 5 * 60_000;
|
||||
const CLOUD_FRAME_MS = 1000 / 12;
|
||||
@@ -117,28 +120,47 @@ function compileShader(gl, type, source) {
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
|
||||
const error = gl.getShaderInfoLog(shader) || 'Unknown shader compilation error';
|
||||
const error =
|
||||
gl.getShaderInfoLog(shader) || 'Unknown shader compilation error';
|
||||
gl.deleteShader(shader);
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
function greatCircleM(a, b) {
|
||||
if (![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(Number.isFinite)) return Infinity;
|
||||
if (
|
||||
![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(
|
||||
Number.isFinite,
|
||||
)
|
||||
)
|
||||
return Infinity;
|
||||
const latitudeA = Cesium.Math.toRadians(a.latitude);
|
||||
const latitudeB = Cesium.Math.toRadians(b.latitude);
|
||||
const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude);
|
||||
const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude);
|
||||
const haversine = Math.sin(latitudeDelta / 2) ** 2
|
||||
+ Math.cos(latitudeA) * Math.cos(latitudeB) * Math.sin(longitudeDelta / 2) ** 2;
|
||||
return 6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine));
|
||||
const haversine =
|
||||
Math.sin(latitudeDelta / 2) ** 2 +
|
||||
Math.cos(latitudeA) *
|
||||
Math.cos(latitudeB) *
|
||||
Math.sin(longitudeDelta / 2) ** 2;
|
||||
return (
|
||||
6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine))
|
||||
);
|
||||
}
|
||||
|
||||
/** Return whether cockpit weather must refresh for elapsed time or movement. */
|
||||
export function cockpitWeatherRefreshDue({ nowMs, fetchedAt, anchor, point, hasWeather }) {
|
||||
export function cockpitWeatherRefreshDue({
|
||||
nowMs,
|
||||
fetchedAt,
|
||||
anchor,
|
||||
point,
|
||||
hasWeather,
|
||||
}) {
|
||||
if (!hasWeather) return true;
|
||||
if (!Number.isFinite(nowMs) || !Number.isFinite(fetchedAt)) return true;
|
||||
return nowMs - fetchedAt >= WEATHER_REFRESH_MS
|
||||
|| greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M;
|
||||
return (
|
||||
nowMs - fetchedAt >= WEATHER_REFRESH_MS ||
|
||||
greatCircleM(anchor, point) >= WEATHER_MOVE_REFRESH_M
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the capped framebuffer size used by the cockpit cloud pass. */
|
||||
@@ -192,18 +214,24 @@ export class CockpitCloudEffectsController {
|
||||
this.pending = null;
|
||||
this.abort = null;
|
||||
this.destroyed = false;
|
||||
this.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
this.reducedMotion = window.matchMedia(
|
||||
'(prefers-reduced-motion: reduce)',
|
||||
).matches;
|
||||
this.enabled = this.readEnabledPreference();
|
||||
|
||||
this.onResize = () => this.resize();
|
||||
this.onCockpitMode = (event) => {
|
||||
const active = event?.detail?.active ?? document.body.classList.contains('cockpit-mode');
|
||||
const active =
|
||||
event?.detail?.active ??
|
||||
document.body.classList.contains('cockpit-mode');
|
||||
if (active && this.enabled) this.start();
|
||||
else this.stop();
|
||||
};
|
||||
this.onEnabledChange = (event) => {
|
||||
const requested = event?.detail?.enabled;
|
||||
this.setEnabled(typeof requested === 'boolean' ? requested : !this.enabled);
|
||||
this.setEnabled(
|
||||
typeof requested === 'boolean' ? requested : !this.enabled,
|
||||
);
|
||||
};
|
||||
window.addEventListener('resize', this.onResize);
|
||||
window.addEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
|
||||
@@ -222,9 +250,11 @@ export class CockpitCloudEffectsController {
|
||||
}
|
||||
|
||||
emitEnabledState() {
|
||||
window.dispatchEvent(new CustomEvent('gev:cockpit-weather-state', {
|
||||
detail: { enabled: this.enabled },
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:cockpit-weather-state', {
|
||||
detail: { enabled: this.enabled },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setEnabled(enabled) {
|
||||
@@ -232,7 +262,9 @@ export class CockpitCloudEffectsController {
|
||||
this.enabled = next;
|
||||
try {
|
||||
localStorage.setItem(WEATHER_ENABLED_STORAGE_KEY, next ? '1' : '0');
|
||||
} catch { /* best effort */ }
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
if (!next) {
|
||||
this.stop();
|
||||
} else if (document.body.classList.contains('cockpit-mode')) {
|
||||
@@ -255,7 +287,11 @@ export class CockpitCloudEffectsController {
|
||||
if (!gl) throw new Error('WebGL unavailable');
|
||||
|
||||
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
|
||||
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
|
||||
const fragmentShader = compileShader(
|
||||
gl,
|
||||
gl.FRAGMENT_SHADER,
|
||||
FRAGMENT_SHADER,
|
||||
);
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, vertexShader);
|
||||
gl.attachShader(program, fragmentShader);
|
||||
@@ -263,15 +299,18 @@ export class CockpitCloudEffectsController {
|
||||
gl.deleteShader(vertexShader);
|
||||
gl.deleteShader(fragmentShader);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
throw new Error(gl.getProgramInfoLog(program) || 'Cloud shader link failed');
|
||||
throw new Error(
|
||||
gl.getProgramInfoLog(program) || 'Cloud shader link failed',
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||||
-1, -1, 1, -1, -1, 1,
|
||||
-1, 1, 1, -1, 1, 1,
|
||||
]), gl.STATIC_DRAW);
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
|
||||
gl.STATIC_DRAW,
|
||||
);
|
||||
const position = gl.getAttribLocation(program, 'position');
|
||||
gl.enableVertexAttribArray(position);
|
||||
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
|
||||
@@ -293,7 +332,8 @@ export class CockpitCloudEffectsController {
|
||||
resize() {
|
||||
if (!this.gl) return;
|
||||
const size = cockpitCloudRenderSize(window.innerWidth, window.innerHeight);
|
||||
if (this.canvas.width === size.width && this.canvas.height === size.height) return;
|
||||
if (this.canvas.width === size.width && this.canvas.height === size.height)
|
||||
return;
|
||||
this.canvas.width = size.width;
|
||||
this.canvas.height = size.height;
|
||||
this.gl.viewport(0, 0, size.width, size.height);
|
||||
@@ -313,7 +353,13 @@ export class CockpitCloudEffectsController {
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.destroyed || !this.enabled || this.frame !== null || this.suspended) return;
|
||||
if (
|
||||
this.destroyed ||
|
||||
!this.enabled ||
|
||||
this.frame !== null ||
|
||||
this.suspended
|
||||
)
|
||||
return;
|
||||
if (!this.gl) {
|
||||
this.initializeRenderer();
|
||||
this.resize();
|
||||
@@ -353,16 +399,21 @@ export class CockpitCloudEffectsController {
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (this.destroyed || !this.enabled || this.pending || this.suspended
|
||||
|| !document.body.classList.contains('cockpit-mode')) {
|
||||
if (
|
||||
this.destroyed ||
|
||||
!this.enabled ||
|
||||
this.pending ||
|
||||
this.suspended ||
|
||||
!document.body.classList.contains('cockpit-mode')
|
||||
) {
|
||||
return this.pending;
|
||||
}
|
||||
const point = this.cameraPoint();
|
||||
if (!point) return null;
|
||||
if (
|
||||
this.weather
|
||||
&& Date.now() - this.fetchedAt < WEATHER_REFRESH_MS
|
||||
&& greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M
|
||||
this.weather &&
|
||||
Date.now() - this.fetchedAt < WEATHER_REFRESH_MS &&
|
||||
greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M
|
||||
) {
|
||||
this.applyWeather(this.weather, point.altitudeM);
|
||||
return this.weather;
|
||||
@@ -370,11 +421,17 @@ export class CockpitCloudEffectsController {
|
||||
|
||||
this.abort?.abort();
|
||||
this.abort = new AbortController();
|
||||
const pending = this.weatherService.getConditions(point.latitude, point.longitude, { signal: this.abort.signal });
|
||||
const pending = this.weatherService.getConditions(
|
||||
point.latitude,
|
||||
point.longitude,
|
||||
{ signal: this.abort.signal },
|
||||
);
|
||||
const request = this.abort;
|
||||
this.pending = pending.then(async (payload) => {
|
||||
this.pending = pending
|
||||
.then(async (payload) => {
|
||||
if (this.abort !== request || request.signal.aborted) return null;
|
||||
if (!payload?.weather) throw new Error('Cloud weather observation unavailable');
|
||||
if (!payload?.weather)
|
||||
throw new Error('Cloud weather observation unavailable');
|
||||
this.weather = payload.weather;
|
||||
this.fetchedAt = Date.now();
|
||||
this.anchor = point;
|
||||
@@ -391,7 +448,10 @@ export class CockpitCloudEffectsController {
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.abort === request) { this.pending = null; this.abort = null; }
|
||||
if (this.abort === request) {
|
||||
this.pending = null;
|
||||
this.abort = null;
|
||||
}
|
||||
});
|
||||
return this.pending;
|
||||
}
|
||||
@@ -399,12 +459,17 @@ export class CockpitCloudEffectsController {
|
||||
applyWeather(weather, altitudeM) {
|
||||
const profile = deriveWeatherEffectProfile(weather);
|
||||
const altitude = weatherAltitudeFactors(altitudeM);
|
||||
this.targetStrength = profile.available ? profile.cloud * altitude.cloud : 0;
|
||||
this.targetStrength = profile.available
|
||||
? profile.cloud * altitude.cloud
|
||||
: 0;
|
||||
this.windDirectionDeg = profile.windDirectionDeg || 0;
|
||||
this.windStrength = profile.wind || 0;
|
||||
this.canvas.dataset.cloudStrength = this.targetStrength.toFixed(3);
|
||||
this.canvas.dataset.weatherCode = String(weather?.weatherCode ?? 'unknown');
|
||||
if (this.reducedMotion && document.body.classList.contains('cockpit-mode')) {
|
||||
if (
|
||||
this.reducedMotion &&
|
||||
document.body.classList.contains('cockpit-mode')
|
||||
) {
|
||||
this.strength = this.targetStrength;
|
||||
const visible = this.strength > 0.035;
|
||||
this.canvas.classList.toggle('active', visible);
|
||||
@@ -414,7 +479,11 @@ export class CockpitCloudEffectsController {
|
||||
}
|
||||
|
||||
tick(timeMs) {
|
||||
if (this.destroyed || !this.enabled || !document.body.classList.contains('cockpit-mode')) {
|
||||
if (
|
||||
this.destroyed ||
|
||||
!this.enabled ||
|
||||
!document.body.classList.contains('cockpit-mode')
|
||||
) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
@@ -431,20 +500,27 @@ export class CockpitCloudEffectsController {
|
||||
if (timeMs - this.lastRefreshCheckMs >= 1000) {
|
||||
this.lastRefreshCheckMs = timeMs;
|
||||
const point = this.cameraPoint();
|
||||
if (point && cockpitWeatherRefreshDue({
|
||||
nowMs: Date.now(),
|
||||
fetchedAt: this.fetchedAt,
|
||||
anchor: this.anchor,
|
||||
point,
|
||||
hasWeather: Boolean(this.weather),
|
||||
})) void this.refresh();
|
||||
if (
|
||||
point &&
|
||||
cockpitWeatherRefreshDue({
|
||||
nowMs: Date.now(),
|
||||
fetchedAt: this.fetchedAt,
|
||||
anchor: this.anchor,
|
||||
point,
|
||||
hasWeather: Boolean(this.weather),
|
||||
})
|
||||
)
|
||||
void this.refresh();
|
||||
}
|
||||
|
||||
const blend = this.reducedMotion ? 1 : 0.08;
|
||||
this.strength += (this.targetStrength - this.strength) * blend;
|
||||
const visible = this.strength > 0.035;
|
||||
this.canvas.classList.toggle('active', visible);
|
||||
if (visible && (this.reducedMotion || timeMs - this.lastFrameMs >= CLOUD_FRAME_MS)) {
|
||||
if (
|
||||
visible &&
|
||||
(this.reducedMotion || timeMs - this.lastFrameMs >= CLOUD_FRAME_MS)
|
||||
) {
|
||||
this.lastFrameMs = timeMs;
|
||||
this.render(timeMs / 1000);
|
||||
} else if (!visible) {
|
||||
@@ -468,9 +544,16 @@ export class CockpitCloudEffectsController {
|
||||
const windRadians = Cesium.Math.toRadians(this.windDirectionDeg);
|
||||
const windScale = 0.008 + this.windStrength * 0.022;
|
||||
gl.useProgram(this.program);
|
||||
gl.uniform2f(this.locations.resolution, this.canvas.width, this.canvas.height);
|
||||
gl.uniform2f(
|
||||
this.locations.resolution,
|
||||
this.canvas.width,
|
||||
this.canvas.height,
|
||||
);
|
||||
gl.uniform1f(this.locations.time, timeSec);
|
||||
gl.uniform1f(this.locations.strength, Math.min(1, Math.max(0, this.strength)));
|
||||
gl.uniform1f(
|
||||
this.locations.strength,
|
||||
Math.min(1, Math.max(0, this.strength)),
|
||||
);
|
||||
gl.uniform2f(
|
||||
this.locations.wind,
|
||||
Math.sin(windRadians) * windScale,
|
||||
@@ -517,7 +600,10 @@ export class CockpitCloudEffectsController {
|
||||
this.stop();
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
window.removeEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
|
||||
window.removeEventListener('gev:cockpit-weather-toggle', this.onEnabledChange);
|
||||
window.removeEventListener(
|
||||
'gev:cockpit-weather-toggle',
|
||||
this.onEnabledChange,
|
||||
);
|
||||
if (this.program && this.gl) this.gl.deleteProgram(this.program);
|
||||
this.canvas.remove();
|
||||
}
|
||||
|
||||
+32
-15
@@ -8,7 +8,11 @@
|
||||
*/
|
||||
|
||||
/** Force the prior aircraft layer to reacquire Cesium and durable tracking ownership. */
|
||||
export function restoreAircraftTrackingOwner(layer, id, { origin = 'programmatic' } = {}) {
|
||||
export function restoreAircraftTrackingOwner(
|
||||
layer,
|
||||
id,
|
||||
{ origin = 'programmatic' } = {},
|
||||
) {
|
||||
if (!layer?.trackById || !id) return false;
|
||||
layer.stopTracking?.({ origin });
|
||||
return Boolean(layer.trackById(id, { origin }));
|
||||
@@ -33,11 +37,13 @@ export function enterCockpitWithTracking({
|
||||
rollbackTarget = undefined,
|
||||
selectionOrigin = 'programmatic',
|
||||
}) {
|
||||
const currentTarget = aircraftTrackingTarget(cockpitView?.readAircraftInfo?.());
|
||||
const restoreTarget = rollbackTarget === undefined ? currentTarget : rollbackTarget;
|
||||
const key = (target) => target?.layerId && target?.id
|
||||
? `${target.layerId}:${target.id}`
|
||||
: null;
|
||||
const currentTarget = aircraftTrackingTarget(
|
||||
cockpitView?.readAircraftInfo?.(),
|
||||
);
|
||||
const restoreTarget =
|
||||
rollbackTarget === undefined ? currentTarget : rollbackTarget;
|
||||
const key = (target) =>
|
||||
target?.layerId && target?.id ? `${target.layerId}:${target.id}` : null;
|
||||
let activeLayer = currentLayer;
|
||||
let activeTarget = currentTarget;
|
||||
let entryError = null;
|
||||
@@ -49,8 +55,14 @@ export function enterCockpitWithTracking({
|
||||
if (selectingTarget) {
|
||||
activeLayer = selectedLayer;
|
||||
activeTarget = selectedTarget;
|
||||
if (!selectedLayer.trackById?.(selectedTarget.id, { origin: selectionOrigin })) {
|
||||
entryError = new Error('Selected aircraft could not be tracked for Cockpit entry');
|
||||
if (
|
||||
!selectedLayer.trackById?.(selectedTarget.id, {
|
||||
origin: selectionOrigin,
|
||||
})
|
||||
) {
|
||||
entryError = new Error(
|
||||
'Selected aircraft could not be tracked for Cockpit entry',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!entryError) entered = Boolean(cockpitView.enter());
|
||||
@@ -66,12 +78,15 @@ export function enterCockpitWithTracking({
|
||||
// programmatic rollback would fix the camera while leaving the failed
|
||||
// target durable in local/share state.
|
||||
activeLayer?.stopTracking?.({ origin: selectionOrigin });
|
||||
if (restoreTarget && !restoreAircraftTrackingOwner(
|
||||
rollbackLayer,
|
||||
restoreTarget.id,
|
||||
{ origin: selectionOrigin },
|
||||
)) {
|
||||
entryError ||= new Error('Prior aircraft tracking could not be restored');
|
||||
if (
|
||||
restoreTarget &&
|
||||
!restoreAircraftTrackingOwner(rollbackLayer, restoreTarget.id, {
|
||||
origin: selectionOrigin,
|
||||
})
|
||||
) {
|
||||
entryError ||= new Error(
|
||||
'Prior aircraft tracking could not be restored',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
entryError ||= error instanceof Error ? error : new Error(String(error));
|
||||
@@ -87,6 +102,8 @@ export function enterCockpitWithTracking({
|
||||
|
||||
return {
|
||||
entered,
|
||||
error: entered ? null : entryError?.message || 'Cockpit entry was unavailable',
|
||||
error: entered
|
||||
? null
|
||||
: entryError?.message || 'Cockpit entry was unavailable',
|
||||
};
|
||||
}
|
||||
@@ -75,11 +75,16 @@ export function contactsDetectionEnterPlan(current, restore) {
|
||||
* @returns {?{mode: string, densityPct: ?number}} State to replay, or null when
|
||||
* there is nothing to do.
|
||||
*/
|
||||
export function contactsDetectionExitPlan(restore, current, styleOwnsDetection = false) {
|
||||
export function contactsDetectionExitPlan(
|
||||
restore,
|
||||
current,
|
||||
styleOwnsDetection = false,
|
||||
) {
|
||||
if (!restore || styleOwnsDetection) return null;
|
||||
const saved = normalizeDetectionState(restore);
|
||||
const now = normalizeDetectionState(current);
|
||||
if (saved.mode === now.mode && saved.densityPct === now.densityPct) return null;
|
||||
if (saved.mode === now.mode && saved.densityPct === now.densityPct)
|
||||
return null;
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -119,7 +124,11 @@ export function applyContactsDetection({
|
||||
if (plan.turnOn) applyPreset();
|
||||
return { restore: plan.restore, changed: plan.turnOn };
|
||||
}
|
||||
const plan = contactsDetectionExitPlan(restore, getState(), styleOwnsDetection);
|
||||
const plan = contactsDetectionExitPlan(
|
||||
restore,
|
||||
getState(),
|
||||
styleOwnsDetection,
|
||||
);
|
||||
if (plan) restoreState(plan);
|
||||
return { restore: null, changed: Boolean(plan) };
|
||||
}
|
||||
@@ -145,7 +154,9 @@ export function shareableDetectionState({ owned, liveMode, liveDensityPct }) {
|
||||
if (!owned) return { mode: liveMode, densityPct: liveDensityPct };
|
||||
return {
|
||||
mode: owned.mode ?? liveMode,
|
||||
densityPct: Number.isFinite(owned.densityPct) ? owned.densityPct : liveDensityPct,
|
||||
densityPct: Number.isFinite(owned.densityPct)
|
||||
? owned.densityPct
|
||||
: liveDensityPct,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+125
-72
@@ -1,5 +1,11 @@
|
||||
const CONTEXT_DEPENDENCIES = Object.freeze({
|
||||
flights: new Set(['military-awareness', 'flights', 'military', 'ais-live-vessels', 'military-installations']),
|
||||
flights: new Set([
|
||||
'military-awareness',
|
||||
'flights',
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
]),
|
||||
'space-missions': new Set(['rocket-launches', 'satellites']),
|
||||
});
|
||||
const CONTEXT_COMPANIONS = new Set(['radio']);
|
||||
@@ -11,11 +17,13 @@ export function isExplicitUserIntentOrigin(origin, layerId = null) {
|
||||
/** Preserve explicit layer intent that completes while a stale restore is queued. */
|
||||
export function recordContextRestoreExplicitChange({ restoreState, change }) {
|
||||
if (
|
||||
!restoreState?.enabledLayerIds
|
||||
|| change?.type !== 'visibility'
|
||||
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
) return false;
|
||||
if (!restoreState.explicitLayerStates) restoreState.explicitLayerStates = new Map();
|
||||
!restoreState?.enabledLayerIds ||
|
||||
change?.type !== 'visibility' ||
|
||||
!isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
)
|
||||
return false;
|
||||
if (!restoreState.explicitLayerStates)
|
||||
restoreState.explicitLayerStates = new Map();
|
||||
restoreState.explicitLayerStates.set(change.layerId, Boolean(change.enabled));
|
||||
if (change.enabled) restoreState.enabledLayerIds.add(change.layerId);
|
||||
else restoreState.enabledLayerIds.delete(change.layerId);
|
||||
@@ -23,30 +31,41 @@ export function recordContextRestoreExplicitChange({ restoreState, change }) {
|
||||
}
|
||||
|
||||
/** Settle every explicit Context-restore replay without losing semantic failures. */
|
||||
export async function settleContextIntentReplay({ restoreState, setEnabled, notificationToken = null }) {
|
||||
export async function settleContextIntentReplay({
|
||||
restoreState,
|
||||
setEnabled,
|
||||
notificationToken = null,
|
||||
}) {
|
||||
if (restoreState?.cancelled) return null;
|
||||
const entries = [...(restoreState?.explicitLayerStates || [])];
|
||||
const results = await Promise.allSettled(entries.map(([layerId, enabled]) => (
|
||||
setEnabled(layerId, enabled, {
|
||||
origin: 'context-intent-replay',
|
||||
...(notificationToken ? { notificationToken } : {}),
|
||||
})
|
||||
)));
|
||||
const failedIndexes = results.flatMap((result, index) => (
|
||||
result.status === 'rejected' || result.value === false ? [index] : []
|
||||
));
|
||||
const results = await Promise.allSettled(
|
||||
entries.map(([layerId, enabled]) =>
|
||||
setEnabled(layerId, enabled, {
|
||||
origin: 'context-intent-replay',
|
||||
...(notificationToken ? { notificationToken } : {}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const failedIndexes = results.flatMap((result, index) =>
|
||||
result.status === 'rejected' || result.value === false ? [index] : [],
|
||||
);
|
||||
if (failedIndexes.length === 0) return null;
|
||||
const failedLayerIds = failedIndexes.map((index) => entries[index][0]);
|
||||
const rejected = failedIndexes
|
||||
.map((index) => results[index])
|
||||
.find((result) => result.status === 'rejected');
|
||||
const error = rejected?.reason instanceof Error
|
||||
? rejected.reason
|
||||
: new Error(`Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`);
|
||||
error.failedLayerIds = [...new Set([
|
||||
...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []),
|
||||
...failedLayerIds,
|
||||
])];
|
||||
const error =
|
||||
rejected?.reason instanceof Error
|
||||
? rejected.reason
|
||||
: new Error(
|
||||
`Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`,
|
||||
);
|
||||
error.failedLayerIds = [
|
||||
...new Set([
|
||||
...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []),
|
||||
...failedLayerIds,
|
||||
]),
|
||||
];
|
||||
return error;
|
||||
}
|
||||
|
||||
@@ -54,18 +73,29 @@ export async function settleContextIntentReplay({ restoreState, setEnabled, noti
|
||||
export function mergeContextTransitionErrors(primaryError, secondaryError) {
|
||||
if (!primaryError) return secondaryError || null;
|
||||
if (!secondaryError) return primaryError;
|
||||
primaryError.failedLayerIds = [...new Set([
|
||||
...(Array.isArray(primaryError.failedLayerIds) ? primaryError.failedLayerIds : []),
|
||||
...(Array.isArray(secondaryError.failedLayerIds) ? secondaryError.failedLayerIds : []),
|
||||
])];
|
||||
primaryError.failedLayerIds = [
|
||||
...new Set([
|
||||
...(Array.isArray(primaryError.failedLayerIds)
|
||||
? primaryError.failedLayerIds
|
||||
: []),
|
||||
...(Array.isArray(secondaryError.failedLayerIds)
|
||||
? secondaryError.failedLayerIds
|
||||
: []),
|
||||
]),
|
||||
];
|
||||
return primaryError;
|
||||
}
|
||||
|
||||
/** Settle a user-facing Context action and convert every failure form to false. */
|
||||
export async function settleUserFacingContextAction({ operation, onFailure, falseIsFailure = true }) {
|
||||
export async function settleUserFacingContextAction({
|
||||
operation,
|
||||
onFailure,
|
||||
falseIsFailure = true,
|
||||
}) {
|
||||
try {
|
||||
const result = await operation();
|
||||
if (falseIsFailure && result === false) throw new Error('Context transition did not complete');
|
||||
if (falseIsFailure && result === false)
|
||||
throw new Error('Context transition did not complete');
|
||||
return result;
|
||||
} catch (error) {
|
||||
try {
|
||||
@@ -83,7 +113,10 @@ export async function settleUserFacingContextAction({ operation, onFailure, fals
|
||||
* one into `userAdded` makes exit restoration re-enable the mode layer the
|
||||
* user just turned off.
|
||||
*/
|
||||
export const CONTEXT_ENTRY_LAYER_IDS = Object.freeze(['military-awareness', 'rocket-launches']);
|
||||
export const CONTEXT_ENTRY_LAYER_IDS = Object.freeze([
|
||||
'military-awareness',
|
||||
'rocket-launches',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Leave a Context transaction: publish the settled coordination flag, then
|
||||
@@ -108,7 +141,8 @@ export function settleContextModeChange(owner, changing = false) {
|
||||
if (!owner) return;
|
||||
const wasChanging = Boolean(owner._contextModeChanging);
|
||||
owner._contextModeChanging = Boolean(changing);
|
||||
if (wasChanging && !owner._contextModeChanging) owner._syncContextModeButtons?.();
|
||||
if (wasChanging && !owner._contextModeChanging)
|
||||
owner._syncContextModeButtons?.();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,19 +176,24 @@ export async function runWithContextModeChanging(owner, operation) {
|
||||
* @param {object|null} input.change DataLayerManager visibility notification.
|
||||
* @returns {boolean} Whether the active Context bundle should be disabled.
|
||||
*/
|
||||
export function shouldExitContextForLayerChange({ contextMode, globalContextEnabled, change }) {
|
||||
export function shouldExitContextForLayerChange({
|
||||
contextMode,
|
||||
globalContextEnabled,
|
||||
change,
|
||||
}) {
|
||||
if (
|
||||
change?.type !== 'visibility'
|
||||
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
|| change.layerId === 'military-awareness'
|
||||
|| CONTEXT_COMPANIONS.has(change.layerId)
|
||||
change?.type !== 'visibility' ||
|
||||
!isExplicitUserIntentOrigin(change.origin, change.layerId) ||
|
||||
change.layerId === 'military-awareness' ||
|
||||
CONTEXT_COMPANIONS.has(change.layerId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const active = Boolean(globalContextEnabled || contextMode);
|
||||
if (!active) return false;
|
||||
|
||||
const dependencies = CONTEXT_DEPENDENCIES[contextMode] || new Set(['military-awareness']);
|
||||
const dependencies =
|
||||
CONTEXT_DEPENDENCIES[contextMode] || new Set(['military-awareness']);
|
||||
if (change.enabled) return false;
|
||||
return Boolean(contextMode && dependencies.has(change.layerId));
|
||||
}
|
||||
@@ -181,10 +220,10 @@ export function contextAllowedLayerIds(contextMode) {
|
||||
*/
|
||||
export function shouldCaptureContextSession(change) {
|
||||
return Boolean(
|
||||
['visibility-requested', 'visibility-will-change'].includes(change?.type)
|
||||
&& isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
&& change.enabled
|
||||
&& CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId)
|
||||
['visibility-requested', 'visibility-will-change'].includes(change?.type) &&
|
||||
isExplicitUserIntentOrigin(change.origin, change.layerId) &&
|
||||
change.enabled &&
|
||||
CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -194,12 +233,12 @@ export function shouldCaptureContextSession(change) {
|
||||
*/
|
||||
export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
|
||||
return Boolean(
|
||||
clearInFlight
|
||||
&& change?.type === 'visibility-requested'
|
||||
&& change.layerId === 'rocket-launches'
|
||||
&& change.enabled === true
|
||||
&& isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
&& Number.isInteger(change.intentEpoch)
|
||||
clearInFlight &&
|
||||
change?.type === 'visibility-requested' &&
|
||||
change.layerId === 'rocket-launches' &&
|
||||
change.enabled === true &&
|
||||
isExplicitUserIntentOrigin(change.origin, change.layerId) &&
|
||||
Number.isInteger(change.intentEpoch),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,11 +257,15 @@ export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
|
||||
* @param {string|null} [input.effectiveContextMode] Committed or entering mode.
|
||||
* @returns {boolean} Whether the snapshot's userAdded set was modified.
|
||||
*/
|
||||
export function recordContextSessionUserChange({ snapshot, change, effectiveContextMode = null }) {
|
||||
export function recordContextSessionUserChange({
|
||||
snapshot,
|
||||
change,
|
||||
effectiveContextMode = null,
|
||||
}) {
|
||||
if (
|
||||
!snapshot?.userAdded
|
||||
|| change?.type !== 'visibility'
|
||||
|| !isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
!snapshot?.userAdded ||
|
||||
change?.type !== 'visibility' ||
|
||||
!isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -233,7 +276,11 @@ export function recordContextSessionUserChange({ snapshot, change, effectiveCont
|
||||
snapshot.userAdded.add(change.layerId);
|
||||
return true;
|
||||
}
|
||||
if (contextAllowedLayerIds(effectiveContextMode).has(change.layerId) && !isCompanion) return false;
|
||||
if (
|
||||
contextAllowedLayerIds(effectiveContextMode).has(change.layerId) &&
|
||||
!isCompanion
|
||||
)
|
||||
return false;
|
||||
snapshot.userAdded.add(change.layerId);
|
||||
if (isCompanion) snapshot.userRemoved?.delete(change.layerId);
|
||||
return true;
|
||||
@@ -276,11 +323,15 @@ export function contextSnapshotLayerIds(
|
||||
* @param {string|null} [input.layerName] User-facing layer name.
|
||||
* @returns {string|null} Honest user-facing refusal reason, or null when allowed.
|
||||
*/
|
||||
export function contextLayerEnableBlockReason({ contextMode, change, layerName = null }) {
|
||||
export function contextLayerEnableBlockReason({
|
||||
contextMode,
|
||||
change,
|
||||
layerName = null,
|
||||
}) {
|
||||
if (
|
||||
contextMode !== 'space-missions'
|
||||
|| change?.enabled !== true
|
||||
|| contextAllowedLayerIds('space-missions').has(change.layerId)
|
||||
contextMode !== 'space-missions' ||
|
||||
change?.enabled !== true ||
|
||||
contextAllowedLayerIds('space-missions').has(change.layerId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -297,20 +348,20 @@ export function contextLayerEnableBlockReason({ contextMode, change, layerName =
|
||||
* @param {object|null} input.change Manager visibility notification.
|
||||
* @returns {'ignore'|'replacement'|'restore'} Cancellation disposition.
|
||||
*/
|
||||
export function spaceMissionEntryCancellationDisposition({
|
||||
change,
|
||||
}) {
|
||||
export function spaceMissionEntryCancellationDisposition({ change }) {
|
||||
if (
|
||||
change?.type !== 'visibility-cancelled'
|
||||
|| change.layerId !== 'rocket-launches'
|
||||
|| change.enabled !== true
|
||||
) return 'ignore';
|
||||
change?.type !== 'visibility-cancelled' ||
|
||||
change.layerId !== 'rocket-launches' ||
|
||||
change.enabled !== true
|
||||
)
|
||||
return 'ignore';
|
||||
if (
|
||||
change.cancellationReason === 'superseded'
|
||||
&& Number.isInteger(change.successorIntentEpoch)
|
||||
&& change.successorIntentEpoch > change.intentEpoch
|
||||
&& change.successorEnabled === true
|
||||
) return 'replacement';
|
||||
change.cancellationReason === 'superseded' &&
|
||||
Number.isInteger(change.successorIntentEpoch) &&
|
||||
change.successorIntentEpoch > change.intentEpoch &&
|
||||
change.successorEnabled === true
|
||||
)
|
||||
return 'replacement';
|
||||
return 'restore';
|
||||
}
|
||||
|
||||
@@ -348,10 +399,12 @@ export function cockpitEntryAllowed({
|
||||
flightsEnabled,
|
||||
militaryEnabled,
|
||||
}) {
|
||||
return contextMode === 'flights'
|
||||
&& !contextModeChanging
|
||||
&& Boolean(flightsEnabled)
|
||||
&& Boolean(militaryEnabled);
|
||||
return (
|
||||
contextMode === 'flights' &&
|
||||
!contextModeChanging &&
|
||||
Boolean(flightsEnabled) &&
|
||||
Boolean(militaryEnabled)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* Cesium retains ownership of showing and hiding its required credit lightbox.
|
||||
*/
|
||||
export function configureCreditKeyboardAccess(root = document) {
|
||||
const expand = root?.querySelector?.('#cesium-credits .cesium-credit-expand-link');
|
||||
const expand = root?.querySelector?.(
|
||||
'#cesium-credits .cesium-credit-expand-link',
|
||||
);
|
||||
const lightbox = root?.querySelector?.('.cesium-credit-lightbox');
|
||||
const close = lightbox?.querySelector?.('.cesium-credit-lightbox-close');
|
||||
if (!expand || !lightbox || !close) return false;
|
||||
@@ -44,7 +46,9 @@ export function configureCreditKeyboardAccess(root = document) {
|
||||
// A long Space hold may hand control to push-to-talk and move focus away.
|
||||
// Cancelling the armed release prevents that eventual keyup from also
|
||||
// activating the attribution control.
|
||||
control.addEventListener('blur', () => { spacePressed = false; });
|
||||
control.addEventListener('blur', () => {
|
||||
spacePressed = false;
|
||||
});
|
||||
};
|
||||
const closeAndRestoreFocus = () => {
|
||||
expand.setAttribute('aria-expanded', 'false');
|
||||
|
||||
+414
-67
@@ -8,90 +8,415 @@
|
||||
*/
|
||||
|
||||
const HELI = new Set([
|
||||
'EC20', 'EC25', 'EC30', 'EC35', 'EC45', 'EC55', 'AS50', 'AS55', 'AS65', 'AS32',
|
||||
'A109', 'A119', 'A139', 'A169', 'A189', 'B06', 'B06T', 'B407', 'B412', 'B427',
|
||||
'B429', 'B430', 'B505', 'S76', 'S92', 'S61', 'S64', 'H60', 'H500', 'MD52',
|
||||
'MD60', 'R22', 'R44', 'R66', 'EXEC', 'EXPL', 'GAZL', 'LYNX', 'NH90', 'PUMA',
|
||||
'SCAV', 'UH1', 'B105', 'B212', 'B214', 'B222', 'AC', 'H47', 'H64',
|
||||
'EC20',
|
||||
'EC25',
|
||||
'EC30',
|
||||
'EC35',
|
||||
'EC45',
|
||||
'EC55',
|
||||
'AS50',
|
||||
'AS55',
|
||||
'AS65',
|
||||
'AS32',
|
||||
'A109',
|
||||
'A119',
|
||||
'A139',
|
||||
'A169',
|
||||
'A189',
|
||||
'B06',
|
||||
'B06T',
|
||||
'B407',
|
||||
'B412',
|
||||
'B427',
|
||||
'B429',
|
||||
'B430',
|
||||
'B505',
|
||||
'S76',
|
||||
'S92',
|
||||
'S61',
|
||||
'S64',
|
||||
'H60',
|
||||
'H500',
|
||||
'MD52',
|
||||
'MD60',
|
||||
'R22',
|
||||
'R44',
|
||||
'R66',
|
||||
'EXEC',
|
||||
'EXPL',
|
||||
'GAZL',
|
||||
'LYNX',
|
||||
'NH90',
|
||||
'PUMA',
|
||||
'SCAV',
|
||||
'UH1',
|
||||
'B105',
|
||||
'B212',
|
||||
'B214',
|
||||
'B222',
|
||||
'AC',
|
||||
'H47',
|
||||
'H64',
|
||||
]);
|
||||
const QUAD = new Set([
|
||||
'B741', 'B742', 'B743', 'B744', 'B748', 'B74S', 'B74R', 'B74D', 'A388', 'A342',
|
||||
'A343', 'A345', 'A346', 'A124', 'C5M', 'A225', 'IL96', 'B52', 'A140',
|
||||
'B741',
|
||||
'B742',
|
||||
'B743',
|
||||
'B744',
|
||||
'B748',
|
||||
'B74S',
|
||||
'B74R',
|
||||
'B74D',
|
||||
'A388',
|
||||
'A342',
|
||||
'A343',
|
||||
'A345',
|
||||
'A346',
|
||||
'A124',
|
||||
'C5M',
|
||||
'A225',
|
||||
'IL96',
|
||||
'B52',
|
||||
'A140',
|
||||
]);
|
||||
const WIDE = new Set([
|
||||
'A306', 'A30B', 'A310', 'A332', 'A333', 'A338', 'A339', 'A359', 'A35K', 'B762',
|
||||
'B763', 'B764', 'B772', 'B77L', 'B773', 'B77W', 'B778', 'B779', 'B788', 'B789',
|
||||
'B78X', 'MD11', 'IL86', 'DC10', 'L101', 'A337', 'B767', 'B777', 'B787',
|
||||
'C17', 'K35R', // military heavies that read as widebodies
|
||||
'A306',
|
||||
'A30B',
|
||||
'A310',
|
||||
'A332',
|
||||
'A333',
|
||||
'A338',
|
||||
'A339',
|
||||
'A359',
|
||||
'A35K',
|
||||
'B762',
|
||||
'B763',
|
||||
'B764',
|
||||
'B772',
|
||||
'B77L',
|
||||
'B773',
|
||||
'B77W',
|
||||
'B778',
|
||||
'B779',
|
||||
'B788',
|
||||
'B789',
|
||||
'B78X',
|
||||
'MD11',
|
||||
'IL86',
|
||||
'DC10',
|
||||
'L101',
|
||||
'A337',
|
||||
'B767',
|
||||
'B777',
|
||||
'B787',
|
||||
'C17',
|
||||
'K35R', // military heavies that read as widebodies
|
||||
]);
|
||||
const TPROP = new Set([
|
||||
'DH8A', 'DH8B', 'DH8C', 'DH8D', 'AT43', 'AT44', 'AT45', 'AT46', 'AT72', 'AT73',
|
||||
'AT75', 'AT76', 'SF34', 'SB20', 'SW3', 'SW4', 'E110', 'E120', 'C208', 'C212',
|
||||
'C408', 'PC12', 'B190', 'BE20', 'B350', 'B300', 'JS31', 'JS32', 'JS41', 'D228',
|
||||
'D328', 'F50', 'F27', 'ATP', 'TBM7', 'TBM8', 'TBM9', 'TBM0', 'PC6', 'C441',
|
||||
'C425', 'DHC6', 'DHC7', 'C130', 'AN12', 'AN26', 'AN32', 'SH36', 'CVLT', 'SAAB',
|
||||
'DH8A',
|
||||
'DH8B',
|
||||
'DH8C',
|
||||
'DH8D',
|
||||
'AT43',
|
||||
'AT44',
|
||||
'AT45',
|
||||
'AT46',
|
||||
'AT72',
|
||||
'AT73',
|
||||
'AT75',
|
||||
'AT76',
|
||||
'SF34',
|
||||
'SB20',
|
||||
'SW3',
|
||||
'SW4',
|
||||
'E110',
|
||||
'E120',
|
||||
'C208',
|
||||
'C212',
|
||||
'C408',
|
||||
'PC12',
|
||||
'B190',
|
||||
'BE20',
|
||||
'B350',
|
||||
'B300',
|
||||
'JS31',
|
||||
'JS32',
|
||||
'JS41',
|
||||
'D228',
|
||||
'D328',
|
||||
'F50',
|
||||
'F27',
|
||||
'ATP',
|
||||
'TBM7',
|
||||
'TBM8',
|
||||
'TBM9',
|
||||
'TBM0',
|
||||
'PC6',
|
||||
'C441',
|
||||
'C425',
|
||||
'DHC6',
|
||||
'DHC7',
|
||||
'C130',
|
||||
'AN12',
|
||||
'AN26',
|
||||
'AN32',
|
||||
'SH36',
|
||||
'CVLT',
|
||||
'SAAB',
|
||||
'A400',
|
||||
]);
|
||||
const GLIDER = new Set([
|
||||
'DISC', 'DUOD', 'VENT', 'NIMB', 'NIM3', 'NIM4', 'JANS', 'ARCE', 'DG40', 'DG80',
|
||||
'DG1T', 'DG30', 'DG50', 'LS3', 'LS4', 'LS6', 'LS7', 'LS8', 'STD3', 'G103',
|
||||
'G102', 'G104', 'PW5', 'PW6', 'L13', 'L23', 'L33', 'PIK', 'PEGA', 'KEST',
|
||||
'TWIN', 'AS33', 'ASW', 'ASG', 'ASK', 'VENS', 'GLID', 'MOSQ', 'DIMO',
|
||||
'DISC',
|
||||
'DUOD',
|
||||
'VENT',
|
||||
'NIMB',
|
||||
'NIM3',
|
||||
'NIM4',
|
||||
'JANS',
|
||||
'ARCE',
|
||||
'DG40',
|
||||
'DG80',
|
||||
'DG1T',
|
||||
'DG30',
|
||||
'DG50',
|
||||
'LS3',
|
||||
'LS4',
|
||||
'LS6',
|
||||
'LS7',
|
||||
'LS8',
|
||||
'STD3',
|
||||
'G103',
|
||||
'G102',
|
||||
'G104',
|
||||
'PW5',
|
||||
'PW6',
|
||||
'L13',
|
||||
'L23',
|
||||
'L33',
|
||||
'PIK',
|
||||
'PEGA',
|
||||
'KEST',
|
||||
'TWIN',
|
||||
'AS33',
|
||||
'ASW',
|
||||
'ASG',
|
||||
'ASK',
|
||||
'VENS',
|
||||
'GLID',
|
||||
'MOSQ',
|
||||
'DIMO',
|
||||
]);
|
||||
const LIGHT = new Set([
|
||||
'C150', 'C152', 'C162', 'C172', 'C72R', 'C175', 'C177', 'C180', 'C182', 'C185',
|
||||
'C188', 'C206', 'C207', 'C210', 'C310', 'C337', 'SR20', 'SR22', 'S22T', 'PA18',
|
||||
'PA24', 'PA28', 'P28A', 'P28B', 'P28R', 'PA32', 'P32R', 'PA34', 'PA38', 'PA44',
|
||||
'PA46', 'DA20', 'DA40', 'DA42', 'DA62', 'BE33', 'BE35', 'BE36', 'BE58', 'BE76',
|
||||
'BE19', 'BE23', 'BE24', 'M20P', 'M20T', 'AA1', 'AA5', 'GLAS', 'COL4', 'RV4',
|
||||
'RV6', 'RV7', 'RV8', 'RV9', 'RV10', 'RV14', 'GA8', 'G115', 'BL8', 'CH7',
|
||||
'C150',
|
||||
'C152',
|
||||
'C162',
|
||||
'C172',
|
||||
'C72R',
|
||||
'C175',
|
||||
'C177',
|
||||
'C180',
|
||||
'C182',
|
||||
'C185',
|
||||
'C188',
|
||||
'C206',
|
||||
'C207',
|
||||
'C210',
|
||||
'C310',
|
||||
'C337',
|
||||
'SR20',
|
||||
'SR22',
|
||||
'S22T',
|
||||
'PA18',
|
||||
'PA24',
|
||||
'PA28',
|
||||
'P28A',
|
||||
'P28B',
|
||||
'P28R',
|
||||
'PA32',
|
||||
'P32R',
|
||||
'PA34',
|
||||
'PA38',
|
||||
'PA44',
|
||||
'PA46',
|
||||
'DA20',
|
||||
'DA40',
|
||||
'DA42',
|
||||
'DA62',
|
||||
'BE33',
|
||||
'BE35',
|
||||
'BE36',
|
||||
'BE58',
|
||||
'BE76',
|
||||
'BE19',
|
||||
'BE23',
|
||||
'BE24',
|
||||
'M20P',
|
||||
'M20T',
|
||||
'AA1',
|
||||
'AA5',
|
||||
'GLAS',
|
||||
'COL4',
|
||||
'RV4',
|
||||
'RV6',
|
||||
'RV7',
|
||||
'RV8',
|
||||
'RV9',
|
||||
'RV10',
|
||||
'RV14',
|
||||
'GA8',
|
||||
'G115',
|
||||
'BL8',
|
||||
'CH7',
|
||||
]);
|
||||
// GEV addition (2026-08-15 Hangar fleet): business jets — distinct class so the
|
||||
// Citation II GLB + a slimmer glyph read apart from airliners.
|
||||
const BIZJET = new Set([
|
||||
'C500', 'C501', 'C510', 'C525', 'C25A', 'C25B', 'C25C', 'C25M', 'C550',
|
||||
'C551', 'C560', 'C56X', 'C650', 'C680', 'C68A', 'C700', 'C750',
|
||||
'CL30', 'CL35', 'CL60', 'GLF2', 'GLF3', 'GLF4', 'GLF5', 'GLF6', 'GA5C',
|
||||
'GA6C', 'G150', 'G280', 'GL5T', 'GL7T', 'GLEX',
|
||||
'LJ23', 'LJ24', 'LJ25', 'LJ31', 'LJ35', 'LJ40', 'LJ45', 'LJ55', 'LJ60',
|
||||
'LJ70', 'LJ75', 'FA10', 'FA20', 'FA50', 'FA7X', 'FA8X', 'F900', 'F2TH',
|
||||
'H25A', 'H25B', 'H25C', 'HDJT', 'E50P', 'E55P', 'E545', 'E550', 'PC24',
|
||||
'PRM1', 'BE40', 'ASTR', 'WW24',
|
||||
'C500',
|
||||
'C501',
|
||||
'C510',
|
||||
'C525',
|
||||
'C25A',
|
||||
'C25B',
|
||||
'C25C',
|
||||
'C25M',
|
||||
'C550',
|
||||
'C551',
|
||||
'C560',
|
||||
'C56X',
|
||||
'C650',
|
||||
'C680',
|
||||
'C68A',
|
||||
'C700',
|
||||
'C750',
|
||||
'CL30',
|
||||
'CL35',
|
||||
'CL60',
|
||||
'GLF2',
|
||||
'GLF3',
|
||||
'GLF4',
|
||||
'GLF5',
|
||||
'GLF6',
|
||||
'GA5C',
|
||||
'GA6C',
|
||||
'G150',
|
||||
'G280',
|
||||
'GL5T',
|
||||
'GL7T',
|
||||
'GLEX',
|
||||
'LJ23',
|
||||
'LJ24',
|
||||
'LJ25',
|
||||
'LJ31',
|
||||
'LJ35',
|
||||
'LJ40',
|
||||
'LJ45',
|
||||
'LJ55',
|
||||
'LJ60',
|
||||
'LJ70',
|
||||
'LJ75',
|
||||
'FA10',
|
||||
'FA20',
|
||||
'FA50',
|
||||
'FA7X',
|
||||
'FA8X',
|
||||
'F900',
|
||||
'F2TH',
|
||||
'H25A',
|
||||
'H25B',
|
||||
'H25C',
|
||||
'HDJT',
|
||||
'E50P',
|
||||
'E55P',
|
||||
'E545',
|
||||
'E550',
|
||||
'PC24',
|
||||
'PRM1',
|
||||
'BE40',
|
||||
'ASTR',
|
||||
'WW24',
|
||||
'SF50', // Cirrus Vision Jet — a JET (was mis-set in LIGHT before the bizjet class existed)
|
||||
]);
|
||||
// GEV addition (2026-08-15 Hangar fleet): large UAVs — ICAO designators seen on
|
||||
// ADS-B/adsb.lol for Predator/Reaper/Global Hawk-class airframes.
|
||||
const UAV = new Set([
|
||||
'Q1', 'Q4', 'Q9', 'MQ1', 'MQ4', 'MQ9', 'RQ4', 'TB2', 'SHDW', 'HERN',
|
||||
'Q1',
|
||||
'Q4',
|
||||
'Q9',
|
||||
'MQ1',
|
||||
'MQ4',
|
||||
'MQ9',
|
||||
'RQ4',
|
||||
'TB2',
|
||||
'SHDW',
|
||||
'HERN',
|
||||
]);
|
||||
// GEV addition: fast jets, for the military layer's adsb.lol `t` codes.
|
||||
const FASTJET = new Set([
|
||||
'F16', 'F15', 'F18', 'FA18', 'F14', 'F22', 'F35', 'F4', 'F5', 'A10', 'AV8B',
|
||||
'TYPH', 'EUFI', 'RFAL', 'RAFL', 'GRIP', 'JAS39', 'TOR', 'MIR2', 'M2000',
|
||||
'SU27', 'SU30', 'SU33', 'SU34', 'SU35', 'SU57', 'MG29', 'MIG29', 'MG31', 'J20',
|
||||
'T38', 'HAWK', 'L39', 'M346', 'T7A',
|
||||
'F16',
|
||||
'F15',
|
||||
'F18',
|
||||
'FA18',
|
||||
'F14',
|
||||
'F22',
|
||||
'F35',
|
||||
'F4',
|
||||
'F5',
|
||||
'A10',
|
||||
'AV8B',
|
||||
'TYPH',
|
||||
'EUFI',
|
||||
'RFAL',
|
||||
'RAFL',
|
||||
'GRIP',
|
||||
'JAS39',
|
||||
'TOR',
|
||||
'MIR2',
|
||||
'M2000',
|
||||
'SU27',
|
||||
'SU30',
|
||||
'SU33',
|
||||
'SU34',
|
||||
'SU35',
|
||||
'SU57',
|
||||
'MG29',
|
||||
'MIG29',
|
||||
'MG31',
|
||||
'J20',
|
||||
'T38',
|
||||
'HAWK',
|
||||
'L39',
|
||||
'M346',
|
||||
'T7A',
|
||||
]);
|
||||
|
||||
/** OpenSky extended-states integer category → class. */
|
||||
const OPENSKY_CATEGORY = {
|
||||
2: 'light', // Light (< 15 500 lbs)
|
||||
3: 'airliner', // Small
|
||||
4: 'airliner', // Large
|
||||
5: 'airliner', // High-vortex large (B757)
|
||||
6: 'widebody', // Heavy
|
||||
7: 'fastjet', // High performance (>5g, >400 kt)
|
||||
2: 'light', // Light (< 15 500 lbs)
|
||||
3: 'airliner', // Small
|
||||
4: 'airliner', // Large
|
||||
5: 'airliner', // High-vortex large (B757)
|
||||
6: 'widebody', // Heavy
|
||||
7: 'fastjet', // High performance (>5g, >400 kt)
|
||||
8: 'helicopter', // Rotorcraft
|
||||
9: 'glider',
|
||||
};
|
||||
|
||||
/** ADS-B emitter-category string → class. */
|
||||
const EMITTER_CATEGORY = {
|
||||
A1: 'light', A2: 'light', A3: 'airliner', A4: 'airliner',
|
||||
A5: 'widebody', A6: 'fastjet', A7: 'helicopter', B1: 'glider',
|
||||
A1: 'light',
|
||||
A2: 'light',
|
||||
A3: 'airliner',
|
||||
A4: 'airliner',
|
||||
A5: 'widebody',
|
||||
A6: 'fastjet',
|
||||
A7: 'helicopter',
|
||||
B1: 'glider',
|
||||
};
|
||||
|
||||
export function classifyAircraft({ typeCode, category } = {}) {
|
||||
const code = String(typeCode || '').trim().toUpperCase();
|
||||
const code = String(typeCode || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (code) {
|
||||
if (FASTJET.has(code)) return 'fastjet';
|
||||
if (UAV.has(code)) return 'uav';
|
||||
@@ -104,37 +429,59 @@ export function classifyAircraft({ typeCode, category } = {}) {
|
||||
if (LIGHT.has(code)) return 'light';
|
||||
return 'airliner'; // known type code, not in a special set → default jet
|
||||
}
|
||||
if (Number.isFinite(category) && OPENSKY_CATEGORY[category]) return OPENSKY_CATEGORY[category];
|
||||
const cat = String(category || '').trim().toUpperCase();
|
||||
if (Number.isFinite(category) && OPENSKY_CATEGORY[category])
|
||||
return OPENSKY_CATEGORY[category];
|
||||
const cat = String(category || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (EMITTER_CATEGORY[cat]) return EMITTER_CATEGORY[cat];
|
||||
return 'airliner';
|
||||
}
|
||||
|
||||
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
|
||||
export const CLASS_SCALE_2D = {
|
||||
light: 0.62, glider: 0.58, turboprop: 0.86, airliner: 1.0,
|
||||
widebody: 1.3, quadjet: 1.45, helicopter: 0.82, fastjet: 0.8,
|
||||
bizjet: 0.72, uav: 0.75,
|
||||
light: 0.62,
|
||||
glider: 0.58,
|
||||
turboprop: 0.86,
|
||||
airliner: 1.0,
|
||||
widebody: 1.3,
|
||||
quadjet: 1.45,
|
||||
helicopter: 0.82,
|
||||
fastjet: 0.8,
|
||||
bizjet: 0.72,
|
||||
uav: 0.75,
|
||||
};
|
||||
|
||||
/** 3D model scale multipliers — clamped [0.75, 1.45] while every class shares
|
||||
* the single airplane.glb (a jet mesh at C172 scale reads wrong). Widen when
|
||||
* real per-class models land in CLASS_MODEL_URL. */
|
||||
export const CLASS_SCALE_3D = {
|
||||
light: 0.75, glider: 0.75, turboprop: 0.85, airliner: 1.0,
|
||||
widebody: 1.3, quadjet: 1.45, helicopter: 0.8, fastjet: 0.8,
|
||||
bizjet: 0.8, uav: 0.8,
|
||||
light: 0.75,
|
||||
glider: 0.75,
|
||||
turboprop: 0.85,
|
||||
airliner: 1.0,
|
||||
widebody: 1.3,
|
||||
quadjet: 1.45,
|
||||
helicopter: 0.8,
|
||||
fastjet: 0.8,
|
||||
bizjet: 0.8,
|
||||
uav: 0.8,
|
||||
};
|
||||
|
||||
/** Per-class glTF — all the shared airplane today; drop real assets in here.
|
||||
* NOTE for future models: each asset may need its own heading offset (the
|
||||
* shared GLB uses MODEL_HEADING_OFFSET_DEG = 180 in both layers). */
|
||||
export const CLASS_MODEL_URL = {
|
||||
light: '/models/airplane.glb', glider: '/models/airplane.glb',
|
||||
turboprop: '/models/airplane.glb', airliner: '/models/airplane.glb',
|
||||
widebody: '/models/airplane.glb', quadjet: '/models/airplane.glb',
|
||||
helicopter: '/models/airplane.glb', fastjet: '/models/airplane.glb',
|
||||
bizjet: '/models/airplane.glb', uav: '/models/airplane.glb',
|
||||
light: '/models/airplane.glb',
|
||||
glider: '/models/airplane.glb',
|
||||
turboprop: '/models/airplane.glb',
|
||||
airliner: '/models/airplane.glb',
|
||||
widebody: '/models/airplane.glb',
|
||||
quadjet: '/models/airplane.glb',
|
||||
helicopter: '/models/airplane.glb',
|
||||
fastjet: '/models/airplane.glb',
|
||||
bizjet: '/models/airplane.glb',
|
||||
uav: '/models/airplane.glb',
|
||||
};
|
||||
|
||||
/** Real per-class GLBs (2026-08-15 Hangar fleet, owner picks; CC-BY 4.0 —
|
||||
@@ -148,10 +495,10 @@ export const CLASS_MODEL_URL = {
|
||||
* pinned by modelScale.test.mjs. Classes NOT listed (airliner, quadjet,
|
||||
* glider, fastjet) still render the shared airplane.glb via CLASS_MODEL_URL. */
|
||||
export const CLASS_MODEL_REAL = {
|
||||
helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 },
|
||||
light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 },
|
||||
bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 },
|
||||
uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 },
|
||||
widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 },
|
||||
turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 },
|
||||
helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 },
|
||||
light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 },
|
||||
bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 },
|
||||
uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 },
|
||||
widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 },
|
||||
turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 },
|
||||
};
|
||||
@@ -35,9 +35,11 @@ const VIEW = 96;
|
||||
const C = VIEW / 2; // 48 — glyph centre
|
||||
|
||||
// Hairline dark edge (scaled for the 96 box: ~1.5u ≈ the old 0.5u at 32).
|
||||
const STROKE = 'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"';
|
||||
const STROKE =
|
||||
'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"';
|
||||
// Heavier edge for the chart-symbol ("bold") glyphs adopted 2026-08-15.
|
||||
const STROKE_BOLD = 'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"';
|
||||
const STROKE_BOLD =
|
||||
'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"';
|
||||
// Softer white for translucent detail (prop discs, rotor disc) — still white so
|
||||
// the tint multiplies cleanly; only the alpha differs.
|
||||
const DISC = 'fill="white" fill-opacity="0.5"';
|
||||
@@ -239,7 +241,9 @@ const BODIES = {
|
||||
const _iconCache = new Map();
|
||||
|
||||
const _b64 = (s) =>
|
||||
typeof btoa === 'function' ? btoa(s) : Buffer.from(s, 'utf8').toString('base64');
|
||||
typeof btoa === 'function'
|
||||
? btoa(s)
|
||||
: Buffer.from(s, 'utf8').toString('base64');
|
||||
|
||||
/** Fleet raster: billboards render at ~40–58 DEVICE px (width 20–24 CSS ×
|
||||
* Retina × class scale). Cesium's billboard atlas has no mipmaps, so a big
|
||||
|
||||
@@ -11,5 +11,7 @@ export function aircraftIncludedInNearby({
|
||||
modelRendering = false,
|
||||
includeHidden = false,
|
||||
} = {}) {
|
||||
return Boolean(includeHidden || isTracked || billboardShown || modelRendering);
|
||||
return Boolean(
|
||||
includeHidden || isTracked || billboardShown || modelRendering,
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
|
||||
startLimbRatio: 0.5,
|
||||
scaleFloor: 0.45,
|
||||
alphaFloor: 0.35,
|
||||
combinedAlphaFloor: 0.20,
|
||||
combinedAlphaFloor: 0.2,
|
||||
globeViewBlendStartM: 3_500_000,
|
||||
globeViewBlendEndM: 4_500_000,
|
||||
earthRadiusM: 6_378_137,
|
||||
@@ -36,10 +36,19 @@ function resolvedParams(overrides) {
|
||||
return { ..._params, ...overrides };
|
||||
}
|
||||
|
||||
function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning, result) {
|
||||
if (!Number.isFinite(cameraDistanceM) || cameraDistanceM < 0
|
||||
|| !Number.isFinite(cameraHeightM) || cameraHeightM <= 0
|
||||
|| cameraHeightM >= tuning.globeViewBlendEndM) {
|
||||
function aircraftRecessionFactorsResolved(
|
||||
cameraDistanceM,
|
||||
cameraHeightM,
|
||||
tuning,
|
||||
result,
|
||||
) {
|
||||
if (
|
||||
!Number.isFinite(cameraDistanceM) ||
|
||||
cameraDistanceM < 0 ||
|
||||
!Number.isFinite(cameraHeightM) ||
|
||||
cameraHeightM <= 0 ||
|
||||
cameraHeightM >= tuning.globeViewBlendEndM
|
||||
) {
|
||||
result.scale = 1;
|
||||
result.alpha = 1;
|
||||
result.limbRatio = null;
|
||||
@@ -60,11 +69,13 @@ function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning
|
||||
return result;
|
||||
}
|
||||
|
||||
const rawT = (limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio);
|
||||
const rawT =
|
||||
(limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio);
|
||||
const t = clamp(rawT, 0, 1);
|
||||
const limbEase = t * t * (3 - 2 * t); // smoothstep: zero slope at both band edges
|
||||
const globeRawT = (cameraHeightM - tuning.globeViewBlendStartM)
|
||||
/ (tuning.globeViewBlendEndM - tuning.globeViewBlendStartM);
|
||||
const globeRawT =
|
||||
(cameraHeightM - tuning.globeViewBlendStartM) /
|
||||
(tuning.globeViewBlendEndM - tuning.globeViewBlendStartM);
|
||||
const globeT = clamp(globeRawT, 0, 1);
|
||||
const globeEase = globeT * globeT * (3 - 2 * globeT);
|
||||
const strength = 1 - globeEase;
|
||||
@@ -84,8 +95,10 @@ export function setAircraftRecessionParams(patch = {}) {
|
||||
if (Number.isFinite(patch.startLimbRatio)) {
|
||||
next.startLimbRatio = clamp(patch.startLimbRatio, 0, 0.99);
|
||||
}
|
||||
if (Number.isFinite(patch.scaleFloor)) next.scaleFloor = clamp(patch.scaleFloor, 0.05, 1);
|
||||
if (Number.isFinite(patch.alphaFloor)) next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1);
|
||||
if (Number.isFinite(patch.scaleFloor))
|
||||
next.scaleFloor = clamp(patch.scaleFloor, 0.05, 1);
|
||||
if (Number.isFinite(patch.alphaFloor))
|
||||
next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1);
|
||||
if (Number.isFinite(patch.combinedAlphaFloor)) {
|
||||
next.combinedAlphaFloor = clamp(patch.combinedAlphaFloor, 0.05, 1);
|
||||
}
|
||||
@@ -109,8 +122,10 @@ export function setAircraftRecessionParams(patch = {}) {
|
||||
if (next.globeViewBlendStartM >= next.globeViewBlendEndM) {
|
||||
next.globeViewBlendStartM = Math.max(1, next.globeViewBlendEndM - 1);
|
||||
}
|
||||
if (Number.isFinite(patch.earthRadiusM)) next.earthRadiusM = Math.max(1, patch.earthRadiusM);
|
||||
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon);
|
||||
if (Number.isFinite(patch.earthRadiusM))
|
||||
next.earthRadiusM = Math.max(1, patch.earthRadiusM);
|
||||
if (Number.isFinite(patch.writeEpsilon))
|
||||
next.writeEpsilon = Math.max(0, patch.writeEpsilon);
|
||||
_params = next;
|
||||
return { ..._params };
|
||||
}
|
||||
@@ -149,7 +164,10 @@ export function cameraLimbDistanceM(
|
||||
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [params]
|
||||
* @returns {{scale:number,alpha:number,limbRatio:number|null}}
|
||||
*/
|
||||
export function aircraftRecessionFactors({ cameraDistanceM, cameraHeightM }, params) {
|
||||
export function aircraftRecessionFactors(
|
||||
{ cameraDistanceM, cameraHeightM },
|
||||
params,
|
||||
) {
|
||||
const tuning = resolvedParams(params);
|
||||
return aircraftRecessionFactorsResolved(
|
||||
cameraDistanceM,
|
||||
@@ -202,12 +220,17 @@ export function applyAircraftBillboardTreatment({
|
||||
const alpha = baseAlpha * composedTreatment;
|
||||
let scaleWrites = 0;
|
||||
let alphaWrites = 0;
|
||||
if (!Number.isFinite(billboard.scale) || Math.abs(billboard.scale - scale) > tuning.writeEpsilon) {
|
||||
if (
|
||||
!Number.isFinite(billboard.scale) ||
|
||||
Math.abs(billboard.scale - scale) > tuning.writeEpsilon
|
||||
) {
|
||||
billboard.scale = scale;
|
||||
scaleWrites = 1;
|
||||
}
|
||||
if (!Number.isFinite(billboard.color?.alpha)
|
||||
|| Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon) {
|
||||
if (
|
||||
!Number.isFinite(billboard.color?.alpha) ||
|
||||
Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon
|
||||
) {
|
||||
billboard.color = baseColor.withAlpha(alpha);
|
||||
alphaWrites = 1;
|
||||
}
|
||||
@@ -228,11 +251,19 @@ export function applyAircraftBillboardTreatment({
|
||||
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
|
||||
* @returns {number} One when a color write occurred, otherwise zero.
|
||||
*/
|
||||
export function applyAircraftModelTreatment({ model, baseColor, alpha, params }) {
|
||||
export function applyAircraftModelTreatment({
|
||||
model,
|
||||
baseColor,
|
||||
alpha,
|
||||
params,
|
||||
}) {
|
||||
const tuning = resolvedParams(params);
|
||||
if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
|
||||
if (Number.isFinite(model.color?.alpha)
|
||||
&& Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon) return 0;
|
||||
if (
|
||||
Number.isFinite(model.color?.alpha) &&
|
||||
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon
|
||||
)
|
||||
return 0;
|
||||
model.color = baseColor.withAlpha(alpha);
|
||||
return 1;
|
||||
}
|
||||
+110
-40
@@ -28,11 +28,39 @@ import { pointInRing } from './naturalEarthRegions.js';
|
||||
|
||||
/** Layers the engine understands, with the fields queries may reference. */
|
||||
export const ANALYST_LAYERS = {
|
||||
flights: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass'], flags: ['military', 'onGround'] },
|
||||
military: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'], flags: ['military', 'onGround'] },
|
||||
'ais-live-vessels': { numeric: ['speedKts', 'courseDeg'], text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'], flags: [] },
|
||||
'local-firms': { numeric: ['frp'], text: ['confidence', 'satellite'], flags: [] },
|
||||
earthquakes: { numeric: ['magnitude', 'depthKm'], text: ['place'], flags: [] },
|
||||
flights: {
|
||||
numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
|
||||
text: [
|
||||
'callsign',
|
||||
'icao24',
|
||||
'originCountry',
|
||||
'operator',
|
||||
'routeOrigin',
|
||||
'routeDestination',
|
||||
'aircraftClass',
|
||||
],
|
||||
flags: ['military', 'onGround'],
|
||||
},
|
||||
military: {
|
||||
numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
|
||||
text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'],
|
||||
flags: ['military', 'onGround'],
|
||||
},
|
||||
'ais-live-vessels': {
|
||||
numeric: ['speedKts', 'courseDeg'],
|
||||
text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'],
|
||||
flags: [],
|
||||
},
|
||||
'local-firms': {
|
||||
numeric: ['frp'],
|
||||
text: ['confidence', 'satellite'],
|
||||
flags: [],
|
||||
},
|
||||
earthquakes: {
|
||||
numeric: ['magnitude', 'depthKm'],
|
||||
text: ['place'],
|
||||
flags: [],
|
||||
},
|
||||
};
|
||||
|
||||
const EARTH_R_KM = 6371;
|
||||
@@ -42,8 +70,9 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const d2r = Math.PI / 180;
|
||||
const dLat = (lat2 - lat1) * d2r;
|
||||
const dLon = (lon2 - lon1) * d2r;
|
||||
const a = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
@@ -55,17 +84,25 @@ export function applyFilter(records, filter) {
|
||||
const got = r[field];
|
||||
if (got === null || got === undefined) return false;
|
||||
switch (op) {
|
||||
case 'gt': return Number(got) > Number(value);
|
||||
case 'gte': return Number(got) >= Number(value);
|
||||
case 'lt': return Number(got) < Number(value);
|
||||
case 'lte': return Number(got) <= Number(value);
|
||||
case 'gt':
|
||||
return Number(got) > Number(value);
|
||||
case 'gte':
|
||||
return Number(got) >= Number(value);
|
||||
case 'lt':
|
||||
return Number(got) < Number(value);
|
||||
case 'lte':
|
||||
return Number(got) <= Number(value);
|
||||
case 'eq': {
|
||||
if (typeof got === 'boolean' || typeof value === 'boolean') return Boolean(got) === Boolean(value);
|
||||
if (typeof got === 'boolean' || typeof value === 'boolean')
|
||||
return Boolean(got) === Boolean(value);
|
||||
return String(got).toLowerCase() === String(value).toLowerCase();
|
||||
}
|
||||
case 'neq': return String(got).toLowerCase() !== String(value).toLowerCase();
|
||||
case 'contains': return String(got).toLowerCase().includes(String(value).toLowerCase());
|
||||
default: return true;
|
||||
case 'neq':
|
||||
return String(got).toLowerCase() !== String(value).toLowerCase();
|
||||
case 'contains':
|
||||
return String(got).toLowerCase().includes(String(value).toLowerCase());
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -74,15 +111,23 @@ export function applyFilter(records, filter) {
|
||||
export function applyScope(records, scope, resolved) {
|
||||
if (!scope || scope.kind === 'anywhere') return records;
|
||||
if (scope.kind === 'region' && resolved?.ring) {
|
||||
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon)
|
||||
&& pointInRing(resolved.ring, r.lat, r.lon));
|
||||
return records.filter(
|
||||
(r) =>
|
||||
Number.isFinite(r.lat) &&
|
||||
Number.isFinite(r.lon) &&
|
||||
pointInRing(resolved.ring, r.lat, r.lon),
|
||||
);
|
||||
}
|
||||
if (scope.kind === 'radius' || scope.kind === 'view') {
|
||||
const c = resolved?.center;
|
||||
const km = resolved?.km;
|
||||
if (!c || !Number.isFinite(km)) return records;
|
||||
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon)
|
||||
&& haversineKm(c.lat, c.lon, r.lat, r.lon) <= km);
|
||||
return records.filter(
|
||||
(r) =>
|
||||
Number.isFinite(r.lat) &&
|
||||
Number.isFinite(r.lon) &&
|
||||
haversineKm(c.lat, c.lon, r.lat, r.lon) <= km,
|
||||
);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
@@ -108,9 +153,12 @@ export function createAnalystEngine(providers) {
|
||||
let lastResult = null;
|
||||
|
||||
async function query(spec = {}) {
|
||||
const layers = (spec.followUp && lastResult)
|
||||
? null // follow-up: re-filter the remembered set, no re-snapshot
|
||||
: (Array.isArray(spec.layers) && spec.layers.length ? spec.layers : ['flights']);
|
||||
const layers =
|
||||
spec.followUp && lastResult
|
||||
? null // follow-up: re-filter the remembered set, no re-snapshot
|
||||
: Array.isArray(spec.layers) && spec.layers.length
|
||||
? spec.layers
|
||||
: ['flights'];
|
||||
|
||||
// 1) Source records
|
||||
let records;
|
||||
@@ -164,20 +212,23 @@ export function createAnalystEngine(providers) {
|
||||
// camera made the two disagree — a parked, high-altitude camera answered
|
||||
// "46 within 250 km" while the panel showed a far larger contact-centred
|
||||
// count. Same question, two numbers.
|
||||
const explicitCenter = scope.center && Number.isFinite(scope.center.lat)
|
||||
? scope.center
|
||||
: null;
|
||||
const subject = explicitCenter ? null : providers.getContextSubject?.() || null;
|
||||
const explicitCenter =
|
||||
scope.center && Number.isFinite(scope.center.lat) ? scope.center : null;
|
||||
const subject = explicitCenter
|
||||
? null
|
||||
: providers.getContextSubject?.() || null;
|
||||
const view = providers.getViewContext();
|
||||
// Only a subject that actually SUPPLIED the centre may name it. A
|
||||
// subject present but without usable coordinates silently fell back to
|
||||
// the camera while the label still read "within 250 km of <contact>" —
|
||||
// a count centred on one place, reported as centred on another, with
|
||||
// nothing in the payload to show which.
|
||||
const subjectCenter = Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
|
||||
? { lat: subject.lat, lon: subject.lon }
|
||||
: null;
|
||||
const center = explicitCenter || subjectCenter || { lat: view.lat, lon: view.lon };
|
||||
const subjectCenter =
|
||||
Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
|
||||
? { lat: subject.lat, lon: subject.lon }
|
||||
: null;
|
||||
const center = explicitCenter ||
|
||||
subjectCenter || { lat: view.lat, lon: view.lon };
|
||||
resolvedScope = { center, km: Number(scope.km) || 100 };
|
||||
if (subjectCenter) resolvedScope.centeredOn = subject.label || null;
|
||||
scopeNote = resolvedScope.centeredOn
|
||||
@@ -188,7 +239,10 @@ export function createAnalystEngine(providers) {
|
||||
: `within ${resolvedScope.km} km`;
|
||||
} else if (scope.kind === 'view') {
|
||||
const view = providers.getViewContext();
|
||||
resolvedScope = { center: { lat: view.lat, lon: view.lon }, km: view.viewRadiusKm };
|
||||
resolvedScope = {
|
||||
center: { lat: view.lat, lon: view.lon },
|
||||
km: view.viewRadiusKm,
|
||||
};
|
||||
scopeNote = `view:${Math.round(view.viewRadiusKm)}km`;
|
||||
scopeLabel = 'in view';
|
||||
} else {
|
||||
@@ -205,15 +259,22 @@ export function createAnalystEngine(providers) {
|
||||
if (sortBy === 'distance') {
|
||||
const ref = resolvedScope?.center || providers.getViewContext();
|
||||
for (const it of items) {
|
||||
it.distanceKm = (Number.isFinite(it.lat) && Number.isFinite(it.lon))
|
||||
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) / 10 : null;
|
||||
it.distanceKm =
|
||||
Number.isFinite(it.lat) && Number.isFinite(it.lon)
|
||||
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) /
|
||||
10
|
||||
: null;
|
||||
}
|
||||
}
|
||||
if (sortBy) {
|
||||
const dir = spec.sortDir === 'asc' ? 1 : -1;
|
||||
items.sort((a, b) => (Number(a[sortBy]) - Number(b[sortBy])) * dir
|
||||
|| String(a.id).localeCompare(String(b.id)));
|
||||
if (sortBy === 'distance') items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9));
|
||||
items.sort(
|
||||
(a, b) =>
|
||||
(Number(a[sortBy]) - Number(b[sortBy])) * dir ||
|
||||
String(a.id).localeCompare(String(b.id)),
|
||||
);
|
||||
if (sortBy === 'distance')
|
||||
items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9));
|
||||
}
|
||||
const limit = Math.max(1, Math.min(50, Number(spec.limit) || 10));
|
||||
const top = items.slice(0, limit);
|
||||
@@ -223,7 +284,10 @@ export function createAnalystEngine(providers) {
|
||||
count: items.length,
|
||||
items: top,
|
||||
truncated: items.length > top.length,
|
||||
summary: summarize(items, sortBy && sortBy !== 'distance' ? sortBy : null),
|
||||
summary: summarize(
|
||||
items,
|
||||
sortBy && sortBy !== 'distance' ? sortBy : null,
|
||||
),
|
||||
scopeLabel,
|
||||
coverage: {
|
||||
layersQueried,
|
||||
@@ -233,7 +297,9 @@ export function createAnalystEngine(providers) {
|
||||
},
|
||||
// Surfaced so the narration can name the centre it measured from rather
|
||||
// than implying a view-centred answer.
|
||||
...(resolvedScope?.centeredOn ? { centeredOn: resolvedScope.centeredOn } : {}),
|
||||
...(resolvedScope?.centeredOn
|
||||
? { centeredOn: resolvedScope.centeredOn }
|
||||
: {}),
|
||||
};
|
||||
lastResult = { items, coverage: result.coverage };
|
||||
return result;
|
||||
@@ -241,7 +307,11 @@ export function createAnalystEngine(providers) {
|
||||
|
||||
return {
|
||||
query,
|
||||
reset() { lastResult = null; },
|
||||
hasMemory() { return Boolean(lastResult); },
|
||||
reset() {
|
||||
lastResult = null;
|
||||
},
|
||||
hasMemory() {
|
||||
return Boolean(lastResult);
|
||||
},
|
||||
};
|
||||
}
|
||||
+67
-19
@@ -110,8 +110,12 @@ export const CCTV_THUMBNAIL_ALTITUDE_SCALE = Object.freeze({
|
||||
*/
|
||||
export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
|
||||
if (pinned) return Number.isFinite(sy);
|
||||
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0) return false;
|
||||
const safeTop = Math.min(CCTV_CARD_SAFE_TOP_MAX_PX, viewH * CCTV_CARD_SAFE_TOP_RATIO);
|
||||
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0)
|
||||
return false;
|
||||
const safeTop = Math.min(
|
||||
CCTV_CARD_SAFE_TOP_MAX_PX,
|
||||
viewH * CCTV_CARD_SAFE_TOP_RATIO,
|
||||
);
|
||||
return sy >= safeTop;
|
||||
}
|
||||
|
||||
@@ -126,11 +130,24 @@ export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
|
||||
* @param {number} [options.limit]
|
||||
* @returns {string[]} Accepted ids, nearest-first.
|
||||
*/
|
||||
export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {}) {
|
||||
export function declutterCctvCards(
|
||||
candidates,
|
||||
{ minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {},
|
||||
) {
|
||||
const valid = (Array.isArray(candidates) ? candidates : [])
|
||||
.filter((c) => c && typeof c.id === 'string' && c.id
|
||||
&& Number.isFinite(c.sx) && Number.isFinite(c.sy))
|
||||
.sort((a, b) => (a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) || a.id.localeCompare(b.id));
|
||||
.filter(
|
||||
(c) =>
|
||||
c &&
|
||||
typeof c.id === 'string' &&
|
||||
c.id &&
|
||||
Number.isFinite(c.sx) &&
|
||||
Number.isFinite(c.sy),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
const minSq = minSepPx * minSepPx;
|
||||
const accepted = [];
|
||||
const acceptedIds = [];
|
||||
@@ -140,7 +157,10 @@ export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX
|
||||
for (let i = 0; i < accepted.length; i++) {
|
||||
const dx = candidate.sx - accepted[i].sx;
|
||||
const dy = candidate.sy - accepted[i].sy;
|
||||
if (dx * dx + dy * dy < minSq) { clear = false; break; }
|
||||
if (dx * dx + dy * dy < minSq) {
|
||||
clear = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!clear) continue;
|
||||
accepted.push(candidate);
|
||||
@@ -165,15 +185,26 @@ export function cardScaleForAltitude(cameraHeightM) {
|
||||
const h = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : 0;
|
||||
if (h <= CCTV_CARD_SCALE_FULL_M) return { scale: 1, alpha: 1 };
|
||||
if (h <= CCTV_CARD_SCALE_MID_M) {
|
||||
const t = (h - CCTV_CARD_SCALE_FULL_M) / (CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M);
|
||||
const t =
|
||||
(h - CCTV_CARD_SCALE_FULL_M) /
|
||||
(CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M);
|
||||
const s = t * t * (3 - 2 * t); // smoothstep — no visible kink at either end
|
||||
return { scale: 1 + (CCTV_CARD_SCALE_AT_MID - 1) * s, alpha: 1 };
|
||||
}
|
||||
const t = Math.min(1, (h - CCTV_CARD_SCALE_MID_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M));
|
||||
const scale = CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t;
|
||||
if (h >= CCTV_CARD_FADE_END_M) return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 };
|
||||
const t = Math.min(
|
||||
1,
|
||||
(h - CCTV_CARD_SCALE_MID_M) /
|
||||
(CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M),
|
||||
);
|
||||
const scale =
|
||||
CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t;
|
||||
if (h >= CCTV_CARD_FADE_END_M)
|
||||
return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 };
|
||||
if (h <= CCTV_CARD_FADE_START_M) return { scale, alpha: 1 };
|
||||
const alpha = 1 - (h - CCTV_CARD_FADE_START_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
|
||||
const alpha =
|
||||
1 -
|
||||
(h - CCTV_CARD_FADE_START_M) /
|
||||
(CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
|
||||
return { scale, alpha };
|
||||
}
|
||||
|
||||
@@ -192,17 +223,23 @@ export function cardScaleForAltitude(cameraHeightM) {
|
||||
* @param {number} [input.sinceLastLaunchMs] - Ms since the last fetch launch.
|
||||
* @returns {{mode:('burst'|'steady'), launch:boolean}}
|
||||
*/
|
||||
export function cardFetchPolicy({ coldFill = false, inFlight = 0, sinceLastLaunchMs = Infinity } = {}) {
|
||||
export function cardFetchPolicy({
|
||||
coldFill = false,
|
||||
inFlight = 0,
|
||||
sinceLastLaunchMs = Infinity,
|
||||
} = {}) {
|
||||
if (coldFill) {
|
||||
return {
|
||||
mode: 'burst',
|
||||
launch: inFlight < CCTV_CARD_FETCH_BURST_LIMIT
|
||||
&& sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
|
||||
launch:
|
||||
inFlight < CCTV_CARD_FETCH_BURST_LIMIT &&
|
||||
sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: 'steady',
|
||||
launch: inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
|
||||
launch:
|
||||
inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,7 +267,12 @@ export function createFrameSlot() {
|
||||
export function applyFrameResult(prev, result, nowMs) {
|
||||
const base = prev || createFrameSlot();
|
||||
if (result?.ok && result.frame) {
|
||||
return { frame: result.frame, stamp: nowMs, failCount: 0, lastAttemptAt: nowMs };
|
||||
return {
|
||||
frame: result.frame,
|
||||
stamp: nowMs,
|
||||
failCount: 0,
|
||||
lastAttemptAt: nowMs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
frame: base.frame,
|
||||
@@ -278,11 +320,17 @@ export function frameFetchDue(slot, refreshMs, nowMs) {
|
||||
* @param {number} [cap]
|
||||
* @returns {string[]} Ids to drop from the cache.
|
||||
*/
|
||||
export function planFrameCachePrune(slots, keepIds, cap = CCTV_FRAME_CACHE_MAX) {
|
||||
export function planFrameCachePrune(
|
||||
slots,
|
||||
keepIds,
|
||||
cap = CCTV_FRAME_CACHE_MAX,
|
||||
) {
|
||||
const keep = new Set(keepIds);
|
||||
const spare = (Array.isArray(slots) ? slots : [])
|
||||
.filter((slot) => slot && typeof slot.id === 'string' && !keep.has(slot.id))
|
||||
.sort((a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id));
|
||||
.sort(
|
||||
(a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id),
|
||||
);
|
||||
const capacity = Math.max(0, cap - keep.size);
|
||||
return spare.slice(capacity).map((slot) => slot.id);
|
||||
}
|
||||
|
||||
+173
-44
@@ -74,7 +74,11 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
|
||||
const toPlane = Cesium.Cartesian3.subtract(planeOrigin, rayOrigin, scratchW);
|
||||
const s = Cesium.Cartesian3.dot(toPlane, planeNormal) / denom;
|
||||
if (s < 0) return null;
|
||||
const hit = Cesium.Cartesian3.multiplyByScalar(rayDir, s, new Cesium.Cartesian3());
|
||||
const hit = Cesium.Cartesian3.multiplyByScalar(
|
||||
rayDir,
|
||||
s,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
return Cesium.Cartesian3.add(rayOrigin, hit, hit);
|
||||
}
|
||||
|
||||
@@ -89,7 +93,10 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
|
||||
*/
|
||||
export function ringAngle(hitPoint, center, basisA, basisB) {
|
||||
const v = Cesium.Cartesian3.subtract(hitPoint, center, scratchW);
|
||||
return Math.atan2(Cesium.Cartesian3.dot(v, basisB), Cesium.Cartesian3.dot(v, basisA));
|
||||
return Math.atan2(
|
||||
Cesium.Cartesian3.dot(v, basisB),
|
||||
Cesium.Cartesian3.dot(v, basisA),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,7 +157,11 @@ function enuAxes(position) {
|
||||
|
||||
/** a*sa + b*sb (fresh Cartesian3). */
|
||||
function combine2(a, sa, b, sb) {
|
||||
const out = Cesium.Cartesian3.multiplyByScalar(a, sa, new Cesium.Cartesian3());
|
||||
const out = Cesium.Cartesian3.multiplyByScalar(
|
||||
a,
|
||||
sa,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const t = Cesium.Cartesian3.multiplyByScalar(b, sb, new Cesium.Cartesian3());
|
||||
return Cesium.Cartesian3.add(out, t, out);
|
||||
}
|
||||
@@ -165,7 +176,12 @@ function combine2(a, sa, b, sb) {
|
||||
function viewAxesFor(headingDeg, pitchDeg, axes) {
|
||||
const h = toRadians(headingDeg);
|
||||
const p = toRadians(pitchDeg);
|
||||
const forwardHoriz = combine2(axes.east, Math.sin(h), axes.north, Math.cos(h));
|
||||
const forwardHoriz = combine2(
|
||||
axes.east,
|
||||
Math.sin(h),
|
||||
axes.north,
|
||||
Math.cos(h),
|
||||
);
|
||||
const right = combine2(axes.east, Math.cos(h), axes.north, -Math.sin(h));
|
||||
const view = combine2(forwardHoriz, Math.cos(p), axes.up, Math.sin(p));
|
||||
return { forwardHoriz, right, view };
|
||||
@@ -176,7 +192,12 @@ function ringPositions(center, radius, basisA, basisB) {
|
||||
const positions = [];
|
||||
for (let i = 0; i <= RING_SEGMENTS; i++) {
|
||||
const angle = (i / RING_SEGMENTS) * 2 * Math.PI;
|
||||
const offset = combine2(basisA, Math.cos(angle) * radius, basisB, Math.sin(angle) * radius);
|
||||
const offset = combine2(
|
||||
basisA,
|
||||
Math.cos(angle) * radius,
|
||||
basisB,
|
||||
Math.sin(angle) * radius,
|
||||
);
|
||||
positions.push(Cesium.Cartesian3.add(center, offset, offset));
|
||||
}
|
||||
return positions;
|
||||
@@ -202,7 +223,12 @@ function ringPositions(center, radius, basisA, basisB) {
|
||||
* destroy: function(): void, isDragging: function(): boolean,
|
||||
* isEnabled: function(): boolean}}
|
||||
*/
|
||||
export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, endPatch }) {
|
||||
export function createCalibrationGizmo({
|
||||
viewer,
|
||||
getActiveRecord,
|
||||
applyPatch,
|
||||
endPatch,
|
||||
}) {
|
||||
let enabled = false;
|
||||
let drag = null; // { part, startCal, basePose, refs... }
|
||||
let hoveredId = null;
|
||||
@@ -225,20 +251,24 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
|
||||
function polylinePart(part, color, width, arrow = false) {
|
||||
addEntity(part, {
|
||||
...(arrow ? {
|
||||
position: Cesium.Cartesian3.ZERO,
|
||||
point: {
|
||||
pixelSize: 14,
|
||||
color,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.7),
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
} : {}),
|
||||
...(arrow
|
||||
? {
|
||||
position: Cesium.Cartesian3.ZERO,
|
||||
point: {
|
||||
pixelSize: 14,
|
||||
color,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.7),
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
polyline: {
|
||||
positions: [],
|
||||
width,
|
||||
material: arrow ? new Cesium.PolylineArrowMaterialProperty(color) : color.withAlpha(0.9),
|
||||
material: arrow
|
||||
? new Cesium.PolylineArrowMaterialProperty(color)
|
||||
: color.withAlpha(0.9),
|
||||
// Standard gizmo convention: parts behind geometry stay clearly
|
||||
// visible, just dimmed (a street-level mount buries half the heading
|
||||
// ring in sloped photogrammetry tiles — smoke-tested 2026-07-05).
|
||||
@@ -286,16 +316,42 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
}
|
||||
const mount = positions.mount;
|
||||
const axes = enuAxes(mount);
|
||||
const { forwardHoriz, right, view } = viewAxesFor(camera.headingDeg, camera.pitchDeg, axes);
|
||||
const { forwardHoriz, right, view } = viewAxesFor(
|
||||
camera.headingDeg,
|
||||
camera.pitchDeg,
|
||||
axes,
|
||||
);
|
||||
const rangeM = Math.max(1, Number(camera.rangeM) || 1);
|
||||
const radius = Math.min(RING_RADIUS_MAX_M, Math.max(RING_RADIUS_MIN_M, rangeM * RING_RADIUS_FACTOR));
|
||||
const radius = Math.min(
|
||||
RING_RADIUS_MAX_M,
|
||||
Math.max(RING_RADIUS_MIN_M, rangeM * RING_RADIUS_FACTOR),
|
||||
);
|
||||
const arrowLen = radius * ARROW_LENGTH_FACTOR;
|
||||
|
||||
entities.get('ring-heading').polyline.positions = ringPositions(mount, radius, axes.north, axes.east);
|
||||
entities.get('ring-pitch').polyline.positions = ringPositions(mount, radius, forwardHoriz, axes.up);
|
||||
const arrowTo = (dir) => [mount, Cesium.Cartesian3.add(
|
||||
mount, Cesium.Cartesian3.multiplyByScalar(dir, arrowLen, new Cesium.Cartesian3()), new Cesium.Cartesian3()
|
||||
)];
|
||||
entities.get('ring-heading').polyline.positions = ringPositions(
|
||||
mount,
|
||||
radius,
|
||||
axes.north,
|
||||
axes.east,
|
||||
);
|
||||
entities.get('ring-pitch').polyline.positions = ringPositions(
|
||||
mount,
|
||||
radius,
|
||||
forwardHoriz,
|
||||
axes.up,
|
||||
);
|
||||
const arrowTo = (dir) => [
|
||||
mount,
|
||||
Cesium.Cartesian3.add(
|
||||
mount,
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
dir,
|
||||
arrowLen,
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
];
|
||||
const eastArrow = arrowTo(axes.east);
|
||||
const northArrow = arrowTo(axes.north);
|
||||
const upArrow = arrowTo(axes.up);
|
||||
@@ -306,8 +362,16 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
entities.get('move-up').polyline.positions = upArrow;
|
||||
entities.get('move-up').position = upArrow[1];
|
||||
entities.get('handle-range').position = positions.capCenter;
|
||||
entities.get('handle-fov-l').position = Cesium.Cartesian3.midpoint(positions.tl, positions.bl, new Cesium.Cartesian3());
|
||||
entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint(positions.tr, positions.br, new Cesium.Cartesian3());
|
||||
entities.get('handle-fov-l').position = Cesium.Cartesian3.midpoint(
|
||||
positions.tl,
|
||||
positions.bl,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint(
|
||||
positions.tr,
|
||||
positions.br,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
for (const entity of entities.values()) entity.show = true;
|
||||
}
|
||||
|
||||
@@ -343,8 +407,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
return null;
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.__gevGizmoDebug) {
|
||||
console.debug('[CCTV:gizmo] drillPick @', windowPosition?.x, windowPosition?.y, '→',
|
||||
JSON.stringify(results.map((r) => String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name))));
|
||||
console.debug(
|
||||
'[CCTV:gizmo] drillPick @',
|
||||
windowPosition?.x,
|
||||
windowPosition?.y,
|
||||
'→',
|
||||
JSON.stringify(
|
||||
results.map((r) =>
|
||||
String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const picked of results) {
|
||||
const part = gizmoPartFrom(picked);
|
||||
@@ -379,7 +452,8 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
for (const [name, entity] of entities.entries()) {
|
||||
if (!entity.point) continue;
|
||||
const hot = hoveredId === `${GIZMO_ID_PREFIX}${name}`;
|
||||
entity.point.pixelSize = (name === 'handle-range' ? 13 : 10) + (hot ? 4 : 0);
|
||||
entity.point.pixelSize =
|
||||
(name === 'handle-range' ? 13 : 10) + (hot ? 4 : 0);
|
||||
}
|
||||
setCursor(hoveredId ? 'grab' : '');
|
||||
}
|
||||
@@ -397,7 +471,11 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
const mount = Cesium.Cartesian3.clone(positions.mount);
|
||||
const capCenter = Cesium.Cartesian3.clone(positions.capCenter);
|
||||
const axes = enuAxes(mount);
|
||||
const { forwardHoriz, right, view } = viewAxesFor(camera.headingDeg, camera.pitchDeg, axes);
|
||||
const { forwardHoriz, right, view } = viewAxesFor(
|
||||
camera.headingDeg,
|
||||
camera.pitchDeg,
|
||||
axes,
|
||||
);
|
||||
const startCal = { ...camera.calibration };
|
||||
const basePose = { ...camera.basePose };
|
||||
const state = {
|
||||
@@ -405,7 +483,15 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
// mid-drag active-camera switch (voice select / auto-hop) onto a
|
||||
// different camera — its basePose makes the captured offsets wrong.
|
||||
record,
|
||||
part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view,
|
||||
part,
|
||||
startCal,
|
||||
basePose,
|
||||
mount,
|
||||
capCenter,
|
||||
axes,
|
||||
forwardHoriz,
|
||||
right,
|
||||
view,
|
||||
effectiveRangeM: geometry.rangeM,
|
||||
startAngle: null,
|
||||
startParam: null,
|
||||
@@ -419,8 +505,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
|
||||
if (!hit) return false;
|
||||
state.startAngle = ringAngle(hit, mount, forwardHoriz, axes.up); // 0 = level, + = up
|
||||
} else if (part === 'move-east' || part === 'move-north' || part === 'move-up') {
|
||||
const dir = part === 'move-east' ? axes.east : part === 'move-north' ? axes.north : axes.up;
|
||||
} else if (
|
||||
part === 'move-east' ||
|
||||
part === 'move-north' ||
|
||||
part === 'move-up'
|
||||
) {
|
||||
const dir =
|
||||
part === 'move-east'
|
||||
? axes.east
|
||||
: part === 'move-north'
|
||||
? axes.north
|
||||
: axes.up;
|
||||
state.axisDir = dir;
|
||||
const t = closestParamOnAxis(ray.origin, ray.direction, mount, dir);
|
||||
if (t === null) return false;
|
||||
@@ -448,26 +543,49 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
function dragPatch(windowPosition) {
|
||||
const ray = pickRay(windowPosition);
|
||||
if (!ray || !drag) return null;
|
||||
const { part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view } = drag;
|
||||
const {
|
||||
part,
|
||||
startCal,
|
||||
basePose,
|
||||
mount,
|
||||
capCenter,
|
||||
axes,
|
||||
forwardHoriz,
|
||||
right,
|
||||
view,
|
||||
} = drag;
|
||||
|
||||
if (part === 'ring-heading') {
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, axes.up);
|
||||
if (!hit) return null;
|
||||
const angle = ringAngle(hit, mount, axes.north, axes.east);
|
||||
return { headingDeg: startCal.headingDeg + toDeg(signedAngleDelta(drag.startAngle, angle)) };
|
||||
return {
|
||||
headingDeg:
|
||||
startCal.headingDeg + toDeg(signedAngleDelta(drag.startAngle, angle)),
|
||||
};
|
||||
}
|
||||
if (part === 'ring-pitch') {
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
|
||||
if (!hit) return null;
|
||||
const angle = ringAngle(hit, mount, forwardHoriz, axes.up);
|
||||
return { pitchDeg: startCal.pitchDeg + toDeg(signedAngleDelta(drag.startAngle, angle)) };
|
||||
return {
|
||||
pitchDeg:
|
||||
startCal.pitchDeg + toDeg(signedAngleDelta(drag.startAngle, angle)),
|
||||
};
|
||||
}
|
||||
if (part === 'move-east' || part === 'move-north' || part === 'move-up') {
|
||||
const t = closestParamOnAxis(ray.origin, ray.direction, mount, drag.axisDir);
|
||||
const t = closestParamOnAxis(
|
||||
ray.origin,
|
||||
ray.direction,
|
||||
mount,
|
||||
drag.axisDir,
|
||||
);
|
||||
if (t === null) return null;
|
||||
const delta = t - drag.startParam;
|
||||
if (part === 'move-east') return { offsetEastM: startCal.offsetEastM + delta };
|
||||
if (part === 'move-north') return { offsetNorthM: startCal.offsetNorthM + delta };
|
||||
if (part === 'move-east')
|
||||
return { offsetEastM: startCal.offsetEastM + delta };
|
||||
if (part === 'move-north')
|
||||
return { offsetNorthM: startCal.offsetNorthM + delta };
|
||||
return { heightM: startCal.heightM + delta };
|
||||
}
|
||||
if (part === 'handle-range') {
|
||||
@@ -478,11 +596,16 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
if (part === 'handle-fov-l' || part === 'handle-fov-r') {
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, capCenter, view);
|
||||
if (!hit) return null;
|
||||
const lateral = Math.abs(Cesium.Cartesian3.dot(
|
||||
Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()), right
|
||||
));
|
||||
const lateral = Math.abs(
|
||||
Cesium.Cartesian3.dot(
|
||||
Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()),
|
||||
right,
|
||||
),
|
||||
);
|
||||
const halfW = Math.max(0.5, lateral);
|
||||
const fovDeg = toDeg(2 * Math.atan(halfW / Math.max(1, drag.effectiveRangeM)));
|
||||
const fovDeg = toDeg(
|
||||
2 * Math.atan(halfW / Math.max(1, drag.effectiveRangeM)),
|
||||
);
|
||||
return { fovDeg: fovDeg - basePose.fovDeg };
|
||||
}
|
||||
return null;
|
||||
@@ -531,7 +654,13 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
|
||||
return;
|
||||
}
|
||||
const patch = dragPatch(event.endPosition);
|
||||
debugLog('dragPatch', drag.part, event.endPosition, '→', patch ? JSON.stringify(patch) : null);
|
||||
debugLog(
|
||||
'dragPatch',
|
||||
drag.part,
|
||||
event.endPosition,
|
||||
'→',
|
||||
patch ? JSON.stringify(patch) : null,
|
||||
);
|
||||
if (patch) applyPatch(patch, drag.record);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function cameraHue(index) {
|
||||
* @returns {{fill: Cesium.Color, fillActive: Cesium.Color, line: Cesium.Color, lineActive: Cesium.Color}}
|
||||
*/
|
||||
export function viewshedColors(hueDeg) {
|
||||
const hue = ((Number(hueDeg) % 360) + 360) % 360 / 360;
|
||||
const hue = (((Number(hueDeg) % 360) + 360) % 360) / 360;
|
||||
return {
|
||||
fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
|
||||
fillActive: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE),
|
||||
@@ -66,7 +66,13 @@ export function viewshedColors(hueDeg) {
|
||||
* @returns {{positions: Float64Array, indices: Uint16Array}}
|
||||
*/
|
||||
export function frustumVolumeGeometryData(positions) {
|
||||
const pts = [positions.mount, positions.tl, positions.tr, positions.br, positions.bl];
|
||||
const pts = [
|
||||
positions.mount,
|
||||
positions.tl,
|
||||
positions.tr,
|
||||
positions.br,
|
||||
positions.bl,
|
||||
];
|
||||
const flat = new Float64Array(15);
|
||||
pts.forEach((p, i) => {
|
||||
flat[i * 3] = p.x;
|
||||
@@ -75,12 +81,7 @@ export function frustumVolumeGeometryData(positions) {
|
||||
});
|
||||
// apex=0, tl=1, tr=2, br=3, bl=4 — 4 side faces + far cap (2 triangles).
|
||||
const indices = new Uint16Array([
|
||||
0, 1, 2,
|
||||
0, 2, 3,
|
||||
0, 3, 4,
|
||||
0, 4, 1,
|
||||
1, 2, 3,
|
||||
1, 3, 4,
|
||||
0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1, 1, 2, 3, 1, 3, 4,
|
||||
]);
|
||||
return { positions: flat, indices };
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function nextCockpitNearContacts(
|
||||
) {
|
||||
const previous = previousIds instanceof Set ? previousIds : new Set();
|
||||
const addSq = Math.max(0, Number(addDistanceM) || 0) ** 2;
|
||||
const keepSq = Math.max(addSq, (Math.max(0, Number(keepDistanceM) || 0) ** 2));
|
||||
const keepSq = Math.max(addSq, Math.max(0, Number(keepDistanceM) || 0) ** 2);
|
||||
const next = new Set();
|
||||
|
||||
for (const [id, distanceSq] of distancesSquared || []) {
|
||||
@@ -32,4 +32,3 @@ export function nextCockpitNearContacts(
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,9 @@ export const CONTACT_MATCH_TIER = Object.freeze({
|
||||
* @returns {string} Canonical form, or '' when there is nothing to compare.
|
||||
*/
|
||||
export function canonicalizeContactId(value) {
|
||||
return String(value ?? '').replace(/[^a-z0-9]/gi, '').toUpperCase();
|
||||
return String(value ?? '')
|
||||
.replace(/[^a-z0-9]/gi, '')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,16 +59,31 @@ export function canonicalizeContactId(value) {
|
||||
* @param {string} [params.registration] Contact's registration, if any.
|
||||
* @returns {number} A {@link CONTACT_MATCH_TIER} value; `NONE` when no match.
|
||||
*/
|
||||
export function rankContactMatch({ query, hex = '', callsign = '', registration = '' }) {
|
||||
const rawQuery = String(query ?? '').trim().toLowerCase();
|
||||
export function rankContactMatch({
|
||||
query,
|
||||
hex = '',
|
||||
callsign = '',
|
||||
registration = '',
|
||||
}) {
|
||||
const rawQuery = String(query ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!rawQuery) return CONTACT_MATCH_TIER.NONE;
|
||||
|
||||
if (String(hex ?? '').trim().toLowerCase() === rawQuery) return CONTACT_MATCH_TIER.HEX_EXACT;
|
||||
if (
|
||||
String(hex ?? '')
|
||||
.trim()
|
||||
.toLowerCase() === rawQuery
|
||||
)
|
||||
return CONTACT_MATCH_TIER.HEX_EXACT;
|
||||
|
||||
const callsignText = String(callsign ?? '').trim().toLowerCase();
|
||||
const callsignText = String(callsign ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
// Callsigns carry no separators, so they compare raw — canonicalizing them
|
||||
// would silently widen the long-standing callsign contract.
|
||||
if (callsignText && callsignText === rawQuery) return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
|
||||
if (callsignText && callsignText === rawQuery)
|
||||
return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
|
||||
|
||||
const canonicalQuery = canonicalizeContactId(rawQuery);
|
||||
const canonicalRegistration = canonicalizeContactId(registration);
|
||||
@@ -74,11 +91,13 @@ export function rankContactMatch({ query, hex = '', callsign = '', registration
|
||||
return CONTACT_MATCH_TIER.REGISTRATION_EXACT;
|
||||
}
|
||||
|
||||
if (callsignText && callsignText.startsWith(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
|
||||
if (callsignText && callsignText.startsWith(rawQuery))
|
||||
return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
|
||||
if (canonicalQuery && canonicalRegistration.startsWith(canonicalQuery)) {
|
||||
return CONTACT_MATCH_TIER.REGISTRATION_PREFIX;
|
||||
}
|
||||
if (callsignText && callsignText.includes(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
|
||||
if (callsignText && callsignText.includes(rawQuery))
|
||||
return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
|
||||
if (canonicalQuery && canonicalRegistration.includes(canonicalQuery)) {
|
||||
return CONTACT_MATCH_TIER.REGISTRATION_SUBSTRING;
|
||||
}
|
||||
|
||||
+23
-11
@@ -48,7 +48,9 @@ export function selectEntityContext(entity) {
|
||||
store.selectedEntityId = contextId;
|
||||
store.selectedAt = Date.now();
|
||||
const record = store.entities.get(contextId);
|
||||
window.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: record }));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:entity-selected', { detail: record }),
|
||||
);
|
||||
return record;
|
||||
}
|
||||
|
||||
@@ -78,7 +80,8 @@ export function selectTrackedSubjectContext(metadata) {
|
||||
const store = getContextStore();
|
||||
const id = String(metadata.id);
|
||||
for (const [key, record] of store.entities) {
|
||||
if (record?.layerId === metadata.layerId && key !== id) store.entities.delete(key);
|
||||
if (record?.layerId === metadata.layerId && key !== id)
|
||||
store.entities.delete(key);
|
||||
}
|
||||
// Reuse the existing carrier so a per-poll refresh does not churn identity.
|
||||
const carrier = store.entities.get(id)?.entity || { __gevContextId: id };
|
||||
@@ -146,16 +149,21 @@ export function getSelectedEntityContext({ dataManager = null } = {}) {
|
||||
* rather than being deselected. Readouts that stay on screen hold their
|
||||
* last-known values for an eviction and only tear down on a deliberate clear.
|
||||
*/
|
||||
export function clearSelectedEntityContextForLayer(layerId, { evicted = false } = {}) {
|
||||
export function clearSelectedEntityContextForLayer(
|
||||
layerId,
|
||||
{ evicted = false } = {},
|
||||
) {
|
||||
const store = getContextStore();
|
||||
if (!store.selectedEntityId) return;
|
||||
const record = store.entities.get(store.selectedEntityId);
|
||||
if (record?.layerId === layerId) {
|
||||
store.selectedEntityId = null;
|
||||
store.selectedAt = null;
|
||||
window.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' },
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,16 +174,19 @@ export function clearSelectedEntityContextForLayer(layerId, { evicted = false }
|
||||
export function removeEntityContextsForLayer(layerId, { retainIds } = {}) {
|
||||
const store = getContextStore();
|
||||
for (const [id, record] of store.entities) {
|
||||
if (record?.layerId === layerId && !retainIds?.has(id)) store.entities.delete(id);
|
||||
if (record?.layerId === layerId && !retainIds?.has(id))
|
||||
store.entities.delete(id);
|
||||
}
|
||||
if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) {
|
||||
store.selectedEntityId = null;
|
||||
store.selectedAt = null;
|
||||
// A viewport refresh dropped the record out from under the selection —
|
||||
// the user did not deselect anything.
|
||||
window.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: 'evicted' },
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: 'evicted' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +194,7 @@ export function isContextRecordActive(record, dataManager = null) {
|
||||
if (!record) return false;
|
||||
if (record.entity?.show === false) return false;
|
||||
if (record.dataSource && record.dataSource.show === false) return false;
|
||||
if (dataManager && record.layerId && !dataManager.isEnabled(record.layerId)) return false;
|
||||
if (dataManager && record.layerId && !dataManager.isEnabled(record.layerId))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
@@ -185,8 +185,7 @@ export const DATA_CREDITS = [
|
||||
},
|
||||
{
|
||||
key: 'gtfs-rt',
|
||||
html:
|
||||
'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
|
||||
html: 'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
|
||||
},
|
||||
{
|
||||
key: 'radio-browser',
|
||||
@@ -299,11 +298,12 @@ export const NATURAL_EARTH_CREDIT = {
|
||||
* @returns {{ key: string, html: string }}
|
||||
*/
|
||||
export function transitFeedCredit(feed) {
|
||||
const escape = (text) => String(text)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
const escape = (text) =>
|
||||
String(text)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
return {
|
||||
key: `transit-${feed.id}`,
|
||||
html:
|
||||
|
||||
+219
-70
@@ -279,7 +279,10 @@ let _cockpitModeListener = null;
|
||||
*/
|
||||
export function initDetection(viewer, layers, onModeChange) {
|
||||
if (_cockpitModeListener && typeof window !== 'undefined') {
|
||||
window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener);
|
||||
window.removeEventListener(
|
||||
'gev:cockpit-mode-changed',
|
||||
_cockpitModeListener,
|
||||
);
|
||||
}
|
||||
_hostLane?.unregister?.();
|
||||
_calloutLane?.unregister?.();
|
||||
@@ -323,7 +326,10 @@ export function initDetection(viewer, layers, onModeChange) {
|
||||
/** Release the host lane and all retained detection runtime state. */
|
||||
export function destroyDetection() {
|
||||
if (_cockpitModeListener && typeof window !== 'undefined') {
|
||||
window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener);
|
||||
window.removeEventListener(
|
||||
'gev:cockpit-mode-changed',
|
||||
_cockpitModeListener,
|
||||
);
|
||||
}
|
||||
_cockpitModeListener = null;
|
||||
_cockpitActive = false;
|
||||
@@ -397,7 +403,8 @@ export function setMode(modeLabel) {
|
||||
_mode = MODE_OFF;
|
||||
} else {
|
||||
const requestedDensity = defaultDensityForProfile(target);
|
||||
if (profileForDensity(_densityPct) !== target) _densityPct = requestedDensity;
|
||||
if (profileForDensity(_densityPct) !== target)
|
||||
_densityPct = requestedDensity;
|
||||
_mode = _modeForDensity();
|
||||
_lastNonOffMode = _mode;
|
||||
}
|
||||
@@ -462,7 +469,10 @@ export function isDetectionSuspended() {
|
||||
*/
|
||||
export function setDetectionTuning(options = {}) {
|
||||
let modeChanged = false;
|
||||
if (typeof options.densityPct === 'number' && Number.isFinite(options.densityPct)) {
|
||||
if (
|
||||
typeof options.densityPct === 'number' &&
|
||||
Number.isFinite(options.densityPct)
|
||||
) {
|
||||
const nextDensity = canonicalizeDensity(options.densityPct);
|
||||
if (nextDensity !== _densityPct) {
|
||||
_densityPct = nextDensity;
|
||||
@@ -477,7 +487,10 @@ export function setDetectionTuning(options = {}) {
|
||||
}
|
||||
}
|
||||
if (options.allocationStrategy != null) {
|
||||
const nextStrategy = normalizeAllocationStrategy(options.allocationStrategy, _allocationStrategy);
|
||||
const nextStrategy = normalizeAllocationStrategy(
|
||||
options.allocationStrategy,
|
||||
_allocationStrategy,
|
||||
);
|
||||
if (nextStrategy !== _allocationStrategy) {
|
||||
_allocationStrategy = nextStrategy;
|
||||
_labelSolveDirty = true;
|
||||
@@ -526,7 +539,9 @@ export function getDetectionDiagnostics() {
|
||||
const result = JSON.parse(JSON.stringify(_lastDiagnostics));
|
||||
// Readback only: expose painted plates so pixel QA can exclude occluded
|
||||
// sprites/strokes. Nothing is copied on the production paint path.
|
||||
result.calloutRects = _calloutPool.slice(0, _calloutCount).map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
|
||||
result.calloutRects = _calloutPool
|
||||
.slice(0, _calloutCount)
|
||||
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -535,19 +550,30 @@ function _publishDiagnostics() {
|
||||
const dataset = _hostCanvas.dataset;
|
||||
dataset.profile = _lastDiagnostics.profile || 'OFF';
|
||||
dataset.densityPct = String(_lastDiagnostics.densityPct ?? _densityPct);
|
||||
dataset.allocationStrategy = _lastDiagnostics.allocationStrategy || _allocationStrategy;
|
||||
dataset.allocationStrategy =
|
||||
_lastDiagnostics.allocationStrategy || _allocationStrategy;
|
||||
dataset.solveRevision = String(_lastDiagnostics.solveRevision ?? 0);
|
||||
dataset.collectiveLabelBudget = String(_lastDiagnostics.collectiveLabelBudget ?? 0);
|
||||
dataset.collectiveLabelBudget = String(
|
||||
_lastDiagnostics.collectiveLabelBudget ?? 0,
|
||||
);
|
||||
dataset.labelsByLayer = JSON.stringify(_lastDiagnostics.labelsByLayer || {});
|
||||
dataset.entitlementByLayer = JSON.stringify(_lastDiagnostics.entitlementByLayer || {});
|
||||
dataset.entitlementByLayer = JSON.stringify(
|
||||
_lastDiagnostics.entitlementByLayer || {},
|
||||
);
|
||||
dataset.labeledKeys = JSON.stringify(_lastDiagnostics.labeledKeys || []);
|
||||
dataset.labelAlphaByKey = JSON.stringify(_lastDiagnostics.labelAlphaByKey || {});
|
||||
dataset.bracketOpacityCounts = JSON.stringify(_lastDiagnostics.bracketOpacityCounts || {});
|
||||
dataset.labelAlphaByKey = JSON.stringify(
|
||||
_lastDiagnostics.labelAlphaByKey || {},
|
||||
);
|
||||
dataset.bracketOpacityCounts = JSON.stringify(
|
||||
_lastDiagnostics.bracketOpacityCounts || {},
|
||||
);
|
||||
dataset.keyholeRadius = String(_lastDiagnostics.keyholeRadius ?? 0);
|
||||
dataset.keyholeFeatherPx = String(_lastDiagnostics.keyholeFeatherPx ?? 0);
|
||||
dataset.demandByLayer = JSON.stringify(_lastDiagnostics.demandByLayer || {});
|
||||
dataset.cohortByLayer = JSON.stringify(_lastDiagnostics.cohortByLayer || {});
|
||||
dataset.placementBuildCount = String(_lastDiagnostics.placementBuildCount ?? 0);
|
||||
dataset.placementBuildCount = String(
|
||||
_lastDiagnostics.placementBuildCount ?? 0,
|
||||
);
|
||||
dataset.observationCount = String(_lastDiagnostics.observationCount ?? 0);
|
||||
dataset.selectedCount = String(_lastDiagnostics.selectedCount ?? 0);
|
||||
dataset.fadingCount = String(_lastDiagnostics.fadingCount ?? 0);
|
||||
@@ -632,7 +658,11 @@ function _applyModeState() {
|
||||
_labelArbiter.clear();
|
||||
_lastLabelSolveAt = 0;
|
||||
_labelSolveDirty = true;
|
||||
_lastSolveSnapshot = { demandByLayer: {}, cohortByLayer: {}, cohortCount: 0 };
|
||||
_lastSolveSnapshot = {
|
||||
demandByLayer: {},
|
||||
cohortByLayer: {},
|
||||
cohortCount: 0,
|
||||
};
|
||||
} else {
|
||||
_enableTime = _nowMs(); // restart the subtle fade-in on (re)activation
|
||||
_syncSurfaceVisibility();
|
||||
@@ -665,8 +695,10 @@ function _shouldPaintDetectionLane(frame) {
|
||||
});
|
||||
if (decision.skip) {
|
||||
_throttleSkipCount++;
|
||||
if (_lastDiagnostics) _lastDiagnostics.throttleSkipCount = _throttleSkipCount;
|
||||
if (decision.requestFollowUp) governorRequestRender('detection-paint-skipped');
|
||||
if (_lastDiagnostics)
|
||||
_lastDiagnostics.throttleSkipCount = _throttleSkipCount;
|
||||
if (decision.requestFollowUp)
|
||||
governorRequestRender('detection-paint-skipped');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -687,8 +719,13 @@ function _paintDetectionLane(frame) {
|
||||
_labelSolveDirty = true;
|
||||
}
|
||||
const start = performance.now();
|
||||
const result = _drawOverlay(frame)
|
||||
|| { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
|
||||
const result = _drawOverlay(frame) || {
|
||||
didSolve: false,
|
||||
solveMs: 0,
|
||||
fadingCount: 0,
|
||||
animatingCount: 0,
|
||||
solvePending: false,
|
||||
};
|
||||
_lastRenderMs = performance.now() - start;
|
||||
_lastSolveMs = result.solveMs || 0;
|
||||
_lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs);
|
||||
@@ -705,17 +742,19 @@ function _paintDetectionLane(frame) {
|
||||
// exactly what makes it safe where the old blanket hold was not. On a parked
|
||||
// scene with nothing animating it asks for nothing, and the governor stays
|
||||
// idle. See `detectionRenderDemand.js`.
|
||||
if (detectionNeedsFollowUpFrame({
|
||||
active: _mode !== MODE_OFF && !_suspended,
|
||||
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here
|
||||
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at
|
||||
// 220 ms, and the settled alpha never painted).
|
||||
nowMs: Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(),
|
||||
enabledAtMs: _enableTime,
|
||||
fadeMs: FADE_MS,
|
||||
animatingLabelCount: result.animatingCount || 0,
|
||||
solvePending: result.solvePending === true,
|
||||
})) {
|
||||
if (
|
||||
detectionNeedsFollowUpFrame({
|
||||
active: _mode !== MODE_OFF && !_suspended,
|
||||
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here
|
||||
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at
|
||||
// 220 ms, and the settled alpha never painted).
|
||||
nowMs: Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(),
|
||||
enabledAtMs: _enableTime,
|
||||
fadeMs: FADE_MS,
|
||||
animatingLabelCount: result.animatingCount || 0,
|
||||
solvePending: result.solvePending === true,
|
||||
})
|
||||
) {
|
||||
governorRequestRender('detection-animation');
|
||||
}
|
||||
}
|
||||
@@ -804,7 +843,8 @@ function _drawModeBanner(visibleCount, sampledCount) {
|
||||
if (!_debugBanner) return;
|
||||
const mode = MODE_LABELS[_mode];
|
||||
const paused = _suspended ? ` PAUSED:${_suspendReason || 'transition'}` : '';
|
||||
const allocation = _allocationStrategy === ALLOCATION_ELASTIC ? 'ELASTIC' : 'WEIGHTED';
|
||||
const allocation =
|
||||
_allocationStrategy === ALLOCATION_ELASTIC ? 'ELASTIC' : 'WEIGHTED';
|
||||
const text = `${mode} VIS:${visibleCount} SRC:${sampledCount} DENS:${_densityPct}% ${allocation} ${_lastRenderMs.toFixed(1)}ms${paused}`;
|
||||
_ctx.fillStyle = _theme.labelBg;
|
||||
_ctx.fillRect(12, 12, Math.max(180, _ctx.measureText(text).width + 12), 16);
|
||||
@@ -900,16 +940,27 @@ function _buildLabelPlacements(
|
||||
const placements = [];
|
||||
for (const placement of raw) {
|
||||
const { cardX, cardY } = placement;
|
||||
if (cardX < margin || cardY < margin || cardX + card.w > width - margin || cardY + card.h > height - margin) {
|
||||
if (
|
||||
cardX < margin ||
|
||||
cardY < margin ||
|
||||
cardX + card.w > width - margin ||
|
||||
cardY + card.h > height - margin
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const cardRect = { x: cardX, y: cardY, w: card.w, h: card.h };
|
||||
if (rectIntersectsAny(cardRect, occlusionRects)) continue;
|
||||
const leadToX = placement.leadToSide.endsWith('E') ? cardX + card.w : cardX;
|
||||
const leadToY = placement.leadToSide.startsWith('S') ? cardY + card.h : cardY;
|
||||
const leadToY = placement.leadToSide.startsWith('S')
|
||||
? cardY + card.h
|
||||
: cardY;
|
||||
const centerX = cardX + card.w * 0.5;
|
||||
const centerY = cardY + card.h * 0.5;
|
||||
const radialAlpha = keyholeLabelAlphaFromGeometry(centerX, centerY, keyhole);
|
||||
const radialAlpha = keyholeLabelAlphaFromGeometry(
|
||||
centerX,
|
||||
centerY,
|
||||
keyhole,
|
||||
);
|
||||
if (radialAlpha <= 0) continue;
|
||||
placements.push({
|
||||
...placement,
|
||||
@@ -940,18 +991,37 @@ function _buildLabelPlacements(
|
||||
*/
|
||||
function _stashCallout(entry, acquireFade, keyhole) {
|
||||
const { candidate, placement, temporalAlpha } = entry;
|
||||
const radialAlpha = keyholeLabelAlphaFromGeometry(placement.centerX, placement.centerY, keyhole);
|
||||
const radialAlpha = keyholeLabelAlphaFromGeometry(
|
||||
placement.centerX,
|
||||
placement.centerY,
|
||||
keyhole,
|
||||
);
|
||||
const alpha = acquireFade * temporalAlpha * radialAlpha;
|
||||
if (alpha <= 0.001) return;
|
||||
|
||||
let row = _calloutPool[_calloutCount];
|
||||
if (!row) {
|
||||
row = {
|
||||
x: 0, y: 0, w: 0, h: 0,
|
||||
primaryX: 0, microX: 0, baseline: 0,
|
||||
leadFromX: 0, leadFromY: 0, leadToX: 0, leadToY: 0,
|
||||
plate: '', plateScale: 1, accent: '', label: '', primary: '', micro: '',
|
||||
font: FONT, microFont: MICRO_FONT, alpha: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 0,
|
||||
h: 0,
|
||||
primaryX: 0,
|
||||
microX: 0,
|
||||
baseline: 0,
|
||||
leadFromX: 0,
|
||||
leadFromY: 0,
|
||||
leadToX: 0,
|
||||
leadToY: 0,
|
||||
plate: '',
|
||||
plateScale: 1,
|
||||
accent: '',
|
||||
label: '',
|
||||
primary: '',
|
||||
micro: '',
|
||||
font: FONT,
|
||||
microFont: MICRO_FONT,
|
||||
alpha: 1,
|
||||
};
|
||||
_calloutPool[_calloutCount] = row;
|
||||
}
|
||||
@@ -996,7 +1066,13 @@ function _paintCalloutLane(frame) {
|
||||
if (!ctx) return;
|
||||
for (const bands of _transitBracketPaths.values()) {
|
||||
for (const entry of bands) {
|
||||
if (entry) paintTransitBracket(ctx, entry.path, entry.color, entry.alpha * _transitBracketAlpha);
|
||||
if (entry)
|
||||
paintTransitBracket(
|
||||
ctx,
|
||||
entry.path,
|
||||
entry.color,
|
||||
entry.alpha * _transitBracketAlpha,
|
||||
);
|
||||
}
|
||||
}
|
||||
ctx.textAlign = 'left';
|
||||
@@ -1021,7 +1097,14 @@ function _plateScaleForBackdrop(skyFactor) {
|
||||
}
|
||||
|
||||
/** Materialize one bounded rich-callout candidate for the existing arbiter. */
|
||||
function _materializeCandidate(obj, width, height, keyhole, occlusionRects, cameraPosition) {
|
||||
function _materializeCandidate(
|
||||
obj,
|
||||
width,
|
||||
height,
|
||||
keyhole,
|
||||
occlusionRects,
|
||||
cameraPosition,
|
||||
) {
|
||||
const primary = obj._candidatePrimary;
|
||||
const micro = obj._candidateMicro;
|
||||
const card = measureTrackLabel(primary, micro, _charWidth);
|
||||
@@ -1042,7 +1125,10 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
|
||||
for (const placement of placements) {
|
||||
centerDistance = Math.min(
|
||||
centerDistance,
|
||||
Math.hypot(placement.centerX - keyhole.centerX, placement.centerY - keyhole.centerY),
|
||||
Math.hypot(
|
||||
placement.centerX - keyhole.centerX,
|
||||
placement.centerY - keyhole.centerY,
|
||||
),
|
||||
);
|
||||
keyholeAlpha = Math.max(keyholeAlpha, placement.keyholeAlpha);
|
||||
}
|
||||
@@ -1063,7 +1149,9 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
|
||||
// Costed here rather than in the object sweep: the sweep walks every
|
||||
// observation in view, while this runs only for callouts that actually
|
||||
// placed — a budgeted handful per frame.
|
||||
plateScale: _plateScaleForBackdrop(skyBackdropFactor(cameraPosition, obj.position)),
|
||||
plateScale: _plateScaleForBackdrop(
|
||||
skyBackdropFactor(cameraPosition, obj.position),
|
||||
),
|
||||
primary,
|
||||
micro,
|
||||
hasMicro: card.hasMicro,
|
||||
@@ -1079,7 +1167,13 @@ function _drawOverlay(frame) {
|
||||
_transitBracketPaths.clear();
|
||||
const { width, height } = frame;
|
||||
if (!_ctx || width <= 0 || height <= 0) {
|
||||
return { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
|
||||
return {
|
||||
didSolve: false,
|
||||
solveMs: 0,
|
||||
fadingCount: 0,
|
||||
animatingCount: 0,
|
||||
solvePending: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ONE timestamp per frame, monotonic, shared with the demand policy below and
|
||||
@@ -1087,12 +1181,16 @@ function _drawOverlay(frame) {
|
||||
// animation's final state is the same frame that ends its demand.
|
||||
const now = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
|
||||
const bracketPresentationOpacity = detectionBracketOpacity(_cockpitActive);
|
||||
const shouldSolve = _labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
|
||||
const shouldSolve =
|
||||
_labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
|
||||
const calloutOcclusionRects = frame.uiRects;
|
||||
const selectedIdentities = shouldSolve
|
||||
? _labelArbiter.liveIdentities({ includeFading: false, now })
|
||||
: null;
|
||||
const renderIdentities = _labelArbiter.liveIdentities({ includeFading: true, now });
|
||||
const renderIdentities = _labelArbiter.liveIdentities({
|
||||
includeFading: true,
|
||||
now,
|
||||
});
|
||||
const objects = _collectDetectableObjects();
|
||||
const sampledCount = objects.length;
|
||||
if (objects.length === 0) {
|
||||
@@ -1116,7 +1214,9 @@ function _drawOverlay(frame) {
|
||||
profile: MODE_LABELS[_mode],
|
||||
densityPct: _densityPct,
|
||||
allocationStrategy: _allocationStrategy,
|
||||
viewScale: viewScaleForAltitude(_viewer?.camera?.positionCartographic?.height),
|
||||
viewScale: viewScaleForAltitude(
|
||||
_viewer?.camera?.positionCartographic?.height,
|
||||
),
|
||||
candidateCount: 0,
|
||||
observationCount: 0,
|
||||
visibleCount: 0,
|
||||
@@ -1144,7 +1244,13 @@ function _drawOverlay(frame) {
|
||||
// whole mechanism exists to remove.
|
||||
_lastLabelSolveAt = now;
|
||||
_labelSolveDirty = false;
|
||||
return { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
|
||||
return {
|
||||
didSolve: false,
|
||||
solveMs: 0,
|
||||
fadingCount: 0,
|
||||
animatingCount: 0,
|
||||
solvePending: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Horizon culling, keyhole geometry, and camera transforms are shared with
|
||||
@@ -1179,7 +1285,10 @@ function _drawOverlay(frame) {
|
||||
const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
|
||||
const bracketPaths = new Map();
|
||||
const pathFor = (map, color, alpha) => {
|
||||
const band = Math.max(1, Math.min(BRACKET_ALPHA_STEPS, Math.ceil(alpha * BRACKET_ALPHA_STEPS)));
|
||||
const band = Math.max(
|
||||
1,
|
||||
Math.min(BRACKET_ALPHA_STEPS, Math.ceil(alpha * BRACKET_ALPHA_STEPS)),
|
||||
);
|
||||
let bands = map.get(color);
|
||||
if (!bands) {
|
||||
bands = new Array(BRACKET_ALPHA_STEPS + 1);
|
||||
@@ -1217,8 +1326,10 @@ function _drawOverlay(frame) {
|
||||
const clipW = vp3 * px + vp7 * py + vp11 * pz + vp15;
|
||||
if (clipW <= 0) continue;
|
||||
const invW = 1 / clipW;
|
||||
const sx = ((vp0 * px + vp4 * py + vp8 * pz + vp12) * invW * 0.5 + 0.5) * width;
|
||||
const sy = (0.5 - (vp1 * px + vp5 * py + vp9 * pz + vp13) * invW * 0.5) * height;
|
||||
const sx =
|
||||
((vp0 * px + vp4 * py + vp8 * pz + vp12) * invW * 0.5 + 0.5) * width;
|
||||
const sy =
|
||||
(0.5 - (vp1 * px + vp5 * py + vp9 * pz + vp13) * invW * 0.5) * height;
|
||||
|
||||
// Tracked objects (skipLabel) get larger boxes. AIR reticles scale with the
|
||||
// plane's on-screen size (same scaleByDistance curve as the billboards) so
|
||||
@@ -1229,7 +1340,10 @@ function _drawOverlay(frame) {
|
||||
if (obj.type === 'AIR') {
|
||||
const bscale = nearFarScale(
|
||||
Cesium.Cartesian3.distance(camPos, obj.position),
|
||||
BILL_NEAR, BILL_NEAR_SCALE, BILL_FAR, BILL_FAR_SCALE,
|
||||
BILL_NEAR,
|
||||
BILL_NEAR_SCALE,
|
||||
BILL_FAR,
|
||||
BILL_FAR_SCALE,
|
||||
);
|
||||
halfW = _clamp((isTracked ? 14 : 9) * bscale, 7, 48);
|
||||
halfH = _clamp((isTracked ? 11 : 7) * bscale, 5, 38);
|
||||
@@ -1241,18 +1355,34 @@ function _drawOverlay(frame) {
|
||||
halfH = _mode === MODE_DENSE ? (isTracked ? 22 : 7) : 10;
|
||||
}
|
||||
// Viewport bounds check with padding
|
||||
if (sx < -halfW || sx > width + halfW || sy < -halfH || sy > height + halfH) continue;
|
||||
if (sx < -halfW || sx > width + halfW || sy < -halfH || sy > height + halfH)
|
||||
continue;
|
||||
|
||||
// Tier color drives the bracket, accent bar, and leader line for this object.
|
||||
// The bracket follows the same linear radial keyhole fade as its callout.
|
||||
const color = colorFor(resolveTier(obj));
|
||||
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
|
||||
const bracketAlpha = detectionBracketAlpha(obj.type, keyholeAlpha, keyholeOutsideOpacity);
|
||||
const bracketAlpha = detectionBracketAlpha(
|
||||
obj.type,
|
||||
keyholeAlpha,
|
||||
keyholeOutsideOpacity,
|
||||
);
|
||||
if (bracketAlpha > 0) {
|
||||
const transit = obj.tier?.startsWith('transit_');
|
||||
(transit ? appendTransitBracket : appendCornerBracket)(pathFor(transit ? _transitBracketPaths : bracketPaths, color, bracketAlpha), sx, sy, halfW, halfH);
|
||||
(transit ? appendTransitBracket : appendCornerBracket)(
|
||||
pathFor(
|
||||
transit ? _transitBracketPaths : bracketPaths,
|
||||
color,
|
||||
bracketAlpha,
|
||||
),
|
||||
sx,
|
||||
sy,
|
||||
halfW,
|
||||
halfH,
|
||||
);
|
||||
visibleCount++;
|
||||
if (obj.type === 'AIR') aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
|
||||
if (obj.type === 'AIR')
|
||||
aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
|
||||
if (bracketAlpha >= 1) bracketOpacityCounts.full++;
|
||||
else bracketOpacityCounts.partial++;
|
||||
} else {
|
||||
@@ -1286,8 +1416,10 @@ function _drawOverlay(frame) {
|
||||
|
||||
if (shouldSolve && keyholeAlpha > 0) {
|
||||
demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1);
|
||||
if (!cohortBuilders.has(layerId)) cohortBuilders.set(layerId, new BoundedCohort(256));
|
||||
const incumbent = selectedIdentities?.get(layerId)?.has(sourceId) || false;
|
||||
if (!cohortBuilders.has(layerId))
|
||||
cohortBuilders.set(layerId, new BoundedCohort(256));
|
||||
const incumbent =
|
||||
selectedIdentities?.get(layerId)?.has(sourceId) || false;
|
||||
cohortBuilders.get(layerId).consider(obj, incumbent);
|
||||
}
|
||||
|
||||
@@ -1301,13 +1433,17 @@ function _drawOverlay(frame) {
|
||||
calloutOcclusionRects,
|
||||
camPos,
|
||||
);
|
||||
if (candidate && !candidateMap.has(candidate.key)) candidateMap.set(candidate.key, candidate);
|
||||
if (candidate && !candidateMap.has(candidate.key))
|
||||
candidateMap.set(candidate.key, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
|
||||
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
|
||||
const ambientBudget = Math.max(0, collectiveBudget - Math.min(collectiveBudget, protectedVisibleCount));
|
||||
const ambientBudget = Math.max(
|
||||
0,
|
||||
collectiveBudget - Math.min(collectiveBudget, protectedVisibleCount),
|
||||
);
|
||||
let didSolve = false;
|
||||
let solveMs = 0;
|
||||
if (shouldSolve) {
|
||||
@@ -1320,7 +1456,9 @@ function _drawOverlay(frame) {
|
||||
const solveCandidates = [];
|
||||
const cohortByLayer = {};
|
||||
for (const [layerId, builder] of cohortBuilders) {
|
||||
const cohort = builder.values(cohortCapForQuota(quotas.get(layerId) || 0));
|
||||
const cohort = builder.values(
|
||||
cohortCapForQuota(quotas.get(layerId) || 0),
|
||||
);
|
||||
for (const obj of cohort) {
|
||||
const key = _detectionKey(layerId, obj._cohortSourceId);
|
||||
let candidate = candidateMap.get(key);
|
||||
@@ -1338,7 +1476,9 @@ function _drawOverlay(frame) {
|
||||
}
|
||||
if (candidate) solveCandidates.push(candidate);
|
||||
}
|
||||
cohortByLayer[layerId] = solveCandidates.filter((candidate) => candidate.layerId === layerId).length;
|
||||
cohortByLayer[layerId] = solveCandidates.filter(
|
||||
(candidate) => candidate.layerId === layerId,
|
||||
).length;
|
||||
}
|
||||
const solveStarted = performance.now();
|
||||
_labelArbiter.solve(solveCandidates, {
|
||||
@@ -1419,14 +1559,17 @@ function _drawOverlay(frame) {
|
||||
labelsByLayer: arbiterDiagnostics.labelsByLayer || {},
|
||||
entitlementByLayer: arbiterDiagnostics.quotas || {},
|
||||
labeledKeys: Array.from(_labelArbiter.selectedKeys),
|
||||
labelAlphaByKey: Object.fromEntries(renderEntries.map((entry) => [
|
||||
entry.candidate.key,
|
||||
entry.temporalAlpha * keyholeLabelAlphaFromGeometry(
|
||||
entry.placement.centerX,
|
||||
entry.placement.centerY,
|
||||
keyhole,
|
||||
),
|
||||
])),
|
||||
labelAlphaByKey: Object.fromEntries(
|
||||
renderEntries.map((entry) => [
|
||||
entry.candidate.key,
|
||||
entry.temporalAlpha *
|
||||
keyholeLabelAlphaFromGeometry(
|
||||
entry.placement.centerX,
|
||||
entry.placement.centerY,
|
||||
keyhole,
|
||||
),
|
||||
]),
|
||||
),
|
||||
solveRevision: _labelArbiter.solveRevision,
|
||||
keyholeRadius: keyhole.radius,
|
||||
keyholeFeatherPx: keyhole.featherPx,
|
||||
@@ -1451,5 +1594,11 @@ function _drawOverlay(frame) {
|
||||
_drawModeBanner(visibleCount, sampledCount);
|
||||
// `_labelSolveDirty` surviving a paint means the solve was owed and did not
|
||||
// run — the frame that carried the request cannot be the last one.
|
||||
return { didSolve, solveMs, fadingCount, animatingCount, solvePending: _labelSolveDirty };
|
||||
return {
|
||||
didSolve,
|
||||
solveMs,
|
||||
fadingCount,
|
||||
animatingCount,
|
||||
solvePending: _labelSolveDirty,
|
||||
};
|
||||
}
|
||||
+41
-12
@@ -19,11 +19,14 @@ export function stableIdentityHash(layerId, sourceId) {
|
||||
|
||||
/** Negative means a is a better deterministic contender than b. */
|
||||
export function compareCohortContenders(a, b) {
|
||||
const priorityDelta = (Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
|
||||
const priorityDelta =
|
||||
(Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
|
||||
if (priorityDelta) return priorityDelta;
|
||||
const bandDelta = (Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
|
||||
const bandDelta =
|
||||
(Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
|
||||
if (bandDelta) return bandDelta;
|
||||
const hashDelta = (Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
|
||||
const hashDelta =
|
||||
(Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
|
||||
if (hashDelta) return hashDelta;
|
||||
return String(a?._cohortSourceId).localeCompare(String(b?._cohortSourceId));
|
||||
}
|
||||
@@ -39,8 +42,14 @@ function isWorse(a, b) {
|
||||
*/
|
||||
export class BoundedCohort {
|
||||
constructor(maxSize = DEFAULT_MAX_COHORT, hardMax = DEFAULT_MAX_COHORT) {
|
||||
const ceiling = Math.max(1, Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT));
|
||||
this.maxSize = Math.max(1, Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT)));
|
||||
const ceiling = Math.max(
|
||||
1,
|
||||
Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT),
|
||||
);
|
||||
this.maxSize = Math.max(
|
||||
1,
|
||||
Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT)),
|
||||
);
|
||||
this.incumbents = [];
|
||||
this.heap = [];
|
||||
}
|
||||
@@ -62,11 +71,20 @@ export class BoundedCohort {
|
||||
}
|
||||
|
||||
values(limit = this.maxSize) {
|
||||
const cap = Math.max(0, Math.min(this.maxSize, Math.floor(Number(limit) || 0)));
|
||||
const incumbents = this.incumbents.slice().sort(compareCohortContenders).slice(0, cap);
|
||||
const cap = Math.max(
|
||||
0,
|
||||
Math.min(this.maxSize, Math.floor(Number(limit) || 0)),
|
||||
);
|
||||
const incumbents = this.incumbents
|
||||
.slice()
|
||||
.sort(compareCohortContenders)
|
||||
.slice(0, cap);
|
||||
const remaining = Math.max(0, cap - incumbents.length);
|
||||
if (remaining === 0) return incumbents;
|
||||
const contenders = this.heap.slice().sort(compareCohortContenders).slice(0, remaining);
|
||||
const contenders = this.heap
|
||||
.slice()
|
||||
.sort(compareCohortContenders)
|
||||
.slice(0, remaining);
|
||||
return incumbents.concat(contenders);
|
||||
}
|
||||
|
||||
@@ -75,7 +93,10 @@ export class BoundedCohort {
|
||||
while (current > 0) {
|
||||
const parent = Math.floor((current - 1) / 2);
|
||||
if (!isWorse(this.heap[current], this.heap[parent])) break;
|
||||
[this.heap[current], this.heap[parent]] = [this.heap[parent], this.heap[current]];
|
||||
[this.heap[current], this.heap[parent]] = [
|
||||
this.heap[parent],
|
||||
this.heap[current],
|
||||
];
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
@@ -86,10 +107,18 @@ export class BoundedCohort {
|
||||
const left = current * 2 + 1;
|
||||
const right = left + 1;
|
||||
let worst = current;
|
||||
if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst])) worst = left;
|
||||
if (right < this.heap.length && isWorse(this.heap[right], this.heap[worst])) worst = right;
|
||||
if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst]))
|
||||
worst = left;
|
||||
if (
|
||||
right < this.heap.length &&
|
||||
isWorse(this.heap[right], this.heap[worst])
|
||||
)
|
||||
worst = right;
|
||||
if (worst === current) return;
|
||||
[this.heap[current], this.heap[worst]] = [this.heap[worst], this.heap[current]];
|
||||
[this.heap[current], this.heap[worst]] = [
|
||||
this.heap[worst],
|
||||
this.heap[current],
|
||||
];
|
||||
current = worst;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,11 +211,14 @@ export function measureTrackLabel(primary, micro, charWidth) {
|
||||
*/
|
||||
export function rectIntersectsAny(rect, obstacles, padding = 0) {
|
||||
if (!rect || !Array.isArray(obstacles)) return false;
|
||||
return obstacles.some((obstacle) => obstacle
|
||||
&& rect.x < obstacle.x + obstacle.w + padding
|
||||
&& rect.x + rect.w + padding > obstacle.x
|
||||
&& rect.y < obstacle.y + obstacle.h + padding
|
||||
&& rect.y + rect.h + padding > obstacle.y);
|
||||
return obstacles.some(
|
||||
(obstacle) =>
|
||||
obstacle &&
|
||||
rect.x < obstacle.x + obstacle.w + padding &&
|
||||
rect.x + rect.w + padding > obstacle.x &&
|
||||
rect.y < obstacle.y + obstacle.h + padding &&
|
||||
rect.y + rect.h + padding > obstacle.y,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,7 +241,6 @@ export function nearFarScale(distance, near, nearValue, far, farValue) {
|
||||
return nearValue + t * (farValue - nearValue);
|
||||
}
|
||||
|
||||
|
||||
/** Transit keeps literal mode colour on the normal-composite surface. */
|
||||
export function paintTransitBracket(ctx, path, color, alpha = 1) {
|
||||
ctx.save?.();
|
||||
@@ -255,6 +257,11 @@ export function paintTransitBracket(ctx, path, color, alpha = 1) {
|
||||
|
||||
/** Centre 1.25 px strokes on pixels, so their cores are fully covered. */
|
||||
export function appendTransitBracket(sink, sx, sy, halfW, halfH) {
|
||||
appendCornerBracket(sink, Math.floor(sx) + 0.5, Math.floor(sy) + 0.5,
|
||||
Math.round(halfW), Math.round(halfH));
|
||||
appendCornerBracket(
|
||||
sink,
|
||||
Math.floor(sx) + 0.5,
|
||||
Math.floor(sy) + 0.5,
|
||||
Math.round(halfW),
|
||||
Math.round(halfH),
|
||||
);
|
||||
}
|
||||
+66
-20
@@ -8,7 +8,10 @@
|
||||
export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]);
|
||||
export const ALLOCATION_ELASTIC = 'ELASTIC';
|
||||
export const ALLOCATION_WEIGHTED = 'WEIGHTED';
|
||||
export const ALLOCATION_STRATEGIES = Object.freeze([ALLOCATION_ELASTIC, ALLOCATION_WEIGHTED]);
|
||||
export const ALLOCATION_STRATEGIES = Object.freeze([
|
||||
ALLOCATION_ELASTIC,
|
||||
ALLOCATION_WEIGHTED,
|
||||
]);
|
||||
|
||||
// The keyhole's default 1% outside opacity is appropriate for text, but it
|
||||
// made side aircraft brackets effectively disappear while the same contacts
|
||||
@@ -71,10 +74,16 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
|
||||
: AIRCRAFT_BRACKET_FLOOR_ANCHOR;
|
||||
if (outside <= 0) return 0;
|
||||
if (outside <= AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
|
||||
return AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR);
|
||||
return (
|
||||
AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR)
|
||||
);
|
||||
}
|
||||
const progress = (outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) / (1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR);
|
||||
return AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress;
|
||||
const progress =
|
||||
(outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) /
|
||||
(1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR);
|
||||
return (
|
||||
AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +94,11 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
|
||||
* the default, so a caller that does not know it reproduces the shipped look.
|
||||
* @returns {number} Paint alpha, 0-1.
|
||||
*/
|
||||
export function detectionBracketAlpha(type, keyholeAlpha, outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
|
||||
export function detectionBracketAlpha(
|
||||
type,
|
||||
keyholeAlpha,
|
||||
outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR,
|
||||
) {
|
||||
const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0));
|
||||
if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha;
|
||||
return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha);
|
||||
@@ -97,7 +110,7 @@ export function detectionHorizontalSector(screenX, viewportWidth) {
|
||||
const x = Number(screenX);
|
||||
if (!(width > 0) || !Number.isFinite(x)) return 'front';
|
||||
if (x < width / 3) return 'left';
|
||||
if (x > width * 2 / 3) return 'right';
|
||||
if (x > (width * 2) / 3) return 'right';
|
||||
return 'front';
|
||||
}
|
||||
|
||||
@@ -140,19 +153,29 @@ export function defaultDensityForProfile(profile) {
|
||||
|
||||
/** Normalize current and legacy profile labels. OFF remains an enabled-state value. */
|
||||
export function normalizeProfile(profile) {
|
||||
const raw = String(profile || '').trim().toUpperCase();
|
||||
const raw = String(profile || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (raw === 'OFF') return 'OFF';
|
||||
if (raw === 'SPARSE' || raw === 'SURVEY') return 'SPARSE';
|
||||
if (raw === 'BALANCED' || raw === 'NORMAL') return 'BALANCED';
|
||||
if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON') return 'DENSE';
|
||||
if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON')
|
||||
return 'DENSE';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Normalize the user-selectable layer allocation strategy. */
|
||||
export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELASTIC) {
|
||||
const raw = String(strategy || '').trim().toUpperCase();
|
||||
export function normalizeAllocationStrategy(
|
||||
strategy,
|
||||
fallback = ALLOCATION_ELASTIC,
|
||||
) {
|
||||
const raw = String(strategy || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (ALLOCATION_STRATEGIES.includes(raw)) return raw;
|
||||
const normalizedFallback = String(fallback || '').trim().toUpperCase();
|
||||
const normalizedFallback = String(fallback || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
return ALLOCATION_STRATEGIES.includes(normalizedFallback)
|
||||
? normalizedFallback
|
||||
: ALLOCATION_ELASTIC;
|
||||
@@ -160,7 +183,9 @@ export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELAS
|
||||
|
||||
/** Classify camera altitude into the shared label-budget view scale. */
|
||||
export function viewScaleForAltitude(altitudeM) {
|
||||
const altitude = Number.isFinite(Number(altitudeM)) ? Math.max(0, Number(altitudeM)) : 1e9;
|
||||
const altitude = Number.isFinite(Number(altitudeM))
|
||||
? Math.max(0, Number(altitudeM))
|
||||
: 1e9;
|
||||
if (altitude < 1200) return 'street';
|
||||
if (altitude < 4500) return 'city';
|
||||
if (altitude < 20000) return 'metro';
|
||||
@@ -181,25 +206,46 @@ export function labelBudgetFor(altitudeM, densityPct) {
|
||||
* density was below 75; legacy Sparse remains inside the Sparse band.
|
||||
*/
|
||||
export function migrateDetectionState(mode, densityPct, fallbackDensity = 50) {
|
||||
const rawMode = String(mode || '').trim().toUpperCase();
|
||||
const rawMode = String(mode || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const normalized = normalizeProfile(rawMode);
|
||||
if (normalized === 'OFF') {
|
||||
const supplied = Number.isFinite(Number(densityPct)) ? densityPct : fallbackDensity;
|
||||
const supplied = Number.isFinite(Number(densityPct))
|
||||
? densityPct
|
||||
: fallbackDensity;
|
||||
const stop = canonicalizeDensity(supplied, fallbackDensity);
|
||||
return { enabled: false, profile: profileForDensity(stop), densityPct: stop };
|
||||
return {
|
||||
enabled: false,
|
||||
profile: profileForDensity(stop),
|
||||
densityPct: stop,
|
||||
};
|
||||
}
|
||||
if (rawMode === 'PANOPTIC' || rawMode === 'GOD' || rawMode === 'ON') {
|
||||
const density = Number.isFinite(Number(densityPct)) ? Math.max(75, Number(densityPct)) : 75;
|
||||
return { enabled: true, profile: 'DENSE', densityPct: canonicalizeDensity(density) };
|
||||
const density = Number.isFinite(Number(densityPct))
|
||||
? Math.max(75, Number(densityPct))
|
||||
: 75;
|
||||
return {
|
||||
enabled: true,
|
||||
profile: 'DENSE',
|
||||
densityPct: canonicalizeDensity(density),
|
||||
};
|
||||
}
|
||||
if (rawMode === 'SPARSE' || rawMode === 'SURVEY') {
|
||||
const density = Number.isFinite(Number(densityPct)) ? Math.min(25, Number(densityPct)) : 25;
|
||||
const density = Number.isFinite(Number(densityPct))
|
||||
? Math.min(25, Number(densityPct))
|
||||
: 25;
|
||||
const stop = canonicalizeDensity(density, 25);
|
||||
return { enabled: true, profile: 'SPARSE', densityPct: stop };
|
||||
}
|
||||
if (normalized === 'BALANCED' || normalized === 'DENSE') {
|
||||
const supplied = Number.isFinite(Number(densityPct)) ? canonicalizeDensity(densityPct) : defaultDensityForProfile(normalized);
|
||||
const stop = profileForDensity(supplied) === normalized ? supplied : defaultDensityForProfile(normalized);
|
||||
const supplied = Number.isFinite(Number(densityPct))
|
||||
? canonicalizeDensity(densityPct)
|
||||
: defaultDensityForProfile(normalized);
|
||||
const stop =
|
||||
profileForDensity(supplied) === normalized
|
||||
? supplied
|
||||
: defaultDensityForProfile(normalized);
|
||||
return { enabled: true, profile: normalized, densityPct: stop };
|
||||
}
|
||||
const stop = canonicalizeDensity(densityPct, fallbackDensity);
|
||||
|
||||
@@ -10,4 +10,3 @@ export const COCKPIT_BRACKET_OPACITY = 0.45;
|
||||
export function detectionBracketOpacity(cockpitActive) {
|
||||
return cockpitActive === true ? COCKPIT_BRACKET_OPACITY : 1;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ test('Cockpit reduces only the bracket presentation multiplier', () => {
|
||||
test('detection owns and releases the Cockpit lifecycle listener', async () => {
|
||||
const source = await readFile(new URL('./detection.js', import.meta.url), 'utf8');
|
||||
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(
|
||||
source,
|
||||
/fade \* entry\.alpha \* bracketPresentationOpacity/,
|
||||
|
||||
@@ -158,9 +158,10 @@ export function detectionPaintSkipDecision({
|
||||
frameCount,
|
||||
thresholdMs = DETECTION_PAINT_SKIP_THRESHOLD_MS,
|
||||
} = {}) {
|
||||
const skip = !layoutChanged
|
||||
&& Number(lastPaintMs) > thresholdMs
|
||||
&& Number(frameCount) % 2 !== 0;
|
||||
const skip =
|
||||
!layoutChanged &&
|
||||
Number(lastPaintMs) > thresholdMs &&
|
||||
Number(frameCount) % 2 !== 0;
|
||||
return { skip, requestFollowUp: skip };
|
||||
}
|
||||
|
||||
|
||||
@@ -277,7 +277,7 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
|
||||
|
||||
assert.match(source, /governorRequestRender\('detection-visibility'\)/,
|
||||
'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 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
|
||||
// with zero layers; the fix settles the solve there, because with nothing
|
||||
// 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.match(emptyExit, /_labelSolveDirty = false;/,
|
||||
'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.doesNotMatch(drawOverlay, /Date\.now\(\)/,
|
||||
'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\(\);/,
|
||||
'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');
|
||||
assert.doesNotMatch(shouldPaint, /_lastPaintMs > 22/,
|
||||
'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');
|
||||
|
||||
// The scanline must not go back to the frame counter: that is what made a
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
* @returns {number} Heading in degrees [0..360), or NaN if unrecognized.
|
||||
*/
|
||||
export function directionToHeading(value, allowBare = false) {
|
||||
const text = String(value || '').trim().toUpperCase();
|
||||
const text = String(value || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!text) return NaN;
|
||||
// Explicit travel/intercardinal forms — safe on free-form text (a street
|
||||
// name almost never contains "NORTHBOUND" or a lone "NB" token).
|
||||
|
||||
@@ -79,8 +79,12 @@ export function parseAcquisitionMs(date, time, cache = new Map()) {
|
||||
const hhmm = String(time ?? '0000').padStart(4, '0');
|
||||
const hours = Number(hhmm.slice(0, 2));
|
||||
const minutes = Number(hhmm.slice(2, 4));
|
||||
const valid = Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day)
|
||||
&& Number.isFinite(hours) && Number.isFinite(minutes);
|
||||
const valid =
|
||||
Number.isFinite(year) &&
|
||||
Number.isFinite(month) &&
|
||||
Number.isFinite(day) &&
|
||||
Number.isFinite(hours) &&
|
||||
Number.isFinite(minutes);
|
||||
const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
|
||||
cache.set(key, ms);
|
||||
return ms;
|
||||
|
||||
+11
-5
@@ -12,10 +12,14 @@ export const FIRMS_OVERLAY_SOURCE_ID = 'firms';
|
||||
export function fireDetectionKey(fire) {
|
||||
const lat = Number.isFinite(fire?.lat) ? fire.lat.toFixed(4) : 'x';
|
||||
const lon = Number.isFinite(fire?.lon) ? fire.lon.toFixed(4) : 'x';
|
||||
const acq = Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? String(fire.acqMs) : '0';
|
||||
const source = satelliteShortName(fire?.satellite)
|
||||
|| String(fire?.sensor || '').trim().toUpperCase()
|
||||
|| 'x';
|
||||
const acq =
|
||||
Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? String(fire.acqMs) : '0';
|
||||
const source =
|
||||
satelliteShortName(fire?.satellite) ||
|
||||
String(fire?.sensor || '')
|
||||
.trim()
|
||||
.toUpperCase() ||
|
||||
'x';
|
||||
return `firms:${lat}:${lon}:${acq}:${source}`;
|
||||
}
|
||||
|
||||
@@ -37,7 +41,9 @@ export function accentForSeverity(stopName) {
|
||||
|
||||
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
|
||||
export function satelliteShortName(satellite) {
|
||||
const s = String(satellite || '').trim().toUpperCase();
|
||||
const s = String(satellite || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (s === 'N20' || s === 'NOAA-20') return 'N20';
|
||||
if (s === 'N21' || s === 'NOAA-21') return 'N21';
|
||||
if (s === 'N' || s === 'NPP' || s === 'SUOMI NPP') return 'SNPP';
|
||||
|
||||
+29
-9
@@ -45,7 +45,9 @@ export function median(values) {
|
||||
if (!Array.isArray(values) || values.length === 0) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = sorted.length >> 1;
|
||||
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
return sorted.length % 2 === 1
|
||||
? sorted[mid]
|
||||
: (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
/** Bearing (degrees, 0 = north, clockwise) of a projected dx/dy vector. */
|
||||
@@ -98,13 +100,19 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
const roadCount = Array.isArray(roads) ? roads.length : 0;
|
||||
const matches = new Array(roadCount).fill(null);
|
||||
const empty = { matches, matchedCount: 0, candidateCount: 0 };
|
||||
if (roadCount === 0 || !Array.isArray(flowSegments) || flowSegments.length === 0) {
|
||||
if (
|
||||
roadCount === 0 ||
|
||||
!Array.isArray(flowSegments) ||
|
||||
flowSegments.length === 0
|
||||
) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
// Local equirectangular projection anchored at the first flow coordinate —
|
||||
// over a ≤0.05° fetch box the distortion is negligible.
|
||||
const anchor = flowSegments.find((f) => Array.isArray(f?.coords) && f.coords.length >= 2);
|
||||
const anchor = flowSegments.find(
|
||||
(f) => Array.isArray(f?.coords) && f.coords.length >= 2,
|
||||
);
|
||||
if (!anchor) return empty;
|
||||
const [refLon, refLat] = anchor.coords[0];
|
||||
const mPerDegLon = M_PER_DEG_LAT * Math.cos((refLat * Math.PI) / 180);
|
||||
@@ -114,7 +122,8 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
// ── 1. Spatial hash of flow segments (midpoint-keyed 100 m cells) ──
|
||||
/** @type {Map<string, Array<{ax:number,ay:number,bx:number,by:number,bearing:number,level:number,closure:boolean}>>} */
|
||||
const grid = new Map();
|
||||
const cellOf = (x, y) => `${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
|
||||
const cellOf = (x, y) =>
|
||||
`${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
|
||||
|
||||
for (const flow of flowSegments) {
|
||||
const coords = flow?.coords;
|
||||
@@ -173,7 +182,8 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
for (let i = 0; i < coords.length; i++) {
|
||||
xs[i] = projX(coords[i][0]);
|
||||
ys[i] = projY(coords[i][1]);
|
||||
if (i > 0) cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
|
||||
if (i > 0)
|
||||
cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
|
||||
}
|
||||
const totalLen = cum[coords.length - 1];
|
||||
if (!(totalLen > 0)) continue;
|
||||
@@ -188,10 +198,14 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
// cross-street flow lines meet at intersections.
|
||||
const target = (totalLen * (s + 0.5)) / ROAD_SAMPLES;
|
||||
while (cursor < coords.length - 1 && cum[cursor] < target) cursor++;
|
||||
const segT = (target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
|
||||
const segT =
|
||||
(target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
|
||||
const px = xs[cursor - 1] + (xs[cursor] - xs[cursor - 1]) * segT;
|
||||
const py = ys[cursor - 1] + (ys[cursor] - ys[cursor - 1]) * segT;
|
||||
const sampleBearing = bearingDeg(xs[cursor] - xs[cursor - 1], ys[cursor] - ys[cursor - 1]);
|
||||
const sampleBearing = bearingDeg(
|
||||
xs[cursor] - xs[cursor - 1],
|
||||
ys[cursor] - ys[cursor - 1],
|
||||
);
|
||||
|
||||
// 3×3 cell probe around the sample.
|
||||
const cx = Math.floor(px / CELL_SIZE_M);
|
||||
@@ -206,7 +220,11 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
|
||||
if (d2 > radius2) continue;
|
||||
hadCandidate = true; // within radius, bearing not yet checked
|
||||
if (bearingDiffDeg(seg.bearing, sampleBearing) >= BEARING_TOLERANCE_DEG) continue;
|
||||
if (
|
||||
bearingDiffDeg(seg.bearing, sampleBearing) >=
|
||||
BEARING_TOLERANCE_DEG
|
||||
)
|
||||
continue;
|
||||
if (d2 <= bestDist2) {
|
||||
bestDist2 = d2;
|
||||
best = seg;
|
||||
@@ -221,7 +239,9 @@ export function matchFlowToRoads(roads, flowSegments) {
|
||||
}
|
||||
|
||||
if (hadCandidate) candidateCount++;
|
||||
if (matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)) {
|
||||
if (
|
||||
matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)
|
||||
) {
|
||||
matches[r] = { level: median(matchedLevels), closure: matchedClosure };
|
||||
matchedCount++;
|
||||
}
|
||||
|
||||
+114
-50
@@ -59,24 +59,35 @@ function focusTargetEmphasisResolved(
|
||||
previouslyFarther,
|
||||
) {
|
||||
if (!target?.screenRect || !spriteScreenPosition) return 1;
|
||||
if (!Number.isFinite(spriteScreenPosition.x) || !Number.isFinite(spriteScreenPosition.y)) return 1;
|
||||
if (
|
||||
!Number.isFinite(spriteScreenPosition.x) ||
|
||||
!Number.isFinite(spriteScreenPosition.y)
|
||||
)
|
||||
return 1;
|
||||
|
||||
// Published rects already contain their publication-time padding. The
|
||||
// delta keeps direct pure-function calls and runtime A/B overrides honest.
|
||||
// The ambient extent makes overlap mean visual overlap, not center-point
|
||||
// containment: a class-sized icon whose edge intrudes into the focus rect
|
||||
// yields even when its center remains just outside it.
|
||||
const paddingDelta = tuning.paddingPx - (Number.isFinite(target.paddingPx) ? target.paddingPx : 0);
|
||||
const paddingDelta =
|
||||
tuning.paddingPx -
|
||||
(Number.isFinite(target.paddingPx) ? target.paddingPx : 0);
|
||||
const hysteresis = previouslyCompeting ? tuning.hysteresisPx : 0;
|
||||
const expansionX = paddingDelta + hysteresis
|
||||
+ Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0);
|
||||
const expansionY = paddingDelta + hysteresis
|
||||
+ Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0);
|
||||
const expansionX =
|
||||
paddingDelta +
|
||||
hysteresis +
|
||||
Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0);
|
||||
const expansionY =
|
||||
paddingDelta +
|
||||
hysteresis +
|
||||
Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0);
|
||||
const rect = target.screenRect;
|
||||
const inside = spriteScreenPosition.x >= rect.left - expansionX
|
||||
&& spriteScreenPosition.x <= rect.right + expansionX
|
||||
&& spriteScreenPosition.y >= rect.top - expansionY
|
||||
&& spriteScreenPosition.y <= rect.bottom + expansionY;
|
||||
const inside =
|
||||
spriteScreenPosition.x >= rect.left - expansionX &&
|
||||
spriteScreenPosition.x <= rect.right + expansionX &&
|
||||
spriteScreenPosition.y >= rect.top - expansionY &&
|
||||
spriteScreenPosition.y <= rect.bottom + expansionY;
|
||||
if (!inside) return 1;
|
||||
|
||||
const floor = clamp(tuning.dimFloor, 0.01, 1);
|
||||
@@ -88,24 +99,33 @@ function focusTargetEmphasisResolved(
|
||||
const fartherThreshold = Number.isFinite(target.cameraDistance)
|
||||
? target.cameraDistance + (previouslyFarther ? -distanceBand : distanceBand)
|
||||
: Number.NaN;
|
||||
const farther = !Number.isFinite(spriteCameraDistance)
|
||||
|| !Number.isFinite(fartherThreshold)
|
||||
|| spriteCameraDistance >= fartherThreshold;
|
||||
const farther =
|
||||
!Number.isFinite(spriteCameraDistance) ||
|
||||
!Number.isFinite(fartherThreshold) ||
|
||||
spriteCameraDistance >= fartherThreshold;
|
||||
if (farther) return floor;
|
||||
if (tuning.nearerBehavior === 'partial') return floor + (1 - floor) * 0.5;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
|
||||
const from = clamp(Number.isFinite(current) ? current : 1, tuning.dimFloor, 1);
|
||||
const from = clamp(
|
||||
Number.isFinite(current) ? current : 1,
|
||||
tuning.dimFloor,
|
||||
1,
|
||||
);
|
||||
const to = clamp(Number.isFinite(target) ? target : 1, tuning.dimFloor, 1);
|
||||
if (Math.abs(to - from) < Number.EPSILON) return to;
|
||||
const duration = to < from ? tuning.attackMs : tuning.releaseMs;
|
||||
if (duration <= 0) return to;
|
||||
const t = clamp((Number.isFinite(elapsedMs) ? elapsedMs : 0) / duration, 0, 1);
|
||||
const t = clamp(
|
||||
(Number.isFinite(elapsedMs) ? elapsedMs : 0) / duration,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
// Cubic ease-out reaches the exact endpoint at the configured duration and
|
||||
// stays continuous when a moving contact reverses direction mid-transition.
|
||||
const eased = 1 - ((1 - t) ** 3);
|
||||
const eased = 1 - (1 - t) ** 3;
|
||||
return clamp(from + (to - from) * eased, tuning.dimFloor, 1);
|
||||
}
|
||||
|
||||
@@ -118,18 +138,24 @@ function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
|
||||
*/
|
||||
export function setFocusDeemphasisParams(patch = {}) {
|
||||
const next = { ..._params };
|
||||
if (Number.isFinite(patch.paddingPx)) next.paddingPx = Math.max(0, patch.paddingPx);
|
||||
if (Number.isFinite(patch.dimFloor)) next.dimFloor = clamp(patch.dimFloor, 0.01, 1);
|
||||
if (Number.isFinite(patch.paddingPx))
|
||||
next.paddingPx = Math.max(0, patch.paddingPx);
|
||||
if (Number.isFinite(patch.dimFloor))
|
||||
next.dimFloor = clamp(patch.dimFloor, 0.01, 1);
|
||||
if (['allow', 'dim', 'partial'].includes(patch.nearerBehavior)) {
|
||||
next.nearerBehavior = patch.nearerBehavior;
|
||||
}
|
||||
if (Number.isFinite(patch.hysteresisPx)) next.hysteresisPx = Math.max(0, patch.hysteresisPx);
|
||||
if (Number.isFinite(patch.hysteresisPx))
|
||||
next.hysteresisPx = Math.max(0, patch.hysteresisPx);
|
||||
if (Number.isFinite(patch.distanceHysteresisRatio)) {
|
||||
next.distanceHysteresisRatio = clamp(patch.distanceHysteresisRatio, 0, 0.5);
|
||||
}
|
||||
if (Number.isFinite(patch.attackMs)) next.attackMs = Math.max(0, patch.attackMs);
|
||||
if (Number.isFinite(patch.releaseMs)) next.releaseMs = Math.max(0, patch.releaseMs);
|
||||
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon);
|
||||
if (Number.isFinite(patch.attackMs))
|
||||
next.attackMs = Math.max(0, patch.attackMs);
|
||||
if (Number.isFinite(patch.releaseMs))
|
||||
next.releaseMs = Math.max(0, patch.releaseMs);
|
||||
if (Number.isFinite(patch.writeEpsilon))
|
||||
next.writeEpsilon = Math.max(0, patch.writeEpsilon);
|
||||
_params = next;
|
||||
return { ..._params };
|
||||
}
|
||||
@@ -171,11 +197,22 @@ export function publishFocusTargetFromCachedPosition({
|
||||
heightPx = 24,
|
||||
params,
|
||||
}) {
|
||||
if (!ownerLayer || id === null || id === undefined || !scene || !camera || !displayPosition) {
|
||||
if (
|
||||
!ownerLayer ||
|
||||
id === null ||
|
||||
id === undefined ||
|
||||
!scene ||
|
||||
!camera ||
|
||||
!displayPosition
|
||||
) {
|
||||
clearFocusTarget(ownerLayer, id);
|
||||
return null;
|
||||
}
|
||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(scene, displayPosition, _scratchScreen);
|
||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
displayPosition,
|
||||
_scratchScreen,
|
||||
);
|
||||
if (!screen || !Number.isFinite(screen.x) || !Number.isFinite(screen.y)) {
|
||||
clearFocusTarget(ownerLayer, id);
|
||||
return null;
|
||||
@@ -195,12 +232,19 @@ export function publishFocusTargetFromCachedPosition({
|
||||
bottom: screen.y + halfHeight + padding,
|
||||
},
|
||||
paddingPx: padding,
|
||||
cameraDistance: Cesium.Cartesian3.distance(camera.positionWC, displayPosition),
|
||||
cameraDistance: Cesium.Cartesian3.distance(
|
||||
camera.positionWC,
|
||||
displayPosition,
|
||||
),
|
||||
frameNumber: scene.frameState?.frameNumber ?? -1,
|
||||
};
|
||||
if (appeared) {
|
||||
for (const listener of _focusAppearListeners) {
|
||||
try { listener(); } catch (e) { console.warn('[focus] appear listener error:', e); }
|
||||
try {
|
||||
listener();
|
||||
} catch (e) {
|
||||
console.warn('[focus] appear listener error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return _focusTarget;
|
||||
@@ -311,15 +355,18 @@ export function smoothFocusEmphasis(current, target, elapsedMs, params) {
|
||||
* every needed property before the next call; never retain it as a snapshot.
|
||||
* @returns {{factor:number, changed:boolean, transitioning:boolean,active:boolean,desired:number}}
|
||||
*/
|
||||
export function advanceSpriteFocus(sprite, {
|
||||
screenPosition,
|
||||
cameraDistance,
|
||||
nowMs,
|
||||
target = _focusTarget,
|
||||
params,
|
||||
spriteHalfWidthPx = 0,
|
||||
spriteHalfHeightPx = spriteHalfWidthPx,
|
||||
}) {
|
||||
export function advanceSpriteFocus(
|
||||
sprite,
|
||||
{
|
||||
screenPosition,
|
||||
cameraDistance,
|
||||
nowMs,
|
||||
target = _focusTarget,
|
||||
params,
|
||||
spriteHalfWidthPx = 0,
|
||||
spriteHalfHeightPx = spriteHalfWidthPx,
|
||||
},
|
||||
) {
|
||||
const tuning = resolvedParams(params);
|
||||
let state = _spriteStates.get(sprite);
|
||||
if (!state) {
|
||||
@@ -369,12 +416,18 @@ export function advanceSpriteFocus(sprite, {
|
||||
state.desired = desired;
|
||||
}
|
||||
const elapsedMs = Math.max(0, nowMs - state.transitionStartMs);
|
||||
const next = smoothFocusEmphasisResolved(state.transitionFrom, desired, elapsedMs, tuning);
|
||||
const next = smoothFocusEmphasisResolved(
|
||||
state.transitionFrom,
|
||||
desired,
|
||||
elapsedMs,
|
||||
tuning,
|
||||
);
|
||||
const changed = Math.abs(next - state.factor) > tuning.writeEpsilon;
|
||||
state.factor = next;
|
||||
state.desired = desired;
|
||||
state.wasFarther = tuning.nearerBehavior !== 'dim'
|
||||
&& Math.abs(desired - tuning.dimFloor) <= tuning.writeEpsilon;
|
||||
state.wasFarther =
|
||||
tuning.nearerBehavior !== 'dim' &&
|
||||
Math.abs(desired - tuning.dimFloor) <= tuning.writeEpsilon;
|
||||
const transitioning = Math.abs(next - desired) > tuning.writeEpsilon;
|
||||
const active = Math.abs(next - 1) > tuning.writeEpsilon;
|
||||
|
||||
@@ -426,12 +479,18 @@ export function advanceProjectedSpriteFocus(
|
||||
_advanceResult.desired = 1;
|
||||
return _advanceResult;
|
||||
}
|
||||
const screen = position && scene
|
||||
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, _scratchScreen)
|
||||
: null;
|
||||
const cameraDistance = position && camera?.positionWC
|
||||
? Cesium.Cartesian3.distance(camera.positionWC, position)
|
||||
: Number.NaN;
|
||||
const screen =
|
||||
position && scene
|
||||
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
position,
|
||||
_scratchScreen,
|
||||
)
|
||||
: null;
|
||||
const cameraDistance =
|
||||
position && camera?.positionWC
|
||||
? Cesium.Cartesian3.distance(camera.positionWC, position)
|
||||
: Number.NaN;
|
||||
return advanceSpriteFocus(sprite, {
|
||||
screenPosition: screen,
|
||||
cameraDistance,
|
||||
@@ -451,8 +510,10 @@ export function advanceProjectedSpriteFocus(
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function focusPassIsNeeded(target, activeCount) {
|
||||
return target !== null && target !== undefined
|
||||
|| (Number.isFinite(activeCount) && activeCount > 0);
|
||||
return (
|
||||
(target !== null && target !== undefined) ||
|
||||
(Number.isFinite(activeCount) && activeCount > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -466,8 +527,9 @@ export function nearFarScalarValueAtDistance(scalar, cameraDistanceM) {
|
||||
if (!scalar || !Number.isFinite(cameraDistanceM)) return 1;
|
||||
const nearSq = scalar.near * scalar.near;
|
||||
const farSq = scalar.far * scalar.far;
|
||||
if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq) return 1;
|
||||
const rawT = ((cameraDistanceM * cameraDistanceM) - nearSq) / (farSq - nearSq);
|
||||
if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq)
|
||||
return 1;
|
||||
const rawT = (cameraDistanceM * cameraDistanceM - nearSq) / (farSq - nearSq);
|
||||
const t = Math.pow(clamp(rawT, 0, 1), 0.2);
|
||||
return scalar.nearValue + (scalar.farValue - scalar.nearValue) * t;
|
||||
}
|
||||
@@ -507,6 +569,8 @@ export function forgetSpriteFocus(sprite) {
|
||||
*/
|
||||
export function focusAlphaNeedsWrite(currentAlpha, nextAlpha, params) {
|
||||
const tuning = resolvedParams(params);
|
||||
return !Number.isFinite(currentAlpha)
|
||||
|| Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon;
|
||||
return (
|
||||
!Number.isFinite(currentAlpha) ||
|
||||
Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -51,7 +51,7 @@ export function geoidHeight(latDeg, lonDeg) {
|
||||
if (!egm96Module) {
|
||||
throw new Error(
|
||||
'geoid.js: geoidHeight() called before ensureGeoidReady() resolved — ' +
|
||||
'await ensureGeoidReady() first.'
|
||||
'await ensureGeoidReady() first.',
|
||||
);
|
||||
}
|
||||
return egm96Module.meanSeaLevel(latDeg, lonDeg);
|
||||
|
||||
+29
-1
@@ -1,4 +1,32 @@
|
||||
import { createGroundFloor } from '../services/groundFloor.js';
|
||||
import * as terrain from './terrainHeights.js';
|
||||
const groundFloor = createGroundFloor({ terrain });
|
||||
export const { GROUND_FLOOR_LIFT_M, MESH_FLOOR_BELOW_PRIOR_M, MESH_FLOOR_ABOVE_PRIOR_M, coarseFloorCoord, floorAltitudeM, displayFloorHeightM, FLOOR_CELL_DEG, NEIGHBOR_FLOOR_MIN_SAMPLES, neighborFloorM, CORRIDOR_MAX_CELLS, CORRIDOR_WALK_STEP_DEG, CELL_HYSTERESIS_DEG, stickyFloorCell, corridorFloorCells, allocateCorridorCells, setMeshFloorPreferred, meshFloorPreferred, reportMeshFloorCell, meshFloorSampleWithinPrior, reportValidatedMeshFloorCell, cachedMeshFloor, _clearMeshFloorCellsForTest, cachedGroundFloor, FLOOR_RESOLVE_DEADLINE_MS, resolveGroundFloorCellsBounded, resolveGroundFloorCells, warmGroundFloor } = groundFloor;
|
||||
export const {
|
||||
GROUND_FLOOR_LIFT_M,
|
||||
MESH_FLOOR_BELOW_PRIOR_M,
|
||||
MESH_FLOOR_ABOVE_PRIOR_M,
|
||||
coarseFloorCoord,
|
||||
floorAltitudeM,
|
||||
displayFloorHeightM,
|
||||
FLOOR_CELL_DEG,
|
||||
NEIGHBOR_FLOOR_MIN_SAMPLES,
|
||||
neighborFloorM,
|
||||
CORRIDOR_MAX_CELLS,
|
||||
CORRIDOR_WALK_STEP_DEG,
|
||||
CELL_HYSTERESIS_DEG,
|
||||
stickyFloorCell,
|
||||
corridorFloorCells,
|
||||
allocateCorridorCells,
|
||||
setMeshFloorPreferred,
|
||||
meshFloorPreferred,
|
||||
reportMeshFloorCell,
|
||||
meshFloorSampleWithinPrior,
|
||||
reportValidatedMeshFloorCell,
|
||||
cachedMeshFloor,
|
||||
_clearMeshFloorCellsForTest,
|
||||
cachedGroundFloor,
|
||||
FLOOR_RESOLVE_DEADLINE_MS,
|
||||
resolveGroundFloorCellsBounded,
|
||||
resolveGroundFloorCells,
|
||||
warmGroundFloor,
|
||||
} = groundFloor;
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createGroundSnap as createSnap } from '../services/groundSnap.js';
|
||||
import * as groundFloor from './groundFloor.js';
|
||||
/** Compatibility constructor using the default floor cache. */
|
||||
export function createGroundSnap() { return createSnap({ groundFloor }); }
|
||||
export function createGroundSnap() {
|
||||
return createSnap({ groundFloor });
|
||||
}
|
||||
+25
-6
@@ -29,8 +29,14 @@ export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
|
||||
}
|
||||
|
||||
export function findNextIssPass({
|
||||
satrec, latDeg, lonDeg, fromMs,
|
||||
minElevDeg = 10, horizonHours = 24, coarseStepSec = 30, fineStepSec = 5,
|
||||
satrec,
|
||||
latDeg,
|
||||
lonDeg,
|
||||
fromMs,
|
||||
minElevDeg = 10,
|
||||
horizonHours = 24,
|
||||
coarseStepSec = 30,
|
||||
fineStepSec = 5,
|
||||
}) {
|
||||
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
|
||||
const horizonMs = fromMs + horizonHours * 3600_000;
|
||||
@@ -41,13 +47,17 @@ export function findNextIssPass({
|
||||
// pass, that's still "the next pass" for a voice answer — accept it.
|
||||
let hit = null;
|
||||
for (let t = fromMs; t <= horizonMs; t += coarse) {
|
||||
if (elev(t) >= minElevDeg) { hit = t; break; }
|
||||
if (elev(t) >= minElevDeg) {
|
||||
hit = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hit == null) return null;
|
||||
|
||||
// Refine rise: walk back in fine steps to the first sample ≥ threshold.
|
||||
let riseMs = hit;
|
||||
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg) riseMs -= fine;
|
||||
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg)
|
||||
riseMs -= fine;
|
||||
|
||||
// Walk forward through the pass tracking the peak until we drop below.
|
||||
let maxElevDeg = -90;
|
||||
@@ -56,11 +66,20 @@ export function findNextIssPass({
|
||||
while (t <= horizonMs) {
|
||||
const e = elev(t);
|
||||
if (e < minElevDeg && t > riseMs) break;
|
||||
if (e > maxElevDeg) { maxElevDeg = e; maxElevMs = t; }
|
||||
if (e > maxElevDeg) {
|
||||
maxElevDeg = e;
|
||||
maxElevMs = t;
|
||||
}
|
||||
t += fine;
|
||||
}
|
||||
const setMs = t;
|
||||
|
||||
const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
|
||||
return { riseMs, setMs, maxElevDeg, maxElevMs, riseAzDeg: rise ? rise.azDeg : 0 };
|
||||
return {
|
||||
riseMs,
|
||||
setMs,
|
||||
maxElevDeg,
|
||||
maxElevMs,
|
||||
riseAzDeg: rise ? rise.azDeg : 0,
|
||||
};
|
||||
}
|
||||
+175
-72
@@ -16,11 +16,15 @@ function clampInt(value, min, max) {
|
||||
}
|
||||
|
||||
function demandEntries(demandByLayer) {
|
||||
const entries = demandByLayer instanceof Map
|
||||
? Array.from(demandByLayer.entries())
|
||||
: Object.entries(demandByLayer || {});
|
||||
const entries =
|
||||
demandByLayer instanceof Map
|
||||
? Array.from(demandByLayer.entries())
|
||||
: Object.entries(demandByLayer || {});
|
||||
return entries
|
||||
.map(([layerId, demand]) => [String(layerId), Math.max(0, Math.floor(Number(demand) || 0))])
|
||||
.map(([layerId, demand]) => [
|
||||
String(layerId),
|
||||
Math.max(0, Math.floor(Number(demand) || 0)),
|
||||
])
|
||||
.filter(([, demand]) => demand > 0)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
}
|
||||
@@ -104,7 +108,9 @@ function allocateLayerQuotasInto(
|
||||
weighted.length = idCount;
|
||||
priorityOrder.length = idCount;
|
||||
remainders.length = idCount;
|
||||
priorityOrder.sort((a, b) => b.weight - a.weight || a.layerId.localeCompare(b.layerId));
|
||||
priorityOrder.sort(
|
||||
(a, b) => b.weight - a.weight || a.layerId.localeCompare(b.layerId),
|
||||
);
|
||||
|
||||
let remaining = capacity;
|
||||
if (capacity >= idCount) {
|
||||
@@ -115,7 +121,7 @@ function allocateLayerQuotasInto(
|
||||
let apportioned = 0;
|
||||
for (let i = 0; i < idCount; i++) {
|
||||
const entry = weighted[i];
|
||||
const exact = remaining * entry.weight / totalWeight;
|
||||
const exact = (remaining * entry.weight) / totalWeight;
|
||||
const room = entry.count - (quotas.get(entry.layerId) || 0);
|
||||
const whole = Math.min(room, Math.floor(exact));
|
||||
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
|
||||
@@ -123,9 +129,12 @@ function allocateLayerQuotasInto(
|
||||
entry.fraction = exact - Math.floor(exact);
|
||||
}
|
||||
remaining -= apportioned;
|
||||
remainders.sort((a, b) => b.fraction - a.fraction
|
||||
|| b.weight - a.weight
|
||||
|| a.layerId.localeCompare(b.layerId));
|
||||
remainders.sort(
|
||||
(a, b) =>
|
||||
b.fraction - a.fraction ||
|
||||
b.weight - a.weight ||
|
||||
a.layerId.localeCompare(b.layerId),
|
||||
);
|
||||
for (let i = 0; i < idCount && remaining > 0; i++) {
|
||||
const entry = remainders[i];
|
||||
const current = quotas.get(entry.layerId) || 0;
|
||||
@@ -156,7 +165,12 @@ function allocateLayerQuotasInto(
|
||||
* Allocate a collective capacity across non-empty layers. Both strategies are
|
||||
* work-conserving and deterministic; unused entitlement is always borrowed.
|
||||
*/
|
||||
export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATION_ELASTIC, layerWeights = {}) {
|
||||
export function allocateLayerQuotas(
|
||||
demandByLayer,
|
||||
capacity,
|
||||
strategy = ALLOCATION_ELASTIC,
|
||||
layerWeights = {},
|
||||
) {
|
||||
const entries = demandEntries(demandByLayer);
|
||||
const totalDemand = entries.reduce((sum, [, demand]) => sum + demand, 0);
|
||||
const cap = clampInt(capacity, 0, totalDemand);
|
||||
@@ -198,15 +212,24 @@ export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATI
|
||||
if (remaining > 0 && totalWeight > 0) {
|
||||
let apportioned = 0;
|
||||
for (const entry of weighted) {
|
||||
const exact = remaining * entry.weight / totalWeight;
|
||||
const exact = (remaining * entry.weight) / totalWeight;
|
||||
const room = entry.count - (quotas.get(entry.layerId) || 0);
|
||||
const whole = Math.min(room, Math.floor(exact));
|
||||
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
|
||||
apportioned += whole;
|
||||
remainders.push({ layerId: entry.layerId, fraction: exact - Math.floor(exact), weight: entry.weight });
|
||||
remainders.push({
|
||||
layerId: entry.layerId,
|
||||
fraction: exact - Math.floor(exact),
|
||||
weight: entry.weight,
|
||||
});
|
||||
}
|
||||
remaining -= apportioned;
|
||||
remainders.sort((a, b) => b.fraction - a.fraction || b.weight - a.weight || a.layerId.localeCompare(b.layerId));
|
||||
remainders.sort(
|
||||
(a, b) =>
|
||||
b.fraction - a.fraction ||
|
||||
b.weight - a.weight ||
|
||||
a.layerId.localeCompare(b.layerId),
|
||||
);
|
||||
for (const entry of remainders) {
|
||||
if (remaining <= 0) break;
|
||||
const current = quotas.get(entry.layerId) || 0;
|
||||
@@ -220,20 +243,24 @@ export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATI
|
||||
}
|
||||
|
||||
function rectIsFinite(rect) {
|
||||
return rect
|
||||
&& Number.isFinite(rect.x)
|
||||
&& Number.isFinite(rect.y)
|
||||
&& Number.isFinite(rect.w)
|
||||
&& Number.isFinite(rect.h)
|
||||
&& rect.w > 0
|
||||
&& rect.h > 0;
|
||||
return (
|
||||
rect &&
|
||||
Number.isFinite(rect.x) &&
|
||||
Number.isFinite(rect.y) &&
|
||||
Number.isFinite(rect.w) &&
|
||||
Number.isFinite(rect.h) &&
|
||||
rect.w > 0 &&
|
||||
rect.h > 0
|
||||
);
|
||||
}
|
||||
|
||||
function overlaps(a, b, padding = 4) {
|
||||
return a.x < b.x + b.w + padding
|
||||
&& a.x + a.w + padding > b.x
|
||||
&& a.y < b.y + b.h + padding
|
||||
&& a.y + a.h + padding > b.y;
|
||||
return (
|
||||
a.x < b.x + b.w + padding &&
|
||||
a.x + a.w + padding > b.x &&
|
||||
a.y < b.y + b.h + padding &&
|
||||
a.y + a.h + padding > b.y
|
||||
);
|
||||
}
|
||||
|
||||
const CELL_ORIGIN = 8192;
|
||||
@@ -271,8 +298,10 @@ class SpatialHash {
|
||||
* never hide a real collision.
|
||||
*/
|
||||
_cellKey(x, y) {
|
||||
const cx = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, x)) + CELL_ORIGIN;
|
||||
const cy = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, y)) + CELL_ORIGIN;
|
||||
const cx =
|
||||
Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, x)) + CELL_ORIGIN;
|
||||
const cy =
|
||||
Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, y)) + CELL_ORIGIN;
|
||||
// Both coordinates occupy 14 bits. Bit-packing forces the lookup key to a
|
||||
// Smi; arithmetic multiplication materializes a boxed double at each
|
||||
// spatial-hash get/add on this hot solve path.
|
||||
@@ -325,8 +354,14 @@ function candidateCompare(a, b, states, now) {
|
||||
// A stateless candidate is never lifetime-pinned: its source shipped as a
|
||||
// per-frame rebuild, so it must be free to lose its slot the moment something
|
||||
// better wants it. Plain incumbency ordering below still applies.
|
||||
const aPinned = !!aState?.selected && a.stateless !== true && now - aState.selectedAt < MIN_LIFETIME_MS;
|
||||
const bPinned = !!bState?.selected && b.stateless !== true && now - bState.selectedAt < MIN_LIFETIME_MS;
|
||||
const aPinned =
|
||||
!!aState?.selected &&
|
||||
a.stateless !== true &&
|
||||
now - aState.selectedAt < MIN_LIFETIME_MS;
|
||||
const bPinned =
|
||||
!!bState?.selected &&
|
||||
b.stateless !== true &&
|
||||
now - bState.selectedAt < MIN_LIFETIME_MS;
|
||||
if (aPinned !== bPinned) return aPinned ? -1 : 1;
|
||||
const aIncumbent = !!aState?.selected;
|
||||
const bIncumbent = !!bState?.selected;
|
||||
@@ -346,12 +381,20 @@ function candidateCompare(a, b, states, now) {
|
||||
function cacheCandidateAnchorScalars(candidate) {
|
||||
let anchorX = Number.NaN;
|
||||
let anchorY = Number.NaN;
|
||||
if (Number.isFinite(candidate?.screenX) && Number.isFinite(candidate?.screenY)) {
|
||||
if (
|
||||
Number.isFinite(candidate?.screenX) &&
|
||||
Number.isFinite(candidate?.screenY)
|
||||
) {
|
||||
anchorX = candidate.screenX;
|
||||
anchorY = candidate.screenY;
|
||||
} else {
|
||||
const placement = Array.isArray(candidate?.placements) ? candidate.placements[0] : null;
|
||||
if (Number.isFinite(placement?.leadFromX) && Number.isFinite(placement?.leadFromY)) {
|
||||
const placement = Array.isArray(candidate?.placements)
|
||||
? candidate.placements[0]
|
||||
: null;
|
||||
if (
|
||||
Number.isFinite(placement?.leadFromX) &&
|
||||
Number.isFinite(placement?.leadFromY)
|
||||
) {
|
||||
anchorX = placement.leadFromX;
|
||||
anchorY = placement.leadFromY;
|
||||
} else if (rectIsFinite(placement?.rect)) {
|
||||
@@ -401,7 +444,16 @@ class SpatialCandidateQueue {
|
||||
* stay unboxed. Few-placement candidates already blocked by the
|
||||
* monotonically growing collision field are dismissed before anchor scans.
|
||||
*/
|
||||
reset(candidates, count, anchors, anchorCount, attemptStamps, stamp, spatial, states) {
|
||||
reset(
|
||||
candidates,
|
||||
count,
|
||||
anchors,
|
||||
anchorCount,
|
||||
attemptStamps,
|
||||
stamp,
|
||||
spatial,
|
||||
states,
|
||||
) {
|
||||
this.candidates = candidates;
|
||||
this.count = count;
|
||||
this.anchorCount = anchorCount;
|
||||
@@ -529,10 +581,13 @@ class SpatialCandidateQueue {
|
||||
const rectY = placementYs[rectIndex];
|
||||
const rectW = placementWs[rectIndex];
|
||||
const rectH = placementHs[rectIndex];
|
||||
if (rectX < anchorRectX + anchorRectW + 4
|
||||
&& rectX + rectW + 4 > anchorRectX
|
||||
&& rectY < anchorRectY + anchorRectH + 4
|
||||
&& rectY + rectH + 4 > anchorRectY) freeMask &= ~bit;
|
||||
if (
|
||||
rectX < anchorRectX + anchorRectW + 4 &&
|
||||
rectX + rectW + 4 > anchorRectX &&
|
||||
rectY < anchorRectY + anchorRectH + 4 &&
|
||||
rectY + rectH + 4 > anchorRectY
|
||||
)
|
||||
freeMask &= ~bit;
|
||||
}
|
||||
placementMasks[i] = freeMask;
|
||||
if (freeMask === 0 && this._dismissBlockedKey(i)) continue;
|
||||
@@ -558,7 +613,8 @@ class SpatialCandidateQueue {
|
||||
bestIndex = i;
|
||||
continue;
|
||||
}
|
||||
const priorityDelta = (Number(candidate.priority) || 0) - (Number(best.priority) || 0);
|
||||
const priorityDelta =
|
||||
(Number(candidate.priority) || 0) - (Number(best.priority) || 0);
|
||||
if (priorityDelta !== 0) {
|
||||
if (priorityDelta > 0) {
|
||||
best = candidate;
|
||||
@@ -621,7 +677,8 @@ function firstFreePlacement(candidate, stickyCorner, spatial) {
|
||||
if (stickyCorner) {
|
||||
for (let i = 0; i < placements.length; i++) {
|
||||
const placement = placements[i];
|
||||
if (placement?.corner !== stickyCorner || !rectIsFinite(placement.rect)) continue;
|
||||
if (placement?.corner !== stickyCorner || !rectIsFinite(placement.rect))
|
||||
continue;
|
||||
if (!spatial.collides(placement.rect)) return placement;
|
||||
}
|
||||
}
|
||||
@@ -790,7 +847,13 @@ export class LabelArbiter {
|
||||
let candidateCount = 0;
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const candidate = source[i];
|
||||
if (!candidate || !candidate.key || !candidate.layerId || !(candidate.keyholeAlpha > 0)) continue;
|
||||
if (
|
||||
!candidate ||
|
||||
!candidate.key ||
|
||||
!candidate.layerId ||
|
||||
!(candidate.keyholeAlpha > 0)
|
||||
)
|
||||
continue;
|
||||
cacheCandidateAnchorScalars(candidate);
|
||||
candidateList[candidateCount++] = candidate;
|
||||
let bucket = byLayer.get(candidate.layerId);
|
||||
@@ -815,13 +878,15 @@ export class LabelArbiter {
|
||||
});
|
||||
} else if (options.demandByLayer != null) {
|
||||
const supplied = demandEntries(options.demandByLayer);
|
||||
for (let i = 0; i < supplied.length; i++) demand.set(supplied[i][0], supplied[i][1]);
|
||||
for (let i = 0; i < supplied.length; i++)
|
||||
demand.set(supplied[i][0], supplied[i][1]);
|
||||
}
|
||||
// A supplied demand map describes the full lightweight field while the
|
||||
// candidate list may be only a bounded materialized cohort. Keep defensive
|
||||
// support for candidate layers omitted by an incomplete caller map.
|
||||
byLayer.forEach((bucket, layerId) => {
|
||||
if (bucket.count > 0 && !demand.has(layerId)) demand.set(layerId, bucket.count);
|
||||
if (bucket.count > 0 && !demand.has(layerId))
|
||||
demand.set(layerId, bucket.count);
|
||||
});
|
||||
|
||||
const activeLayers = this._activeLayersScratch;
|
||||
@@ -857,14 +922,20 @@ export class LabelArbiter {
|
||||
|
||||
let sameActiveLayers = activeCount === this.lastActiveLayers.length;
|
||||
for (let i = 0; sameActiveLayers && i < activeCount; i++) {
|
||||
if (activeLayers[i] !== this.lastActiveLayers[i]) sameActiveLayers = false;
|
||||
if (activeLayers[i] !== this.lastActiveLayers[i])
|
||||
sameActiveLayers = false;
|
||||
}
|
||||
const preserveIncumbents = options.preserveIncumbents !== false && sameActiveLayers;
|
||||
const preserveIncumbents =
|
||||
options.preserveIncumbents !== false && sameActiveLayers;
|
||||
let spatialQueueBuildCount = 0;
|
||||
let spatialQueueNextCount = 0;
|
||||
|
||||
const attempt = (candidate, allowOverlapFallback = false) => {
|
||||
if (selectedCount >= capacity || attemptStamps.get(candidate.key) === stamp) return false;
|
||||
if (
|
||||
selectedCount >= capacity ||
|
||||
attemptStamps.get(candidate.key) === stamp
|
||||
)
|
||||
return false;
|
||||
attemptStamps.set(candidate.key, stamp);
|
||||
const previous = this.states.get(candidate.key);
|
||||
const stateless = candidate.stateless === true;
|
||||
@@ -872,10 +943,12 @@ export class LabelArbiter {
|
||||
// very next solve, as their stateless originals did) and carry no sticky
|
||||
// corner, so above/below is re-decided from geometry every solve instead
|
||||
// of being pinned by whichever side happened to be free once.
|
||||
if (!stateless && !previous?.selected && previous?.cooldownUntil > now) return false;
|
||||
if (!stateless && !previous?.selected && previous?.cooldownUntil > now)
|
||||
return false;
|
||||
const sticky = stateless ? undefined : previous?.corner;
|
||||
let placement = firstFreePlacement(candidate, sticky, spatial);
|
||||
if (!placement && allowOverlapFallback) placement = firstOrderedPlacement(candidate, sticky);
|
||||
if (!placement && allowOverlapFallback)
|
||||
placement = firstOrderedPlacement(candidate, sticky);
|
||||
if (!placement) return false;
|
||||
spatial.add(placement.rect);
|
||||
selectStamps.set(candidate.key, stamp);
|
||||
@@ -953,7 +1026,8 @@ export class LabelArbiter {
|
||||
}
|
||||
let retainedIncumbents = 0;
|
||||
for (let i = 0; i < selectedCount; i++) {
|
||||
if (this.selectedKeys.has(selectedCandidates[i].key)) retainedIncumbents++;
|
||||
if (this.selectedKeys.has(selectedCandidates[i].key))
|
||||
retainedIncumbents++;
|
||||
}
|
||||
|
||||
const exitingStates = this._refreshStateList();
|
||||
@@ -964,7 +1038,9 @@ export class LabelArbiter {
|
||||
state.exitStartedAt = now;
|
||||
// Stateless entries may be re-selected immediately; the cooldown exists
|
||||
// to damp thrash for sources that shipped with hysteresis.
|
||||
state.cooldownUntil = state.stateless ? 0 : now + FADE_OUT_MS + COOLDOWN_MS;
|
||||
state.cooldownUntil = state.stateless
|
||||
? 0
|
||||
: now + FADE_OUT_MS + COOLDOWN_MS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1002,7 +1078,8 @@ export class LabelArbiter {
|
||||
this._stateListDirty = true;
|
||||
}
|
||||
if (labelsByLayer) {
|
||||
labelsByLayer[candidate.layerId] = (labelsByLayer[candidate.layerId] || 0) + 1;
|
||||
labelsByLayer[candidate.layerId] =
|
||||
(labelsByLayer[candidate.layerId] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1013,7 +1090,10 @@ export class LabelArbiter {
|
||||
const livingStates = this._refreshStateList();
|
||||
for (let i = 0; i < livingStates.length; i++) {
|
||||
const state = livingStates[i];
|
||||
if (!state.selected && now - (state.exitStartedAt || now) > FADE_OUT_MS + COOLDOWN_MS) {
|
||||
if (
|
||||
!state.selected &&
|
||||
now - (state.exitStartedAt || now) > FADE_OUT_MS + COOLDOWN_MS
|
||||
) {
|
||||
this.states.delete(state.key);
|
||||
this._stateListDirty = true;
|
||||
}
|
||||
@@ -1026,25 +1106,28 @@ export class LabelArbiter {
|
||||
});
|
||||
for (let i = 0; i < droppedCount; i++) this.selectedKeys.delete(dropped[i]);
|
||||
dropped.length = droppedCount;
|
||||
for (let i = 0; i < selectedCount; i++) this.selectedKeys.add(selectedCandidates[i].key);
|
||||
for (let i = 0; i < selectedCount; i++)
|
||||
this.selectedKeys.add(selectedCandidates[i].key);
|
||||
|
||||
const previousActiveLayers = this.lastActiveLayers;
|
||||
this.lastActiveLayers = activeLayers;
|
||||
this._activeLayersScratch = previousActiveLayers;
|
||||
this.solveRevision++;
|
||||
this.lastDiagnostics = collectDiagnostics ? {
|
||||
strategy,
|
||||
capacity,
|
||||
selectedCount,
|
||||
quotas: Object.fromEntries(quotas),
|
||||
demand: Object.fromEntries(demand),
|
||||
labelsByLayer,
|
||||
solveRevision: this.solveRevision,
|
||||
spatialQueueBuildCount,
|
||||
spatialQueueNextCount,
|
||||
eligibleIncumbents,
|
||||
retainedIncumbents,
|
||||
} : null;
|
||||
this.lastDiagnostics = collectDiagnostics
|
||||
? {
|
||||
strategy,
|
||||
capacity,
|
||||
selectedCount,
|
||||
quotas: Object.fromEntries(quotas),
|
||||
demand: Object.fromEntries(demand),
|
||||
labelsByLayer,
|
||||
solveRevision: this.solveRevision,
|
||||
spatialQueueBuildCount,
|
||||
spatialQueueNextCount,
|
||||
eligibleIncumbents,
|
||||
retainedIncumbents,
|
||||
}
|
||||
: null;
|
||||
selectedCandidates.length = selectedCount;
|
||||
selectedPlacements.length = selectedCount;
|
||||
pruneStampMap(attemptStamps, candidateCount);
|
||||
@@ -1057,9 +1140,15 @@ export class LabelArbiter {
|
||||
* A caller-owned output array enables allocation-free per-frame rendering.
|
||||
*/
|
||||
renderEntries(currentCandidates, now = Date.now(), out = []) {
|
||||
const current = currentCandidates instanceof Map
|
||||
? currentCandidates
|
||||
: new Map((currentCandidates || []).map((candidate) => [candidate.key, candidate]));
|
||||
const current =
|
||||
currentCandidates instanceof Map
|
||||
? currentCandidates
|
||||
: new Map(
|
||||
(currentCandidates || []).map((candidate) => [
|
||||
candidate.key,
|
||||
candidate,
|
||||
]),
|
||||
);
|
||||
const states = this._refreshStateList();
|
||||
let outIndex = 0;
|
||||
for (let i = 0; i < states.length; i++) {
|
||||
@@ -1070,14 +1159,23 @@ export class LabelArbiter {
|
||||
// fully painted this solve or gone. No enter ramp, no exit tail.
|
||||
temporalAlpha = state.selected ? 1 : 0;
|
||||
} else if (state.selected) {
|
||||
temporalAlpha = Math.min(1, Math.max(0, (now - state.enterStartedAt) / FADE_IN_MS));
|
||||
temporalAlpha = Math.min(
|
||||
1,
|
||||
Math.max(0, (now - state.enterStartedAt) / FADE_IN_MS),
|
||||
);
|
||||
} else {
|
||||
temporalAlpha = 1 - Math.min(1, Math.max(0, (now - state.exitStartedAt) / FADE_OUT_MS));
|
||||
temporalAlpha =
|
||||
1 -
|
||||
Math.min(1, Math.max(0, (now - state.exitStartedAt) / FADE_OUT_MS));
|
||||
}
|
||||
if (temporalAlpha <= 0) continue;
|
||||
const candidate = current.get(state.key) || state.lastCandidate;
|
||||
if (!candidate) continue;
|
||||
const placement = renderPlacement(candidate, state.stateless ? undefined : state.corner, state.lastPlacement);
|
||||
const placement = renderPlacement(
|
||||
candidate,
|
||||
state.stateless ? undefined : state.corner,
|
||||
state.lastPlacement,
|
||||
);
|
||||
if (!placement) continue;
|
||||
const entry = out[outIndex] || (out[outIndex] = {});
|
||||
entry.candidate = candidate;
|
||||
@@ -1100,11 +1198,16 @@ export class LabelArbiter {
|
||||
for (const state of this.states.values()) {
|
||||
if (!state.selected) {
|
||||
if (!includeFading) continue;
|
||||
if (!Number.isFinite(state.exitStartedAt) || now - state.exitStartedAt >= FADE_OUT_MS) continue;
|
||||
if (
|
||||
!Number.isFinite(state.exitStartedAt) ||
|
||||
now - state.exitStartedAt >= FADE_OUT_MS
|
||||
)
|
||||
continue;
|
||||
}
|
||||
const candidate = state.lastCandidate;
|
||||
if (!candidate?.layerId || candidate.sourceId == null) continue;
|
||||
if (!grouped.has(candidate.layerId)) grouped.set(candidate.layerId, new Set());
|
||||
if (!grouped.has(candidate.layerId))
|
||||
grouped.set(candidate.layerId, new Set());
|
||||
grouped.get(candidate.layerId).add(candidate.sourceId);
|
||||
}
|
||||
return grouped;
|
||||
|
||||
+428
-177
@@ -42,7 +42,9 @@ const RADIO_FILTER_CODES = Object.freeze({
|
||||
other: 'o',
|
||||
});
|
||||
const RADIO_CODE_FILTERS = Object.freeze(
|
||||
Object.fromEntries(Object.entries(RADIO_FILTER_CODES).map(([key, value]) => [value, key])),
|
||||
Object.fromEntries(
|
||||
Object.entries(RADIO_FILTER_CODES).map(([key, value]) => [value, key]),
|
||||
),
|
||||
);
|
||||
|
||||
function normalizeBoolean(value) {
|
||||
@@ -54,7 +56,9 @@ function normalizeEnum(values, value) {
|
||||
}
|
||||
|
||||
export function normalizeRadioFilter(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
const normalized = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (Object.hasOwn(RADIO_FILTER_CODES, normalized)) return normalized;
|
||||
if (/^genre:[a-z0-9][a-z0-9 &-]{0,31}$/.test(normalized)) return normalized;
|
||||
return null;
|
||||
@@ -65,8 +69,10 @@ function encodeRadioFilter(value) {
|
||||
}
|
||||
|
||||
function decodeRadioFilter(value) {
|
||||
if (Object.hasOwn(RADIO_CODE_FILTERS, value)) return RADIO_CODE_FILTERS[value];
|
||||
if (/^g-[a-z0-9][a-z0-9 &-]{0,31}$/.test(value)) return `genre:${value.slice(2)}`;
|
||||
if (Object.hasOwn(RADIO_CODE_FILTERS, value))
|
||||
return RADIO_CODE_FILTERS[value];
|
||||
if (/^g-[a-z0-9][a-z0-9 &-]{0,31}$/.test(value))
|
||||
return `genre:${value.slice(2)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -76,7 +82,12 @@ function normalizeVolume(value) {
|
||||
return Math.round(Math.max(0, Math.min(1, numeric)) * 100) / 100;
|
||||
}
|
||||
|
||||
function booleanOption(key, token, defaultValue, { absentValue = defaultValue } = {}) {
|
||||
function booleanOption(
|
||||
key,
|
||||
token,
|
||||
defaultValue,
|
||||
{ absentValue = defaultValue } = {},
|
||||
) {
|
||||
return Object.freeze({
|
||||
key,
|
||||
token,
|
||||
@@ -109,15 +120,20 @@ function booleanOption(key, token, defaultValue, { absentValue = defaultValue }
|
||||
* first option in THIS codec to need it — flipped to default-ON on 2026-08-22.)
|
||||
*/
|
||||
function absentTokenValue(spec) {
|
||||
return Object.hasOwn(spec, 'absentValue') ? spec.absentValue : spec.defaultValue;
|
||||
return Object.hasOwn(spec, 'absentValue')
|
||||
? spec.absentValue
|
||||
: spec.defaultValue;
|
||||
}
|
||||
|
||||
function trackingIdOption(key, token, defaultValue = null) {
|
||||
const bounded = (candidate) => {
|
||||
if (candidate === null || candidate === undefined) return null;
|
||||
const raw = typeof candidate === 'number' && Number.isFinite(candidate)
|
||||
? String(candidate)
|
||||
: (typeof candidate === 'string' ? candidate : null);
|
||||
const raw =
|
||||
typeof candidate === 'number' && Number.isFinite(candidate)
|
||||
? String(candidate)
|
||||
: typeof candidate === 'string'
|
||||
? candidate
|
||||
: null;
|
||||
if (raw === null) return null;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
return TRACKING_ID_GRAMMAR.test(normalized) ? normalized : null;
|
||||
@@ -138,18 +154,24 @@ function stringOption(key, token, defaultValue) {
|
||||
token,
|
||||
defaultValue,
|
||||
normalize: (value) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value).trim().toLowerCase();
|
||||
if (typeof value === 'number' && Number.isFinite(value))
|
||||
return String(value).trim().toLowerCase();
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized ? normalized : null;
|
||||
},
|
||||
encode: (value) => String(value),
|
||||
decode: (value) => (typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null),
|
||||
decode: (value) =>
|
||||
typeof value === 'string' && value.trim()
|
||||
? value.trim().toLowerCase()
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
function enumOption(key, token, defaultValue, values, codes) {
|
||||
const reverse = Object.fromEntries(Object.entries(codes).map(([name, code]) => [code, name]));
|
||||
const reverse = Object.fromEntries(
|
||||
Object.entries(codes).map(([name, code]) => [code, name]),
|
||||
);
|
||||
return Object.freeze({
|
||||
key,
|
||||
token,
|
||||
@@ -166,7 +188,8 @@ function integerOption(key, token, defaultValue) {
|
||||
token,
|
||||
defaultValue,
|
||||
normalize: (value) => {
|
||||
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
|
||||
if (typeof value === 'number' && Number.isInteger(value) && value > 0)
|
||||
return value;
|
||||
const candidate = typeof value === 'string' ? value.trim() : '';
|
||||
const parsed = Number(candidate);
|
||||
if (!candidate || !Number.isInteger(parsed) || parsed <= 0) return null;
|
||||
@@ -211,7 +234,10 @@ const OPTION_GROUPS = Object.freeze({
|
||||
trackingIdOption('selectedMilitaryTrackingId', 'u', null),
|
||||
]),
|
||||
satellites: Object.freeze([
|
||||
enumOption('catalog', 'c', 'core', ['core', 'dense'], { core: 'c', dense: 'd' }),
|
||||
enumOption('catalog', 'c', 'core', ['core', 'dense'], {
|
||||
core: 'c',
|
||||
dense: 'd',
|
||||
}),
|
||||
integerOption('selectedSatTrackingId', 't', null),
|
||||
]),
|
||||
cctv: Object.freeze([
|
||||
@@ -238,7 +264,8 @@ const OPTION_GROUPS = Object.freeze({
|
||||
defaultValue: 0.8,
|
||||
normalize: normalizeVolume,
|
||||
encode: (value) => String(Math.round(value * 100)),
|
||||
decode: (value) => (/^\d{1,3}$/.test(value) ? normalizeVolume(Number(value) / 100) : null),
|
||||
decode: (value) =>
|
||||
/^\d{1,3}$/.test(value) ? normalizeVolume(Number(value) / 100) : null,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
@@ -280,45 +307,102 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
Object.freeze({ id: 'bhote-koshi-2026', token: 'h', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'bhote-koshi-locator', token: 'z', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'bikeshare', token: 'b', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'cctv', token: 'c', disposition: 'enabled+options', optionOwner: 'cctv' }),
|
||||
Object.freeze({
|
||||
id: 'cctv',
|
||||
token: 'c',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'cctv',
|
||||
}),
|
||||
Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'flights', token: 'f', disposition: 'enabled+options', optionOwner: 'flights' }),
|
||||
Object.freeze({
|
||||
id: 'flights',
|
||||
token: 'f',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'flights',
|
||||
}),
|
||||
Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'local-datacenters', token: 'd', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'local-datacenters',
|
||||
token: 'd',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'military', token: 'm', disposition: 'enabled+mirrored-options', optionOwner: 'flights' }),
|
||||
Object.freeze({ id: 'military-awareness', token: 'g', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'military-installations', token: 'i', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'radio', token: 'r', disposition: 'enabled+options', optionOwner: 'radio' }),
|
||||
Object.freeze({ id: 'rocket-launches', token: 'x', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'satellites', token: 's', disposition: 'enabled+options', optionOwner: 'satellites' }),
|
||||
Object.freeze({ id: 'telegeography-submarine-cables', token: 'u', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'military',
|
||||
token: 'm',
|
||||
disposition: 'enabled+mirrored-options',
|
||||
optionOwner: 'flights',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'military-awareness',
|
||||
token: 'g',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'military-installations',
|
||||
token: 'i',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'radio',
|
||||
token: 'r',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'radio',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'rocket-launches',
|
||||
token: 'x',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'satellites',
|
||||
token: 's',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'satellites',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'telegeography-submarine-cables',
|
||||
token: 'u',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }),
|
||||
]);
|
||||
|
||||
export const REGISTERED_LAYER_IDS = Object.freeze(LAYER_STATE_REGISTRY.map((entry) => entry.id));
|
||||
export const REGISTERED_LAYER_IDS = Object.freeze(
|
||||
LAYER_STATE_REGISTRY.map((entry) => entry.id),
|
||||
);
|
||||
|
||||
const REGISTRY_BY_ID = new Map(LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]));
|
||||
const REGISTRY_BY_TOKEN = new Map(LAYER_STATE_REGISTRY.map((entry) => [entry.token, entry]));
|
||||
const OPTION_OWNER_IDS = Object.freeze([...new Set(
|
||||
LAYER_STATE_REGISTRY.map((entry) => entry.optionOwner).filter(Boolean),
|
||||
)]);
|
||||
const REGISTRY_BY_ID = new Map(
|
||||
LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]),
|
||||
);
|
||||
const REGISTRY_BY_TOKEN = new Map(
|
||||
LAYER_STATE_REGISTRY.map((entry) => [entry.token, entry]),
|
||||
);
|
||||
const OPTION_OWNER_IDS = Object.freeze([
|
||||
...new Set(
|
||||
LAYER_STATE_REGISTRY.map((entry) => entry.optionOwner).filter(Boolean),
|
||||
),
|
||||
]);
|
||||
|
||||
function optionSpecs(ownerId) {
|
||||
return OPTION_GROUPS[ownerId] || [];
|
||||
}
|
||||
|
||||
function defaultsForOwner(ownerId) {
|
||||
return Object.fromEntries(optionSpecs(ownerId).map((spec) => [spec.key, spec.defaultValue]));
|
||||
return Object.fromEntries(
|
||||
optionSpecs(ownerId).map((spec) => [spec.key, spec.defaultValue]),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeOwnerOptions(ownerId, candidate = {}) {
|
||||
const input = candidate && typeof candidate === 'object' ? candidate : {};
|
||||
const normalized = {};
|
||||
for (const spec of optionSpecs(ownerId)) {
|
||||
const value = Object.hasOwn(input, spec.key) ? spec.normalize(input[spec.key]) : null;
|
||||
const value = Object.hasOwn(input, spec.key)
|
||||
? spec.normalize(input[spec.key])
|
||||
: null;
|
||||
normalized[spec.key] = value === null ? spec.defaultValue : value;
|
||||
}
|
||||
return normalized;
|
||||
@@ -337,12 +421,17 @@ export function validateLayerStateRegistry(registry = LAYER_STATE_REGISTRY) {
|
||||
const ids = new Set();
|
||||
const tokens = new Set();
|
||||
for (const entry of registry) {
|
||||
if (!entry || typeof entry.id !== 'string' || !entry.id) throw new Error('Layer-state entry missing id');
|
||||
if (!/^[a-z0-9-]+$/.test(entry.id)) throw new Error(`Invalid layer-state id: ${entry.id}`);
|
||||
if (ids.has(entry.id)) throw new Error(`Duplicate layer-state id: ${entry.id}`);
|
||||
if (!entry || typeof entry.id !== 'string' || !entry.id)
|
||||
throw new Error('Layer-state entry missing id');
|
||||
if (!/^[a-z0-9-]+$/.test(entry.id))
|
||||
throw new Error(`Invalid layer-state id: ${entry.id}`);
|
||||
if (ids.has(entry.id))
|
||||
throw new Error(`Duplicate layer-state id: ${entry.id}`);
|
||||
ids.add(entry.id);
|
||||
if (!/^[a-z0-9]$/.test(entry.token || '')) throw new Error(`Invalid layer-state token: ${entry.id}`);
|
||||
if (tokens.has(entry.token)) throw new Error(`Duplicate layer-state token: ${entry.token}`);
|
||||
if (!/^[a-z0-9]$/.test(entry.token || ''))
|
||||
throw new Error(`Invalid layer-state token: ${entry.id}`);
|
||||
if (tokens.has(entry.token))
|
||||
throw new Error(`Duplicate layer-state token: ${entry.token}`);
|
||||
tokens.add(entry.token);
|
||||
if (!VALID_DISPOSITIONS.has(entry.disposition)) {
|
||||
throw new Error(`Invalid layer-state disposition: ${entry.id}`);
|
||||
@@ -365,10 +454,9 @@ export function createDefaultLayerState() {
|
||||
return {
|
||||
version: LAYER_STATE_VERSION,
|
||||
enabledLayerIds: [],
|
||||
options: Object.fromEntries(OPTION_OWNER_IDS.map((ownerId) => [
|
||||
ownerId,
|
||||
defaultsForOwner(ownerId),
|
||||
])),
|
||||
options: Object.fromEntries(
|
||||
OPTION_OWNER_IDS.map((ownerId) => [ownerId, defaultsForOwner(ownerId)]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -376,20 +464,28 @@ export function createDefaultLayerState() {
|
||||
export function normalizeLayerState(candidate) {
|
||||
const input = candidate && typeof candidate === 'object' ? candidate : {};
|
||||
const requestedEnabled = new Set(
|
||||
Array.isArray(input.enabledLayerIds) ? input.enabledLayerIds.map(String) : [],
|
||||
Array.isArray(input.enabledLayerIds)
|
||||
? input.enabledLayerIds.map(String)
|
||||
: [],
|
||||
);
|
||||
const enabledLayerIds = REGISTERED_LAYER_IDS.filter((id) =>
|
||||
requestedEnabled.has(id),
|
||||
);
|
||||
const enabledLayerIds = REGISTERED_LAYER_IDS.filter((id) => requestedEnabled.has(id));
|
||||
const enabled = new Set(enabledLayerIds);
|
||||
const options = Object.fromEntries(OPTION_OWNER_IDS.map((ownerId) => [
|
||||
ownerId,
|
||||
normalizeOwnerOptions(ownerId, input.options?.[ownerId]),
|
||||
]));
|
||||
const options = Object.fromEntries(
|
||||
OPTION_OWNER_IDS.map((ownerId) => [
|
||||
ownerId,
|
||||
normalizeOwnerOptions(ownerId, input.options?.[ownerId]),
|
||||
]),
|
||||
);
|
||||
// A selected entity cannot outlive an explicitly disabled owner layer.
|
||||
// Keeping these IDs would resurrect tracking when that layer is enabled
|
||||
// later, even though OFF was newer explicit intent.
|
||||
if (!enabled.has('flights')) options.flights.selectedFlightsTrackingId = null;
|
||||
if (!enabled.has('military')) options.flights.selectedMilitaryTrackingId = null;
|
||||
if (!enabled.has('satellites')) options.satellites.selectedSatTrackingId = null;
|
||||
if (!enabled.has('military'))
|
||||
options.flights.selectedMilitaryTrackingId = null;
|
||||
if (!enabled.has('satellites'))
|
||||
options.satellites.selectedSatTrackingId = null;
|
||||
// The codec has no cross-family recency field, so multiple tracking IDs are
|
||||
// ambiguous rather than an ordered handoff. Fail closed instead of letting
|
||||
// asynchronous feed arrival decide which tracker and camera owner wins.
|
||||
@@ -416,7 +512,10 @@ export function cloneLayerState(state) {
|
||||
...normalized,
|
||||
enabledLayerIds: [...normalized.enabledLayerIds],
|
||||
options: Object.fromEntries(
|
||||
Object.entries(normalized.options).map(([id, options]) => [id, { ...options }]),
|
||||
Object.entries(normalized.options).map(([id, options]) => [
|
||||
id,
|
||||
{ ...options },
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -425,10 +524,12 @@ export function cloneLayerState(state) {
|
||||
export function encodeLayerStateParams(params, state) {
|
||||
const normalized = normalizeLayerState(state);
|
||||
const enabled = new Set(normalized.enabledLayerIds);
|
||||
params.set('l', LAYER_STATE_REGISTRY
|
||||
.filter((entry) => enabled.has(entry.id))
|
||||
.map((entry) => entry.token)
|
||||
.join('.'));
|
||||
params.set(
|
||||
'l',
|
||||
LAYER_STATE_REGISTRY.filter((entry) => enabled.has(entry.id))
|
||||
.map((entry) => entry.token)
|
||||
.join('.'),
|
||||
);
|
||||
const encodedOptions = [];
|
||||
for (const ownerId of OPTION_OWNER_IDS) {
|
||||
const ownerEntry = REGISTRY_BY_ID.get(ownerId);
|
||||
@@ -438,7 +539,9 @@ export function encodeLayerStateParams(params, state) {
|
||||
// current default — those are the same thing for every option whose
|
||||
// default never moved, and deliberately different for one whose did.
|
||||
if (ownerOptions[spec.key] === absentTokenValue(spec)) continue;
|
||||
encodedOptions.push(`${ownerEntry.token}.${spec.token}.${spec.encode(ownerOptions[spec.key])}`);
|
||||
encodedOptions.push(
|
||||
`${ownerEntry.token}.${spec.token}.${spec.encode(ownerOptions[spec.key])}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (encodedOptions.length) params.set('lo', encodedOptions.join('_'));
|
||||
@@ -448,7 +551,8 @@ export function encodeLayerStateParams(params, state) {
|
||||
|
||||
/** Decode v2 fields. Null means that the layer payload is absent. */
|
||||
export function decodeLayerStateParams(params) {
|
||||
if (params.get('v') !== String(LAYER_STATE_VERSION) || !params.has('l')) return null;
|
||||
if (params.get('v') !== String(LAYER_STATE_VERSION) || !params.has('l'))
|
||||
return null;
|
||||
const rawLayers = String(params.get('l') || '');
|
||||
const rawOptionsField = String(params.get('lo') || '');
|
||||
// Fail closed on an oversized payload rather than decoding a truncated one.
|
||||
@@ -459,16 +563,21 @@ export function decodeLayerStateParams(params) {
|
||||
// set containing an unknown member rejects the complete layer payload so a
|
||||
// typo or future token cannot silently become an authoritative empty set.
|
||||
if (layerTokens.some((token) => !REGISTRY_BY_TOKEN.has(token))) return null;
|
||||
const enabledLayerIds = layerTokens.map((token) => REGISTRY_BY_TOKEN.get(token).id);
|
||||
const enabledLayerIds = layerTokens.map(
|
||||
(token) => REGISTRY_BY_TOKEN.get(token).id,
|
||||
);
|
||||
const rawOptions = {};
|
||||
for (const assignment of rawOptionsField.split('_')) {
|
||||
if (!assignment) continue;
|
||||
const [layerToken, optionToken, encodedValue, ...extra] = assignment.split('.');
|
||||
const [layerToken, optionToken, encodedValue, ...extra] =
|
||||
assignment.split('.');
|
||||
if (extra.length) continue;
|
||||
const entry = REGISTRY_BY_TOKEN.get(layerToken);
|
||||
const ownerId = entry?.optionOwner || null;
|
||||
if (!ownerId) continue;
|
||||
const spec = optionSpecs(ownerId).find((candidate) => candidate.token === optionToken);
|
||||
const spec = optionSpecs(ownerId).find(
|
||||
(candidate) => candidate.token === optionToken,
|
||||
);
|
||||
if (!spec) continue;
|
||||
const decoded = spec.decode(encodedValue);
|
||||
if (decoded === null) continue;
|
||||
@@ -482,7 +591,8 @@ export function decodeLayerStateParams(params) {
|
||||
// saying "whatever the default happens to be today". See `absentTokenValue`.
|
||||
for (const ownerId of OPTION_OWNER_IDS) {
|
||||
for (const spec of optionSpecs(ownerId)) {
|
||||
if (rawOptions[ownerId] && Object.hasOwn(rawOptions[ownerId], spec.key)) continue;
|
||||
if (rawOptions[ownerId] && Object.hasOwn(rawOptions[ownerId], spec.key))
|
||||
continue;
|
||||
if (!rawOptions[ownerId]) rawOptions[ownerId] = {};
|
||||
rawOptions[ownerId][spec.key] = absentTokenValue(spec);
|
||||
}
|
||||
@@ -504,8 +614,12 @@ export function parseStoredLayerState(raw) {
|
||||
if (typeof raw !== 'string' || !raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.v !== LAYER_STATE_VERSION || !Array.isArray(parsed.l)) return null;
|
||||
return normalizeLayerState({ enabledLayerIds: parsed.l, options: parsed.o });
|
||||
if (parsed?.v !== LAYER_STATE_VERSION || !Array.isArray(parsed.l))
|
||||
return null;
|
||||
return normalizeLayerState({
|
||||
enabledLayerIds: parsed.l,
|
||||
options: parsed.o,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -519,7 +633,11 @@ export function layerOptionsForRestore(state, layerId) {
|
||||
}
|
||||
|
||||
function safeStorage() {
|
||||
try { return globalThis.localStorage || null; } catch { return null; }
|
||||
try {
|
||||
return globalThis.localStorage || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function currentLayerOutcome(dataManager, layerId) {
|
||||
@@ -536,19 +654,25 @@ function currentLayerOutcome(dataManager, layerId) {
|
||||
* choreography, and coordinates passive post-registration restoration.
|
||||
*/
|
||||
export class LayerStateCoordinator {
|
||||
constructor(dataManager, shareLinkManager, {
|
||||
storage = safeStorage(),
|
||||
restoreGate = null,
|
||||
onDurableStateChange = null,
|
||||
onTrackingRestoreStatus = null,
|
||||
now = () => Date.now(),
|
||||
// Injectable so the pending-window behavior is deterministically testable
|
||||
// without sleeping out a 90 s expiry.
|
||||
setTimer = (fn, ms) => setTimeout(fn, ms),
|
||||
clearTimer = (handle) => clearTimeout(handle),
|
||||
} = {}) {
|
||||
constructor(
|
||||
dataManager,
|
||||
shareLinkManager,
|
||||
{
|
||||
storage = safeStorage(),
|
||||
restoreGate = null,
|
||||
onDurableStateChange = null,
|
||||
onTrackingRestoreStatus = null,
|
||||
now = () => Date.now(),
|
||||
// Injectable so the pending-window behavior is deterministically testable
|
||||
// without sleeping out a 90 s expiry.
|
||||
setTimer = (fn, ms) => setTimeout(fn, ms),
|
||||
clearTimer = (handle) => clearTimeout(handle),
|
||||
} = {},
|
||||
) {
|
||||
if (!dataManager?.registrationsFinalized) {
|
||||
throw new Error('Layer state requires finalized data-layer registrations');
|
||||
throw new Error(
|
||||
'Layer state requires finalized data-layer registrations',
|
||||
);
|
||||
}
|
||||
this.dataManager = dataManager;
|
||||
this.shareLinkManager = shareLinkManager || null;
|
||||
@@ -568,23 +692,41 @@ export class LayerStateCoordinator {
|
||||
this._trackingRestoreGeneration = 0;
|
||||
this._pendingTrackingTimer = null;
|
||||
this._pendingTrackingContext = null;
|
||||
this._unsubscribe = this.dataManager.subscribe((change) => this._handleManagerChange(change));
|
||||
this._unsubscribeVisibilityRequests = this.dataManager.subscribeVisibilityRequests(
|
||||
(change) => this._handleVisibilityRequest(change),
|
||||
this._unsubscribe = this.dataManager.subscribe((change) =>
|
||||
this._handleManagerChange(change),
|
||||
);
|
||||
this._unsubscribeVisibilityRequests =
|
||||
this.dataManager.subscribeVisibilityRequests((change) =>
|
||||
this._handleVisibilityRequest(change),
|
||||
);
|
||||
this.restorePromise = Promise.resolve([]);
|
||||
this.lastRestoreResults = [];
|
||||
}
|
||||
|
||||
start({ shareLayerState = null, allowLocalState = true, shareCreatedAtMs = null } = {}) {
|
||||
if (this._destroyed) throw new Error('Layer-state coordinator is destroyed');
|
||||
let selected = shareLayerState ? normalizeLayerState(shareLayerState) : null;
|
||||
start({
|
||||
shareLayerState = null,
|
||||
allowLocalState = true,
|
||||
shareCreatedAtMs = null,
|
||||
} = {}) {
|
||||
if (this._destroyed)
|
||||
throw new Error('Layer-state coordinator is destroyed');
|
||||
let selected = shareLayerState
|
||||
? normalizeLayerState(shareLayerState)
|
||||
: null;
|
||||
if (selected) {
|
||||
this._source = 'share';
|
||||
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null;
|
||||
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs)
|
||||
? shareCreatedAtMs
|
||||
: null;
|
||||
} else if (allowLocalState) {
|
||||
let stored = null;
|
||||
try { stored = parseStoredLayerState(this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY)); } catch { /* best effort */ }
|
||||
try {
|
||||
stored = parseStoredLayerState(
|
||||
this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY),
|
||||
);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
if (stored) {
|
||||
selected = stored;
|
||||
this._source = 'local';
|
||||
@@ -596,12 +738,16 @@ export class LayerStateCoordinator {
|
||||
this._source = 'legacy-share';
|
||||
}
|
||||
this._durableState = selected || createDefaultLayerState();
|
||||
this.shareLinkManager?.setLayerStateProvider?.(() => this.getDurableState());
|
||||
this.shareLinkManager?.setLayerStateProvider?.(() =>
|
||||
this.getDurableState(),
|
||||
);
|
||||
this.shareLinkManager?.onLayerStateChange?.();
|
||||
this._notifyDurableState();
|
||||
if (!selected) return this.restorePromise;
|
||||
this.restorePromise = this._restoreSelectedState(
|
||||
this._source === 'share' ? LAYER_RESTORE_ORIGINS.share : LAYER_RESTORE_ORIGINS.local,
|
||||
this._source === 'share'
|
||||
? LAYER_RESTORE_ORIGINS.share
|
||||
: LAYER_RESTORE_ORIGINS.local,
|
||||
);
|
||||
return this.restorePromise;
|
||||
}
|
||||
@@ -615,12 +761,18 @@ export class LayerStateCoordinator {
|
||||
}
|
||||
|
||||
_notifyDurableState() {
|
||||
try { this.onDurableStateChange?.(this.getDurableState()); } catch { /* UI sync is best effort */ }
|
||||
try {
|
||||
this.onDurableStateChange?.(this.getDurableState());
|
||||
} catch {
|
||||
/* UI sync is best effort */
|
||||
}
|
||||
}
|
||||
|
||||
_handleVisibilityRequest(change) {
|
||||
if (!isExplicitLayerStateOrigin(change?.origin)) return;
|
||||
this._restoreControllers.get(change.layerId)?.abort('superseded-by-explicit-visibility');
|
||||
this._restoreControllers
|
||||
.get(change.layerId)
|
||||
?.abort('superseded-by-explicit-visibility');
|
||||
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
||||
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
|
||||
}
|
||||
@@ -628,7 +780,8 @@ export class LayerStateCoordinator {
|
||||
|
||||
/** Revoke every passive restore before explicit navigation can be reclaimed. */
|
||||
cancelPendingRestores(reason = 'superseded-by-explicit-navigation') {
|
||||
for (const controller of this._restoreControllers.values()) controller.abort(reason);
|
||||
for (const controller of this._restoreControllers.values())
|
||||
controller.abort(reason);
|
||||
this._revokePendingTrackingWatch(reason);
|
||||
}
|
||||
|
||||
@@ -636,9 +789,10 @@ export class LayerStateCoordinator {
|
||||
* Revoke a pending shared Follow. Physical navigation may also clear only
|
||||
* the exact passive selection, without writing recipient preferences.
|
||||
*/
|
||||
cancelPendingShareTracking(reason = 'superseded-by-explicit-navigation', {
|
||||
clearSelection = false,
|
||||
} = {}) {
|
||||
cancelPendingShareTracking(
|
||||
reason = 'superseded-by-explicit-navigation',
|
||||
{ clearSelection = false } = {},
|
||||
) {
|
||||
this._revokePendingTrackingWatch(reason);
|
||||
if (!clearSelection) return false;
|
||||
const selected = this._selectedShareTrackingTarget();
|
||||
@@ -651,8 +805,10 @@ export class LayerStateCoordinator {
|
||||
// option request may replace passive share options, but it must not abort
|
||||
// the same layer's visibility lifecycle.
|
||||
if (change.type === 'params-requested') {
|
||||
if (isExplicitLayerStateOrigin(change.origin)
|
||||
&& SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
||||
if (
|
||||
isExplicitLayerStateOrigin(change.origin) &&
|
||||
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
|
||||
) {
|
||||
this._revokePendingTrackingWatch('superseded-by-explicit-params');
|
||||
}
|
||||
return;
|
||||
@@ -660,21 +816,28 @@ export class LayerStateCoordinator {
|
||||
// A layer that goes away takes its latch with it, at ANY origin — a
|
||||
// programmatic disable or teardown never reaches the explicit-intent path
|
||||
// below, so revoke here before that early return.
|
||||
if (change.type === 'visibility'
|
||||
&& change.enabled === false
|
||||
&& SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
||||
if (
|
||||
change.type === 'visibility' &&
|
||||
change.enabled === false &&
|
||||
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
|
||||
) {
|
||||
this._revokePendingTrackingWatch('owner-layer-disabled');
|
||||
}
|
||||
if (!isExplicitLayerStateOrigin(change.origin)) return;
|
||||
if (change.type === 'visibility') {
|
||||
this._restoreControllers.get(change.layerId)?.abort('superseded-by-explicit-visibility');
|
||||
this._restoreControllers
|
||||
.get(change.layerId)
|
||||
?.abort('superseded-by-explicit-visibility');
|
||||
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
||||
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
|
||||
}
|
||||
const enabled = new Set(this._durableState.enabledLayerIds);
|
||||
if (change.enabled) enabled.add(change.layerId);
|
||||
else enabled.delete(change.layerId);
|
||||
this._commitExplicit({ ...this._durableState, enabledLayerIds: [...enabled] });
|
||||
this._commitExplicit({
|
||||
...this._durableState,
|
||||
enabledLayerIds: [...enabled],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (change.type !== 'params') return;
|
||||
@@ -683,7 +846,8 @@ export class LayerStateCoordinator {
|
||||
const ownerId = entry.optionOwner;
|
||||
const nextOwnerOptions = { ...this._durableState.options[ownerId] };
|
||||
const requestedParams = change.requestedParams || {};
|
||||
const trackingOptionKey = TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null;
|
||||
const trackingOptionKey =
|
||||
TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null;
|
||||
let changed = false;
|
||||
for (const spec of optionSpecs(ownerId)) {
|
||||
// Only persist keys present in this explicit request. getLayerParams()
|
||||
@@ -693,7 +857,8 @@ export class LayerStateCoordinator {
|
||||
// in that family, so its wider live value (active ID or null) must replace
|
||||
// the formerly durable pending ID instead of allowing reload resurrection.
|
||||
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
|
||||
const implicitTrackingSync = !explicitlyRequested && spec.key === trackingOptionKey;
|
||||
const implicitTrackingSync =
|
||||
!explicitlyRequested && spec.key === trackingOptionKey;
|
||||
if (!explicitlyRequested && !implicitTrackingSync) continue;
|
||||
const value = spec.normalize(change.params[spec.key]);
|
||||
if (value === null) {
|
||||
@@ -702,7 +867,8 @@ export class LayerStateCoordinator {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (spec.defaultValue !== null || change.params[spec.key] !== null) continue;
|
||||
if (spec.defaultValue !== null || change.params[spec.key] !== null)
|
||||
continue;
|
||||
}
|
||||
nextOwnerOptions[spec.key] = value;
|
||||
changed = true;
|
||||
@@ -721,14 +887,18 @@ export class LayerStateCoordinator {
|
||||
if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) {
|
||||
this.storage?.setItem?.(LAYER_STATE_STORAGE_KEY, serialized);
|
||||
}
|
||||
} catch { /* storage can be unavailable or quota-limited */ }
|
||||
} catch {
|
||||
/* storage can be unavailable or quota-limited */
|
||||
}
|
||||
this.shareLinkManager?.onLayerStateChange?.();
|
||||
this._notifyDurableState();
|
||||
}
|
||||
|
||||
async _waitForRestoreGate() {
|
||||
if (!this.restoreGate) return;
|
||||
await (typeof this.restoreGate === 'function' ? this.restoreGate() : this.restoreGate);
|
||||
await (typeof this.restoreGate === 'function'
|
||||
? this.restoreGate()
|
||||
: this.restoreGate);
|
||||
}
|
||||
|
||||
async _restoreSelectedState(origin) {
|
||||
@@ -738,44 +908,58 @@ export class LayerStateCoordinator {
|
||||
try {
|
||||
await this._waitForRestoreGate();
|
||||
const enabled = new Set(this._durableState.enabledLayerIds);
|
||||
const settled = await Promise.allSettled(LAYER_STATE_REGISTRY.map(async (entry) => {
|
||||
const controller = this._restoreControllers.get(entry.id);
|
||||
const targetEnabled = enabled.has(entry.id);
|
||||
const options = layerOptionsForRestore(this._durableState, entry.id);
|
||||
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
|
||||
for (const trackingKey of Object.values(TRACKING_OPTION_KEY_BY_LAYER)) {
|
||||
delete options[trackingKey];
|
||||
const settled = await Promise.allSettled(
|
||||
LAYER_STATE_REGISTRY.map(async (entry) => {
|
||||
const controller = this._restoreControllers.get(entry.id);
|
||||
const targetEnabled = enabled.has(entry.id);
|
||||
const options = layerOptionsForRestore(this._durableState, entry.id);
|
||||
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
|
||||
for (const trackingKey of Object.values(
|
||||
TRACKING_OPTION_KEY_BY_LAYER,
|
||||
)) {
|
||||
delete options[trackingKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this._destroyed || controller?.signal.aborted) {
|
||||
return {
|
||||
layerId: entry.id,
|
||||
targetEnabled,
|
||||
origin,
|
||||
phase: 'reserved',
|
||||
...currentLayerOutcome(this.dataManager, entry.id),
|
||||
appliedOptions: {},
|
||||
cancellationReason: this._destroyed ? 'destroyed' : 'superseded',
|
||||
errorClass: 'cancelled',
|
||||
persistenceWrite: false,
|
||||
succeeded: false,
|
||||
};
|
||||
}
|
||||
// Reserve passive option state before any asynchronous lifecycle work.
|
||||
// A later explicit params intent then wins on its own lane without
|
||||
// cancelling or being overwritten by the visibility restore.
|
||||
const paramsSucceeded = !options || Object.keys(options).length === 0
|
||||
|| this.dataManager.setLayerParams(entry.id, options, { origin });
|
||||
return this.dataManager.restoreLayerState(entry.id, {
|
||||
enabled: targetEnabled,
|
||||
params: null,
|
||||
}, { origin, signal: controller.signal }).then((result) => ({
|
||||
...result,
|
||||
appliedOptions: paramsSucceeded && options ? options : {},
|
||||
errorClass: paramsSucceeded ? result.errorClass : 'ParamsRejected',
|
||||
succeeded: paramsSucceeded && result.succeeded,
|
||||
}));
|
||||
}));
|
||||
if (this._destroyed || controller?.signal.aborted) {
|
||||
return {
|
||||
layerId: entry.id,
|
||||
targetEnabled,
|
||||
origin,
|
||||
phase: 'reserved',
|
||||
...currentLayerOutcome(this.dataManager, entry.id),
|
||||
appliedOptions: {},
|
||||
cancellationReason: this._destroyed ? 'destroyed' : 'superseded',
|
||||
errorClass: 'cancelled',
|
||||
persistenceWrite: false,
|
||||
succeeded: false,
|
||||
};
|
||||
}
|
||||
// Reserve passive option state before any asynchronous lifecycle work.
|
||||
// A later explicit params intent then wins on its own lane without
|
||||
// cancelling or being overwritten by the visibility restore.
|
||||
const paramsSucceeded =
|
||||
!options ||
|
||||
Object.keys(options).length === 0 ||
|
||||
this.dataManager.setLayerParams(entry.id, options, { origin });
|
||||
return this.dataManager
|
||||
.restoreLayerState(
|
||||
entry.id,
|
||||
{
|
||||
enabled: targetEnabled,
|
||||
params: null,
|
||||
},
|
||||
{ origin, signal: controller.signal },
|
||||
)
|
||||
.then((result) => ({
|
||||
...result,
|
||||
appliedOptions: paramsSucceeded && options ? options : {},
|
||||
errorClass: paramsSucceeded
|
||||
? result.errorClass
|
||||
: 'ParamsRejected',
|
||||
succeeded: paramsSucceeded && result.succeeded,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
this.lastRestoreResults = settled.map((result, index) => {
|
||||
if (result.status === 'fulfilled') return result.value;
|
||||
const entry = LAYER_STATE_REGISTRY[index];
|
||||
@@ -802,8 +986,11 @@ export class LayerStateCoordinator {
|
||||
|
||||
_selectedShareTrackingTarget() {
|
||||
if (this._source !== 'share') return null;
|
||||
for (const [layerId, policy] of Object.entries(SHARE_TRACKING_RESTORE_POLICIES)) {
|
||||
const targetId = this._durableState.options?.[policy.optionOwner]?.[policy.optionKey];
|
||||
for (const [layerId, policy] of Object.entries(
|
||||
SHARE_TRACKING_RESTORE_POLICIES,
|
||||
)) {
|
||||
const targetId =
|
||||
this._durableState.options?.[policy.optionOwner]?.[policy.optionKey];
|
||||
if (targetId !== null && targetId !== undefined && targetId !== '') {
|
||||
return { layerId, targetId, ...policy };
|
||||
}
|
||||
@@ -812,7 +999,8 @@ export class LayerStateCoordinator {
|
||||
}
|
||||
|
||||
_passivelyClearTrackingSelection(selected) {
|
||||
const current = this._durableState.options?.[selected.optionOwner]?.[selected.optionKey];
|
||||
const current =
|
||||
this._durableState.options?.[selected.optionOwner]?.[selected.optionKey];
|
||||
if (String(current) !== String(selected.targetId)) return false;
|
||||
const ownerOptions = {
|
||||
...this._durableState.options[selected.optionOwner],
|
||||
@@ -852,13 +1040,17 @@ export class LayerStateCoordinator {
|
||||
_trackingTargetLatched(selected) {
|
||||
const params = this.dataManager.getLayerParams?.(selected.layerId);
|
||||
const active = params?.[selected.optionKey];
|
||||
return active !== null && active !== undefined
|
||||
&& String(active) === String(selected.targetId);
|
||||
return (
|
||||
active !== null &&
|
||||
active !== undefined &&
|
||||
String(active) === String(selected.targetId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Stop watching a pending shared subject without deciding its fate. */
|
||||
_cancelPendingTrackingWatch() {
|
||||
if (this._pendingTrackingTimer !== null) this.clearTimer(this._pendingTrackingTimer);
|
||||
if (this._pendingTrackingTimer !== null)
|
||||
this.clearTimer(this._pendingTrackingTimer);
|
||||
this._pendingTrackingTimer = null;
|
||||
const pending = this._pendingTrackingContext;
|
||||
if (pending?.signal && pending.abortHandler) {
|
||||
@@ -869,7 +1061,11 @@ export class LayerStateCoordinator {
|
||||
|
||||
/** Publish a share-follow lifecycle update without allowing UI errors to own state. */
|
||||
_publishTrackingRestoreStatus(status) {
|
||||
try { this.onTrackingRestoreStatus?.(status); } catch { /* status UI is best effort */ }
|
||||
try {
|
||||
this.onTrackingRestoreStatus?.(status);
|
||||
} catch {
|
||||
/* status UI is best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -929,15 +1125,20 @@ export class LayerStateCoordinator {
|
||||
let armed = false;
|
||||
let armError = null;
|
||||
try {
|
||||
armed = await this.dataManager.setLayerParams?.(
|
||||
selected.layerId,
|
||||
{ [selected.optionKey]: selected.targetId },
|
||||
{ origin: LAYER_RESTORE_ORIGINS.share },
|
||||
) === true;
|
||||
armed =
|
||||
(await this.dataManager.setLayerParams?.(
|
||||
selected.layerId,
|
||||
{ [selected.optionKey]: selected.targetId },
|
||||
{ origin: LAYER_RESTORE_ORIGINS.share },
|
||||
)) === true;
|
||||
} catch (error) {
|
||||
armError = error;
|
||||
}
|
||||
if (this._destroyed || generation !== this._trackingRestoreGeneration || signal?.aborted) {
|
||||
if (
|
||||
this._destroyed ||
|
||||
generation !== this._trackingRestoreGeneration ||
|
||||
signal?.aborted
|
||||
) {
|
||||
if (armed) {
|
||||
this.dataManager.cancelPendingLayerRestore?.(selected.layerId, {
|
||||
origin: LAYER_RESTORE_ORIGINS.share,
|
||||
@@ -959,7 +1160,9 @@ export class LayerStateCoordinator {
|
||||
...selected,
|
||||
status: 'source-unavailable',
|
||||
classification: 'source-unavailable',
|
||||
reason: String(armError?.message || armError || 'tracking restore latch rejected'),
|
||||
reason: String(
|
||||
armError?.message || armError || 'tracking restore latch rejected',
|
||||
),
|
||||
cleared: this._passivelyClearTrackingSelection(selected),
|
||||
};
|
||||
this._publishTrackingRestoreStatus(terminal);
|
||||
@@ -974,7 +1177,8 @@ export class LayerStateCoordinator {
|
||||
};
|
||||
const poll = () => {
|
||||
this._pendingTrackingTimer = null;
|
||||
if (this._destroyed || generation !== this._trackingRestoreGeneration) return;
|
||||
if (this._destroyed || generation !== this._trackingRestoreGeneration)
|
||||
return;
|
||||
// The layer that owns the latch may have gone away since the last tick
|
||||
// (disable, teardown, replacement). There is nothing left attempting this
|
||||
// restore, so abandon it silently rather than announcing a verdict.
|
||||
@@ -983,23 +1187,39 @@ export class LayerStateCoordinator {
|
||||
return;
|
||||
}
|
||||
if (this._trackingTargetLatched(selected)) {
|
||||
settle({ ...probe, ...selected, status: 'found', classification: 'followed', cleared: false });
|
||||
settle({
|
||||
...probe,
|
||||
...selected,
|
||||
status: 'found',
|
||||
classification: 'followed',
|
||||
cleared: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.now() >= deadline) {
|
||||
// The window really has elapsed — only now does the verdict apply.
|
||||
const classification = probe.status === 'missing'
|
||||
? this._classifyMissingTrackingTarget(selected, absentAtMs)
|
||||
: 'source-unavailable';
|
||||
const classification =
|
||||
probe.status === 'missing'
|
||||
? this._classifyMissingTrackingTarget(selected, absentAtMs)
|
||||
: 'source-unavailable';
|
||||
const cleared = this._passivelyClearTrackingSelection(selected);
|
||||
settle({ ...probe, ...selected, classification, cleared });
|
||||
return;
|
||||
}
|
||||
this._pendingTrackingTimer = this.setTimer(poll, PENDING_TRACKING_POLL_MS);
|
||||
this._pendingTrackingTimer = this.setTimer(
|
||||
poll,
|
||||
PENDING_TRACKING_POLL_MS,
|
||||
);
|
||||
this._pendingTrackingTimer?.unref?.();
|
||||
};
|
||||
this._cancelPendingTrackingWatch();
|
||||
const pending = { ...probe, ...selected, status: 'pending', classification: 'pending', cleared: false };
|
||||
const pending = {
|
||||
...probe,
|
||||
...selected,
|
||||
status: 'pending',
|
||||
classification: 'pending',
|
||||
cleared: false,
|
||||
};
|
||||
const pendingContext = {
|
||||
generation,
|
||||
selected,
|
||||
@@ -1012,7 +1232,9 @@ export class LayerStateCoordinator {
|
||||
if (this._pendingTrackingContext?.generation !== generation) return;
|
||||
this._revokePendingTrackingWatch(signal.reason || 'aborted');
|
||||
};
|
||||
signal.addEventListener('abort', pendingContext.abortHandler, { once: true });
|
||||
signal.addEventListener('abort', pendingContext.abortHandler, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
this._pendingTrackingContext = pendingContext;
|
||||
this._publishTrackingRestoreStatus(pending);
|
||||
@@ -1027,7 +1249,8 @@ export class LayerStateCoordinator {
|
||||
*/
|
||||
async restoreShareTrackingSelection({ signal = null } = {}) {
|
||||
const selected = this._selectedShareTrackingTarget();
|
||||
if (!selected || this._destroyed) return { status: 'skipped', reason: 'no-shared-target' };
|
||||
if (!selected || this._destroyed)
|
||||
return { status: 'skipped', reason: 'no-shared-target' };
|
||||
this._revokePendingTrackingWatch('superseded-by-newer-restore');
|
||||
const controller = new AbortController();
|
||||
const combinedSignal = signal
|
||||
@@ -1044,33 +1267,60 @@ export class LayerStateCoordinator {
|
||||
);
|
||||
} catch (error) {
|
||||
result = combinedSignal.aborted
|
||||
? { status: 'cancelled', reason: String(combinedSignal.reason || 'aborted') }
|
||||
: { status: 'source-unavailable', reason: String(error?.message || error) };
|
||||
? {
|
||||
status: 'cancelled',
|
||||
reason: String(combinedSignal.reason || 'aborted'),
|
||||
}
|
||||
: {
|
||||
status: 'source-unavailable',
|
||||
reason: String(error?.message || error),
|
||||
};
|
||||
}
|
||||
if (generation !== this._trackingRestoreGeneration || this._destroyed || combinedSignal.aborted) {
|
||||
if (this._trackingRestoreController === controller) this._trackingRestoreController = null;
|
||||
return { ...result, status: 'cancelled', reason: String(combinedSignal.reason || 'superseded') };
|
||||
if (
|
||||
generation !== this._trackingRestoreGeneration ||
|
||||
this._destroyed ||
|
||||
combinedSignal.aborted
|
||||
) {
|
||||
if (this._trackingRestoreController === controller)
|
||||
this._trackingRestoreController = null;
|
||||
return {
|
||||
...result,
|
||||
status: 'cancelled',
|
||||
reason: String(combinedSignal.reason || 'superseded'),
|
||||
};
|
||||
}
|
||||
if (this._trackingRestoreController === controller) this._trackingRestoreController = null;
|
||||
if (this._trackingRestoreController === controller)
|
||||
this._trackingRestoreController = null;
|
||||
|
||||
if (result.status === 'found') {
|
||||
const terminal = { ...result, ...selected, classification: 'followed', cleared: false };
|
||||
const terminal = {
|
||||
...result,
|
||||
...selected,
|
||||
classification: 'followed',
|
||||
cleared: false,
|
||||
};
|
||||
this._publishTrackingRestoreStatus(terminal);
|
||||
return terminal;
|
||||
}
|
||||
if (['cancelled', 'superseded', 'destroyed'].includes(result.status)) return result;
|
||||
if (['cancelled', 'superseded', 'destroyed'].includes(result.status))
|
||||
return result;
|
||||
|
||||
// A subject that is simply not here YET is not a subject that is gone. Hold
|
||||
// it on the layer's own deferred-restore latch for its source-specific
|
||||
// window before any verdict is reached or shown. `unsupported` layers have
|
||||
// no latch to arm, so they still decide immediately.
|
||||
if (result.status === 'missing' || result.status === 'source-unavailable') {
|
||||
return this._beginPendingTrackingRestore(selected, result, combinedSignal);
|
||||
return this._beginPendingTrackingRestore(
|
||||
selected,
|
||||
result,
|
||||
combinedSignal,
|
||||
);
|
||||
}
|
||||
|
||||
const classification = result.status === 'missing'
|
||||
? this._classifyMissingTrackingTarget(selected)
|
||||
: 'source-unavailable';
|
||||
const classification =
|
||||
result.status === 'missing'
|
||||
? this._classifyMissingTrackingTarget(selected)
|
||||
: 'source-unavailable';
|
||||
const cleared = this._passivelyClearTrackingSelection(selected);
|
||||
const terminal = { ...result, ...selected, classification, cleared };
|
||||
this._publishTrackingRestoreStatus(terminal);
|
||||
@@ -1080,7 +1330,8 @@ export class LayerStateCoordinator {
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
this._destroyed = true;
|
||||
for (const controller of this._restoreControllers.values()) controller.abort('coordinator-destroyed');
|
||||
for (const controller of this._restoreControllers.values())
|
||||
controller.abort('coordinator-destroyed');
|
||||
this._restoreControllers.clear();
|
||||
this._revokePendingTrackingWatch('coordinator-destroyed');
|
||||
this._trackingRestoreController = null;
|
||||
|
||||
@@ -11,8 +11,10 @@ export * from './localGeojsonCore.js';
|
||||
export function createLocalGeoJsonLayer(options) {
|
||||
return createLayer(options, {
|
||||
...localGeoJsonServices,
|
||||
overlayHost: options.overlayHost === undefined
|
||||
? localGeoJsonServices.overlayHost : options.overlayHost,
|
||||
overlayHost:
|
||||
options.overlayHost === undefined
|
||||
? localGeoJsonServices.overlayHost
|
||||
: options.overlayHost,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +22,9 @@ export function createLocalGeoJsonLayer(options) {
|
||||
export function createLocalInfrastructureOverlayPublisher(options) {
|
||||
return createPublisher({
|
||||
...options,
|
||||
host: options.host === undefined ? localGeoJsonServices.overlayHost : options.host,
|
||||
host:
|
||||
options.host === undefined
|
||||
? localGeoJsonServices.overlayHost
|
||||
: options.host,
|
||||
});
|
||||
}
|
||||
@@ -15,9 +15,4 @@ const fires = createFirmsHeatmapLayer({
|
||||
source: 'NASA FIRMS · LIVE',
|
||||
});
|
||||
|
||||
export default [
|
||||
datacenters,
|
||||
dams,
|
||||
submarineCablesLayer,
|
||||
fires,
|
||||
];
|
||||
export default [datacenters, dams, submarineCablesLayer, fires];
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createMeshFloorSampler } from '../services/meshFloorSampler.js';
|
||||
import * as groundFloor from './groundFloor.js';
|
||||
const meshFloorSampler = createMeshFloorSampler({ groundFloor, eventTarget: typeof window === 'undefined' ? null : window });
|
||||
const meshFloorSampler = createMeshFloorSampler({
|
||||
groundFloor,
|
||||
eventTarget: typeof window === 'undefined' ? null : window,
|
||||
});
|
||||
export const { sampleMeshFloorCells, cachedGroundFloor } = meshFloorSampler;
|
||||
@@ -18,10 +18,13 @@ export const AWARENESS_RELATIONSHIP = Object.freeze({
|
||||
* @param {{initialRadiusM?: number, maxRadiusM?: number}} [options] Bounds.
|
||||
* @returns {{candidate: T, radiusM: number}|null} First match and radius used.
|
||||
*/
|
||||
export function findByDoublingRadius(search, {
|
||||
initialRadiusM = AWARENESS_RADIUS_M,
|
||||
maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
|
||||
} = {}) {
|
||||
export function findByDoublingRadius(
|
||||
search,
|
||||
{
|
||||
initialRadiusM = AWARENESS_RADIUS_M,
|
||||
maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
|
||||
} = {},
|
||||
) {
|
||||
if (typeof search !== 'function') return null;
|
||||
if (!Number.isFinite(initialRadiusM) || initialRadiusM <= 0) return null;
|
||||
if (!Number.isFinite(maxRadiusM) || maxRadiusM < initialRadiusM) return null;
|
||||
@@ -39,13 +42,22 @@ export function findByDoublingRadius(search, {
|
||||
/** @param {number} meters @returns {string} */
|
||||
export function formatAwarenessDistance(meters) {
|
||||
if (!Number.isFinite(meters) || meters < 0) return '—';
|
||||
return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
|
||||
return meters < 1000
|
||||
? `${Math.round(meters)} m`
|
||||
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
|
||||
}
|
||||
|
||||
/** Return a stable visible label without leaking missing values into UI text. */
|
||||
export function formatAwarenessLabel(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
for (const candidate of [value.callsign, value.name, value.label, value.id, value.icao24, value.mmsi]) {
|
||||
for (const candidate of [
|
||||
value.callsign,
|
||||
value.name,
|
||||
value.label,
|
||||
value.id,
|
||||
value.icao24,
|
||||
value.mmsi,
|
||||
]) {
|
||||
const label = formatAwarenessLabel(candidate);
|
||||
if (label !== '—') return label;
|
||||
}
|
||||
@@ -61,19 +73,41 @@ export function formatAwarenessLabel(value) {
|
||||
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range
|
||||
* claim in this MVP.
|
||||
*/
|
||||
export function summarizeAwarenessCohort(items, { available = true, stale = false, limit = 3 } = {}) {
|
||||
if (!available) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed unavailable' };
|
||||
if (stale) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed stale' };
|
||||
export function summarizeAwarenessCohort(
|
||||
items,
|
||||
{ available = true, stale = false, limit = 3 } = {},
|
||||
) {
|
||||
if (!available)
|
||||
return {
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: 'feed unavailable',
|
||||
};
|
||||
if (stale)
|
||||
return {
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: 'feed stale',
|
||||
};
|
||||
const normalized = (Array.isArray(items) ? items : [])
|
||||
.filter((item) => Number.isFinite(item?.distanceM ?? item?.distance)
|
||||
&& (item.distanceM ?? item.distance) >= 0)
|
||||
.filter(
|
||||
(item) =>
|
||||
Number.isFinite(item?.distanceM ?? item?.distance) &&
|
||||
(item.distanceM ?? item.distance) >= 0,
|
||||
)
|
||||
.map((item) => ({ ...item, distanceM: item.distanceM ?? item.distance }))
|
||||
.sort((a, b) => a.distanceM - b.distanceM);
|
||||
return {
|
||||
relationship: normalized.length ? AWARENESS_RELATIONSHIP.NEARBY : AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
relationship: normalized.length
|
||||
? AWARENESS_RELATIONSHIP.NEARBY
|
||||
: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: normalized.length,
|
||||
nearest: normalized.slice(0, limit),
|
||||
reason: normalized.length ? 'observed or mapped nearby context' : 'no observed or mapped objects in current feeds',
|
||||
reason: normalized.length
|
||||
? 'observed or mapped nearby context'
|
||||
: 'no observed or mapped objects in current feeds',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,21 +118,40 @@ export function summarizeAwarenessCohort(items, { available = true, stale = fals
|
||||
* @param {Iterable<string>} [visitedKeys] Previously visited layer/id keys.
|
||||
* @returns {Array<{layerId: string, id: string, item: Object}>} Selectable targets.
|
||||
*/
|
||||
export function getAwarenessNavigationTargets(cohorts, subject = null, visitedKeys = []) {
|
||||
export function getAwarenessNavigationTargets(
|
||||
cohorts,
|
||||
subject = null,
|
||||
visitedKeys = [],
|
||||
) {
|
||||
const visited = new Set(visitedKeys);
|
||||
const targets = [];
|
||||
for (const cohort of Array.isArray(cohorts) ? cohorts : []) {
|
||||
const layerId = cohort?.id;
|
||||
if (!['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(layerId)) continue;
|
||||
if (
|
||||
![
|
||||
'flights',
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
].includes(layerId)
|
||||
)
|
||||
continue;
|
||||
const sourceItems = Array.isArray(cohort?.summary?.navigationNearest)
|
||||
? cohort.summary.navigationNearest
|
||||
: (Array.isArray(cohort?.summary?.nearest) ? cohort.summary.nearest : []);
|
||||
: Array.isArray(cohort?.summary?.nearest)
|
||||
? cohort.summary.nearest
|
||||
: [];
|
||||
for (const item of sourceItems) {
|
||||
const id = item?.icao24 || item?.mmsi || item?.id;
|
||||
if (!id) continue;
|
||||
const key = `${layerId}:${id}`;
|
||||
if (key === `${subject?.layerId}:${subject?.id}`) continue;
|
||||
targets.push({ layerId, id: String(id), item, visited: visited.has(key) });
|
||||
targets.push({
|
||||
layerId,
|
||||
id: String(id),
|
||||
item,
|
||||
visited: visited.has(key),
|
||||
});
|
||||
}
|
||||
}
|
||||
targets.sort((a, b) => {
|
||||
@@ -108,7 +161,9 @@ export function getAwarenessNavigationTargets(cohorts, subject = null, visitedKe
|
||||
// reached vessels, installations, or the other flight layer at all.
|
||||
const visitedOrder = Number(a.visited) - Number(b.visited);
|
||||
if (visitedOrder) return visitedOrder;
|
||||
const subjectOrder = Number(a.layerId !== subject?.layerId) - Number(b.layerId !== subject?.layerId);
|
||||
const subjectOrder =
|
||||
Number(a.layerId !== subject?.layerId) -
|
||||
Number(b.layerId !== subject?.layerId);
|
||||
if (subjectOrder) return subjectOrder;
|
||||
return (a.item.distanceM ?? Infinity) - (b.item.distanceM ?? Infinity);
|
||||
});
|
||||
|
||||
@@ -37,10 +37,14 @@ const MAX_FOOTPRINT_POINTS = 400;
|
||||
* @returns {string} Display label, never empty.
|
||||
*/
|
||||
export function humanizeInstallationClass(klass) {
|
||||
const key = String(klass || '').trim().toLowerCase();
|
||||
const key = String(klass || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (LABEL_BY_CLASS[key]) return LABEL_BY_CLASS[key];
|
||||
const words = key.replaceAll('_', ' ').trim();
|
||||
return words ? `${words[0].toUpperCase()}${words.slice(1)}` : 'Mapped installation';
|
||||
return words
|
||||
? `${words[0].toUpperCase()}${words.slice(1)}`
|
||||
: 'Mapped installation';
|
||||
}
|
||||
|
||||
function finiteLatitude(value) {
|
||||
@@ -54,7 +58,8 @@ function finiteLongitude(value) {
|
||||
function pointFrom(element) {
|
||||
const lat = Number(element?.lat ?? element?.center?.lat);
|
||||
const longitude = Number(element?.lon ?? element?.center?.lon);
|
||||
if (finiteLatitude(lat) && finiteLongitude(longitude)) return { latitude: lat, longitude };
|
||||
if (finiteLatitude(lat) && finiteLongitude(longitude))
|
||||
return { latitude: lat, longitude };
|
||||
|
||||
// Bounds midpoint fallback (field test 2026-08-28, Warendorf: nothing drawn).
|
||||
// The proxy asks for `out center tags geom`, but Overpass takes the LAST
|
||||
@@ -67,8 +72,12 @@ function pointFrom(element) {
|
||||
// recovers all of them without touching the query or the footprint path.
|
||||
const bounds = element?.bounds;
|
||||
// JSON nulls, booleans, and empty strings must not coerce to a false 0,0 site.
|
||||
if (![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon]
|
||||
.every((value) => typeof value === 'number' && Number.isFinite(value))) return null;
|
||||
if (
|
||||
![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon].every(
|
||||
(value) => typeof value === 'number' && Number.isFinite(value),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
const south = Number(bounds?.minlat);
|
||||
const west = Number(bounds?.minlon);
|
||||
const north = Number(bounds?.maxlat);
|
||||
@@ -79,17 +88,28 @@ function pointFrom(element) {
|
||||
// A single installation footprint is never wider than the request bbox cap
|
||||
// (10°): an ascending box that spans the antimeridian (minlon -179, maxlon
|
||||
// 179) passes the ordering check yet midpoints to longitude 0.
|
||||
if (finiteLatitude(south) && finiteLatitude(north)
|
||||
&& finiteLongitude(west) && finiteLongitude(east)
|
||||
&& south <= north && west <= east
|
||||
&& north - south <= 10 && east - west <= 10) {
|
||||
if (
|
||||
finiteLatitude(south) &&
|
||||
finiteLatitude(north) &&
|
||||
finiteLongitude(west) &&
|
||||
finiteLongitude(east) &&
|
||||
south <= north &&
|
||||
west <= east &&
|
||||
north - south <= 10 &&
|
||||
east - west <= 10
|
||||
) {
|
||||
return { latitude: (south + north) / 2, longitude: (west + east) / 2 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function footprintFrom(element) {
|
||||
if (!Array.isArray(element?.geometry) || element.geometry.length < 3 || element.geometry.length > MAX_FOOTPRINT_POINTS) return null;
|
||||
if (
|
||||
!Array.isArray(element?.geometry) ||
|
||||
element.geometry.length < 3 ||
|
||||
element.geometry.length > MAX_FOOTPRINT_POINTS
|
||||
)
|
||||
return null;
|
||||
const points = [];
|
||||
for (const point of element.geometry) {
|
||||
const latitude = Number(point?.lat);
|
||||
@@ -106,21 +126,30 @@ function footprintFrom(element) {
|
||||
* @param {string} [retrievedAt]
|
||||
* @returns {{records: Array, droppedCount: number}}
|
||||
*/
|
||||
export function normalizeMilitaryInstallations(payload, retrievedAt = new Date().toISOString()) {
|
||||
export function normalizeMilitaryInstallations(
|
||||
payload,
|
||||
retrievedAt = new Date().toISOString(),
|
||||
) {
|
||||
const records = [];
|
||||
const ids = new Set();
|
||||
let droppedCount = 0;
|
||||
for (const element of Array.isArray(payload?.elements) ? payload.elements : []) {
|
||||
for (const element of Array.isArray(payload?.elements)
|
||||
? payload.elements
|
||||
: []) {
|
||||
const type = String(element?.type || '');
|
||||
const osmId = Number(element?.id);
|
||||
if (!['node', 'way', 'relation'].includes(type) || !Number.isSafeInteger(osmId)) {
|
||||
if (
|
||||
!['node', 'way', 'relation'].includes(type) ||
|
||||
!Number.isSafeInteger(osmId)
|
||||
) {
|
||||
droppedCount += 1;
|
||||
continue;
|
||||
}
|
||||
const id = `osm:${type}:${osmId}`;
|
||||
const tags = element?.tags || {};
|
||||
const klass = CLASS_BY_MILITARY_TAG[String(tags.military || '').toLowerCase()]
|
||||
|| (tags.landuse === 'military' ? 'military_land' : null);
|
||||
const klass =
|
||||
CLASS_BY_MILITARY_TAG[String(tags.military || '').toLowerCase()] ||
|
||||
(tags.landuse === 'military' ? 'military_land' : null);
|
||||
const point = pointFrom(element);
|
||||
if (!klass || !point || ids.has(id)) {
|
||||
droppedCount += 1;
|
||||
@@ -134,7 +163,9 @@ export function normalizeMilitaryInstallations(payload, retrievedAt = new Date()
|
||||
// geometry, while a footprint-less way/relation has unknown extent.
|
||||
osmType: type,
|
||||
class: klass,
|
||||
name: String(tags.name || tags['name:en'] || '').trim() || humanizeInstallationClass(klass),
|
||||
name:
|
||||
String(tags.name || tags['name:en'] || '').trim() ||
|
||||
humanizeInstallationClass(klass),
|
||||
...point,
|
||||
footprint: footprintFrom(element),
|
||||
sources: [{ name: 'OpenStreetMap', id: `${type}/${osmId}`, retrievedAt }],
|
||||
@@ -152,6 +183,14 @@ export function isValidInstallationBoundingBox(value) {
|
||||
const west = Number(box.west);
|
||||
const north = Number(box.north);
|
||||
const east = Number(box.east);
|
||||
return finiteLatitude(south) && finiteLatitude(north) && finiteLongitude(west) && finiteLongitude(east)
|
||||
&& south < north && west < east && north - south <= 10 && east - west <= 10;
|
||||
return (
|
||||
finiteLatitude(south) &&
|
||||
finiteLatitude(north) &&
|
||||
finiteLongitude(west) &&
|
||||
finiteLongitude(east) &&
|
||||
south < north &&
|
||||
west < east &&
|
||||
north - south <= 10 &&
|
||||
east - west <= 10
|
||||
);
|
||||
}
|
||||
@@ -77,7 +77,9 @@ export const MODEL_TRAIL_ANCHOR_NATIVE = Object.freeze({
|
||||
* trail anchor has to ride the same chain or it lands on a different axis than
|
||||
* the aircraft it is supposed to be attached to. */
|
||||
const AXIS_CORRECTION = Cesium.Matrix4.multiplyTransformation(
|
||||
Cesium.Axis.Y_UP_TO_Z_UP, Cesium.Axis.Z_UP_TO_X_UP, new Cesium.Matrix4(),
|
||||
Cesium.Axis.Y_UP_TO_Z_UP,
|
||||
Cesium.Axis.Z_UP_TO_X_UP,
|
||||
new Cesium.Matrix4(),
|
||||
);
|
||||
const _anchorScratch = new Cesium.Cartesian3();
|
||||
const _chainScratch = new Cesium.Matrix4();
|
||||
@@ -124,7 +126,11 @@ export function modelAnchorWorld(model, nativeAnchor, result) {
|
||||
components || Cesium.Matrix4.IDENTITY,
|
||||
_chainScratch,
|
||||
);
|
||||
Cesium.Matrix4.multiplyTransformation(_chainScratch, AXIS_CORRECTION, _chainScratch);
|
||||
Cesium.Matrix4.multiplyTransformation(
|
||||
_chainScratch,
|
||||
AXIS_CORRECTION,
|
||||
_chainScratch,
|
||||
);
|
||||
return Cesium.Matrix4.multiplyByPoint(_chainScratch, _anchorScratch, result);
|
||||
}
|
||||
|
||||
@@ -220,9 +226,17 @@ export function trailHeadStart(start, anchor, center, radiusM, result) {
|
||||
// a real trail.
|
||||
if (!anchor || !center || !result) return start;
|
||||
if (!Number.isFinite(radiusM) || radiusM <= 0) return start;
|
||||
const startD = Math.hypot(start.x - center.x, start.y - center.y, start.z - center.z);
|
||||
const startD = Math.hypot(
|
||||
start.x - center.x,
|
||||
start.y - center.y,
|
||||
start.z - center.z,
|
||||
);
|
||||
if (startD >= radiusM) return start;
|
||||
const anchorD = Math.hypot(anchor.x - center.x, anchor.y - center.y, anchor.z - center.z);
|
||||
const anchorD = Math.hypot(
|
||||
anchor.x - center.x,
|
||||
anchor.y - center.y,
|
||||
anchor.z - center.z,
|
||||
);
|
||||
// A degenerate shell (an anchor at or outside the envelope — no shipped asset
|
||||
// has one, since every anchor is a measured hull point) leaves nothing to
|
||||
// ramp across; fall back to the containment verdict rather than divide by it.
|
||||
@@ -252,8 +266,20 @@ export function modelVisualAnchor(modelMatrix, nativeCenter, scale, result) {
|
||||
const x = (nativeCenter?.[0] || 0) * safeScale;
|
||||
const y = (nativeCenter?.[1] || 0) * safeScale;
|
||||
const z = (nativeCenter?.[2] || 0) * safeScale;
|
||||
result.x = modelMatrix[0] * x + modelMatrix[4] * y + modelMatrix[8] * z + modelMatrix[12];
|
||||
result.y = modelMatrix[1] * x + modelMatrix[5] * y + modelMatrix[9] * z + modelMatrix[13];
|
||||
result.z = modelMatrix[2] * x + modelMatrix[6] * y + modelMatrix[10] * z + modelMatrix[14];
|
||||
result.x =
|
||||
modelMatrix[0] * x +
|
||||
modelMatrix[4] * y +
|
||||
modelMatrix[8] * z +
|
||||
modelMatrix[12];
|
||||
result.y =
|
||||
modelMatrix[1] * x +
|
||||
modelMatrix[5] * y +
|
||||
modelMatrix[9] * z +
|
||||
modelMatrix[13];
|
||||
result.z =
|
||||
modelMatrix[2] * x +
|
||||
modelMatrix[6] * y +
|
||||
modelMatrix[10] * z +
|
||||
modelMatrix[14];
|
||||
return result;
|
||||
}
|
||||
+102
-27
@@ -29,10 +29,14 @@ export function norm360(deg) {
|
||||
export function liftRepeatedGroundFix(newest, candidatePosition, onGround) {
|
||||
if (!onGround || !newest?.position || !candidatePosition) return false;
|
||||
const stored = Cesium.Cartographic.fromCartesian(
|
||||
newest.position, Cesium.Ellipsoid.WGS84, _mmCarto,
|
||||
newest.position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_mmCarto,
|
||||
);
|
||||
const candidate = Cesium.Cartographic.fromCartesian(
|
||||
candidatePosition, Cesium.Ellipsoid.WGS84, _mmCandidateCarto,
|
||||
candidatePosition,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_mmCandidateCarto,
|
||||
);
|
||||
if (!stored || !candidate || candidate.height <= stored.height) return false;
|
||||
Cesium.Cartesian3.fromRadians(
|
||||
@@ -62,7 +66,11 @@ export function courseBetweenCartesians(from, to, minChordM = 25) {
|
||||
const dy = to.y - from.y;
|
||||
const dz = to.z - from.z;
|
||||
if (dx * dx + dy * dy + dz * dz < minChordM * minChordM) return null;
|
||||
const carto = Cesium.Cartographic.fromCartesian(from, Cesium.Ellipsoid.WGS84, _mmCarto);
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
from,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_mmCarto,
|
||||
);
|
||||
if (!carto) return null;
|
||||
const sLat = Math.sin(carto.latitude);
|
||||
const cLat = Math.cos(carto.latitude);
|
||||
@@ -110,7 +118,10 @@ export function speedRamp(speedMps) {
|
||||
if (!Number.isFinite(speedMps)) return 1;
|
||||
if (speedMps <= COURSE_TRACK_ONLY_MPS) return 0;
|
||||
if (speedMps >= COURSE_CHORD_ONLY_MPS) return 1;
|
||||
return (speedMps - COURSE_TRACK_ONLY_MPS) / (COURSE_CHORD_ONLY_MPS - COURSE_TRACK_ONLY_MPS);
|
||||
return (
|
||||
(speedMps - COURSE_TRACK_ONLY_MPS) /
|
||||
(COURSE_CHORD_ONLY_MPS - COURSE_TRACK_ONLY_MPS)
|
||||
);
|
||||
}
|
||||
|
||||
/** Shortest-arc angle lerp: from → to by t (t=0 → from, t=1 → to). Also the
|
||||
@@ -141,10 +152,16 @@ export function displayedKinematics({
|
||||
reportedTrackDeg,
|
||||
} = {}) {
|
||||
return {
|
||||
speedMps: Number.isFinite(derivedSpeedMps) ? Math.max(0, derivedSpeedMps)
|
||||
: (Number.isFinite(reportedSpeedMps) ? Math.max(0, reportedSpeedMps) : null),
|
||||
trackDeg: Number.isFinite(derivedTrackDeg) ? derivedTrackDeg
|
||||
: (Number.isFinite(reportedTrackDeg) ? reportedTrackDeg : null),
|
||||
speedMps: Number.isFinite(derivedSpeedMps)
|
||||
? Math.max(0, derivedSpeedMps)
|
||||
: Number.isFinite(reportedSpeedMps)
|
||||
? Math.max(0, reportedSpeedMps)
|
||||
: null,
|
||||
trackDeg: Number.isFinite(derivedTrackDeg)
|
||||
? derivedTrackDeg
|
||||
: Number.isFinite(reportedTrackDeg)
|
||||
? reportedTrackDeg
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -167,10 +184,17 @@ export function staleCoastLimitSeconds({
|
||||
maximumSec = 300,
|
||||
} = {}) {
|
||||
const floor = Math.max(0, Number.isFinite(minimumSec) ? minimumSec : 60);
|
||||
const ceiling = Math.max(floor, Number.isFinite(maximumSec) ? maximumSec : 300);
|
||||
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(lastContactEpochMs)) return floor;
|
||||
const ceiling = Math.max(
|
||||
floor,
|
||||
Number.isFinite(maximumSec) ? maximumSec : 300,
|
||||
);
|
||||
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(lastContactEpochMs))
|
||||
return floor;
|
||||
const contactLeadSec = Math.max(0, (lastContactEpochMs - fixEpochMs) / 1000);
|
||||
const grace = Math.max(0, Number.isFinite(contactGraceSec) ? contactGraceSec : 60);
|
||||
const grace = Math.max(
|
||||
0,
|
||||
Number.isFinite(contactGraceSec) ? contactGraceSec : 60,
|
||||
);
|
||||
return Math.min(ceiling, Math.max(floor, contactLeadSec + grace));
|
||||
}
|
||||
|
||||
@@ -197,18 +221,31 @@ export function limitCourseStep(prevDeg, targetDeg, maxDegPerSec, dtSec) {
|
||||
* is GPS-vector noise, not a turn (samples without a speed keep the legacy
|
||||
* behavior: no way to tell, so they still count).
|
||||
*/
|
||||
export function estimateTurnRateDps(samples, noiseFloorDps = 0.4, maxDps = 4, minSpeedMps = 0) {
|
||||
export function estimateTurnRateDps(
|
||||
samples,
|
||||
noiseFloorDps = 0.4,
|
||||
maxDps = 4,
|
||||
minSpeedMps = 0,
|
||||
) {
|
||||
if (!samples || samples.length < 2) return 0;
|
||||
let sum = 0;
|
||||
let n = 0;
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
const dt = samples[i].tSec - samples[i - 1].tSec;
|
||||
if (dt < 2 || dt > 120) continue;
|
||||
if (!Number.isFinite(samples[i].trackDeg) || !Number.isFinite(samples[i - 1].trackDeg)) continue;
|
||||
if (
|
||||
!Number.isFinite(samples[i].trackDeg) ||
|
||||
!Number.isFinite(samples[i - 1].trackDeg)
|
||||
)
|
||||
continue;
|
||||
if (minSpeedMps > 0) {
|
||||
const v0 = samples[i - 1].speedMps;
|
||||
const v1 = samples[i].speedMps;
|
||||
if ((Number.isFinite(v0) && v0 < minSpeedMps) || (Number.isFinite(v1) && v1 < minSpeedMps)) continue;
|
||||
if (
|
||||
(Number.isFinite(v0) && v0 < minSpeedMps) ||
|
||||
(Number.isFinite(v1) && v1 < minSpeedMps)
|
||||
)
|
||||
continue;
|
||||
}
|
||||
sum += norm180(samples[i].trackDeg - samples[i - 1].trackDeg) / dt;
|
||||
n += 1;
|
||||
@@ -274,13 +311,31 @@ const _scratchProjectArc = { east: 0, north: 0, endCourseDeg: 0 };
|
||||
* @param {number} dtSec - Seconds ahead; 0 or negative returns the start.
|
||||
* @returns {{lat: number, lon: number}} Projected coordinate, degrees.
|
||||
*/
|
||||
export function projectGroundArcLatLon(lat, lon, courseDeg, speedMps, turnRateDps, dtSec) {
|
||||
export function projectGroundArcLatLon(
|
||||
lat,
|
||||
lon,
|
||||
courseDeg,
|
||||
speedMps,
|
||||
turnRateDps,
|
||||
dtSec,
|
||||
) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return { lat, lon };
|
||||
if (!Number.isFinite(courseDeg) || !Number.isFinite(speedMps) || !Number.isFinite(dtSec)
|
||||
|| speedMps <= 0 || dtSec <= 0) {
|
||||
if (
|
||||
!Number.isFinite(courseDeg) ||
|
||||
!Number.isFinite(speedMps) ||
|
||||
!Number.isFinite(dtSec) ||
|
||||
speedMps <= 0 ||
|
||||
dtSec <= 0
|
||||
) {
|
||||
return { lat, lon };
|
||||
}
|
||||
const off = arcOffsetEnu(speedMps, courseDeg, turnRateDps || 0, dtSec, _scratchProjectArc);
|
||||
const off = arcOffsetEnu(
|
||||
speedMps,
|
||||
courseDeg,
|
||||
turnRateDps || 0,
|
||||
dtSec,
|
||||
_scratchProjectArc,
|
||||
);
|
||||
// Guard the pole: cos(lat) → 0 would send the longitude to infinity.
|
||||
const cosLat = Math.max(Math.cos(lat * DEG), 1e-6);
|
||||
return {
|
||||
@@ -320,8 +375,15 @@ export function projectGroundArcLatLon(lat, lon, courseDeg, speedMps, turnRateDp
|
||||
* @returns {Array<{lat: number, lon: number}>} Polyline, display position first.
|
||||
*/
|
||||
export function corridorPathLatLon({
|
||||
extrapolating, displayLat, displayLon, courseDeg, speedMps, turnRateDps = 0,
|
||||
fixLat, fixLon, lookaheadSec,
|
||||
extrapolating,
|
||||
displayLat,
|
||||
displayLon,
|
||||
courseDeg,
|
||||
speedMps,
|
||||
turnRateDps = 0,
|
||||
fixLat,
|
||||
fixLon,
|
||||
lookaheadSec,
|
||||
}) {
|
||||
if (!Number.isFinite(displayLat) || !Number.isFinite(displayLon)) return [];
|
||||
const start = { lat: displayLat, lon: displayLon };
|
||||
@@ -338,9 +400,16 @@ export function corridorPathLatLon({
|
||||
const n = Math.max(1, Math.ceil(arcLengthM / CORRIDOR_SAMPLE_SPACING_M));
|
||||
const out = [start];
|
||||
for (let i = 1; i <= n; i++) {
|
||||
out.push(projectGroundArcLatLon(
|
||||
displayLat, displayLon, courseDeg, speed, turnRateDps, cappedSec * (i / n),
|
||||
));
|
||||
out.push(
|
||||
projectGroundArcLatLon(
|
||||
displayLat,
|
||||
displayLon,
|
||||
courseDeg,
|
||||
speed,
|
||||
turnRateDps,
|
||||
cappedSec * (i / n),
|
||||
),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -385,13 +454,17 @@ const _forwardFixOffset = new Cesium.Cartesian3();
|
||||
* velocity: number, track: number}|null} Forward synthetic fix, or null.
|
||||
*/
|
||||
export function synthesizeForwardKinematicsFix(newest, next = {}) {
|
||||
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs)) return null;
|
||||
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs))
|
||||
return null;
|
||||
const startEpochMs = Number.isFinite(newest.epochMs)
|
||||
? newest.epochMs
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime();
|
||||
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs) return null;
|
||||
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs)
|
||||
return null;
|
||||
|
||||
const previousVelocity = Number.isFinite(newest.velocity) ? Math.max(0, newest.velocity) : 0;
|
||||
const previousVelocity = Number.isFinite(newest.velocity)
|
||||
? Math.max(0, newest.velocity)
|
||||
: 0;
|
||||
const previousTrack = Number.isFinite(newest.track) ? newest.track : 0;
|
||||
const dtSec = (next.epochMs - startEpochMs) / 1000;
|
||||
arcOffsetEnu(
|
||||
@@ -421,7 +494,9 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
|
||||
time: Cesium.JulianDate.fromDate(new Date(next.epochMs)),
|
||||
epochMs: next.epochMs,
|
||||
position,
|
||||
velocity: Number.isFinite(next.velocity) ? Math.max(0, next.velocity) : previousVelocity,
|
||||
velocity: Number.isFinite(next.velocity)
|
||||
? Math.max(0, next.velocity)
|
||||
: previousVelocity,
|
||||
track: Number.isFinite(next.track) ? next.track : previousTrack,
|
||||
};
|
||||
}
|
||||
@@ -32,7 +32,8 @@ export function resolvePickId(picked) {
|
||||
if (id === null || id === undefined) return undefined;
|
||||
if (typeof id === 'object') {
|
||||
// AIS vessel record (id object with .mmsi) or Cesium Entity (.id string)
|
||||
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number') return id.mmsi;
|
||||
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number')
|
||||
return id.mmsi;
|
||||
if (typeof id.id === 'string' || typeof id.id === 'number') return id.id;
|
||||
return undefined;
|
||||
}
|
||||
@@ -40,7 +41,7 @@ export function resolvePickId(picked) {
|
||||
};
|
||||
let id = unwrap(picked.id);
|
||||
if (id === undefined) id = unwrap(picked.primitive?.id);
|
||||
return (typeof id === 'string' || typeof id === 'number') ? String(id) : null;
|
||||
return typeof id === 'string' || typeof id === 'number' ? String(id) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+22
-13
@@ -1,6 +1,7 @@
|
||||
const RADIO_COUNTRY_MAX_LENGTH = 80;
|
||||
|
||||
const ISO_ALPHA_2_CODES = Object.freeze(`
|
||||
const ISO_ALPHA_2_CODES = Object.freeze(
|
||||
`
|
||||
AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
|
||||
CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR
|
||||
GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO
|
||||
@@ -8,12 +9,16 @@ JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME
|
||||
MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO
|
||||
RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV
|
||||
TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW
|
||||
`.trim().split(/\s+/));
|
||||
`
|
||||
.trim()
|
||||
.split(/\s+/),
|
||||
);
|
||||
|
||||
const ISO_ALPHA_2_SET = new Set(ISO_ALPHA_2_CODES);
|
||||
const ENGLISH_REGION_NAMES = typeof Intl?.DisplayNames === 'function'
|
||||
? new Intl.DisplayNames(['en'], { type: 'region' })
|
||||
: null;
|
||||
const ENGLISH_REGION_NAMES =
|
||||
typeof Intl?.DisplayNames === 'function'
|
||||
? new Intl.DisplayNames(['en'], { type: 'region' })
|
||||
: null;
|
||||
|
||||
function countryKey(value) {
|
||||
return String(value || '')
|
||||
@@ -29,7 +34,8 @@ function countryKey(value) {
|
||||
const COUNTRY_NAME_TO_CODE = new Map();
|
||||
for (const code of ISO_ALPHA_2_CODES) {
|
||||
const displayName = ENGLISH_REGION_NAMES?.of(code);
|
||||
if (displayName && displayName !== code) COUNTRY_NAME_TO_CODE.set(countryKey(displayName), code);
|
||||
if (displayName && displayName !== code)
|
||||
COUNTRY_NAME_TO_CODE.set(countryKey(displayName), code);
|
||||
}
|
||||
|
||||
for (const [name, code] of Object.entries({
|
||||
@@ -71,7 +77,8 @@ for (const [name, code] of Object.entries({
|
||||
'east timor': 'TL',
|
||||
'cabo verde': 'CV',
|
||||
vatican: 'VA',
|
||||
})) COUNTRY_NAME_TO_CODE.set(countryKey(name), code);
|
||||
}))
|
||||
COUNTRY_NAME_TO_CODE.set(countryKey(name), code);
|
||||
|
||||
function canonicalCountryName(code) {
|
||||
return ENGLISH_REGION_NAMES?.of(code) || code;
|
||||
@@ -90,17 +97,19 @@ export function normalizeRadioCountryInput(value) {
|
||||
}
|
||||
const trimmed = value.normalize('NFKC').trim().replace(/\s+/g, ' ');
|
||||
if (
|
||||
!trimmed
|
||||
|| trimmed.length > RADIO_COUNTRY_MAX_LENGTH
|
||||
|| /[\u0000-\u001f\u007f]/.test(trimmed)
|
||||
|| !/^[\p{L}\p{M}.&'’()\-\s]+$/u.test(trimmed)
|
||||
) return Object.freeze({ valid: false, empty: false, code: '', name: '' });
|
||||
!trimmed ||
|
||||
trimmed.length > RADIO_COUNTRY_MAX_LENGTH ||
|
||||
/[\u0000-\u001f\u007f]/.test(trimmed) ||
|
||||
!/^[\p{L}\p{M}.&'’()\-\s]+$/u.test(trimmed)
|
||||
)
|
||||
return Object.freeze({ valid: false, empty: false, code: '', name: '' });
|
||||
|
||||
const possibleCode = trimmed.toUpperCase();
|
||||
const code = ISO_ALPHA_2_SET.has(possibleCode)
|
||||
? possibleCode
|
||||
: COUNTRY_NAME_TO_CODE.get(countryKey(trimmed)) || '';
|
||||
if (!code) return Object.freeze({ valid: false, empty: false, code: '', name: '' });
|
||||
if (!code)
|
||||
return Object.freeze({ valid: false, empty: false, code: '', name: '' });
|
||||
return Object.freeze({
|
||||
valid: true,
|
||||
empty: false,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { applicationServices } from '../services/application.js';
|
||||
export * from './regionalModel.js';
|
||||
/** Fetch a bounded regional brief through the same-origin dev/preview proxy. */
|
||||
export async function fetchRegionalBrief(latitude, longitude, { signal } = {}) {
|
||||
if (![latitude, longitude].every(Number.isFinite)) throw new Error('Valid coordinates are required');
|
||||
if (![latitude, longitude].every(Number.isFinite))
|
||||
throw new Error('Valid coordinates are required');
|
||||
return applicationServices.regional.getBrief(latitude, longitude, { signal });
|
||||
}
|
||||
+52
-20
@@ -1,13 +1,18 @@
|
||||
const MAX_ARTICLES = 5;
|
||||
|
||||
function cleanText(value, maxLength = 180) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxLength);
|
||||
return String(value || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function safeHttpUrl(value) {
|
||||
try {
|
||||
const parsed = new URL(String(value || ''));
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null;
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||
? parsed.href
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -17,14 +22,27 @@ function safeHttpUrl(value) {
|
||||
export function normalizeRegionalPlace(payload) {
|
||||
const address = payload?.address || {};
|
||||
const locality = cleanText(
|
||||
address.city || address.town || address.village || address.municipality
|
||||
|| address.hamlet || address.county,
|
||||
address.city ||
|
||||
address.town ||
|
||||
address.village ||
|
||||
address.municipality ||
|
||||
address.hamlet ||
|
||||
address.county,
|
||||
90,
|
||||
);
|
||||
const region = cleanText(
|
||||
address.state || address.region || address.county,
|
||||
90,
|
||||
);
|
||||
const region = cleanText(address.state || address.region || address.county, 90);
|
||||
const country = cleanText(address.country, 90);
|
||||
const label = [locality, region].filter((value, index, values) => value && values.indexOf(value) === index)
|
||||
.join(', ') || country || cleanText(payload?.display_name, 120);
|
||||
const label =
|
||||
[locality, region]
|
||||
.filter(
|
||||
(value, index, values) => value && values.indexOf(value) === index,
|
||||
)
|
||||
.join(', ') ||
|
||||
country ||
|
||||
cleanText(payload?.display_name, 120);
|
||||
if (!label) return null;
|
||||
return {
|
||||
label,
|
||||
@@ -51,11 +69,16 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
|
||||
const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate);
|
||||
const publishedAt = compactDate
|
||||
? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z`
|
||||
: Number.isNaN(Date.parse(rawDate)) ? null : new Date(rawDate).toISOString();
|
||||
: Number.isNaN(Date.parse(rawDate))
|
||||
? null
|
||||
: new Date(rawDate).toISOString();
|
||||
articles.push({
|
||||
title,
|
||||
url,
|
||||
domain: cleanText(row?.domain || new URL(url).hostname.replace(/^www\./, ''), 80),
|
||||
domain: cleanText(
|
||||
row?.domain || new URL(url).hostname.replace(/^www\./, ''),
|
||||
80,
|
||||
),
|
||||
publishedAt,
|
||||
sourceCountry: cleanText(row?.sourcecountry, 60) || null,
|
||||
});
|
||||
@@ -74,11 +97,15 @@ export function normalizeRegionalWeather(payload) {
|
||||
};
|
||||
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
|
||||
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
|
||||
const observedRaw = typeof current.time === 'string' && !/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time)
|
||||
? `${current.time}Z`
|
||||
: current.time;
|
||||
const observedRaw =
|
||||
typeof current.time === 'string' &&
|
||||
!/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time)
|
||||
? `${current.time}Z`
|
||||
: current.time;
|
||||
return {
|
||||
observedAt: Number.isNaN(Date.parse(observedRaw)) ? null : new Date(observedRaw).toISOString(),
|
||||
observedAt: Number.isNaN(Date.parse(observedRaw))
|
||||
? null
|
||||
: new Date(observedRaw).toISOString(),
|
||||
temperatureC: numberOrNull(current.temperature_2m),
|
||||
apparentTemperatureC: numberOrNull(current.apparent_temperature),
|
||||
precipitationMm: numberOrNull(current.precipitation),
|
||||
@@ -109,14 +136,19 @@ export function weatherCodeLabel(code) {
|
||||
|
||||
/** Great-circle distance used to avoid refetching a regional brief every animation frame. */
|
||||
export function regionalDistanceM(from, to) {
|
||||
if (![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(Number.isFinite)) {
|
||||
if (
|
||||
![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(
|
||||
Number.isFinite,
|
||||
)
|
||||
) {
|
||||
return Infinity;
|
||||
}
|
||||
const phi1 = from.latitude * Math.PI / 180;
|
||||
const phi2 = to.latitude * Math.PI / 180;
|
||||
const deltaPhi = (to.latitude - from.latitude) * Math.PI / 180;
|
||||
const deltaLambda = (to.longitude - from.longitude) * Math.PI / 180;
|
||||
const a = Math.sin(deltaPhi / 2) ** 2
|
||||
+ Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
|
||||
const phi1 = (from.latitude * Math.PI) / 180;
|
||||
const phi2 = (to.latitude * Math.PI) / 180;
|
||||
const deltaPhi = ((to.latitude - from.latitude) * Math.PI) / 180;
|
||||
const deltaLambda = ((to.longitude - from.longitude) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(deltaPhi / 2) ** 2 +
|
||||
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
|
||||
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
@@ -26,7 +26,13 @@
|
||||
* none of the above are usable — the caller applies its OWN existing sticky/default
|
||||
* fallback (this helper never invents one, so it can't drift from the caller's policy).
|
||||
*/
|
||||
export function pickRenderAltitudeM({ geoAltM, baroAltM, onGround, surfaceM, geoidN }) {
|
||||
export function pickRenderAltitudeM({
|
||||
geoAltM,
|
||||
baroAltM,
|
||||
onGround,
|
||||
surfaceM,
|
||||
geoidN,
|
||||
}) {
|
||||
if (onGround && Number.isFinite(surfaceM)) {
|
||||
return surfaceM;
|
||||
}
|
||||
@@ -89,7 +95,12 @@ export function reuseGroundedSurfaceM(currentM, previousM) {
|
||||
* @param {number|null|undefined} params.geoidN - Geoid undulation N here.
|
||||
* @returns {number|null} The geoid guess, or null when it must not be used.
|
||||
*/
|
||||
export function geoidSurfaceLastResortM({ geoAltM, baroAltM, priorRenderM, geoidN }) {
|
||||
export function geoidSurfaceLastResortM({
|
||||
geoAltM,
|
||||
baroAltM,
|
||||
priorRenderM,
|
||||
geoidN,
|
||||
}) {
|
||||
if (Number.isFinite(geoAltM) || Number.isFinite(baroAltM)) return null;
|
||||
if (Number.isFinite(priorRenderM)) return null;
|
||||
return Number.isFinite(geoidN) ? geoidN : null;
|
||||
|
||||
@@ -34,11 +34,14 @@ export const RETRY_COOLDOWN_MAX_MS = 300_000;
|
||||
* @param {() => number} [options.now] - Clock seam for tests.
|
||||
* @returns {() => Promise<T>} Memoized loader; retries once the cooldown elapses.
|
||||
*/
|
||||
export function createRetryableLoader(load, {
|
||||
cooldownMs = RETRY_COOLDOWN_MS,
|
||||
maxCooldownMs = RETRY_COOLDOWN_MAX_MS,
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
export function createRetryableLoader(
|
||||
load,
|
||||
{
|
||||
cooldownMs = RETRY_COOLDOWN_MS,
|
||||
maxCooldownMs = RETRY_COOLDOWN_MAX_MS,
|
||||
now = () => Date.now(),
|
||||
} = {},
|
||||
) {
|
||||
/** @type {Promise<T>|null} */
|
||||
let inflight = null;
|
||||
/** @type {*} Last rejection, replayed to callers inside the cooldown. */
|
||||
@@ -51,7 +54,10 @@ export function createRetryableLoader(load, {
|
||||
return function loadOnce() {
|
||||
if (inflight) return inflight;
|
||||
if (hasFailure) {
|
||||
const wait = Math.min(cooldownMs * 2 ** (consecutiveFailures - 1), maxCooldownMs);
|
||||
const wait = Math.min(
|
||||
cooldownMs * 2 ** (consecutiveFailures - 1),
|
||||
maxCooldownMs,
|
||||
);
|
||||
// Replay the recorded reason rather than re-running a loader that is
|
||||
// probably still broken. Callers see identical behavior either way.
|
||||
if (now() - failedAt < wait) return Promise.reject(failure);
|
||||
|
||||
+44
-12
@@ -19,7 +19,8 @@ export function greatCircleKm(lat1, lon1, lat2, lon2) {
|
||||
const p2 = lat2 * D2R;
|
||||
const dp = (lat2 - lat1) * D2R;
|
||||
const dl = (lon2 - lon1) * D2R;
|
||||
const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||
const a =
|
||||
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||
return R_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
@@ -28,7 +29,8 @@ function bearingRad(lat1, lon1, lat2, lon2) {
|
||||
const p2 = lat2 * D2R;
|
||||
const dl = (lon2 - lon1) * D2R;
|
||||
const y = Math.sin(dl) * Math.cos(p2);
|
||||
const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
|
||||
const x =
|
||||
Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
|
||||
return Math.atan2(y, x);
|
||||
}
|
||||
|
||||
@@ -42,8 +44,8 @@ export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) {
|
||||
|
||||
const NEAR_ENDPOINT_KM = 130;
|
||||
const CROSS_TRACK_KM = 200;
|
||||
const LOW_ALT_M = 3700; // ~12 000 ft
|
||||
const VERT_TREND_MPS = 2; // ~400 fpm
|
||||
const LOW_ALT_M = 3700; // ~12 000 ft
|
||||
const VERT_TREND_MPS = 2; // ~400 fpm
|
||||
const LOCAL_AIRPORT_KM = 150;
|
||||
|
||||
/**
|
||||
@@ -55,16 +57,35 @@ const LOCAL_AIRPORT_KM = 150;
|
||||
* @param {{lat:number|null, lon:number|null}|null} [p.destination]
|
||||
* @returns {boolean} false ONLY when the route is confidently wrong.
|
||||
*/
|
||||
export function routePlausible({ latDeg, lonDeg, altitudeM = null, verticalRateMps = null, origin = null, destination = null }) {
|
||||
export function routePlausible({
|
||||
latDeg,
|
||||
lonDeg,
|
||||
altitudeM = null,
|
||||
verticalRateMps = null,
|
||||
origin = null,
|
||||
destination = null,
|
||||
}) {
|
||||
const haveO = Number.isFinite(origin?.lat) && Number.isFinite(origin?.lon);
|
||||
const haveD = Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
|
||||
const haveD =
|
||||
Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
|
||||
if (!haveO && !haveD) return true; // no coordinates — cannot judge, do not hide
|
||||
|
||||
// (a) Geographic consistency: near an endpoint, or roughly on the path.
|
||||
const near = (pt) => greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM;
|
||||
const near = (pt) =>
|
||||
greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM;
|
||||
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
|
||||
if (!geomOk && haveO && haveD) {
|
||||
geomOk = Math.abs(crossTrackKm(latDeg, lonDeg, origin.lat, origin.lon, destination.lat, destination.lon)) < CROSS_TRACK_KM;
|
||||
geomOk =
|
||||
Math.abs(
|
||||
crossTrackKm(
|
||||
latDeg,
|
||||
lonDeg,
|
||||
origin.lat,
|
||||
origin.lon,
|
||||
destination.lat,
|
||||
destination.lon,
|
||||
),
|
||||
) < CROSS_TRACK_KM;
|
||||
} else if (!geomOk) {
|
||||
geomOk = true; // only one endpoint known and not near — cannot judge, allow
|
||||
}
|
||||
@@ -72,13 +93,24 @@ export function routePlausible({ latDeg, lonDeg, altitudeM = null, verticalRateM
|
||||
|
||||
// (b) Vertical-trend consistency for low traffic (observer-free adaptation).
|
||||
if (
|
||||
Number.isFinite(altitudeM) && altitudeM < LOW_ALT_M &&
|
||||
Number.isFinite(verticalRateMps) && Math.abs(verticalRateMps) > VERT_TREND_MPS
|
||||
Number.isFinite(altitudeM) &&
|
||||
altitudeM < LOW_ALT_M &&
|
||||
Number.isFinite(verticalRateMps) &&
|
||||
Math.abs(verticalRateMps) > VERT_TREND_MPS
|
||||
) {
|
||||
if (verticalRateMps > 0) {
|
||||
if (haveO && greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM) return false; // departing — origin should be local
|
||||
if (
|
||||
haveO &&
|
||||
greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM
|
||||
)
|
||||
return false; // departing — origin should be local
|
||||
} else {
|
||||
if (haveD && greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) > LOCAL_AIRPORT_KM) return false; // arriving — destination should be local
|
||||
if (
|
||||
haveD &&
|
||||
greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) >
|
||||
LOCAL_AIRPORT_KM
|
||||
)
|
||||
return false; // arriving — destination should be local
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -69,7 +69,13 @@ export const SATELLITE_CLASSES = Object.freeze({
|
||||
});
|
||||
|
||||
/** Legend/report order for the classes above. */
|
||||
export const SATELLITE_CLASS_ORDER = Object.freeze(['station', 'nav', 'geo', 'visual', 'comms']);
|
||||
export const SATELLITE_CLASS_ORDER = Object.freeze([
|
||||
'station',
|
||||
'nav',
|
||||
'geo',
|
||||
'visual',
|
||||
'comms',
|
||||
]);
|
||||
|
||||
/**
|
||||
* CelesTrak group tag → { class, subtype }. Subtype names the specific
|
||||
@@ -150,8 +156,11 @@ export function satelliteClassLabel(group, { isIss = false } = {}) {
|
||||
export function tallySatelliteClasses(entries) {
|
||||
const counts = Object.create(null);
|
||||
for (const entry of entries || []) {
|
||||
const descriptor = entry && typeof entry === 'object' ? entry : { group: entry };
|
||||
const { klass } = satelliteClassOf(descriptor.group, { isIss: descriptor.isIss });
|
||||
const descriptor =
|
||||
entry && typeof entry === 'object' ? entry : { group: entry };
|
||||
const { klass } = satelliteClassOf(descriptor.group, {
|
||||
isIss: descriptor.isIss,
|
||||
});
|
||||
counts[klass] = (counts[klass] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
@@ -171,7 +180,13 @@ export function satelliteClassLegend(counts) {
|
||||
const count = counts?.[klass];
|
||||
if (!(count > 0)) continue;
|
||||
const spec = SATELLITE_CLASSES[klass];
|
||||
result.push({ klass, label: spec.label, color: spec.color, blurb: spec.blurb, count });
|
||||
result.push({
|
||||
klass,
|
||||
label: spec.label,
|
||||
color: spec.color,
|
||||
blurb: spec.blurb,
|
||||
count,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -42,7 +42,8 @@ const MAX_PICK_MAGNITUDE_M = 1_000_000_000;
|
||||
export function isPickedWorldPosition(position) {
|
||||
if (!position) return false;
|
||||
const { x, y, z } = position;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return false;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
|
||||
return false;
|
||||
const magnitude = Math.hypot(x, y, z);
|
||||
return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M;
|
||||
}
|
||||
@@ -47,7 +47,8 @@ export function restoreSpriteOrder(viewer) {
|
||||
if (!viewer || viewer.isDestroyed?.()) return;
|
||||
const scene = viewer.scene;
|
||||
const primitives = scene?.primitives;
|
||||
if (!primitives || scene.isDestroyed?.() || primitives.isDestroyed?.()) return;
|
||||
if (!primitives || scene.isDestroyed?.() || primitives.isDestroyed?.())
|
||||
return;
|
||||
|
||||
for (const layerId of SPRITE_LAYER_ORDER) {
|
||||
const collection = _collections.get(layerId);
|
||||
@@ -71,6 +72,7 @@ export function restoreSpriteOrderOnEnable(
|
||||
viewer,
|
||||
restore = restoreSpriteOrder,
|
||||
) {
|
||||
if (!SPRITE_LAYER_ORDER.includes(layerId) || typeof restore !== 'function') return;
|
||||
if (!SPRITE_LAYER_ORDER.includes(layerId) || typeof restore !== 'function')
|
||||
return;
|
||||
restore(viewer);
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { createTerrainHeights } from '../services/terrainHeights.js';
|
||||
import { applicationServices } from '../services/application.js';
|
||||
const terrainHeights = createTerrainHeights({ source: applicationServices.terrain });
|
||||
export const { cachedEllipsoidalGround, cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrainHeights;
|
||||
const terrainHeights = createTerrainHeights({
|
||||
source: applicationServices.terrain,
|
||||
});
|
||||
export const {
|
||||
cachedEllipsoidalGround,
|
||||
cachedRealEllipsoidalGround,
|
||||
resolveEllipsoidalGround,
|
||||
} = terrainHeights;
|
||||
@@ -38,7 +38,9 @@ export const TR3B_CLASS = 'tr3b';
|
||||
|
||||
/** @param {*} id Raw contact id. @returns {string} Normalized registry key. */
|
||||
function key(id) {
|
||||
return String(id ?? '').trim().toLowerCase();
|
||||
return String(id ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+37
-19
@@ -26,17 +26,23 @@ export function trackedModelScaleForPixelCap({
|
||||
maximumPixelSize,
|
||||
}) {
|
||||
if (
|
||||
!Number.isFinite(baseScale) || baseScale <= 0
|
||||
|| !Number.isFinite(nativeRadiusM) || nativeRadiusM <= 0
|
||||
|| !Number.isFinite(rangeM) || rangeM <= 0
|
||||
|| !Number.isFinite(viewportHeightPx) || viewportHeightPx <= 0
|
||||
|| !Number.isFinite(fovyRad) || fovyRad <= 0
|
||||
|| !Number.isFinite(maximumPixelSize) || maximumPixelSize <= 0
|
||||
) return baseScale;
|
||||
!Number.isFinite(baseScale) ||
|
||||
baseScale <= 0 ||
|
||||
!Number.isFinite(nativeRadiusM) ||
|
||||
nativeRadiusM <= 0 ||
|
||||
!Number.isFinite(rangeM) ||
|
||||
rangeM <= 0 ||
|
||||
!Number.isFinite(viewportHeightPx) ||
|
||||
viewportHeightPx <= 0 ||
|
||||
!Number.isFinite(fovyRad) ||
|
||||
fovyRad <= 0 ||
|
||||
!Number.isFinite(maximumPixelSize) ||
|
||||
maximumPixelSize <= 0
|
||||
)
|
||||
return baseScale;
|
||||
const focalLengthPx = viewportHeightPx / (2 * Math.tan(fovyRad / 2));
|
||||
const projectedDiameterPx = (
|
||||
2 * nativeRadiusM * baseScale * focalLengthPx
|
||||
) / rangeM;
|
||||
const projectedDiameterPx =
|
||||
(2 * nativeRadiusM * baseScale * focalLengthPx) / rangeM;
|
||||
if (projectedDiameterPx <= maximumPixelSize) return baseScale;
|
||||
return baseScale * (maximumPixelSize / projectedDiameterPx);
|
||||
}
|
||||
@@ -99,17 +105,25 @@ export function clampTrackedCameraPosition(
|
||||
previousPosition,
|
||||
minimumRangeM = MIN_TRACKED_RANGE_M,
|
||||
) {
|
||||
const crossedOrigin = Cesium.Cartesian3.dot(camera.position, previousPosition) <= 0;
|
||||
const forwardDistance = -Cesium.Cartesian3.dot(camera.position, camera.direction);
|
||||
const crossedOrigin =
|
||||
Cesium.Cartesian3.dot(camera.position, previousPosition) <= 0;
|
||||
const forwardDistance = -Cesium.Cartesian3.dot(
|
||||
camera.position,
|
||||
camera.direction,
|
||||
);
|
||||
const rangeSquared = Cesium.Cartesian3.magnitudeSquared(camera.position);
|
||||
if (crossedOrigin) {
|
||||
Cesium.Cartesian3.normalize(previousPosition, camera.position);
|
||||
Cesium.Cartesian3.multiplyByScalar(camera.position, minimumRangeM, camera.position);
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
camera.position,
|
||||
minimumRangeM,
|
||||
camera.position,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
forwardDistance < minimumRangeM
|
||||
|| rangeSquared < minimumRangeM * minimumRangeM
|
||||
forwardDistance < minimumRangeM ||
|
||||
rangeSquared < minimumRangeM * minimumRangeM
|
||||
) {
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
camera.direction,
|
||||
@@ -192,10 +206,14 @@ export function applyTrackedCameraFrame(viewer, entity, viewFrom) {
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
trackedTransform,
|
||||
);
|
||||
const offset = typeof viewFrom.getValue === 'function'
|
||||
? viewFrom.getValue(viewer.clock.currentTime, resolvedViewFrom)
|
||||
: Cesium.Cartesian3.clone(viewFrom, resolvedViewFrom);
|
||||
if (!offset || Cesium.Cartesian3.magnitudeSquared(offset) < Cesium.Math.EPSILON12) {
|
||||
const offset =
|
||||
typeof viewFrom.getValue === 'function'
|
||||
? viewFrom.getValue(viewer.clock.currentTime, resolvedViewFrom)
|
||||
: Cesium.Cartesian3.clone(viewFrom, resolvedViewFrom);
|
||||
if (
|
||||
!offset ||
|
||||
Cesium.Cartesian3.magnitudeSquared(offset) < Cesium.Math.EPSILON12
|
||||
) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,5 +76,8 @@ export const TRACKED_MODEL_EXIT_ALT_M =
|
||||
*/
|
||||
export function trackedModelZoomActive(cameraHeightM, wasActive = false) {
|
||||
if (!Number.isFinite(cameraHeightM)) return false;
|
||||
return cameraHeightM < (wasActive ? TRACKED_MODEL_EXIT_ALT_M : TRACKED_MODEL_ENTER_ALT_M);
|
||||
return (
|
||||
cameraHeightM <
|
||||
(wasActive ? TRACKED_MODEL_EXIT_ALT_M : TRACKED_MODEL_ENTER_ALT_M)
|
||||
);
|
||||
}
|
||||
+59
-21
@@ -49,17 +49,24 @@ let _overlayHost = DEFAULT_TRACKED_OVERLAY_HOST;
|
||||
* @param {string} accent Source-owned accent color.
|
||||
* @returns {{title:string,details:string[],accent:string}}
|
||||
*/
|
||||
export function trackedLabelModelFromText(text, accent = WORLD_OVERLAY_STYLE.accent) {
|
||||
export function trackedLabelModelFromText(
|
||||
text,
|
||||
accent = WORLD_OVERLAY_STYLE.accent,
|
||||
) {
|
||||
const raw = String(text || '').trim();
|
||||
if (!raw) return { title: '', details: [], accent };
|
||||
let lines;
|
||||
if (raw.includes('\n')) {
|
||||
lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
lines = raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
const parts = raw.split(' · ');
|
||||
lines = parts.length > 1
|
||||
? [parts[0].trim(), parts.slice(1).join(' · ').trim()]
|
||||
: [raw];
|
||||
lines =
|
||||
parts.length > 1
|
||||
? [parts[0].trim(), parts.slice(1).join(' · ').trim()]
|
||||
: [raw];
|
||||
}
|
||||
return { title: lines[0] || '', details: lines.slice(1), accent };
|
||||
}
|
||||
@@ -101,7 +108,9 @@ export function cachedTrackedVisualPosition(entity) {
|
||||
try {
|
||||
const visual = entity.gevVisualPosition();
|
||||
if (visual) return visual;
|
||||
} catch { /* fall through to the display position */ }
|
||||
} catch {
|
||||
/* fall through to the display position */
|
||||
}
|
||||
}
|
||||
return cachedTrackedDisplayPosition(entity);
|
||||
}
|
||||
@@ -140,7 +149,9 @@ export function createTrackedOverlayEntry(entity) {
|
||||
collisionGroup: 'ambient-card',
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
title,
|
||||
details: Array.isArray(model.details) ? model.details.map((line) => String(line)) : [],
|
||||
details: Array.isArray(model.details)
|
||||
? model.details.map((line) => String(line))
|
||||
: [],
|
||||
accent: model.accent || WORLD_OVERLAY_STYLE.accent,
|
||||
cardStyle: model.cardStyle,
|
||||
selected: model.selected === true,
|
||||
@@ -149,9 +160,14 @@ export function createTrackedOverlayEntry(entity) {
|
||||
leaderAnimationStartedAt: model.leaderAnimationStartedAt,
|
||||
leaderDrawRatio: model.leaderDrawRatio,
|
||||
anchorRadiusPx: Number.isFinite(Number(model.anchorRadiusPx))
|
||||
? Math.max(0, Number(model.anchorRadiusPx)) : 10,
|
||||
anchorRadiusScale: Object.prototype.hasOwnProperty.call(model, 'anchorRadiusScale')
|
||||
? model.anchorRadiusScale : TRACKED_BILLBOARD_SCALE,
|
||||
? Math.max(0, Number(model.anchorRadiusPx))
|
||||
: 10,
|
||||
anchorRadiusScale: Object.prototype.hasOwnProperty.call(
|
||||
model,
|
||||
'anchorRadiusScale',
|
||||
)
|
||||
? model.anchorRadiusScale
|
||||
: TRACKED_BILLBOARD_SCALE,
|
||||
minAnchorGapPx: 16,
|
||||
anchorGapPaddingPx: 10,
|
||||
verticalOnly: true,
|
||||
@@ -203,7 +219,10 @@ export function getActiveTrackedReadoutId() {
|
||||
}
|
||||
|
||||
/** Static-context layers whose click selection publishes the readout card. */
|
||||
const READOUT_CONTEXT_LAYERS = new Set(['military-installations', 'alpr-cameras']);
|
||||
const READOUT_CONTEXT_LAYERS = new Set([
|
||||
'military-installations',
|
||||
'alpr-cameras',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Initialize the model bridge and selection listeners. No render listener is
|
||||
@@ -215,10 +234,11 @@ export function initTrackedReadout(viewer) {
|
||||
if (_viewer) destroyTrackedReadout();
|
||||
_viewer = viewer;
|
||||
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true);
|
||||
_trackedEntityChangedRemove = viewer.trackedEntityChanged?.addEventListener?.(() => {
|
||||
if (viewer.trackedEntity) _selectedContext = null;
|
||||
syncActiveEntity();
|
||||
}) || null;
|
||||
_trackedEntityChangedRemove =
|
||||
viewer.trackedEntityChanged?.addEventListener?.(() => {
|
||||
if (viewer.trackedEntity) _selectedContext = null;
|
||||
syncActiveEntity();
|
||||
}) || null;
|
||||
_contextSelectedHandler = (event) => {
|
||||
const record = event.detail;
|
||||
if (READOUT_CONTEXT_LAYERS.has(record?.layerId)) {
|
||||
@@ -232,7 +252,10 @@ export function initTrackedReadout(viewer) {
|
||||
}
|
||||
};
|
||||
_contextClearedHandler = (event) => {
|
||||
if (!_selectedContext || event.detail?.layerId === _selectedContext.layerId) {
|
||||
if (
|
||||
!_selectedContext ||
|
||||
event.detail?.layerId === _selectedContext.layerId
|
||||
) {
|
||||
_selectedContext = null;
|
||||
syncActiveEntity();
|
||||
}
|
||||
@@ -242,8 +265,14 @@ export function initTrackedReadout(viewer) {
|
||||
if (!_viewer?.trackedEntity) clearTrackedSource();
|
||||
};
|
||||
window.addEventListener('gev:entity-selected', _contextSelectedHandler);
|
||||
window.addEventListener('gev:entity-selection-cleared', _contextClearedHandler);
|
||||
window.addEventListener('gev:awareness-subject-selected', _aircraftSelectedHandler);
|
||||
window.addEventListener(
|
||||
'gev:entity-selection-cleared',
|
||||
_contextClearedHandler,
|
||||
);
|
||||
window.addEventListener(
|
||||
'gev:awareness-subject-selected',
|
||||
_aircraftSelectedHandler,
|
||||
);
|
||||
syncActiveEntity();
|
||||
// Boot-verification contract: the track regression harness asserts this
|
||||
// exact line at init (dropped by the host migration; restored).
|
||||
@@ -254,9 +283,18 @@ export function initTrackedReadout(viewer) {
|
||||
export function destroyTrackedReadout() {
|
||||
_trackedEntityChangedRemove?.();
|
||||
_trackedEntityChangedRemove = null;
|
||||
if (_contextSelectedHandler) window.removeEventListener('gev:entity-selected', _contextSelectedHandler);
|
||||
if (_contextClearedHandler) window.removeEventListener('gev:entity-selection-cleared', _contextClearedHandler);
|
||||
if (_aircraftSelectedHandler) window.removeEventListener('gev:awareness-subject-selected', _aircraftSelectedHandler);
|
||||
if (_contextSelectedHandler)
|
||||
window.removeEventListener('gev:entity-selected', _contextSelectedHandler);
|
||||
if (_contextClearedHandler)
|
||||
window.removeEventListener(
|
||||
'gev:entity-selection-cleared',
|
||||
_contextClearedHandler,
|
||||
);
|
||||
if (_aircraftSelectedHandler)
|
||||
window.removeEventListener(
|
||||
'gev:awareness-subject-selected',
|
||||
_aircraftSelectedHandler,
|
||||
);
|
||||
_contextSelectedHandler = null;
|
||||
_contextClearedHandler = null;
|
||||
_aircraftSelectedHandler = null;
|
||||
|
||||
@@ -28,8 +28,10 @@ export function isTrackingClickGesture(gesture = {}) {
|
||||
const durationMs = Number.isFinite(gesture.durationMs)
|
||||
? Math.max(0, gesture.durationMs)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return isTrackingSelectionGesture(gesture)
|
||||
&& durationMs <= MAX_TRACKING_CLICK_DURATION_MS;
|
||||
return (
|
||||
isTrackingSelectionGesture(gesture) &&
|
||||
durationMs <= MAX_TRACKING_CLICK_DURATION_MS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +55,12 @@ export function bindTrackingClickGesture(handler, onClick, options = {}) {
|
||||
let completedGesture = null;
|
||||
|
||||
const appendTravel = (position) => {
|
||||
if (!pressActive || !Number.isFinite(position?.x) || !Number.isFinite(position?.y)) return;
|
||||
if (
|
||||
!pressActive ||
|
||||
!Number.isFinite(position?.x) ||
|
||||
!Number.isFinite(position?.y)
|
||||
)
|
||||
return;
|
||||
if (previousPosition) {
|
||||
travelPx += Math.hypot(
|
||||
position.x - previousPosition.x,
|
||||
|
||||
@@ -33,8 +33,8 @@ export function greatCircleKm(lat1, lon1, lat2, lon2) {
|
||||
const p2 = toRad(lat2);
|
||||
const dp = toRad(lat2 - lat1);
|
||||
const dl = toRad(lon2 - lon1);
|
||||
const a = Math.sin(dp / 2) ** 2
|
||||
+ Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||
const a =
|
||||
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ function initialBearingRad(lat1, lon1, lat2, lon2) {
|
||||
const p2 = toRad(lat2);
|
||||
const dl = toRad(lon2 - lon1);
|
||||
const y = Math.sin(dl) * Math.cos(p2);
|
||||
const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
|
||||
const x =
|
||||
Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
|
||||
return Math.atan2(y, x);
|
||||
}
|
||||
|
||||
@@ -68,12 +69,15 @@ function destinationPoint(lat, lon, bearingRad, distKm) {
|
||||
const p1 = toRad(lat);
|
||||
const l1 = toRad(lon);
|
||||
const p2 = Math.asin(
|
||||
Math.sin(p1) * Math.cos(delta) + Math.cos(p1) * Math.sin(delta) * Math.cos(bearingRad)
|
||||
);
|
||||
const l2 = l1 + Math.atan2(
|
||||
Math.sin(bearingRad) * Math.sin(delta) * Math.cos(p1),
|
||||
Math.cos(delta) - Math.sin(p1) * Math.sin(p2)
|
||||
Math.sin(p1) * Math.cos(delta) +
|
||||
Math.cos(p1) * Math.sin(delta) * Math.cos(bearingRad),
|
||||
);
|
||||
const l2 =
|
||||
l1 +
|
||||
Math.atan2(
|
||||
Math.sin(bearingRad) * Math.sin(delta) * Math.cos(p1),
|
||||
Math.cos(delta) - Math.sin(p1) * Math.sin(p2),
|
||||
);
|
||||
// Normalize longitude to [-180, 180)
|
||||
const lonDeg = ((toDeg(l2) + 540) % 360) - 180;
|
||||
return { lat: toDeg(p2), lon: lonDeg };
|
||||
@@ -100,7 +104,13 @@ function destinationPoint(lat, lon, bearingRad, distKm) {
|
||||
* @returns {{lat:number, lon:number, source:'hit'|'nadir'|'pulled'}}
|
||||
* The fetch center and which rule produced it.
|
||||
*/
|
||||
export function deriveFetchCenter({ nadirLat, nadirLon, hitLat, hitLon, maxPullKm = 12 }) {
|
||||
export function deriveFetchCenter({
|
||||
nadirLat,
|
||||
nadirLon,
|
||||
hitLat,
|
||||
hitLon,
|
||||
maxPullKm = 12,
|
||||
}) {
|
||||
if (!Number.isFinite(hitLat) || !Number.isFinite(hitLon)) {
|
||||
return { lat: nadirLat, lon: nadirLon, source: 'nadir' };
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@
|
||||
/** @const {Object<string,'mono'|'crt'>} Style name → non-normal profile. */
|
||||
const PROFILE_BY_STYLE = {
|
||||
surveillance: 'mono', // NVG — P43 phosphor × luma
|
||||
thermal: 'mono', // FLIR — grayscale/ironbow × luma
|
||||
noir: 'mono', // full desaturation
|
||||
retro: 'crt', // CRT — hue survives, small dots don't
|
||||
thermal: 'mono', // FLIR — grayscale/ironbow × luma
|
||||
noir: 'mono', // full desaturation
|
||||
retro: 'crt', // CRT — hue survives, small dots don't
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -49,13 +49,33 @@ const PROFILE_BY_STYLE = {
|
||||
*/
|
||||
const DOT_STYLE = {
|
||||
mono: {
|
||||
jam: { rgba: [255, 255, 255, 0.95], sizeDelta: 3, outline: { rgba: [0, 0, 0, 0.9], width: 2 } },
|
||||
slow: { rgba: [255, 255, 255, 0.9], sizeDelta: 1, outline: { rgba: [0, 0, 0, 0.85], width: 1 } },
|
||||
free: { rgba: [255, 255, 255, 0.85], sizeDelta: 0, outline: { rgba: [0, 0, 0, 0.8], width: 1 } },
|
||||
jam: {
|
||||
rgba: [255, 255, 255, 0.95],
|
||||
sizeDelta: 3,
|
||||
outline: { rgba: [0, 0, 0, 0.9], width: 2 },
|
||||
},
|
||||
slow: {
|
||||
rgba: [255, 255, 255, 0.9],
|
||||
sizeDelta: 1,
|
||||
outline: { rgba: [0, 0, 0, 0.85], width: 1 },
|
||||
},
|
||||
free: {
|
||||
rgba: [255, 255, 255, 0.85],
|
||||
sizeDelta: 0,
|
||||
outline: { rgba: [0, 0, 0, 0.8], width: 1 },
|
||||
},
|
||||
},
|
||||
crt: {
|
||||
jam: { rgba: [255, 59, 48, 0.95], sizeDelta: 3, outline: { rgba: [0, 0, 0, 0.9], width: 2 } },
|
||||
slow: { rgba: [255, 179, 0, 0.92], sizeDelta: 2, outline: { rgba: [0, 0, 0, 0.85], width: 1 } },
|
||||
jam: {
|
||||
rgba: [255, 59, 48, 0.95],
|
||||
sizeDelta: 3,
|
||||
outline: { rgba: [0, 0, 0, 0.9], width: 2 },
|
||||
},
|
||||
slow: {
|
||||
rgba: [255, 179, 0, 0.92],
|
||||
sizeDelta: 2,
|
||||
outline: { rgba: [0, 0, 0, 0.85], width: 1 },
|
||||
},
|
||||
free: { rgba: [0, 255, 102, 0.9], sizeDelta: 1, outline: null },
|
||||
},
|
||||
};
|
||||
@@ -123,7 +143,8 @@ export function presetDotOutline(styleName, bucket) {
|
||||
* @returns {string|null} Tier key, or null (no override → stock 'vehicle').
|
||||
*/
|
||||
export function trafficBucketTier(bucket) {
|
||||
if (bucket === 'free' || bucket === 'slow' || bucket === 'jam') return `veh_${bucket}`;
|
||||
if (bucket === 'free' || bucket === 'slow' || bucket === 'jam')
|
||||
return `veh_${bucket}`;
|
||||
if (bucket === 'sim') return 'veh_nodata';
|
||||
return null;
|
||||
}
|
||||
@@ -33,13 +33,14 @@ const GAP_SPREAD_M = 6;
|
||||
*/
|
||||
export function queuePlatoons(totalLen, count, rng = Math.random) {
|
||||
const platoons = [];
|
||||
if (!Number.isFinite(totalLen) || totalLen <= 0 || !(count > 0)) return platoons;
|
||||
if (!Number.isFinite(totalLen) || totalLen <= 0 || !(count > 0))
|
||||
return platoons;
|
||||
|
||||
let placed = 0;
|
||||
while (placed < count) {
|
||||
const platoonSize = Math.min(
|
||||
count - placed,
|
||||
PLATOON_MIN + Math.floor(rng() * PLATOON_SPREAD)
|
||||
PLATOON_MIN + Math.floor(rng() * PLATOON_SPREAD),
|
||||
);
|
||||
const anchor = rng() * totalLen;
|
||||
const platoon = [];
|
||||
|
||||
@@ -28,7 +28,9 @@ import { registerPickOwner } from './pickRegistry.js';
|
||||
// plane being tracked. Claiming the 'gev-trail:' id namespace makes
|
||||
// isOwnedByOtherLayer() true for every layer — clicking a trail is a no-op
|
||||
// everywhere. Registered once at module load; the predicate is pure.
|
||||
registerPickOwner('trails', (pickedId) => String(pickedId).startsWith('gev-trail:'));
|
||||
registerPickOwner('trails', (pickedId) =>
|
||||
String(pickedId).startsWith('gev-trail:'),
|
||||
);
|
||||
|
||||
/** @type {number} Uniquifier for trail entity ids (Cesium requires unique entity ids). */
|
||||
let _trailSeq = 0;
|
||||
@@ -99,7 +101,12 @@ export function createTrail(viewer, { color, width = 2.5 }) {
|
||||
for (const position of Array.isArray(cartesians) ? cartesians : []) {
|
||||
if (!position) continue;
|
||||
const last = positions[positions.length - 1];
|
||||
if (last && Cesium.Cartesian3.distanceSquared(last, position) < MIN_SEGMENT_DISTANCE_SQ) continue;
|
||||
if (
|
||||
last &&
|
||||
Cesium.Cartesian3.distanceSquared(last, position) <
|
||||
MIN_SEGMENT_DISTANCE_SQ
|
||||
)
|
||||
continue;
|
||||
positions.push(position);
|
||||
}
|
||||
current = positions.length >= 2 ? positions : [];
|
||||
@@ -122,7 +129,11 @@ export function createTrail(viewer, { color, width = 2.5 }) {
|
||||
destroyed = true;
|
||||
current = [];
|
||||
if (entity && viewer && !viewer.isDestroyed()) {
|
||||
try { viewer.entities.remove(entity); } catch { /* torn down */ }
|
||||
try {
|
||||
viewer.entities.remove(entity);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
}
|
||||
entity = null;
|
||||
},
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { validTransitIdentifier } from '../sources/transitHistory.js';
|
||||
export {
|
||||
validTransitIdentifier,
|
||||
fetchTransitHistory,
|
||||
} from '../sources/transitHistory.js';
|
||||
|
||||
/**
|
||||
* @module transitProxy
|
||||
* @description Pure server-side mechanics for the `/api/transit` proxy.
|
||||
@@ -67,18 +73,6 @@ export const TRANSIT_BACKOFF_LADDER_MS = Object.freeze([
|
||||
* @param {string} url Request URL relative to the mount point.
|
||||
* @returns {{ route: 'feeds' } | { route: 'vehicles', feed: object } | null}
|
||||
*/
|
||||
/** Validate the entire decoded identifier, including UTF-8 length. */
|
||||
export function validTransitIdentifier(value) {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
!/[\u0000-\u001f\u007f/\\]/u.test(value) &&
|
||||
value !== '.' &&
|
||||
value !== '..' &&
|
||||
new TextEncoder().encode(value).length <= 256 &&
|
||||
!/[\uD800-\uDFFF]/u.test(value)
|
||||
);
|
||||
}
|
||||
export function resolveTransitRoute(url) {
|
||||
const pathname = String(url || '').split('?')[0];
|
||||
if (pathname === '/feeds' || pathname === '/feeds/')
|
||||
@@ -101,91 +95,6 @@ export function resolveTransitRoute(url) {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Selection reads are bounded and cancellable through body parsing. */
|
||||
export async function fetchTransitHistory(
|
||||
feedId,
|
||||
vehicleId,
|
||||
signal,
|
||||
fetchImpl = fetch,
|
||||
) {
|
||||
if (!validTransitIdentifier(vehicleId))
|
||||
throw new TypeError('Invalid transit vehicle identifier');
|
||||
const response = await fetchImpl(
|
||||
`/api/transit/trail/${encodeURIComponent(feedId)}/${encodeURIComponent(vehicleId)}`,
|
||||
{ signal, headers: { Accept: 'application/json' } },
|
||||
);
|
||||
if (!response.ok) throw new Error(`Transit history HTTP ${response.status}`);
|
||||
const reader = response.body?.getReader?.();
|
||||
let text = '';
|
||||
if (reader) {
|
||||
const decoder = new TextDecoder();
|
||||
let bytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
signal?.throwIfAborted();
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > 32768)
|
||||
throw new RangeError('Transit history exceeds response limit');
|
||||
text += decoder.decode(chunk.value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
}
|
||||
} else {
|
||||
text = await response.text();
|
||||
if (new TextEncoder().encode(text).length > 32768)
|
||||
throw new RangeError('Transit history exceeds response limit');
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const payload = JSON.parse(text);
|
||||
if (
|
||||
payload.version !== 1 ||
|
||||
payload.feedId !== feedId ||
|
||||
payload.vehicleId !== vehicleId ||
|
||||
!Array.isArray(payload.fixes) ||
|
||||
payload.fixes.length > 128 ||
|
||||
!Array.isArray(payload.epochs) ||
|
||||
payload.epochs.length > 8
|
||||
)
|
||||
throw new TypeError('Invalid transit history response');
|
||||
let previous = -Infinity;
|
||||
const epochs = new Set();
|
||||
for (const epoch of payload.epochs) {
|
||||
if (
|
||||
!Number.isInteger(epoch.id) ||
|
||||
epoch.id < 1 ||
|
||||
epoch.id > 0xffffffff ||
|
||||
epochs.has(epoch.id) ||
|
||||
!['trip', 'route', 'mode'].every(
|
||||
(key) =>
|
||||
typeof epoch[key] === 'string' &&
|
||||
new TextEncoder().encode(epoch[key]).length <= 256,
|
||||
)
|
||||
)
|
||||
throw new TypeError('Invalid transit history epoch');
|
||||
epochs.add(epoch.id);
|
||||
}
|
||||
for (const fix of payload.fixes) {
|
||||
if (
|
||||
!Array.isArray(fix) ||
|
||||
fix.length !== 5 ||
|
||||
!fix.every(Number.isFinite) ||
|
||||
fix[0] < 0 ||
|
||||
fix[0] <= previous ||
|
||||
Math.abs(fix[1]) > 90 ||
|
||||
Math.abs(fix[2]) > 180 ||
|
||||
!Number.isInteger(fix[3]) ||
|
||||
!epochs.has(fix[4])
|
||||
)
|
||||
throw new TypeError('Invalid transit history fix');
|
||||
previous = fix[0];
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request headers for one upstream fetch.
|
||||
*
|
||||
|
||||
+51
-12
@@ -20,21 +20,48 @@ export const VESSEL_CARD_FADE_DISTANCE_M = 5_000_000;
|
||||
*/
|
||||
const TYPE_STYLES = [
|
||||
{ pattern: /tanker/i, css: '#ffb347', accent: '255, 179, 71' },
|
||||
{ pattern: /cargo|container|bulk|carrier/i, css: '#39d5ff', accent: '57, 213, 255' },
|
||||
{ pattern: /passenger|ferry|cruise/i, css: '#ff7adf', accent: '255, 122, 223' },
|
||||
{
|
||||
pattern: /cargo|container|bulk|carrier/i,
|
||||
css: '#39d5ff',
|
||||
accent: '57, 213, 255',
|
||||
},
|
||||
{
|
||||
pattern: /passenger|ferry|cruise/i,
|
||||
css: '#ff7adf',
|
||||
accent: '255, 122, 223',
|
||||
},
|
||||
{ pattern: /fishing/i, css: '#7cff9b', accent: '124, 255, 155' },
|
||||
{ pattern: /tug|tow|pilot|supply|service/i, css: '#f7f0a3', accent: '247, 240, 163' },
|
||||
{
|
||||
pattern: /tug|tow|pilot|supply|service/i,
|
||||
css: '#f7f0a3',
|
||||
accent: '247, 240, 163',
|
||||
},
|
||||
];
|
||||
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
||||
|
||||
const NUMERIC_TYPE_SPECIALS = {
|
||||
30: 'FISHING', 31: 'TOWING', 32: 'TOWING', 33: 'DREDGER', 34: 'DIVE OPS',
|
||||
35: 'MILITARY', 36: 'SAILING', 37: 'PLEASURE',
|
||||
50: 'PILOT', 51: 'SAR', 52: 'TUG', 53: 'PORT TENDER', 54: 'ANTI-POLLUTION',
|
||||
55: 'LAW ENFORCE', 58: 'MEDICAL',
|
||||
30: 'FISHING',
|
||||
31: 'TOWING',
|
||||
32: 'TOWING',
|
||||
33: 'DREDGER',
|
||||
34: 'DIVE OPS',
|
||||
35: 'MILITARY',
|
||||
36: 'SAILING',
|
||||
37: 'PLEASURE',
|
||||
50: 'PILOT',
|
||||
51: 'SAR',
|
||||
52: 'TUG',
|
||||
53: 'PORT TENDER',
|
||||
54: 'ANTI-POLLUTION',
|
||||
55: 'LAW ENFORCE',
|
||||
58: 'MEDICAL',
|
||||
};
|
||||
const NUMERIC_TYPE_FAMILIES = {
|
||||
4: 'HIGH-SPEED', 6: 'PASSENGER', 7: 'CARGO', 8: 'TANKER', 9: 'OTHER',
|
||||
4: 'HIGH-SPEED',
|
||||
6: 'PASSENGER',
|
||||
7: 'CARGO',
|
||||
8: 'TANKER',
|
||||
9: 'OTHER',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -76,13 +103,22 @@ function styleForType(type) {
|
||||
* @param {number} [rowLimit=900] Configured source row ceiling.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function vesselOverlayCohortLimit(width, height, rowLimit = VESSEL_DEFAULT_LABEL_LIMIT) {
|
||||
export function vesselOverlayCohortLimit(
|
||||
width,
|
||||
height,
|
||||
rowLimit = VESSEL_DEFAULT_LABEL_LIMIT,
|
||||
) {
|
||||
const w = Number(width);
|
||||
const h = Number(height);
|
||||
const requested = Number(rowLimit);
|
||||
if (!(w > 0) || !(h > 0) || !(requested > 0)) return 0;
|
||||
const gridCapacity = Math.ceil(w / VESSEL_LABEL_GRID_PX) * Math.ceil(h / VESSEL_LABEL_GRID_PX);
|
||||
return Math.min(VESSEL_OVERLAY_MAX_COHORT, Math.floor(requested), gridCapacity);
|
||||
const gridCapacity =
|
||||
Math.ceil(w / VESSEL_LABEL_GRID_PX) * Math.ceil(h / VESSEL_LABEL_GRID_PX);
|
||||
return Math.min(
|
||||
VESSEL_OVERLAY_MAX_COHORT,
|
||||
Math.floor(requested),
|
||||
gridCapacity,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,7 +129,10 @@ export function vesselOverlayCohortLimit(width, height, rowLimit = VESSEL_DEFAUL
|
||||
* @param {number} [fadeDistance=5000000] Ambient distance-fade endpoint.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function applyVesselOverlayPolicy(card, fadeDistance = VESSEL_CARD_FADE_DISTANCE_M) {
|
||||
export function applyVesselOverlayPolicy(
|
||||
card,
|
||||
fadeDistance = VESSEL_CARD_FADE_DISTANCE_M,
|
||||
) {
|
||||
const selected = card?.selected === true;
|
||||
const rawGap = Number(card?.gapPx) || 10;
|
||||
const gapPx = Math.max(12, rawGap + 8);
|
||||
|
||||
+67
-35
@@ -143,7 +143,9 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
|
||||
function resolveStore(kind, injected) {
|
||||
if (injected !== undefined) return injected;
|
||||
try {
|
||||
return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;
|
||||
return kind === 'session'
|
||||
? globalThis.sessionStorage
|
||||
: globalThis.localStorage;
|
||||
} catch {
|
||||
// Privacy-restricted storage should not make first launch silent.
|
||||
return null;
|
||||
@@ -211,8 +213,13 @@ export function shouldShowFirstRun({
|
||||
if (params.get('welcome') === '0') return false;
|
||||
// The demo/support escape hatch outranks both suppressions on purpose.
|
||||
if (params.get('welcome') === '1') return true;
|
||||
if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;
|
||||
if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;
|
||||
if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')
|
||||
return false;
|
||||
if (
|
||||
readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) ===
|
||||
'dismissed'
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -253,7 +260,10 @@ export function rememberFirstRunSessionDismissed(sessionStorageRef) {
|
||||
* @param {() => Promise<any>} deps.flyToGlobe
|
||||
* @returns {Promise<{ok: boolean, choice: string, result?: object, failedLayerIds?: string[]}>}
|
||||
*/
|
||||
export async function runFirstRunChoice(choice, { setContextMode, setLayerEnabled, flyToGlobe }) {
|
||||
export async function runFirstRunChoice(
|
||||
choice,
|
||||
{ setContextMode, setLayerEnabled, flyToGlobe },
|
||||
) {
|
||||
const mission = FIRST_RUN_MISSIONS[choice];
|
||||
if (!mission) return { ok: false, choice };
|
||||
if (mission.kind === 'none') return { ok: true, choice };
|
||||
@@ -267,15 +277,19 @@ export async function runFirstRunChoice(choice, { setContextMode, setLayerEnable
|
||||
const flight = Promise.resolve()
|
||||
.then(() => flyToGlobe())
|
||||
.catch(() => null);
|
||||
const outcomes = await Promise.all(mission.layerIds.map(async (layerId) => {
|
||||
try {
|
||||
return { layerId, ok: (await setLayerEnabled(layerId)) !== false };
|
||||
} catch {
|
||||
return { layerId, ok: false };
|
||||
}
|
||||
}));
|
||||
const outcomes = await Promise.all(
|
||||
mission.layerIds.map(async (layerId) => {
|
||||
try {
|
||||
return { layerId, ok: (await setLayerEnabled(layerId)) !== false };
|
||||
} catch {
|
||||
return { layerId, ok: false };
|
||||
}
|
||||
}),
|
||||
);
|
||||
await flight;
|
||||
const failedLayerIds = outcomes.filter((entry) => !entry.ok).map((entry) => entry.layerId);
|
||||
const failedLayerIds = outcomes
|
||||
.filter((entry) => !entry.ok)
|
||||
.map((entry) => entry.layerId);
|
||||
return { ok: failedLayerIds.length === 0, choice, failedLayerIds };
|
||||
}
|
||||
|
||||
@@ -325,20 +339,25 @@ export function initFirstRunExperience({
|
||||
if (!root || root.dataset.initialized === 'true') return null;
|
||||
root.dataset.initialized = 'true';
|
||||
|
||||
if (!shouldShowFirstRun({
|
||||
hasShareState: styleManager?.hasShareState,
|
||||
storage,
|
||||
sessionStorageRef,
|
||||
location,
|
||||
})) {
|
||||
if (
|
||||
!shouldShowFirstRun({
|
||||
hasShareState: styleManager?.hasShareState,
|
||||
storage,
|
||||
sessionStorageRef,
|
||||
location,
|
||||
})
|
||||
) {
|
||||
root.remove();
|
||||
return null;
|
||||
}
|
||||
|
||||
// The tile name is owner-switchable from one constant, so paint it from the
|
||||
// module rather than trusting the markup to have been edited to match.
|
||||
const environmentalTitle = root.querySelector('[data-first-run-environmental-title]');
|
||||
if (environmentalTitle) environmentalTitle.textContent = environmentalLabel().title;
|
||||
const environmentalTitle = root.querySelector(
|
||||
'[data-first-run-environmental-title]',
|
||||
);
|
||||
if (environmentalTitle)
|
||||
environmentalTitle.textContent = environmentalLabel().title;
|
||||
|
||||
const status = root.querySelector('[data-first-run-status]');
|
||||
const suppressBox = root.querySelector('[data-first-run-suppress]');
|
||||
@@ -387,10 +406,11 @@ export function initFirstRunExperience({
|
||||
* is exactly how every one of those surfaces hides this card; the hit test
|
||||
* then covers the overlays that leave the box intact and simply sit on top.
|
||||
*/
|
||||
const isTopmost = () => root.isConnected
|
||||
&& root.classList.contains('visible')
|
||||
&& root.getClientRects().length > 0
|
||||
&& !coveredByOverlay();
|
||||
const isTopmost = () =>
|
||||
root.isConnected &&
|
||||
root.classList.contains('visible') &&
|
||||
root.getClientRects().length > 0 &&
|
||||
!coveredByOverlay();
|
||||
|
||||
const dismiss = ({ restoreFocus = true } = {}) => {
|
||||
if (closing) return;
|
||||
@@ -416,10 +436,13 @@ export function initFirstRunExperience({
|
||||
root.setAttribute('aria-busy', String(next));
|
||||
// aria-disabled, not `disabled`: disabling the focused button drops focus to
|
||||
// <body> mid-flight and strands a keyboard visitor outside the launcher.
|
||||
for (const button of buttons) button.setAttribute('aria-disabled', String(next));
|
||||
for (const button of buttons)
|
||||
button.setAttribute('aria-disabled', String(next));
|
||||
if (!status) return;
|
||||
if (next) status.textContent = FIRST_RUN_MISSIONS[choice]?.busyText || 'Working…';
|
||||
else if (status.dataset.sticky !== 'true') status.textContent = defaultStatus;
|
||||
if (next)
|
||||
status.textContent = FIRST_RUN_MISSIONS[choice]?.busyText || 'Working…';
|
||||
else if (status.dataset.sticky !== 'true')
|
||||
status.textContent = defaultStatus;
|
||||
};
|
||||
|
||||
const onChoice = async (event) => {
|
||||
@@ -438,13 +461,16 @@ export function initFirstRunExperience({
|
||||
// does not decide panel chrome. This first-run click is an explicit
|
||||
// visual choice, so reveal the result exactly as the visible
|
||||
// Contacts / Space Missions tabs do.
|
||||
styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });
|
||||
styleManager.setPanelCollapsed?.('global-context-panel', false, {
|
||||
explicit: true,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
// `origin: 'user'` on purpose: a mission tile is a real person choosing
|
||||
// these layers, so it persists exactly as clicking those rows would.
|
||||
setLayerEnabled: (layerId) => dataManager.setEnabled(layerId, true, { origin: 'user' }),
|
||||
setLayerEnabled: (layerId) =>
|
||||
dataManager.setEnabled(layerId, true, { origin: 'user' }),
|
||||
flyToGlobe: () => styleManager.resetToGlobeView(),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -460,7 +486,8 @@ export function initFirstRunExperience({
|
||||
const failed = outcome?.failedLayerIds?.length
|
||||
? outcome.failedLayerIds
|
||||
: outcome?.result?.failedLayerIds;
|
||||
const detail = Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
|
||||
const detail =
|
||||
Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
|
||||
if (status) {
|
||||
status.dataset.sticky = 'true';
|
||||
status.textContent = `Could not open that mission${detail}. Retry or explore manually.`;
|
||||
@@ -479,7 +506,8 @@ export function initFirstRunExperience({
|
||||
if (box) box.checked = !wanted;
|
||||
if (!status) return;
|
||||
status.dataset.sticky = 'true';
|
||||
status.textContent = 'This browser is blocking storage, so that could not be saved.';
|
||||
status.textContent =
|
||||
'This browser is blocking storage, so that could not be saved.';
|
||||
};
|
||||
|
||||
const keyboard = createSurfaceKeyboard({
|
||||
@@ -585,13 +613,17 @@ export function initFirstRunExperience({
|
||||
const onViewportResize = () => syncScrollAffordance();
|
||||
globalThis.addEventListener?.('resize', onViewportResize);
|
||||
|
||||
const surfaceObserver = typeof globalThis.MutationObserver === 'function'
|
||||
? new globalThis.MutationObserver(syncToExclusiveSurfaces)
|
||||
: null;
|
||||
const surfaceObserver =
|
||||
typeof globalThis.MutationObserver === 'function'
|
||||
? new globalThis.MutationObserver(syncToExclusiveSurfaces)
|
||||
: null;
|
||||
// Attributes only, no subtree: this is a class watch on one element, so it
|
||||
// costs nothing per frame and never asks the render governor for a frame.
|
||||
if (documentRef.body) {
|
||||
surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });
|
||||
surfaceObserver?.observe(documentRef.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
}
|
||||
syncToExclusiveSurfaces();
|
||||
|
||||
|
||||
@@ -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\(\)/);
|
||||
// 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.
|
||||
assert.match(module, /const isTopmost = \(\) => root\.isConnected/);
|
||||
assert.match(module, /&& root\.getClientRects\(\)\.length > 0\s*\n\s*&& !coveredByOverlay\(\);/);
|
||||
assert.match(module, /const isTopmost = \(\) =>\s*root\.isConnected/);
|
||||
assert.match(module, /&&\s*root\.getClientRects\(\)\.length > 0 &&\s*!coveredByOverlay\(\);/);
|
||||
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
|
||||
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
|
||||
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 \}\)/);
|
||||
// A cheap attribute watch, not a per-frame poll — the render governor must
|
||||
// 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.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) || [];
|
||||
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:'));
|
||||
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', () => {
|
||||
|
||||
+96
-31
@@ -18,16 +18,36 @@ import * as Cesium from 'cesium';
|
||||
import { forward as toMGRS } from 'mgrs';
|
||||
import { CITY_POIS } from './locations.js';
|
||||
import { composeLocalityTag } from './hudLocality.js';
|
||||
import { ellipsoidalToMslDisplayM, ensureGeoidReady, geoidHeight } from './data/geoid.js';
|
||||
import {
|
||||
ellipsoidalToMslDisplayM,
|
||||
ensureGeoidReady,
|
||||
geoidHeight,
|
||||
} from './data/geoid.js';
|
||||
import { getBasemapLabelContext } from './voice/gevActions.js';
|
||||
import { isHudSummaryUnconfigured } from './hudSummaryResponse.js';
|
||||
|
||||
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||
const HUD_COLORS = {
|
||||
surveillance: { main: 'rgba(51, 255, 51, 0.8)', glow: 'rgba(51, 255, 51, 0.5)', border: 'rgba(51, 255, 51, 0.2)' },
|
||||
thermal: { main: 'rgba(255, 255, 255, 0.7)', glow: 'rgba(255, 255, 255, 0.4)', border: 'rgba(255, 255, 255, 0.15)' },
|
||||
retro: { main: 'rgba(255, 170, 0, 0.8)', glow: 'rgba(255, 170, 0, 0.5)', border: 'rgba(255, 170, 0, 0.2)' },
|
||||
_default: { main: 'rgba(0, 255, 255, 0.6)', glow: 'rgba(0, 255, 255, 0.4)', border: 'rgba(0, 255, 255, 0.15)' },
|
||||
surveillance: {
|
||||
main: 'rgba(51, 255, 51, 0.8)',
|
||||
glow: 'rgba(51, 255, 51, 0.5)',
|
||||
border: 'rgba(51, 255, 51, 0.2)',
|
||||
},
|
||||
thermal: {
|
||||
main: 'rgba(255, 255, 255, 0.7)',
|
||||
glow: 'rgba(255, 255, 255, 0.4)',
|
||||
border: 'rgba(255, 255, 255, 0.15)',
|
||||
},
|
||||
retro: {
|
||||
main: 'rgba(255, 170, 0, 0.8)',
|
||||
glow: 'rgba(255, 170, 0, 0.5)',
|
||||
border: 'rgba(255, 170, 0, 0.2)',
|
||||
},
|
||||
_default: {
|
||||
main: 'rgba(0, 255, 255, 0.6)',
|
||||
glow: 'rgba(0, 255, 255, 0.4)',
|
||||
border: 'rgba(0, 255, 255, 0.15)',
|
||||
},
|
||||
};
|
||||
|
||||
/** Shader modes that automatically show the HUD overlay. */
|
||||
@@ -45,13 +65,14 @@ const HUD_SUMMARY_INTERVAL_MS = 15000;
|
||||
const HUD_GEOID_CELL_DEG = 0.01;
|
||||
|
||||
/** Flattened list of all city POIs for nearest-point lookups. */
|
||||
const NEARBY_POINTS = Object.values(CITY_POIS)
|
||||
.flatMap((city) => city.pois.map((poi) => ({
|
||||
const NEARBY_POINTS = Object.values(CITY_POIS).flatMap((city) =>
|
||||
city.pois.map((poi) => ({
|
||||
city: city.name,
|
||||
poi: poi.name,
|
||||
lat: poi.lat,
|
||||
lon: poi.lon,
|
||||
})));
|
||||
})),
|
||||
);
|
||||
|
||||
/**
|
||||
* Full-screen intelligence HUD overlay rendered on top of the Cesium canvas.
|
||||
@@ -66,7 +87,15 @@ export class IntelHUD {
|
||||
* @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for
|
||||
* camera telemetry and coordinate derivation.
|
||||
*/
|
||||
constructor(viewer, { placeSearch, summaryPolicy = {}, basemapContext = {}, summaryService = applicationServices.summary } = {}) {
|
||||
constructor(
|
||||
viewer,
|
||||
{
|
||||
placeSearch,
|
||||
summaryPolicy = {},
|
||||
basemapContext = {},
|
||||
summaryService = applicationServices.summary,
|
||||
} = {},
|
||||
) {
|
||||
this.summaryService = summaryService;
|
||||
this.summaryPolicy = summaryPolicy;
|
||||
this.basemapContext = basemapContext;
|
||||
@@ -227,7 +256,8 @@ export class IntelHUD {
|
||||
this._recBlinkInterval = setInterval(() => {
|
||||
this._recBlinkState = !this._recBlinkState;
|
||||
const dot = document.getElementById('hud-rec-dot');
|
||||
if (dot) dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden';
|
||||
if (dot)
|
||||
dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden';
|
||||
}, 800);
|
||||
|
||||
// Camera-derived data — 4 updates/second (250ms)
|
||||
@@ -269,8 +299,12 @@ export class IntelHUD {
|
||||
if (!this._geoidRequested) {
|
||||
this._geoidRequested = true;
|
||||
ensureGeoidReady()
|
||||
.then(() => { this._geoidReady = true; })
|
||||
.catch(() => { /* readout falls back to the uncorrected height */ });
|
||||
.then(() => {
|
||||
this._geoidReady = true;
|
||||
})
|
||||
.catch(() => {
|
||||
/* readout falls back to the uncorrected height */
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -331,9 +365,13 @@ export class IntelHUD {
|
||||
// Equation (GIQE) simplified form: NIIRS = 10.25 - 3.32 * log10(GSD_inches).
|
||||
const gsd = Math.max(0.01, altM * 0.000375);
|
||||
const gsdInches = gsd * 39.37;
|
||||
const niirs = Math.max(0, Math.min(9, 10.25 - 3.32 * Math.log10(gsdInches)));
|
||||
const niirs = Math.max(
|
||||
0,
|
||||
Math.min(9, 10.25 - 3.32 * Math.log10(gsdInches)),
|
||||
);
|
||||
const gsdEl = document.getElementById('hud-gsd');
|
||||
if (gsdEl) gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
|
||||
if (gsdEl)
|
||||
gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
|
||||
|
||||
// Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
|
||||
// ellipsoidal camera height, which reads far below zero wherever the geoid
|
||||
@@ -344,7 +382,8 @@ export class IntelHUD {
|
||||
const geoidN = this._geoidUndulationM(latDeg, lonDeg);
|
||||
const altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
|
||||
const sunEl = this._estimateSunElevation(latDeg, lonDeg);
|
||||
if (altEl) altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
||||
if (altEl)
|
||||
altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
||||
|
||||
// Collection timestamp
|
||||
const collEl = document.getElementById('hud-coll');
|
||||
@@ -435,7 +474,10 @@ export class IntelHUD {
|
||||
if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S';
|
||||
else dir = decimal >= 0 ? 'E' : 'W';
|
||||
|
||||
const degStr = type === 'lon' ? String(deg).padStart(3, '0') : String(deg).padStart(2, '0');
|
||||
const degStr =
|
||||
type === 'lon'
|
||||
? String(deg).padStart(3, '0')
|
||||
: String(deg).padStart(2, '0');
|
||||
return `${degStr}°${String(min).padStart(2, '0')}'${String(sec).padStart(5, '0')}"${dir}`;
|
||||
}
|
||||
|
||||
@@ -457,12 +499,20 @@ export class IntelHUD {
|
||||
const solarNoon = 12;
|
||||
const hourAngle = (hours - solarNoon) * 15;
|
||||
// Solar declination approximation (~23.45 deg amplitude sinusoidal over the year)
|
||||
const declination = 23.45 * Math.sin(Cesium.Math.toRadians((360 / 365) * (now.getUTCDate() + 30 * now.getUTCMonth() - 81)));
|
||||
const declination =
|
||||
23.45 *
|
||||
Math.sin(
|
||||
Cesium.Math.toRadians(
|
||||
(360 / 365) * (now.getUTCDate() + 30 * now.getUTCMonth() - 81),
|
||||
),
|
||||
);
|
||||
const latRad = Cesium.Math.toRadians(lat);
|
||||
const decRad = Cesium.Math.toRadians(declination);
|
||||
const haRad = Cesium.Math.toRadians(hourAngle);
|
||||
// Standard formula: sin(el) = sin(lat)*sin(dec) + cos(lat)*cos(dec)*cos(ha)
|
||||
const sinEl = Math.sin(latRad) * Math.sin(decRad) + Math.cos(latRad) * Math.cos(decRad) * Math.cos(haRad);
|
||||
const sinEl =
|
||||
Math.sin(latRad) * Math.sin(decRad) +
|
||||
Math.cos(latRad) * Math.cos(decRad) * Math.cos(haRad);
|
||||
// Clamp to [-1,1] to guard against floating-point drift before asin
|
||||
return Cesium.Math.toDegrees(Math.asin(Math.max(-1, Math.min(1, sinEl))));
|
||||
}
|
||||
@@ -489,8 +539,10 @@ export class IntelHUD {
|
||||
_regionLabel(lat, lon) {
|
||||
if (lat > 72) return 'ARCTIC';
|
||||
if (lat < -60) return 'ANTARCTIC';
|
||||
if (lat >= 5 && lat <= 83 && lon >= -170 && lon <= -50) return 'NORTH AMERICA';
|
||||
if (lat >= -60 && lat <= 15 && lon >= -90 && lon <= -30) return 'SOUTH AMERICA';
|
||||
if (lat >= 5 && lat <= 83 && lon >= -170 && lon <= -50)
|
||||
return 'NORTH AMERICA';
|
||||
if (lat >= -60 && lat <= 15 && lon >= -90 && lon <= -30)
|
||||
return 'SOUTH AMERICA';
|
||||
if (lat >= 34 && lat <= 72 && lon >= -25 && lon <= 45) return 'EUROPE';
|
||||
if (lat >= -35 && lat <= 38 && lon >= -20 && lon <= 55) return 'AFRICA';
|
||||
if (lat >= 5 && lat <= 80 && lon >= 45 && lon <= 180) return 'ASIA';
|
||||
@@ -519,7 +571,10 @@ export class IntelHUD {
|
||||
const latSpan = Math.abs(north - south);
|
||||
// 111 km/deg is the approximate surface distance per degree of latitude;
|
||||
// longitude distance is scaled by cos(lat) to account for meridian convergence.
|
||||
const widthKm = Math.max(0, lonSpan * 111 * Math.cos(Cesium.Math.toRadians(latDeg)));
|
||||
const widthKm = Math.max(
|
||||
0,
|
||||
lonSpan * 111 * Math.cos(Cesium.Math.toRadians(latDeg)),
|
||||
);
|
||||
const heightKm = Math.max(0, latSpan * 111);
|
||||
return { widthKm, heightKm };
|
||||
}
|
||||
@@ -537,8 +592,9 @@ export class IntelHUD {
|
||||
const toRad = (deg) => Cesium.Math.toRadians(deg);
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
@@ -583,9 +639,10 @@ export class IntelHUD {
|
||||
// together, so they must never disagree. The view band above deliberately
|
||||
// keeps the ellipsoidal height: its thresholds were tuned against it.
|
||||
const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM;
|
||||
const altTag = altDisplayM >= 1000
|
||||
? `${(altDisplayM / 1000).toFixed(1)}KM`
|
||||
: `${Math.round(altDisplayM)}M`;
|
||||
const altTag =
|
||||
altDisplayM >= 1000
|
||||
? `${(altDisplayM / 1000).toFixed(1)}KM`
|
||||
: `${Math.round(altDisplayM)}M`;
|
||||
const winTag = window
|
||||
? `${Math.max(1, Math.round(window.widthKm))}x${Math.max(1, Math.round(window.heightKm))}KM`
|
||||
: 'N/A';
|
||||
@@ -665,7 +722,9 @@ export class IntelHUD {
|
||||
this._summaryRequest = controller;
|
||||
try {
|
||||
this.summaryPolicy.onRequest?.();
|
||||
const response = await this.summaryService.summarize(context, { signal: controller.signal });
|
||||
const response = await this.summaryService.summarize(context, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const data = response.data;
|
||||
if (revision !== this._summaryRevision) return;
|
||||
if (isHudSummaryUnconfigured(response.status, data)) {
|
||||
@@ -701,10 +760,16 @@ export class IntelHUD {
|
||||
}
|
||||
|
||||
async _summaryContext() {
|
||||
const labels = await getBasemapLabelContext(this.viewer, this.placeSearch, this.basemapContext);
|
||||
const enabledLayers = this._dataManager?.getAll?.()
|
||||
?.filter((layer) => layer.enabled)
|
||||
.map((layer) => layer.name) || [];
|
||||
const labels = await getBasemapLabelContext(
|
||||
this.viewer,
|
||||
this.placeSearch,
|
||||
this.basemapContext,
|
||||
);
|
||||
const enabledLayers =
|
||||
this._dataManager
|
||||
?.getAll?.()
|
||||
?.filter((layer) => layer.enabled)
|
||||
.map((layer) => layer.name) || [];
|
||||
return {
|
||||
placeLabels: labels.placeLabels,
|
||||
streetLabels: labels.streetLabels,
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
assert.equal(
|
||||
has(/\.catch\(\(\) => \{ \/\* readout falls back to the uncorrected height \*\/ \}\)/),
|
||||
has(/\.catch\(\(\) => \{\s*\/\* readout falls back to the uncorrected height \*\/\s*\}\)/),
|
||||
true,
|
||||
'a failed geoid load must leave the readout uncorrected, not unhandled',
|
||||
);
|
||||
|
||||
@@ -23,13 +23,16 @@ export function keylessHudSummaryResponse(apiKey) {
|
||||
|
||||
/** Return true only for the deliberate, successful no-key capability response. */
|
||||
export function isHudSummaryUnconfigured(status, data) {
|
||||
const keys = data !== null && typeof data === 'object' && !Array.isArray(data)
|
||||
? Object.keys(data)
|
||||
: [];
|
||||
return keys.length === 4
|
||||
&& status === 200
|
||||
&& data?.configured === false
|
||||
&& data?.code === HUD_SUMMARY_UNCONFIGURED_CODE
|
||||
&& data?.error === null
|
||||
&& data?.summary === null;
|
||||
const keys =
|
||||
data !== null && typeof data === 'object' && !Array.isArray(data)
|
||||
? Object.keys(data)
|
||||
: [];
|
||||
return (
|
||||
keys.length === 4 &&
|
||||
status === 200 &&
|
||||
data?.configured === false &&
|
||||
data?.code === HUD_SUMMARY_UNCONFIGURED_CODE &&
|
||||
data?.error === null &&
|
||||
data?.summary === null
|
||||
);
|
||||
}
|
||||
+69
-26
@@ -19,7 +19,9 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
||||
/** Chip label — pure, exported for tests. */
|
||||
export function keySetupChipLabel(status) {
|
||||
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0));
|
||||
return missing > 0 ? `POWER UP · ${missing} ${missing === 1 ? 'KEY' : 'KEYS'} WAITING` : 'POWERED UP';
|
||||
return missing > 0
|
||||
? `POWER UP · ${missing} ${missing === 1 ? 'KEY' : 'KEYS'} WAITING`
|
||||
: 'POWERED UP';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,13 +80,17 @@ function buildRow(documentRef, key) {
|
||||
const tier = documentRef.createElement('span');
|
||||
tier.className = 'key-setup-tier';
|
||||
tier.textContent = TIER_DOTS[key.tier] || '';
|
||||
tier.title = key.tier === 'metered' ? 'Metered — a billing-enabled account' : 'Free key — register, paste, done';
|
||||
tier.title =
|
||||
key.tier === 'metered'
|
||||
? 'Metered — a billing-enabled account'
|
||||
: 'Free key — register, paste, done';
|
||||
head.append(led, title, tier);
|
||||
if (key.clientExposed) {
|
||||
const exposed = documentRef.createElement('span');
|
||||
exposed.className = 'key-setup-exposed';
|
||||
exposed.textContent = 'browser-side';
|
||||
exposed.title = 'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)';
|
||||
exposed.title =
|
||||
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)';
|
||||
head.append(exposed);
|
||||
}
|
||||
if (external) {
|
||||
@@ -93,7 +99,8 @@ function buildRow(documentRef, key) {
|
||||
const badge = documentRef.createElement('span');
|
||||
badge.className = 'key-setup-external';
|
||||
badge.textContent = 'configured externally';
|
||||
badge.title = 'Supplied by your environment, Keychain, or launcher — change it where it was set';
|
||||
badge.title =
|
||||
'Supplied by your environment, Keychain, or launcher — change it where it was set';
|
||||
head.append(badge);
|
||||
}
|
||||
const get = documentRef.createElement('a');
|
||||
@@ -144,7 +151,11 @@ function buildRow(documentRef, key) {
|
||||
* Wire the chip + dialog. Fire-and-forget from main.js; resolves to null when
|
||||
* the surface has no business existing (prod build, LAN visitor, no markup).
|
||||
*/
|
||||
export async function initKeySetup({ documentRef = globalThis.document, fetchImpl, signal } = {}) {
|
||||
export async function initKeySetup({
|
||||
documentRef = globalThis.document,
|
||||
fetchImpl,
|
||||
signal,
|
||||
} = {}) {
|
||||
const chip = documentRef?.getElementById?.('key-setup-chip');
|
||||
const root = documentRef?.getElementById?.('key-setup');
|
||||
if (!chip || !root || root.dataset.initialized === 'true') return null;
|
||||
@@ -161,13 +172,19 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
||||
chip.remove();
|
||||
root.remove();
|
||||
};
|
||||
if (signal?.aborted) { destroy(); return null; }
|
||||
if (signal?.aborted) {
|
||||
destroy();
|
||||
return null;
|
||||
}
|
||||
signal?.addEventListener('abort', destroy, { once: true });
|
||||
const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis);
|
||||
|
||||
let status = null;
|
||||
try {
|
||||
const response = await doFetch('/api/setup/status', { cache: 'no-store', signal: lifetime.signal });
|
||||
const response = await doFetch('/api/setup/status', {
|
||||
cache: 'no-store',
|
||||
signal: lifetime.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
status = await response.json();
|
||||
if (disposed) return null;
|
||||
@@ -196,12 +213,14 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
||||
chip.hidden = status.setCount >= status.total;
|
||||
if (!rowsHost) return;
|
||||
rowsHost.textContent = '';
|
||||
for (const key of status.keys || []) rowsHost.append(buildRow(documentRef, key));
|
||||
for (const key of status.keys || [])
|
||||
rowsHost.append(buildRow(documentRef, key));
|
||||
};
|
||||
|
||||
const visible = () => root.isConnected
|
||||
&& root.classList.contains('visible')
|
||||
&& root.getClientRects().length > 0;
|
||||
const visible = () =>
|
||||
root.isConnected &&
|
||||
root.classList.contains('visible') &&
|
||||
root.getClientRects().length > 0;
|
||||
|
||||
const keyboard = createSurfaceKeyboard({
|
||||
root,
|
||||
@@ -226,22 +245,29 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
||||
if (!open) return;
|
||||
open = false;
|
||||
root.classList.remove('visible');
|
||||
const hide = () => { if (!open) root.hidden = true; };
|
||||
const hide = () => {
|
||||
if (!open) root.hidden = true;
|
||||
};
|
||||
root.addEventListener('transitionend', hide, { once: true });
|
||||
globalThis.setTimeout?.(hide, 400);
|
||||
if (statusLine) statusLine.textContent = defaultStatusText;
|
||||
keyboard.deactivate({ restoreFocus: true });
|
||||
};
|
||||
|
||||
const say = (text) => { if (statusLine) statusLine.textContent = text; };
|
||||
const say = (text) => {
|
||||
if (statusLine) statusLine.textContent = text;
|
||||
};
|
||||
|
||||
const storeLabel = () => (status?.store === 'pinokio-environment'
|
||||
? 'your app configuration'
|
||||
: 'your local .env');
|
||||
const storeLabel = () =>
|
||||
status?.store === 'pinokio-environment'
|
||||
? 'your app configuration'
|
||||
: 'your local .env';
|
||||
|
||||
const submitUpdates = async (updates, doneVerb) => {
|
||||
if (disposed || busy) return;
|
||||
const googleWasUnset = !status?.keys?.find((key) => key.id === 'google-maps')?.set;
|
||||
const googleWasUnset = !status?.keys?.find(
|
||||
(key) => key.id === 'google-maps',
|
||||
)?.set;
|
||||
busy = true;
|
||||
applyButton?.setAttribute('aria-disabled', 'true');
|
||||
say('Saving…');
|
||||
@@ -258,13 +284,17 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
||||
say(payload.error || `Save failed (${response.status}).`);
|
||||
return;
|
||||
}
|
||||
for (const input of root.querySelectorAll('input[data-env-var]')) input.value = '';
|
||||
for (const input of root.querySelectorAll('input[data-env-var]'))
|
||||
input.value = '';
|
||||
render(payload.status);
|
||||
if (googleWasUnset && payload.saved?.includes('GOOGLE_MAPS_API_KEY')) {
|
||||
const strip = () => {
|
||||
try {
|
||||
const next = stripKeylessBasemapFromHash(globalThis.location?.hash?.slice(1) || '');
|
||||
if (next !== null) globalThis.history?.replaceState?.(null, '', `#${next}`);
|
||||
const next = stripKeylessBasemapFromHash(
|
||||
globalThis.location?.hash?.slice(1) || '',
|
||||
);
|
||||
if (next !== null)
|
||||
globalThis.history?.replaceState?.(null, '', `#${next}`);
|
||||
} catch {
|
||||
// Continuity is a nicety, never a blocker.
|
||||
}
|
||||
@@ -272,9 +302,14 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
||||
strip();
|
||||
// The live share writer may re-serialize the still-OSM stack before
|
||||
// the restart's reload lands, so strip again at the door.
|
||||
globalThis.addEventListener?.('pagehide', strip, { once: true, signal: lifetime.signal });
|
||||
globalThis.addEventListener?.('pagehide', strip, {
|
||||
once: true,
|
||||
signal: lifetime.signal,
|
||||
});
|
||||
}
|
||||
say(`${doneVerb} ${storeLabel()}. Restarting — this page reloads itself.`);
|
||||
say(
|
||||
`${doneVerb} ${storeLabel()}. Restarting — this page reloads itself.`,
|
||||
);
|
||||
} catch (error) {
|
||||
say(`Save failed: ${error?.message || error}`);
|
||||
} finally {
|
||||
@@ -287,7 +322,10 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
||||
if (disposed || busy) return;
|
||||
const inputs = [...root.querySelectorAll('input[data-env-var]')];
|
||||
const updates = collectKeyUpdates(
|
||||
inputs.map((input) => ({ envVar: input.dataset.envVar, value: input.value })),
|
||||
inputs.map((input) => ({
|
||||
envVar: input.dataset.envVar,
|
||||
value: input.value,
|
||||
})),
|
||||
);
|
||||
if (!Object.keys(updates).length) {
|
||||
say('Paste at least one key first.');
|
||||
@@ -313,8 +351,9 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
||||
// Removal is destructive and — behind a framing defense that should already
|
||||
// stop it — a clickjack target. A confirm turns a single aligned click into
|
||||
// a deliberate two-step the lure cannot pre-satisfy.
|
||||
const ok = typeof globalThis.confirm !== 'function'
|
||||
|| globalThis.confirm('Remove this key from your saved configuration?');
|
||||
const ok =
|
||||
typeof globalThis.confirm !== 'function' ||
|
||||
globalThis.confirm('Remove this key from your saved configuration?');
|
||||
if (!ok) return;
|
||||
void submitUpdates(
|
||||
Object.fromEntries(envVars.map((name) => [name, null])),
|
||||
@@ -327,7 +366,11 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
||||
// Re-entry for a fully-keyed setup, demos, and support: ?setup=1 opens the
|
||||
// dialog even though the chip has retired.
|
||||
try {
|
||||
if (new URLSearchParams(globalThis.location?.search || '').get('setup') === '1') openDialog();
|
||||
if (
|
||||
new URLSearchParams(globalThis.location?.search || '').get('setup') ===
|
||||
'1'
|
||||
)
|
||||
openDialog();
|
||||
} catch {
|
||||
// An unparsable location never blocks init.
|
||||
}
|
||||
|
||||
+108
-32
@@ -18,7 +18,8 @@ export const KEY_SETUP_VALUE_LIMIT = 512;
|
||||
export const KEY_SETUP_UPDATE_LIMIT = 16;
|
||||
|
||||
/** 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
|
||||
@@ -42,7 +43,8 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
id: 'google-maps-server',
|
||||
title: 'GOOGLE MAPS — SERVER',
|
||||
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']),
|
||||
tier: 'metered',
|
||||
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. */
|
||||
function localAuthority(hostHeader, protocol) {
|
||||
const raw = String(hostHeader || '').trim().toLowerCase();
|
||||
const raw = String(hostHeader || '')
|
||||
.trim()
|
||||
.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 {
|
||||
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 {
|
||||
return null;
|
||||
}
|
||||
@@ -131,7 +138,9 @@ export function commandCompletedSuccessfully(result) {
|
||||
|
||||
/** Parse one RFC-4180-shaped CSV record, sufficient for `whoami /fo csv`. */
|
||||
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;
|
||||
const fields = [];
|
||||
let field = '';
|
||||
@@ -210,9 +219,25 @@ export function admitKeySetupRequest({
|
||||
// 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
|
||||
// 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'];
|
||||
if (PROXY_SIGNALS.some((name) => String(proxyHeaders[name] || '').trim() !== '')) {
|
||||
return { ok: false, status: 403, error: 'Provider Settings does not answer proxied requests' };
|
||||
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',
|
||||
];
|
||||
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)
|
||||
// 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
|
||||
// refuses to boot at all when sharing is genuinely on.
|
||||
const shareVar = String(env.PINOKIO_SHARE_VAR ?? '').trim();
|
||||
const sharingEnabled = ['PINOKIO_SHARE_CLOUDFLARE', 'PINOKIO_SHARE_LOCAL']
|
||||
.some((name) => /^(1|true)$/i.test(String(env[name] || '').trim()))
|
||||
|| (shareVar !== '' && shareVar !== '__gev_sharing_disabled__');
|
||||
const sharingEnabled =
|
||||
['PINOKIO_SHARE_CLOUDFLARE', 'PINOKIO_SHARE_LOCAL'].some((name) =>
|
||||
/^(1|true)$/i.test(String(env[name] || '').trim()),
|
||||
) ||
|
||||
(shareVar !== '' && shareVar !== '__gev_sharing_disabled__');
|
||||
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 || ''))) {
|
||||
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);
|
||||
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 === '')) {
|
||||
return { ok: false, status: 403, error: 'Provider Settings requires an exact local Origin' };
|
||||
if (
|
||||
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 !== '') {
|
||||
let parsedOrigin;
|
||||
@@ -248,18 +294,32 @@ export function admitKeySetupRequest({
|
||||
} catch {
|
||||
return { ok: false, status: 403, error: 'Unrecognized Origin refused' };
|
||||
}
|
||||
const exactOrigin = parsedOrigin.username === ''
|
||||
&& parsedOrigin.password === ''
|
||||
&& parsedOrigin.pathname === '/'
|
||||
&& parsedOrigin.search === ''
|
||||
&& parsedOrigin.hash === ''
|
||||
&& parsedOrigin.origin === authority;
|
||||
const exactOrigin =
|
||||
parsedOrigin.username === '' &&
|
||||
parsedOrigin.password === '' &&
|
||||
parsedOrigin.pathname === '/' &&
|
||||
parsedOrigin.search === '' &&
|
||||
parsedOrigin.hash === '' &&
|
||||
parsedOrigin.origin === authority;
|
||||
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')) {
|
||||
return { ok: false, status: 415, error: 'Content-Type must be application/json' };
|
||||
if (
|
||||
method === 'POST' &&
|
||||
!String(contentType || '')
|
||||
.toLowerCase()
|
||||
.startsWith('application/json')
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 415,
|
||||
error: 'Content-Type must be application/json',
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -337,12 +397,18 @@ export function keySetupStatus(env = {}) {
|
||||
*/
|
||||
export function validateKeySetupUpdates(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);
|
||||
if (entries.length === 0) return { ok: false, error: 'No keys provided' };
|
||||
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 updates = {};
|
||||
@@ -352,14 +418,21 @@ export function validateKeySetupUpdates(body) {
|
||||
updates[name] = null;
|
||||
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();
|
||||
if (!value) return { ok: false, error: `${name} is empty` };
|
||||
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)) {
|
||||
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
|
||||
// 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
|
||||
// these; they are base64url / hex / JWT alphabets.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createTransitSource } from './source.js';
|
||||
import { createTrails } from './trails.js';
|
||||
import { createState } from './state.js';
|
||||
import { createHeight } from './height.js';
|
||||
@@ -23,15 +24,26 @@ import { TRANSIT_POLL_MS } from './policy.js';
|
||||
* @param {{services: object}} options
|
||||
* @returns {object} The data-layer module the manager registers.
|
||||
*/
|
||||
export function createTransitLayer({ services }) {
|
||||
export function createTransitLayer({
|
||||
services,
|
||||
source = createTransitSource(),
|
||||
}) {
|
||||
if (!services?.overlays || !services?.render || !services?.sprites) {
|
||||
throw new TypeError(
|
||||
'A transit layer needs overlay, render and sprite services',
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof source?.requestSnapshot !== 'function' ||
|
||||
typeof source?.getHistory !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'A transit source needs snapshot and history operations',
|
||||
);
|
||||
}
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, services, parts };
|
||||
const context = { state, services, parts, source };
|
||||
parts.trails = createTrails(context);
|
||||
parts.height = createHeight(context);
|
||||
parts.selection = createSelection(context);
|
||||
|
||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user