mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +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
@@ -18,7 +18,7 @@ import { createApplicationFirms } from './layers/firms.js';
|
||||
import { createApplicationEarthquakes } from './layers/earthquakes.js';
|
||||
import { createApplicationCables } from './layers/submarineCables.js';
|
||||
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
||||
import { localGeoJsonServices } from '../data/localGeojson.js';
|
||||
import { localGeoJsonServices } from './localGeojsonServices.js';
|
||||
|
||||
const SOURCE_METHODS = Object.freeze({
|
||||
flights: ['getSnapshot'],
|
||||
|
||||
@@ -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. */
|
||||
export function createSourceSlot(
|
||||
initial,
|
||||
methods,
|
||||
label = 'Source',
|
||||
optional = {},
|
||||
) {
|
||||
let active = initial;
|
||||
let binding = null;
|
||||
const source = Object.fromEntries(
|
||||
[...methods, ...Object.keys(optional)].map((method) => [
|
||||
method,
|
||||
(...args) => {
|
||||
if (typeof active?.[method] !== 'function' && optional[method])
|
||||
return optional[method](...args);
|
||||
if (typeof active?.[method] !== 'function')
|
||||
throw new Error(`${label} is not configured`);
|
||||
const owner = binding;
|
||||
const provider = active;
|
||||
const value = provider[method](...args);
|
||||
if (value && typeof value.then === 'function')
|
||||
return value.then((result) => {
|
||||
if (binding !== owner || active !== provider)
|
||||
throw new DOMException(`${label} was replaced`, 'AbortError');
|
||||
return result;
|
||||
});
|
||||
return value;
|
||||
},
|
||||
]),
|
||||
);
|
||||
for (const property of ['label', 'attribution']) {
|
||||
Object.defineProperty(source, property, {
|
||||
enumerable: true,
|
||||
get: () => active?.[property],
|
||||
});
|
||||
}
|
||||
return {
|
||||
source,
|
||||
configure(next) {
|
||||
if (methods.some((method) => typeof next?.[method] !== 'function'))
|
||||
throw new TypeError(`Invalid ${label.toLowerCase()}`);
|
||||
const owner = Symbol(label);
|
||||
active = next;
|
||||
binding = owner;
|
||||
return () => {
|
||||
if (binding !== owner) return;
|
||||
active = null;
|
||||
binding = null;
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
// Compatibility entry; source contracts are owned by sources/.
|
||||
export { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defaultSurface } from './surfaceServices.js';
|
||||
import { createApplicationAlpr } from '../app/layers/alprCameras.js';
|
||||
import { createSourceSlot } from '../app/sourceSlot.js';
|
||||
import { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
import { createOverpassAlprSource } from '../layers/alpr/index.js';
|
||||
export * from '../layers/alpr/index.js';
|
||||
const slot = createSourceSlot(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createApplicationBikeshare } from '../app/layers/bikeshare.js';
|
||||
import { createSourceSlot } from '../app/sourceSlot.js';
|
||||
import { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
import { createBikeshareSource } from '../layers/bikeshare/source.js';
|
||||
|
||||
const sourceSlot = createSourceSlot(
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { defaultSurface } from './surfaceServices.js';
|
||||
import { createApplicationCctv } from '../app/layers/cctv.js';
|
||||
import { createSourceSlot } from '../app/sourceSlot.js';
|
||||
import { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
import { createCctvSource } from '../layers/cctv/source.js';
|
||||
|
||||
const sourceSlot = createSourceSlot(
|
||||
|
||||
@@ -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 { createApplicationFirms, firmsServices } from '../app/layers/firms.js';
|
||||
import { createSourceSlot } from '../app/sourceSlot.js';
|
||||
import { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
import {
|
||||
createFirmsHelpers,
|
||||
createFirmsSource,
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
import {
|
||||
clearSelectedEntityContextForLayer,
|
||||
registerEntityContext,
|
||||
removeEntityContextsForLayer,
|
||||
selectEntityContext,
|
||||
} from './contextStore.js';
|
||||
import {
|
||||
clearOverlaySource,
|
||||
setOverlayEntries,
|
||||
setOverlaySourceVisible,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
|
||||
export { localGeoJsonServices } from '../app/localGeojsonServices.js';
|
||||
import {
|
||||
createLocalGeoJsonLayer as createLayer,
|
||||
createLocalInfrastructureOverlayPublisher as createPublisher,
|
||||
@@ -17,20 +7,6 @@ import {
|
||||
|
||||
export * from './localGeojsonCore.js';
|
||||
|
||||
/** Existing standalone application operations, shared by its local layers. */
|
||||
export const localGeoJsonServices = Object.freeze({
|
||||
overlayHost: Object.freeze({
|
||||
clearSource: clearOverlaySource,
|
||||
setEntries: setOverlayEntries,
|
||||
setVisible: setOverlaySourceVisible,
|
||||
}),
|
||||
clearSelectedEntityContextForLayer,
|
||||
registerEntityContext,
|
||||
removeEntityContextsForLayer,
|
||||
selectEntityContext,
|
||||
governorRequestRender,
|
||||
});
|
||||
|
||||
/** Create a layer using the standalone app's operations; retain overlay overrides. */
|
||||
export function createLocalGeoJsonLayer(options) {
|
||||
return createLayer(options, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defaultSurface } from './surfaceServices.js';
|
||||
import { createApplicationInstallations } from '../app/layers/militaryInstallations.js';
|
||||
import { createSourceSlot } from '../app/sourceSlot.js';
|
||||
import { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
import { createInstallationSource } from '../layers/installations/index.js';
|
||||
|
||||
const sourceSlot = createSourceSlot(
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { defaultSurface } from './surfaceServices.js';
|
||||
import { createApplicationRadio } from '../app/layers/radio.js';
|
||||
import { createSourceSlot } from '../app/sourceSlot.js';
|
||||
import { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
import { createRadioSource } from '../layers/radio/source.js';
|
||||
|
||||
const sourceSlot = createSourceSlot(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createApplicationLaunches } from '../app/layers/rocketLaunches.js';
|
||||
import { createSourceSlot } from '../app/sourceSlot.js';
|
||||
import { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
import { createLaunchSource } from '../layers/launches/index.js';
|
||||
import * as satellites from './satellites.js';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createApplicationSatellites } from '../app/layers/satellites.js';
|
||||
import { createSourceSlot } from '../app/sourceSlot.js';
|
||||
import { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
import { createSatelliteSource } from '../layers/satellites/index.js';
|
||||
|
||||
const sourceSlot = createSourceSlot(
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { createApplicationTraffic } from '../app/layers/traffic.js';
|
||||
import { createSourceSlot } from '../app/sourceSlot.js';
|
||||
import { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
import { createTrafficSource } from '../layers/traffic/source.js';
|
||||
|
||||
const sourceSlot = createSourceSlot(
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
commandCompletedSuccessfully,
|
||||
parseWindowsUserSid,
|
||||
} from './keySetupCore.mjs';
|
||||
|
||||
/** PowerShell verification for the exact owner-only Windows credential DACL. */
|
||||
const WINDOWS_ACL_VERIFY_SCRIPT = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
// Load Microsoft.PowerShell.Security (Get-Acl) from the module tree of the
|
||||
// interpreter that is actually running. $PSHOME is that interpreter's own
|
||||
// physical directory, so this is correct even when the executable was named
|
||||
// through the Sysnative bridge, and it cannot be steered by anything the
|
||||
// parent environment set.
|
||||
"$env:PSModulePath = Join-Path $PSHOME 'Modules'",
|
||||
'$acl = Get-Acl -LiteralPath $env:GEV_ACL_FILE',
|
||||
'if (-not $acl.AreAccessRulesProtected) { exit 2 }',
|
||||
"$allowed = @($env:GEV_ACL_USER_SID, 'S-1-5-18', 'S-1-5-32-544')",
|
||||
'$seen = @{}',
|
||||
'$rules = @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))',
|
||||
'if ($rules.Count -ne 3) { exit 7 }',
|
||||
'foreach ($rule in $rules) {',
|
||||
' $ruleSid = $rule.IdentityReference.Value',
|
||||
' if ($rule.IsInherited) { exit 3 }',
|
||||
' if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { exit 4 }',
|
||||
' if ($allowed -notcontains $ruleSid) { exit 5 }',
|
||||
' if ($seen.ContainsKey($ruleSid)) { exit 8 }',
|
||||
' $full = [System.Security.AccessControl.FileSystemRights]::FullControl',
|
||||
' if ($rule.FileSystemRights -ne $full) { exit 6 }',
|
||||
' $seen[$ruleSid] = $true',
|
||||
'}',
|
||||
'if ($seen.Count -ne 3) { exit 9 }',
|
||||
].join('; ');
|
||||
|
||||
/**
|
||||
* Resolve the native Windows ACL tools without consulting PATH.
|
||||
*
|
||||
* Provider Settings supports the standard Windows installation layout only:
|
||||
* a local drive root named `Windows` (for example C:\\Windows or D:\\Windows).
|
||||
* Requiring consistent aliases, canonical paths, and regular files prevents an
|
||||
* inherited environment override, UNC share, device path, junction, or PATH
|
||||
* shim from being treated as an operating-system security tool.
|
||||
*/
|
||||
function resolveWindowsNativeTools(environment, fileSystem, architecture) {
|
||||
const aliases = ['SystemRoot', 'SYSTEMROOT', 'WINDIR', 'windir'];
|
||||
const configured = aliases
|
||||
.map((name) => environment[name])
|
||||
.filter((value) => typeof value === 'string' && value.length > 0);
|
||||
if (configured.length === 0) return null;
|
||||
|
||||
const roots = configured.map((value) => {
|
||||
if (value !== value.trim() || !/^[A-Za-z]:\\Windows\\?$/i.test(value)) return null;
|
||||
return value.endsWith('\\') ? value.slice(0, -1) : value;
|
||||
});
|
||||
if (roots.some((root) => !root)) return null;
|
||||
if (roots.some((root) => root.toLowerCase() !== roots[0].toLowerCase())) return null;
|
||||
|
||||
const systemRoot = roots[0];
|
||||
const systemDirectory = architecture === 'ia32' ? 'Sysnative' : 'System32';
|
||||
const expected = {
|
||||
whoami: path.win32.join(systemRoot, systemDirectory, 'whoami.exe'),
|
||||
icacls: path.win32.join(systemRoot, systemDirectory, 'icacls.exe'),
|
||||
powershell: path.win32.join(
|
||||
systemRoot,
|
||||
systemDirectory,
|
||||
'WindowsPowerShell',
|
||||
'v1.0',
|
||||
'powershell.exe',
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
const realpath = fileSystem.realpathSync.native || fileSystem.realpathSync;
|
||||
const rootEntry = fileSystem.lstatSync(systemRoot);
|
||||
if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink()) return null;
|
||||
const canonicalRoot = realpath.call(fileSystem.realpathSync, systemRoot);
|
||||
if (canonicalRoot.toLowerCase() !== systemRoot.toLowerCase()) return null;
|
||||
for (const executable of Object.values(expected)) {
|
||||
const entry = fileSystem.lstatSync(executable);
|
||||
if (!entry.isFile() || entry.isSymbolicLink()) return null;
|
||||
const canonicalExecutable = realpath.call(fileSystem.realpathSync, executable);
|
||||
const canonicalCandidates = [executable];
|
||||
if (architecture === 'ia32') {
|
||||
canonicalCandidates.push(executable.replace('\\Sysnative\\', '\\System32\\'));
|
||||
}
|
||||
if (!canonicalCandidates.some(
|
||||
(candidate) => candidate.toLowerCase() === canonicalExecutable.toLowerCase(),
|
||||
)) return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict a credential file before any secret is written to it.
|
||||
* Dependencies are injectable so every fail-closed branch is unit-testable.
|
||||
*/
|
||||
export function hardenCredentialFile(filepath, {
|
||||
platform = process.platform,
|
||||
architecture = process.arch,
|
||||
spawn = spawnSync,
|
||||
fileSystem = fs,
|
||||
environment = process.env,
|
||||
} = {}) {
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
const aclRemoval = spawn('chmod', ['-N', filepath], { stdio: 'ignore' });
|
||||
if (!commandCompletedSuccessfully(aclRemoval)) return false;
|
||||
}
|
||||
fileSystem.chmodSync(filepath, 0o600);
|
||||
return (fileSystem.statSync(filepath).mode & 0o777) === 0o600;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const tools = resolveWindowsNativeTools(environment, fileSystem, architecture);
|
||||
if (!tools) return false;
|
||||
|
||||
try {
|
||||
// Grant by the CURRENT PROCESS TOKEN'S SID, never a bare username. Parsing
|
||||
// the second CSV field structurally prevents an SID-looking account name or
|
||||
// a broad group SID from becoming the credential owner.
|
||||
const whoami = spawn(tools.whoami, ['/user', '/fo', 'csv', '/nh'], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
});
|
||||
const sid = commandCompletedSuccessfully(whoami)
|
||||
? parseWindowsUserSid(whoami.stdout)
|
||||
: null;
|
||||
if (!sid) return false;
|
||||
|
||||
const applied = spawn(tools.icacls, [
|
||||
filepath,
|
||||
'/inheritance:r',
|
||||
'/grant:r',
|
||||
`*${sid}:F`,
|
||||
'*S-1-5-18:F',
|
||||
'*S-1-5-32-544:F',
|
||||
], { stdio: 'ignore', windowsHide: true });
|
||||
if (!commandCompletedSuccessfully(applied)) return false;
|
||||
|
||||
// Command success is not proof of the resulting DACL. Query it back and
|
||||
// accept only three explicit FullControl allow principals, with inheritance
|
||||
// disabled. Any unexpected rule, right, command error, or missing principal
|
||||
// fails closed before the secret reaches disk.
|
||||
//
|
||||
// The verify process must load Microsoft.PowerShell.Security (Get-Acl)
|
||||
// from the Windows PowerShell system module tree ONLY. A side-by-side
|
||||
// PowerShell 7 install prepends its own module trees to PSModulePath at
|
||||
// startup; inherited into a 5.1 process, the incompatible 7.x manifest
|
||||
// cannot be autoloaded and the verify step fails. The script itself sets
|
||||
// the path from $PSHOME; this is the same value computed ahead of time, so
|
||||
// nothing inherited is in force even for the moment before it runs. The
|
||||
// Sysnative spelling is a 32-bit caller's bridge and not a directory the
|
||||
// launched native process can read, so the physical name is used.
|
||||
const powershellModuleDirectory = path.win32.join(
|
||||
path.win32
|
||||
.dirname(tools.powershell)
|
||||
.replace(/\\Sysnative\\/i, '\\System32\\'),
|
||||
'Modules',
|
||||
);
|
||||
// Windows environment names are case-insensitive, and a child can end up
|
||||
// carrying a differently cased alias alongside the value set here. Drop
|
||||
// every spelling before setting the trusted one.
|
||||
const verifyEnvironment = Object.fromEntries(
|
||||
Object.entries(environment).filter(
|
||||
([name]) => name.toLowerCase() !== 'psmodulepath',
|
||||
),
|
||||
);
|
||||
const verified = spawn(tools.powershell, [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command', WINDOWS_ACL_VERIFY_SCRIPT,
|
||||
], {
|
||||
env: {
|
||||
...verifyEnvironment,
|
||||
GEV_ACL_FILE: filepath,
|
||||
GEV_ACL_USER_SID: sid,
|
||||
PSModulePath: powershellModuleDirectory,
|
||||
},
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
return commandCompletedSuccessfully(verified);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { hardenCredentialFile } from './keySetupHardening.mjs';
|
||||
import { hardenCredentialFile } from '../server/standalone/key-setup-hardening.mjs';
|
||||
|
||||
const FILE = path.join(os.tmpdir(), 'provider-settings-test');
|
||||
const USER_SID = 'S-1-5-21-1111111111-2222222222-3333333333-1001';
|
||||
|
||||
+2
-159
@@ -1,52 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
QUERY_LIMIT,
|
||||
EARTH_MEAN_RADIUS_M,
|
||||
QUERY_SNAP_DEGREES,
|
||||
} from './policy.js';
|
||||
|
||||
export function textTag(value) {
|
||||
const t = String(value ?? '').trim();
|
||||
return t || null;
|
||||
}
|
||||
|
||||
export function numTag(value) {
|
||||
// `Number('')` and `Number(' ')` are 0, not NaN — a blank or whitespace-only
|
||||
// tag value must read as missing, never as a due-north bearing.
|
||||
if (value == null) return null;
|
||||
const trimmed = String(value).trim();
|
||||
if (!trimmed) return null;
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a viewport box OUTWARD to the query grid, clamped to valid ranges.
|
||||
* Pure so the reuse contract is pinnable.
|
||||
* @param {{south:number,west:number,north:number,east:number}} box
|
||||
* @param {number} [step=QUERY_SNAP_DEGREES]
|
||||
*/
|
||||
export function snapAlprBox(box, step = QUERY_SNAP_DEGREES) {
|
||||
const down = (v) => Math.floor(v / step) * step;
|
||||
const up = (v) => Math.ceil(v / step) * step;
|
||||
return {
|
||||
south: Math.max(-90, down(box.south)),
|
||||
west: Math.max(-180, down(box.west)),
|
||||
north: Math.min(90, up(box.north)),
|
||||
east: Math.min(180, up(box.east)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether `inner` lies entirely within `outer` (no dateline handling; the viewport gate already rejects east <= west). */
|
||||
export function boxContains(outer, inner) {
|
||||
if (!outer || !inner) return false;
|
||||
return (
|
||||
inner.south >= outer.south &&
|
||||
inner.north <= outer.north &&
|
||||
inner.west >= outer.west &&
|
||||
inner.east <= outer.east
|
||||
);
|
||||
}
|
||||
import { EARTH_MEAN_RADIUS_M } from './policy.js';
|
||||
export * from './records.js';
|
||||
|
||||
/**
|
||||
* Great-circle destination point — used only to draw the short
|
||||
@@ -77,117 +31,6 @@ export function destinationPointDeg(latDeg, lonDeg, bearingDeg, distanceM) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoff progression for the unavailable-state retry: 30 s, doubling to a
|
||||
* 240 s ceiling. Pure so the progression is pinnable without booting the layer.
|
||||
*/
|
||||
export function alprRetryDelayMs(prevDelayMs) {
|
||||
const RETRY_MIN_MS = 30000;
|
||||
const RETRY_CEIL_MS = 240000;
|
||||
if (!Number.isFinite(prevDelayMs) || prevDelayMs <= 0) return RETRY_MIN_MS;
|
||||
return Math.min(prevDelayMs * 2, RETRY_CEIL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `surveillance:type` value denotes an ALPR camera. OSM allows
|
||||
* semicolon multi-values (`camera;ALPR`) and mixed case, so an exact match
|
||||
* would silently miss tagged cameras.
|
||||
* @param {*} value - raw `surveillance:type` tag value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAlprSurveillanceType(value) {
|
||||
return String(value ?? '')
|
||||
.split(';')
|
||||
.some((part) => part.trim().toUpperCase() === 'ALPR');
|
||||
}
|
||||
|
||||
/** Map one raw Overpass node element to a plain camera record. Null for anything unusable. */
|
||||
export function normalizeAlprNode(el) {
|
||||
if (
|
||||
!el ||
|
||||
el.type !== 'node' ||
|
||||
!Number.isSafeInteger(el.id) ||
|
||||
el.id <= 0 ||
|
||||
!Number.isFinite(el.lat) ||
|
||||
Math.abs(el.lat) > 90 ||
|
||||
!Number.isFinite(el.lon) ||
|
||||
Math.abs(el.lon) > 180
|
||||
)
|
||||
return null;
|
||||
const tags = el.tags || {};
|
||||
if (!isAlprSurveillanceType(tags['surveillance:type'])) return null;
|
||||
return {
|
||||
id: `alpr:${el.id}`,
|
||||
osmId: el.id,
|
||||
latitude: el.lat,
|
||||
longitude: el.lon,
|
||||
operator: textTag(tags.operator),
|
||||
manufacturer: textTag(tags.manufacturer),
|
||||
cameraType: textTag(tags['camera:type']),
|
||||
zone: textTag(tags['surveillance:zone']),
|
||||
// Only numeric bearings are parsed; compass-word directions ("N"/"NE")
|
||||
// are rare on this tag and simply render with no facing line.
|
||||
directionDeg: normalizeDirection(
|
||||
tags['camera:direction'] ?? tags.direction,
|
||||
),
|
||||
ref: textTag(tags.ref),
|
||||
lastVerified: textTag(tags.check_date) || textTag(tags['survey:date']),
|
||||
source: textTag(tags.source),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDirection(value) {
|
||||
const degrees = numTag(value);
|
||||
return degrees != null && degrees >= 0 && degrees <= 360
|
||||
? degrees % 360
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overpass QL for the viewport. The tag match is a case-insensitive regex, not
|
||||
* an exact `="ALPR"`, because OSM semicolon multi-values such as
|
||||
* `surveillance:type=camera;ALPR` are common and an exact match drops them.
|
||||
* Exported so the query shape is pinnable.
|
||||
*/
|
||||
export function buildOverpassQuery(south, west, north, east) {
|
||||
return (
|
||||
`[out:json][timeout:20];node["man_made"="surveillance"]["surveillance:type"~"(^|;)\\s*ALPR\\s*(;|$)",i]` +
|
||||
`(${south},${west},${north},${east});out body ${QUERY_LIMIT};`
|
||||
);
|
||||
}
|
||||
|
||||
/** Validate a source-independent snapshot before replacing the accepted display. */
|
||||
export function validateAlprSnapshot(snapshot) {
|
||||
if (
|
||||
!Array.isArray(snapshot?.records) ||
|
||||
typeof snapshot.stale !== 'boolean' ||
|
||||
typeof snapshot.saturated !== 'boolean'
|
||||
) {
|
||||
throw new TypeError('Camera source returned an invalid snapshot');
|
||||
}
|
||||
const ids = new Set();
|
||||
for (const record of snapshot.records) {
|
||||
if (
|
||||
!record ||
|
||||
typeof record.id !== 'string' ||
|
||||
!record.id.trim() ||
|
||||
ids.has(record.id) ||
|
||||
!Number.isFinite(record.latitude) ||
|
||||
Math.abs(record.latitude) > 90 ||
|
||||
!Number.isFinite(record.longitude) ||
|
||||
Math.abs(record.longitude) > 180 ||
|
||||
(record.directionDeg != null &&
|
||||
(!Number.isFinite(record.directionDeg) ||
|
||||
record.directionDeg < 0 ||
|
||||
record.directionDeg >= 360))
|
||||
) {
|
||||
throw new TypeError('Camera source returned an invalid record');
|
||||
}
|
||||
ids.add(record.id);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Build a linked attribution from plain text and an HTTPS URL only. */
|
||||
export function alprCreditMarkup(attribution) {
|
||||
if (!attribution?.text || !attribution?.href) return null;
|
||||
|
||||
@@ -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_LIMIT,
|
||||
} from './policy.js';
|
||||
import { buildOverpassQuery, normalizeAlprNode } from './model.js';
|
||||
import { buildOverpassQuery, normalizeAlprNode } from './records.js';
|
||||
/** Construct the bounded OSM request adapter without starting a request. */
|
||||
export function createOverpassAlprSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
@@ -80,3 +80,5 @@ export function createOverpassAlprSource({
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { buildOverpassQuery } from './records.js';
|
||||
@@ -1,3 +1,10 @@
|
||||
export {
|
||||
FRAME_ENDPOINT,
|
||||
SOURCE_ENDPOINT,
|
||||
HEALTH_ENDPOINT,
|
||||
MEDIA_ENDPOINT,
|
||||
ACTIVE_FRAME_REFRESH_MS,
|
||||
} from './sourcePolicy.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js';
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
@@ -6,14 +13,6 @@ import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
// API endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FRAME_ENDPOINT = '/api/cctv/frame';
|
||||
|
||||
export const SOURCE_ENDPOINT = '/api/cctv/sources';
|
||||
|
||||
export const HEALTH_ENDPOINT = '/api/cctv/health';
|
||||
|
||||
export const MEDIA_ENDPOINT = '/api/cctv/media';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timing and geometry constants
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -26,8 +25,6 @@ export const MAX_AUTO_HOP_SEC = 90;
|
||||
|
||||
export const HEALTH_SYNC_INTERVAL_MS = 7000;
|
||||
|
||||
export const ACTIVE_FRAME_REFRESH_MS = 10000;
|
||||
|
||||
export const IDLE_FRAME_REFRESH_MS = 60000;
|
||||
|
||||
export const PROJECTION_ACTIVE_REFRESH_MS = 10000;
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
ACTIVE_FRAME_REFRESH_MS,
|
||||
FRAME_ENDPOINT,
|
||||
MEDIA_ENDPOINT,
|
||||
} from './policy.js';
|
||||
} from './sourcePolicy.js';
|
||||
function safeNumber(value, fallback = NaN) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
|
||||
@@ -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 function normalizeEarthquakeSnapshot(geojson) {
|
||||
if (!Array.isArray(geojson?.features)) return null;
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
for (const [index, feature] of geojson.features.entries()) {
|
||||
const coordinates = feature?.geometry?.coordinates;
|
||||
const properties = feature?.properties;
|
||||
if (
|
||||
!Array.isArray(coordinates) ||
|
||||
coordinates.length < 2 ||
|
||||
!properties ||
|
||||
typeof properties !== 'object' ||
|
||||
Array.isArray(properties) ||
|
||||
(feature.geometry.type != null && feature.geometry.type !== 'Point')
|
||||
)
|
||||
return null;
|
||||
const [lon, lat, depthKm] = coordinates;
|
||||
const mag = properties.mag;
|
||||
if (
|
||||
!Number.isFinite(lon) ||
|
||||
Math.abs(lon) > 180 ||
|
||||
!Number.isFinite(lat) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
(depthKm != null && !Number.isFinite(depthKm)) ||
|
||||
(mag != null && (!Number.isFinite(mag) || mag > 10))
|
||||
)
|
||||
return null;
|
||||
// A missing magnitude cannot establish that this event meets M2.5+.
|
||||
if (mag == null || mag < 2.5) continue;
|
||||
const stableId =
|
||||
feature.id == null || feature.id === ''
|
||||
? `event-${index + 1}`
|
||||
: String(feature.id);
|
||||
if (ids.has(stableId)) return null;
|
||||
ids.add(stableId);
|
||||
rows.push({
|
||||
stableId,
|
||||
usgsId: feature.id ?? null,
|
||||
lon,
|
||||
lat,
|
||||
depthKm: depthKm ?? null,
|
||||
mag,
|
||||
place: typeof properties.place === 'string' ? properties.place : null,
|
||||
time: Number.isFinite(properties.time) ? properties.time : null,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
export { normalizeEarthquakeSnapshot } from './records.js';
|
||||
@@ -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 =
|
||||
'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. */
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import {
|
||||
LOCAL_OVERLAY_COHORT_LIMIT,
|
||||
createLocalInfrastructureOverlayEntry,
|
||||
} from '../data/localGeojson.js';
|
||||
} from '../data/localGeojsonCore.js';
|
||||
import {
|
||||
FIRMS_AMBIENT_COHORT_LIMIT,
|
||||
FIRMS_OVERLAY_SOURCE_ID,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSourceSlot } from '../app/sourceSlot.js';
|
||||
import { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
import { createApplicationRequestServices } from './requests.js';
|
||||
|
||||
// Compatibility owners are page-scoped, like the viewer and its layer registry.
|
||||
|
||||
@@ -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 { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
const FEED_STATE_LABELS = Object.freeze({
|
||||
@@ -29,59 +31,6 @@ export function layerKeyRequirementTooltip(layer = {}) {
|
||||
return requiresKeyId ? keySetupRequirement(requiresKeyId) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize heterogeneous layer stats into one honest control-chip state.
|
||||
* @param {object|null} stats Layer getStats() result.
|
||||
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state.
|
||||
*/
|
||||
export function layerFeedState(stats = {}) {
|
||||
const state = stats || {};
|
||||
const status =
|
||||
typeof state.status === 'string' ? state.status.toLowerCase() : '';
|
||||
const source = `${state.source || ''} ${state.coverage || ''}`;
|
||||
const hasExplicitFallback = typeof state.fallback === 'boolean';
|
||||
const hasPriorData = Number(state.count) > 0 || Boolean(state.lastUpdate);
|
||||
const presentedError =
|
||||
state.error || state.lastError || state.managerRefreshError;
|
||||
if (['unavailable', 'offline', 'down', 'error'].includes(status))
|
||||
return 'unavailable';
|
||||
if (
|
||||
(presentedError ||
|
||||
state.unavailable === true ||
|
||||
state.available === false) &&
|
||||
!hasPriorData &&
|
||||
!GUIDANCE_STATUSES.includes(status)
|
||||
) {
|
||||
return 'unavailable';
|
||||
}
|
||||
if (state.loading) return 'loading';
|
||||
// Guidance states ask the user to act (zoom in, run a search) — normal
|
||||
// operation, not feed faults. One honesty carve-out: layers keep their
|
||||
// rendered records through the guidance state, so a genuinely stale cache
|
||||
// still reads STALE; a guidance prompt alone never reads DEGRADED.
|
||||
if (GUIDANCE_STATUSES.includes(status)) {
|
||||
return state.stale ? 'stale' : 'nominal';
|
||||
}
|
||||
if (
|
||||
state.fallback === true ||
|
||||
status === 'fallback' ||
|
||||
state.mode === 'sim' ||
|
||||
/\bfallback\b/i.test(source) ||
|
||||
(!hasExplicitFallback && /\badsb\.lol\b/i.test(source))
|
||||
) {
|
||||
return 'fallback';
|
||||
}
|
||||
if (state.stale || status === 'stale') return 'stale';
|
||||
if (
|
||||
state.degraded ||
|
||||
presentedError ||
|
||||
state.unavailable === true ||
|
||||
state.available === false
|
||||
)
|
||||
return 'degraded';
|
||||
return 'nominal';
|
||||
}
|
||||
|
||||
/** Layer row presentation over supplied state and actions; no layer imports. */
|
||||
export class LayerPanel {
|
||||
constructor({
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js';
|
||||
import { contextModeWord } from '../contextModePolicy.js';
|
||||
import { createAnalystEngine } from '../data/analystEngine.js';
|
||||
import { layerFeedState } from '../data/manager.js';
|
||||
import { layerFeedState } from '../data/feedState.js';
|
||||
import { initCameraVerbs, moveCamera, flyRoute, interruptCameraMotion, adjustOrbitRange } from '../cameraVerbs.js';
|
||||
import * as defaultFloorServices from '../data/groundFloor.js';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
|
||||
Reference in new issue
Block a user