refactor: enforce import directions and expose source factories

This commit is contained in:
Sameh Khamis committed 2026-09-14 21:12:01 -07:00
1 parent 704e0fcc51
commit cc960b0dbe
41 files changed
+1080 -417

No files matched your search

+2
View File
@@ -1,5 +1,7 @@
# Changelog # Changelog
- Add source-only layer exports and enforce source, browser, standalone and voice import directions. Move plain record/feed helpers and settings filesystem hardening to their owners while preserving compatibility and behavior.
- Separate voice session lifetime and common controls from the default Realtime protocol adapter. - Separate voice session lifetime and common controls from the default Realtime protocol adapter.
- Separate canonical voice action arguments from descriptive wording, preserving the existing Realtime tool inventory. - Separate canonical voice action arguments from descriptive wording, preserving the existing Realtime tool inventory.
+4 -3
View File
@@ -42,15 +42,16 @@ it is not a production server.
The highest-leverage places to jump in: The highest-leverage places to jump in:
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane. - **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane.
- **🛰️ Add or improve a data layer.** Each layer is one self-contained module in `src/data/<layer>.js` implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template. - **🛰️ Add or improve a data layer.** Layer factories live in `src/layers/<family>/`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template.
- **🎙️ Extend voice control.** Voice tools are declared server-side (`GEV_REALTIME_TOOLS` in `server/providers/openai/tools.js`) and executed client-side (`src/voice/gevActions.js`). Keep the tool surface tight and the responses honest (confirm only what actually happened). - **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened).
- **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`. - **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`.
- **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md). - **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md).
## Architecture in one minute ## Architecture in one minute
- **No framework.** Vanilla JS + [CesiumJS](https://cesium.com/platform/cesiumjs/) + [Vite](https://vitejs.dev/). - **No framework.** Vanilla JS + [CesiumJS](https://cesium.com/platform/cesiumjs/) + [Vite](https://vitejs.dev/).
- **UI lives in `src/ui.js`** (panels, HUD, styles, the control facade). **Layer logic lives in `src/data/<layer>.js`.** Keep them separate. - **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/ui.js` and `src/data/<layer>.js` entries retain compatibility; new code belongs with its focused owner.
- Sources acquire records; renderers own Cesium resources. Import `gods-eye-view/layers/<family>/source` when only a source factory is needed. Common voice controls consume the session interface; protocol adapters own connection details.
- **Secrets stay server-side.** Anything needing a private key goes through a local proxy under `server/providers/`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens. - **Secrets stay server-side.** Anything needing a private key goes through a local proxy under `server/providers/`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens.
- `docs/CURRENT-STATE.md` is the authoritative runtime reference — read it first. - `docs/CURRENT-STATE.md` is the authoritative runtime reference — read it first.
+69 -32
View File
@@ -16,43 +16,80 @@ excluded. The formatter validates every entry before writing any file.
## Current component ownership ## Current component ownership
| Surface | Owns | Receives from its caller | Package imports use `gods-eye-view`; `package.json` is the authoritative
| -------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | export inventory. Use declared exports rather than reaching into internal files.
| `gods-eye-view/infrastructure` | Datacenter/dam definitions and fresh layer construction | Context, overlay and render operations |
| `gods-eye-view/infrastructure/geojson` | Data loading, Cesium entities, selection handling and resource cleanup | A viewer and those same operations |
| `gods-eye-view/infrastructure/lod` | Pure visibility budgets and selection policy | Position/visibility records and camera measurements |
| `src/data/localGeojson.js` | Standalone compatibility wiring | The application's existing shared services |
| `src/main.js` and `src/standalone/` | Standalone browser startup | Local configuration |
The application and infrastructure exports are browser source modules. Use their documented | Owner | Responsibility and lifetime |
exports instead of importing standalone startup or reaching into internal files. | ---------------------- | ------------------------------------------------------------------------------------------------------- |
The application owns the viewer, context store, overlay host and render scheduler; | `src/app/` | Construct supplied components, share scene/request services, cancel startup and dispose the application |
layers use the supplied callbacks. See [the infrastructure contract](INFRASTRUCTURE-LAYERS.md). | `src/standalone/` | Select the default catalog, local sources and setup controls |
| `src/ui/` | Navigation generations, restoration, visual state, panel snapshots and their subscriptions |
| `src/data/` | Lifecycle, context and feed state; legacy default-layer facades |
| `src/layers/<family>/` | Source acquisition, records and Cesium resources with explicit controller/renderer owners |
| `src/sources/` | Portable protocols and source contracts; request state belongs to each factory instance |
| `src/services/` | Supplied application operations; scene construction owns caches and cancellation |
| `src/voice/` | Portable action schemas/session, common controls, action execution and separate protocol adapters |
| `server/providers/` | Node route factories, process-scoped provider caches and shutdown cleanup |
| `server/standalone/` | Environment, local settings writes and server composition |
`npm run check:boundaries` builds every declared package export, with app Vite See [application construction](APPLICATION.md) and the
configuration disabled. `scripts/package-boundaries.json` lists each export's [infrastructure contract](INFRASTRUCTURE-LAYERS.md) for construction interfaces.
component, owned modules and external runtime dependencies. A new export must be
classified. Imports outside the declared modules fail, including unused and
literal dynamic imports. Cesium stays external so the consuming application
supplies the same compatible instance as its viewer. Existing consumer tests
also check import-time inactivity and asset URLs under a non-root base.
These checks cover the declared exports, not every import in the application. ## Import direction gates
They check build-time imports, not arbitrary runtime-generated module URLs.
Keep runtime module discovery out of these exports. When extracting another
component, add its ownership and consumer tests together. Node services must use
separate entry points and their own checks when they become reusable; importing
them into a browser component is not supported.
`gods-eye-view/application` owns construction order, startup state, cancellation `npm run check:boundaries` runs two complementary checks:
and disposal of caller-supplied components. Its only owned module is
`src/app/application.js`. `gods-eye-view/application/viewer` separately owns the
standard Cesium viewer configuration in `src/app/viewer.js`; Cesium stays external.
Neither export imports standalone UI, layers, tools or configuration. See
[application construction](APPLICATION.md) for the contracts and current limits.
UI panels and individual source adapters remain future extractions. They should 1. `scripts/check-import-directions.mjs` parses every runtime JS/MJS/CJS file in
become smaller modules with explicit lifecycle owners as their callers migrate. `src/` and `server/`, including files unused by the current bundle. Static,
literal dynamic and re-export edges are checked; computed module imports and
CommonJS `require` are rejected. Browser graphs cannot reach Node, server or
test modules through helpers. Reusable modules cannot select standalone setup,
and provider modules cannot import application/rendering modules.
2. `scripts/check-package-boundaries.mjs` builds every declared export without
app Vite configuration or environment files. `scripts/package-boundaries.json`
assigns each export exactly once and lists its owned modules and external
dependencies. Unused imports still count. Node exports have only a `node`
condition; browser groups cannot use build-only dependency exceptions.
Portable source graphs cannot reach application/rendering, Node, Cesium or
browser globals. This includes `sources/*`, dedicated `layers/*/source` exports,
flight/military/vessel record and ingestion exports, action schemas, the session
interface, lifecycle and feed state. The browser-global rule reserves platform
names such as `document` and `window` in these modules; it is an architectural
check, not a JavaScript sandbox. Common voice controls cannot depend on a
Realtime protocol implementation. Negative fixtures cover indirect helpers,
self-package imports, symlinks and unreachable files.
Source factories have dedicated exports for ALPR, bikeshare, CCTV, earthquakes,
FIRMS, installations, launches, radio, satellites and traffic. They preserve the
same factory implementations without loading layer rendering. ALPR/earthquake
record normalization and CCTV source endpoint policy have plain owners separate
from geometry/cards. Source exports do not start acquisition at import time.
Other layer `ingestion.js` files may still coordinate Cesium resources; the
portable contract applies to the explicitly reviewed graphs above. Their layer
controller remains the owner of rendering/cleanup until a focused extraction
moves it. Do not label all ingestion modules platform-independent by filename.
## Compatibility entries and owners
| Retained entry | Owner and current reason |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `src/ui.js` | UI facade; direct callers retain standalone StyleManager defaults. Normal assembly uses `src/ui/composition.js` |
| `src/data/manager.js` | Lifecycle/panel compatibility and existing tests; normal assembly constructs lifecycle and presentation separately |
| `src/data/<layer>.js` | Legacy default instances; new assembly calls layer factories with supplied sources |
| `src/data/localGeojson.js` | Infrastructure compatibility factories; catalog imports services from `src/app/localGeojsonServices.js` directly |
| `src/app/sourceSlot.js` | Re-export for direct callers/tests; portable slot ownership is `src/sources/sourceSlot.js` |
| `src/app/sources.js`, `src/services/application.js` | Existing default/override API; current application construction supplies its own instances |
| `server/providers/local.js` | Existing Node composition barrel and provider helper re-exports |
The two exact standalone-import exceptions are `src/ui.js` to
`src/standalone/catalog.js` and `server/providers/local.js` to
`server/standalone/key-setup.js`. They do not authorize new compatibility
back-edges. Voice actions use plain `src/data/feedState.js`, not the manager
facade. Settings filesystem hardening belongs in
`server/standalone/key-setup-hardening.mjs`; its settings policy is unchanged.
Test modules and the Node-only allocation benchmark are outside browser runtime.
## Build and standalone server configuration ## Build and standalone server configuration
+2
View File
@@ -1,5 +1,7 @@
# God's Eye View Current State # God's Eye View Current State
Source factories have dedicated `layers/<family>/source` exports. ALPR and earthquake record normalization and CCTV source policy no longer pull rendering into source consumers. Catalog construction and voice feed reads use their focused owners; compatibility entries remain available. Settings filesystem hardening lives under `server/standalone/`. Import-direction checks complement export ownership checks; source behavior, settings policy and rendering are unchanged.
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/result, interruption and completion events; stopping or replacing a session cancels pending actions. Alternate adapters can use the same controls and action runner. Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/result, interruption and completion events; stopping or replacing a session cancels pending actions. Alternate adapters can use the same controls and action runner.
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds the same 28 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields. Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds the same 28 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields.
+12 -2
View File
@@ -43,7 +43,7 @@
"test": "node scripts/run-unit-tests.mjs", "test": "node scripts/run-unit-tests.mjs",
"test:track": "node scripts/track-regression.mjs", "test:track": "node scripts/track-regression.mjs",
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs", "qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
"check:boundaries": "node scripts/check-package-boundaries.mjs" "check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs"
}, },
"dependencies": { "dependencies": {
"@mapbox/vector-tile": "^3.0.0", "@mapbox/vector-tile": "^3.0.0",
@@ -214,6 +214,16 @@
"./voice/action-schemas": "./src/voice/actionSchemas.js", "./voice/action-schemas": "./src/voice/actionSchemas.js",
"./voice/session": "./src/voice/session.js", "./voice/session": "./src/voice/session.js",
"./voice/session-controls": "./src/voice/sessionCommands.js", "./voice/session-controls": "./src/voice/sessionCommands.js",
"./voice/realtime-session": "./src/voice/realtimeSession.js" "./voice/realtime-session": "./src/voice/realtimeSession.js",
"./layers/alpr/source": "./src/layers/alpr/source.js",
"./layers/bikeshare/source": "./src/layers/bikeshare/source.js",
"./layers/cctv/source": "./src/layers/cctv/source.js",
"./layers/earthquakes/source": "./src/layers/earthquakes/source.js",
"./layers/firms/source": "./src/layers/firms/source.js",
"./layers/installations/source": "./src/layers/installations/source.js",
"./layers/launches/source": "./src/layers/launches/source.js",
"./layers/radio/source": "./src/layers/radio/source.js",
"./layers/satellites/source": "./src/layers/satellites/source.js",
"./layers/traffic/source": "./src/layers/traffic/source.js"
} }
} }
+254
View File
@@ -0,0 +1,254 @@
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, realpathSync } from 'node:fs';
import { builtinModules } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { analyzeModule } from './module-analysis.mjs';
const builtins = new Set(
builtinModules.flatMap((name) => [name, `node:${name}`]),
);
const code = /\.[mc]?js$/;
const entry = (file) =>
file === 'src/main.js' || file.startsWith('src/standalone/');
const tests = (file) =>
file.endsWith('.test.mjs') ||
file.startsWith('src/testSupport/') ||
file.startsWith('src/tooling/') ||
file === 'src/overlays/worldOverlayAllocation.worker.mjs';
const source = (file) =>
file.startsWith('src/sources/') ||
/^src\/layers\/[^/]+\/(?:source|bundledSource|flowSource)\.js$/.test(file);
const renderer = (file) =>
/^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
file,
);
// Two compatibility composition entries intentionally select standalone defaults.
const compatibilityEdge = (from, to) =>
(from === 'server/providers/local.js' &&
to === 'server/standalone/key-setup.js') ||
(from === 'src/ui.js' && to === 'src/standalone/catalog.js');
const portableExport = (key) =>
key.startsWith('./sources/') ||
/\/source$/.test(key) ||
/^\.\/layers\/(?:flights|military|vessels)\/(?:records|ingestion)$/.test(
key,
) ||
['./voice/action-schemas', './voice/session', './data/lifecycle'].includes(
key,
);
/** Check every runtime file, plus transitive portable/source graphs, independently of bundler reachability. */
export function checkImportDirections(root) {
root = realpathSync(root);
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
const files = [
...new Set(
execFileSync(
'git',
[
'ls-files',
'-z',
'--cached',
'--others',
'--exclude-standard',
'--',
'src',
'server',
],
{ cwd: root, encoding: 'utf8' },
).split('\0'),
),
].filter(
(file) =>
code.test(file) && !tests(file) && existsSync(path.join(root, file)),
);
const errors = new Set();
const modules = new Map();
const relative = (file) =>
path.relative(root, file).split(path.sep).join('/');
const report = (file, message) => errors.add(`${file}: ${message}`);
function load(file) {
if (modules.has(file)) return modules.get(file);
const resolved = realpathSync(path.join(root, file));
if (relative(resolved) !== file) {
report(
file,
'Runtime modules must not alias other owners through symlinks',
);
return null;
}
let analysis;
try {
analysis = analyzeModule(readFileSync(resolved, 'utf8'));
} catch (error) {
report(file, error.message);
return null;
}
const record = { ...analysis, edges: [], external: [] };
modules.set(file, record);
for (const specifier of analysis.imports) {
let target;
if (specifier.startsWith('.'))
target = path.resolve(path.dirname(resolved), specifier.split('?')[0]);
else if (specifier === pkg.name || specifier.startsWith(`${pkg.name}/`)) {
const key =
specifier === pkg.name ? '.' : '.' + specifier.slice(pkg.name.length);
const exported = pkg.exports?.[key];
if (typeof exported !== 'string') {
report(file, `Unsupported self import: ${specifier}`);
continue;
}
target = path.resolve(root, exported);
} else {
record.external.push(specifier);
if (
file.startsWith('src/') &&
(builtins.has(specifier) || specifier.startsWith('node:'))
)
report(file, `Node builtin in browser module: ${specifier}`);
if (
specifier.startsWith('/') ||
(/^[a-z]+:/i.test(specifier) && !specifier.startsWith('node:'))
)
report(file, `Use repository or package imports: ${specifier}`);
continue;
}
if (!existsSync(target)) {
report(file, `Missing import: ${specifier}`);
continue;
}
const to = relative(realpathSync(target));
if (to.startsWith('../') || path.isAbsolute(to)) {
report(file, `Import escapes repository: ${specifier}`);
continue;
}
record.edges.push(to);
if (file.startsWith('src/') && to.startsWith('server/'))
report(file, `Browser imports server: ${to}`);
if (
!compatibilityEdge(file, to) &&
file.startsWith('server/providers/') &&
(to.startsWith('server/standalone/') || renderer(to))
)
report(file, `Provider imports application/rendering: ${to}`);
if (
!compatibilityEdge(file, to) &&
file.startsWith('src/') &&
!entry(file) &&
entry(to)
)
report(file, `Reusable module imports standalone setup: ${to}`);
if (source(file) && renderer(to))
report(file, `Source imports rendering/application: ${to}`);
if (file === 'src/voice/gevActions.js' && to === 'src/data/manager.js')
report(
file,
'Actions must consume feed state without the manager facade',
);
if (
file === 'src/app/constructCatalog.js' &&
to === 'src/data/localGeojson.js'
)
report(
file,
'Catalog must use the services owner without compatibility layer construction',
);
}
return record;
}
for (const file of files) load(file);
// A helper outside src must not smuggle Node or server code into a browser graph.
const browserSeen = new Set();
function browser(file) {
if (browserSeen.has(file) || !code.test(file)) return;
browserSeen.add(file);
const info = load(file);
if (!info) return;
for (const specifier of info.external) {
if (builtins.has(specifier) || specifier.startsWith('node:'))
report(file, `Browser graph reaches Node builtin: ${specifier}`);
}
for (const to of info.edges) {
if (to.startsWith('server/') || tests(to))
report(file, `Browser graph reaches non-runtime owner: ${to}`);
else browser(to);
}
}
for (const file of files.filter((file) => file.startsWith('src/')))
browser(file);
const roots = new Map(files.filter(source).map((file) => [file, 'source']));
for (const [key, value] of Object.entries(pkg.exports || {})) {
if (portableExport(key) && typeof value === 'string')
roots.set(value.replace(/^\.\//, ''), key);
}
roots.set('src/data/feedState.js', 'feed state');
for (const [start, label] of roots) {
if (!existsSync(path.join(root, start))) {
report(start, `Missing portable entry: ${label}`);
continue;
}
const seen = new Set();
function visit(file) {
if (seen.has(file) || !code.test(file)) return;
seen.add(file);
const info = load(file);
if (!info) return;
if (info.browser.length)
report(
file,
`${label} reaches browser globals: ${info.browser.join(', ')}`,
);
for (const specifier of info.external) {
if (
builtins.has(specifier) ||
specifier.startsWith('node:') ||
/^(?:cesium|vite)(?:\/|$)/.test(specifier)
)
report(
file,
`${label} reaches platform/rendering dependency: ${specifier}`,
);
}
for (const to of info.edges) {
if (renderer(to) || to.startsWith('server/'))
report(file, `${label} reaches application/rendering: ${to}`);
else visit(to);
}
}
visit(start);
}
// Common voice controls may use their DOM view, but never a protocol implementation.
const voiceSeen = new Set();
function voice(file) {
if (voiceSeen.has(file)) return;
voiceSeen.add(file);
const info = load(file);
for (const to of info?.edges || []) {
if (/^src\/voice\/realtime/.test(to))
report(file, `Common voice controls import protocol: ${to}`);
else if (code.test(to)) voice(to);
}
}
if (existsSync(path.join(root, 'src/voice/sessionCommands.js')))
voice('src/voice/sessionCommands.js');
if (errors.size) throw new Error([...errors].join('\n'));
return { modules: files.length, portableEntries: roots.size };
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
try {
console.log(
JSON.stringify(
checkImportDirections(fileURLToPath(new URL('../', import.meta.url))),
),
);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+10 -1
View File
@@ -659,5 +659,14 @@
"src/voice/sessionCommands.js", "src/voice/sessionCommands.js",
"src/voice/realtimeSession.js", "src/voice/realtimeSession.js",
"src/voice/realtimeEvents.js", "src/voice/realtimeEvents.js",
"src/voice/session.test.mjs" "src/voice/session.test.mjs",
"src/layers/alpr/records.js",
"src/layers/earthquakes/records.js",
"src/sources/sourceSlot.js",
"src/data/feedState.js",
"src/app/localGeojsonServices.js",
"src/layers/cctv/sourcePolicy.js",
"scripts/module-analysis.mjs",
"scripts/check-import-directions.mjs",
"src/tooling/importDirections.test.mjs"
] ]
+80
View File
@@ -0,0 +1,80 @@
import { parsers } from 'prettier/plugins/babel.mjs';
const browserGlobals = new Set([
'window',
'document',
'navigator',
'HTMLElement',
'HTMLCanvasElement',
'Image',
'Audio',
'localStorage',
'sessionStorage',
'requestAnimationFrame',
'cancelAnimationFrame',
'getComputedStyle',
]);
/** Inspect literal module dependencies and browser-platform references without executing code. */
export function analyzeModule(source) {
const imports = [];
const browser = new Set();
function walk(node, parent, field) {
if (!node || typeof node !== 'object') return;
if (
[
'ImportDeclaration',
'ExportNamedDeclaration',
'ExportAllDeclaration',
'ImportExpression',
].includes(node.type) &&
node.source
) {
if (node.source.type !== 'StringLiteral')
throw new Error(
'Computed module imports are not allowed in runtime code',
);
imports.push(node.source.value);
}
if (node.type === 'CallExpression' && node.callee?.name === 'require')
throw new Error('Runtime modules must use ES imports');
if (node.type === 'Identifier' && browserGlobals.has(node.name)) {
const property =
(parent?.type === 'MemberExpression' ||
parent?.type === 'OptionalMemberExpression') &&
field === 'property' &&
!parent.computed;
const key =
['ObjectProperty', 'ObjectMethod', 'ClassMethod'].includes(
parent?.type,
) &&
field === 'key' &&
!parent.computed;
const declaration =
field === 'id' ||
field === 'params' ||
parent?.type?.startsWith('Import');
if (!property && !key && !declaration) browser.add(node.name);
}
if (
['MemberExpression', 'OptionalMemberExpression'].includes(node.type) &&
['globalThis', 'self'].includes(node.object?.name)
) {
const name = node.computed ? node.property?.value : node.property?.name;
if (browserGlobals.has(name)) browser.add(name);
}
for (const [key, value] of Object.entries(node)) {
if (['loc', 'comments', 'tokens', 'extra'].includes(key)) continue;
if (Array.isArray(value))
value.forEach((child) => walk(child, node, key));
else if (value && typeof value === 'object') walk(value, node, key);
}
}
walk(parsers.babel.parse(source));
return { imports, browser: [...browser] };
}
/** Return static imports, literal dynamic imports and re-exports. */
export function moduleImports(source) {
return analyzeModule(source).imports;
}
+87 -11
View File
@@ -268,7 +268,7 @@
"server/providers/common/source-root.js", "server/providers/common/source-root.js",
"server/standalone/key-setup.js", "server/standalone/key-setup.js",
"src/keySetupCore.mjs", "src/keySetupCore.mjs",
"src/keySetupHardening.mjs" "server/standalone/key-setup-hardening.mjs"
], ],
"external": [] "external": []
}, },
@@ -358,7 +358,8 @@
"src/ui/clearLayersControl.js", "src/ui/clearLayersControl.js",
"src/loadingFeedback.js", "src/loadingFeedback.js",
"src/data/installationFeedback.js", "src/data/installationFeedback.js",
"src/keySetupCore.mjs" "src/keySetupCore.mjs",
"src/data/feedState.js"
], ],
"external": [] "external": []
}, },
@@ -552,7 +553,8 @@
"src/ui/visualInput.js", "src/ui/visualInput.js",
"src/ui/visualPresets.js", "src/ui/visualPresets.js",
"src/ui/visualSettings.js", "src/ui/visualSettings.js",
"src/worldFocus.js" "src/worldFocus.js",
"src/data/feedState.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -735,7 +737,8 @@
"modules": [ "modules": [
"src/layers/earthquakes/index.js", "src/layers/earthquakes/index.js",
"src/layers/earthquakes/model.js", "src/layers/earthquakes/model.js",
"src/layers/earthquakes/source.js" "src/layers/earthquakes/source.js",
"src/layers/earthquakes/records.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -940,7 +943,8 @@
"src/layers/cctv/testing.js", "src/layers/cctv/testing.js",
"src/overlays/worldOverlayTokens.js", "src/overlays/worldOverlayTokens.js",
"src/data/cctvFootprint.js", "src/data/cctvFootprint.js",
"src/data/inputOwnership.js" "src/data/inputOwnership.js",
"src/layers/cctv/sourcePolicy.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -954,7 +958,8 @@
"src/layers/alpr/visuals.js", "src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js", "src/layers/alpr/overlay.js",
"src/layers/alpr/source.js", "src/layers/alpr/source.js",
"src/data/inputOwnership.js" "src/data/inputOwnership.js",
"src/layers/alpr/records.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1126,7 +1131,7 @@
"src/app/layers/traffic.js", "src/app/layers/traffic.js",
"src/app/operations.js", "src/app/operations.js",
"src/app/scene.js", "src/app/scene.js",
"src/app/sourceSlot.js", "src/sources/sourceSlot.js",
"src/app/sources.js", "src/app/sources.js",
"src/app/startupChrome.js", "src/app/startupChrome.js",
"src/app/stateChannel.js", "src/app/stateChannel.js",
@@ -1608,7 +1613,12 @@
"src/voice/session.js", "src/voice/session.js",
"src/voice/sessionCommands.js", "src/voice/sessionCommands.js",
"src/voice/realtimeSession.js", "src/voice/realtimeSession.js",
"src/voice/realtimeEvents.js" "src/voice/realtimeEvents.js",
"src/app/localGeojsonServices.js",
"src/data/feedState.js",
"src/layers/alpr/records.js",
"src/layers/earthquakes/records.js",
"src/layers/cctv/sourcePolicy.js"
], ],
"external": [ "external": [
"@mapbox/vector-tile", "@mapbox/vector-tile",
@@ -1647,7 +1657,7 @@
"src/app/layers/satellites.js", "src/app/layers/satellites.js",
"src/app/layers/submarineCables.js", "src/app/layers/submarineCables.js",
"src/app/layers/traffic.js", "src/app/layers/traffic.js",
"src/app/sourceSlot.js", "src/sources/sourceSlot.js",
"src/cctvFocusRequest.js", "src/cctvFocusRequest.js",
"src/celestialRing.js", "src/celestialRing.js",
"src/cockpitMath.js", "src/cockpitMath.js",
@@ -1956,7 +1966,11 @@
"src/app/layers/directions.js", "src/app/layers/directions.js",
"src/search/coordinateGeocoder.js", "src/search/coordinateGeocoder.js",
"src/search/coordinateParser.js", "src/search/coordinateParser.js",
"src/search/presetGeocoder.js" "src/search/presetGeocoder.js",
"src/app/localGeojsonServices.js",
"src/layers/alpr/records.js",
"src/layers/earthquakes/records.js",
"src/layers/cctv/sourcePolicy.js"
], ],
"external": [ "external": [
"cesium", "cesium",
@@ -1973,7 +1987,7 @@
"src/annotations/annotationResolver.js", "src/annotations/annotationResolver.js",
"src/annotations/resolver.js", "src/annotations/resolver.js",
"src/app/operations.js", "src/app/operations.js",
"src/app/sourceSlot.js", "src/sources/sourceSlot.js",
"src/app/surfaceServices.js", "src/app/surfaceServices.js",
"src/data/dataCredits.js", "src/data/dataCredits.js",
"src/data/geoid.js", "src/data/geoid.js",
@@ -2166,5 +2180,67 @@
"src/voice/control.js" "src/voice/control.js"
], ],
"external": [] "external": []
},
"alpr-source": {
"exports": ["./layers/alpr/source"],
"modules": [
"src/layers/alpr/policy.js",
"src/layers/alpr/records.js",
"src/layers/alpr/source.js"
],
"external": []
},
"bikeshare-source": {
"exports": ["./layers/bikeshare/source"],
"modules": ["src/layers/bikeshare/source.js"],
"external": []
},
"cctv-source": {
"exports": ["./layers/cctv/source"],
"modules": ["src/layers/cctv/source.js", "src/layers/cctv/sourcePolicy.js"],
"external": []
},
"earthquakes-source": {
"exports": ["./layers/earthquakes/source"],
"modules": [
"src/layers/earthquakes/records.js",
"src/layers/earthquakes/source.js"
],
"external": []
},
"firms-source": {
"exports": ["./layers/firms/source"],
"modules": ["src/layers/firms/source.js"],
"external": []
},
"installations-source": {
"exports": ["./layers/installations/source"],
"modules": ["src/layers/installations/source.js"],
"external": []
},
"launches-source": {
"exports": ["./layers/launches/source"],
"modules": ["src/layers/launches/source.js"],
"external": []
},
"radio-source": {
"exports": ["./layers/radio/source"],
"modules": ["src/layers/radio/policy.js", "src/layers/radio/source.js"],
"external": []
},
"satellites-source": {
"exports": ["./layers/satellites/source"],
"modules": ["src/layers/satellites/source.js"],
"external": []
},
"traffic-source-factory": {
"exports": ["./layers/traffic/source"],
"modules": [
"src/data/tomtomTiles.js",
"src/layers/traffic/flowDecode.js",
"src/layers/traffic/flowSource.js",
"src/layers/traffic/source.js"
],
"external": ["@mapbox/vector-tile", "pbf"]
} }
} }
@@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process';
import { import {
commandCompletedSuccessfully, commandCompletedSuccessfully,
parseWindowsUserSid, parseWindowsUserSid,
} from './keySetupCore.mjs'; } from '../../src/keySetupCore.mjs';
/** PowerShell verification for the exact owner-only Windows credential DACL. */ /** PowerShell verification for the exact owner-only Windows credential DACL. */
const WINDOWS_ACL_VERIFY_SCRIPT = [ const WINDOWS_ACL_VERIFY_SCRIPT = [
+1 -1
View File
@@ -12,7 +12,7 @@ import { readEnvironmentSource as readPinokioEnvironmentSource } from '../../scr
import fs from 'node:fs'; import fs from 'node:fs';
import { parseEnv as parseDotenvText } from 'node:util'; import { parseEnv as parseDotenvText } from 'node:util';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { hardenCredentialFile } from '../../src/keySetupHardening.mjs'; import { hardenCredentialFile } from './key-setup-hardening.mjs';
/** /**
* Which launcher started this process, captured at MODULE LOAD — before the * Which launcher started this process, captured at MODULE LOAD — before the
+1 -1
View File
@@ -18,7 +18,7 @@ import { createApplicationFirms } from './layers/firms.js';
import { createApplicationEarthquakes } from './layers/earthquakes.js'; import { createApplicationEarthquakes } from './layers/earthquakes.js';
import { createApplicationCables } from './layers/submarineCables.js'; import { createApplicationCables } from './layers/submarineCables.js';
import { createInfrastructureLayers } from '../data/infrastructure.js'; import { createInfrastructureLayers } from '../data/infrastructure.js';
import { localGeoJsonServices } from '../data/localGeojson.js'; import { localGeoJsonServices } from './localGeojsonServices.js';
const SOURCE_METHODS = Object.freeze({ const SOURCE_METHODS = Object.freeze({
flights: ['getSnapshot'], flights: ['getSnapshot'],
+25
View File
@@ -0,0 +1,25 @@
import { governorRequestRender } from '../renderGovernor.js';
import {
clearSelectedEntityContextForLayer,
registerEntityContext,
removeEntityContextsForLayer,
selectEntityContext,
} from '../data/contextStore.js';
import {
clearOverlaySource,
setOverlayEntries,
setOverlaySourceVisible,
} from '../overlays/worldOverlay.js';
/** Existing standalone application operations, shared by its local layers. */
export const localGeoJsonServices = Object.freeze({
overlayHost: Object.freeze({
clearSource: clearOverlaySource,
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
}),
clearSelectedEntityContextForLayer,
registerEntityContext,
removeEntityContextsForLayer,
selectEntityContext,
governorRequestRender,
});
+2 -52
View File
@@ -1,52 +1,2 @@
/** Bind a source contract once for the application without replacing its layer instance. */ // Compatibility entry; source contracts are owned by sources/.
export function createSourceSlot( export { createSourceSlot } from '../sources/sourceSlot.js';
initial,
methods,
label = 'Source',
optional = {},
) {
let active = initial;
let binding = null;
const source = Object.fromEntries(
[...methods, ...Object.keys(optional)].map((method) => [
method,
(...args) => {
if (typeof active?.[method] !== 'function' && optional[method])
return optional[method](...args);
if (typeof active?.[method] !== 'function')
throw new Error(`${label} is not configured`);
const owner = binding;
const provider = active;
const value = provider[method](...args);
if (value && typeof value.then === 'function')
return value.then((result) => {
if (binding !== owner || active !== provider)
throw new DOMException(`${label} was replaced`, 'AbortError');
return result;
});
return value;
},
]),
);
for (const property of ['label', 'attribution']) {
Object.defineProperty(source, property, {
enumerable: true,
get: () => active?.[property],
});
}
return {
source,
configure(next) {
if (methods.some((method) => typeof next?.[method] !== 'function'))
throw new TypeError(`Invalid ${label.toLowerCase()}`);
const owner = Symbol(label);
active = next;
binding = owner;
return () => {
if (binding !== owner) return;
active = null;
binding = null;
};
},
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { defaultSurface } from './surfaceServices.js'; import { defaultSurface } from './surfaceServices.js';
import { createApplicationAlpr } from '../app/layers/alprCameras.js'; import { createApplicationAlpr } from '../app/layers/alprCameras.js';
import { createSourceSlot } from '../app/sourceSlot.js'; import { createSourceSlot } from '../sources/sourceSlot.js';
import { createOverpassAlprSource } from '../layers/alpr/index.js'; import { createOverpassAlprSource } from '../layers/alpr/index.js';
export * from '../layers/alpr/index.js'; export * from '../layers/alpr/index.js';
const slot = createSourceSlot( const slot = createSourceSlot(
+1 -1
View File
@@ -1,5 +1,5 @@
import { createApplicationBikeshare } from '../app/layers/bikeshare.js'; import { createApplicationBikeshare } from '../app/layers/bikeshare.js';
import { createSourceSlot } from '../app/sourceSlot.js'; import { createSourceSlot } from '../sources/sourceSlot.js';
import { createBikeshareSource } from '../layers/bikeshare/source.js'; import { createBikeshareSource } from '../layers/bikeshare/source.js';
const sourceSlot = createSourceSlot( const sourceSlot = createSourceSlot(
+1 -1
View File
@@ -1,6 +1,6 @@
import { defaultSurface } from './surfaceServices.js'; import { defaultSurface } from './surfaceServices.js';
import { createApplicationCctv } from '../app/layers/cctv.js'; import { createApplicationCctv } from '../app/layers/cctv.js';
import { createSourceSlot } from '../app/sourceSlot.js'; import { createSourceSlot } from '../sources/sourceSlot.js';
import { createCctvSource } from '../layers/cctv/source.js'; import { createCctvSource } from '../layers/cctv/source.js';
const sourceSlot = createSourceSlot( const sourceSlot = createSourceSlot(
+54
View File
@@ -0,0 +1,54 @@
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
/**
* Normalize heterogeneous layer stats into one honest control-chip state.
* @param {object|null} stats Layer getStats() result.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state.
*/
export function layerFeedState(stats = {}) {
const state = stats || {};
const status =
typeof state.status === 'string' ? state.status.toLowerCase() : '';
const source = `${state.source || ''} ${state.coverage || ''}`;
const hasExplicitFallback = typeof state.fallback === 'boolean';
const hasPriorData = Number(state.count) > 0 || Boolean(state.lastUpdate);
const presentedError =
state.error || state.lastError || state.managerRefreshError;
if (['unavailable', 'offline', 'down', 'error'].includes(status))
return 'unavailable';
if (
(presentedError ||
state.unavailable === true ||
state.available === false) &&
!hasPriorData &&
!GUIDANCE_STATUSES.includes(status)
) {
return 'unavailable';
}
if (state.loading) return 'loading';
// Guidance states ask the user to act (zoom in, run a search) — normal
// operation, not feed faults. One honesty carve-out: layers keep their
// rendered records through the guidance state, so a genuinely stale cache
// still reads STALE; a guidance prompt alone never reads DEGRADED.
if (GUIDANCE_STATUSES.includes(status)) {
return state.stale ? 'stale' : 'nominal';
}
if (
state.fallback === true ||
status === 'fallback' ||
state.mode === 'sim' ||
/\bfallback\b/i.test(source) ||
(!hasExplicitFallback && /\badsb\.lol\b/i.test(source))
) {
return 'fallback';
}
if (state.stale || status === 'stale') return 'stale';
if (
state.degraded ||
presentedError ||
state.unavailable === true ||
state.available === false
)
return 'degraded';
return 'nominal';
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { defaultSurface } from './surfaceServices.js'; import { defaultSurface } from './surfaceServices.js';
import { createApplicationFirms, firmsServices } from '../app/layers/firms.js'; import { createApplicationFirms, firmsServices } from '../app/layers/firms.js';
import { createSourceSlot } from '../app/sourceSlot.js'; import { createSourceSlot } from '../sources/sourceSlot.js';
import { import {
createFirmsHelpers, createFirmsHelpers,
createFirmsSource, createFirmsSource,
+2 -26
View File
@@ -1,15 +1,5 @@
import { governorRequestRender } from '../renderGovernor.js'; import { localGeoJsonServices } from '../app/localGeojsonServices.js';
import { export { localGeoJsonServices } from '../app/localGeojsonServices.js';
clearSelectedEntityContextForLayer,
registerEntityContext,
removeEntityContextsForLayer,
selectEntityContext,
} from './contextStore.js';
import {
clearOverlaySource,
setOverlayEntries,
setOverlaySourceVisible,
} from '../overlays/worldOverlay.js';
import { import {
createLocalGeoJsonLayer as createLayer, createLocalGeoJsonLayer as createLayer,
createLocalInfrastructureOverlayPublisher as createPublisher, createLocalInfrastructureOverlayPublisher as createPublisher,
@@ -17,20 +7,6 @@ import {
export * from './localGeojsonCore.js'; export * from './localGeojsonCore.js';
/** Existing standalone application operations, shared by its local layers. */
export const localGeoJsonServices = Object.freeze({
overlayHost: Object.freeze({
clearSource: clearOverlaySource,
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
}),
clearSelectedEntityContextForLayer,
registerEntityContext,
removeEntityContextsForLayer,
selectEntityContext,
governorRequestRender,
});
/** Create a layer using the standalone app's operations; retain overlay overrides. */ /** Create a layer using the standalone app's operations; retain overlay overrides. */
export function createLocalGeoJsonLayer(options) { export function createLocalGeoJsonLayer(options) {
return createLayer(options, { return createLayer(options, {
+1 -1
View File
@@ -1,6 +1,6 @@
import { defaultSurface } from './surfaceServices.js'; import { defaultSurface } from './surfaceServices.js';
import { createApplicationInstallations } from '../app/layers/militaryInstallations.js'; import { createApplicationInstallations } from '../app/layers/militaryInstallations.js';
import { createSourceSlot } from '../app/sourceSlot.js'; import { createSourceSlot } from '../sources/sourceSlot.js';
import { createInstallationSource } from '../layers/installations/index.js'; import { createInstallationSource } from '../layers/installations/index.js';
const sourceSlot = createSourceSlot( const sourceSlot = createSourceSlot(
+1 -1
View File
@@ -1,6 +1,6 @@
import { defaultSurface } from './surfaceServices.js'; import { defaultSurface } from './surfaceServices.js';
import { createApplicationRadio } from '../app/layers/radio.js'; import { createApplicationRadio } from '../app/layers/radio.js';
import { createSourceSlot } from '../app/sourceSlot.js'; import { createSourceSlot } from '../sources/sourceSlot.js';
import { createRadioSource } from '../layers/radio/source.js'; import { createRadioSource } from '../layers/radio/source.js';
const sourceSlot = createSourceSlot( const sourceSlot = createSourceSlot(
+1 -1
View File
@@ -1,5 +1,5 @@
import { createApplicationLaunches } from '../app/layers/rocketLaunches.js'; import { createApplicationLaunches } from '../app/layers/rocketLaunches.js';
import { createSourceSlot } from '../app/sourceSlot.js'; import { createSourceSlot } from '../sources/sourceSlot.js';
import { createLaunchSource } from '../layers/launches/index.js'; import { createLaunchSource } from '../layers/launches/index.js';
import * as satellites from './satellites.js'; import * as satellites from './satellites.js';
+1 -1
View File
@@ -1,5 +1,5 @@
import { createApplicationSatellites } from '../app/layers/satellites.js'; import { createApplicationSatellites } from '../app/layers/satellites.js';
import { createSourceSlot } from '../app/sourceSlot.js'; import { createSourceSlot } from '../sources/sourceSlot.js';
import { createSatelliteSource } from '../layers/satellites/index.js'; import { createSatelliteSource } from '../layers/satellites/index.js';
const sourceSlot = createSourceSlot( const sourceSlot = createSourceSlot(
+1 -1
View File
@@ -1,5 +1,5 @@
import { createApplicationTraffic } from '../app/layers/traffic.js'; import { createApplicationTraffic } from '../app/layers/traffic.js';
import { createSourceSlot } from '../app/sourceSlot.js'; import { createSourceSlot } from '../sources/sourceSlot.js';
import { createTrafficSource } from '../layers/traffic/source.js'; import { createTrafficSource } from '../layers/traffic/source.js';
const sourceSlot = createSourceSlot( const sourceSlot = createSourceSlot(
+1 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { hardenCredentialFile } from './keySetupHardening.mjs'; import { hardenCredentialFile } from '../server/standalone/key-setup-hardening.mjs';
const FILE = path.join(os.tmpdir(), 'provider-settings-test'); const FILE = path.join(os.tmpdir(), 'provider-settings-test');
const USER_SID = 'S-1-5-21-1111111111-2222222222-3333333333-1001'; const USER_SID = 'S-1-5-21-1111111111-2222222222-3333333333-1001';
+2 -159
View File
@@ -1,52 +1,6 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { import { EARTH_MEAN_RADIUS_M } from './policy.js';
QUERY_LIMIT, export * from './records.js';
EARTH_MEAN_RADIUS_M,
QUERY_SNAP_DEGREES,
} from './policy.js';
export function textTag(value) {
const t = String(value ?? '').trim();
return t || null;
}
export function numTag(value) {
// `Number('')` and `Number(' ')` are 0, not NaN — a blank or whitespace-only
// tag value must read as missing, never as a due-north bearing.
if (value == null) return null;
const trimmed = String(value).trim();
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
/**
* Snap a viewport box OUTWARD to the query grid, clamped to valid ranges.
* Pure so the reuse contract is pinnable.
* @param {{south:number,west:number,north:number,east:number}} box
* @param {number} [step=QUERY_SNAP_DEGREES]
*/
export function snapAlprBox(box, step = QUERY_SNAP_DEGREES) {
const down = (v) => Math.floor(v / step) * step;
const up = (v) => Math.ceil(v / step) * step;
return {
south: Math.max(-90, down(box.south)),
west: Math.max(-180, down(box.west)),
north: Math.min(90, up(box.north)),
east: Math.min(180, up(box.east)),
};
}
/** Whether `inner` lies entirely within `outer` (no dateline handling; the viewport gate already rejects east <= west). */
export function boxContains(outer, inner) {
if (!outer || !inner) return false;
return (
inner.south >= outer.south &&
inner.north <= outer.north &&
inner.west >= outer.west &&
inner.east <= outer.east
);
}
/** /**
* Great-circle destination point — used only to draw the short * Great-circle destination point — used only to draw the short
@@ -77,117 +31,6 @@ export function destinationPointDeg(latDeg, lonDeg, bearingDeg, distanceM) {
}; };
} }
/**
* Backoff progression for the unavailable-state retry: 30 s, doubling to a
* 240 s ceiling. Pure so the progression is pinnable without booting the layer.
*/
export function alprRetryDelayMs(prevDelayMs) {
const RETRY_MIN_MS = 30000;
const RETRY_CEIL_MS = 240000;
if (!Number.isFinite(prevDelayMs) || prevDelayMs <= 0) return RETRY_MIN_MS;
return Math.min(prevDelayMs * 2, RETRY_CEIL_MS);
}
/**
* Whether a `surveillance:type` value denotes an ALPR camera. OSM allows
* semicolon multi-values (`camera;ALPR`) and mixed case, so an exact match
* would silently miss tagged cameras.
* @param {*} value - raw `surveillance:type` tag value
* @returns {boolean}
*/
export function isAlprSurveillanceType(value) {
return String(value ?? '')
.split(';')
.some((part) => part.trim().toUpperCase() === 'ALPR');
}
/** Map one raw Overpass node element to a plain camera record. Null for anything unusable. */
export function normalizeAlprNode(el) {
if (
!el ||
el.type !== 'node' ||
!Number.isSafeInteger(el.id) ||
el.id <= 0 ||
!Number.isFinite(el.lat) ||
Math.abs(el.lat) > 90 ||
!Number.isFinite(el.lon) ||
Math.abs(el.lon) > 180
)
return null;
const tags = el.tags || {};
if (!isAlprSurveillanceType(tags['surveillance:type'])) return null;
return {
id: `alpr:${el.id}`,
osmId: el.id,
latitude: el.lat,
longitude: el.lon,
operator: textTag(tags.operator),
manufacturer: textTag(tags.manufacturer),
cameraType: textTag(tags['camera:type']),
zone: textTag(tags['surveillance:zone']),
// Only numeric bearings are parsed; compass-word directions ("N"/"NE")
// are rare on this tag and simply render with no facing line.
directionDeg: normalizeDirection(
tags['camera:direction'] ?? tags.direction,
),
ref: textTag(tags.ref),
lastVerified: textTag(tags.check_date) || textTag(tags['survey:date']),
source: textTag(tags.source),
};
}
export function normalizeDirection(value) {
const degrees = numTag(value);
return degrees != null && degrees >= 0 && degrees <= 360
? degrees % 360
: null;
}
/**
* Overpass QL for the viewport. The tag match is a case-insensitive regex, not
* an exact `="ALPR"`, because OSM semicolon multi-values such as
* `surveillance:type=camera;ALPR` are common and an exact match drops them.
* Exported so the query shape is pinnable.
*/
export function buildOverpassQuery(south, west, north, east) {
return (
`[out:json][timeout:20];node["man_made"="surveillance"]["surveillance:type"~"(^|;)\\s*ALPR\\s*(;|$)",i]` +
`(${south},${west},${north},${east});out body ${QUERY_LIMIT};`
);
}
/** Validate a source-independent snapshot before replacing the accepted display. */
export function validateAlprSnapshot(snapshot) {
if (
!Array.isArray(snapshot?.records) ||
typeof snapshot.stale !== 'boolean' ||
typeof snapshot.saturated !== 'boolean'
) {
throw new TypeError('Camera source returned an invalid snapshot');
}
const ids = new Set();
for (const record of snapshot.records) {
if (
!record ||
typeof record.id !== 'string' ||
!record.id.trim() ||
ids.has(record.id) ||
!Number.isFinite(record.latitude) ||
Math.abs(record.latitude) > 90 ||
!Number.isFinite(record.longitude) ||
Math.abs(record.longitude) > 180 ||
(record.directionDeg != null &&
(!Number.isFinite(record.directionDeg) ||
record.directionDeg < 0 ||
record.directionDeg >= 360))
) {
throw new TypeError('Camera source returned an invalid record');
}
ids.add(record.id);
}
return snapshot;
}
/** Build a linked attribution from plain text and an HTTPS URL only. */ /** Build a linked attribution from plain text and an HTTPS URL only. */
export function alprCreditMarkup(attribution) { export function alprCreditMarkup(attribution) {
if (!attribution?.text || !attribution?.href) return null; if (!attribution?.text || !attribution?.href) return null;
+155
View File
@@ -0,0 +1,155 @@
import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js';
export function textTag(value) {
const t = String(value ?? '').trim();
return t || null;
}
export function numTag(value) {
// `Number('')` and `Number(' ')` are 0, not NaN — a blank or whitespace-only
// tag value must read as missing, never as a due-north bearing.
if (value == null) return null;
const trimmed = String(value).trim();
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
/**
* Snap a viewport box OUTWARD to the query grid, clamped to valid ranges.
* Pure so the reuse contract is pinnable.
* @param {{south:number,west:number,north:number,east:number}} box
* @param {number} [step=QUERY_SNAP_DEGREES]
*/
export function snapAlprBox(box, step = QUERY_SNAP_DEGREES) {
const down = (v) => Math.floor(v / step) * step;
const up = (v) => Math.ceil(v / step) * step;
return {
south: Math.max(-90, down(box.south)),
west: Math.max(-180, down(box.west)),
north: Math.min(90, up(box.north)),
east: Math.min(180, up(box.east)),
};
}
/** Whether `inner` lies entirely within `outer` (no dateline handling; the viewport gate already rejects east <= west). */
export function boxContains(outer, inner) {
if (!outer || !inner) return false;
return (
inner.south >= outer.south &&
inner.north <= outer.north &&
inner.west >= outer.west &&
inner.east <= outer.east
);
}
/**
* Backoff progression for the unavailable-state retry: 30 s, doubling to a
* 240 s ceiling. Pure so the progression is pinnable without booting the layer.
*/
export function alprRetryDelayMs(prevDelayMs) {
const RETRY_MIN_MS = 30000;
const RETRY_CEIL_MS = 240000;
if (!Number.isFinite(prevDelayMs) || prevDelayMs <= 0) return RETRY_MIN_MS;
return Math.min(prevDelayMs * 2, RETRY_CEIL_MS);
}
/**
* Whether a `surveillance:type` value denotes an ALPR camera. OSM allows
* semicolon multi-values (`camera;ALPR`) and mixed case, so an exact match
* would silently miss tagged cameras.
* @param {*} value - raw `surveillance:type` tag value
* @returns {boolean}
*/
export function isAlprSurveillanceType(value) {
return String(value ?? '')
.split(';')
.some((part) => part.trim().toUpperCase() === 'ALPR');
}
/** Map one raw Overpass node element to a plain camera record. Null for anything unusable. */
export function normalizeAlprNode(el) {
if (
!el ||
el.type !== 'node' ||
!Number.isSafeInteger(el.id) ||
el.id <= 0 ||
!Number.isFinite(el.lat) ||
Math.abs(el.lat) > 90 ||
!Number.isFinite(el.lon) ||
Math.abs(el.lon) > 180
)
return null;
const tags = el.tags || {};
if (!isAlprSurveillanceType(tags['surveillance:type'])) return null;
return {
id: `alpr:${el.id}`,
osmId: el.id,
latitude: el.lat,
longitude: el.lon,
operator: textTag(tags.operator),
manufacturer: textTag(tags.manufacturer),
cameraType: textTag(tags['camera:type']),
zone: textTag(tags['surveillance:zone']),
// Only numeric bearings are parsed; compass-word directions ("N"/"NE")
// are rare on this tag and simply render with no facing line.
directionDeg: normalizeDirection(
tags['camera:direction'] ?? tags.direction,
),
ref: textTag(tags.ref),
lastVerified: textTag(tags.check_date) || textTag(tags['survey:date']),
source: textTag(tags.source),
};
}
export function normalizeDirection(value) {
const degrees = numTag(value);
return degrees != null && degrees >= 0 && degrees <= 360
? degrees % 360
: null;
}
/**
* Overpass QL for the viewport. The tag match is a case-insensitive regex, not
* an exact `="ALPR"`, because OSM semicolon multi-values such as
* `surveillance:type=camera;ALPR` are common and an exact match drops them.
* Exported so the query shape is pinnable.
*/
export function buildOverpassQuery(south, west, north, east) {
return (
`[out:json][timeout:20];node["man_made"="surveillance"]["surveillance:type"~"(^|;)\\s*ALPR\\s*(;|$)",i]` +
`(${south},${west},${north},${east});out body ${QUERY_LIMIT};`
);
}
/** Validate a source-independent snapshot before replacing the accepted display. */
export function validateAlprSnapshot(snapshot) {
if (
!Array.isArray(snapshot?.records) ||
typeof snapshot.stale !== 'boolean' ||
typeof snapshot.saturated !== 'boolean'
) {
throw new TypeError('Camera source returned an invalid snapshot');
}
const ids = new Set();
for (const record of snapshot.records) {
if (
!record ||
typeof record.id !== 'string' ||
!record.id.trim() ||
ids.has(record.id) ||
!Number.isFinite(record.latitude) ||
Math.abs(record.latitude) > 90 ||
!Number.isFinite(record.longitude) ||
Math.abs(record.longitude) > 180 ||
(record.directionDeg != null &&
(!Number.isFinite(record.directionDeg) ||
record.directionDeg < 0 ||
record.directionDeg >= 360))
) {
throw new TypeError('Camera source returned an invalid record');
}
ids.add(record.id);
}
return snapshot;
}
+3 -1
View File
@@ -4,7 +4,7 @@ import {
QUERY_SNAP_DEGREES, QUERY_SNAP_DEGREES,
QUERY_LIMIT, QUERY_LIMIT,
} from './policy.js'; } from './policy.js';
import { buildOverpassQuery, normalizeAlprNode } from './model.js'; import { buildOverpassQuery, normalizeAlprNode } from './records.js';
/** Construct the bounded OSM request adapter without starting a request. */ /** Construct the bounded OSM request adapter without starting a request. */
export function createOverpassAlprSource({ export function createOverpassAlprSource({
fetchImpl = (...args) => globalThis.fetch(...args), fetchImpl = (...args) => globalThis.fetch(...args),
@@ -80,3 +80,5 @@ export function createOverpassAlprSource({
}, },
}; };
} }
export { buildOverpassQuery } from './records.js';
+7 -10
View File
@@ -1,3 +1,10 @@
export {
FRAME_ENDPOINT,
SOURCE_ENDPOINT,
HEALTH_ENDPOINT,
MEDIA_ENDPOINT,
ACTIVE_FRAME_REFRESH_MS,
} from './sourcePolicy.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js'; import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js';
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
@@ -6,14 +13,6 @@ import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
// API endpoints // API endpoints
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const FRAME_ENDPOINT = '/api/cctv/frame';
export const SOURCE_ENDPOINT = '/api/cctv/sources';
export const HEALTH_ENDPOINT = '/api/cctv/health';
export const MEDIA_ENDPOINT = '/api/cctv/media';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Timing and geometry constants // Timing and geometry constants
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -26,8 +25,6 @@ export const MAX_AUTO_HOP_SEC = 90;
export const HEALTH_SYNC_INTERVAL_MS = 7000; export const HEALTH_SYNC_INTERVAL_MS = 7000;
export const ACTIVE_FRAME_REFRESH_MS = 10000;
export const IDLE_FRAME_REFRESH_MS = 60000; export const IDLE_FRAME_REFRESH_MS = 60000;
export const PROJECTION_ACTIVE_REFRESH_MS = 10000; export const PROJECTION_ACTIVE_REFRESH_MS = 10000;
+1 -1
View File
@@ -2,7 +2,7 @@ import {
ACTIVE_FRAME_REFRESH_MS, ACTIVE_FRAME_REFRESH_MS,
FRAME_ENDPOINT, FRAME_ENDPOINT,
MEDIA_ENDPOINT, MEDIA_ENDPOINT,
} from './policy.js'; } from './sourcePolicy.js';
function safeNumber(value, fallback = NaN) { function safeNumber(value, fallback = NaN) {
const n = Number(value); const n = Number(value);
return Number.isFinite(n) ? n : fallback; return Number.isFinite(n) ? n : fallback;
+9
View File
@@ -0,0 +1,9 @@
export const FRAME_ENDPOINT = '/api/cctv/frame';
export const SOURCE_ENDPOINT = '/api/cctv/sources';
export const HEALTH_ENDPOINT = '/api/cctv/health';
export const MEDIA_ENDPOINT = '/api/cctv/media';
export const ACTIVE_FRAME_REFRESH_MS = 10000;
+1 -49
View File
@@ -99,52 +99,4 @@ export function mapAnalystRecord(raw, index = 0) {
}; };
} }
/** Validate a complete feed before replacing the last good earthquake snapshot. */ export { normalizeEarthquakeSnapshot } from './records.js';
export function normalizeEarthquakeSnapshot(geojson) {
if (!Array.isArray(geojson?.features)) return null;
const rows = [];
const ids = new Set();
for (const [index, feature] of geojson.features.entries()) {
const coordinates = feature?.geometry?.coordinates;
const properties = feature?.properties;
if (
!Array.isArray(coordinates) ||
coordinates.length < 2 ||
!properties ||
typeof properties !== 'object' ||
Array.isArray(properties) ||
(feature.geometry.type != null && feature.geometry.type !== 'Point')
)
return null;
const [lon, lat, depthKm] = coordinates;
const mag = properties.mag;
if (
!Number.isFinite(lon) ||
Math.abs(lon) > 180 ||
!Number.isFinite(lat) ||
Math.abs(lat) > 90 ||
(depthKm != null && !Number.isFinite(depthKm)) ||
(mag != null && (!Number.isFinite(mag) || mag > 10))
)
return null;
// A missing magnitude cannot establish that this event meets M2.5+.
if (mag == null || mag < 2.5) continue;
const stableId =
feature.id == null || feature.id === ''
? `event-${index + 1}`
: String(feature.id);
if (ids.has(stableId)) return null;
ids.add(stableId);
rows.push({
stableId,
usgsId: feature.id ?? null,
lon,
lat,
depthKm: depthKm ?? null,
mag,
place: typeof properties.place === 'string' ? properties.place : null,
time: Number.isFinite(properties.time) ? properties.time : null,
});
}
return rows;
}
+49
View File
@@ -0,0 +1,49 @@
/** Validate a complete feed before replacing the last good earthquake snapshot. */
export function normalizeEarthquakeSnapshot(geojson) {
if (!Array.isArray(geojson?.features)) return null;
const rows = [];
const ids = new Set();
for (const [index, feature] of geojson.features.entries()) {
const coordinates = feature?.geometry?.coordinates;
const properties = feature?.properties;
if (
!Array.isArray(coordinates) ||
coordinates.length < 2 ||
!properties ||
typeof properties !== 'object' ||
Array.isArray(properties) ||
(feature.geometry.type != null && feature.geometry.type !== 'Point')
)
return null;
const [lon, lat, depthKm] = coordinates;
const mag = properties.mag;
if (
!Number.isFinite(lon) ||
Math.abs(lon) > 180 ||
!Number.isFinite(lat) ||
Math.abs(lat) > 90 ||
(depthKm != null && !Number.isFinite(depthKm)) ||
(mag != null && (!Number.isFinite(mag) || mag > 10))
)
return null;
// A missing magnitude cannot establish that this event meets M2.5+.
if (mag == null || mag < 2.5) continue;
const stableId =
feature.id == null || feature.id === ''
? `event-${index + 1}`
: String(feature.id);
if (ids.has(stableId)) return null;
ids.add(stableId);
rows.push({
stableId,
usgsId: feature.id ?? null,
lon,
lat,
depthKm: depthKm ?? null,
mag,
place: typeof properties.place === 'string' ? properties.place : null,
time: Number.isFinite(properties.time) ? properties.time : null,
});
}
return rows;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { normalizeEarthquakeSnapshot } from './model.js'; import { normalizeEarthquakeSnapshot } from './records.js';
const API_URL = const API_URL =
'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson'; 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson';
/** Request and validate a complete USGS snapshot before it can replace displayed events. */ /** Request and validate a complete USGS snapshot before it can replace displayed events. */
@@ -9,7 +9,7 @@ import {
import { import {
LOCAL_OVERLAY_COHORT_LIMIT, LOCAL_OVERLAY_COHORT_LIMIT,
createLocalInfrastructureOverlayEntry, createLocalInfrastructureOverlayEntry,
} from '../data/localGeojson.js'; } from '../data/localGeojsonCore.js';
import { import {
FIRMS_AMBIENT_COHORT_LIMIT, FIRMS_AMBIENT_COHORT_LIMIT,
FIRMS_OVERLAY_SOURCE_ID, FIRMS_OVERLAY_SOURCE_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js'; import { createSourceSlot } from '../sources/sourceSlot.js';
import { createApplicationRequestServices } from './requests.js'; import { createApplicationRequestServices } from './requests.js';
// Compatibility owners are page-scoped, like the viewer and its layer registry. // Compatibility owners are page-scoped, like the viewer and its layer registry.
+52
View File
@@ -0,0 +1,52 @@
/** Bind a source contract once for the application without replacing its layer instance. */
export function createSourceSlot(
initial,
methods,
label = 'Source',
optional = {},
) {
let active = initial;
let binding = null;
const source = Object.fromEntries(
[...methods, ...Object.keys(optional)].map((method) => [
method,
(...args) => {
if (typeof active?.[method] !== 'function' && optional[method])
return optional[method](...args);
if (typeof active?.[method] !== 'function')
throw new Error(`${label} is not configured`);
const owner = binding;
const provider = active;
const value = provider[method](...args);
if (value && typeof value.then === 'function')
return value.then((result) => {
if (binding !== owner || active !== provider)
throw new DOMException(`${label} was replaced`, 'AbortError');
return result;
});
return value;
},
]),
);
for (const property of ['label', 'attribution']) {
Object.defineProperty(source, property, {
enumerable: true,
get: () => active?.[property],
});
}
return {
source,
configure(next) {
if (methods.some((method) => typeof next?.[method] !== 'function'))
throw new TypeError(`Invalid ${label.toLowerCase()}`);
const owner = Symbol(label);
active = next;
binding = owner;
return () => {
if (binding !== owner) return;
active = null;
binding = null;
};
},
};
}
+179
View File
@@ -0,0 +1,179 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { execFileSync } from 'node:child_process';
import {
mkdtempSync,
mkdirSync,
writeFileSync,
rmSync,
symlinkSync,
} from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { analyzeModule } from '../../scripts/module-analysis.mjs';
import { checkImportDirections } from '../../scripts/check-import-directions.mjs';
function fixture(t, exports = {}) {
const root = mkdtempSync(path.join(os.tmpdir(), 'gev-directions-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
execFileSync('git', ['init', '--quiet'], { cwd: root });
const write = (name, source = '') => {
mkdirSync(path.dirname(path.join(root, name)), { recursive: true });
writeFileSync(path.join(root, name), source);
};
write(
'package.json',
JSON.stringify({ name: '@gods-eye-view/core', exports }),
);
write('src/data/feedState.js');
return { root, write };
}
test('module analysis parses dependencies and platform references without reading comments or property labels', () => {
assert.deepEqual(
analyzeModule(
"// import('node:fs'); document\nimport x from 'one'; export { x } from 'two'; export * from 'three'; import('four'); const data = { document: 1 }; data.window;",
),
{ imports: ['one', 'two', 'three', 'four'], browser: [] },
);
assert.deepEqual(
analyzeModule(
"const data = { element: document }; globalThis['navigator'];",
).browser,
['document', 'navigator'],
);
assert.throws(() => analyzeModule('import(name)'), /Computed/);
assert.throws(() => analyzeModule("require('node:fs')"), /ES imports/);
});
test('portable sources can share records, and standalone can assemble rendering', (t) => {
const { root, write } = fixture(t, {
'./layers/demo/source': './src/layers/demo/source.js',
});
write('src/layers/demo/source.js', "export { read } from './records.js';");
write('src/layers/demo/records.js', 'export const read = (value) => value;');
write('src/standalone/catalog.js', "import '../layers/demo/index.js';");
write('src/layers/demo/index.js', "import * as Cesium from 'cesium';");
assert.equal(checkImportDirections(root).modules, 5);
});
for (const [name, files, pattern] of [
[
'unreachable source imports rendering',
{
'src/layers/demo/source.js': "import './rendering.js';",
'src/layers/demo/rendering.js': '',
},
/Source imports rendering/,
],
[
'source reaches a platform through a record helper',
{
'src/layers/demo/source.js': "import './records.js';",
'src/layers/demo/records.js': "import 'cesium';",
},
/reaches platform\/rendering/,
],
[
'source reaches a DOM helper',
{
'src/sources/demo.js': "import '../data/helper.js';",
'src/data/helper.js': 'export const view = () => globalThis.document;',
},
/reaches browser globals/,
],
[
'browser imports Node without prefix',
{ 'src/unused.mjs': "import 'fs/promises';" },
/Node builtin/,
],
[
'browser hides Node outside src',
{
'src/unused.js': "import '../shared/helper.js';",
'shared/helper.js': "export * from 'node:fs';",
},
/Browser graph reaches Node/,
],
[
'browser imports server',
{
'src/data/demo.js': "import '../../server/helper.js';",
'server/helper.js': '',
},
/Browser imports server/,
],
[
'reusable module selects standalone',
{
'src/app/demo.js': "import '../standalone/catalog.js';",
'src/standalone/catalog.js': '',
},
/Reusable module imports standalone/,
],
[
'provider selects application setup',
{
'server/providers/demo.js': "import '../standalone/key-setup.js';",
'server/standalone/key-setup.js': '',
},
/Provider imports application/,
],
[
'voice controls reach protocol indirectly',
{
'src/voice/sessionCommands.js': "import './helper.js';",
'src/voice/helper.js': "import './realtimeSession.js';",
'src/voice/realtimeSession.js': '',
},
/Common voice controls import protocol/,
],
[
'dynamic import cannot evade direction',
{ 'src/app/demo.js': "const target = './module.js'; import(target);" },
/Computed/,
],
[
'browser imports test helper',
{
'src/app/demo.js': "import '../testSupport/helper.js';",
'src/testSupport/helper.js': '',
},
/non-runtime owner/,
],
]) {
test(`rejects ${name}`, (t) => {
const { root, write } = fixture(t);
for (const [file, body] of Object.entries(files)) write(file, body);
assert.throws(() => checkImportDirections(root), pattern);
});
}
test('self package imports cannot evade portable ownership', (t) => {
const { root, write } = fixture(t, { './view': './src/ui/view.js' });
write('src/sources/demo.js', "import '@gods-eye-view/core/view';");
write('src/ui/view.js');
assert.throws(() => checkImportDirections(root), /Source imports rendering/);
});
test('compatibility exceptions are limited to the two existing composition entries', (t) => {
const { root, write } = fixture(t);
write('src/ui.js', "import './standalone/catalog.js';");
write('src/standalone/catalog.js');
write('server/providers/local.js', "import '../standalone/key-setup.js';");
write('server/standalone/key-setup.js');
assert.doesNotThrow(() => checkImportDirections(root));
write('src/ui/new.js', "import '../standalone/catalog.js';");
assert.throws(
() => checkImportDirections(root),
/Reusable module imports standalone/,
);
});
test('symlink aliases do not hide renderer ownership', (t) => {
const { root, write } = fixture(t);
write('src/sources/demo.js', "import './helper.js';");
write('src/ui/view.js');
symlinkSync('../ui/view.js', path.join(root, 'src/sources/helper.js'));
assert.throws(() => checkImportDirections(root), /rendering|symlinks/);
});
+2 -53
View File
@@ -1,3 +1,5 @@
import { layerFeedState } from '../data/feedState.js';
export { layerFeedState } from '../data/feedState.js';
import { GUIDANCE_STATUSES } from '../loadingFeedback.js'; import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
import { keySetupRequirement } from '../keySetupCore.mjs'; import { keySetupRequirement } from '../keySetupCore.mjs';
const FEED_STATE_LABELS = Object.freeze({ const FEED_STATE_LABELS = Object.freeze({
@@ -29,59 +31,6 @@ export function layerKeyRequirementTooltip(layer = {}) {
return requiresKeyId ? keySetupRequirement(requiresKeyId) : ''; return requiresKeyId ? keySetupRequirement(requiresKeyId) : '';
} }
/**
* Normalize heterogeneous layer stats into one honest control-chip state.
* @param {object|null} stats Layer getStats() result.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state.
*/
export function layerFeedState(stats = {}) {
const state = stats || {};
const status =
typeof state.status === 'string' ? state.status.toLowerCase() : '';
const source = `${state.source || ''} ${state.coverage || ''}`;
const hasExplicitFallback = typeof state.fallback === 'boolean';
const hasPriorData = Number(state.count) > 0 || Boolean(state.lastUpdate);
const presentedError =
state.error || state.lastError || state.managerRefreshError;
if (['unavailable', 'offline', 'down', 'error'].includes(status))
return 'unavailable';
if (
(presentedError ||
state.unavailable === true ||
state.available === false) &&
!hasPriorData &&
!GUIDANCE_STATUSES.includes(status)
) {
return 'unavailable';
}
if (state.loading) return 'loading';
// Guidance states ask the user to act (zoom in, run a search) — normal
// operation, not feed faults. One honesty carve-out: layers keep their
// rendered records through the guidance state, so a genuinely stale cache
// still reads STALE; a guidance prompt alone never reads DEGRADED.
if (GUIDANCE_STATUSES.includes(status)) {
return state.stale ? 'stale' : 'nominal';
}
if (
state.fallback === true ||
status === 'fallback' ||
state.mode === 'sim' ||
/\bfallback\b/i.test(source) ||
(!hasExplicitFallback && /\badsb\.lol\b/i.test(source))
) {
return 'fallback';
}
if (state.stale || status === 'stale') return 'stale';
if (
state.degraded ||
presentedError ||
state.unavailable === true ||
state.available === false
)
return 'degraded';
return 'nominal';
}
/** Layer row presentation over supplied state and actions; no layer imports. */ /** Layer row presentation over supplied state and actions; no layer imports. */
export class LayerPanel { export class LayerPanel {
constructor({ constructor({
+1 -1
View File
@@ -11,7 +11,7 @@ import {
import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js'; import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js';
import { contextModeWord } from '../contextModePolicy.js'; import { contextModeWord } from '../contextModePolicy.js';
import { createAnalystEngine } from '../data/analystEngine.js'; import { createAnalystEngine } from '../data/analystEngine.js';
import { layerFeedState } from '../data/manager.js'; import { layerFeedState } from '../data/feedState.js';
import { initCameraVerbs, moveCamera, flyRoute, interruptCameraMotion, adjustOrbitRange } from '../cameraVerbs.js'; import { initCameraVerbs, moveCamera, flyRoute, interruptCameraMotion, adjustOrbitRange } from '../cameraVerbs.js';
import * as defaultFloorServices from '../data/groundFloor.js'; import * as defaultFloorServices from '../data/groundFloor.js';
import { isPickedWorldPosition } from '../data/scenePick.js'; import { isPickedWorldPosition } from '../data/scenePick.js';