9.9 KiB
Story 1.2: Framework Core — Init, Canvas, and DOM Setup
Status: done Epic: 1 — WebGL Layer Framework Module Story Key: 1-2-framework-core-init-canvas-and-dom-setup Created: (SM workflow) Developer: Amelia (Dev Agent)
Story
As a developer,
I want init() to set up the WebGL2 canvas, wrap svg#map in div#map-container, create the Three.js renderer/scene/camera, attach the ResizeObserver, and subscribe to D3 zoom events,
So that the framework owns the single shared WebGL context and the canvas is correctly positioned in the DOM alongside the SVG map.
Context
Prior Art (Story 1.1 — Complete)
Story 1.1 delivered the scaffold in src/modules/webgl-layer-framework.ts:
- Pure exports:
buildCameraBounds,detectWebGL2,getLayerZIndex - Interfaces:
WebGLLayerConfig(exported),RegisteredLayer(internal) - Class:
WebGL2LayerFrameworkClasswith all 9 private fields (stubs) - All Seven public API methods:
init(),register(),unregister(),setVisible(),clearLayer(),requestRender(),syncTransform()— stubs only _fallbackbacking field +get hasFallback()getterregister()currently pushes topendingConfigs[]- Global:
globalThis.WebGL2LayerFramework = new WebGL2LayerFrameworkClass()(last line) - 16 tests in
src/modules/webgl-layer-framework.test.ts— all passing
Files to Modify
src/modules/webgl-layer-framework.ts— implementinit(),observeResize(),subscribeD3Zoom(),syncTransform()(partial), changeimport type→ value imports for WebGLRenderer/Scene/OrthographicCamera/Groupsrc/modules/webgl-layer-framework.test.ts— add Story 1.2 tests forinit()paths
Acceptance Criteria
AC1: init() called + WebGL2 available
→ div#map-container wraps svg#map (position:relative, z-index:1 for svg), canvas#terrainCanvas is sibling to #map inside container (position:absolute; inset:0; pointer-events:none; aria-hidden:true; z-index:2)
AC2: detectWebGL2() returns false
→ init() returns false, hasFallback === true, all subsequent API calls are no-ops (guard on _fallback)
AC3: hasFallback uses backing field _fallback (NOT readonly) — already implemented in Story 1.1; verify pattern remains correct
AC4: After successful init()
→ exactly one WebGLRenderer, Scene, OrthographicCamera exist as instance fields (non-null)
AC5: ResizeObserver on #map-container
→ calls renderer.setSize(width, height) and requestRender() on resize events
AC6: D3 zoom subscription
→ viewbox.on("zoom.webgl", () => this.requestRender()) called in init(); guarded with typeof globalThis.viewbox !== "undefined" for Node test env
AC7: Constructor has no side effects
→ all of canvas/renderer/scene/camera/container are null after construction; only _fallback=false, layers=new Map(), pendingConfigs=[] are initialized
AC8: init() completes in <200ms (NFR-P5) — no explicit test; implementation must avoid blocking operations
AC9: Global pattern unchanged — globalThis.WebGL2LayerFramework = new WebGL2LayerFrameworkClass() remains as last line
Technical Notes
init() Sequence (step-by-step)
this._fallback = !detectWebGL2()— use probe-less call;document.createElement("canvas")is fine at init time (only called when browser runsinit())- If
_fallback: returnfalseimmediately (no DOM mutation) - Find
#mapviadocument.getElementById("map")— if not found, log WARN, return false - Create
div#map-container:style.position = "relative"; id = "map-container"— insert before#mapin parent, then move#mapinside - Build
canvas#terrainCanvas: set styles (position:absolute; inset:0; pointer-events:none; aria-hidden:true; z-index:2) - Size canvas:
canvas.width = container.clientWidth || 960; canvas.height = container.clientHeight || 540 - Create
new WebGLRenderer({ canvas, antialias: false, alpha: true }) - Create
new Scene() - Create
new OrthographicCamera(0, canvas.width, 0, canvas.height, -1, 1)— initial ortho bounds; will be updated on firstsyncTransform() - Store all in instance fields
- Call
subscribeD3Zoom() - Process
pendingConfigs[]→ for each, createnew Group(), setgroup.renderOrder = config.renderOrder, callconfig.setup(group),scene.add(group), store inlayersMap - Clear
pendingConfigs = [] - Call
observeResize() - Return
true
Three.js Import Change
Converting from import type → value imports:
import type {Group} from "three"; // Group stays type-only until Story 1.3 uses it at runtime
import {WebGLRenderer, Scene, OrthographicCamera} from "three";
// Note: Group is created in init() → must also be a value import in 1.2
→ Final: import { Group, WebGLRenderer, Scene, OrthographicCamera } from "three";
→ Remove import type line
subscribeD3Zoom() Implementation
private subscribeD3Zoom(): void {
if (typeof (globalThis as any).viewbox === "undefined") return;
(globalThis as any).viewbox.on("zoom.webgl", () => this.requestRender());
}
observeResize() Implementation
private observeResize(): void {
if (!this.container || !this.renderer) return;
this.resizeObserver = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
if (this.renderer && this.canvas) {
this.renderer.setSize(width, height);
this.requestRender();
}
});
this.resizeObserver.observe(this.container);
}
Fallback Guard Pattern
All public methods of the class must guard against _fallback (and against null init state). For Story 1.2, register() already works pre-init; init() has the primary guard. Story 1.3 lifecycle methods will add _fallback guards.
syncTransform() (Partial — Story 1.2)
Story 1.3 implements the full syncTransform(). Story 1.2 may leave stub. Story 1.3 reads globalThis.viewX, globalThis.viewY, globalThis.scale, globalThis.graphWidth, globalThis.graphHeight and calls buildCameraBounds().
requestRender() — Story 1.2 transition
Current stub in Story 1.1 calls this.render() directly. Story 1.2 still leaves requestRender() as-is (direct render call) since render() private impl is Story 1.3. Just remove the direct this.render() call from requestRender() stub or leave it — tests will tell us.
Actually, requestRender() stub currently calls this.render() which is also a stub (no-op). This is fine for Story 1.2. Story 1.3 will replace requestRender() with RAF-coalescing.
Tasks
- T1: Implement
init()inwebgl-layer-framework.tsfollowing the sequence above- T1a: Change
import type { Group, ... }to value importsimport { Group, WebGLRenderer, Scene, OrthographicCamera } from "three" - T1b:
detectWebGL2()fallback guard - T1c: DOM wrap (
#map→#map-container > #map + canvas#terrainCanvas) - T1d: Renderer/Scene/Camera creation
- T1e:
subscribeD3Zoom()call - T1f:
pendingConfigs[]queue processing - T1g:
observeResize()call
- T1a: Change
- T2: Implement private
subscribeD3Zoom()method - T3: Implement private
observeResize()method - T4: Remove
biome-ignorecomments for fields now fully used (canvas,renderer,scene,container,resizeObserver) —cameraandrafIdintentionally retain comments; both are assigned in this story but not read until Story 1.3 - T5: Add Story 1.2 tests for
init()towebgl-layer-framework.test.ts:- T5a:
init()with failing WebGL2 probe → hasFallback=true, returns false - T5b:
init()with missing#mapelement → returns false, no DOM mutation - T5c:
init()success: renderer/scene/camera all non-null after init - T5d:
init()success:pendingConfigs[]processed (setup called, layers Map populated) - T5e: ResizeObserver attached to container (non-null) on success — callback trigger verified implicitly via observeResize() implementation
- T5a:
- T6:
npm run lintclean - T7:
npx vitest run modules/webgl-layer-framework.test.tsall pass (21/21) - T8: Set story status to
review→ updated todoneafter SM review
Dev Agent Record
Implementation Notes
- AC1 deviation: AC1 specifies
z-index:1onsvg#map. The implementation does not set an explicitz-indexorpositionon the existing#mapSVG element. Natural DOM stacking provides correct visual order (SVG below canvas) consistent with architecture Decision 3 and the existing codebase behavior indraw-relief-icons.ts. Story 1.3 or a follow-up can formalize this if needed. - T4 deviation:
cameraandrafIdretainbiome-ignore lint/correctness/noUnusedPrivateClassMemberscomments. Both fields are assigned in this story but not read until Story 1.3'srender()andrequestRender()implementations. Removing the comments now would re-introduce lint errors. They will be removed as part of Story 1.3 T7. - T5e coverage: Test verifies
resizeObserver !== nullafter successfulinit(). The resize callback itself (renderer.setSize+requestRender) is covered by code inspection; an explicit callback invocation test would require a more complex ResizeObserver mock. Deferred to Story 1.3 integration coverage.
Files Modified
src/modules/webgl-layer-framework.ts— implementedinit(),subscribeD3Zoom(),observeResize(); changed Three.js imports fromimport typeto value importssrc/modules/webgl-layer-framework.test.ts— added 5 Story 1.2init()tests (total: 21 tests)
Test Results
✓ modules/webgl-layer-framework.test.ts (21 tests) 6ms
✓ buildCameraBounds (5)
✓ detectWebGL2 (3)
✓ getLayerZIndex (1)
✓ WebGL2LayerFrameworkClass (7)
✓ WebGL2LayerFrameworkClass — init() (5)
Test Files 1 passed (1) | Tests 21 passed (21)
npm run lint: Checked 80 files — no fixes applied.