mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-03-22 07:07:24 +01:00
feat: refactor draw-relief-icons renderer to utilize WebGL2LayerFramework
- Removed global renderer, camera, and scene management in favor of layer framework integration. - Implemented terrain layer registration with setup, render, and dispose methods. - Enhanced texture loading and caching mechanisms. - Updated geometry building to return Mesh objects directly. - Added performance benchmarking story for render performance validation. - Created bundle size audit story to ensure effective tree-shaking and size constraints.
This commit is contained in:
parent
30f74373b8
commit
a285d450c8
12 changed files with 1152 additions and 491 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# Story 2.1: Verify and Implement Per-Icon Rotation in buildSetMesh
|
||||
|
||||
**Status:** ready-for-dev
|
||||
**Status:** done
|
||||
**Epic:** 2 — Relief Icons Layer Migration
|
||||
**Story Key:** 2-1-verify-and-implement-per-icon-rotation-in-buildsetmesh
|
||||
**Created:** 2026-03-12
|
||||
|
|
@ -237,25 +237,25 @@ for (const {icon: r, tileIndex} of entries) {
|
|||
|
||||
## Tasks
|
||||
|
||||
- [ ] **T1:** Read and understand `src/modules/relief-generator.ts`
|
||||
- [ ] T1a: Read `ReliefIcon` interface — document what `i` field contains
|
||||
- [ ] T1b: Read `generateRelief()` function — confirm `i: reliefIcons.length` (sequential index, not rotation)
|
||||
- [x] **T1:** Read and understand `src/modules/relief-generator.ts`
|
||||
- [x] T1a: Read `ReliefIcon` interface — document what `i` field contains
|
||||
- [x] T1b: Read `generateRelief()` function — confirm `i: reliefIcons.length` (sequential index, not rotation)
|
||||
|
||||
- [ ] **T2:** Read and understand `buildSetMesh` in `src/renderers/draw-relief-icons.ts`
|
||||
- [ ] T2a: Confirm `r.i` is NOT read in vertex construction code
|
||||
- [ ] T2b: Confirm rotation is absent from both positions and UV arrays
|
||||
- [x] **T2:** Read and understand `buildSetMesh` in `src/renderers/draw-relief-icons.ts`
|
||||
- [x] T2a: Confirm `r.i` is NOT read in vertex construction code
|
||||
- [x] T2b: Confirm rotation is absent from both positions and UV arrays
|
||||
|
||||
- [ ] **T3:** Read `drawSvg` — confirm SVG renderer also applies zero rotation (no `transform` attribute on `<use>`)
|
||||
- [x] **T3:** Read `drawSvg` — confirm SVG renderer also applies zero rotation (no `transform` attribute on `<use>`)
|
||||
|
||||
- [ ] **T4:** Decision branch
|
||||
- [ ] T4a: If NO rotation field in dataset → proceed to T5 (documentation only, no code change)
|
||||
- [ ] T4b: If rotation field EXISTS in live browser `pack.relief` data → implement rotation per Dev Notes Step 4 (update both `buildSetMesh` AND `drawSvg` for parity)
|
||||
- [x] **T4:** Decision branch
|
||||
- [x] T4a: If NO rotation field in dataset → proceed to T5 (documentation only, no code change)
|
||||
- [ ] T4b: If rotation field EXISTS in live browser `pack.relief` data → implement rotation per Dev Notes Step 4 (N/A — no rotation field found)
|
||||
|
||||
- [ ] **T5:** Add verification comment in `buildSetMesh` documenting the FR15 investigation finding (see Dev Notes Step 3 for exact comment text)
|
||||
- [x] **T5:** Add verification comment in `buildSetMesh` documenting the FR15 investigation finding (see Dev Notes Step 3 for exact comment text)
|
||||
|
||||
- [ ] **T6:** `npm run lint` — zero errors
|
||||
- [x] **T6:** `npm run lint` — zero errors
|
||||
|
||||
- [ ] **T7:** Update this story status to `done` (no code review needed for a verification/comment story; this is developer's call per team process)
|
||||
- [x] **T7:** Update this story status to `done`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -263,15 +263,23 @@ for (const {icon: r, tileIndex} of entries) {
|
|||
|
||||
### Agent Model Used
|
||||
|
||||
_to be filled by dev agent_
|
||||
Claude Sonnet 4.5 (GitHub Copilot)
|
||||
|
||||
### Debug Log References
|
||||
|
||||
_None — no implementation errors encountered._
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
- **T1–T3 (Investigation):** `ReliefIcon.i` is a sequential 0-based index (`reliefIcons.length` at push time). Never read in `buildSetMesh` vertex construction. `drawSvg` uses `r.i` only as `data-id` — no rotation transform applied.
|
||||
- **T4 Decision (T4a):** No rotation field in terrain dataset. Path T4b is N/A. Visual parity (FR19) maintained — both renderers produce identical unrotated icons.
|
||||
- **T5:** Added 5-line FR15 verification comment in `buildSetMesh` immediately before vertex position declarations.
|
||||
- **T6:** `npm run lint` → `Checked 80 files in 98ms. No fixes applied.` ✅
|
||||
- **AC1 ✅** — documented that `r.i` is sequential index, not rotation angle
|
||||
- **AC2 N/A** — rotation field absent; no code change needed
|
||||
- **AC3 ✅** — documented in comment: no rotation in code, no rotation in data
|
||||
- **AC4 ✅** — visual parity confirmed: both paths produce identical unrotated icons
|
||||
|
||||
### File List
|
||||
|
||||
_Files modified (to be filled by dev agent):_
|
||||
|
||||
- `src/renderers/draw-relief-icons.ts` — verification comment added to `buildSetMesh`
|
||||
- `src/modules/relief-generator.ts` — only if rotation field implementation path (T4b) triggered
|
||||
- `src/renderers/draw-relief-icons.ts` — FR15 verification comment added to `buildSetMesh` vertex loop
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Story 2.2: Refactor draw-relief-icons.ts to Use Framework
|
||||
|
||||
**Status:** ready-for-dev
|
||||
**Status:** review
|
||||
**Epic:** 2 — Relief Icons Layer Migration
|
||||
**Story Key:** 2-2-refactor-draw-relief-icons-ts-to-use-framework
|
||||
**Created:** 2026-03-12
|
||||
|
|
@ -425,49 +425,52 @@ declare global {
|
|||
|
||||
## Tasks
|
||||
|
||||
- [ ] **T1:** Update Three.js imports — replace `import * as THREE from "three"` with named imports
|
||||
- [ ] T1a: List all `THREE.X` usages in the current file
|
||||
- [ ] T1b: Add each as a named import from `"three"`
|
||||
- [ ] T1c: Import `getLayerZIndex` from `"../modules/webgl-layer-framework"`
|
||||
- [ ] T1d: Replace all `THREE.X` references with bare `X` names
|
||||
- [x] **T1:** Update Three.js imports — replace `import * as THREE from "three"` with named imports
|
||||
- [x] T1a: List all `THREE.X` usages in the current file
|
||||
- [x] T1b: Add each as a named import from `"three"`
|
||||
- [x] T1c: Import `getLayerZIndex` from `"../modules/webgl-layer-framework"`
|
||||
- [x] T1d: Replace all `THREE.X` references with bare `X` names
|
||||
|
||||
- [ ] **T2:** Remove module-level renderer state
|
||||
- [ ] T2a: Remove `glCanvas`, `renderer`, `camera`, `scene` variable declarations
|
||||
- [ ] T2b: Remove `ensureRenderer()` function entirely
|
||||
- [ ] T2c: Remove `disposeScene()` function entirely
|
||||
- [ ] T2d: Remove `renderFrame()` function entirely
|
||||
- [ ] T2e: Remove module-level `rafId` variable (used by old `rerenderReliefIcons`)
|
||||
- [x] **T2:** Remove module-level renderer state
|
||||
- [x] T2a: Remove `glCanvas`, `renderer`, `camera`, `scene` variable declarations
|
||||
- [x] T2b: Remove `ensureRenderer()` function entirely
|
||||
- [x] T2c: Remove `disposeScene()` function entirely
|
||||
- [x] T2d: Remove `renderFrame()` function entirely
|
||||
- [x] T2e: Remove module-level `rafId` variable (used by old `rerenderReliefIcons`)
|
||||
|
||||
- [ ] **T3:** Add `terrainGroup` module-level variable and `register()` call
|
||||
- [ ] T3a: Add `let terrainGroup: Group | null = null;`
|
||||
- [ ] T3b: Add `WebGL2LayerFramework.register({...})` with `setup` callback that sets `terrainGroup = group`
|
||||
- [ ] T3c: Implement `render` callback (no-op with comment)
|
||||
- [ ] T3d: Implement `dispose` callback (traverse group, call `.geometry.dispose()`, `.material.dispose()`, `.map?.dispose()`, then `disposeTextureCache()`)
|
||||
- [x] **T3:** Add `terrainGroup` module-level variable and `register()` call
|
||||
- [x] T3a: Add `let terrainGroup: Group | null = null;`
|
||||
- [x] T3b: Add `WebGL2LayerFramework.register({...})` with `setup` callback that sets `terrainGroup = group`
|
||||
- [x] T3c: Implement `render` callback (no-op with comment)
|
||||
- [x] T3d: Implement `dispose` callback (traverse group, call `.geometry.dispose()`, `.material.dispose()`, `.map?.dispose()`, then `disposeTextureCache()`)
|
||||
|
||||
- [ ] **T4:** Refactor `buildScene()` → `buildReliefScene(icons)`
|
||||
- [ ] T4a: Rename function to `buildReliefScene`
|
||||
- [ ] T4b: Replace `if (!scene) return` guard with `if (!terrainGroup) return`
|
||||
- [ ] T4c: Replace `disposeScene()` call with `terrainGroup.clear()`
|
||||
- [ ] T4d: Replace `scene.add(buildSetMesh(...))` with `terrainGroup.add(buildSetMesh(...))`
|
||||
- [x] **T4:** Refactor `buildScene()` → `buildReliefScene(icons)`
|
||||
- [x] T4a: Rename function to `buildReliefScene`
|
||||
- [x] T4b: Replace `if (!scene) return` guard with `if (!terrainGroup) return`
|
||||
- [x] T4c: Replace `disposeScene()` call with `terrainGroup.traverse(dispose)+terrainGroup.clear()`
|
||||
- [x] T4d: Replace `scene.add(buildSetMesh(...))` with `terrainGroup.add(buildSetMesh(...))`
|
||||
|
||||
- [ ] **T5:** Remove anisotropy line from `loadTexture()` (renderer no longer accessible)
|
||||
- [ ] Add comment explaining removal
|
||||
- [x] **T5:** Remove anisotropy line from `loadTexture()` (renderer no longer accessible)
|
||||
- [x] Add comment explaining removal
|
||||
|
||||
- [ ] **T6:** Refactor `window.drawRelief`
|
||||
- [ ] T6a: Keep `type: "svg" | "webGL"` signature unchanged
|
||||
- [ ] T6b: Add `if (type === "svg" || WebGL2LayerFramework.hasFallback)` check for SVG path
|
||||
- [ ] T6c: WebGL path: call `buildReliefScene(icons)` then `WebGL2LayerFramework.requestRender()`
|
||||
- [x] **T6:** Refactor `window.drawRelief`
|
||||
- [x] T6a: Keep `type: "svg" | "webGL"` signature unchanged
|
||||
- [x] T6b: Add `if (type === "svg" || WebGL2LayerFramework.hasFallback)` check for SVG path
|
||||
- [x] T6c: WebGL path: call `buildReliefScene(icons)` then `WebGL2LayerFramework.requestRender()`
|
||||
|
||||
- [ ] **T7:** Refactor `window.undrawRelief`
|
||||
- [ ] T7a: Replace `disposeScene()` + `renderer.dispose()` + `glCanvas.remove()` sequence with `WebGL2LayerFramework.clearLayer("terrain")`
|
||||
- [ ] T7b: Keep `terrainEl.innerHTML = ""` for SVG fallback cleanup
|
||||
- [x] **T7:** Refactor `window.undrawRelief`
|
||||
- [x] T7a: Replace `disposeScene()` + `renderer.dispose()` + `glCanvas.remove()` sequence with `WebGL2LayerFramework.clearLayer("terrain")`
|
||||
- [x] T7b: Keep `terrainEl.innerHTML = ""` for SVG fallback cleanup
|
||||
- [x] T7c: Reset `lastBuiltIcons` and `lastBuiltSet` to null so next `drawRelief` rebuilds the scene
|
||||
|
||||
- [ ] **T8:** Refactor `window.rerenderReliefIcons`
|
||||
- [ ] T8a: Replace entire RAF + `renderFrame()` body with single line: `WebGL2LayerFramework.requestRender()`
|
||||
- [x] **T8:** Refactor `window.rerenderReliefIcons`
|
||||
- [x] T8a: Replace entire RAF + `renderFrame()` body with single line: `WebGL2LayerFramework.requestRender()`
|
||||
|
||||
- [ ] **T9:** `npm run lint` — zero errors; fix any `import * as THREE` or unused variable warnings
|
||||
- [x] **T9:** `npm run lint` — zero errors; fix any `import * as THREE` or unused variable warnings
|
||||
- Result: `Checked 80 files in 102ms. No fixes applied.` ✅
|
||||
|
||||
- [ ] **T10:** `npx vitest run src/modules/webgl-layer-framework.test.ts` — all 34 tests pass (existing framework tests should be unaffected by this renderer refactor)
|
||||
- [x] **T10:** `npx vitest run src/modules/webgl-layer-framework.test.ts` — all 34 tests pass
|
||||
- Result: `34 passed (34)` ✅
|
||||
|
||||
- [ ] **T11:** Manual smoke test (optional but recommended)
|
||||
- [ ] T11a: Load the app in browser; generate a map; confirm relief icons render
|
||||
|
|
@ -481,14 +484,26 @@ declare global {
|
|||
|
||||
### Agent Model Used
|
||||
|
||||
_to be filled by dev agent_
|
||||
Claude Sonnet 4.6 (GitHub Copilot)
|
||||
|
||||
### Debug Log References
|
||||
|
||||
- Biome auto-fixed import ordering (sorted named imports alphabetically within `from "three"` block, moved `getLayerZIndex` after RELIEF_SYMBOLS in import order) — functionally identical.
|
||||
- `buildReliefScene` traverses and disposes geometry/material before `terrainGroup.clear()` to prevent GPU memory leaks on repeated `drawRelief()` calls. Texture.map is NOT disposed here (textures stay in `textureCache`); map disposal happens only in the `dispose` framework callback.
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
- T1 ✅ Named imports: `BufferAttribute, BufferGeometry, DoubleSide, type Group, LinearFilter, LinearMipmapLinearFilter, Mesh, MeshBasicMaterial, SRGBColorSpace, type Texture, TextureLoader`. `Group` and `Texture` imported as type-only since no `new Group()` or `new Texture()` in this file.
|
||||
- T2 ✅ All 5 module-level state variables removed; `ensureRenderer`, `disposeScene`, `renderFrame` functions removed; `rafId` and old RAF loop removed.
|
||||
- T3 ✅ `WebGL2LayerFramework.register({...})` at module load (safe via `pendingConfigs[]`). `preloadTextures()` called in `setup()` callback after framework assigns the group.
|
||||
- T4 ✅ `buildReliefScene(icons)` uses `terrainGroup`; disposes existing meshes before `clear()` to prevent leaks.
|
||||
- T5 ✅ Anisotropy line removed; comment added explaining renderer ownership moved to framework.
|
||||
- T6 ✅ `window.drawRelief` checks `WebGL2LayerFramework.hasFallback`; WebGL path calls `buildReliefScene` + `requestRender`.
|
||||
- T7 ✅ `window.undrawRelief` uses `clearLayer("terrain")`; resets `lastBuiltIcons/lastBuiltSet` to null (prevents stale memoization after group.clear).
|
||||
- T8 ✅ `window.rerenderReliefIcons` = single `WebGL2LayerFramework.requestRender()` call.
|
||||
- T9 ✅ `npm run lint` → `Checked 80 files in 102ms. No fixes applied.`
|
||||
- T10 ✅ `npx vitest run` → `34 passed (34)` — all existing framework tests unaffected.
|
||||
|
||||
### File List
|
||||
|
||||
_Files modified (to be filled by dev agent):_
|
||||
|
||||
- `src/renderers/draw-relief-icons.ts` — major refactor: named imports, removed module-level state, registered with framework, refactored window globals
|
||||
- `src/renderers/draw-relief-icons.ts` — major refactor: named imports, removed module-level renderer/camera/scene/canvas state, registered with WebGL2LayerFramework, refactored all three window globals
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Story 2.3: WebGL2 Fallback Integration Verification
|
||||
|
||||
**Status:** ready-for-dev
|
||||
**Status:** review
|
||||
**Epic:** 2 — Relief Icons Layer Migration
|
||||
**Story Key:** 2-3-webgl2-fallback-integration-verification
|
||||
**Created:** 2026-03-12
|
||||
|
|
@ -323,42 +323,40 @@ If tests run in node environment (no DOM), DOM-dependent tests in the `init() fa
|
|||
|
||||
## Tasks
|
||||
|
||||
- [ ] **T1:** Read current `webgl-layer-framework.test.ts` — understand existing test structure and count (34 tests baseline)
|
||||
- [x] **T1:** Read current `webgl-layer-framework.test.ts` — understand existing test structure and count (34 tests baseline)
|
||||
|
||||
- [ ] **T2:** Read current `draw-relief-icons.ts` (post-Story 2.2) — verify `WebGL2LayerFramework.hasFallback` check exists in `window.drawRelief`
|
||||
- [x] **T2:** Read current `draw-relief-icons.ts` (post-Story 2.2) — verify `WebGL2LayerFramework.hasFallback` check exists in `window.drawRelief`
|
||||
|
||||
- [ ] **T3:** Add `describe("WebGL2LayerFramework — fallback no-op path (Story 2.3)")` block to `webgl-layer-framework.test.ts`
|
||||
- [ ] T3a: `register()` — no exception, `setup` not called
|
||||
- [ ] T3b: `setVisible()` — no exception (both true and false)
|
||||
- [ ] T3c: `clearLayer()` — no exception
|
||||
- [ ] T3d: `requestRender()` — no exception, RAF not called
|
||||
- [ ] T3e: `unregister()` — no exception
|
||||
- [ ] T3f: `syncTransform()` — no exception
|
||||
- [ ] T3g: `hasFallback` getter returns `true`
|
||||
- [ ] T3h: NFR-C1 — no `console.error` emitted during fallback operations
|
||||
- [x] **T3:** Add `describe("WebGL2LayerFramework — fallback no-op path (Story 2.3)")` block to `webgl-layer-framework.test.ts`
|
||||
- [x] T3a: `register()` — no exception, `setup` not called
|
||||
- [x] T3b: `setVisible()` — no exception (both true and false; split into two tests)
|
||||
- [x] T3c: `clearLayer()` — no exception
|
||||
- [x] T3d: `requestRender()` — no exception, RAF not called
|
||||
- [x] T3e: `unregister()` — no exception
|
||||
- [x] T3f: `syncTransform()` — no exception
|
||||
- [x] T3g: `hasFallback` getter returns `true`
|
||||
- [x] T3h: NFR-C1 — no `console.error` emitted during fallback operations
|
||||
|
||||
- [ ] **T4:** Add `init()` fallback DOM non-mutation test (only if environment supports `document.getElementById`)
|
||||
- [ ] T4a: Check Vitest environment config (`vitest.config.ts`)
|
||||
- [ ] T4b: If jsdom/happy-dom: add test asserting `#map-container` and `#terrainCanvas` do NOT exist after fallback `init()`
|
||||
- [ ] T4c: If node-only environment: skip DOM assertion; rely on `init() returns false` and `hasFallback === true` tests
|
||||
- [x] **T4:** Add `init()` fallback DOM non-mutation test (only if environment supports `document.getElementById`)
|
||||
- [x] T4a: Check Vitest environment config (`vitest.config.ts`) — confirmed node environment (no jsdom)
|
||||
- [x] T4b: If jsdom/happy-dom: skip — existing `init() returns false and sets hasFallback` test in Story 1.2 covers this
|
||||
- [x] T4c: node-only environment: existing test `init() returns false and sets hasFallback` already covers AC1
|
||||
|
||||
- [ ] **T5:** (Optional/Bonus) Add integration test verifying `window.drawRelief()` SVG output when `hasFallback === true`
|
||||
- [ ] T5a: Stub `hasFallback` on global framework instance
|
||||
- [ ] T5b: Create DOM element, populate `pack.relief` stub data
|
||||
- [ ] T5c: Call `window.drawRelief("webGL", element)` — assert SVG output contains `<use>` elements
|
||||
- [ ] T5d: Restore stubs
|
||||
- Not added — requires full DOM + `pack` globals not available in node test environment; structural analysis in completion notes is sufficient
|
||||
|
||||
- [ ] **T6:** `npx vitest run src/modules/webgl-layer-framework.test.ts`
|
||||
- [ ] T6a: All existing 34 tests pass (no regressions)
|
||||
- [ ] T6b: All new fallback tests pass
|
||||
- [ ] T6c: Statement coverage remains ≥80% (NFR-M5)
|
||||
- [x] **T6:** `npx vitest run src/modules/webgl-layer-framework.test.ts`
|
||||
- [x] T6a: All existing 34 tests pass (no regressions) ✅
|
||||
- [x] T6b: All new fallback tests pass — 9 new tests added, 43 total ✅
|
||||
- [x] T6c: Statement coverage increased to 88.51% (was 85.13%) ≥80% NFR-M5 ✅
|
||||
|
||||
- [ ] **T7:** `npm run lint` — zero errors
|
||||
- [x] **T7:** `npm run lint` — zero errors
|
||||
- Result: `Checked 80 files in 120ms. No fixes applied.` ✅
|
||||
|
||||
- [ ] **T8:** Document completion:
|
||||
- [ ] T8a: Record actual test count after new tests
|
||||
- [ ] T8b: Record final statement coverage percentage
|
||||
- [ ] T8c: Verify AC4 (SVG visual parity) by manual or structural analysis — document finding
|
||||
- [x] **T8:** Document completion:
|
||||
- [x] T8a: New test count: 43 tests (was 34; +9 new fallback tests)
|
||||
- [x] T8b: Final statement coverage: 88.51% (was 85.13%) — 3.38% increase from fallback branch coverage
|
||||
- [x] T8c: AC4 SVG visual parity — verified structurally: `drawSvg()` is unchanged from pre-refactor; Story 2.2 only added the `hasFallback` dispatch — same `drawSvg()` code path executes for both SVG type and fallback mode, producing identical `<use>` element output
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -366,14 +364,21 @@ If tests run in node environment (no DOM), DOM-dependent tests in the `init() fa
|
|||
|
||||
### Agent Model Used
|
||||
|
||||
_to be filled by dev agent_
|
||||
Claude Sonnet 4.6 (GitHub Copilot)
|
||||
|
||||
### Debug Log References
|
||||
|
||||
- Initial `requestRender()` test used `vi.spyOn(globalThis, "requestAnimationFrame")` which fails in node env because the property doesn't exist. Fixed by using `vi.stubGlobal("requestAnimationFrame", vi.fn())` + `vi.unstubAllGlobals()` — consistent with the pattern used in Story 1.3 tests at lines 339, 359.
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
- T3 ✅ 9 new tests added in `describe("WebGL2LayerFramework — fallback no-op path (Story 2.3)")`. `setVisible()` split into two tests (false + true) for clearer failure isolation.
|
||||
- T4 ✅ Vitest runs in node env (no jsdom/happy-dom). Existing Story 1.2 test `returns false and sets hasFallback when WebGL2 is unavailable` already covers AC1 DOM non-mutation.
|
||||
- T5 ⏭️ Skipped — integration test requires full DOM + `pack` globals not available in node; structural analysis sufficient.
|
||||
- T6 ✅ 43 tests passing; statement coverage 88.51% (up from 85.13%).
|
||||
- T7 ✅ `npm run lint` → `No fixes applied.`
|
||||
- AC4 ✅ Visual parity verified structurally: `drawSvg()` function is unchanged from pre-refactor; same code path executes for `type === "svg"` and `hasFallback === true`. Identical `<use>` element output guaranteed by shared code.
|
||||
|
||||
### File List
|
||||
|
||||
_Files modified (to be filled by dev agent):_
|
||||
|
||||
- `src/modules/webgl-layer-framework.test.ts` — new describe block with 8+ fallback tests
|
||||
- `src/modules/webgl-layer-framework.test.ts` — new `describe` block with 9 fallback no-op tests (lines 564–631)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,380 @@
|
|||
# Story 3.1: Performance Benchmarking
|
||||
|
||||
**Status:** ready-for-dev
|
||||
**Epic:** 3 — Quality & Bundle Integrity
|
||||
**Story Key:** 3-1-performance-benchmarking
|
||||
**Created:** 2026-03-12
|
||||
**Developer:** _unassigned_
|
||||
|
||||
---
|
||||
|
||||
## Story
|
||||
|
||||
As a developer,
|
||||
I want baseline and post-migration render performance measured and documented,
|
||||
So that we can confirm the WebGL implementation meets all NFR performance targets.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC1:** Initial render — 1,000 icons
|
||||
**Given** a map generated with 1,000 terrain icons (relief cells)
|
||||
**When** `window.drawRelief()` is called and render time is measured via `performance.now()`
|
||||
**Then** WebGL render completes in <16ms (NFR-P1)
|
||||
|
||||
**AC2:** Initial render — 10,000 icons
|
||||
**Given** a map generated with 10,000 terrain icons
|
||||
**When** `window.drawRelief()` is called
|
||||
**Then** render completes in <100ms (NFR-P2)
|
||||
|
||||
**AC3:** Layer visibility toggle
|
||||
**Given** the terrain layer is currently visible
|
||||
**When** `WebGL2LayerFramework.setVisible('terrain', false)` is called and measured
|
||||
**Then** toggle completes in <4ms (NFR-P3)
|
||||
|
||||
**AC4:** D3 zoom latency
|
||||
**Given** a D3 zoom event fires
|
||||
**When** the transform update propagates through to the WebGL canvas
|
||||
**Then** latency is <8ms (NFR-P4)
|
||||
|
||||
**AC5:** Framework initialization
|
||||
**Given** `WebGL2LayerFramework.init()` is called cold
|
||||
**When** measured via `performance.now()`
|
||||
**Then** initialization completes in <200ms (NFR-P5)
|
||||
|
||||
**AC6:** GPU state preservation on hide
|
||||
**Given** the terrain layer is hidden via `setVisible(false)`
|
||||
**When** the browser GPU memory profiler is observed
|
||||
**Then** VBO and texture memory is NOT released (NFR-P6)
|
||||
|
||||
**AC7:** SVG vs WebGL baseline comparison
|
||||
**Given** benchmark results are collected for both render paths
|
||||
**When** documented
|
||||
**Then** baseline SVG render time vs. WebGL render time is recorded with >80% reduction for 5,000+ icons confirmed
|
||||
|
||||
**AC8:** Results documented
|
||||
**When** all measurements are taken
|
||||
**Then** actual timings are recorded in this story's Dev Agent Record, annotated with pass/fail against NFR targets
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
### What This Story Is
|
||||
|
||||
This is a **measurement and documentation story**. The code is complete (Epics 1 and 2 done). This story runs the implementation against all performance NFRs, records actual measurements, and produces an evidence record.
|
||||
|
||||
There are two components:
|
||||
|
||||
1. **Automated bench test** (`src/renderers/draw-relief-icons.bench.ts`) — Vitest `bench()` for geometry build time (`buildSetMesh` proxy). Runs in node env with Three.js mocked (same mock as framework tests). Measures CPU cost of geometry construction, not GPU cost. Partial proxy for NFR-P1/P2.
|
||||
|
||||
2. **Manual browser validation** — Run the app locally (`npm run dev`), measure `init()`, `drawRelief()`, `setVisible()`, zoom latency, and GPU memory via browser DevTools. Record results in completion notes.
|
||||
|
||||
### Why Split Automated vs Manual
|
||||
|
||||
- `draw-relief-icons.ts` internal functions (`buildSetMesh`, `buildReliefScene`) are not exported. They run inside `window.drawRelief()`.
|
||||
- GPU render time (`renderer.render(scene, camera)`) requires a real WebGL2 context — unavailable in node env.
|
||||
- Browser-mode Vitest (`vitest.browser.config.ts`) could bench real GPU calls, but has setup overhead and flaky timing. Manual DevTools profiling is the gold standard for GPU frame time.
|
||||
- Geometry build time (the JS part: Float32Array construction, BufferGeometry setup) CAN be measured in node env via a standalone bench harness.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Epic 1 done ✅: `WebGL2LayerFramework` fully implemented
|
||||
- Epic 2 done ✅: `draw-relief-icons.ts` refactored to use framework
|
||||
- `npm run lint` → clean ✅
|
||||
- `npx vitest run` → 43 tests passing ✅
|
||||
|
||||
### Key Source Files (Read-Only)
|
||||
|
||||
| File | Purpose |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `src/modules/webgl-layer-framework.ts` | Framework — `init()`, `requestRender()`, `setVisible()`, `clearLayer()` |
|
||||
| `src/renderers/draw-relief-icons.ts` | Renderer — `window.drawRelief()`, `buildSetMesh()`, `buildReliefScene()` |
|
||||
| `src/config/relief-config.ts` | `RELIEF_SYMBOLS` — icon atlas registry (9 icons in "simple" set) |
|
||||
| `src/modules/relief-generator.ts` | `generateRelief()` — produces `ReliefIcon[]` from terrain cells |
|
||||
|
||||
---
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Automated Bench Test
|
||||
|
||||
Create `src/renderers/draw-relief-icons.bench.ts`. Use Vitest's `bench()` function (built into Vitest 4.x via tinybench). The test must mock Three.js the same way `webgl-layer-framework.test.ts` does.
|
||||
|
||||
**Problem:** `buildSetMesh()` and `buildReliefScene()` are not exported from `draw-relief-icons.ts`. To bench them without modifying the source file, use a **standalone harness** that re-implements the geometry-build logic (copy-imports only) or refactor the bench to call `window.drawRelief()` after setting up all required globals.
|
||||
|
||||
**Recommended approach** — standalone geometry harness (no source changes required):
|
||||
|
||||
```typescript
|
||||
// src/renderers/draw-relief-icons.bench.ts
|
||||
import {bench, describe, vi} from "vitest";
|
||||
import {
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
DoubleSide,
|
||||
LinearFilter,
|
||||
LinearMipmapLinearFilter,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
SRGBColorSpace,
|
||||
TextureLoader
|
||||
} from "three";
|
||||
import {RELIEF_SYMBOLS} from "../config/relief-config";
|
||||
import type {ReliefIcon} from "../modules/relief-generator";
|
||||
|
||||
// Re-implement buildSetMesh locally for benchmarking (mirrors the production impl)
|
||||
function buildSetMeshBench(entries: Array<{icon: ReliefIcon; tileIndex: number}>, set: string, texture: any): any {
|
||||
const ids = RELIEF_SYMBOLS[set] ?? [];
|
||||
const n = ids.length || 1;
|
||||
const cols = Math.ceil(Math.sqrt(n));
|
||||
const rows = Math.ceil(n / cols);
|
||||
const positions = new Float32Array(entries.length * 4 * 3);
|
||||
const uvs = new Float32Array(entries.length * 4 * 2);
|
||||
const indices = new Uint32Array(entries.length * 6);
|
||||
let vi = 0,
|
||||
ii = 0;
|
||||
for (const {icon: r, tileIndex} of entries) {
|
||||
const col = tileIndex % cols;
|
||||
const row = Math.floor(tileIndex / cols);
|
||||
const u0 = col / cols,
|
||||
u1 = (col + 1) / cols;
|
||||
const v0 = row / rows,
|
||||
v1 = (row + 1) / rows;
|
||||
const x0 = r.x,
|
||||
x1 = r.x + r.s;
|
||||
const y0 = r.y,
|
||||
y1 = r.y + r.s;
|
||||
const base = vi;
|
||||
positions.set([x0, y0, 0], vi * 3);
|
||||
uvs.set([u0, v0], vi * 2);
|
||||
vi++;
|
||||
positions.set([x1, y0, 0], vi * 3);
|
||||
uvs.set([u1, v0], vi * 2);
|
||||
vi++;
|
||||
positions.set([x0, y1, 0], vi * 3);
|
||||
uvs.set([u0, v1], vi * 2);
|
||||
vi++;
|
||||
positions.set([x1, y1, 0], vi * 3);
|
||||
uvs.set([u1, v1], vi * 2);
|
||||
vi++;
|
||||
indices.set([base, base + 1, base + 3, base, base + 3, base + 2], ii);
|
||||
ii += 6;
|
||||
}
|
||||
const geo = new BufferGeometry();
|
||||
geo.setAttribute("position", new BufferAttribute(positions, 3));
|
||||
geo.setAttribute("uv", new BufferAttribute(uvs, 2));
|
||||
geo.setIndex(new BufferAttribute(indices, 1));
|
||||
return geo; // skip material for geometry-only bench
|
||||
}
|
||||
|
||||
// Generate N synthetic icons (no real pack/generateRelief needed)
|
||||
function makeIcons(n: number): Array<{icon: ReliefIcon; tileIndex: number}> {
|
||||
return Array.from({length: n}, (_, i) => ({
|
||||
icon: {i, href: "#relief-mount-1", x: (i % 100) * 10, y: Math.floor(i / 100) * 10, s: 8},
|
||||
tileIndex: i % 9
|
||||
}));
|
||||
}
|
||||
|
||||
describe("draw-relief-icons geometry build benchmarks", () => {
|
||||
bench("buildSetMesh — 1,000 icons (NFR-P1 proxy)", () => {
|
||||
buildSetMeshBench(makeIcons(1000), "simple", null);
|
||||
});
|
||||
|
||||
bench("buildSetMesh — 10,000 icons (NFR-P2 proxy)", () => {
|
||||
buildSetMeshBench(makeIcons(10000), "simple", null);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
> **Note:** This bench measures JS geometry construction only (Float32Array allocation + BufferGeometry setup). GPU rendering cost is NOT measured here — that requires a real browser DevTools profile. The bench is a regression guard: if geometry build time grows by >5× on a future refactor, the bench will flag it.
|
||||
|
||||
**Run bench:** `npx vitest bench src/renderers/draw-relief-icons.bench.ts`
|
||||
|
||||
**Three.js mock:** Add the same `vi.mock("three", () => { ... })` block from `webgl-layer-framework.test.ts`. The bench uses `BufferGeometry` and `BufferAttribute` which need the mock's stubs, or just use the real Three.js (no GPU needed for geometry).
|
||||
|
||||
> **Simplification:** Do NOT mock Three.js for the bench file. `BufferGeometry`, `BufferAttribute` have no GPU dependency — they're pure JS objects. Only `WebGLRenderer`, `Scene`, `OrthographicCamera` need mocking. The bench can import real Three.js and create real buffer geometries without any DOM/GPU.
|
||||
|
||||
### Manual Browser Measurement Protocol
|
||||
|
||||
Run `npm run dev` in a terminal. Open the app at `http://localhost:5173/Fantasy-Map-Generator/`.
|
||||
|
||||
**NFR-P5: init() time (<200ms)**
|
||||
|
||||
```javascript
|
||||
// In browser console before map load:
|
||||
const t0 = performance.now();
|
||||
WebGL2LayerFramework.init();
|
||||
console.log("init time:", performance.now() - t0, "ms");
|
||||
```
|
||||
|
||||
**NFR-P1: drawRelief 1k icons (<16ms)**
|
||||
|
||||
```javascript
|
||||
// Generate a small map, then:
|
||||
const icons1k = pack.relief.slice(0, 1000);
|
||||
const t0 = performance.now();
|
||||
window.drawRelief("webGL", document.getElementById("terrain"));
|
||||
requestAnimationFrame(() => console.log("drawRelief 1k:", performance.now() - t0, "ms"));
|
||||
```
|
||||
|
||||
**NFR-P2: drawRelief 10k icons (<100ms)**
|
||||
|
||||
```javascript
|
||||
const icons10k = pack.relief.slice(0, 10000);
|
||||
// Repeat as above with 10k icons
|
||||
```
|
||||
|
||||
**NFR-P3: setVisible toggle (<4ms)**
|
||||
|
||||
```javascript
|
||||
const t0 = performance.now();
|
||||
WebGL2LayerFramework.setVisible("terrain", false);
|
||||
console.log("toggle:", performance.now() - t0, "ms");
|
||||
```
|
||||
|
||||
**NFR-P4: Zoom latency (<8ms)**
|
||||
|
||||
- Open DevTools → Performance tab → Record
|
||||
- Pan/zoom the map
|
||||
- Measure time from D3 zoom event to last WebGL draw call in the flame graph
|
||||
- Target: <8ms from event dispatch to `gl.drawArrays`
|
||||
|
||||
**NFR-P6: GPU state on hide**
|
||||
|
||||
- Open DevTools → Memory tab → GPU profiler (Chrome: `chrome://tracing` or Memory tab in DevTools)
|
||||
- Call `WebGL2LayerFramework.setVisible('terrain', false)`
|
||||
- Confirm texture and VBO memory sizes do NOT decrease
|
||||
- Expected: `clearLayer()` is NOT called on `setVisible(false)` — GPU memory preserved
|
||||
|
||||
**SVG vs WebGL comparison (AC7)**
|
||||
|
||||
```javascript
|
||||
// SVG path:
|
||||
const s = performance.now();
|
||||
window.drawRelief("svg", document.getElementById("terrain"));
|
||||
console.log("SVG render:", performance.now() - s, "ms");
|
||||
|
||||
// WebGL path (after undrawing SVG):
|
||||
window.undrawRelief();
|
||||
const w = performance.now();
|
||||
window.drawRelief("webGL", document.getElementById("terrain"));
|
||||
requestAnimationFrame(() => console.log("WebGL render:", performance.now() - w, "ms"));
|
||||
```
|
||||
|
||||
### Vitest Config Note
|
||||
|
||||
The existing `vitest.browser.config.ts` uses Playwright for browser tests. The bench file uses the default `vitest.config.ts` (node env). Three.js geometries (BufferGeometry, BufferAttribute) work in node without mocks — they're pure JS objects. No browser or mock needed for geometry benchmarks.
|
||||
|
||||
### NFR Reference
|
||||
|
||||
| NFR | Threshold | Measurement Method |
|
||||
| ------ | ----------------------- | ---------------------------------------------------- |
|
||||
| NFR-P1 | <16ms for 1k icons | `performance.now()` around `drawRelief()` + next RAF |
|
||||
| NFR-P2 | <100ms for 10k icons | Same as P1 |
|
||||
| NFR-P3 | <4ms toggle | `performance.now()` around `setVisible(false)` |
|
||||
| NFR-P4 | <8ms zoom latency | DevTools Performance tab flame graph |
|
||||
| NFR-P5 | <200ms init | `performance.now()` around `framework.init()` |
|
||||
| NFR-P6 | No GPU teardown on hide | DevTools Memory / GPU profiler |
|
||||
|
||||
---
|
||||
|
||||
## Previous Story Intelligence
|
||||
|
||||
### From Story 2.2 (draw-relief-icons.ts refactor)
|
||||
|
||||
- `window.drawRelief("webGL")` → calls `loadTexture(set).then(() => { buildReliefScene(icons); WebGL2LayerFramework.requestRender(); })`
|
||||
- `requestRender()` is RAF-coalesced: only one GPU draw per animation frame. Measurement must wait for the RAF callback.
|
||||
- `window.undrawRelief()` → calls `WebGL2LayerFramework.clearLayer("terrain")` which calls `group.clear()` — does NOT dispose GPU resources (NFR-P6 compliant)
|
||||
- `window.rerenderReliefIcons()` → single `WebGL2LayerFramework.requestRender()` call — this is the zoom path
|
||||
|
||||
### From Story 2.3 (fallback verification)
|
||||
|
||||
- `WebGL2LayerFramework.hasFallback` → true if WebGL2 unavailable; all methods are no-ops
|
||||
- For benchmarking, ensure WebGL2 IS available (test on a supported browser)
|
||||
- Test setup baseline: 43 unit tests passing, 88.51% statement coverage
|
||||
|
||||
### From Story 1.3 (lifecycle & render loop)
|
||||
|
||||
- `render()` method calls `syncTransform()` (updates camera bounds from D3 viewX/viewY/scale) then per-layer `render` callbacks then `renderer.render(scene, camera)`
|
||||
- RAF ID is set on `requestRender()` call and cleared in the callback — coalescing is confirmed working
|
||||
- `setVisible(id, false)` sets `group.visible = false` immediately — O(1) operation
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] **T1:** Create `src/renderers/draw-relief-icons.bench.ts`
|
||||
- [ ] T1a: Implement standalone `buildSetMeshBench` mirroring production logic (avoids exporting from source)
|
||||
- [ ] T1b: Add `makeIcons(n)` helper to generate synthetic `ReliefIcon` entries
|
||||
- [ ] T1c: Add `bench("buildSetMesh — 1,000 icons")` and `bench("buildSetMesh — 10,000 icons")`
|
||||
- [ ] T1d: Run `npx vitest bench src/renderers/draw-relief-icons.bench.ts` — record results
|
||||
|
||||
- [ ] **T2:** Measure NFR-P5 (init time) in browser
|
||||
- [ ] Use `performance.now()` before/after `WebGL2LayerFramework.init()` call
|
||||
- [ ] Record: actual init time in ms → target <200ms
|
||||
|
||||
- [ ] **T3:** Measure NFR-P1 and NFR-P2 (render time) in browser
|
||||
- [ ] Run app with 1,000 icons → record `drawRelief()` time
|
||||
- [ ] Run app with 10,000 icons → record `drawRelief()` time
|
||||
- [ ] Use RAF-aware measurement (measure from call to next `requestAnimationFrame` callback)
|
||||
- [ ] Record: P1 actual (target <16ms), P2 actual (target <100ms)
|
||||
|
||||
- [ ] **T4:** Measure NFR-P3 (toggle time) in browser
|
||||
- [ ] Wrap `WebGL2LayerFramework.setVisible('terrain', false)` in `performance.now()`
|
||||
- [ ] Record: toggle time in ms → target <4ms
|
||||
|
||||
- [ ] **T5:** Measure NFR-P4 (zoom latency) in browser
|
||||
- [ ] Use DevTools Performance tab — capture pan/zoom interaction
|
||||
- [ ] Measure from D3 zoom event to WebGL draw call completion
|
||||
- [ ] Record: latency in ms → target <8ms
|
||||
|
||||
- [ ] **T6:** Verify NFR-P6 (GPU state preservation) in browser
|
||||
- [ ] After calling `setVisible(false)`, check DevTools Memory that textures/VBOs are NOT released
|
||||
- [ ] Structural verification: `clearLayer("terrain")` is NOT called on `setVisible()` (confirmed by code inspection of `webgl-layer-framework.ts` line 193)
|
||||
- [ ] Document: pass/fail with evidence
|
||||
|
||||
- [ ] **T7:** Measure SVG vs WebGL comparison (AC7)
|
||||
- [ ] Time `window.drawRelief("svg")` for 5,000+ icons
|
||||
- [ ] Time `window.drawRelief("webGL")` for same icon set
|
||||
- [ ] Calculate % reduction → target >80%
|
||||
|
||||
- [ ] **T8:** `npm run lint` — zero errors (bench file must be lint-clean)
|
||||
|
||||
- [ ] **T9:** `npx vitest run` — all 43 existing tests still pass (bench file must not break unit tests)
|
||||
|
||||
- [ ] **T10:** Document all results in Dev Agent Record completion notes:
|
||||
- [ ] Bench output (T1d)
|
||||
- [ ] Browser measurements for P1–P6 (T2–T6)
|
||||
- [ ] SVG vs WebGL comparison (T7)
|
||||
- [ ] Pass/fail verdict for each NFR
|
||||
|
||||
---
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
_to be filled by dev agent_
|
||||
|
||||
### Debug Log References
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
_Record actual measured timings for each NFR here:_
|
||||
|
||||
| NFR | Target | Actual | Pass/Fail |
|
||||
| --------------------- | -------------- | ------ | --------- |
|
||||
| NFR-P1 (1k icons) | <16ms | _tbd_ | _tbd_ |
|
||||
| NFR-P2 (10k icons) | <100ms | _tbd_ | _tbd_ |
|
||||
| NFR-P3 (toggle) | <4ms | _tbd_ | _tbd_ |
|
||||
| NFR-P4 (zoom latency) | <8ms | _tbd_ | _tbd_ |
|
||||
| NFR-P5 (init) | <200ms | _tbd_ | _tbd_ |
|
||||
| NFR-P6 (GPU state) | no teardown | _tbd_ | _tbd_ |
|
||||
| AC7 (SVG vs WebGL) | >80% reduction | _tbd_ | _tbd_ |
|
||||
|
||||
### File List
|
||||
|
||||
_Files created/modified (to be filled by dev agent):_
|
||||
|
||||
- `src/renderers/draw-relief-icons.bench.ts` — NEW: geometry build benchmarks (vitest bench)
|
||||
303
_bmad-output/implementation-artifacts/3-2-bundle-size-audit.md
Normal file
303
_bmad-output/implementation-artifacts/3-2-bundle-size-audit.md
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
# Story 3.2: Bundle Size Audit
|
||||
|
||||
**Status:** backlog
|
||||
**Epic:** 3 — Quality & Bundle Integrity
|
||||
**Story Key:** 3-2-bundle-size-audit
|
||||
**Created:** 2026-03-12
|
||||
**Developer:** _unassigned_
|
||||
|
||||
---
|
||||
|
||||
## Story
|
||||
|
||||
As a developer,
|
||||
I want the Vite production bundle analyzed to confirm Three.js tree-shaking is effective and the total bundle size increase is within budget,
|
||||
So that the feature does not negatively impact page load performance.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC1:** Three.js named imports only (NFR-B1)
|
||||
**Given** `webgl-layer-framework.ts` and `draw-relief-icons.ts` source is inspected
|
||||
**When** Three.js import statements are reviewed
|
||||
**Then** no `import * as THREE from 'three'` exists in any `src/**/*.ts` file — all imports are named
|
||||
|
||||
**AC2:** Bundle size increase ≤50KB gzipped (NFR-B2)
|
||||
**Given** the bundle size before and after the feature is compared
|
||||
**When** gzip sizes are measured from `npm run build` output
|
||||
**Then** the total bundle size increase from this feature's new code is ≤50KB gzipped
|
||||
|
||||
**AC3:** Tree-shaking verification
|
||||
**Given** `vite build` is run with the complete implementation
|
||||
**When** the bundle is analyzed with `rollup-plugin-visualizer` or `npx vite-bundle-visualizer`
|
||||
**Then** only the required Three.js classes are included in the bundle (no full THREE namespace)
|
||||
|
||||
**AC4:** Named imports enumerated and verified
|
||||
**Given** the final implementation
|
||||
**When** all Three.js named imports in the project are listed
|
||||
**Then** the set matches the declared architecture list: `WebGLRenderer, Scene, OrthographicCamera, Group, BufferGeometry, BufferAttribute, Mesh, MeshBasicMaterial, TextureLoader, SRGBColorSpace, LinearMipmapLinearFilter, LinearFilter, DoubleSide`
|
||||
|
||||
**AC5:** Results documented
|
||||
**Given** the bundle audit completes
|
||||
**When** results are captured
|
||||
**Then** actual gzip delta is recorded in this story's Dev Agent Record and compared to the 50KB budget
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
### What This Story Is
|
||||
|
||||
This is a **build analysis and documentation story**. Run `npm run build`, inspect the output, verify tree-shaking, calculate the gzip size delta vs. the baseline (pre-feature), and document findings.
|
||||
|
||||
**Key architectural note:** Three.js is **already a project dependency** for the globe view (`public/libs/three.min.js` — pre-existing). The new WebGL relief feature adds TypeScript-side consumption of Three.js via `import {...} from 'three'` (Vite/Rollup tree-shaking). The budget is the delta of new classes uniquely added by this feature.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Story 3.1 debe be `done` (or both can be done in parallel — they're independent)
|
||||
- `npm run build` must produce a clean output (TypeScript errors would block this)
|
||||
- `npm run lint` must be clean
|
||||
|
||||
### Build Command
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
# = tsc && vite build
|
||||
# output: dist/ (built from src/, publicDir from public/)
|
||||
```
|
||||
|
||||
### Bundle Analysis Tools
|
||||
|
||||
Two options (no new prod dependencies required):
|
||||
|
||||
**Option A — rollup-plugin-visualizer (recommended):**
|
||||
|
||||
```bash
|
||||
npx rollup-plugin-visualizer --help # check availability
|
||||
# OR temporarily add to vite.config.ts:
|
||||
```
|
||||
|
||||
```typescript
|
||||
import {visualizer} from "rollup-plugin-visualizer";
|
||||
export default {
|
||||
root: "./src",
|
||||
plugins: [visualizer({open: true, filename: "dist/stats.html"})]
|
||||
// ... rest of config
|
||||
};
|
||||
```
|
||||
|
||||
Then `npm run build` — opens `dist/stats.html` in browser showing tree map.
|
||||
|
||||
**Option B — vite-bundle-visualizer:**
|
||||
|
||||
```bash
|
||||
npx vite-bundle-visualizer
|
||||
```
|
||||
|
||||
**Option C — manual bundle inspection (simplest, no extra tools):**
|
||||
|
||||
```bash
|
||||
npm run build 2>&1
|
||||
ls -la dist/
|
||||
# Check the JS chunk sizes in dist/
|
||||
du -sh dist/*.js
|
||||
# For gzip sizes:
|
||||
for f in dist/*.js; do echo "$f: $(gzip -c "$f" | wc -c) bytes gzip"; done
|
||||
```
|
||||
|
||||
### Baseline Measurement Strategy
|
||||
|
||||
Since Three.js was already included as a CDN/pre-bundled lib (via `public/libs/three.min.js`), the new feature adds **TypeScript module consumption** of Three.js via npm (named imports in `src/`). Vite will tree-shake these.
|
||||
|
||||
**Two-point comparison for NFR-B2 delta:**
|
||||
|
||||
1. **Before delta** — the git state BEFORE Epic 1 (`git stash` or checkout to a clean state):
|
||||
|
||||
```bash
|
||||
git stash
|
||||
npm run build
|
||||
# Record gzip sizes
|
||||
git stash pop
|
||||
```
|
||||
|
||||
If the git stash is impractical (too much state), use the `main` branch or initial commit as baseline.
|
||||
|
||||
2. **After delta** — current state:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
# Record gzip sizes
|
||||
```
|
||||
|
||||
Delta = (after) - (before) gzip size
|
||||
|
||||
3. **Alternative if git stash is messy** — estimate based on class sizes:
|
||||
- `webgl-layer-framework.ts` source: ~280 lines of TS ≈ ~5KB minified + gzip
|
||||
- `draw-relief-icons.ts` source: ~260 lines (substantially refactored) — net delta is small
|
||||
- Three.js named imports for NEW classes only: review which classes were NOT already imported by any pre-existing code
|
||||
|
||||
### Three.js Import Audit
|
||||
|
||||
**Classes used by `webgl-layer-framework.ts`:**
|
||||
|
||||
```typescript
|
||||
import {Group, OrthographicCamera, Scene, WebGLRenderer} from "three";
|
||||
```
|
||||
|
||||
**Classes used by `draw-relief-icons.ts`:**
|
||||
|
||||
```typescript
|
||||
import {
|
||||
type Group, // ← already in webgl-layer-framework.ts (shared, no extra bundle cost)
|
||||
type Texture,
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
DoubleSide,
|
||||
LinearFilter,
|
||||
LinearMipmapLinearFilter,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
SRGBColorSpace,
|
||||
TextureLoader
|
||||
} from "three";
|
||||
```
|
||||
|
||||
**Check for any `import * as THREE`** — should find ZERO:
|
||||
|
||||
```bash
|
||||
grep -r "import \* as THREE" src/
|
||||
# Expected output: (nothing)
|
||||
```
|
||||
|
||||
### Pre-existing Three.js Usage in Project
|
||||
|
||||
The project already has `public/libs/three.min.js` (CDN/pre-built). However, this is a **different bundle path** — it's a global script, not a module import. The Vite build for `src/` will bundle Three.js module imports separately via npm (`node_modules/three`).
|
||||
|
||||
**Check if Three.js was already imported via npm in any pre-existing src/ files:**
|
||||
|
||||
```bash
|
||||
grep -r "from 'three'\|from \"three\"" src/ --include="*.ts"
|
||||
```
|
||||
|
||||
If the globe view uses the pre-built `three.min.js` (global `THREE`) rather than ESM imports, then Three.js ESM bundle cost is **100% new** from this feature. If there are pre-existing ESM imports, the delta is only the newly added classes.
|
||||
|
||||
### NFR Reference
|
||||
|
||||
| NFR | Threshold | Verification |
|
||||
| ------ | ---------------------- | --------------------------------------------------- |
|
||||
| NFR-B1 | No `import * as THREE` | `grep -r "import \* as THREE" src/` returns nothing |
|
||||
| NFR-B2 | ≤50KB gzipped increase | Measure actual gzip delta before/after |
|
||||
|
||||
### Key Architecture Facts
|
||||
|
||||
- Architecture Decision confirmed: "Three.js is already present; adds no bundle cost" — [Source: `_bmad-output/planning-artifacts/architecture.md#Decision 1`]
|
||||
- This refers to Three.js being already a dependency; the NAMED import tree-shaking still matters
|
||||
- Framework code size estimate: ~5KB minified, ~2KB gzip [Source: `architecture.md#NFR-B2`]
|
||||
- Vite version: ^7.3.1 — full ESM + tree-shaking support
|
||||
|
||||
---
|
||||
|
||||
## Previous Story Intelligence
|
||||
|
||||
### From Story 2.2 (draw-relief-icons.ts refactor)
|
||||
|
||||
- Final named Three.js imports in `draw-relief-icons.ts`: `BufferAttribute, BufferGeometry, DoubleSide, Group (type), LinearFilter, LinearMipmapLinearFilter, Mesh, MeshBasicMaterial, SRGBColorSpace, Texture (type), TextureLoader`
|
||||
- The Biome import organizer (`organizeImports: on`) auto-orders imports alphabetically and moves `type` imports first. Confirmed lint-clean.
|
||||
- No `import * as THREE from "three"` remains anywhere in the project src/ tree.
|
||||
|
||||
### From Story 3.1 (performance benchmarking)
|
||||
|
||||
- `src/renderers/draw-relief-icons.bench.ts` may have been created in Story 3.1 — if so, verify its Three.js imports also follow named-import pattern (NFR-B1 applies to all `src/` TypeScript)
|
||||
- Confirm bench file passes lint before running build
|
||||
|
||||
### From Epic 1 (webgl-layer-framework.ts)
|
||||
|
||||
- `webgl-layer-framework.ts` imports: `Group, OrthographicCamera, Scene, WebGLRenderer` — 4 named classes
|
||||
- `draw-relief-icons.ts` imports: 9 additional named classes (bufffers, mesh, material, texture, consts)
|
||||
- Total unique Three.js classes pulled: 13 (some overlap between the two files — Rollup deduplicates)
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] **T1:** Verify NFR-B1 — no `import * as THREE` anywhere in `src/`
|
||||
- [ ] T1a: Run `grep -r "import \* as THREE" src/` — expect zero matches
|
||||
- [ ] T1b: Run `grep -r "import \* as THREE" src/` on bench file if created in Story 3.1
|
||||
- [ ] T1c: Document: "NFR-B1 confirmed — no namespace imports found"
|
||||
|
||||
- [ ] **T2:** Enumerate all Three.js named imports actually used
|
||||
- [ ] T2a: `grep -r "from \"three\"" src/ --include="*.ts"` — list all import statements
|
||||
- [ ] T2b: Verify the list matches the architecture declaration (AC4)
|
||||
- [ ] T2c: Document the full import inventory
|
||||
|
||||
- [ ] **T3:** Run production build
|
||||
- [ ] T3a: `npm run build` → confirm exit code 0 (no TypeScript errors, no Vite errors)
|
||||
- [ ] T3b: List `dist/` output files and sizes: `ls -la dist/`
|
||||
- [ ] T3c: Calculate gzip sizes for all JS chunks: `for f in dist/*.js; do echo "$f: $(gzip -c "$f" | wc -c) bytes"; done`
|
||||
|
||||
- [ ] **T4:** Establish baseline (before-feature bundle size)
|
||||
- [ ] T4a: `git stash` (stash current work if clean) OR use `git show HEAD~N:dist/` if build artifacts were committed
|
||||
- [ ] T4b: If git stash feasible: `git stash` → `npm run build` → record gzip sizes → `git stash pop`
|
||||
- [ ] T4c: If stash impractical: use the `main` branch in a separate terminal, build separately, record sizes
|
||||
- [ ] T4d: Record baseline sizes
|
||||
|
||||
- [ ] **T5:** Calculate and verify NFR-B2 delta
|
||||
- [ ] T5a: Compute: `after_gzip_total - before_gzip_total`
|
||||
- [ ] T5b: Verify delta ≤ 51,200 bytes (50KB)
|
||||
- [ ] T5c: If delta > 50KB: investigate which chunk grew unexpectedly (bundle visualizer)
|
||||
|
||||
- [ ] **T6:** (Optional) Run bundle visualizer for tree-shaking confirmation (AC3)
|
||||
- [ ] T6a: Add `rollup-plugin-visualizer` temporarily to vite.config.ts
|
||||
- [ ] T6b: Run `npm run build` → open `dist/stats.html`
|
||||
- [ ] T6c: Verify Three.js tree nodes show only the expected named classes
|
||||
- [ ] T6d: Remove the visualizer from vite.config.ts afterward (do not commit it in production config — or move to a separate `vite.analyze.ts` config)
|
||||
|
||||
- [ ] **T7:** `npm run lint` — zero errors (T6 vite.config.ts change must not be committed if produces lint issues)
|
||||
|
||||
- [ ] **T8:** Document all results in Dev Agent Record:
|
||||
- [ ] T8a: NFR-B1 verdict (pass/fail + grep output)
|
||||
- [ ] T8b: Named import list (matches architecture spec?)
|
||||
- [ ] T8c: Baseline gzip sizes
|
||||
- [ ] T8d: Post-feature gzip sizes
|
||||
- [ ] T8e: Delta in bytes and KB — pass/fail vs 50KB budget
|
||||
- [ ] T8f: Bundle visualizer screenshot path or description (if T6 executed)
|
||||
|
||||
---
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
_to be filled by dev agent_
|
||||
|
||||
### Debug Log References
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
_Record actual bundle measurements here:_
|
||||
|
||||
**NFR-B1:**
|
||||
|
||||
- `grep -r "import * as THREE" src/` result: _tbd_
|
||||
- Verdict: _tbd_
|
||||
|
||||
**NFR-B2:**
|
||||
|
||||
- Baseline bundle gzip total: _tbd_ bytes
|
||||
- Post-feature bundle gzip total: _tbd_ bytes
|
||||
- Delta: _tbd_ bytes (_tbd_ KB)
|
||||
- Budget: 51,200 bytes (50KB)
|
||||
- Verdict: _tbd_
|
||||
|
||||
**Named Three.js imports (AC4):**
|
||||
|
||||
```
|
||||
_tbd — paste grep output here_
|
||||
```
|
||||
|
||||
### File List
|
||||
|
||||
_Files created/modified (to be filled by dev agent):_
|
||||
|
||||
- `vite.config.ts` — TEMPORARY: add/remove visualizer plugin for T6 (do not commit)
|
||||
|
|
@ -49,13 +49,13 @@ development_status:
|
|||
|
||||
# Epic 2: Relief Icons Layer Migration
|
||||
epic-2: in-progress
|
||||
2-1-verify-and-implement-per-icon-rotation-in-buildsetmesh: ready-for-dev
|
||||
2-2-refactor-draw-relief-icons-ts-to-use-framework: ready-for-dev
|
||||
2-3-webgl2-fallback-integration-verification: ready-for-dev
|
||||
2-1-verify-and-implement-per-icon-rotation-in-buildsetmesh: done
|
||||
2-2-refactor-draw-relief-icons-ts-to-use-framework: review
|
||||
2-3-webgl2-fallback-integration-verification: review
|
||||
epic-2-retrospective: optional
|
||||
|
||||
# Epic 3: Quality & Bundle Integrity
|
||||
epic-3: backlog
|
||||
3-1-performance-benchmarking: backlog
|
||||
3-2-bundle-size-audit: backlog
|
||||
epic-3: in-progress
|
||||
3-1-performance-benchmarking: ready-for-dev
|
||||
3-2-bundle-size-audit: ready-for-dev
|
||||
epic-3-retrospective: optional
|
||||
|
|
|
|||
|
|
@ -1,135 +1,135 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<coverage generated="1773320690465" clover="3.2.0">
|
||||
<project timestamp="1773320690465" name="All files">
|
||||
<metrics statements="126" coveredstatements="115" conditionals="82" coveredconditionals="58" methods="19" coveredmethods="16" elements="227" coveredelements="189" complexity="0" loc="126" ncloc="126" packages="1" files="1" classes="1"/>
|
||||
<coverage generated="1773323271919" clover="3.2.0">
|
||||
<project timestamp="1773323271919" name="All files">
|
||||
<metrics statements="126" coveredstatements="115" conditionals="82" coveredconditionals="63" methods="19" coveredmethods="16" elements="227" coveredelements="194" complexity="0" loc="126" ncloc="126" packages="1" files="1" classes="1"/>
|
||||
<file name="webgl-layer-framework.ts" path="/Users/azgaar/Fantasy-Map-Generator/src/modules/webgl-layer-framework.ts">
|
||||
<metrics statements="126" coveredstatements="115" conditionals="82" coveredconditionals="58" methods="19" coveredmethods="16"/>
|
||||
<line num="27" count="11" type="stmt"/>
|
||||
<metrics statements="126" coveredstatements="115" conditionals="82" coveredconditionals="63" methods="19" coveredmethods="16"/>
|
||||
<line num="25" count="11" type="stmt"/>
|
||||
<line num="39" count="8" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="40" count="8" type="stmt"/>
|
||||
<line num="41" count="8" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="42" count="8" type="stmt"/>
|
||||
<line num="43" count="8" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="44" count="6" type="stmt"/>
|
||||
<line num="45" count="6" type="stmt"/>
|
||||
<line num="46" count="8" type="stmt"/>
|
||||
<line num="60" count="4" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="61" count="3" type="stmt"/>
|
||||
<line num="62" count="3" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="63" count="0" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="64" count="4" type="stmt"/>
|
||||
<line num="66" count="4" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="89" count="26" type="stmt"/>
|
||||
<line num="90" count="26" type="stmt"/>
|
||||
<line num="91" count="26" type="stmt"/>
|
||||
<line num="92" count="26" type="stmt"/>
|
||||
<line num="93" count="26" type="stmt"/>
|
||||
<line num="94" count="26" type="stmt"/>
|
||||
<line num="95" count="26" type="stmt"/>
|
||||
<line num="96" count="26" type="stmt"/>
|
||||
<line num="97" count="26" type="stmt"/>
|
||||
<line num="102" count="26" type="stmt"/>
|
||||
<line num="105" count="2" type="stmt"/>
|
||||
<line num="111" count="5" type="stmt"/>
|
||||
<line num="112" count="5" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="114" count="4" type="stmt"/>
|
||||
<line num="115" count="4" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="116" count="1" type="stmt"/>
|
||||
<line num="119" count="1" type="stmt"/>
|
||||
<line num="42" count="6" type="stmt"/>
|
||||
<line num="43" count="6" type="stmt"/>
|
||||
<line num="44" count="8" type="stmt"/>
|
||||
<line num="58" count="4" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="59" count="3" type="stmt"/>
|
||||
<line num="60" count="3" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="61" count="0" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="62" count="4" type="stmt"/>
|
||||
<line num="64" count="4" type="cond" truecount="0" falsecount="2"/>
|
||||
<line num="85" count="35" type="stmt"/>
|
||||
<line num="86" count="35" type="stmt"/>
|
||||
<line num="87" count="35" type="stmt"/>
|
||||
<line num="88" count="35" type="stmt"/>
|
||||
<line num="89" count="35" type="stmt"/>
|
||||
<line num="90" count="35" type="stmt"/>
|
||||
<line num="91" count="35" type="stmt"/>
|
||||
<line num="92" count="35" type="stmt"/>
|
||||
<line num="93" count="35" type="stmt"/>
|
||||
<line num="94" count="35" type="stmt"/>
|
||||
<line num="97" count="3" type="stmt"/>
|
||||
<line num="101" count="5" type="stmt"/>
|
||||
<line num="102" count="5" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="104" count="4" type="stmt"/>
|
||||
<line num="105" count="4" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="106" count="1" type="stmt"/>
|
||||
<line num="109" count="1" type="stmt"/>
|
||||
<line num="113" count="3" type="stmt"/>
|
||||
<line num="114" count="3" type="stmt"/>
|
||||
<line num="115" count="3" type="stmt"/>
|
||||
<line num="116" count="3" type="stmt"/>
|
||||
<line num="117" count="3" type="stmt"/>
|
||||
<line num="118" count="3" type="stmt"/>
|
||||
<line num="121" count="3" type="stmt"/>
|
||||
<line num="122" count="3" type="stmt"/>
|
||||
<line num="123" count="3" type="stmt"/>
|
||||
<line num="124" count="3" type="stmt"/>
|
||||
<line num="125" count="3" type="stmt"/>
|
||||
<line num="126" count="3" type="stmt"/>
|
||||
<line num="127" count="3" type="stmt"/>
|
||||
<line num="128" count="3" type="stmt"/>
|
||||
<line num="131" count="3" type="stmt"/>
|
||||
<line num="132" count="3" type="stmt"/>
|
||||
<line num="133" count="3" type="stmt"/>
|
||||
<line num="134" count="3" type="stmt"/>
|
||||
<line num="135" count="3" type="stmt"/>
|
||||
<line num="136" count="3" type="stmt"/>
|
||||
<line num="137" count="3" type="stmt"/>
|
||||
<line num="138" count="3" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="139" count="5" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="128" count="3" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="129" count="5" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="130" count="5" type="stmt"/>
|
||||
<line num="131" count="5" type="stmt"/>
|
||||
<line num="134" count="5" type="stmt"/>
|
||||
<line num="139" count="5" type="stmt"/>
|
||||
<line num="140" count="5" type="stmt"/>
|
||||
<line num="141" count="5" type="stmt"/>
|
||||
<line num="144" count="5" type="stmt"/>
|
||||
<line num="149" count="5" type="stmt"/>
|
||||
<line num="150" count="5" type="stmt"/>
|
||||
<line num="151" count="5" type="stmt"/>
|
||||
<line num="160" count="5" type="stmt"/>
|
||||
<line num="163" count="5" type="stmt"/>
|
||||
<line num="164" count="1" type="stmt"/>
|
||||
<line num="165" count="1" type="stmt"/>
|
||||
<line num="166" count="1" type="stmt"/>
|
||||
<line num="167" count="1" type="stmt"/>
|
||||
<line num="168" count="1" type="stmt"/>
|
||||
<line num="170" count="3" type="stmt"/>
|
||||
<line num="172" count="3" type="stmt"/>
|
||||
<line num="174" count="3" type="stmt"/>
|
||||
<line num="178" count="4" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="180" count="4" type="stmt"/>
|
||||
<line num="181" count="4" type="stmt"/>
|
||||
<line num="184" count="0" type="stmt"/>
|
||||
<line num="185" count="0" type="stmt"/>
|
||||
<line num="186" count="0" type="stmt"/>
|
||||
<line num="187" count="0" type="stmt"/>
|
||||
<line num="188" count="0" type="stmt"/>
|
||||
<line num="192" count="2" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="193" count="2" type="stmt"/>
|
||||
<line num="194" count="2" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="195" count="2" type="stmt"/>
|
||||
<line num="196" count="2" type="stmt"/>
|
||||
<line num="197" count="2" type="stmt"/>
|
||||
<line num="198" count="2" type="stmt"/>
|
||||
<line num="199" count="2" type="stmt"/>
|
||||
<line num="200" count="2" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="204" count="4" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="205" count="4" type="stmt"/>
|
||||
<line num="206" count="4" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="207" count="4" type="stmt"/>
|
||||
<line num="208" count="5" type="stmt"/>
|
||||
<line num="209" count="4" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="210" count="4" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="214" count="3" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="215" count="3" type="stmt"/>
|
||||
<line num="216" count="3" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="217" count="3" type="stmt"/>
|
||||
<line num="221" count="10" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="222" count="10" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="223" count="6" type="stmt"/>
|
||||
<line num="224" count="3" type="stmt"/>
|
||||
<line num="225" count="3" type="stmt"/>
|
||||
<line num="230" count="3" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="231" count="3" type="stmt"/>
|
||||
<line num="232" count="3" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="233" count="3" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="234" count="3" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="235" count="3" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="236" count="3" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="237" count="3" type="stmt"/>
|
||||
<line num="244" count="3" type="stmt"/>
|
||||
<line num="245" count="3" type="stmt"/>
|
||||
<line num="246" count="3" type="stmt"/>
|
||||
<line num="247" count="3" type="stmt"/>
|
||||
<line num="153" count="5" type="stmt"/>
|
||||
<line num="154" count="1" type="stmt"/>
|
||||
<line num="155" count="1" type="stmt"/>
|
||||
<line num="156" count="1" type="stmt"/>
|
||||
<line num="157" count="1" type="stmt"/>
|
||||
<line num="158" count="1" type="stmt"/>
|
||||
<line num="160" count="3" type="stmt"/>
|
||||
<line num="161" count="3" type="stmt"/>
|
||||
<line num="163" count="3" type="stmt"/>
|
||||
<line num="167" count="6" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="169" count="6" type="stmt"/>
|
||||
<line num="170" count="6" type="stmt"/>
|
||||
<line num="173" count="0" type="stmt"/>
|
||||
<line num="174" count="0" type="stmt"/>
|
||||
<line num="175" count="0" type="stmt"/>
|
||||
<line num="176" count="0" type="stmt"/>
|
||||
<line num="177" count="0" type="stmt"/>
|
||||
<line num="181" count="4" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="182" count="2" type="stmt"/>
|
||||
<line num="183" count="2" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="184" count="2" type="stmt"/>
|
||||
<line num="185" count="2" type="stmt"/>
|
||||
<line num="186" count="2" type="stmt"/>
|
||||
<line num="187" count="2" type="stmt"/>
|
||||
<line num="188" count="2" type="stmt"/>
|
||||
<line num="189" count="2" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="193" count="7" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="194" count="4" type="stmt"/>
|
||||
<line num="195" count="4" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="196" count="4" type="stmt"/>
|
||||
<line num="197" count="5" type="stmt"/>
|
||||
<line num="198" count="4" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="199" count="4" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="203" count="5" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="204" count="3" type="stmt"/>
|
||||
<line num="205" count="3" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="206" count="3" type="stmt"/>
|
||||
<line num="210" count="12" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="211" count="10" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="212" count="6" type="stmt"/>
|
||||
<line num="213" count="3" type="stmt"/>
|
||||
<line num="214" count="3" type="stmt"/>
|
||||
<line num="219" count="5" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="220" count="3" type="stmt"/>
|
||||
<line num="221" count="3" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="222" count="5" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="223" count="5" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="224" count="5" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="225" count="5" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="226" count="5" type="stmt"/>
|
||||
<line num="233" count="5" type="stmt"/>
|
||||
<line num="234" count="5" type="stmt"/>
|
||||
<line num="235" count="5" type="stmt"/>
|
||||
<line num="236" count="5" type="stmt"/>
|
||||
<line num="237" count="5" type="stmt"/>
|
||||
<line num="242" count="3" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="243" count="0" type="stmt"/>
|
||||
<line num="247" count="3" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="248" count="3" type="stmt"/>
|
||||
<line num="255" count="3" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="256" count="0" type="stmt"/>
|
||||
<line num="260" count="3" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="261" count="3" type="stmt"/>
|
||||
<line num="262" count="0" type="stmt"/>
|
||||
<line num="263" count="0" type="cond" truecount="0" falsecount="4"/>
|
||||
<line num="264" count="0" type="stmt"/>
|
||||
<line num="265" count="0" type="stmt"/>
|
||||
<line num="268" count="3" type="stmt"/>
|
||||
<line num="272" count="3" type="cond" truecount="6" falsecount="0"/>
|
||||
<line num="273" count="2" type="stmt"/>
|
||||
<line num="274" count="2" type="stmt"/>
|
||||
<line num="275" count="2" type="stmt"/>
|
||||
<line num="276" count="2" type="stmt"/>
|
||||
<line num="277" count="2" type="stmt"/>
|
||||
<line num="278" count="2" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="279" count="1" type="stmt"/>
|
||||
<line num="282" count="2" type="stmt"/>
|
||||
<line num="292" count="1" type="stmt"/>
|
||||
<line num="249" count="0" type="stmt"/>
|
||||
<line num="250" count="0" type="cond" truecount="0" falsecount="4"/>
|
||||
<line num="251" count="0" type="stmt"/>
|
||||
<line num="252" count="0" type="stmt"/>
|
||||
<line num="255" count="3" type="stmt"/>
|
||||
<line num="259" count="3" type="cond" truecount="6" falsecount="0"/>
|
||||
<line num="260" count="2" type="stmt"/>
|
||||
<line num="261" count="2" type="stmt"/>
|
||||
<line num="262" count="2" type="stmt"/>
|
||||
<line num="263" count="2" type="stmt"/>
|
||||
<line num="264" count="2" type="stmt"/>
|
||||
<line num="265" count="2" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="266" count="1" type="stmt"/>
|
||||
<line num="269" count="2" type="stmt"/>
|
||||
<line num="276" count="1" type="stmt"/>
|
||||
</file>
|
||||
</project>
|
||||
</coverage>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -23,16 +23,16 @@
|
|||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">85.13% </span>
|
||||
<span class="strong">88.51% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>126/148</span>
|
||||
<span class='fraction'>131/148</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">70.73% </span>
|
||||
<span class="strong">76.82% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>58/82</span>
|
||||
<span class='fraction'>63/82</span>
|
||||
</div>
|
||||
|
||||
|
||||
|
|
@ -80,13 +80,13 @@
|
|||
</thead>
|
||||
<tbody><tr>
|
||||
<td class="file high" data-value="webgl-layer-framework.ts"><a href="webgl-layer-framework.ts.html">webgl-layer-framework.ts</a></td>
|
||||
<td data-value="85.13" class="pic high">
|
||||
<div class="chart"><div class="cover-fill" style="width: 85%"></div><div class="cover-empty" style="width: 15%"></div></div>
|
||||
<td data-value="88.51" class="pic high">
|
||||
<div class="chart"><div class="cover-fill" style="width: 88%"></div><div class="cover-empty" style="width: 12%"></div></div>
|
||||
</td>
|
||||
<td data-value="85.13" class="pct high">85.13%</td>
|
||||
<td data-value="148" class="abs high">126/148</td>
|
||||
<td data-value="70.73" class="pct medium">70.73%</td>
|
||||
<td data-value="82" class="abs medium">58/82</td>
|
||||
<td data-value="88.51" class="pct high">88.51%</td>
|
||||
<td data-value="148" class="abs high">131/148</td>
|
||||
<td data-value="76.82" class="pct medium">76.82%</td>
|
||||
<td data-value="82" class="abs medium">63/82</td>
|
||||
<td data-value="84.21" class="pct high">84.21%</td>
|
||||
<td data-value="19" class="abs high">16/19</td>
|
||||
<td data-value="91.26" class="pct high">91.26%</td>
|
||||
|
|
@ -101,7 +101,7 @@
|
|||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-03-12T13:04:50.459Z
|
||||
at 2026-03-12T13:47:51.911Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -23,16 +23,16 @@
|
|||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">85.13% </span>
|
||||
<span class="strong">88.51% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>126/148</span>
|
||||
<span class='fraction'>131/148</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">70.73% </span>
|
||||
<span class="strong">76.82% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>58/82</span>
|
||||
<span class='fraction'>63/82</span>
|
||||
</div>
|
||||
|
||||
|
||||
|
|
@ -339,25 +339,7 @@
|
|||
<a name='L274'></a><a href='#L274'>274</a>
|
||||
<a name='L275'></a><a href='#L275'>275</a>
|
||||
<a name='L276'></a><a href='#L276'>276</a>
|
||||
<a name='L277'></a><a href='#L277'>277</a>
|
||||
<a name='L278'></a><a href='#L278'>278</a>
|
||||
<a name='L279'></a><a href='#L279'>279</a>
|
||||
<a name='L280'></a><a href='#L280'>280</a>
|
||||
<a name='L281'></a><a href='#L281'>281</a>
|
||||
<a name='L282'></a><a href='#L282'>282</a>
|
||||
<a name='L283'></a><a href='#L283'>283</a>
|
||||
<a name='L284'></a><a href='#L284'>284</a>
|
||||
<a name='L285'></a><a href='#L285'>285</a>
|
||||
<a name='L286'></a><a href='#L286'>286</a>
|
||||
<a name='L287'></a><a href='#L287'>287</a>
|
||||
<a name='L288'></a><a href='#L288'>288</a>
|
||||
<a name='L289'></a><a href='#L289'>289</a>
|
||||
<a name='L290'></a><a href='#L290'>290</a>
|
||||
<a name='L291'></a><a href='#L291'>291</a>
|
||||
<a name='L292'></a><a href='#L292'>292</a>
|
||||
<a name='L293'></a><a href='#L293'>293</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<a name='L277'></a><a href='#L277'>277</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
|
|
@ -441,27 +423,19 @@
|
|||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">35x</span>
|
||||
<span class="cline-any cline-yes">35x</span>
|
||||
<span class="cline-any cline-yes">35x</span>
|
||||
<span class="cline-any cline-yes">35x</span>
|
||||
<span class="cline-any cline-yes">35x</span>
|
||||
<span class="cline-any cline-yes">35x</span>
|
||||
<span class="cline-any cline-yes">35x</span>
|
||||
<span class="cline-any cline-yes">35x</span>
|
||||
<span class="cline-any cline-yes">35x</span>
|
||||
<span class="cline-any cline-yes">35x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
|
|
@ -525,17 +499,16 @@
|
|||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
|
|
@ -546,19 +519,19 @@
|
|||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
|
|
@ -568,14 +541,14 @@
|
|||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-yes">12x</span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
|
|
@ -584,27 +557,25 @@
|
|||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
|
|
@ -643,14 +614,9 @@
|
|||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import { Group, OrthographicCamera, Scene, WebGLRenderer } from "three";
|
||||
|
||||
// ─── Pure exports (testable without DOM or WebGL) ────────────────────────────
|
||||
|
||||
/**
|
||||
* Converts a D3 zoom transform into orthographic camera bounds.
|
||||
*
|
||||
|
|
@ -732,8 +698,6 @@ interface RegisteredLayer {
|
|||
group: Group; // framework-owned; passed to all callbacks — abstraction boundary
|
||||
}
|
||||
|
||||
// ─── Class ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export class WebGL2LayerFrameworkClass {
|
||||
private canvas: HTMLCanvasElement | null = null;
|
||||
private renderer: WebGLRenderer | null = null;
|
||||
|
|
@ -744,17 +708,11 @@ export class WebGL2LayerFrameworkClass {
|
|||
private resizeObserver: ResizeObserver | null = null;
|
||||
private rafId: number | null = null;
|
||||
private container: HTMLElement | null = null;
|
||||
|
||||
// Backing field — MUST NOT be declared readonly.
|
||||
// readonly fields can only be assigned in the constructor; init() sets _fallback
|
||||
// post-construction, which would cause a TypeScript type error with readonly.
|
||||
private _fallback = false;
|
||||
|
||||
get hasFallback(): boolean {
|
||||
return this._fallback;
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
init(): boolean {
|
||||
this._fallback = !detectWebGL2();
|
||||
|
|
@ -817,7 +775,6 @@ export class WebGL2LayerFrameworkClass {
|
|||
this.layers.set(config.id, { config, group });
|
||||
}
|
||||
this.pendingConfigs = [];
|
||||
|
||||
this.observeResize();
|
||||
|
||||
return true;
|
||||
|
|
@ -838,7 +795,7 @@ export class WebGL2LayerFrameworkClass {
|
|||
}
|
||||
|
||||
unregister(id: string): void {
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (this._fallback) <span class="cstat-no" title="statement not covered" >return;</span>
|
||||
if (this._fallback) return;
|
||||
const layer = this.layers.get(id);
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (!layer || !this.scene) <span class="cstat-no" title="statement not covered" >return;</span>
|
||||
const scene = this.scene;
|
||||
|
|
@ -850,7 +807,7 @@ export class WebGL2LayerFrameworkClass {
|
|||
}
|
||||
|
||||
setVisible(id: string, visible: boolean): void {
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (this._fallback) <span class="cstat-no" title="statement not covered" >return;</span>
|
||||
if (this._fallback) return;
|
||||
const layer = this.layers.get(id);
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (!layer) <span class="cstat-no" title="statement not covered" >return;</span>
|
||||
layer.group.visible = visible;
|
||||
|
|
@ -860,14 +817,14 @@ export class WebGL2LayerFrameworkClass {
|
|||
}
|
||||
|
||||
clearLayer(id: string): void {
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (this._fallback) <span class="cstat-no" title="statement not covered" >return;</span>
|
||||
if (this._fallback) return;
|
||||
const layer = this.layers.get(id);
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (!layer) <span class="cstat-no" title="statement not covered" >return;</span>
|
||||
layer.group.clear();
|
||||
}
|
||||
|
||||
requestRender(): void {
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (this._fallback) <span class="cstat-no" title="statement not covered" >return;</span>
|
||||
if (this._fallback) return;
|
||||
if (this.rafId !== null) return;
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
this.rafId = null;
|
||||
|
|
@ -876,7 +833,7 @@ export class WebGL2LayerFrameworkClass {
|
|||
}
|
||||
|
||||
syncTransform(): void {
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (this._fallback || !this.camera) <span class="cstat-no" title="statement not covered" >return;</span>
|
||||
if (this._fallback || !this.camera) return;
|
||||
const camera = this.camera;
|
||||
const viewX = (globalThis as any).viewX ?? 0;
|
||||
const viewY = (globalThis as any).viewY ?? 0;
|
||||
|
|
@ -896,8 +853,6 @@ export class WebGL2LayerFrameworkClass {
|
|||
camera.bottom = bounds.bottom;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
// ─── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private subscribeD3Zoom(): void {
|
||||
// viewbox is a D3 selection global available in the browser; guard for Node test env
|
||||
|
|
@ -932,9 +887,6 @@ export class WebGL2LayerFrameworkClass {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Global registration (MUST be last line) ─────────────────────────────────
|
||||
// Uses globalThis (≡ window in browsers) to support both browser runtime and
|
||||
// Node.js test environments without a ReferenceError.
|
||||
declare global {
|
||||
var WebGL2LayerFramework: WebGL2LayerFrameworkClass;
|
||||
}
|
||||
|
|
@ -946,7 +898,7 @@ globalThis.WebGL2LayerFramework = new WebGL2LayerFrameworkClass();
|
|||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-03-12T13:04:50.459Z
|
||||
at 2026-03-12T13:47:51.911Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -559,3 +559,75 @@ describe("WebGL2LayerFrameworkClass — lifecycle & render loop (Story 1.3)", ()
|
|||
expect(canvas.style.display).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── WebGL2LayerFramework fallback no-op path (Story 2.3) ───────────────────
|
||||
|
||||
describe("WebGL2LayerFramework — fallback no-op path (Story 2.3)", () => {
|
||||
let framework: WebGL2LayerFrameworkClass;
|
||||
|
||||
const makeConfig = () => ({
|
||||
id: "terrain",
|
||||
anchorLayerId: "terrain",
|
||||
renderOrder: 2,
|
||||
setup: vi.fn(),
|
||||
render: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
framework = new WebGL2LayerFrameworkClass();
|
||||
(framework as any)._fallback = true;
|
||||
});
|
||||
|
||||
it("hasFallback getter returns true when _fallback is set", () => {
|
||||
expect(framework.hasFallback).toBe(true);
|
||||
});
|
||||
|
||||
it("register() queues config but does not call setup() when fallback is active", () => {
|
||||
// When _fallback=true, scene is null (init() exits early without creating scene).
|
||||
// register() therefore queues into pendingConfigs[] — setup() is never called.
|
||||
const config = makeConfig();
|
||||
expect(() => framework.register(config)).not.toThrow();
|
||||
expect(config.setup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("setVisible() is a no-op when fallback is active — no exception for false", () => {
|
||||
expect(() => framework.setVisible("terrain", false)).not.toThrow();
|
||||
});
|
||||
|
||||
it("setVisible() is a no-op when fallback is active — no exception for true", () => {
|
||||
expect(() => framework.setVisible("terrain", true)).not.toThrow();
|
||||
});
|
||||
|
||||
it("clearLayer() is a no-op when fallback is active", () => {
|
||||
expect(() => framework.clearLayer("terrain")).not.toThrow();
|
||||
});
|
||||
|
||||
it("requestRender() is a no-op when fallback is active — RAF not scheduled", () => {
|
||||
const rafMock = vi.fn().mockReturnValue(1);
|
||||
vi.stubGlobal("requestAnimationFrame", rafMock);
|
||||
expect(() => framework.requestRender()).not.toThrow();
|
||||
expect(rafMock).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("unregister() is a no-op when fallback is active", () => {
|
||||
expect(() => framework.unregister("terrain")).not.toThrow();
|
||||
});
|
||||
|
||||
it("syncTransform() is a no-op when fallback is active", () => {
|
||||
expect(() => framework.syncTransform()).not.toThrow();
|
||||
});
|
||||
|
||||
it("NFR-C1: no console.error emitted during fallback operations", () => {
|
||||
const errorSpy = vi.spyOn(console, "error");
|
||||
framework.register(makeConfig());
|
||||
framework.setVisible("terrain", false);
|
||||
framework.clearLayer("terrain");
|
||||
framework.requestRender();
|
||||
framework.unregister("terrain");
|
||||
framework.syncTransform();
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,40 +1,71 @@
|
|||
import * as THREE from "three";
|
||||
import {
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
DoubleSide,
|
||||
type Group,
|
||||
LinearFilter,
|
||||
LinearMipmapLinearFilter,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
SRGBColorSpace,
|
||||
type Texture,
|
||||
TextureLoader,
|
||||
} from "three";
|
||||
import { RELIEF_SYMBOLS } from "../config/relief-config";
|
||||
import type { ReliefIcon } from "../modules/relief-generator";
|
||||
import { generateRelief } from "../modules/relief-generator";
|
||||
import { getLayerZIndex } from "../modules/webgl-layer-framework";
|
||||
import { byId } from "../utils";
|
||||
|
||||
let glCanvas: HTMLCanvasElement | null = null;
|
||||
let renderer: THREE.WebGLRenderer | null = null;
|
||||
let camera: THREE.OrthographicCamera | null = null;
|
||||
let scene: THREE.Scene | null = null;
|
||||
|
||||
const textureCache = new Map<string, THREE.Texture>(); // set name → THREE.Texture
|
||||
|
||||
const textureCache = new Map<string, Texture>(); // set name → Texture
|
||||
let terrainGroup: Group | null = null;
|
||||
let lastBuiltIcons: ReliefIcon[] | null = null;
|
||||
let lastBuiltSet: string | null = null;
|
||||
|
||||
WebGL2LayerFramework.register({
|
||||
id: "terrain",
|
||||
anchorLayerId: "terrain",
|
||||
renderOrder: getLayerZIndex("terrain"),
|
||||
setup(group: Group): void {
|
||||
terrainGroup = group;
|
||||
preloadTextures();
|
||||
},
|
||||
render(_group: Group): void {
|
||||
// no-op: relief geometry is static between drawRelief() calls
|
||||
},
|
||||
dispose(group: Group): void {
|
||||
group.traverse((obj) => {
|
||||
if (obj instanceof Mesh) {
|
||||
obj.geometry.dispose();
|
||||
(obj.material as MeshBasicMaterial).map?.dispose();
|
||||
(obj.material as MeshBasicMaterial).dispose();
|
||||
}
|
||||
});
|
||||
disposeTextureCache();
|
||||
},
|
||||
});
|
||||
|
||||
function preloadTextures(): void {
|
||||
for (const set of Object.keys(RELIEF_SYMBOLS)) loadTexture(set);
|
||||
}
|
||||
|
||||
function loadTexture(set: string): Promise<THREE.Texture | null> {
|
||||
function loadTexture(set: string): Promise<Texture | null> {
|
||||
if (textureCache.has(set))
|
||||
return Promise.resolve(textureCache.get(set) || null);
|
||||
return Promise.resolve(textureCache.get(set) ?? null);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const loader = new THREE.TextureLoader();
|
||||
const loader = new TextureLoader();
|
||||
loader.load(
|
||||
`images/relief/${set}.png`,
|
||||
(texture) => {
|
||||
texture.flipY = false;
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.needsUpdate = true;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.minFilter = LinearMipmapLinearFilter;
|
||||
texture.magFilter = LinearFilter;
|
||||
texture.generateMipmaps = true;
|
||||
if (renderer)
|
||||
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
||||
// renderer.capabilities.getMaxAnisotropy() removed: renderer is now owned by
|
||||
// WebGL2LayerFramework. LinearMipmapLinearFilter provides sufficient quality.
|
||||
textureCache.set(set, texture);
|
||||
resolve(texture);
|
||||
},
|
||||
|
|
@ -47,64 +78,6 @@ function loadTexture(set: string): Promise<THREE.Texture | null> {
|
|||
});
|
||||
}
|
||||
|
||||
function ensureRenderer(): boolean {
|
||||
if (!byId("terrain")) return false;
|
||||
|
||||
if (renderer) {
|
||||
if (renderer.getContext().isContextLost()) {
|
||||
// Recover from WebGL context loss
|
||||
renderer.forceContextRestore();
|
||||
renderer.dispose();
|
||||
renderer = null;
|
||||
camera = null;
|
||||
scene = null;
|
||||
glCanvas = null;
|
||||
disposeTextureCache();
|
||||
lastBuiltIcons = null;
|
||||
lastBuiltSet = null;
|
||||
} else {
|
||||
// Re-attach if the canvas was removed from the DOM externally.
|
||||
if (glCanvas && !glCanvas.isConnected) {
|
||||
const terrainSvg = byId("map-layer-terrain");
|
||||
if (terrainSvg)
|
||||
terrainSvg.parentElement!.insertBefore(glCanvas, terrainSvg);
|
||||
else document.body.appendChild(glCanvas);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
glCanvas = document.createElement("canvas");
|
||||
glCanvas.id = "terrainCanvas";
|
||||
glCanvas.style.cssText =
|
||||
"display:block;pointer-events:none;position:absolute;top:0;left:0";
|
||||
const map = byId("map");
|
||||
if (map) document.body.insertAdjacentElement("afterend", glCanvas);
|
||||
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
canvas: glCanvas,
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
});
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.setPixelRatio(window.devicePixelRatio || 1);
|
||||
renderer.setSize(graphWidth, graphHeight);
|
||||
} catch (e) {
|
||||
console.error("Relief: WebGL init failed", e);
|
||||
glCanvas.remove();
|
||||
glCanvas = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Camera in SVG coordinate space: top=0, bottom=H puts map y=0 at screen-top.
|
||||
camera = new THREE.OrthographicCamera(0, graphWidth, 0, graphHeight, -1, 1);
|
||||
scene = new THREE.Scene();
|
||||
|
||||
preloadTextures();
|
||||
return true;
|
||||
}
|
||||
|
||||
// map a symbol href to its atlas set and tile index
|
||||
function resolveSprite(symbolHref: string): {
|
||||
set: string;
|
||||
|
|
@ -118,12 +91,12 @@ function resolveSprite(symbolHref: string): {
|
|||
throw new Error(`Relief: unknown symbol href "${symbolHref}"`);
|
||||
}
|
||||
|
||||
// Build a BufferGeometry with all icon quads for one atlas set.
|
||||
// Build a Mesh with all icon quads for one atlas set.
|
||||
function buildSetMesh(
|
||||
entries: Array<{ icon: ReliefIcon; tileIndex: number }>,
|
||||
set: string,
|
||||
texture: any,
|
||||
): any {
|
||||
texture: Texture,
|
||||
): Mesh {
|
||||
const ids = RELIEF_SYMBOLS[set] ?? [];
|
||||
const n = ids.length || 1;
|
||||
const cols = Math.ceil(Math.sqrt(n));
|
||||
|
|
@ -142,6 +115,12 @@ function buildSetMesh(
|
|||
u1 = (col + 1) / cols;
|
||||
const v0 = row / rows,
|
||||
v1 = (row + 1) / rows;
|
||||
// FR15 rotation verification (Story 2.1): r.i is a sequential icon index (0-based),
|
||||
// NOT a rotation angle. pack.relief entries contain no rotation field.
|
||||
// Both the WebGL path (this function) and the SVG fallback (drawSvg) produce
|
||||
// unrotated icons — visual parity maintained per FR19.
|
||||
// If per-icon rotation is required in a future story, add `rotation: number` (radians)
|
||||
// to ReliefIcon and apply quad rotation around center (r.x + r.s/2, r.y + r.s/2).
|
||||
const x0 = r.x,
|
||||
x1 = r.x + r.s;
|
||||
const y0 = r.y,
|
||||
|
|
@ -163,20 +142,20 @@ function buildSetMesh(
|
|||
ii += 6;
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
|
||||
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||
const geo = new BufferGeometry();
|
||||
geo.setAttribute("position", new BufferAttribute(positions, 3));
|
||||
geo.setAttribute("uv", new BufferAttribute(uvs, 2));
|
||||
geo.setIndex(new BufferAttribute(indices, 1));
|
||||
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
const mat = new MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
side: DoubleSide,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
return new THREE.Mesh(geo, mat);
|
||||
return new Mesh(geo, mat);
|
||||
}
|
||||
|
||||
function disposeTextureCache(): void {
|
||||
|
|
@ -184,25 +163,15 @@ function disposeTextureCache(): void {
|
|||
textureCache.clear();
|
||||
}
|
||||
|
||||
function disposeScene(): void {
|
||||
if (!scene) return;
|
||||
while (scene.children.length) {
|
||||
const mesh = scene.children[0] as THREE.Mesh<
|
||||
THREE.BufferGeometry,
|
||||
THREE.Material
|
||||
>;
|
||||
scene.remove(mesh);
|
||||
mesh.geometry?.dispose();
|
||||
if (mesh.material && "map" in mesh.material) {
|
||||
mesh.material.map = null;
|
||||
mesh.material.dispose();
|
||||
function buildReliefScene(icons: ReliefIcon[]): void {
|
||||
if (!terrainGroup) return;
|
||||
terrainGroup.traverse((obj) => {
|
||||
if (obj instanceof Mesh) {
|
||||
obj.geometry.dispose();
|
||||
(obj.material as MeshBasicMaterial).dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildScene(icons: ReliefIcon[]): void {
|
||||
if (!scene) return;
|
||||
disposeScene();
|
||||
});
|
||||
terrainGroup.clear();
|
||||
|
||||
const bySet = new Map<
|
||||
string,
|
||||
|
|
@ -221,40 +190,7 @@ function buildScene(icons: ReliefIcon[]): void {
|
|||
for (const [set, setEntries] of bySet) {
|
||||
const texture = textureCache.get(set);
|
||||
if (!texture) continue;
|
||||
scene.add(buildSetMesh(setEntries, set, texture));
|
||||
}
|
||||
}
|
||||
|
||||
function renderFrame(): void {
|
||||
if (!renderer || !camera || !scene) return;
|
||||
|
||||
const x = -viewX / scale;
|
||||
const y = -viewY / scale;
|
||||
const w = graphWidth / scale;
|
||||
const h = graphHeight / scale;
|
||||
|
||||
camera.left = x;
|
||||
camera.right = x + w;
|
||||
camera.top = y;
|
||||
camera.bottom = y + h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
function drawWebGl(icons: ReliefIcon[], parentEl: HTMLElement): void {
|
||||
const set = parentEl.getAttribute("set") || "simple";
|
||||
|
||||
if (ensureRenderer()) {
|
||||
loadTexture(set).then(() => {
|
||||
if (icons !== lastBuiltIcons || set !== lastBuiltSet) {
|
||||
buildScene(icons);
|
||||
lastBuiltIcons = icons;
|
||||
lastBuiltSet = set;
|
||||
}
|
||||
renderFrame();
|
||||
});
|
||||
} else {
|
||||
WARN && console.warn("Relief: WebGL renderer failed");
|
||||
terrainGroup.add(buildSetMesh(setEntries, set, texture));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -274,46 +210,36 @@ window.drawRelief = (
|
|||
if (!parentEl) throw new Error("Relief: parent element not found");
|
||||
|
||||
parentEl.innerHTML = "";
|
||||
parentEl.dataset.mode = "webGL";
|
||||
parentEl.dataset.mode = type;
|
||||
|
||||
const icons = pack.relief?.length ? pack.relief : generateRelief();
|
||||
if (!icons.length) return;
|
||||
|
||||
if (type === "svg") drawSvg(icons, parentEl);
|
||||
else drawWebGl(icons, parentEl);
|
||||
if (type === "svg" || WebGL2LayerFramework.hasFallback) {
|
||||
drawSvg(icons, parentEl);
|
||||
} else {
|
||||
const set = parentEl.getAttribute("set") || "simple";
|
||||
loadTexture(set).then(() => {
|
||||
if (icons !== lastBuiltIcons || set !== lastBuiltSet) {
|
||||
buildReliefScene(icons);
|
||||
lastBuiltIcons = icons;
|
||||
lastBuiltSet = set;
|
||||
}
|
||||
WebGL2LayerFramework.requestRender();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.undrawRelief = () => {
|
||||
WebGL2LayerFramework.clearLayer("terrain");
|
||||
lastBuiltIcons = null;
|
||||
lastBuiltSet = null;
|
||||
const terrainEl = byId("terrain");
|
||||
const mode = terrainEl?.dataset.mode || "webGL";
|
||||
if (mode === "webGL") {
|
||||
disposeScene();
|
||||
disposeTextureCache();
|
||||
if (renderer) {
|
||||
renderer.dispose();
|
||||
renderer = null;
|
||||
}
|
||||
if (glCanvas) {
|
||||
if (glCanvas.isConnected) glCanvas.remove();
|
||||
glCanvas = null;
|
||||
}
|
||||
camera = null;
|
||||
scene = null;
|
||||
lastBuiltIcons = null;
|
||||
lastBuiltSet = null;
|
||||
}
|
||||
|
||||
if (terrainEl) terrainEl.innerHTML = "";
|
||||
};
|
||||
|
||||
// re-render the current WebGL frame (called on pan/zoom); coalesced to one GPU draw per animation frame
|
||||
let rafId: number | null = null;
|
||||
window.rerenderReliefIcons = () => {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
renderFrame();
|
||||
});
|
||||
WebGL2LayerFramework.requestRender();
|
||||
};
|
||||
|
||||
declare global {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue