mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-03-23 15:47:24 +01:00
feat: implement WebGL2 layer framework with core functionalities including init, resize observation, and D3 zoom subscription
This commit is contained in:
parent
42b92d93b4
commit
769ef9eff0
7 changed files with 790 additions and 25 deletions
|
|
@ -18,4 +18,5 @@ import "./river-generator";
|
|||
import "./routes-generator";
|
||||
import "./states-generator";
|
||||
import "./voronoi";
|
||||
import "./webgl-layer-framework";
|
||||
import "./zones-generator";
|
||||
|
|
|
|||
319
src/modules/webgl-layer-framework.test.ts
Normal file
319
src/modules/webgl-layer-framework.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
245
src/modules/webgl-layer-framework.ts
Normal file
245
src/modules/webgl-layer-framework.ts
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue