mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
refactor: separate layer lifecycle from presentation
This commit is contained in:
1 parent
d2c9014c2d
commit
2d68841783
14 files changed
+2547
-2044
No files matched your search
+7
-4
@@ -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. */
|
||||
export function createApplicationData({
|
||||
scene: { viewer },
|
||||
@@ -9,7 +10,7 @@ export function createApplicationData({
|
||||
defer,
|
||||
}) {
|
||||
// Initialize data layer manager
|
||||
const dataManager = new DataLayerManager(viewer, {
|
||||
const dataManager = new LayerLifecycle(viewer, {
|
||||
allowQaRegistration,
|
||||
});
|
||||
defer(async () => {
|
||||
@@ -19,6 +20,8 @@ export function createApplicationData({
|
||||
`Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`,
|
||||
);
|
||||
});
|
||||
const presentation = new LayerPresentation(dataManager);
|
||||
defer(() => presentation.destroy());
|
||||
onData?.(dataManager);
|
||||
if (!catalog?.layers || !catalog?.metadata)
|
||||
throw new TypeError('An application layer catalog is required');
|
||||
@@ -46,8 +49,8 @@ export function createApplicationData({
|
||||
delete window.__gevQaUnregisterLayer;
|
||||
});
|
||||
}
|
||||
dataManager.buildTogglePanel(document.getElementById('data-toggles'));
|
||||
presentation.mount(document.getElementById('data-toggles'));
|
||||
styleManager.attachDataManager(dataManager);
|
||||
|
||||
return { dataManager, catalog };
|
||||
return { dataManager, catalog, presentation };
|
||||
}
|
||||
@@ -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
@@ -69,10 +69,7 @@ export function createApplicationTools({
|
||||
viewer.useDefaultRenderLoop = !hidden;
|
||||
cockpitCloudEffects?.setSuspended?.(hidden);
|
||||
if (!hidden) {
|
||||
if (dataManager._panelRefreshPendingOnVisible) {
|
||||
dataManager._panelRefreshPendingOnVisible = false;
|
||||
dataManager._refreshTogglePanel();
|
||||
}
|
||||
data.presentation.flushVisible();
|
||||
governorRequestRender('visibility-restore');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 css = readStylesheet(path.join(ROOT, 'style.css'));
|
||||
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 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/);
|
||||
const panel = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'layerPanel.js'), 'utf8');
|
||||
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-flights-btn"/);
|
||||
assert.match(html, /id="global-context-missions-btn"/);
|
||||
|
||||
@@ -359,14 +359,38 @@ test('a layer whose detectable set changed dirties the solve', async () => {
|
||||
assert.match(hook, /if \(_mode === MODE_OFF\) return;/,
|
||||
'and it stays inert while detection is off');
|
||||
|
||||
const manager = await readFile(new URL('./manager.js', import.meta.url), 'utf8');
|
||||
assert.match(manager, /import \{ markDetectionSourcesChanged \} from '\.\/detection\.js';/);
|
||||
// Both discrete events that can change the detectable set, next to the render
|
||||
// request each already made.
|
||||
assert.match(manager, /governorRequestRender\(`layer-tick:\$\{layerId\}`\);[\s\S]{0,700}?markDetectionSourcesChanged\(`layer-tick:\$\{layerId\}`\);/,
|
||||
'a poll tick marks the solve dirty alongside its render request');
|
||||
assert.match(manager, /governorRequestRender\('layer-visibility'\);[\s\S]{0,400}?markDetectionSourcesChanged\('layer-visibility'\);/,
|
||||
'so does a layer appearing or disappearing');
|
||||
const { LayerLifecycle } = await import('./lifecycle.js');
|
||||
const { LayerPresentation } = await import('../app/layerPresentation.js');
|
||||
const manager = new LayerLifecycle({});
|
||||
const reactions = [];
|
||||
const presentation = new LayerPresentation(manager, {
|
||||
requestRender: (reason) => reactions.push(['render', reason]),
|
||||
invalidateDetection: (reason) => reactions.push(['detection', reason]),
|
||||
});
|
||||
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 () => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+25
-1995
File diff suppressed because it is too large.
Load diff
@@ -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 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/);
|
||||
});
|
||||
Reference in new issue
Block a user