16 KiB
Story 1.3: Layer Lifecycle — Register, Visibility, Render Loop
Status: done Epic: 1 — WebGL Layer Framework Module Story Key: 1-3-layer-lifecycle-register-visibility-render-loop Created: 2026-03-12 Developer: Amelia (Dev Agent)
Story
As a developer,
I want register(), unregister(), setVisible(), clearLayer(), requestRender(), syncTransform(), and the private per-frame render() fully implemented,
So that multiple layers can be registered, rendered each frame, toggled visible/invisible, and cleaned up without GPU state loss.
Context
Prior Art (Stories 1.1 & 1.2 — Complete)
Stories 1.1 and 1.2 delivered the complete scaffold in src/modules/webgl-layer-framework.ts:
- Pure exports:
buildCameraBounds,detectWebGL2,getLayerZIndex— fully implemented and tested init(): Fully implemented — DOM wrapping, canvas creation, Three.js renderer/scene/camera, ResizeObserver, D3 zoom subscription, pendingConfigs processingregister(): Fully implemented — queues pre-init, creates Group and registers post-initrequestRender(): Stub (callsthis.render()directly — no RAF coalescing yet)syncTransform(): Stub (no-op)setVisible(): Stub (no-op)clearLayer(): Stub (no-op)unregister(): Stub (no-op)render()private: Stub (no-op)- 21 tests passing; lint clean
Files to Modify
src/modules/webgl-layer-framework.ts— implement all stub methods listed abovesrc/modules/webgl-layer-framework.test.ts— add Story 1.3 tests (RAF coalescing, syncTransform, render order, setVisible, clearLayer, unregister)
Acceptance Criteria
AC1: register(config) before init()
→ config is queued in pendingConfigs[] and processed by init() without error (already implemented in Story 1.2; verify remains correct)
AC2: register(config) after init()
→ a THREE.Group with config.renderOrder is created, config.setup(group) is called once, the group is added to scene, and the registration is stored in layers: Map
AC3: setVisible('terrain', false)
→ layer.group.visible === false; config.dispose is NOT called (no GPU teardown, NFR-P6); canvas is hidden only if ALL layers are invisible
AC4: setVisible('terrain', true) after hiding
→ layer.group.visible === true; requestRender() is triggered; toggle completes in <4ms (NFR-P3)
AC5: clearLayer('terrain')
→ group.clear() is called (removes all Mesh children); layer registration in layers: Map remains intact; renderer.dispose() is NOT called
AC6: requestRender() called three times in rapid succession
→ only one requestAnimationFrame is scheduled (RAF coalescing confirmed); rafId is reset to null after the frame executes
AC7: render() private execution order
→ syncTransform() is called first; then each visible layer's config.render(group) callback is dispatched (invisible layer callbacks are skipped); then renderer.render(scene, camera) is called last
AC8: syncTransform() with globals viewX=0, viewY=0, scale=1, graphWidth=960, graphHeight=540
→ camera left/right/top/bottom match buildCameraBounds(0, 0, 1, 960, 540) exactly; camera.updateProjectionMatrix() is called
AC9: unregister('terrain')
→ config.dispose(group) is called; the id is removed from layers: Map; if the unregistered layer was the last one, canvas.style.display is set to "none"
AC10: Framework coverage ≥80% (NFR-M5)
→ npx vitest run --coverage src/modules/webgl-layer-framework.test.ts reports ≥80% statement coverage for webgl-layer-framework.ts
AC11: THREE.Group is the sole abstraction boundary (NFR-M1)
→ scene, renderer, and camera are never exposed to layer callbacks; all three callbacks receive only group: THREE.Group
Technical Notes
requestRender() — RAF Coalescing
Replace the direct this.render() call (Story 1.2 stub) with the RAF-coalesced pattern:
requestRender(): void {
if (this._fallback) return;
if (this.rafId !== null) return; // already scheduled — coalesce
this.rafId = requestAnimationFrame(() => {
this.rafId = null;
this.render();
});
}
Why coalescing matters: D3 zoom fires many events per second; ResizeObserver also calls requestRender(). Without coalescing, every event triggers a renderer.render() call. With coalescing, all calls within the same frame collapse to one GPU draw.
syncTransform() — D3 → Camera Sync
Reads window globals (viewX, viewY, scale, graphWidth, graphHeight) and applies buildCameraBounds() to the orthographic camera:
syncTransform(): void {
if (this._fallback || !this.camera) return;
const viewX = (globalThis as any).viewX ?? 0;
const viewY = (globalThis as any).viewY ?? 0;
const scale = (globalThis as any).scale ?? 1;
const graphWidth = (globalThis as any).graphWidth ?? 960;
const graphHeight = (globalThis as any).graphHeight ?? 540;
const bounds = buildCameraBounds(viewX, viewY, scale, graphWidth, graphHeight);
this.camera.left = bounds.left;
this.camera.right = bounds.right;
this.camera.top = bounds.top;
this.camera.bottom = bounds.bottom;
this.camera.updateProjectionMatrix();
}
Guard note: globalThis as any is required because viewX, viewY, scale, graphWidth, graphHeight are legacy window globals from the pre-TypeScript codebase. They are not typed. Use ?? 0 / ?? 1 / ?? 960 / ?? 540 defaults so tests can run in Node without setting them.
render() — Per-Frame Dispatch
private render(): void {
if (this._fallback || !this.renderer || !this.scene || !this.camera) return;
this.syncTransform();
for (const layer of this.layers.values()) {
if (layer.group.visible) {
layer.config.render(layer.group);
}
}
this.renderer.render(this.scene, this.camera);
}
Order is enforced: syncTransform → per-layer render callbacks → renderer.render. Never swap.
setVisible() — GPU-Preserving Toggle
setVisible(id: string, visible: boolean): void {
if (this._fallback) return;
const layer = this.layers.get(id);
if (!layer) return;
layer.group.visible = visible;
const anyVisible = [...this.layers.values()].some(l => l.group.visible);
if (this.canvas) this.canvas.style.display = anyVisible ? "block" : "none";
if (visible) this.requestRender();
}
Critical: config.dispose must NOT be called here. No GPU teardown. Only group.visible is toggled (Three.js skips invisible objects in draw dispatch automatically).
clearLayer() — Wipe Geometry, Preserve Registration
clearLayer(id: string): void {
if (this._fallback) return;
const layer = this.layers.get(id);
if (!layer) return;
layer.group.clear(); // removes all Mesh children; Three.js Group.clear() does NOT dispose GPU memory
}
Note: group.clear() does NOT call .dispose() on children. Story 2.x's undrawRelief calls this to empty geometry without GPU teardown — preserving VBO/texture memory per NFR-P6.
unregister() — Full Cleanup
unregister(id: string): void {
if (this._fallback) return;
const layer = this.layers.get(id);
if (!layer || !this.scene) return;
layer.config.dispose(layer.group); // caller disposes GPU memory (geometry, material, texture)
this.scene.remove(layer.group);
this.layers.delete(id);
const anyVisible = [...this.layers.values()].some(l => l.group.visible);
if (this.canvas && !anyVisible) this.canvas.style.display = "none";
}
Removing biome-ignore Comments (T7)
Story 1.2 retained biome-ignore lint/correctness/noUnusedPrivateClassMembers on camera and rafId. Both are now fully used in this story:
camerais read insyncTransform()andrender()rafIdis read and written inrequestRender()
Remove both biome-ignore comments as part of this story.
Test Strategy — Story 1.3 Tests
All new tests inject stub state onto private fields (same pattern as Stories 1.1 and 1.2). No real WebGL context needed.
RAF coalescing test: vi.spyOn(globalThis, "requestAnimationFrame").mockReturnValue(1 as any) to assert it is called only once for three rapid requestRender() calls.
syncTransform test: Stub camera with a plain object; set globalThis.viewX = 0 etc. via vi.stubGlobal(); call syncTransform(); assert camera bounds match buildCameraBounds(0,0,1,960,540).
render() order test: Spy on syncTransform, a layer's render callback, and renderer.render. Assert call order.
setVisible test: Already partially covered in Story 1.1; Story 1.3 adds the "canvas hidden when ALL invisible" edge case and the "requestRender triggered on show" case.
unregister test: Verify dispose() called, layer removed from Map, scene.remove() called.
Tasks
-
T1: Implement
requestRender()with RAF coalescing (replace Story 1.2 stub)- T1a: Guard on
_fallback - T1b: Early return if
rafId !== null - T1c:
requestAnimationFramecall storing ID inrafId; reset tonullin callback before callingrender()
- T1a: Guard on
-
T2: Implement
syncTransform()reading window globals- T2a: Guard on
_fallbackand!this.camera - T2b: Read
globalThis.viewX/viewY/scale/graphWidth/graphHeightwith?? defaults - T2c: Call
buildCameraBounds()and write all four camera bounds - T2d: Call
this.camera.updateProjectionMatrix()
- T2a: Guard on
-
T3: Implement private
render()with ordered dispatch- T3a: Guard on
_fallback,!this.renderer,!this.scene,!this.camera - T3b: Call
this.syncTransform() - T3c: Loop
this.layers.values()dispatchinglayer.config.render(group)for visible layers only - T3d: Call
this.renderer.render(this.scene, this.camera)(via local const captures for TypeScript type safety)
- T3a: Guard on
-
T4: Implement
setVisible(id, visible)- T4a: Guard on
_fallback - T4b: Toggle
layer.group.visible - T4c: Check if ANY layer is still visible; update
canvas.style.display - T4d: Call
requestRender()whenvisible === true
- T4a: Guard on
-
T5: Implement
clearLayer(id)- T5a: Guard on
_fallback - T5b: Call
layer.group.clear()— do NOT callrenderer.dispose()
- T5a: Guard on
-
T6: Implement
unregister(id)- T6a: Guard on
_fallback - T6b: Call
layer.config.dispose(layer.group) - T6c: Call
scene.remove(layer.group)(via local const capture) - T6d: Delete from
this.layers - T6e: Update canvas display if no layers remain visible
- T6a: Guard on
-
T7: Remove remaining
biome-ignore lint/correctness/noUnusedPrivateClassMemberscomments fromcameraandrafIdfields -
T8: Add Story 1.3 tests to
webgl-layer-framework.test.ts:- T8a:
requestRender()— RAF coalescing: 3 calls → only 1requestAnimationFrame() - T8b:
requestRender()—rafIdresets tonullafter frame executes - T8c:
syncTransform()— camera bounds matchbuildCameraBounds(0,0,1,960,540) - T8d:
syncTransform()— uses?? defaultswhen globals absent - T8e:
render()—syncTransform()called before layer callbacks,renderer.render()called last - T8f:
render()— invisible layer'sconfig.render()NOT called - T8g:
setVisible(false)—group.visible = false;disposeNOT called (NFR-P6) - T8h:
setVisible(false)for ALL layers — canvasdisplay = "none" - T8i:
setVisible(true)—requestRender()triggered - T8j:
clearLayer()—group.clear()called; layer remains inlayersMap - T8k:
clearLayer()—renderer.dispose()NOT called (NFR-P6) - T8l:
unregister()—dispose()called;scene.remove()called; id removed from Map - T8m:
unregister()last layer — canvasdisplay = "none" - Also updated existing Story 1.1 test
requestRender() does not throwto stub RAF globally
- T8a:
-
T9:
npm run lint— zero errors -
T10:
npx vitest run src/modules/webgl-layer-framework.test.ts— all 34 tests pass; statement coverage 85.13% ≥ 80% (NFR-M5 ✓) -
T11: Set story status to
review
Dev Notes
Globals Referenced
| Global | Type | Default in tests | Source |
|---|---|---|---|
viewX |
number |
0 |
D3 zoom transform X translate |
viewY |
number |
0 |
D3 zoom transform Y translate |
scale |
number |
1 |
D3 zoom scale |
graphWidth |
number |
960 |
Map canvas logical width |
graphHeight |
number |
540 |
Map canvas logical height |
All accessed via (globalThis as any).NAME ?? default — never destructure or assume presence (guard for Node test env).
What Story 1.3 Does NOT Cover
draw-relief-icons.tsrefactor → Story 2.2- Performance benchmarking → Story 3.1
- E2E / browser tests → out of scope for Epic 1
Coverage Target
NFR-M5 requires ≥80% statement coverage. After Story 1.3, all public methods and critical private paths are exercised. The remaining uncovered lines should be limited to edge cases in platform-specific paths (ResizeObserver callbacks, WebGL context loss handlers).
Dev Agent Record
Implementation Notes
render()TypeScript type safety: Used local const captures (const renderer = this.renderer; const scene = this.scene; const camera = this.camera;) immediately after the null-guard, before callingthis.syncTransform(). This is required because TypeScript re-widens class instance field types after any method call — local consts preserve the narrowed (non-null) types for the finalrenderer.render(scene, camera)call.unregister()local capture: Same pattern used forscene— captured beforelayer.config.dispose()call to preserve TypeScript narrowing.syncTransform()local capture:const camera = this.camera;captured after guard, before variable assignments. No function calls between guard and camera use, so TypeScript narrows correctly; the capture is an additional safety measure.- Existing test update (T8 extra): The Story 1.1 test
requestRender() does not throw when called multiple timeswas updated to addvi.stubGlobal("requestAnimationFrame", vi.fn().mockReturnValue(0))since the stub method previously directly calledrender()(a no-op), but the real implementation now callsrequestAnimationFramewhich is absent in the Node.js test environment. - Uncovered lines (15%): Line 88 (
|| 960fallback ininit()clientWidth branch) and lines 256/262-265 (ResizeObserver callback body). Both require real DOM resize events — not testable in Node unit tests. These represent expected coverage gaps acceptable per NFR-M5.
Files Modified
src/modules/webgl-layer-framework.ts— implementedrequestRender(),syncTransform(),render(),setVisible(),clearLayer(),unregister(); removed 2biome-ignorecomments (camera,rafId)src/modules/webgl-layer-framework.test.ts— updated 1 existing test (RAF stub); added new describe blockWebGL2LayerFrameworkClass — lifecycle & render loop (Story 1.3)with 13 tests
Test Results
✓ modules/webgl-layer-framework.test.ts (34 tests) 9ms
✓ buildCameraBounds (5)
✓ detectWebGL2 (3)
✓ getLayerZIndex (1)
✓ WebGL2LayerFrameworkClass (7)
✓ WebGL2LayerFrameworkClass — init() (5)
✓ WebGL2LayerFrameworkClass — lifecycle & render loop (Story 1.3) (13)
Test Files 1 passed (1) | Tests 34 passed (34)
Coverage (v8):
webgl-layer-framework.ts | 85.13% Stmts | 70.73% Branch | 84.21% Funcs | 91.26% Lines
NFR-M5 (≥80% statement coverage): ✓ PASS
npm run lint: Checked 80 files — no fixes applied.