From 42b92d93b44d4a472ebbe9b77bbb8da7abf42458 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Thu, 12 Mar 2026 05:15:54 +0100 Subject: [PATCH] feat: update sprint status to reflect in-progress development for WebGL Layer Framework module --- ...1-pure-functions-types-and-tdd-scaffold.md | 382 ++++++++++++++++++ .../sprint-status.yaml | 4 +- 2 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/1-1-pure-functions-types-and-tdd-scaffold.md diff --git a/_bmad-output/implementation-artifacts/1-1-pure-functions-types-and-tdd-scaffold.md b/_bmad-output/implementation-artifacts/1-1-pure-functions-types-and-tdd-scaffold.md new file mode 100644 index 00000000..a5b75b3f --- /dev/null +++ b/_bmad-output/implementation-artifacts/1-1-pure-functions-types-and-tdd-scaffold.md @@ -0,0 +1,382 @@ +# Story 1.1: Pure Functions, Types, and TDD Scaffold + +Status: ready-for-dev + +## Story + +As a developer, +I want `buildCameraBounds`, `detectWebGL2`, and `getLayerZIndex` implemented as named-exported pure functions with full Vitest coverage, +So that coordinate sync and WebGL detection logic are verified in isolation before the class is wired up. + +## Acceptance Criteria + +1. **Given** the file `src/modules/webgl-layer-framework.ts` does not yet exist + **When** the developer creates it with `WebGLLayerConfig` interface, `RegisteredLayer` interface, and the three pure exported functions + **Then** the file compiles with zero TypeScript errors and `npm run lint` passes + +2. **Given** `buildCameraBounds(viewX, viewY, scale, graphWidth, graphHeight)` is implemented + **When** called with identity transform `(0, 0, 1, 960, 540)` + **Then** it returns `{left: 0, right: 960, top: 0, bottom: 540}` and `top < bottom` (Y-down convention) + +3. **Given** `buildCameraBounds` is called with `(0, 0, 2, 960, 540)` (2× zoom) + **When** asserting bounds + **Then** `right === 480` and `bottom === 270` (viewport shows half the map) + +4. **Given** `buildCameraBounds` is called with `(-100, -50, 1, 960, 540)` (panned right/down) + **When** asserting bounds + **Then** `left === 100` and `top === 50` + +5. **Given** `buildCameraBounds` is called with extreme zoom values `(0.1)` and `(50)` + **When** asserting results + **Then** all returned values are finite (no `NaN` or `Infinity`) + +6. **Given** a mock canvas where `getContext('webgl2')` returns `null` + **When** `detectWebGL2(mockCanvas)` is called + **Then** it returns `false` + +7. **Given** a mock canvas where `getContext('webgl2')` returns a mock context object + **When** `detectWebGL2(mockCanvas)` is called + **Then** it returns `true` + +8. **Given** `getLayerZIndex('terrain')` is called + **When** the `#terrain` element is not present in the DOM + **Then** it returns `2` (safe fallback) + +9. **Given** a Vitest test file `src/modules/webgl-layer-framework.test.ts` exists + **When** `npx vitest run` is executed + **Then** all tests in this file pass and coverage for pure functions is 100% + +## 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) + +- [ ] 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 + +- [ ] 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 + +## Dev Notes + +### Scope for This Story + +**Story 1.1 covers only:** + +- The file scaffold (types, interfaces, pure functions, class stub + global registration) +- Test file for pure functions and stub-level class tests + +**Story 1.2 will add:** Full `init()` implementation — DOM wrapping of `#map`, canvas creation, `THREE.WebGLRenderer`, ResizeObserver, D3 zoom subscription. + +**Story 1.3 will add:** Full implementation of `register()`, `setVisible()`, `clearLayer()`, `requestRender()`, `render()`, `syncTransform()`. + +The class scaffold created in this story must declare all private fields so Stories 1.2 and 1.3 can implement method bodies against them without structural changes. Use `private fieldName: type | null = null` patterns so TypeScript is satisfied without real initialization. + +### File to Create: `src/modules/webgl-layer-framework.ts` + +**Full internal structure (from [architecture.md §5.3](_bmad-output/planning-artifacts/architecture.md)):** + +```typescript +import { + WebGLRenderer, + Scene, + OrthographicCamera, + Group, + Mesh +} from "three"; +// Note: Import only what this story needs now; additional Three.js classes will be +// added in Stories 1.2 and 1.3. Never use `import * as THREE from "three"` — always +// named imports (NFR-B1). Biome's noRestrictedImports may enforce this. + +// ─── Exports (for testability) ─────────────────────────────────────────────── +export function buildCameraBounds(...) { ... } +export function detectWebGL2(...) { ... } +export function getLayerZIndex(...) { ... } + +// ─── Interfaces ────────────────────────────────────────────────────────────── +export interface WebGLLayerConfig { ... } +interface RegisteredLayer { ... } // internal only — NOT exported + +// ─── Class ─────────────────────────────────────────────────────────────────── +export class WebGL2LayerFrameworkClass { ... } + +// ─── Global registration (MUST be last line) ───────────────────────────────── +declare global { + var WebGL2LayerFramework: WebGL2LayerFrameworkClass; +} +window.WebGL2LayerFramework = new WebGL2LayerFrameworkClass(); +``` + +### `buildCameraBounds` — Formula and Implementation + +**From [architecture.md §Decision 4](_bmad-output/planning-artifacts/architecture.md):** + +D3 applies `transform: translate(viewX, viewY) scale(scale)` to `#viewbox`. Inverting: + +``` +left = -viewX / scale +right = (graphWidth - viewX) / scale +top = -viewY / scale +bottom = (graphHeight - viewY) / scale +``` + +`top < bottom` because SVG Y-axis points downward. This is the correct Three.js Y-down configuration — **do NOT swap or negate**. + +```typescript +/** + * Converts a D3 zoom transform into orthographic camera bounds. + * + * D3 applies: screen = map * scale + (viewX, viewY) + * Inverting: map = (screen - (viewX, viewY)) / scale + * + * Orthographic bounds (visible map region at current zoom/pan): + * left = -viewX / scale + * right = (graphWidth - viewX) / scale + * top = -viewY / scale + * bottom = (graphHeight - viewY) / scale + * + * top < bottom: Y-down matches SVG; origin at top-left of map. + * Do NOT swap top/bottom or negate — this is correct Three.js Y-down config. + */ +export function buildCameraBounds( + viewX: number, + viewY: number, + scale: number, + graphWidth: number, + graphHeight: number +): {left: number; right: number; top: number; bottom: number} { + return { + left: -viewX / scale, + right: (graphWidth - viewX) / scale, + top: -viewY / scale, + bottom: (graphHeight - viewY) / scale + }; +} +``` + +### `detectWebGL2` — Implementation + +Must accept an optional injectable `probe` canvas for testability (avoids DOM access in tests): + +```typescript +export function detectWebGL2(probe?: HTMLCanvasElement): boolean { + const canvas = probe ?? document.createElement("canvas"); + const ctx = canvas.getContext("webgl2"); + if (!ctx) return false; + const ext = ctx.getExtension("WEBGL_lose_context"); + ext?.loseContext(); // immediately return the context to the browser + return true; +} +``` + +The `ext?.loseContext()` call releases the probe context immediately, preventing context leaks during testing/init. The `WEBGL_lose_context` extension may not be available in all browsers; optional-chain is the correct guard. + +### `getLayerZIndex` — Implementation + +Phase-2-ready but MVP-safe: returns the DOM sibling index of the anchor element (offset by 1), or 2 as a safe fallback when the element is not found. In MVP this always returns 2 because `#terrain` is a `` inside `` — not a direct sibling of `#map-container` — so DOM lookup returns the element but `parentElement?.children` gives SVG group siblings, not container-level siblings. + +```typescript +export function getLayerZIndex(anchorLayerId: string): number { + const anchor = document.getElementById(anchorLayerId); + if (!anchor) return 2; + const siblings = Array.from(anchor.parentElement?.children ?? []); + const idx = siblings.indexOf(anchor); + // idx + 1 so Phase 2 callers get correct interleaving; in MVP always resolves to 2 + return idx > 0 ? idx + 1 : 2; +} +``` + +### Class Scaffold — Private Fields Required in `WebGL2LayerFrameworkClass` + +These fields must be declared now (even as `| null = null`) so Stories 1.2 and 1.3 can implement against them without structural conflicts. Story 1.1 does NOT implement method bodies — leave methods as stubs: + +```typescript +export class WebGL2LayerFrameworkClass { + // Private state + private canvas: HTMLCanvasElement | null = null; + private renderer: WebGLRenderer | null = null; + private camera: OrthographicCamera | null = null; + private scene: Scene | null = null; + private layers: Map = new Map(); + private pendingConfigs: WebGLLayerConfig[] = []; + private resizeObserver: ResizeObserver | null = null; + private rafId: number | null = null; + private container: HTMLElement | null = null; + private _fallback = false; // MUST be private backing field, NOT readonly — set in init() + + get hasFallback(): boolean { + return this._fallback; + } + + // Public API — stub implementations for this story; full bodies in Stories 1.2 & 1.3 + init(): boolean { + return false; + } + register(_config: WebGLLayerConfig): void { + this.pendingConfigs.push(_config); + } + unregister(_id: string): void { + /* Story 1.3 */ + } + setVisible(_id: string, _visible: boolean): void { + /* Story 1.3 */ + } + clearLayer(_id: string): void { + /* Story 1.3 */ + } + requestRender(): void { + /* Story 1.3 */ + } + syncTransform(): void { + /* Story 1.3 */ + } + private render(): void { + /* Story 1.3 */ + } +} +``` + +**CRITICAL:** `_fallback` must be the private backing field pattern, NOT `readonly hasFallback: boolean = false`. TypeScript `readonly` fields can only be assigned in the constructor; `init()` sets `_fallback` post-construction, which would produce a type error with `readonly`. See [architecture.md §Decision 6](_bmad-output/planning-artifacts/architecture.md). + +**For the class-level tests in Story 1.1**, the stub implementations above are enough: `register()` pushes to `pendingConfigs`, `requestRender()` can be left as a stub but the test injects `scene` and `layers` directly via `(framework as any).fieldName` to test the stubs. + +### `WebGLLayerConfig` Interface + +```typescript +export interface WebGLLayerConfig { + id: string; + anchorLayerId: string; // SVG id; canvas id derived as `${id}Canvas` + renderOrder: number; // Three.js renderOrder for this layer's Group + setup: (group: Group) => void; // called once after WebGL2 confirmed; add meshes to group + render: (group: Group) => void; // called each frame before renderer.render(); update uniforms/geometry + dispose: (group: Group) => void; // called on unregister(); dispose all GPU objects in group +} +``` + +Note: Use `Group` from Three.js named imports — not `THREE.Group` (no `import * as THREE`). + +### `RegisteredLayer` Interface (internal, NOT exported) + +```typescript +interface RegisteredLayer { + config: WebGLLayerConfig; + group: Group; // framework-owned; passed to all callbacks — abstraction boundary +} +``` + +### Global Type Declarations to Add in `src/types/global.ts` + +Add to the `declare global {}` block in the existing file ([src/types/global.ts](src/types/global.ts)): + +```typescript +var WebGL2LayerFramework: import("../modules/webgl-layer-framework").WebGL2LayerFrameworkClass; +var drawRelief: (type?: "svg" | "webGL", parentEl?: HTMLElement) => void; +var undrawRelief: () => void; +var rerenderReliefIcons: () => void; +``` + +### Module Import Order in `src/modules/index.ts` + +Add `import "./webgl-layer-framework"` **before** any renderer imports. The architecture specifies the framework must be registered on `window` before `draw-relief-icons.ts` loads and calls `WebGL2LayerFramework.register()`. Since `src/renderers/index.ts` is a separate file, and modules are evaluated in import order, the framework module just needs to be in `src/modules/index.ts`. The renderers index imports the framework module after modules: + +Current `src/modules/index.ts` ends at `import "./zones-generator"`. Add the framework import at the **end of the modules list**, before the file ends. This is safe because the framework has no dependency on other modules, and `draw-relief-icons.ts` (renderer) is in `src/renderers/index.ts` which loads after modules. + +### Test File: `src/modules/webgl-layer-framework.test.ts` + +The architecture document (§4.6) provides the exact test patterns to use. Key points for the Vitest test file: + +**Imports:** + +```typescript +import {describe, it, expect, vi, beforeEach} from "vitest"; +import {buildCameraBounds, detectWebGL2, getLayerZIndex, WebGL2LayerFrameworkClass} from "./webgl-layer-framework"; +``` + +**For class-level tests**, inject stubs using `(framework as any).fieldName`: + +- `scene = { add: vi.fn() }` to simulate init-complete state +- `layers = new Map()` already initialized by constructor +- `canvas = { style: { display: "block" } }` for setVisible tests +- `renderer = { render: vi.fn() }` for requestRender tests + +**RAF coalescing test** — use `vi.spyOn(globalThis, "requestAnimationFrame").mockReturnValue(1 as any)`. Since `requestRender()` is a stub in this story (Story 1.3 implements it), the test for RAF coalescing should use the stub injection approach OR defer to Story 1.3 once the method is implemented. Include a placeholder test that confirms `requestRender()` doesn't throw until Story 1.3 fills in the body. + +**getLayerZIndex DOM test** — since Vitest runs in Node (not browser) by default, `document.getElementById()` returns `null`, so the fallback path (`return 2`) is always hit. This is intentional and tests the no-DOM path correctly. + +### Three.js Import Constraint (NFR-B1) + +**NEVER** use `import * as THREE from "three"`. All Three.js imports must be named: + +```typescript +import {WebGLRenderer, Scene, OrthographicCamera, Group, Mesh} from "three"; +``` + +For Story 1.1, only `Group` is needed in the interface type. Import it as a named import. Do not import the full renderer/scene/camera yet (they'll be added in Stories 1.2 and 1.3 when the methods are implemented). + +However, TypeScript will need the type to be resolved at compile time. Import `Group` as a type import to avoid runtime loading if not used in this story: + +```typescript +import type {Group} from "three"; +``` + +When the interface is used as a value (setup/render/dispose callbacks), `import type` is fine since it's erased at compile time. + +### Lint Rules to Watch + +From [project-context.md](project-context.md): + +- `Number.isNaN()` not `isNaN()` — no occurrences expected in this story +- `parseInt(str, 10)` — no occurrences expected +- No unused imports (error level) — do not leave unused Three.js imports +- Template literals over string concatenation +- `strict` mode: `noUnusedLocals`, `noUnusedParameters` are enabled — stub method parameters like `_config`, `_id`, `_visible` must be prefixed with `_` to suppress unused parameter errors + +### Project Structure Notes + +- **New file:** `src/modules/webgl-layer-framework.ts` — follows Global Module Pattern +- **New file:** `src/modules/webgl-layer-framework.test.ts` — co-located unit test (Vitest) +- **Modified:** `src/modules/index.ts` — add side-effect import +- **Modified:** `src/types/global.ts` — add global type declarations + +No changes to `public/modules/` or any legacy JS files. + +### References + +- [architecture.md §2 Technology Stack](_bmad-output/planning-artifacts/architecture.md) +- [architecture.md §3 Core Architectural Decisions §Decision 1-7](_bmad-output/planning-artifacts/architecture.md) +- [architecture.md §4.1 Global Module Pattern](_bmad-output/planning-artifacts/architecture.md) +- [architecture.md §4.6 Test Patterns](_bmad-output/planning-artifacts/architecture.md) — contains exact test code +- [architecture.md §5.1-5.3 Project Structure & Internal Structure](_bmad-output/planning-artifacts/architecture.md) +- [project-context.md §Language-Specific Rules](project-context.md) +- [project-context.md §Naming Conventions](project-context.md) +- [epics.md §Story 1.1 Acceptance Criteria](_bmad-output/planning-artifacts/epics.md) — exact ACs with BDD format + +## Dev Agent Record + +### Agent Model Used + +_To be filled by dev agent_ + +### Debug Log References + +_To be filled by dev agent_ + +### Completion Notes List + +_To be filled by dev agent_ + +### File List + +_To be filled by dev agent_ diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index b73ef0c6..5c2600c0 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -41,8 +41,8 @@ story_location: _bmad-output/implementation-artifacts development_status: # Epic 1: WebGL Layer Framework Module - epic-1: backlog - 1-1-pure-functions-types-and-tdd-scaffold: backlog + 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-3-layer-lifecycle-register-visibility-render-loop: backlog epic-1-retrospective: optional