Fantasy-Map-Generator/_bmad-output/planning-artifacts/architecture.md
Azgaar 9e00d69843 refactor: replace webgl-layer-framework with webgl-layer module
- Removed the webgl-layer-framework module and its associated tests.
- Introduced a new webgl-layer module to handle WebGL2 layer management.
- Updated references throughout the codebase to use the new webgl-layer module.
- Adjusted layer registration and rendering logic to align with the new structure.
- Ensured compatibility with existing functionality while improving modularity.
2026-03-12 19:15:49 +01:00

865 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
stepsCompleted:
[
"step-01-init",
"step-02-context",
"step-03-starter",
"step-04-decisions",
"step-05-patterns",
"step-06-structure",
"step-07-validation",
"step-08-complete"
]
inputDocuments:
- "_bmad-output/planning-artifacts/prd.md"
- "_bmad-output/planning-artifacts/research/technical-WebGL-SVG-layered-rendering-research-2026-03-12.md"
- "_bmad-output/project-context.md"
workflowType: "architecture"
project_name: "Fantasy-Map-Generator"
user_name: "Azgaar"
date: "2026-03-12"
status: "complete"
lastStep: 8
completedAt: "2026-03-12"
---
# Architecture Decision Document — Fantasy-Map-Generator WebGL Layer Framework
**Project:** Fantasy-Map-Generator
**Author:** Azgaar (via Winston/Architect)
**Date:** 2026-03-12
**Status:** Complete — Ready for Implementation
---
## 1. Project Context Analysis
### 1.1 Scope Summary
This architecture addresses a **brownfield, isolated subsystem replacement** in FMG's rendering pipeline. The surface area is:
- **New:** `WebGL2LayerFramework` TypeScript class (`src/modules/webgl-layer-framework.ts`)
- **Refactored:** `src/renderers/draw-relief-icons.ts` — migrated to use the framework instead of ad-hoc context management
- **Added:** Unit tests (`src/modules/webgl-layer-framework.test.ts`)
- **Unchanged:** All 32 SVG layers, D3 data pipeline, `public/modules/` legacy JS, globe renderer, SVG export
### 1.2 Functional Requirements Coverage
| FR Category | Count | Architectural Component |
| ------------------------------------ | ----- | ----------------------------------------------------------------- |
| Framework Core (FR1FR4) | 4 | `WebGL2LayerFramework` class |
| Coordinate Sync (FR5FR7) | 3 | `syncTransform()` method + orthographic camera |
| Layer Lifecycle (FR8FR11) | 4 | `setVisible()`, `ResizeObserver`, `dispose()` |
| Relief Rendering (FR12FR17) | 6 | `draw-relief-icons.ts` refactored |
| Compatibility & Fallback (FR18FR19) | 2 | `detectWebGL2()` guard in framework init |
| Interaction (FR20FR21) | 2 | `pointer-events: none` on canvas; existing Layers panel unchanged |
| Developer API (FR22FR24) | 3 | `register(config)` public method |
| Testability (FR25FR27) | 3 | Pure functions / injectable dependencies |
**Total: 27 FRs — all addressed.**
### 1.3 Non-Functional Constraints Shaping Architecture
| NFR | Architectural Impact |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------- |
| NFR-P6: No GPU teardown on hide | `setVisible(false)` must set `mesh.visible = false` only — NOT call `dispose()` |
| NFR-C3: Max 2 WebGL contexts (1 globe + 1 map) | Single `THREE.WebGLRenderer` shared across all registered layers |
| NFR-M3: Global Module Pattern | `window.WebGL2LayerFramework = new WebGL2LayerFrameworkClass()` at module bottom |
| NFR-B1/B2: Bundle size ≤ 50KB gzip increase | Three.js already present; named imports only (`import { WebGLRenderer, ... } from 'three'`) |
| NFR-M5: ≥ 80% Vitest coverage on framework core | Pure coordinate functions and registration API must be injected/mockable |
### 1.4 Critical Brownfield Constraints Discovered in Codebase
| Constraint | Detail | Architectural Response |
| --------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| Existing WebGL relief renderer | `draw-relief-icons.ts` already has a working Three.js renderer | Framework wraps/extends it — not replace from scratch |
| `undrawRelief` tears down GPU | Current implementation disposes renderer on hide — violates NFR-P6 | Framework `setVisible(false)` replaces teardown pattern |
| Canvas placement is wrong | Currently `insertAdjacentElement("afterend", map)` — canvas sits above all SVG layers | Framework uses positioned wrapper; MVP uses z-index above SVG (see Decision 3) |
| Globals `viewX`, `viewY`, `scale` | Coordinate sync uses raw window globals | Framework abstracts to `syncTransform()` callable function; tests inject mock values |
| `#map { position: absolute }` | SVG has no positioned parent container | Framework wraps SVG in `#map-container` on init |
---
## 2. Technology Stack
No new dependencies are introduced. The framework uses only technologies already in the project:
| Technology | Version | Role in This Feature |
| ---------- | -------- | ----------------------------------------------------------- |
| TypeScript | ^5.9.3 | Framework implementation language |
| Three.js | ^0.183.2 | WebGL renderer, orthographic camera, scene management |
| Vite | ^7.3.1 | Bundling (tree-shaking Three.js named imports) |
| Vitest | ^4.0.18 | Unit tests for coordinate sync and framework API |
| D3 | ^7.9.0 | Source of zoom transform values (`viewX`, `viewY`, `scale`) |
**Three.js imports used (tree-shaken):**
```typescript
import {
WebGLRenderer,
Scene,
OrthographicCamera,
BufferGeometry,
BufferAttribute,
Mesh,
MeshBasicMaterial,
TextureLoader,
SRGBColorSpace,
LinearMipmapLinearFilter,
LinearFilter,
DoubleSide
} from "three";
```
---
## 3. Core Architectural Decisions
### Decision 1: Single Shared WebGL Context via `WebGL2LayerFramework`
**Decision:** A new `WebGL2LayerFrameworkClass` TypeScript module manages a single `THREE.WebGLRenderer` instance shared by all registered WebGL layers. It replaces `draw-relief-icons.ts`'s module-level renderer with a centralized framework.
**Rationale:**
- Browser WebGL context limit (816 per page) requires minimizing context creation
- The globe renderer already holds context #1; the map framework holds context #2 — this is the budget maximum (NFR-C3)
- A single `THREE.Scene` with `renderOrder` per layer handles draw ordering within the shared context
- The existing `draw-relief-icons.ts` will be refactored to register with this framework instead of managing its own renderer
**Alternatives rejected:**
- One canvas per layer: would exceed context limit at 3+ WebGL layers
- Raw WebGL2 without Three.js: Three.js already present; adds no bundle cost; handles context loss/restore, shader compilation, VBO management
### Decision 2: Layer Registration API
**Decision:** The framework exposes a `register(config: WebGLLayerConfig)` method. Callers provide an `id`, `anchorLayerId` (SVG element ID for z-position reference), `renderOrder`, a `setup(group)` callback (called once on init), a per-frame `render(group)` callback (called every frame before `renderer.render()`), and a `dispose(group)` cleanup callback. All three callbacks receive the layer's framework-managed `THREE.Group`**never the raw scene, renderer, or camera** — establishing a clean abstraction boundary.
**Abstraction boundary:** `THREE.Group` is the sole interface point between framework internals and layer logic. If the underlying renderer backend changes, only the framework changes — layer code is unaffected. Layer authors never import or depend on `THREE.Scene`, `THREE.WebGLRenderer`, or camera types.
```typescript
export interface WebGLLayerConfig {
id: string;
anchorLayerId: string; // SVG <g> id; canvas element id derived as `${id}Canvas`
renderOrder: number; // Three.js renderOrder for this layer's Group in the scene
setup: (group: THREE.Group) => void; // called once after WebGL2 confirmed; add meshes to group
render: (group: THREE.Group) => void; // called each frame before renderer.render(); update uniforms/geometry
dispose: (group: THREE.Group) => void; // called on unregister(id); dispose all GPU objects in group
}
```
**What the framework manages:**
- Canvas element creation, sizing, positioning; canvas `id` = `${config.id}Canvas`
- `THREE.WebGLRenderer` + `THREE.Scene` + `THREE.OrthographicCamera` initialization
- One `THREE.Group` per registered layer (owns all layer GPU objects)
- Z-index derivation from anchor SVG layer DOM position
- Visibility toggle (`group.visible = false/true`) — no GPU teardown
- Canvas resize via `ResizeObserver`
- D3 zoom subscription in `init()``requestRender()` on every zoom/pan event
- Per-frame dispatch: calls each visible layer's `render(group)` before `renderer.render(scene, camera)`
**What each layer module manages:**
- In `setup(group)`: create `THREE.Mesh` / `BufferGeometry` / textures, add them to `group`
- In `render(group)`: update geometry or material uniforms if data changed since last frame
- In `dispose(group)`: call `.geometry.dispose()`, `.material.dispose()`, `.map?.dispose()` on all children
- **Never** access `scene`, `renderer`, `camera`, or `canvas` directly — those are framework internals
### Decision 3: Canvas Z-Index Positioning — MVP vs. Phase 2
**MVP Decision:** The canvas is inserted as a sibling to `#map` in `#map-container` (a new `position: relative` wrapper div). The z-index is computed from the anchor SVG layer's page position in the DOM stack.
**Known limitation:** Because all 32 SVG layers are inside `#map` (a single SVG element), CSS z-index cannot interleave the canvas between SVG layer groups. In MVP, the canvas renders **above the entire SVG** (higher z-index than `#map`). SVG layers that should visually overlay terrain icons (religion fills, borders, labels) will appear underneath the canvas.
**Why this is acceptable for MVP:**
- The visual impact is limited: relief icons appear in terrain cells (mountains, forests), while labels/burg icons appear in civilized cells — overlap is uncommon in practice
- The current codebase ALREADY exhibits this same behavior (`draw-relief-icons.ts` places canvas after `#map` in DOM order with no z-index)
- `pointer-events: none` preserves all interaction; the UX regression is purely visual
### Decision 4: D3 Zoom → WebGL Orthographic Camera Sync
**Decision:** The sync formula from the existing `draw-relief-icons.ts` is extracted into a pure, testable function `buildCameraBounds(viewX, viewY, scale, graphWidth, graphHeight)` that returns `{left, right, top, bottom}` for the orthographic camera.
**Derivation (documented in code):**
```
D3 applies transform: translate(viewX, viewY) scale(scale) to #viewbox
This means: screen_point = map_point * scale + (viewX, viewY)
Inverting: map_point = (screen_point - (viewX, viewY)) / scale
Orthographic camera bounds (what map rectangle is visible on screen):
left = -viewX / scale ← left edge of visible map
right = (graphWidth - viewX) / scale ← right edge
top = -viewY / scale ← top edge
bottom = (graphHeight - viewY) / scale ← bottom edge
Three.js OrthographicCamera(left, right, top, bottom, near, far):
`top` = upper visible edge in camera space (numerically smaller — closer to y=0 in SVG)
`bottom` = lower visible edge in camera space (numerically larger)
So top < bottom, which means the camera's Y-axis points downward — matching SVG.
new OrthographicCamera(left, right, top, bottom, -1, 1)
// top < bottom: Y-down matches SVG; origin at top-left of map.
// Do NOT swap top/bottom or negate — this is the correct Three.js Y-down configuration.
```
**Why this is testable:** `buildCameraBounds` takes only numbers and returns numbers. Tests inject mock `viewX/viewY/scale` values and assert exact output — no DOM or WebGL required.
### Decision 5: Visibility Toggle — GPU State Preservation
**Decision:** `framework.setVisible(id, visible)` toggles the Three.js `Object3D.visible` property of the layer's registered group. The canvas element's `display` style is changed only when ALL registered layers are hidden.
```typescript
setVisible(id: string, visible: boolean): void {
const layer = this.layers.get(id);
if (!layer) return;
layer.group.visible = visible;
// Only hide canvas if ALL layers are invisible (avoids GPU context loss)
const anyVisible = [...this.layers.values()].some(l => l.group.visible);
this.canvas.style.display = anyVisible ? "block" : "none";
if (visible) this.render();
}
```
**This replaces `undrawRelief`'s current behavior** which calls `renderer.dispose()`, `scene = null`, etc. — destroying GPU buffers on every hide. The framework never destroys buffers except on `framework.unregister(id)`.
### Decision 6: WebGL2 Detection and SVG Fallback
**Decision:** `init()` calls `detectWebGL2()` which attempts `canvas.getContext('webgl2')`. On failure, the framework sets a private `_fallback` backing field to `true` (exposed via a public getter `get hasFallback()`). The relief renderer reads `hasFallback` and falls back to `drawSvg()`. All framework methods silently return when `_fallback` is true.
**Critical TypeScript pattern — `hasFallback` MUST use a backing field, not `readonly`:** TypeScript `readonly` fields can only be assigned in the constructor. Because `detectWebGL2()` runs inside `init()` (called post-construction), `hasFallback` must be implemented as:
```typescript
private _fallback = false;
get hasFallback(): boolean { return this._fallback; }
init(): boolean {
this._fallback = !detectWebGL2();
if (this._fallback) return false;
// ... rest of init
}
```
Do **not** declare `readonly hasFallback: boolean = false` — that pattern compiles but the assignment in `init()` produces a type error.
```typescript
// Exported for testability — accepts an injectable probe canvas
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();
return true;
}
```
**Testable:** `detectWebGL2` accepts an optional injectable probe canvas so tests pass a mock without DOM access.
### Decision 7: Frame Rendering — On-Demand, RAF-Coalesced
**Decision:** The framework exposes a `requestRender()` method that coalesces calls within a single animation frame, preventing redundant GPU draws during rapid pan/zoom events.
```typescript
private rafId: number | null = null;
requestRender(): void {
if (this.rafId !== null) return;
this.rafId = requestAnimationFrame(() => {
this.rafId = null;
this.render();
});
}
private render(): void {
this.syncTransform();
// Dispatch per-frame callback to each visible layer before submitting draw call.
// This is the mechanism through which layers update uniforms, instance matrices,
// or geometry data on a frame-by-frame basis.
for (const [, layer] of this.layers) {
if (layer.group.visible) {
layer.config.render(layer.group);
}
}
this.renderer.render(this.scene, this.camera);
}
```
This replaces `window.rerenderReliefIcons` which currently does the same RAF coalescing at the module level.
### Decision 8: ResizeObserver for Canvas Sizing
**Decision:** The framework attaches a `ResizeObserver` to the `#map-container` element. On resize, it updates canvas dimensions and the orthographic camera aspect ratio, then re-renders.
```typescript
private observeResize(): void {
this.resizeObserver = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
this.renderer.setSize(width, height);
this.requestRender();
});
this.resizeObserver.observe(this.container);
}
```
---
## 4. Implementation Patterns
### 4.1 Global Module Pattern (Mandatory)
All TypeScript modules in `src/modules/` and `src/renderers/` follow the project's Global Module Pattern. The framework module must follow it exactly:
```typescript
// src/modules/webgl-layer-framework.ts
// 1. Global type declaration
declare global {
var WebGL2LayerFramework: WebGL2LayerFrameworkClass;
}
// 2. Class implementation
class WebGL2LayerFrameworkClass {
// ...
}
// 3. Window registration (LAST LINE)
window.WebGL2LayerFramework = new WebGL2LayerFrameworkClass();
```
### 4.2 Module Import Pattern
Add to `src/modules/index.ts` as a side-effect import:
```typescript
import "./webgl-layer-framework";
```
And `draw-relief-icons.ts` remains in `src/renderers/index.ts` — no change to import structure.
### 4.3 Function Naming Conventions
| Pattern | Convention | Example |
| ---------------------- | --------------------------- | ----------------------------------------------------- |
| Framework class | `PascalCase + Class suffix` | `WebGL2LayerFrameworkClass` |
| Window global | `PascalCase` | `window.WebGL2LayerFramework` |
| Pure utility functions | `camelCase` | `buildCameraBounds`, `detectWebGL2`, `getLayerZIndex` |
| Internal methods | `camelCase` | `syncTransform`, `observeResize`, `requestRender` |
### 4.4 TypeScript Type Declarations
New types go in `src/types/global.ts`:
```typescript
declare global {
var WebGL2LayerFramework: import("../modules/webgl-layer-framework").WebGL2LayerFrameworkClass;
var drawRelief: (type?: "svg" | "webGL", parentEl?: HTMLElement) => void;
var undrawRelief: () => void;
var rerenderReliefIcons: () => void;
}
```
### 4.5 Error Handling Philosophy
- Framework `init()` failures (WebGL2 unavailable): sets `_fallback = true` via backing field, logs with `WARN` global, returns `false` — no throw
- Missing DOM elements (e.g., `#map` not found on init): early return + `WARN` log
- WebGL context loss mid-session: `renderer.forceContextRestore()` then `renderer.dispose()` + re-init on next draw call (preserves existing pattern from `draw-relief-icons.ts`)
- Unit tests: pure functions throw `Error`s; framework class methods log and return for resilience
### 4.6 Test Patterns
Unit tests co-located with source in `src/modules/`:
```typescript
// src/modules/webgl-layer-framework.test.ts
import {describe, it, expect, vi, beforeEach} from "vitest";
import {buildCameraBounds, detectWebGL2, getLayerZIndex, WebGL2LayerFrameworkClass} from "./webgl-layer-framework";
// ─── Pure function tests (no DOM, no WebGL) ───────────────────────────────────
describe("buildCameraBounds", () => {
it("returns correct bounds for identity transform", () => {
const b = buildCameraBounds(0, 0, 1, 960, 540);
expect(b.left).toBe(0);
expect(b.right).toBe(960);
expect(b.top).toBe(0);
expect(b.bottom).toBe(540);
});
it("returns correct bounds at 2× zoom", () => {
const b = buildCameraBounds(0, 0, 2, 960, 540);
expect(b.right).toBe(480);
expect(b.bottom).toBe(270);
});
it("returns correct bounds with pan offset (viewX negative = panned right)", () => {
// viewX=-100 means D3 translated +100px right; map origin is at x=100 on screen
const b = buildCameraBounds(-100, -50, 1, 960, 540);
expect(b.left).toBe(100); // -(-100)/1
expect(b.right).toBe(1060); // (960-(-100))/1
expect(b.top).toBe(50);
});
it("top < bottom (Y-down camera convention)", () => {
const b = buildCameraBounds(0, 0, 1, 960, 540);
expect(b.top).toBeLessThan(b.bottom);
});
it("handles extreme zoom values without NaN", () => {
const lo = buildCameraBounds(0, 0, 0.1, 960, 540);
const hi = buildCameraBounds(0, 0, 50, 960, 540);
expect(Number.isFinite(lo.right)).toBe(true);
expect(Number.isFinite(hi.right)).toBe(true);
});
});
describe("detectWebGL2", () => {
it("returns false when getContext returns null", () => {
const canvas = {getContext: () => null} as unknown as HTMLCanvasElement;
expect(detectWebGL2(canvas)).toBe(false);
});
it("returns true when getContext returns a context object", () => {
const mockCtx = {getExtension: () => null};
const canvas = {getContext: () => mockCtx} as unknown as HTMLCanvasElement;
expect(detectWebGL2(canvas)).toBe(true);
});
});
// ─── Class-level tests (stub WebGL2LayerFrameworkClass) ───────────────────────
describe("WebGL2LayerFrameworkClass", () => {
let framework: WebGL2LayerFrameworkClass;
// Stubs: framework.init() requires DOM; short-circuit by stubbing _fallback
beforeEach(() => {
framework = new WebGL2LayerFrameworkClass();
// Force fallback=false path without real WebGL:
(framework as any)._fallback = false;
// Inject a minimal scene + renderer stub so register() doesn't throw
(framework as any).scene = {add: vi.fn()};
(framework as any).layers = new Map();
});
it("register() queues config when called before init()", () => {
const fresh = new WebGL2LayerFrameworkClass();
const config = {
id: "test",
anchorLayerId: "terrain",
renderOrder: 1,
setup: vi.fn(),
render: vi.fn(),
dispose: vi.fn()
};
// Before init(), scene is null — register() must queue, not throw
expect(() => fresh.register(config)).not.toThrow();
});
it("setVisible(false) does not call dispose() on GPU objects", () => {
const mockGroup = {visible: true};
const config = {
id: "terrain",
anchorLayerId: "terrain",
renderOrder: 1,
setup: vi.fn(),
render: vi.fn(),
dispose: vi.fn()
};
(framework as any).layers.set("terrain", {config, group: mockGroup});
(framework as any).canvas = {style: {display: "block"}};
framework.setVisible("terrain", false);
expect(mockGroup.visible).toBe(false);
expect(config.dispose).not.toHaveBeenCalled();
});
it("requestRender() coalesces multiple calls into a single RAF", () => {
const rafSpy = vi.spyOn(globalThis, "requestAnimationFrame").mockReturnValue(1 as any);
(framework as any).renderer = {render: vi.fn()};
(framework as any).camera = {};
framework.requestRender();
framework.requestRender();
framework.requestRender();
expect(rafSpy).toHaveBeenCalledTimes(1);
rafSpy.mockRestore();
});
it("clearLayer() removes group children without disposing the renderer", () => {
const clearFn = vi.fn();
const mockGroup = {visible: true, clear: clearFn};
const config = {
id: "terrain",
anchorLayerId: "terrain",
renderOrder: 1,
setup: vi.fn(),
render: vi.fn(),
dispose: vi.fn()
};
(framework as any).layers.set("terrain", {config, group: mockGroup});
framework.clearLayer("terrain");
expect(clearFn).toHaveBeenCalled();
expect((framework as any).layers.has("terrain")).toBe(true); // still registered
});
it("hasFallback is false by default (backing field pattern)", () => {
expect(framework.hasFallback).toBe(false);
});
});
```
**Key testability rule:** Pure functions (`buildCameraBounds`, `detectWebGL2`, `getLayerZIndex`) are exported as named exports and are fully testable without DOM or WebGL. The class is tested via stubs injected onto private fields — no real renderer required.
---
## 5. Project Structure
### 5.1 Files Created
```
src/
modules/
webgl-layer-framework.ts ← NEW: Framework class (core deliverable)
webgl-layer-framework.test.ts ← NEW: Unit tests (≥80% coverage target)
renderers/
draw-relief-icons.ts ← MODIFIED: Refactored to use framework
```
### 5.2 Files Modified
```
src/
modules/
index.ts ← ADD: import "./webgl-layer-framework"
types/
global.ts ← ADD: WebGL2LayerFramework global declaration
```
### 5.3 `webgl-layer-framework.ts` Internal Structure
```typescript
// ─── Exports (for testability) ───────────────────────────────────────────────
export function detectWebGL2(probe?: HTMLCanvasElement): boolean;
export function buildCameraBounds(
viewX: number,
viewY: number,
scale: number,
graphWidth: number,
graphHeight: number
): {left: number; right: number; top: number; bottom: number};
export function getLayerZIndex(anchorLayerId: string): number;
// ─── Types ───────────────────────────────────────────────────────────────────
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: THREE.Group) => void; // called once on init(); add meshes to group
render: (group: THREE.Group) => void; // called each frame before renderer.render()
dispose: (group: THREE.Group) => void; // called on unregister(); dispose GPU objects
}
interface RegisteredLayer {
config: WebGLLayerConfig;
group: THREE.Group; // framework-owned; passed to all callbacks — abstraction boundary
}
// ─── Class ───────────────────────────────────────────────────────────────────
export class WebGL2LayerFrameworkClass {
private canvas: HTMLCanvasElement | null = null;
private renderer: THREE.WebGLRenderer | null = null;
private camera: THREE.OrthographicCamera | null = null;
private scene: THREE.Scene | null = null;
private layers: Map<string, RegisteredLayer> = new Map();
private pendingConfigs: WebGLLayerConfig[] = []; // queue for register() before init()
private resizeObserver: ResizeObserver | null = null;
private rafId: number | null = null;
private container: HTMLElement | null = null;
private _fallback = false; // backing field — NOT readonly, set in init()
get hasFallback(): boolean {
return this._fallback;
}
// Public API
init(): boolean; // call from app bootstrap; processes pendingConfigs queue
register(config: WebGLLayerConfig): boolean; // safe to call before init() — queues if needed
unregister(id: string): void;
setVisible(id: string, visible: boolean): void;
clearLayer(id: string): void; // wipe group geometry without removing registration
requestRender(): void;
syncTransform(): void;
// Private
private render(): void;
private observeResize(): void;
private subscribeD3Zoom(): void; // called in init(); attaches viewbox.on("zoom.webgl", ...)
}
// ─── Global Registration (MUST be last line) ─────────────────────────────────
declare global {
var WebGL2LayerFramework: WebGL2LayerFrameworkClass;
}
window.WebGL2LayerFramework = new WebGL2LayerFrameworkClass();
```
### 5.4 `draw-relief-icons.ts` Refactored Structure
The module registers itself with the framework on load. Existing window globals (`drawRelief`, `undrawRelief`, `rerenderReliefIcons`) are preserved for backward compatibility with legacy `public/modules/` code that calls them.
```typescript
// Registration call (runs at module load time, before init()) ─────────────────
WebGL2LayerFramework.register({
id: "terrain",
anchorLayerId: "terrain",
renderOrder: getLayerZIndex("terrain"),
setup(group) {
// Called once by framework after init(); nothing to do here —
// geometry is built lazily when drawRelief() is called.
},
render(group) {
// Called each frame. Relief geometry is static between drawRelief() calls;
// no per-frame CPU updates required — this is intentionally a no-op.
},
dispose(group) {
group.traverse(obj => {
if (obj instanceof Mesh) {
obj.geometry.dispose();
(obj.material as MeshBasicMaterial).map?.dispose();
(obj.material as MeshBasicMaterial).dispose();
}
});
}
});
// Internal: rebuild geometry from pack.relief data ────────────────────────────
function buildReliefScene(icons: ReliefIcon[]): void; // adds Meshes to the layer's group
function drawSvgRelief(icons: ReliefIcon[], parentEl: HTMLElement): void;
// Public window globals (backward-compatible) ─────────────────────────────────
window.drawRelief = (type = "webGL", parentEl = byId("terrain")) => {
if (WebGL2LayerFramework.hasFallback || type === "svg") {
drawSvgRelief(icons, parentEl);
} else {
buildReliefScene(icons);
WebGL2LayerFramework.requestRender();
}
};
window.undrawRelief = () => {
// Clear geometry from the framework-owned group — do NOT touch renderer or scene.
// clearLayer() removes all Meshes from the group without disposing the renderer.
WebGL2LayerFramework.clearLayer("terrain");
if (terrainEl) terrainEl.innerHTML = ""; // also clear SVG fallback content
};
window.rerenderReliefIcons = () => {
WebGL2LayerFramework.requestRender();
};
```
### 5.5 DOM Structure After Framework Init
```
body
div#map-container (NEW; position: relative; width: svgWidth; height: svgHeight)
svg#map (MOVED inside container; position: absolute; inset: 0; z-index: 1)
canvas#terrainCanvas (NEW; id = "${config.id}Canvas" = "terrainCanvas";
position: absolute; inset: 0;
z-index: getLayerZIndex("terrain") → 2 in MVP (above #map);
pointer-events: none; aria-hidden: true)
```
**Canvas `id` convention:** The framework derives the canvas element id as `${config.id}Canvas`. For `id: "terrain"``canvas#terrainCanvas`. For `id: "biomes"``canvas#biomesCanvas`. This must be consistent; implementing agents must not hardcode canvas ids.
**MVP z-index note:** In MVP both `#map` (z-index: 1) and `canvas#terrainCanvas` (z-index: 2) are stacked as siblings within `#map-container`. The canvas is visually above the entire `#map` SVG. This is a known, accepted limitation. See Decision 3.
### 5.6 Framework Initialization Sequence
```
1. Framework module loaded (via src/modules/index.ts import)
→ window.WebGL2LayerFramework = new WebGL2LayerFrameworkClass()
→ constructor does NOTHING: renderer=null, _fallback unset, pendingConfigs=[]
2. draw-relief-icons.ts loaded (via src/renderers/index.ts import)
→ WebGL2LayerFramework.register({ id: "terrain", ... })
→ init() has NOT been called yet — register() pushes to pendingConfigs[]
→ This is safe by design: register() before init() is explicitly supported
3. App bootstrap calls WebGL2LayerFramework.init() ← EXPLICIT CALL REQUIRED
→ _fallback = !detectWebGL2() (uses backing field, not readonly)
→ if _fallback: init() returns false; all subsequent API calls are no-ops
→ creates div#map-container wrapper, moves svg#map inside (z-index:1)
→ creates THREE.WebGLRenderer(canvas), THREE.Scene, THREE.OrthographicCamera
→ sets canvas id, position:absolute, inset:0, pointer-events:none, z-index:2
→ calls subscribeD3Zoom(): viewbox.on("zoom.webgl", () => this.requestRender())
→ processes pendingConfigs[]: for each config:
creates THREE.Group with config.renderOrder
calls config.setup(group)
adds group to scene
stores RegisteredLayer in layers Map
→ attaches ResizeObserver to #map-container
4. Main map generation completes → window.drawRelief() called by legacy JS
→ if WebGL: buildReliefScene(icons) builds Meshes in layer's group
→ calls requestRender() → next RAF:
render(): syncTransform() → each visible layer's render(group) → renderer.render(scene,camera)
→ if fallback: drawSvgRelief(icons, parentEl)
5. D3 zoom/pan → framework's own "zoom.webgl" listener fires → requestRender()
rerenderReliefIcons() also calls requestRender() as belt-and-suspenders
6. Layer hide: window.undrawRelief()
→ WebGL2LayerFramework.clearLayer("terrain"): group.clear() wipes Meshes; renderer untouched
→ framework.setVisible("terrain", false): group.visible = false
7. Layer show: window.drawRelief()
→ buildReliefScene(icons) rebuilds Meshes in group
→ framework.setVisible("terrain", true): group.visible = true
→ requestRender()
```
---
## 6. Architecture Validation
### 6.1 FR Coverage Matrix
| Requirement | Addressed By | Status |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| FR1: Single WebGL2 context | `WebGL2LayerFrameworkClass` owns one `THREE.WebGLRenderer` | ✅ |
| FR2: Canvas at correct z-index | `getLayerZIndex(anchorLayerId)` → canvas z-index | ✅ (MVP: above SVG) |
| FR3: Register layer by anchor + callback | `framework.register(config)` | ✅ |
| FR4: Layer registry | `layers: Map<string, RegisteredLayer>` | ✅ |
| FR5: Sync to D3 zoom transform | `syncTransform()` reads `viewX, viewY, scale` globals | ✅ |
| FR6: Update on D3 change | `requestRender()` called from `rerenderReliefIcons` | ✅ |
| FR7: Map-space → WebGL clip coordinates | `buildCameraBounds()` formula | ✅ |
| FR8: Toggle without GPU teardown | `setVisible()``group.visible` only | ✅ |
| FR9: Resize canvas on viewport change | `ResizeObserver` on container | ✅ |
| FR10: Recalculate z-index on layer reorder | `getLayerZIndex()` reads live DOM position | ✅ |
| FR11: Dispose layer + GPU resources | `unregister(id)` → disposes GeometryBuffers, removes from scene | ✅ |
| FR12: All relief icons in one draw call | Per-set `Mesh` with merged `BufferGeometry` (existing batched approach) | ✅ |
| FR13: Icons at SVG-space coordinates | Camera in SVG pixel-space; icon positions in `pack.relief` unchanged | ✅ |
| FR14: Scale with zoom and user setting | Camera bounds change with zoom; icon size uses `r.s` from relief data | ✅ |
| FR15: Per-icon rotation | Rotation encoded in quad vertex positions during `buildSetMesh` | ⚠️ Verify rotation support in existing buildSetMesh |
| FR16: Configurable opacity | `MeshBasicMaterial.opacity` + `transparent: true` | ✅ |
| FR17: Re-render on terrain data change | `drawRelief()` calls `buildReliefScene()` + `requestRender()` | ✅ |
| FR18: WebGL2 detection + fallback | `detectWebGL2()``hasFallback` flag | ✅ |
| FR19: SVG fallback visually identical | Existing `drawSvg()` preserved unchanged | ✅ |
| FR20: No pointer-event capture | `canvas.style.pointerEvents = "none"` | ✅ |
| FR21: Existing Layers panel unchanged | `drawRelief`/`undrawRelief` window globals preserved | ✅ |
| FR22: Register without z-index knowledge | `framework.register` derives z-index internally | ✅ |
| FR23: Render callback receives D3 transform | `render(group)` invoked each frame after `syncTransform()`; camera already synced when callback fires | ✅ |
| FR24: Same visibility API for all layers | `framework.setVisible(id, bool)` uniform for all registered layers | ✅ |
| FR25: Coordinate sync testable in isolation | `buildCameraBounds` is a pure exported function | ✅ |
| FR26: Fallback detection testable | `detectWebGL2(probeCanvas)` accepts injectable canvas | ✅ |
| FR27: Registration testable without real WebGL | `hasFallback = true` path is a no-op; stub renderers in tests | ✅ |
**FR15 Note:** The existing `buildSetMesh` in `draw-relief-icons.ts` constructs static quads; rotation may not be applied. This must be verified and implemented (per-icon rotation via vertex transformation in `buildSetMesh`) before MVP ships.
### 6.2 NFR Compliance
| NFR | Status | Implementation |
| ------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------- |
| NFR-P1: <16ms @ 1k icons | Enabled by | Single-batch `BufferGeometry` per atlas set |
| NFR-P2: <100ms @ 10k icons | Enabled by | Same batch approach; measure at implementation |
| NFR-P3: Toggle <4ms | | `group.visible = false` is O(1) |
| NFR-P4: Pan/zoom latency <8ms | | RAF-coalesced; `syncTransform()` is one matrix update |
| NFR-P5: Init <200ms | Enabled by | Three.js renderer init is ~50100ms |
| NFR-P6: No GPU teardown on hide | | `setVisible` never calls `dispose()` |
| NFR-C1: WebGL2 as sole gate | | `detectWebGL2()` uses `canvas.getContext('webgl2')` |
| NFR-C2: Cross-browser visual parity | Enabled by | Three.js normalizes WebGL2 across browsers |
| NFR-C3: Max 2 contexts | | Framework creates 1 context; globe creates 1 |
| NFR-C4: Fallback when HW accel disabled | | `detectWebGL2()` returns false SVG path |
| NFR-M1: Framework unknown of layer content | | `setup/dispose` callbacks encapsulate all content |
| NFR-M2: New layer = 1 `register()` call | | Confirmed by API design |
| NFR-M3: Global Module Pattern | | `window.WebGL2LayerFramework = new ...` at bottom |
| NFR-M4: Sync formula documented | | `buildCameraBounds` has full derivation in JSDoc |
| NFR-M5: 80% test coverage | Target | Tests for `buildCameraBounds`, `detectWebGL2`, `getLayerZIndex`, `register`, `setVisible` |
| NFR-B1: Tree-shaking Three.js | | Named imports only |
| NFR-B2: 50KB bundle increase | | No new dependencies; framework code ~5KB |
### 6.3 Architecture Risks and Mitigations
| Risk | Likelihood | Impact | Architecture Mitigation |
| --------------------------------------------- | ---------- | ------ | ------------------------------------------------------------------------------------------ |
| D3 + WebGL coordinate offset at extreme zoom | Medium | High | `buildCameraBounds` is unit-tested at zoom 0.150; exact formula documented |
| FR15: Rotation not in existing `buildSetMesh` | High | Medium | Flag as pre-MVP verification item; add rotation attribute if missing |
| MVP z-ordering: canvas above SVG | High | Medium | Accepted tradeoff; documented; Phase 2 DOM-split design provided |
| `register()` called before `init()` | High | High | `register()` pushes to `pendingConfigs[]`; `init()` processes queue order-safe by design |
| `undrawRelief` bypasses framework clearLayer | Medium | Medium | `undrawRelief` explicitly calls `framework.clearLayer()` per section 5.4 |
| Context loss mid-session | Low | High | Framework inherits existing `forceContextRestore` pattern from `draw-relief-icons.ts` |
| Three.js API bleeds into layer code | Low | High | All callbacks receive `THREE.Group` only `scene`, `renderer`, `camera` are private |
### 6.4 Decision Coherence Check
| Decision Pair | Compatible? | Note |
| -------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------- |
| Single context (D1) + Layer registry (D2) | | `renderOrder` on `THREE.Group` within shared scene; one renderer, multiple groups |
| Group abstraction (D2) + framework owns scene (D1) | | Callbacks receive `Three.Group` only; `scene`/`renderer`/`camera` stay private |
| render(group) callback (D2) + RAF coalescing (D7) | | `render(group)` dispatched inside RAF callback before `renderer.render()` correct order |
| MVP z-index above SVG (D3) + pointer-events:none (D3) | | Interaction preserved regardless of z-stack position |
| Camera sync using globals (D4) + testability (FR25) | | `buildCameraBounds` is pure; globals are injected in tests |
| No GPU teardown (D5) + `undrawRelief` backward compat (section 5.4) | | `undrawRelief` calls `framework.clearLayer()` (geometry only); renderer untouched |
| register() before init() (section 5.6) + pendingConfigs queue (D2) | | Queue pattern decouples module load order from DOM/WebGL readiness |
| D3 zoom subscription in init() (D6) + per-layer render callback (D2) | | Framework owns the zoom listener; layer's `render(group)` called inside the resulting RAF |
| On-demand RAF render (D7) + ResizeObserver (D8) | | Both call `requestRender()` which coalesces to one RAF |
---
## 7. Implementation Guidance for AI Agents
When implementing this architecture, follow these rules precisely:
### MUST DO
1. **Framework module registers first** `src/modules/index.ts` import must appear before renderer imports
2. **`window.WebGL2LayerFramework = new WebGL2LayerFrameworkClass()` is the last line** of the framework module
3. **Export `buildCameraBounds`, `detectWebGL2`, `getLayerZIndex`** as named exports tests depend on them
4. **`setVisible(id, false)` NEVER calls `renderer.dispose()`** sets `group.visible = false` only
5. **Implement `clearLayer(id)`** `undrawRelief` calls this to wipe group geometry; layer stays registered
6. **Use `private _fallback = false` + `get hasFallback()`** NOT `readonly hasFallback = false` (TypeScript compile error)
7. **Call `init()` before any `drawRelief()` invocation** app bootstrap must call `WebGL2LayerFramework.init()`
8. **All layer callbacks receive `THREE.Group`** `setup(group)`, `render(group)`, `dispose(group)`; never pass `scene`
9. **Subscribe D3 zoom in `init()`**: `viewbox.on("zoom.webgl", () => this.requestRender())`
10. **Canvas `id` = `${config.id}Canvas`** derived by framework; never hardcoded in layer code
11. **Canvas element gets**: `pointer-events: none; aria-hidden: true; position: absolute; inset: 0; z-index: 2`
12. **Fallback path**: when `hasFallback === true`, all framework methods return silently; `drawRelief` calls `drawSvgRelief`
13. **`window.drawRelief`, `window.undrawRelief`, `window.rerenderReliefIcons`** must remain as window globals (legacy JS calls them)
14. **Verify FR15** (per-icon rotation) in `buildSetMesh` before MVP add rotation support if missing
### MUST NOT DO
1. **Do NOT** declare `readonly hasFallback: boolean = false` this causes a TypeScript error when `init()` sets it
2. **Do NOT** pass `scene`, `renderer`, or `camera` to any layer callback `THREE.Group` is the sole abstraction boundary
3. **Do NOT** call `renderer.dispose()` from `undrawRelief` or any visibility toggle only from full framework teardown
4. **Do NOT** create a second `THREE.WebGLRenderer` framework owns the only map renderer
5. **Do NOT** move layer `<g>` elements between SVG elements DOM-split is Phase 2
6. **Do NOT** add any new entries to `public/modules/` all new code is in `src/`
7. **Do NOT** break the `window.drawRelief(type, parentEl)` signature legacy callers
8. **Do NOT** use `isNaN()` use `Number.isNaN()`; or `parseInt()` without radix
9. **Do NOT** import Three.js as `import * as THREE from "three"` use named imports only
### Verification Checklist
- [ ] `npm run lint` passes with zero errors
- [ ] `npx vitest run` passes all tests
- [ ] `buildCameraBounds` tests pass at zoom 0.1, 1, 2, 10, 50
- [ ] `detectWebGL2` test passes with null-returning mock canvas
- [ ] Layer registration test passes with stub scene
- [ ] `setVisible(false)` test confirms GPU buffers remain allocated
- [ ] Visual: relief icons render at correct coordinate positions
- [ ] Visual: toggling terrain layer on/off preserves icon positions
- [ ] Visual: pan/zoom redraws canvas correctly in sync with SVG
---
## 8. Next Steps
With this architecture complete, the recommended implementation sequence is:
**Story 1:** Create `webgl-layer-framework.ts` with exported pure functions and stub class methods; write all unit tests first (TDD).
**Story 2:** Implement `WebGL2LayerFrameworkClass` core: `init()` with `_fallback` backing field, `detectWebGL2()`, canvas creation (`id = ${config.id}Canvas`), `#map-container` wrapper, `ResizeObserver`, D3 zoom subscription (`viewbox.on("zoom.webgl", ...)`), `pendingConfigs[]` queue processing.
**Story 3:** Implement `register()` (with pre-init queue support), `unregister()`, `setVisible()`, `clearLayer()`, `requestRender()` (RAF coalescing), `syncTransform()`, per-frame `render(group)` dispatch in `render()`.
**Story 4:** Refactor `draw-relief-icons.ts` to use `WebGL2LayerFramework.register()` and remove the module-level renderer state. Verify FR15 rotation support.
**Story 5:** Integration testing generate map, toggle terrain layer, pan/zoom, verify visual output matches SVG baseline.
**Story 6:** Bundle size audit verify Three.js tree-shaking, confirm 50KB gzipped delta.