feat: Enhance WebGL2LayerFramework initialization and improve global variable handling

This commit is contained in:
Azgaar 2026-03-12 18:25:08 +01:00
parent 2dae325d05
commit 8560c131eb
6 changed files with 202 additions and 147 deletions

View file

@ -1,4 +1,5 @@
<!-- BMAD:START --> <!-- BMAD:START -->
# BMAD Method — Project Instructions # BMAD Method — Project Instructions
## Project Configuration ## Project Configuration
@ -8,9 +9,9 @@
- **Communication Language**: English - **Communication Language**: English
- **Document Output Language**: English - **Document Output Language**: English
- **User Skill Level**: intermediate - **User Skill Level**: intermediate
- **Output Folder**: {project-root}/_bmad-output - **Output Folder**: {project-root}/\_bmad-output
- **Planning Artifacts**: {project-root}/_bmad-output/planning-artifacts - **Planning Artifacts**: {project-root}/\_bmad-output/planning-artifacts
- **Implementation Artifacts**: {project-root}/_bmad-output/implementation-artifacts - **Implementation Artifacts**: {project-root}/\_bmad-output/implementation-artifacts
- **Project Knowledge**: {project-root}/docs - **Project Knowledge**: {project-root}/docs
## BMAD Runtime Structure ## BMAD Runtime Structure
@ -39,20 +40,42 @@
## Available Agents ## Available Agents
| Agent | Persona | Title | Capabilities | | Agent | Persona | Title | Capabilities |
|---|---|---|---| | ------------------- | ----------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| bmad-master | BMad Master | BMad Master Executor, Knowledge Custodian, and Workflow Orchestrator | runtime resource management, workflow orchestration, task execution, knowledge custodian | | bmad-master | BMad Master | BMad Master Executor, Knowledge Custodian, and Workflow Orchestrator | runtime resource management, workflow orchestration, task execution, knowledge custodian |
| analyst | Mary | Business Analyst | market research, competitive analysis, requirements elicitation, domain expertise | | analyst | Mary | Business Analyst | market research, competitive analysis, requirements elicitation, domain expertise |
| architect | Winston | Architect | distributed systems, cloud infrastructure, API design, scalable patterns | | architect | Winston | Architect | distributed systems, cloud infrastructure, API design, scalable patterns |
| dev | Amelia | Developer Agent | story execution, test-driven development, code implementation | | dev | Amelia | Developer Agent | story execution, test-driven development, code implementation |
| pm | John | Product Manager | PRD creation, requirements discovery, stakeholder alignment, user interviews | | pm | John | Product Manager | PRD creation, requirements discovery, stakeholder alignment, user interviews |
| qa | Quinn | QA Engineer | test automation, API testing, E2E testing, coverage analysis | | qa | Quinn | QA Engineer | test automation, API testing, E2E testing, coverage analysis |
| quick-flow-solo-dev | Barry | Quick Flow Solo Dev | rapid spec creation, lean implementation, minimum ceremony | | quick-flow-solo-dev | Barry | Quick Flow Solo Dev | rapid spec creation, lean implementation, minimum ceremony |
| sm | Bob | Scrum Master | sprint planning, story preparation, agile ceremonies, backlog management | | sm | Bob | Scrum Master | sprint planning, story preparation, agile ceremonies, backlog management |
| tech-writer | Paige | Technical Writer | documentation, Mermaid diagrams, standards compliance, concept explanation | | tech-writer | Paige | Technical Writer | documentation, Mermaid diagrams, standards compliance, concept explanation |
| ux-designer | Sally | UX Designer | user research, interaction design, UI patterns, experience strategy | | ux-designer | Sally | UX Designer | user research, interaction design, UI patterns, experience strategy |
## Slash Commands ## Slash Commands
Type `/bmad-` in Copilot Chat to see all available BMAD workflows and agent activators. Agents are also available in the agents dropdown. Type `/bmad-` in Copilot Chat to see all available BMAD workflows and agent activators. Agents are also available in the agents dropdown.
## Project Architecture: Critical Rules for All Agents
### main.js globals — NEVER use globalThis
`public/main.js` and all `public/modules/**/*.js` files are **plain `<script defer>` tags — NOT ES modules**. Every top-level declaration is a `window` property automatically.
Key globals always available on `window` at runtime: `scale`, `viewX`, `viewY`, `graphWidth`, `graphHeight`, `svgWidth`, `svgHeight`, `pack`, `grid`, `viewbox`, `svg`, `zoom`, `seed`, `options`, `byId`, `rn`, `tip`, `layerIsOn`, `drawRelief`, `undrawRelief`, `rerenderReliefIcons`, and many more.
**Rule: In `src/**/\*.ts`(ES modules), just use the globals directly — they are declared as ambient globals in`src/types/global.ts`:\*\*
```ts
// ✅ CORRECT — declared in src/types/global.ts, use as bare identifiers
buildCameraBounds(viewX, viewY, scale, graphWidth, graphHeight);
viewbox.on("zoom.webgl", handler);
// ❌ WRONG — never do these
(window as any).scale(globalThis as any).scale;
```
Full reference: see `docs/architecture-globals.md`.
<!-- BMAD:END --> <!-- BMAD:END -->

View file

@ -102,12 +102,7 @@ Reads window globals (`viewX`, `viewY`, `scale`, `graphWidth`, `graphHeight`) an
```typescript ```typescript
syncTransform(): void { syncTransform(): void {
if (this._fallback || !this.camera) return; if (this._fallback || !this.camera) return
const viewX = (globalThis as any).viewX ?? 0;
const viewY = (globalThis as any).viewY ?? 0;
const scale = (globalThis as any).scale ?? 1;
const graphWidth = (globalThis as any).graphWidth ?? 960;
const graphHeight = (globalThis as any).graphHeight ?? 540;
const bounds = buildCameraBounds(viewX, viewY, scale, graphWidth, graphHeight); const bounds = buildCameraBounds(viewX, viewY, scale, graphWidth, graphHeight);
this.camera.left = bounds.left; this.camera.left = bounds.left;
this.camera.right = bounds.right; this.camera.right = bounds.right;

View file

@ -0,0 +1,79 @@
# Fantasy Map Generator — Global Variable Architecture
## Critical: main.js is NOT an ES Module
`public/main.js` (and all other `public/modules/**/*.js` files) are loaded as plain
`<script defer>` tags — **not** ES modules. Every `var`, `let`, `const`, and `function`
declaration at the top level of these files is automatically a **property of `window`**
(the global object).
### Key globals exposed by main.js
| Variable | Type | Description |
| ------------- | ----------------- | -------------------------------------------- |
| `scale` | `number` | Current D3 zoom scale factor (initially `1`) |
| `viewX` | `number` | Current D3 zoom translate X (initially `0`) |
| `viewY` | `number` | Current D3 zoom translate Y (initially `0`) |
| `graphWidth` | `number` | Map canvas width in SVG user units |
| `graphHeight` | `number` | Map canvas height in SVG user units |
| `svgWidth` | `number` | SVG element rendered width (px) |
| `svgHeight` | `number` | SVG element rendered height (px) |
| `pack` | `object` | Packed voronoi graph + all generated data |
| `grid` | `object` | Initial grid graph |
| `viewbox` | D3 selection | D3 selection of `#viewbox` `<g>` |
| `svg` | D3 selection | D3 selection of `#map` `<svg>` |
| `zoom` | D3 zoom behaviour | The active d3-zoom instance |
| `seed` | `string` | Current map seed |
| `options` | `object` | Global render/UI options |
### Rule for TypeScript/ES-module code in `src/`
All main.js globals are declared as ambient globals in `src/types/global.ts`.
Just **use them directly** — no `window.` prefix, no `(window as any)`, no `globalThis`.
TypeScript already knows their types.
```ts
// ✅ CORRECT — declared in src/types/global.ts, use as bare globals
buildCameraBounds(viewX, viewY, scale, graphWidth, graphHeight);
viewbox.on("zoom.webgl", handler);
// ❌ WRONG — unnecessary indirection
(window as any).scale(globalThis as any).viewX;
```
The only exception is a Node/test-env guard where the global may genuinely not exist:
```ts
if (typeof viewbox === "undefined") return; // guard for Node test env
viewbox.on("zoom.webgl", handler); // then use directly
```
In `webgl-layer-framework.ts` the `syncTransform()` method correctly reads:
```ts
buildCameraBounds(viewX, viewY, scale, graphWidth, graphHeight);
```
### Why this matters for new WebGL/canvas overlays
Any canvas or WebGL overlay that must stay pixel-aligned with the SVG viewbox **must**
read `scale`, `viewX`, `viewY` at render time — these are live globals updated on every
D3 zoom event. Do not cache them at module load time.
### Other public/modules globals of note
`toggleRelief`, `drawRelief`, `undrawRelief`, `rerenderReliefIcons`, `layerIsOn`,
`turnButtonOn`, `turnButtonOff`, `byId`, `tip`, `rn`, `P`, `gauss` — all utility
functions defined in public JS files and available globally.
## Module loading order
1. `public/libs/*.js` — third-party (d3, jQuery, etc.)
2. `src/utils/index.ts`, `src/modules/index.ts`, `src/renderers/index.ts` — ES modules
(bundled by Vite); these run **before** the deferred legacy scripts
3. `public/main.js` and `public/modules/**/*.js` — deferred plain scripts
**Implication:** ES modules in `src/` that call `WebGL2LayerFramework.register()` at
module load time are safe because the framework class is instantiated at the bottom of
`webgl-layer-framework.ts` (an ES module), which runs before the deferred `main.js`.
`main.js` then calls `WebGL2LayerFramework.init()` inside `generateMapOnLoad()`.

View file

@ -303,6 +303,7 @@ async function checkLoadParameters() {
async function generateMapOnLoad() { async function generateMapOnLoad() {
await applyStyleOnLoad(); // apply previously selected default or custom style await applyStyleOnLoad(); // apply previously selected default or custom style
await generate(); // generate map await generate(); // generate map
WebGL2LayerFramework.init();
applyLayersPreset(); // apply saved layers preset and reder layers applyLayersPreset(); // apply saved layers preset and reder layers
drawLayers(); drawLayers();
fitMapToScreen(); fitMapToScreen();

View file

@ -1,68 +1,61 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<head>
<title>Code coverage report for webgl-layer-framework.ts</title> <title>Code coverage report for webgl-layer-framework.ts</title>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="stylesheet" href="prettify.css" /> <link rel="stylesheet" href="prettify.css" />
<link rel="stylesheet" href="base.css" /> <link rel="stylesheet" href="base.css" />
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" /> <link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'> <style type="text/css">
.coverage-summary .sorter { .coverage-summary .sorter {
background-image: url(sort-arrow-sprite.png); background-image: url(sort-arrow-sprite.png);
} }
</style> </style>
</head> </head>
<body> <body>
<div class='wrapper'> <div class="wrapper">
<div class='pad1'> <div class="pad1">
<h1><a href="index.html">All files</a> webgl-layer-framework.ts</h1> <h1><a href="index.html">All files</a> webgl-layer-framework.ts</h1>
<div class='clearfix'> <div class="clearfix">
<div class="fl pad1y space-right2">
<div class='fl pad1y space-right2'> <span class="strong">88.51% </span>
<span class="strong">88.51% </span> <span class="quiet">Statements</span>
<span class="quiet">Statements</span> <span class="fraction">131/148</span>
<span class='fraction'>131/148</span> </div>
</div>
<div class="fl pad1y space-right2">
<span class="strong">76.82% </span>
<div class='fl pad1y space-right2'> <span class="quiet">Branches</span>
<span class="strong">76.82% </span> <span class="fraction">63/82</span>
<span class="quiet">Branches</span> </div>
<span class='fraction'>63/82</span>
</div> <div class="fl pad1y space-right2">
<span class="strong">84.21% </span>
<span class="quiet">Functions</span>
<div class='fl pad1y space-right2'> <span class="fraction">16/19</span>
<span class="strong">84.21% </span> </div>
<span class="quiet">Functions</span>
<span class='fraction'>16/19</span> <div class="fl pad1y space-right2">
</div> <span class="strong">91.26% </span>
<span class="quiet">Lines</span>
<span class="fraction">115/126</span>
<div class='fl pad1y space-right2'> </div>
<span class="strong">91.26% </span>
<span class="quiet">Lines</span>
<span class='fraction'>115/126</span>
</div>
</div> </div>
<p class="quiet"> <p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block. Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the
previous block.
</p> </p>
<template id="filterTemplate"> <template id="filterTemplate">
<div class="quiet"> <div class="quiet">
Filter: Filter:
<input type="search" id="fileSearch"> <input type="search" id="fileSearch" />
</div> </div>
</template> </template>
</div> </div>
<div class='status-line high'></div> <div class="status-line high"></div>
<pre><table class="coverage"> <pre><table class="coverage">
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a> <tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
<a name='L2'></a><a href='#L2'>2</a> <a name='L2'></a><a href='#L2'>2</a>
<a name='L3'></a><a href='#L3'>3</a> <a name='L3'></a><a href='#L3'>3</a>
@ -835,11 +828,6 @@ export class WebGL2LayerFrameworkClass {
syncTransform(): void { syncTransform(): void {
if (this._fallback || !this.camera) return; if (this._fallback || !this.camera) return;
const camera = this.camera; const camera = this.camera;
const viewX = (globalThis as any).viewX ?? 0;
const viewY = (globalThis as any).viewY ?? 0;
const scale = (globalThis as any).scale ?? 1;
const graphWidth = (globalThis as any).graphWidth ?? 960;
const graphHeight = (globalThis as any).graphHeight ?? 540;
const bounds = buildCameraBounds( const bounds = buildCameraBounds(
viewX, viewX,
viewY, viewY,
@ -893,21 +881,22 @@ declare global {
globalThis.WebGL2LayerFramework = new WebGL2LayerFrameworkClass(); globalThis.WebGL2LayerFramework = new WebGL2LayerFrameworkClass();
&nbsp;</pre></td></tr></table></pre> &nbsp;</pre></td></tr></table></pre>
<div class='push'></div><!-- for sticky footer --> <div class="push"></div>
</div><!-- /wrapper --> <!-- for sticky footer -->
<div class='footer quiet pad2 space-top1 center small'> </div>
Code coverage generated by <!-- /wrapper -->
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a> <div class="footer quiet pad2 space-top1 center small">
at 2026-03-12T13:47:51.911Z Code coverage generated by
</div> <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
<script src="prettify.js"></script> at 2026-03-12T13:47:51.911Z
<script> </div>
window.onload = function () { <script src="prettify.js"></script>
prettyPrint(); <script>
}; window.onload = function () {
</script> prettyPrint();
<script src="sorter.js"></script> };
<script src="block-navigation.js"></script> </script>
</body> <script src="sorter.js"></script>
<script src="block-navigation.js"></script>
</body>
</html> </html>

View file

@ -91,25 +91,11 @@ export class WebGL2LayerFrameworkClass {
private resizeObserver: ResizeObserver | null = null; private resizeObserver: ResizeObserver | null = null;
private rafId: number | null = null; private rafId: number | null = null;
private container: HTMLElement | null = null; private container: HTMLElement | null = null;
private _fallback = false;
get hasFallback(): boolean {
return this._fallback;
}
init(): boolean { init(): boolean {
this._fallback = !detectWebGL2();
if (this._fallback) return false;
const mapEl = document.getElementById("map"); const mapEl = document.getElementById("map");
if (!mapEl) { if (!mapEl) throw new Error("Map element not found");
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"); const container = document.createElement("div");
container.id = "map-container"; container.id = "map-container";
container.style.position = "relative"; container.style.position = "relative";
@ -117,7 +103,6 @@ export class WebGL2LayerFrameworkClass {
container.appendChild(mapEl); container.appendChild(mapEl);
this.container = container; this.container = container;
// Canvas: sibling to #map, pointerless, z-index above SVG (AC1)
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.id = "terrainCanvas"; canvas.id = "terrainCanvas";
canvas.style.position = "absolute"; canvas.style.position = "absolute";
@ -125,27 +110,20 @@ export class WebGL2LayerFrameworkClass {
canvas.style.pointerEvents = "none"; canvas.style.pointerEvents = "none";
canvas.setAttribute("aria-hidden", "true"); canvas.setAttribute("aria-hidden", "true");
canvas.style.zIndex = String(getLayerZIndex("terrain")); canvas.style.zIndex = String(getLayerZIndex("terrain"));
canvas.width = container.clientWidth || 960; canvas.width = mapEl.clientWidth || 960;
canvas.height = container.clientHeight || 540; canvas.height = mapEl.clientHeight || 540;
container.appendChild(canvas); container.appendChild(canvas);
this.canvas = canvas; this.canvas = canvas;
// Three.js core objects (AC4)
this.renderer = new WebGLRenderer({ this.renderer = new WebGLRenderer({
canvas, canvas,
antialias: false, antialias: true,
alpha: true, alpha: true,
}); });
this.renderer.setSize(canvas.width, canvas.height); this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(canvas.width, canvas.height, false);
this.scene = new Scene(); this.scene = new Scene();
this.camera = new OrthographicCamera( this.camera = new OrthographicCamera(0, graphWidth, 0, graphHeight, -1, 1);
0,
canvas.width,
0,
canvas.height,
-1,
1,
);
this.subscribeD3Zoom(); this.subscribeD3Zoom();
@ -178,7 +156,6 @@ export class WebGL2LayerFrameworkClass {
} }
unregister(id: string): void { unregister(id: string): void {
if (this._fallback) return;
const layer = this.layers.get(id); const layer = this.layers.get(id);
if (!layer || !this.scene) return; if (!layer || !this.scene) return;
const scene = this.scene; const scene = this.scene;
@ -190,7 +167,6 @@ export class WebGL2LayerFrameworkClass {
} }
setVisible(id: string, visible: boolean): void { setVisible(id: string, visible: boolean): void {
if (this._fallback) return;
const layer = this.layers.get(id); const layer = this.layers.get(id);
if (!layer) return; if (!layer) return;
layer.group.visible = visible; layer.group.visible = visible;
@ -200,14 +176,13 @@ export class WebGL2LayerFrameworkClass {
} }
clearLayer(id: string): void { clearLayer(id: string): void {
if (this._fallback) return;
const layer = this.layers.get(id); const layer = this.layers.get(id);
if (!layer) return; if (!layer) return;
layer.group.clear(); layer.group.clear();
this.requestRender();
} }
requestRender(): void { requestRender(): void {
if (this._fallback) return;
if (this.rafId !== null) return; if (this.rafId !== null) return;
this.rafId = requestAnimationFrame(() => { this.rafId = requestAnimationFrame(() => {
this.rafId = null; this.rafId = null;
@ -216,13 +191,7 @@ export class WebGL2LayerFrameworkClass {
} }
syncTransform(): void { syncTransform(): void {
if (this._fallback || !this.camera) return;
const camera = this.camera; const camera = this.camera;
const viewX = (globalThis as any).viewX ?? 0;
const viewY = (globalThis as any).viewY ?? 0;
const scale = (globalThis as any).scale ?? 1;
const graphWidth = (globalThis as any).graphWidth ?? 960;
const graphHeight = (globalThis as any).graphHeight ?? 540;
const bounds = buildCameraBounds( const bounds = buildCameraBounds(
viewX, viewX,
viewY, viewY,
@ -238,39 +207,38 @@ export class WebGL2LayerFrameworkClass {
} }
private subscribeD3Zoom(): void { private subscribeD3Zoom(): void {
// viewbox is a D3 selection global available in the browser; guard for Node test env // viewbox is declared as a global in src/types/global.ts (exposed by main.js).
if (typeof (globalThis as any).viewbox === "undefined") return; // Guard for Node test env where it doesn't exist.
(globalThis as any).viewbox.on("zoom.webgl", () => this.requestRender()); if (typeof viewbox === "undefined") return;
viewbox.on("zoom.webgl", () => this.requestRender());
} }
private observeResize(): void { private observeResize(): void {
if (!this.container || !this.renderer) return; if (!this.container || !this.renderer) return;
const mapEl = this.container.querySelector("#map") ?? this.container;
this.resizeObserver = new ResizeObserver((entries) => { this.resizeObserver = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect; const { width, height } = entries[0].contentRect;
if (this.renderer && this.canvas) { if (this.renderer && this.canvas && width > 0 && height > 0) {
this.renderer.setSize(width, height); // updateStyle=false — CSS inset:0 handles canvas positioning.
this.renderer.setSize(width, height, false);
this.requestRender(); this.requestRender();
} }
}); });
this.resizeObserver.observe(this.container); this.resizeObserver.observe(mapEl);
} }
private render(): void { private render(): void {
if (this._fallback || !this.renderer || !this.scene || !this.camera) return; if (!this.renderer || !this.scene || !this.camera) return;
const renderer = this.renderer;
const scene = this.scene;
const camera = this.camera;
this.syncTransform(); this.syncTransform();
for (const layer of this.layers.values()) { for (const layer of this.layers.values()) {
if (layer.group.visible) { if (layer.group.visible) layer.config.render(layer.group);
layer.config.render(layer.group);
}
} }
renderer.render(scene, camera); this.renderer.render(this.scene, this.camera);
} }
} }
declare global { declare global {
var WebGL2LayerFramework: WebGL2LayerFrameworkClass; var WebGL2LayerFramework: WebGL2LayerFrameworkClass;
} }
globalThis.WebGL2LayerFramework = new WebGL2LayerFrameworkClass();
window.WebGL2LayerFramework = new WebGL2LayerFrameworkClass();