diff --git a/CHANGELOG.md b/CHANGELOG.md index a2af80485..10f9de54a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # 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 canonical voice action arguments from descriptive wording, preserving the existing Realtime tool inventory. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b765948ea..b37aa0559 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,15 +42,16 @@ it is not a production server. 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 or improve a data layer.** Each layer is one self-contained module in `src/data/.js` 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). +- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers//`, 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 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/`. - **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md). ## Architecture in one minute - **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/.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/.js` entries retain compatibility; new code belongs with its focused owner. +- Sources acquire records; renderers own Cesium resources. Import `gods-eye-view/layers//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. - `docs/CURRENT-STATE.md` is the authoritative runtime reference — read it first. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index fb56d4d11..f0158d015 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -16,43 +16,80 @@ excluded. The formatter validates every entry before writing any file. ## Current component ownership -| Surface | Owns | Receives from its caller | -| -------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | -| `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 | +Package imports use `gods-eye-view`; `package.json` is the authoritative +export inventory. Use declared exports rather than reaching into internal files. -The application and infrastructure exports are browser source modules. Use their documented -exports instead of importing standalone startup or reaching into internal files. -The application owns the viewer, context store, overlay host and render scheduler; -layers use the supplied callbacks. See [the infrastructure contract](INFRASTRUCTURE-LAYERS.md). +| Owner | Responsibility and lifetime | +| ---------------------- | ------------------------------------------------------------------------------------------------------- | +| `src/app/` | Construct supplied components, share scene/request services, cancel startup and dispose the application | +| `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//` | 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 -configuration disabled. `scripts/package-boundaries.json` lists each export's -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. +See [application construction](APPLICATION.md) and the +[infrastructure contract](INFRASTRUCTURE-LAYERS.md) for construction interfaces. -These checks cover the declared exports, not every import in the application. -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. +## Import direction gates -`gods-eye-view/application` owns construction order, startup state, cancellation -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. +`npm run check:boundaries` runs two complementary checks: -UI panels and individual source adapters remain future extractions. They should -become smaller modules with explicit lifecycle owners as their callers migrate. +1. `scripts/check-import-directions.mjs` parses every runtime JS/MJS/CJS file in + `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/.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 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 241cdcdea..4ea0b2b74 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Source factories have dedicated `layers//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 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. diff --git a/package.json b/package.json index 5d7a062ea..779a60126 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "test": "node scripts/run-unit-tests.mjs", "test:track": "node scripts/track-regression.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": { "@mapbox/vector-tile": "^3.0.0", @@ -214,6 +214,16 @@ "./voice/action-schemas": "./src/voice/actionSchemas.js", "./voice/session": "./src/voice/session.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" } } diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs new file mode 100644 index 000000000..d84bc18ac --- /dev/null +++ b/scripts/check-import-directions.mjs @@ -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; + } +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2729e2895..5cc813cc5 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -659,5 +659,14 @@ "src/voice/sessionCommands.js", "src/voice/realtimeSession.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" ] diff --git a/scripts/module-analysis.mjs b/scripts/module-analysis.mjs new file mode 100644 index 000000000..0976159f7 --- /dev/null +++ b/scripts/module-analysis.mjs @@ -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; +} diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 43b377849..d5e9d5d2f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -268,7 +268,7 @@ "server/providers/common/source-root.js", "server/standalone/key-setup.js", "src/keySetupCore.mjs", - "src/keySetupHardening.mjs" + "server/standalone/key-setup-hardening.mjs" ], "external": [] }, @@ -358,7 +358,8 @@ "src/ui/clearLayersControl.js", "src/loadingFeedback.js", "src/data/installationFeedback.js", - "src/keySetupCore.mjs" + "src/keySetupCore.mjs", + "src/data/feedState.js" ], "external": [] }, @@ -552,7 +553,8 @@ "src/ui/visualInput.js", "src/ui/visualPresets.js", "src/ui/visualSettings.js", - "src/worldFocus.js" + "src/worldFocus.js", + "src/data/feedState.js" ], "external": ["cesium"] }, @@ -735,7 +737,8 @@ "modules": [ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", - "src/layers/earthquakes/source.js" + "src/layers/earthquakes/source.js", + "src/layers/earthquakes/records.js" ], "external": ["cesium"] }, @@ -940,7 +943,8 @@ "src/layers/cctv/testing.js", "src/overlays/worldOverlayTokens.js", "src/data/cctvFootprint.js", - "src/data/inputOwnership.js" + "src/data/inputOwnership.js", + "src/layers/cctv/sourcePolicy.js" ], "external": ["cesium"] }, @@ -954,7 +958,8 @@ "src/layers/alpr/visuals.js", "src/layers/alpr/overlay.js", "src/layers/alpr/source.js", - "src/data/inputOwnership.js" + "src/data/inputOwnership.js", + "src/layers/alpr/records.js" ], "external": ["cesium"] }, @@ -1126,7 +1131,7 @@ "src/app/layers/traffic.js", "src/app/operations.js", "src/app/scene.js", - "src/app/sourceSlot.js", + "src/sources/sourceSlot.js", "src/app/sources.js", "src/app/startupChrome.js", "src/app/stateChannel.js", @@ -1608,7 +1613,12 @@ "src/voice/session.js", "src/voice/sessionCommands.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": [ "@mapbox/vector-tile", @@ -1647,7 +1657,7 @@ "src/app/layers/satellites.js", "src/app/layers/submarineCables.js", "src/app/layers/traffic.js", - "src/app/sourceSlot.js", + "src/sources/sourceSlot.js", "src/cctvFocusRequest.js", "src/celestialRing.js", "src/cockpitMath.js", @@ -1956,7 +1966,11 @@ "src/app/layers/directions.js", "src/search/coordinateGeocoder.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": [ "cesium", @@ -1973,7 +1987,7 @@ "src/annotations/annotationResolver.js", "src/annotations/resolver.js", "src/app/operations.js", - "src/app/sourceSlot.js", + "src/sources/sourceSlot.js", "src/app/surfaceServices.js", "src/data/dataCredits.js", "src/data/geoid.js", @@ -2166,5 +2180,67 @@ "src/voice/control.js" ], "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"] } } diff --git a/src/keySetupHardening.mjs b/server/standalone/key-setup-hardening.mjs similarity index 99% rename from src/keySetupHardening.mjs rename to server/standalone/key-setup-hardening.mjs index 64d807292..b67c55fc4 100644 --- a/src/keySetupHardening.mjs +++ b/server/standalone/key-setup-hardening.mjs @@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'; import { commandCompletedSuccessfully, parseWindowsUserSid, -} from './keySetupCore.mjs'; +} from '../../src/keySetupCore.mjs'; /** PowerShell verification for the exact owner-only Windows credential DACL. */ const WINDOWS_ACL_VERIFY_SCRIPT = [ diff --git a/server/standalone/key-setup.js b/server/standalone/key-setup.js index 37516cbe2..b8be4c036 100644 --- a/server/standalone/key-setup.js +++ b/server/standalone/key-setup.js @@ -12,7 +12,7 @@ import { readEnvironmentSource as readPinokioEnvironmentSource } from '../../scr import fs from 'node:fs'; import { parseEnv as parseDotenvText } from 'node:util'; 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 diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index cbc848bd5..18d872209 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -18,7 +18,7 @@ import { createApplicationFirms } from './layers/firms.js'; import { createApplicationEarthquakes } from './layers/earthquakes.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; -import { localGeoJsonServices } from '../data/localGeojson.js'; +import { localGeoJsonServices } from './localGeojsonServices.js'; const SOURCE_METHODS = Object.freeze({ flights: ['getSnapshot'], diff --git a/src/app/localGeojsonServices.js b/src/app/localGeojsonServices.js new file mode 100644 index 000000000..83caad8cd --- /dev/null +++ b/src/app/localGeojsonServices.js @@ -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, +}); diff --git a/src/app/sourceSlot.js b/src/app/sourceSlot.js index 8883c0171..fe7590e3b 100644 --- a/src/app/sourceSlot.js +++ b/src/app/sourceSlot.js @@ -1,52 +1,2 @@ -/** 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; - }; - }, - }; -} +// Compatibility entry; source contracts are owned by sources/. +export { createSourceSlot } from '../sources/sourceSlot.js'; diff --git a/src/data/alprCameras.js b/src/data/alprCameras.js index 5f02d1933..77532dc7e 100644 --- a/src/data/alprCameras.js +++ b/src/data/alprCameras.js @@ -1,6 +1,6 @@ import { defaultSurface } from './surfaceServices.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'; export * from '../layers/alpr/index.js'; const slot = createSourceSlot( diff --git a/src/data/bikeshare.js b/src/data/bikeshare.js index e91686fbf..15c9dc802 100644 --- a/src/data/bikeshare.js +++ b/src/data/bikeshare.js @@ -1,5 +1,5 @@ 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'; const sourceSlot = createSourceSlot( diff --git a/src/data/cctv.js b/src/data/cctv.js index 18bbc5225..da65f5f08 100644 --- a/src/data/cctv.js +++ b/src/data/cctv.js @@ -1,6 +1,6 @@ import { defaultSurface } from './surfaceServices.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'; const sourceSlot = createSourceSlot( diff --git a/src/data/feedState.js b/src/data/feedState.js new file mode 100644 index 000000000..f65bbd9bd --- /dev/null +++ b/src/data/feedState.js @@ -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'; +} diff --git a/src/data/firmsHeatmap.js b/src/data/firmsHeatmap.js index 001687446..95fed982d 100644 --- a/src/data/firmsHeatmap.js +++ b/src/data/firmsHeatmap.js @@ -1,6 +1,6 @@ import { defaultSurface } from './surfaceServices.js'; import { createApplicationFirms, firmsServices } from '../app/layers/firms.js'; -import { createSourceSlot } from '../app/sourceSlot.js'; +import { createSourceSlot } from '../sources/sourceSlot.js'; import { createFirmsHelpers, createFirmsSource, diff --git a/src/data/localGeojson.js b/src/data/localGeojson.js index 3798552e0..5154c8aed 100644 --- a/src/data/localGeojson.js +++ b/src/data/localGeojson.js @@ -1,15 +1,5 @@ -import { governorRequestRender } from '../renderGovernor.js'; -import { - clearSelectedEntityContextForLayer, - registerEntityContext, - removeEntityContextsForLayer, - selectEntityContext, -} from './contextStore.js'; -import { - clearOverlaySource, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; +import { localGeoJsonServices } from '../app/localGeojsonServices.js'; +export { localGeoJsonServices } from '../app/localGeojsonServices.js'; import { createLocalGeoJsonLayer as createLayer, createLocalInfrastructureOverlayPublisher as createPublisher, @@ -17,20 +7,6 @@ import { 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. */ export function createLocalGeoJsonLayer(options) { return createLayer(options, { diff --git a/src/data/militaryInstallations.js b/src/data/militaryInstallations.js index 86803ee1d..b8a08f655 100644 --- a/src/data/militaryInstallations.js +++ b/src/data/militaryInstallations.js @@ -1,6 +1,6 @@ import { defaultSurface } from './surfaceServices.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'; const sourceSlot = createSourceSlot( diff --git a/src/data/radio.js b/src/data/radio.js index 1c3a52554..e0a8009bc 100644 --- a/src/data/radio.js +++ b/src/data/radio.js @@ -1,6 +1,6 @@ import { defaultSurface } from './surfaceServices.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'; const sourceSlot = createSourceSlot( diff --git a/src/data/rocketLaunches.js b/src/data/rocketLaunches.js index 4ed012d6e..14002ecaf 100644 --- a/src/data/rocketLaunches.js +++ b/src/data/rocketLaunches.js @@ -1,5 +1,5 @@ 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 * as satellites from './satellites.js'; diff --git a/src/data/satellites.js b/src/data/satellites.js index 3cc47b315..383c583ef 100644 --- a/src/data/satellites.js +++ b/src/data/satellites.js @@ -1,5 +1,5 @@ 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'; const sourceSlot = createSourceSlot( diff --git a/src/data/traffic.js b/src/data/traffic.js index 57ffff927..dbab8cb8f 100644 --- a/src/data/traffic.js +++ b/src/data/traffic.js @@ -1,5 +1,5 @@ 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'; const sourceSlot = createSourceSlot( diff --git a/src/keySetupHardening.test.mjs b/src/keySetupHardening.test.mjs index 1ea9b3a8c..2578be37d 100644 --- a/src/keySetupHardening.test.mjs +++ b/src/keySetupHardening.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; 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 USER_SID = 'S-1-5-21-1111111111-2222222222-3333333333-1001'; diff --git a/src/layers/alpr/model.js b/src/layers/alpr/model.js index 9abc9e3ca..5966cad01 100644 --- a/src/layers/alpr/model.js +++ b/src/layers/alpr/model.js @@ -1,52 +1,6 @@ import * as Cesium from 'cesium'; -import { - QUERY_LIMIT, - 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 - ); -} +import { EARTH_MEAN_RADIUS_M } from './policy.js'; +export * from './records.js'; /** * 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. */ export function alprCreditMarkup(attribution) { if (!attribution?.text || !attribution?.href) return null; diff --git a/src/layers/alpr/records.js b/src/layers/alpr/records.js new file mode 100644 index 000000000..0d751f12d --- /dev/null +++ b/src/layers/alpr/records.js @@ -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; +} diff --git a/src/layers/alpr/source.js b/src/layers/alpr/source.js index bf9288269..86048e12f 100644 --- a/src/layers/alpr/source.js +++ b/src/layers/alpr/source.js @@ -4,7 +4,7 @@ import { QUERY_SNAP_DEGREES, QUERY_LIMIT, } 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. */ export function createOverpassAlprSource({ fetchImpl = (...args) => globalThis.fetch(...args), @@ -80,3 +80,5 @@ export function createOverpassAlprSource({ }, }; } + +export { buildOverpassQuery } from './records.js'; diff --git a/src/layers/cctv/policy.js b/src/layers/cctv/policy.js index df5779edb..85bd0a84b 100644 --- a/src/layers/cctv/policy.js +++ b/src/layers/cctv/policy.js @@ -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 { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.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 // --------------------------------------------------------------------------- -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 // --------------------------------------------------------------------------- @@ -26,8 +25,6 @@ export const MAX_AUTO_HOP_SEC = 90; export const HEALTH_SYNC_INTERVAL_MS = 7000; -export const ACTIVE_FRAME_REFRESH_MS = 10000; - export const IDLE_FRAME_REFRESH_MS = 60000; export const PROJECTION_ACTIVE_REFRESH_MS = 10000; diff --git a/src/layers/cctv/source.js b/src/layers/cctv/source.js index 7fba9a9ca..bf4363128 100644 --- a/src/layers/cctv/source.js +++ b/src/layers/cctv/source.js @@ -2,7 +2,7 @@ import { ACTIVE_FRAME_REFRESH_MS, FRAME_ENDPOINT, MEDIA_ENDPOINT, -} from './policy.js'; +} from './sourcePolicy.js'; function safeNumber(value, fallback = NaN) { const n = Number(value); return Number.isFinite(n) ? n : fallback; diff --git a/src/layers/cctv/sourcePolicy.js b/src/layers/cctv/sourcePolicy.js new file mode 100644 index 000000000..5ce0cd024 --- /dev/null +++ b/src/layers/cctv/sourcePolicy.js @@ -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; diff --git a/src/layers/earthquakes/model.js b/src/layers/earthquakes/model.js index b679b7e35..c88063b7b 100644 --- a/src/layers/earthquakes/model.js +++ b/src/layers/earthquakes/model.js @@ -99,52 +99,4 @@ export function mapAnalystRecord(raw, index = 0) { }; } -/** 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; -} +export { normalizeEarthquakeSnapshot } from './records.js'; diff --git a/src/layers/earthquakes/records.js b/src/layers/earthquakes/records.js new file mode 100644 index 000000000..397b4bb1c --- /dev/null +++ b/src/layers/earthquakes/records.js @@ -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; +} diff --git a/src/layers/earthquakes/source.js b/src/layers/earthquakes/source.js index fe074e139..0d4c7114f 100644 --- a/src/layers/earthquakes/source.js +++ b/src/layers/earthquakes/source.js @@ -1,4 +1,4 @@ -import { normalizeEarthquakeSnapshot } from './model.js'; +import { normalizeEarthquakeSnapshot } from './records.js'; const API_URL = '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. */ diff --git a/src/overlays/worldOverlayAllocation.worker.mjs b/src/overlays/worldOverlayAllocation.worker.mjs index 6dee2193d..9f5bcfad2 100644 --- a/src/overlays/worldOverlayAllocation.worker.mjs +++ b/src/overlays/worldOverlayAllocation.worker.mjs @@ -9,7 +9,7 @@ import { import { LOCAL_OVERLAY_COHORT_LIMIT, createLocalInfrastructureOverlayEntry, -} from '../data/localGeojson.js'; +} from '../data/localGeojsonCore.js'; import { FIRMS_AMBIENT_COHORT_LIMIT, FIRMS_OVERLAY_SOURCE_ID, diff --git a/src/services/application.js b/src/services/application.js index 446c677cb..98804e89b 100644 --- a/src/services/application.js +++ b/src/services/application.js @@ -1,4 +1,4 @@ -import { createSourceSlot } from '../app/sourceSlot.js'; +import { createSourceSlot } from '../sources/sourceSlot.js'; import { createApplicationRequestServices } from './requests.js'; // Compatibility owners are page-scoped, like the viewer and its layer registry. diff --git a/src/sources/sourceSlot.js b/src/sources/sourceSlot.js new file mode 100644 index 000000000..8883c0171 --- /dev/null +++ b/src/sources/sourceSlot.js @@ -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; + }; + }, + }; +} diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs new file mode 100644 index 000000000..ef486fa2c --- /dev/null +++ b/src/tooling/importDirections.test.mjs @@ -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/); +}); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 6a8e16c19..73cf5ecdd 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -1,3 +1,5 @@ +import { layerFeedState } from '../data/feedState.js'; +export { layerFeedState } from '../data/feedState.js'; import { GUIDANCE_STATUSES } from '../loadingFeedback.js'; import { keySetupRequirement } from '../keySetupCore.mjs'; const FEED_STATE_LABELS = Object.freeze({ @@ -29,59 +31,6 @@ export function layerKeyRequirementTooltip(layer = {}) { 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. */ export class LayerPanel { constructor({ diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index e9f313ce1..e3bb766b6 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -11,7 +11,7 @@ import { import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js'; import { contextModeWord } from '../contextModePolicy.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 * as defaultFloorServices from '../data/groundFloor.js'; import { isPickedWorldPosition } from '../data/scenePick.js';