mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-03-23 23:57:23 +01:00
feat: implement WebGL2 layer framework with core functionalities including init, resize observation, and D3 zoom subscription
This commit is contained in:
parent
42b92d93b4
commit
769ef9eff0
7 changed files with 790 additions and 25 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue