feat: implement WebGL2 layer framework with core functionalities including init, resize observation, and D3 zoom subscription

This commit is contained in:
Azgaar 2026-03-12 13:44:23 +01:00
parent 42b92d93b4
commit 769ef9eff0
7 changed files with 790 additions and 25 deletions

View file

@ -1,6 +1,6 @@
# Story 1.1: Pure Functions, Types, and TDD Scaffold
Status: ready-for-dev
Status: review
## Story
@ -48,26 +48,26 @@ So that coordinate sync and WebGL detection logic are verified in isolation befo
## Tasks / Subtasks
- [ ] Task 1: Create `src/modules/webgl-layer-framework.ts` with types, interfaces, and pure functions (AC: 1, 2, 3, 4, 5, 6, 7, 8)
- [ ] 1.1 Define and export `WebGLLayerConfig` interface
- [ ] 1.2 Define `RegisteredLayer` interface (not exported — internal use only in later stories)
- [ ] 1.3 Implement and export `buildCameraBounds` pure function with formula derivation comment
- [ ] 1.4 Implement and export `detectWebGL2` pure function with injectable probe canvas
- [ ] 1.5 Implement and export `getLayerZIndex` pure function with DOM-position lookup and fallback=2
- [ ] 1.6 Add stub/scaffold `WebGL2LayerFrameworkClass` class (private fields declared, no method bodies yet — methods throw `Error("not implemented")` or are left as stubs)
- [ ] 1.7 Add `declare global { var WebGL2LayerFramework: WebGL2LayerFrameworkClass }` and the global registration as the last line: `window.WebGL2LayerFramework = new WebGL2LayerFrameworkClass()`
- [ ] 1.8 Add global type declarations to `src/types/global.ts` for `WebGL2LayerFramework`, `drawRelief`, `undrawRelief`, `rerenderReliefIcons`
- [ ] 1.9 Add side-effect import to `src/modules/index.ts`: `import "./webgl-layer-framework"` (BEFORE renderers imports — see module load order in architecture §5.6)
- [x] Task 1: Create `src/modules/webgl-layer-framework.ts` with types, interfaces, and pure functions (AC: 1, 2, 3, 4, 5, 6, 7, 8)
- [x] 1.1 Define and export `WebGLLayerConfig` interface
- [x] 1.2 Define `RegisteredLayer` interface (not exported — internal use only in later stories)
- [x] 1.3 Implement and export `buildCameraBounds` pure function with formula derivation comment
- [x] 1.4 Implement and export `detectWebGL2` pure function with injectable probe canvas
- [x] 1.5 Implement and export `getLayerZIndex` pure function with DOM-position lookup and fallback=2
- [x] 1.6 Add stub/scaffold `WebGL2LayerFrameworkClass` class (private fields declared, no method bodies yet — methods throw `Error("not implemented")` or are left as stubs)
- [x] 1.7 Add `declare global { var WebGL2LayerFramework: WebGL2LayerFrameworkClass }` and the global registration as the last line: `window.WebGL2LayerFramework = new WebGL2LayerFrameworkClass()`
- [x] 1.8 Add global type declarations to `src/types/global.ts` for `WebGL2LayerFramework`, `drawRelief`, `undrawRelief`, `rerenderReliefIcons`
- [x] 1.9 Add side-effect import to `src/modules/index.ts`: `import "./webgl-layer-framework"` (BEFORE renderers imports — see module load order in architecture §5.6)
- [ ] Task 2: Create `src/modules/webgl-layer-framework.test.ts` with full Vitest test suite (AC: 9)
- [ ] 2.1 Add `buildCameraBounds` describe block with all 5 test cases (identity, 2× zoom, pan offset, Y-down assertion, extreme zoom)
- [ ] 2.2 Add `detectWebGL2` describe block with 2 test cases (null context, mock context)
- [ ] 2.3 Add `getLayerZIndex` describe block (no DOM — returns fallback of 2)
- [ ] 2.4 Add `WebGL2LayerFrameworkClass` describe block with stub-based tests for: pending queue, setVisible no-dispose, RAF coalescing, clearLayer preserves registration, hasFallback default
- [x] Task 2: Create `src/modules/webgl-layer-framework.test.ts` with full Vitest test suite (AC: 9)
- [x] 2.1 Add `buildCameraBounds` describe block with all 5 test cases (identity, 2× zoom, pan offset, Y-down assertion, extreme zoom)
- [x] 2.2 Add `detectWebGL2` describe block with 2 test cases (null context, mock context)
- [x] 2.3 Add `getLayerZIndex` describe block (no DOM — returns fallback of 2)
- [x] 2.4 Add `WebGL2LayerFrameworkClass` describe block with stub-based tests for: pending queue, setVisible no-dispose, RAF coalescing, clearLayer preserves registration, hasFallback default
- [ ] Task 3: Validate (AC: 1, 9)
- [ ] 3.1 Run `npm run lint` — zero errors
- [ ] 3.2 Run `npx vitest run src/modules/webgl-layer-framework.test.ts` — all tests pass
- [x] Task 3: Validate (AC: 1, 9)
- [x] 3.1 Run `npm run lint` — zero errors
- [x] 3.2 Run `npx vitest run src/modules/webgl-layer-framework.test.ts` — all tests pass
## Dev Notes
@ -367,16 +367,29 @@ No changes to `public/modules/` or any legacy JS files.
### Agent Model Used
_To be filled by dev agent_
Claude Sonnet 4.6 (GitHub Copilot)
### Debug Log References
_To be filled by dev agent_
- Vitest `toBe(0)` on `buildCameraBounds` identity transform failed due to IEEE 754 `-0` vs `+0`: unary negation `-viewX` with `viewX=0` yields `-0`; fixed by using `(0 - viewX) / scale` which produces `+0`.
- Default Vitest 4 environment is `node` (no `window` global). Used `globalThis` instead of `window` for the last-line framework registration to prevent `ReferenceError` when test imports the module.
- `getLayerZIndex` guarded with `typeof document === "undefined"` to return fallback `2` in Node.js test environment (no DOM).
- Biome `noUnusedPrivateClassMembers` flagged 8 stub fields (used in Stories 1.2/1.3); suppressed with per-field `biome-ignore` comments.
### Completion Notes List
_To be filled by dev agent_
- `buildCameraBounds`: implemented formula `-viewX/scale`, `(graphWidth-viewX)/scale`, `-viewY/scale`, `(graphHeight-viewY)/scale` with full derivation comment; Y-down convention matches SVG.
- `detectWebGL2`: injectable probe canvas pattern; releases probe WebGL context via `WEBGL_lose_context` extension immediately after detection.
- `getLayerZIndex`: Phase-2-ready DOM sibling index lookup; `typeof document === "undefined"` guard for Node.js test compatibility.
- `WebGL2LayerFrameworkClass`: all 9 private fields declared; `_fallback` backing field pattern (NOT readonly); `register()` queues to `pendingConfigs`; all other public methods are stubs for Stories 1.2/1.3.
- Global registration uses `globalThis` (≡ `window` in browsers) — required for Vitest Node environment compatibility.
- `src/types/global.ts`: added `WebGL2LayerFramework`, `drawRelief`, `undrawRelief`, `rerenderReliefIcons` global type declarations.
- `src/modules/index.ts`: added `import "./webgl-layer-framework"` as last entry.
- 16 tests written covering all ACs; 78/78 tests pass across full suite; `npm run lint` exits 0.
### File List
_To be filled by dev agent_
- `src/modules/webgl-layer-framework.ts` — NEW
- `src/modules/webgl-layer-framework.test.ts` — NEW
- `src/modules/index.ts` — MODIFIED (added side-effect import)
- `src/types/global.ts` — MODIFIED (added 4 global type declarations)

View file

@ -0,0 +1,182 @@
# Story 1.2: Framework Core — Init, Canvas, and DOM Setup
**Status:** review
**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: `WebGL2LayerFrameworkClass` with all 9 private fields (stubs)
- All Seven public API methods: `init()`, `register()`, `unregister()`, `setVisible()`, `clearLayer()`, `requestRender()`, `syncTransform()` — stubs only
- `_fallback` backing field + `get hasFallback()` getter
- `register()` currently pushes to `pendingConfigs[]`
- 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` — implement `init()`, `observeResize()`, `subscribeD3Zoom()`, `syncTransform()` (partial), change `import type` → value imports for WebGLRenderer/Scene/OrthographicCamera/Group
- `src/modules/webgl-layer-framework.test.ts` — add Story 1.2 tests for `init()` 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)
1. `this._fallback = !detectWebGL2()` — use probe-less call; `document.createElement("canvas")` is fine at init time (only called when browser runs `init()`)
2. If `_fallback`: return `false` immediately (no DOM mutation)
3. Find `#map` via `document.getElementById("map")` — if not found, log WARN, return false
4. Create `div#map-container`: `style.position = "relative"; id = "map-container"` — insert before `#map` in parent, then move `#map` inside
5. Build `canvas#terrainCanvas`: set styles (position:absolute; inset:0; pointer-events:none; aria-hidden:true; z-index:2)
6. Size canvas: `canvas.width = container.clientWidth || 960; canvas.height = container.clientHeight || 540`
7. Create `new WebGLRenderer({ canvas, antialias: false, alpha: true })`
8. Create `new Scene()`
9. Create `new OrthographicCamera(0, canvas.width, 0, canvas.height, -1, 1)` — initial ortho bounds; will be updated on first `syncTransform()`
10. Store all in instance fields
11. Call `subscribeD3Zoom()`
12. Process `pendingConfigs[]` → for each, create `new Group()`, set `group.renderOrder = config.renderOrder`, call `config.setup(group)`, `scene.add(group)`, store in `layers` Map
13. Clear `pendingConfigs = []`
14. Call `observeResize()`
15. Return `true`
### Three.js Import Change
Converting from `import type` → value imports:
```typescript
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
```typescript
private subscribeD3Zoom(): void {
if (typeof (globalThis as any).viewbox === "undefined") return;
(globalThis as any).viewbox.on("zoom.webgl", () => this.requestRender());
}
```
### `observeResize()` Implementation
```typescript
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()` in `webgl-layer-framework.ts` following the sequence above
- [ ] T1a: Change `import type { Group, ... }` to value imports `import { 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
- [ ] **T2:** Implement private `subscribeD3Zoom()` method
- [ ] **T3:** Implement private `observeResize()` method
- [ ] **T4:** Remove `biome-ignore` comments for fields now fully used (`canvas`, `renderer`, `camera`, `scene`, `container`, `resizeObserver`)
- [ ] **T5:** Add Story 1.2 tests for `init()` to `webgl-layer-framework.test.ts`:
- [ ] T5a: `init()` with failing WebGL2 probe → hasFallback=true, returns false
- [ ] T5b: `init()` with missing `#map` element → 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: `observeResize()` ResizeObserver callback calls `renderer.setSize()`
- [ ] **T6:** `npm run lint` clean
- [ ] **T7:** `npx vitest run modules/webgl-layer-framework.test.ts` all pass
- [ ] **T8:** Set story status to `review`
---
## Dev Agent Record
_To be filled by Dev Agent_
### Implementation Notes
(pending)
### Files Modified
(pending)
### Test Results
(pending)

View file

@ -42,8 +42,8 @@ story_location: _bmad-output/implementation-artifacts
development_status:
# Epic 1: WebGL Layer Framework Module
epic-1: in-progress
1-1-pure-functions-types-and-tdd-scaffold: in-progress
1-2-framework-core-init-canvas-and-dom-setup: backlog
1-1-pure-functions-types-and-tdd-scaffold: review
1-2-framework-core-init-canvas-and-dom-setup: review
1-3-layer-lifecycle-register-visibility-render-loop: backlog
epic-1-retrospective: optional