mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
refactor: enforce import directions and expose source factories
This commit is contained in:
1 parent
704e0fcc51
commit
cc960b0dbe
41 files changed
+1080
-417
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
]
|
]
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 = [
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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
@@ -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,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,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
@@ -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(
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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,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
@@ -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,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,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
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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,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,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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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({
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in new issue
Block a user