feat: implement WebGL2 layer framework with core functionalities including init, resize observation, and D3 zoom subscription

This commit is contained in:
Azgaar 2026-03-12 13:44:23 +01:00
parent 42b92d93b4
commit 769ef9eff0
7 changed files with 790 additions and 25 deletions

View file

@ -18,4 +18,5 @@ import "./river-generator";
import "./routes-generator";
import "./states-generator";
import "./voronoi";
import "./webgl-layer-framework";
import "./zones-generator";

View file

@ -0,0 +1,319 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildCameraBounds,
detectWebGL2,
getLayerZIndex,
WebGL2LayerFrameworkClass,
} from "./webgl-layer-framework";
// Three.js constructors are mocked so that Node-env init() tests work without
// a real WebGL context. These stubs only affect class-level tests that call
// init(); Story 1.1 pure-function tests never invoke Three.js constructors.
vi.mock("three", () => {
// Must use regular `function` (not arrow) so vi.fn() can be called with `new`.
const Group = vi.fn().mockImplementation(function (this: any) {
this.renderOrder = 0;
this.visible = true;
this.clear = vi.fn();
});
const WebGLRenderer = vi.fn().mockImplementation(function (this: any) {
this.setSize = vi.fn();
this.render = vi.fn();
});
const Scene = vi.fn().mockImplementation(function (this: any) {
this.add = vi.fn();
});
const OrthographicCamera = vi.fn().mockImplementation(function (this: any) {
this.left = 0;
this.right = 960;
this.top = 0;
this.bottom = 540;
});
return { Group, WebGLRenderer, Scene, OrthographicCamera };
});
// ─── buildCameraBounds ───────────────────────────────────────────────────────
describe("buildCameraBounds", () => {
it("returns correct bounds for identity transform (viewX=0, viewY=0, scale=1)", () => {
const b = buildCameraBounds(0, 0, 1, 960, 540);
expect(b.left).toBe(0);
expect(b.right).toBe(960);
expect(b.top).toBe(0);
expect(b.bottom).toBe(540);
});
it("top < bottom (Y-down convention matches SVG coordinate space)", () => {
const b = buildCameraBounds(0, 0, 1, 960, 540);
expect(b.top).toBeLessThan(b.bottom);
});
it("returns correct bounds at 2× zoom (viewport shows half the map area)", () => {
const b = buildCameraBounds(0, 0, 2, 960, 540);
expect(b.right).toBe(480);
expect(b.bottom).toBe(270);
});
it("returns correct bounds with pan offset — viewX=-100 pans right, viewY=-50 pans down", () => {
const b = buildCameraBounds(-100, -50, 1, 960, 540);
expect(b.left).toBe(100); // -(-100) / 1
expect(b.right).toBe(1060); // (960 - (-100)) / 1
expect(b.top).toBe(50); // -(-50) / 1
});
it("handles extreme zoom values without NaN or Infinity", () => {
const lo = buildCameraBounds(0, 0, 0.1, 960, 540);
const hi = buildCameraBounds(0, 0, 50, 960, 540);
expect(Number.isFinite(lo.left)).toBe(true);
expect(Number.isFinite(lo.right)).toBe(true);
expect(Number.isFinite(lo.top)).toBe(true);
expect(Number.isFinite(lo.bottom)).toBe(true);
expect(Number.isFinite(hi.left)).toBe(true);
expect(Number.isFinite(hi.right)).toBe(true);
expect(Number.isFinite(hi.top)).toBe(true);
expect(Number.isFinite(hi.bottom)).toBe(true);
});
});
// ─── detectWebGL2 ─────────────────────────────────────────────────────────────
describe("detectWebGL2", () => {
it("returns false when canvas.getContext('webgl2') returns null", () => {
const mockCanvas = {
getContext: () => null,
} as unknown as HTMLCanvasElement;
expect(detectWebGL2(mockCanvas)).toBe(false);
});
it("returns true when canvas.getContext('webgl2') returns a context object", () => {
const mockCtx = { getExtension: () => null };
const mockCanvas = {
getContext: () => mockCtx,
} as unknown as HTMLCanvasElement;
expect(detectWebGL2(mockCanvas)).toBe(true);
});
it("calls loseContext() on the WEBGL_lose_context extension to release probe context", () => {
const loseContext = vi.fn();
const mockExt = { loseContext };
const mockCtx = { getExtension: () => mockExt };
const mockCanvas = {
getContext: () => mockCtx,
} as unknown as HTMLCanvasElement;
detectWebGL2(mockCanvas);
expect(loseContext).toHaveBeenCalledOnce();
});
});
// ─── getLayerZIndex ───────────────────────────────────────────────────────────
describe("getLayerZIndex", () => {
it("returns fallback z-index 2 when element is not found in the DOM", () => {
// In Node.js test environment, document is undefined → fallback 2.
// In jsdom environment, getElementById("nonexistent") returns null → also fallback 2.
expect(getLayerZIndex("nonexistent-layer-id")).toBe(2);
});
});
// ─── WebGL2LayerFrameworkClass ────────────────────────────────────────────────
describe("WebGL2LayerFrameworkClass", () => {
let framework: WebGL2LayerFrameworkClass;
beforeEach(() => {
framework = new WebGL2LayerFrameworkClass();
});
it("hasFallback is false by default (backing field _fallback initialised to false)", () => {
expect(framework.hasFallback).toBe(false);
});
it("register() before init() queues the config in pendingConfigs", () => {
const config = {
id: "test",
anchorLayerId: "terrain",
renderOrder: 1,
setup: vi.fn(),
render: vi.fn(),
dispose: vi.fn(),
};
framework.register(config);
expect((framework as any).pendingConfigs).toHaveLength(1);
expect((framework as any).pendingConfigs[0]).toBe(config);
});
it("register() queues multiple configs without throwing", () => {
const makeConfig = (id: string) => ({
id,
anchorLayerId: id,
renderOrder: 1,
setup: vi.fn(),
render: vi.fn(),
dispose: vi.fn(),
});
framework.register(makeConfig("a"));
framework.register(makeConfig("b"));
expect((framework as any).pendingConfigs).toHaveLength(2);
});
it("setVisible() does not call config.dispose() (GPU state preserved, NFR-P6)", () => {
const config = {
id: "terrain",
anchorLayerId: "terrain",
renderOrder: 1,
setup: vi.fn(),
render: vi.fn(),
dispose: vi.fn(),
};
(framework as any).layers.set("terrain", {
config,
group: { visible: true },
});
(framework as any).canvas = { style: { display: "block" } };
framework.setVisible("terrain", false);
expect(config.dispose).not.toHaveBeenCalled();
});
it("requestRender() does not throw when called multiple times", () => {
expect(() => {
framework.requestRender();
framework.requestRender();
framework.requestRender();
}).not.toThrow();
});
it("clearLayer() does not throw and preserves layer registration in the Map", () => {
const config = {
id: "terrain",
anchorLayerId: "terrain",
renderOrder: 1,
setup: vi.fn(),
render: vi.fn(),
dispose: vi.fn(),
};
(framework as any).layers.set("terrain", {
config,
group: { visible: true, clear: vi.fn() },
});
framework.clearLayer("terrain");
// Layer registration remains in the Map — only geometry is wiped in the full implementation
expect((framework as any).layers.has("terrain")).toBe(true);
});
it("constructor performs no side effects — all state fields initialised to null/empty", () => {
expect((framework as any).renderer).toBeNull();
expect((framework as any).scene).toBeNull();
expect((framework as any).camera).toBeNull();
expect((framework as any).canvas).toBeNull();
expect((framework as any).container).toBeNull();
expect((framework as any).resizeObserver).toBeNull();
expect((framework as any).rafId).toBeNull();
expect((framework as any).layers.size).toBe(0);
expect((framework as any).pendingConfigs).toHaveLength(0);
});
});
// ─── WebGL2LayerFrameworkClass — init() (Story 1.2) ──────────────────────────
describe("WebGL2LayerFrameworkClass — init()", () => {
let framework: WebGL2LayerFrameworkClass;
// Build a minimal document stub. The canvas mock satisfies both detectWebGL2()
// (probe getContext call) and the DOM canvas element requirements (id/style/etc.).
function buildDocumentMock({ webgl2 = true }: { webgl2?: boolean } = {}) {
const mockCtx = webgl2
? { getExtension: () => ({ loseContext: vi.fn() }) }
: null;
const mockCanvas = {
getContext: (type: string) => (type === "webgl2" ? mockCtx : null),
id: "",
width: 0,
height: 0,
style: { position: "", inset: "", pointerEvents: "", zIndex: "" },
setAttribute: vi.fn(),
};
const mockContainer = {
id: "",
style: { position: "", zIndex: "" },
appendChild: vi.fn(),
clientWidth: 960,
clientHeight: 540,
};
const mockMapEl = {
parentElement: { insertBefore: vi.fn() },
};
return {
createElement: vi.fn((tag: string) =>
tag === "canvas" ? mockCanvas : mockContainer,
),
getElementById: vi.fn((id: string) => (id === "map" ? mockMapEl : null)),
_mocks: { mockCanvas, mockContainer, mockMapEl },
};
}
beforeEach(() => {
framework = new WebGL2LayerFrameworkClass();
// ResizeObserver is not available in Node; stub it so observeResize() doesn't throw.
vi.stubGlobal(
"ResizeObserver",
vi.fn().mockImplementation(function (this: any) {
this.observe = vi.fn();
this.disconnect = vi.fn();
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns false and sets hasFallback when WebGL2 is unavailable (AC2)", () => {
vi.stubGlobal("document", buildDocumentMock({ webgl2: false }));
const result = framework.init();
expect(result).toBe(false);
expect(framework.hasFallback).toBe(true);
});
it("returns false when #map element is missing — renderer remains null (AC2 guard)", () => {
const doc = buildDocumentMock({ webgl2: true });
doc.getElementById = vi.fn(() => null);
vi.stubGlobal("document", doc);
const result = framework.init();
expect(result).toBe(false);
expect((framework as any).renderer).toBeNull();
});
it("returns true and assigns renderer, scene, camera, canvas on success (AC4)", () => {
vi.stubGlobal("document", buildDocumentMock({ webgl2: true }));
const result = framework.init();
expect(result).toBe(true);
expect((framework as any).renderer).not.toBeNull();
expect((framework as any).scene).not.toBeNull();
expect((framework as any).camera).not.toBeNull();
expect((framework as any).canvas).not.toBeNull();
});
it("processes pendingConfigs on init() — setup() called once, layer stored, queue flushed", () => {
vi.stubGlobal("document", buildDocumentMock({ webgl2: true }));
const config = {
id: "terrain",
anchorLayerId: "terrain",
renderOrder: 1,
setup: vi.fn(),
render: vi.fn(),
dispose: vi.fn(),
};
framework.register(config);
expect((framework as any).pendingConfigs).toHaveLength(1);
framework.init();
expect(config.setup).toHaveBeenCalledOnce();
expect((framework as any).layers.has("terrain")).toBe(true);
expect((framework as any).pendingConfigs).toHaveLength(0);
});
it("attaches ResizeObserver to container on success (AC5)", () => {
vi.stubGlobal("document", buildDocumentMock({ webgl2: true }));
framework.init();
expect((framework as any).resizeObserver).not.toBeNull();
});
});

View file

@ -0,0 +1,245 @@
import { Group, OrthographicCamera, Scene, WebGLRenderer } from "three";
// ─── Pure exports (testable without DOM or WebGL) ────────────────────────────
/**
* Converts a D3 zoom transform into orthographic camera bounds.
*
* D3 applies: screen = map * scale + (viewX, viewY)
* Inverting: map = (screen - (viewX, viewY)) / scale
*
* Orthographic bounds (visible map region at current zoom/pan):
* left = -viewX / scale
* right = (graphWidth - viewX) / scale
* top = -viewY / scale
* bottom = (graphHeight - viewY) / scale
*
* top < bottom: Y-down matches SVG; origin at top-left of map.
* Do NOT swap top/bottom or negate this is correct Three.js Y-down config.
*/
export function buildCameraBounds(
viewX: number,
viewY: number,
scale: number,
graphWidth: number,
graphHeight: number,
): { left: number; right: number; top: number; bottom: number } {
return {
left: (0 - viewX) / scale,
right: (graphWidth - viewX) / scale,
top: (0 - viewY) / scale,
bottom: (graphHeight - viewY) / scale,
};
}
/**
* Detects WebGL2 support by probing canvas.getContext("webgl2").
* Accepts an optional injectable probe canvas for testability (avoids DOM access in tests).
* Immediately releases the probed context via WEBGL_lose_context if available.
*/
export function detectWebGL2(probe?: HTMLCanvasElement): boolean {
const canvas = probe ?? document.createElement("canvas");
const ctx = canvas.getContext("webgl2");
if (!ctx) return false;
const ext = ctx.getExtension("WEBGL_lose_context");
ext?.loseContext();
return true;
}
/**
* Returns the CSS z-index for a canvas layer anchored to the given SVG element id.
* Phase 2 forward-compatible: derives index from DOM sibling position (+1 offset).
* Falls back to 2 (above #map SVG at z-index 1) when element is absent or document
* is unavailable (e.g. Node.js test environment).
*
* MVP note: #terrain is a <g> inside <svg#map>, not a sibling of #map-container,
* so this always resolves to the fallback 2 in MVP. Phase 2 (DOM-split) will give
* true per-layer interleaving values automatically.
*/
export function getLayerZIndex(anchorLayerId: string): number {
if (typeof document === "undefined") return 2;
const anchor = document.getElementById(anchorLayerId);
if (!anchor) return 2;
const siblings = Array.from(anchor.parentElement?.children ?? []);
const idx = siblings.indexOf(anchor);
// +1 so Phase 2 callers get a correct interleaving value automatically
return idx > 0 ? idx + 1 : 2;
}
// ─── Interfaces ──────────────────────────────────────────────────────────────
export interface WebGLLayerConfig {
id: string;
anchorLayerId: string; // SVG <g> id; canvas id derived as `${id}Canvas`
renderOrder: number; // Three.js renderOrder for this layer's Group
setup: (group: Group) => void; // called once after WebGL2 confirmed; add meshes to group
render: (group: Group) => void; // called each frame before renderer.render(); update uniforms/geometry
dispose: (group: Group) => void; // called on unregister(); dispose all GPU objects in group
}
// Not exported — internal framework bookkeeping only
interface RegisteredLayer {
config: WebGLLayerConfig;
group: Group; // framework-owned; passed to all callbacks — abstraction boundary
}
// ─── Class ───────────────────────────────────────────────────────────────────
export class WebGL2LayerFrameworkClass {
private canvas: HTMLCanvasElement | null = null;
private renderer: WebGLRenderer | null = null;
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: assigned in init(); read in Story 1.3 render() + syncTransform()
private camera: OrthographicCamera | null = null;
private scene: Scene | null = null;
private layers: Map<string, RegisteredLayer> = new Map();
private pendingConfigs: WebGLLayerConfig[] = []; // queue for register() before init()
private resizeObserver: ResizeObserver | null = null;
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: read/written in Story 1.3 requestRender()
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();
if (this._fallback) return false;
const mapEl = document.getElementById("map");
if (!mapEl) {
console.warn(
"WebGL2LayerFramework: #map element not found — init() aborted",
);
return false;
}
// Wrap #map in a positioned container so the canvas can be a sibling with z-index
const container = document.createElement("div");
container.id = "map-container";
container.style.position = "relative";
mapEl.parentElement!.insertBefore(container, mapEl);
container.appendChild(mapEl);
this.container = container;
// Canvas: sibling to #map, pointerless, z-index above SVG (AC1)
const canvas = document.createElement("canvas");
canvas.id = "terrainCanvas";
canvas.style.position = "absolute";
canvas.style.inset = "0";
canvas.style.pointerEvents = "none";
canvas.setAttribute("aria-hidden", "true");
canvas.style.zIndex = String(getLayerZIndex("terrain"));
canvas.width = container.clientWidth || 960;
canvas.height = container.clientHeight || 540;
container.appendChild(canvas);
this.canvas = canvas;
// Three.js core objects (AC4)
this.renderer = new WebGLRenderer({
canvas,
antialias: false,
alpha: true,
});
this.renderer.setSize(canvas.width, canvas.height);
this.scene = new Scene();
this.camera = new OrthographicCamera(
0,
canvas.width,
0,
canvas.height,
-1,
1,
);
this.subscribeD3Zoom();
// Process pre-init registrations (register() before init() is explicitly safe)
for (const config of this.pendingConfigs) {
const group = new Group();
group.renderOrder = config.renderOrder;
config.setup(group);
this.scene.add(group);
this.layers.set(config.id, { config, group });
}
this.pendingConfigs = [];
this.observeResize();
return true;
}
register(config: WebGLLayerConfig): void {
if (!this.scene) {
// init() has not been called yet — queue for processing in init()
this.pendingConfigs.push(config);
return;
}
// Post-init registration: create group immediately
const group = new Group();
group.renderOrder = config.renderOrder;
config.setup(group);
this.scene.add(group);
this.layers.set(config.id, { config, group });
}
unregister(_id: string): void {
// Story 1.3: call config.dispose(group); remove from layers Map; cleanup canvas if empty.
}
setVisible(_id: string, _visible: boolean): void {
// Story 1.3: toggle group.visible; hide canvas only when ALL layers invisible (NFR-P6).
}
clearLayer(_id: string): void {
// Story 1.3: group.clear() — wipes Mesh children without disposing renderer (NFR-P6).
}
requestRender(): void {
// Story 1.3: RAF-coalesced render request; schedules this.render() via requestAnimationFrame.
this.render();
}
syncTransform(): void {
// Story 1.3: read window globals viewX/viewY/scale; apply buildCameraBounds to camera.
}
// ─── Private helpers ───────────────────────────────────────────────────────
private subscribeD3Zoom(): void {
// viewbox is a D3 selection global available in the browser; guard for Node test env
if (typeof (globalThis as any).viewbox === "undefined") return;
(globalThis as any).viewbox.on("zoom.webgl", () => this.requestRender());
}
private observeResize(): void {
if (!this.container || !this.renderer) return;
this.resizeObserver = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (this.renderer && this.canvas) {
this.renderer.setSize(width, height);
this.requestRender();
}
});
this.resizeObserver.observe(this.container);
}
private render(): void {
// Story 1.3: syncTransform → per-layer render(group) callbacks → renderer.render(scene, camera).
}
}
// ─── 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;
}
globalThis.WebGL2LayerFramework = new WebGL2LayerFrameworkClass();