mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
refactor: extract application construction and lifecycle
This commit is contained in:
1 parent
092f28e301
commit
4d37760314
36 files changed
+1363
-382
No files matched your search
+66
-16
@@ -296,6 +296,9 @@ export class SceneDirector {
|
||||
* @param {Object} dataManager - Manages data layer enable/disable and per-layer params
|
||||
*/
|
||||
constructor(viewer, styleManager, dataManager) {
|
||||
this._destroyed = false;
|
||||
this._pendingWork = new Set();
|
||||
this._uiRemovers = [];
|
||||
this.viewer = viewer;
|
||||
this.styleManager = styleManager;
|
||||
this.dataManager = dataManager;
|
||||
@@ -346,6 +349,40 @@ export class SceneDirector {
|
||||
this._initUI();
|
||||
}
|
||||
|
||||
_listen(target, type, listener) {
|
||||
if (!target) return;
|
||||
target.addEventListener(type, listener);
|
||||
this._uiRemovers.push(() => target.removeEventListener(type, listener));
|
||||
}
|
||||
|
||||
_trackWork(promise) {
|
||||
this._pendingWork ||= new Set();
|
||||
this._pendingWork.add(promise);
|
||||
const release = () => this._pendingWork.delete(promise);
|
||||
promise.then(release, release);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Stop playback and pending loads before releasing their viewer. */
|
||||
destroy() {
|
||||
if (this._destroyPromise) return this._destroyPromise;
|
||||
this._destroyed = true;
|
||||
this._destroyPromise = Promise.resolve().then(async () => {
|
||||
this.stopScene('Stopped');
|
||||
this._loadAbort?.abort();
|
||||
this._loadGeneration++;
|
||||
this.viewer.camera.cancelFlight();
|
||||
for (const remove of this._uiRemovers || []) remove();
|
||||
this._uiRemovers = [];
|
||||
if (this._sceneShotList) this._sceneShotList.textContent = '';
|
||||
clearTimeout(this._storageToastTimer);
|
||||
await Promise.allSettled(this._pendingWork || []);
|
||||
clearInterval(this._progressTimer);
|
||||
document.removeEventListener('keydown', this._onKeyDown);
|
||||
});
|
||||
return this._destroyPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and normalize the project from localStorage.
|
||||
* Returns the default recipe-based project on missing or corrupt data.
|
||||
@@ -402,46 +439,46 @@ export class SceneDirector {
|
||||
this._renderSceneSelect();
|
||||
this._renderShotList();
|
||||
|
||||
this._sceneSelect.addEventListener('change', () => {
|
||||
this._listen(this._sceneSelect, 'change', () => {
|
||||
this._selectedSceneId = this._sceneSelect.value;
|
||||
const scene = this._getSelectedScene();
|
||||
this._selectedShotId = scene?.shots[0]?.id || null;
|
||||
this._renderShotList();
|
||||
});
|
||||
|
||||
this._sceneNewBtn?.addEventListener('click', () => this._createScene());
|
||||
this._sceneDeleteBtn?.addEventListener('click', () => this._deleteSelectedScene());
|
||||
this._sceneCaptureBtn?.addEventListener('click', () => this.captureShot());
|
||||
this._sceneUpdateShotBtn?.addEventListener('click', () => this.updateSelectedShot());
|
||||
this._listen(this._sceneNewBtn, 'click', () => this._createScene());
|
||||
this._listen(this._sceneDeleteBtn, 'click', () => this._deleteSelectedScene());
|
||||
this._listen(this._sceneCaptureBtn, 'click', () => this.captureShot());
|
||||
this._listen(this._sceneUpdateShotBtn, 'click', () => this.updateSelectedShot());
|
||||
|
||||
this._sceneStartBtn?.addEventListener('click', () => {
|
||||
this._listen(this._sceneStartBtn, 'click', () => {
|
||||
this.startScene(this._selectedSceneId);
|
||||
});
|
||||
|
||||
this._sceneStopBtn?.addEventListener('click', () => {
|
||||
this._listen(this._sceneStopBtn, 'click', () => {
|
||||
this.stopScene('Stopped');
|
||||
});
|
||||
|
||||
this._sceneNextBtn?.addEventListener('click', () => {
|
||||
this._listen(this._sceneNextBtn, 'click', () => {
|
||||
this.runNextScene();
|
||||
});
|
||||
|
||||
this._sceneExportBtn?.addEventListener('click', () => {
|
||||
this._listen(this._sceneExportBtn, 'click', () => {
|
||||
this.exportProject();
|
||||
});
|
||||
|
||||
this._sceneImportBtn?.addEventListener('click', () => {
|
||||
this._listen(this._sceneImportBtn, 'click', () => {
|
||||
this._sceneImportFile?.click();
|
||||
});
|
||||
|
||||
this._sceneImportFile?.addEventListener('change', async () => {
|
||||
this._listen(this._sceneImportFile, 'change', async () => {
|
||||
const file = this._sceneImportFile?.files?.[0];
|
||||
if (!file) return;
|
||||
await this.importProjectFile(file);
|
||||
this._sceneImportFile.value = '';
|
||||
});
|
||||
|
||||
this._sceneDownloadBtn?.addEventListener('click', () => {
|
||||
this._listen(this._sceneDownloadBtn, 'click', () => {
|
||||
this.downloadLastRunMetadata();
|
||||
});
|
||||
|
||||
@@ -719,7 +756,12 @@ export class SceneDirector {
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.flyDuration=2.2] - Camera flight duration in seconds
|
||||
*/
|
||||
async loadShot(sceneId, shotId, { flyDuration = 2.2 } = {}) {
|
||||
loadShot(sceneId, shotId, options) {
|
||||
if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' });
|
||||
return this._trackWork(this._loadShot(sceneId, shotId, options));
|
||||
}
|
||||
|
||||
async _loadShot(sceneId, shotId, { flyDuration = 2.2 } = {}) {
|
||||
if (this._running) return;
|
||||
const { scene, shot } = this._getShot(sceneId, shotId);
|
||||
if (!scene || !shot) return;
|
||||
@@ -875,7 +917,12 @@ export class SceneDirector {
|
||||
* round-robining through the whole project (voice playback uses this).
|
||||
* @returns {Promise<{started: boolean, reason?: string, shots?: number}>}
|
||||
*/
|
||||
async startScene(sceneId, { single = false } = {}) {
|
||||
startScene(sceneId, options) {
|
||||
if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' });
|
||||
return this._trackWork(this._startScene(sceneId, options));
|
||||
}
|
||||
|
||||
async _startScene(sceneId, { single = false } = {}) {
|
||||
if (this._running) return { started: false, reason: 'already-running' };
|
||||
|
||||
const queue = this._buildPlaybackQueue(sceneId || this._selectedSceneId || this._project.scenes[0]?.id, { single });
|
||||
@@ -1007,7 +1054,7 @@ export class SceneDirector {
|
||||
* it without starting a full run. Used for manual step-through navigation.
|
||||
*/
|
||||
async runNextScene() {
|
||||
if (this._running) return;
|
||||
if (this._destroyed || this._running) return;
|
||||
|
||||
const queue = this._buildPlaybackQueue(this._selectedSceneId || this._project.scenes[0]?.id);
|
||||
if (!queue.length) return;
|
||||
@@ -1069,6 +1116,7 @@ export class SceneDirector {
|
||||
async importProjectFile(file) {
|
||||
try {
|
||||
const text = await file.text();
|
||||
if (this._destroyed) return;
|
||||
const parsed = JSON.parse(text);
|
||||
this._project = normalizeProject(parsed);
|
||||
this._selectedSceneId = this._project.scenes[0]?.id || null;
|
||||
@@ -1227,9 +1275,11 @@ export class SceneDirector {
|
||||
await new Promise((resolve) => {
|
||||
// Guard against double-resolve from both callback and timeout
|
||||
let done = false;
|
||||
let timer;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
|
||||
@@ -1247,7 +1297,7 @@ export class SceneDirector {
|
||||
});
|
||||
|
||||
// Safety timeout in case Cesium callbacks fail to fire
|
||||
setTimeout(finish, (duration + 0.6) * 1000);
|
||||
if (!done) timer = setTimeout(finish, (duration + 0.6) * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -623,3 +623,25 @@ test('a scene run supersedes a LOAD still suspended on its visual await', async
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('destroy drains cancelled LOAD work before its viewer can be discarded', async (t) => {
|
||||
const restore = installSceneRuntime();
|
||||
t.after(restore);
|
||||
let finishVisual;
|
||||
const dataManager = fakeDataManager();
|
||||
const viewer = { camera: { cancelFlight() {} } };
|
||||
const director = new SceneDirector(viewer, {
|
||||
applyVisualState: () => new Promise((resolve) => { finishVisual = resolve; }),
|
||||
}, dataManager);
|
||||
const loading = director.loadShot('scene-1', 'shot-a');
|
||||
let destroyed = false;
|
||||
const stopping = director.destroy().then(() => { destroyed = true; });
|
||||
await Promise.resolve();
|
||||
assert.equal(destroyed, false);
|
||||
finishVisual();
|
||||
await Promise.all([loading, stopping]);
|
||||
assert.equal(destroyed, true);
|
||||
assert.equal(dataManager.setEnabledCalls.length, 0);
|
||||
assert.equal((await director.startScene('scene-1')).reason, 'destroyed');
|
||||
await director.destroy();
|
||||
});
|
||||
Reference in new issue
Block a user