mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-03-22 15:17:23 +01:00
feat: update sprint status to reflect in-progress development for WebGL Layer Framework module
This commit is contained in:
parent
6b7029a05b
commit
42b92d93b4
2 changed files with 384 additions and 2 deletions
|
|
@ -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 `<g>` inside `<svg#map>` — 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<string, RegisteredLayer> = 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 <g> 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_
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue