Merge pull request #495 from bilawalsidhu/refactor/layer-lifecycle-presentation

refactor: separate layer lifecycle from presentation
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-14 15:10:31 -07:00
commit ddc1edf3af
14 files changed
+2545 -2042

No files matched your search

+2
View File
@@ -1,5 +1,7 @@
# Changelog # Changelog
- Separate layer lifecycle transactions from panel construction and render/detection reactions; retain existing transition and refresh behavior.
- Let CLI tools, development launchers and the setup doctor use an explicit project directory while retaining their existing default paths. - Let CLI tools, development launchers and the setup doctor use an explicit project directory while retaining their existing default paths.
- Split application scene, controls, catalog, tools and HTML into reusable components; configure application request services and sources without changing global fetch. Preserve standalone markup and voice behavior. Explicit annotation navigation may resolve a distant named target. - Split application scene, controls, catalog, tools and HTML into reusable components; configure application request services and sources without changing global fetch. Preserve standalone markup and voice behavior. Explicit annotation navigation may resolve a distant named target.
+13
View File
@@ -448,3 +448,16 @@ location framing use the supplied operations. Voice shares the same boundary and
floor services, with analyst memory scoped to the runner. Direct compatibility floor services, with analyst memory scoped to the runner. Direct compatibility
entrypoints retain default services; normal assembly does not configure their entrypoints retain default services; normal assembly does not configure their
source slots. source slots.
## Layer lifecycle and presentation
`data/lifecycle` owns registrations, visibility intent, refresh transactions,
parameters and teardown. Its package group contains one module and no external
imports. Adding a panel, renderer or application dependency fails the boundary
build, including unused imports.
`app/layerPresentation` mounts the toggle panel and turns lifecycle activity into
render requests and detection invalidation. It owns hidden-panel refresh and
listener cleanup. Application data assembly constructs both owners explicitly;
`data/manager` is the compatibility facade for direct callers. The ordinary
state subscriptions retain their existing event contract.
+9
View File
@@ -1,5 +1,14 @@
# God's Eye View Current State # God's Eye View Current State
Layer lifecycle transactions are owned by `data/lifecycle`, which has no panel,
render-governor or detection imports. Application composition mounts a separate
layer presentation owner that reacts to status, accepted visibility/parameter
changes and non-throwing updates. Rejected partial updates still invalidate the
rendered detection set. The legacy manager retains panel helper compatibility;
registration sealing, restoration order and failed-transition rollback remain
unchanged.
Application scene setup owns request services, terrain/floor caches and annotation Application scene setup owns request services, terrain/floor caches and annotation
lookup state. Controls, layers and voice share those owners, and cancellation lookup state. Controls, layers and voice share those owners, and cancellation
clears caches and removes map-stack listeners. Standalone setup supplies the clears caches and removes map-stack listeners. Standalone setup supplies the
+2 -1
View File
@@ -195,6 +195,7 @@
"./application/catalog": "./src/app/catalog.js", "./application/catalog": "./src/app/catalog.js",
"./standalone/catalog": "./src/standalone/catalog.js", "./standalone/catalog": "./src/standalone/catalog.js",
"./application/layers": "./src/app/constructCatalog.js", "./application/layers": "./src/app/constructCatalog.js",
"./application/operations": "./src/app/operations.js" "./application/operations": "./src/app/operations.js",
"./data/lifecycle": "./src/data/lifecycle.js"
} }
} }
+4 -1
View File
@@ -586,5 +586,8 @@
"src/services/groundFloor.js", "src/services/groundFloor.js",
"src/services/meshFloorSampler.js", "src/services/meshFloorSampler.js",
"src/services/groundSnap.js", "src/services/groundSnap.js",
"src/app/operations.test.mjs" "src/app/operations.test.mjs",
"src/data/lifecycle.js",
"src/data/manager.js",
"src/app/layerPresentation.js"
] ]
+36 -29
View File
@@ -1051,16 +1051,38 @@
"src/annotations/annotationResolver.js", "src/annotations/annotationResolver.js",
"src/annotations/hybridAnnotationRenderer.js", "src/annotations/hybridAnnotationRenderer.js",
"src/annotations/index.js", "src/annotations/index.js",
"src/annotations/resolver.js",
"src/annotations/screenAnnotationRenderer.js", "src/annotations/screenAnnotationRenderer.js",
"src/annotations/worldAnnotationRenderer.js", "src/annotations/worldAnnotationRenderer.js",
"src/app/catalog.js",
"src/app/constructCatalog.js",
"src/app/controls.js", "src/app/controls.js",
"src/app/data.js", "src/app/data.js",
"src/app/errors.js", "src/app/errors.js",
"src/app/layerPresentation.js",
"src/app/layers/aisLiveVessels.js",
"src/app/layers/alprCameras.js",
"src/app/layers/bikeshare.js",
"src/app/layers/cctv.js",
"src/app/layers/earthquakes.js",
"src/app/layers/firms.js",
"src/app/layers/flights.js",
"src/app/layers/militaryAwareness.js",
"src/app/layers/militaryFlights.js",
"src/app/layers/militaryInstallations.js",
"src/app/layers/overlayHost.js",
"src/app/layers/radio.js",
"src/app/layers/rocketLaunches.js",
"src/app/layers/satellites.js",
"src/app/layers/submarineCables.js",
"src/app/layers/traffic.js",
"src/app/operations.js",
"src/app/scene.js", "src/app/scene.js",
"src/app/sourceSlot.js", "src/app/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",
"src/app/surfaceServices.js",
"src/app/tools.js", "src/app/tools.js",
"src/app/viewer.js", "src/app/viewer.js",
"src/bloom.js", "src/bloom.js",
@@ -1120,6 +1142,7 @@
"src/data/issPass.js", "src/data/issPass.js",
"src/data/labelArbiter.js", "src/data/labelArbiter.js",
"src/data/layerState.js", "src/data/layerState.js",
"src/data/lifecycle.js",
"src/data/localGeojson.js", "src/data/localGeojson.js",
"src/data/localGeojsonCore.js", "src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js", "src/data/localGeojsonLod.js",
@@ -1152,6 +1175,7 @@
"src/data/satellites.js", "src/data/satellites.js",
"src/data/scenePick.js", "src/data/scenePick.js",
"src/data/spriteOrder.js", "src/data/spriteOrder.js",
"src/data/surfaceServices.js",
"src/data/telegeographySubmarineCables.js", "src/data/telegeographySubmarineCables.js",
"src/data/terrainHeights.js", "src/data/terrainHeights.js",
"src/data/tomtomTiles.js", "src/data/tomtomTiles.js",
@@ -1413,13 +1437,19 @@
"src/search/http.js", "src/search/http.js",
"src/search/placeSearch.js", "src/search/placeSearch.js",
"src/services/application.js", "src/services/application.js",
"src/services/groundFloor.js",
"src/services/groundSnap.js",
"src/services/meshFloorSampler.js",
"src/services/requests.js", "src/services/requests.js",
"src/services/terrainHeights.js",
"src/sharelink.js", "src/sharelink.js",
"src/sources/live/aircraft.js", "src/sources/live/aircraft.js",
"src/sources/live/contract.js", "src/sources/live/contract.js",
"src/sources/live/standalone.js", "src/sources/live/standalone.js",
"src/sources/live/vessels.js", "src/sources/live/vessels.js",
"src/splitFlap.js", "src/splitFlap.js",
"src/standalone/catalog.js",
"src/standalone/layerSources.js",
"src/styles/anime.js", "src/styles/anime.js",
"src/styles/noir.js", "src/styles/noir.js",
"src/styles/retro.js", "src/styles/retro.js",
@@ -1501,35 +1531,7 @@
"src/voice/realtimeController.js", "src/voice/realtimeController.js",
"src/voice/voiceCost.js", "src/voice/voiceCost.js",
"src/weatherEffectsMath.js", "src/weatherEffectsMath.js",
"src/worldFocus.js", "src/worldFocus.js"
"src/app/catalog.js",
"src/standalone/catalog.js",
"src/app/layers/militaryInstallations.js",
"src/app/layers/alprCameras.js",
"src/app/layers/traffic.js",
"src/app/layers/submarineCables.js",
"src/app/layers/militaryFlights.js",
"src/app/layers/bikeshare.js",
"src/app/layers/flights.js",
"src/app/layers/radio.js",
"src/app/layers/earthquakes.js",
"src/app/layers/cctv.js",
"src/app/layers/firms.js",
"src/app/layers/satellites.js",
"src/app/layers/militaryAwareness.js",
"src/app/layers/rocketLaunches.js",
"src/app/layers/overlayHost.js",
"src/app/layers/aisLiveVessels.js",
"src/app/constructCatalog.js",
"src/standalone/layerSources.js",
"src/app/surfaceServices.js",
"src/app/operations.js",
"src/data/surfaceServices.js",
"src/annotations/resolver.js",
"src/services/terrainHeights.js",
"src/services/groundFloor.js",
"src/services/meshFloorSampler.js",
"src/services/groundSnap.js"
], ],
"external": [ "external": [
"@mapbox/vector-tile", "@mapbox/vector-tile",
@@ -1904,5 +1906,10 @@
"pbf", "pbf",
"mgrs" "mgrs"
] ]
},
"layer-lifecycle": {
"exports": ["./data/lifecycle"],
"modules": ["src/data/lifecycle.js"],
"external": []
} }
} }
+7 -4
View File
@@ -1,4 +1,5 @@
import { DataLayerManager } from '../data/manager.js'; import { LayerLifecycle } from '../data/lifecycle.js';
import { LayerPresentation } from './layerPresentation.js';
/** Register the application layer catalog before allowing state restoration. */ /** Register the application layer catalog before allowing state restoration. */
export function createApplicationData({ export function createApplicationData({
scene: { viewer }, scene: { viewer },
@@ -9,7 +10,7 @@ export function createApplicationData({
defer, defer,
}) { }) {
// Initialize data layer manager // Initialize data layer manager
const dataManager = new DataLayerManager(viewer, { const dataManager = new LayerLifecycle(viewer, {
allowQaRegistration, allowQaRegistration,
}); });
defer(async () => { defer(async () => {
@@ -19,6 +20,8 @@ export function createApplicationData({
`Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`, `Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`,
); );
}); });
const presentation = new LayerPresentation(dataManager);
defer(() => presentation.destroy());
onData?.(dataManager); onData?.(dataManager);
if (!catalog?.layers || !catalog?.metadata) if (!catalog?.layers || !catalog?.metadata)
throw new TypeError('An application layer catalog is required'); throw new TypeError('An application layer catalog is required');
@@ -46,8 +49,8 @@ export function createApplicationData({
delete window.__gevQaUnregisterLayer; delete window.__gevQaUnregisterLayer;
}); });
} }
dataManager.buildTogglePanel(document.getElementById('data-toggles')); presentation.mount(document.getElementById('data-toggles'));
styleManager.attachDataManager(dataManager); styleManager.attachDataManager(dataManager);
return { dataManager, catalog }; return { dataManager, catalog, presentation };
} }
+85
View File
@@ -0,0 +1,85 @@
import { LayerPanel } from '../ui/layers.js';
import { governorRequestRender } from '../renderGovernor.js';
import { markDetectionSourcesChanged } from '../data/detection.js';
/** Own the layer panel and application reactions to lifecycle activity. */
export class LayerPresentation {
constructor(
manager,
{
requestRender = governorRequestRender,
invalidateDetection = markDetectionSourcesChanged,
} = {},
) {
this.manager = manager;
this._panel = null;
this.pendingVisible = false;
this._unsubscribe = manager.subscribeActivity((change) => {
if (change.type === 'status') this.refresh();
else if (change.type === 'destroy-all') this.destroy();
else {
const reason =
change.type === 'data-updated'
? `layer-tick:${change.layerId}`
: change.type === 'visibility-settled'
? 'layer-visibility'
: change.type === 'params-settled'
? `layer-params:${change.layerId}`
: null;
if (!reason) return;
requestRender(reason);
if (change.type !== 'params-settled') invalidateDetection(reason);
}
});
}
get panel() {
if (!this._panel)
this._panel = new LayerPanel({
getLayers: () => this.manager.getAll(),
isEnabled: (id) => this.manager.isEnabled(id),
setEnabled: (id, enabled, options) =>
this.manager.setEnabled(id, enabled, options),
setLayerParams: (id, params, options) =>
this.manager.setLayerParams(id, params, options),
getRowControls: (id) => {
const module = this.manager.layers.get(id)?.module;
try {
return module?.getRowControls?.() || null;
} catch (error) {
console.warn(`[Data] ${id} getRowControls error:`, error);
return null;
}
},
hasRowControls: (id) =>
typeof this.manager.layers.get(id)?.module?.getRowControls ===
'function',
subscribeRowControls: (id, listener) => {
const module = this.manager.layers.get(id)?.module;
module?.setRowControlsListener?.(listener);
return () => module?.setRowControlsListener?.(null);
},
onHiddenRefresh: () => {
this.pendingVisible = true;
},
});
return this._panel;
}
mount(container) {
this.panel.mount(container);
}
refresh() {
this._panel?._refreshTogglePanel();
}
flushVisible() {
if (!this.pendingVisible) return;
this.pendingVisible = false;
this.refresh();
}
destroy() {
this._panel?.destroy();
this._panel = null;
this.pendingVisible = false;
this._unsubscribe?.();
this._unsubscribe = null;
}
}
+1 -4
View File
@@ -69,10 +69,7 @@ export function createApplicationTools({
viewer.useDefaultRenderLoop = !hidden; viewer.useDefaultRenderLoop = !hidden;
cockpitCloudEffects?.setSuspended?.(hidden); cockpitCloudEffects?.setSuspended?.(hidden);
if (!hidden) { if (!hidden) {
if (dataManager._panelRefreshPendingOnVisible) { data.presentation.flushVisible();
dataManager._panelRefreshPendingOnVisible = false;
dataManager._refreshTogglePanel();
}
governorRequestRender('visibility-restore'); governorRequestRender('visibility-restore');
} }
}; };
+2 -2
View File
@@ -31,7 +31,7 @@ const html = expandApplicationHtml(fs.readFileSync(path.join(ROOT, 'index.html')
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8'); const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
const css = readStylesheet(path.join(ROOT, 'style.css')); const css = readStylesheet(path.join(ROOT, 'style.css'));
const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8'); const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8');
const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'manager.js'), 'utf8'); const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'lifecycle.js'), 'utf8');
const contextLayer = readLayerSource(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8'); const contextLayer = readLayerSource(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8');
const voiceActions = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8'); const voiceActions = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
@@ -764,7 +764,7 @@ test('Global Context uses its dedicated right rail without a duplicate Data Laye
assert.match(contextLayer, /id:\s*'military-awareness'[\s\S]*?showInTogglePanel:\s*false/); assert.match(contextLayer, /id:\s*'military-awareness'[\s\S]*?showInTogglePanel:\s*false/);
const panel = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'layerPanel.js'), 'utf8'); const panel = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'layerPanel.js'), 'utf8');
assert.match(panel, /if \(!layer\.showInTogglePanel\) continue;/); assert.match(panel, /if \(!layer\.showInTogglePanel\) continue;/);
assert.match(manager, /getLayers: \(\) => this\.getAll\(\)/); assert.match(fs.readFileSync(path.join(ROOT, 'src', 'app', 'layerPresentation.js'), 'utf8'), /getLayers: \(\) => this\.manager\.getAll\(\)/);
assert.match(html, /id="global-context-panel"/); assert.match(html, /id="global-context-panel"/);
assert.match(html, /id="global-context-flights-btn"/); assert.match(html, /id="global-context-flights-btn"/);
assert.match(html, /id="global-context-missions-btn"/); assert.match(html, /id="global-context-missions-btn"/);
+32 -8
View File
@@ -359,14 +359,38 @@ test('a layer whose detectable set changed dirties the solve', async () => {
assert.match(hook, /if \(_mode === MODE_OFF\) return;/, assert.match(hook, /if \(_mode === MODE_OFF\) return;/,
'and it stays inert while detection is off'); 'and it stays inert while detection is off');
const manager = await readFile(new URL('./manager.js', import.meta.url), 'utf8'); const { LayerLifecycle } = await import('./lifecycle.js');
assert.match(manager, /import \{ markDetectionSourcesChanged \} from '\.\/detection\.js';/); const { LayerPresentation } = await import('../app/layerPresentation.js');
// Both discrete events that can change the detectable set, next to the render const manager = new LayerLifecycle({});
// request each already made. const reactions = [];
assert.match(manager, /governorRequestRender\(`layer-tick:\$\{layerId\}`\);[\s\S]{0,700}?markDetectionSourcesChanged\(`layer-tick:\$\{layerId\}`\);/, const presentation = new LayerPresentation(manager, {
'a poll tick marks the solve dirty alongside its render request'); requestRender: (reason) => reactions.push(['render', reason]),
assert.match(manager, /governorRequestRender\('layer-visibility'\);[\s\S]{0,400}?markDetectionSourcesChanged\('layer-visibility'\);/, invalidateDetection: (reason) => reactions.push(['detection', reason]),
'so does a layer appearing or disappearing'); });
let result = true;
manager.register({ id: 'fixture', name: 'Fixture', updateInterval: 0,
init() {}, enable() {}, disable() {}, destroy() {},
update() { if (result instanceof Error) throw result; return result; },
getStats() { return { count: 1 }; },
});
await manager.setEnabled('fixture', true);
assert.deepEqual(reactions, [['render', 'layer-visibility'], ['detection', 'layer-visibility']]);
reactions.length = 0;
await manager.refreshLayer('fixture');
assert.deepEqual(reactions, [['render', 'layer-tick:fixture'], ['detection', 'layer-tick:fixture']]);
reactions.length = 0;
result = false;
await manager.refreshLayer('fixture');
assert.deepEqual(reactions, [['render', 'layer-tick:fixture'], ['detection', 'layer-tick:fixture']], 'partial/rejected updates still invalidate detection');
reactions.length = 0;
result = new Error('fixture update failure');
await manager.refreshLayer('fixture');
assert.deepEqual(reactions, [], 'throwing updates do not publish changed data');
result = true;
await manager.setEnabled('fixture', false);
assert.deepEqual(reactions, [['render', 'layer-visibility'], ['detection', 'layer-visibility']]);
presentation.destroy();
await manager.destroyAll();
}); });
test('the render-governor gate covers the parked case, with teeth on the painter', async () => { test('the render-governor gate covers the parked case, with teeth on the painter', async () => {
File diff suppressed because it is too large. Load diff
+23 -1993
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -107,3 +107,22 @@ test('a Node build export must be explicitly classified and scoped', async (t) =
await writeFile(path.join(root, 'startup.js'), 'export const app = 2;'); await writeFile(path.join(root, 'startup.js'), 'export const app = 2;');
await assert.rejects(checkPackageBoundaries(root), /unowned module.*startup/); await assert.rejects(checkPackageBoundaries(root), /unowned module.*startup/);
}); });
test('a lifecycle owner cannot acquire a layer panel even through an unused import', async (t) => {
const root = await fixture(
t,
"import './panel.js'; export class LayerLifecycle {}",
);
await writeFile(path.join(root, 'panel.js'), 'export class LayerPanel {}');
await writeFile(
path.join(root, 'scripts/package-boundaries.json'),
JSON.stringify({
'layer-lifecycle': {
exports: ['./feature'],
modules: ['entry.js'],
external: [],
},
}),
);
await assert.rejects(checkPackageBoundaries(root), /unowned module.*panel/);
});