feat: Introduce SceneModule for managing map and WebGL canvas integration

This commit is contained in:
Azgaar 2026-03-13 01:07:10 +01:00
parent 125403b82f
commit 42557881bb
6 changed files with 197 additions and 11 deletions

View file

@ -13,6 +13,8 @@ const ERROR = true;
// detect device
const MOBILE = window.innerWidth < 600 || navigator.userAgentData?.mobile;
Scene.bootstrap();
if (PRODUCTION && "serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("./sw.js").catch(err => {
@ -32,7 +34,7 @@ if (PRODUCTION && "serviceWorker" in navigator) {
}
// append svg layers (in default order)
let svg = d3.select("#map");
let svg = d3.select(Scene.getMapSvg());
let defs = svg.select("#deftemp");
let viewbox = svg.select("#viewbox");
let scaleBar = svg.select("#scaleBar");

View file

@ -302,11 +302,11 @@ async function parseLoadedData(data, mapVersion) {
{
svg.remove();
document.body.insertAdjacentHTML("afterbegin", data[5]);
const mapSvg = Scene.replaceMapSvg(data[5]);
svg = d3.select(mapSvg);
}
{
svg = d3.select("#map");
defs = svg.select("#deftemp");
viewbox = svg.select("#viewbox");
scaleBar = svg.select("#scaleBar");

View file

@ -16,6 +16,7 @@ import "./relief-generator";
import "./religions-generator";
import "./river-generator";
import "./routes-generator";
import "./scene";
import "./states-generator";
import "./voronoi";
import "./webgl-layer";

176
src/modules/scene.ts Normal file
View file

@ -0,0 +1,176 @@
const SVG_NS = "http://www.w3.org/2000/svg";
const MAP_CONTAINER_ID = "map-container";
const SCENE_CONTAINER_ID = "map-scene";
const MAP_ID = "map";
const WEBGL_CANVAS_ID = "webgl-canvas";
const RUNTIME_DEFS_HOST_ID = "runtime-defs-host";
const RUNTIME_DEFS_ID = "runtime-defs";
export class SceneModule {
private mapContainer: HTMLElement | null = null;
private sceneContainer: HTMLDivElement | null = null;
private mapSvg: SVGSVGElement | null = null;
private canvas: HTMLCanvasElement | null = null;
private defsHost: SVGSVGElement | null = null;
private runtimeDefs: SVGGElement | null = null;
bootstrap() {
const mapContainer = this.requireMapContainer();
const sceneContainer = this.ensureSceneContainer(mapContainer);
const defsHost = this.ensureDefsHost(mapContainer, sceneContainer);
const runtimeDefs = this.ensureRuntimeDefs(defsHost);
const mapSvg = document.getElementById(MAP_ID);
if (mapSvg instanceof SVGSVGElement) {
sceneContainer.append(mapSvg);
}
const canvas = document.getElementById(WEBGL_CANVAS_ID);
if (canvas instanceof HTMLCanvasElement) {
sceneContainer.append(canvas);
}
this.mapContainer = mapContainer;
this.sceneContainer = sceneContainer;
this.mapSvg = mapSvg instanceof SVGSVGElement ? mapSvg : null;
this.canvas = canvas instanceof HTMLCanvasElement ? canvas : null;
this.defsHost = defsHost;
this.runtimeDefs = runtimeDefs;
return this;
}
replaceMapSvg(markup: string) {
this.bootstrap();
this.mapSvg?.remove();
this.sceneContainer?.querySelector(`#${MAP_ID}`)?.remove();
this.getSceneContainer().insertAdjacentHTML("afterbegin", markup);
const mapSvg = this.getSceneContainer().querySelector(`#${MAP_ID}`);
if (!(mapSvg instanceof SVGSVGElement)) {
throw new Error("Scene could not rebind the map SVG after reload");
}
this.mapSvg = mapSvg;
if (this.canvas) {
this.getSceneContainer().append(this.canvas);
}
return mapSvg;
}
getMapContainer() {
this.bootstrap();
return this.mapContainer!;
}
getSceneContainer() {
this.bootstrap();
return this.sceneContainer!;
}
getMapSvg() {
this.bootstrap();
if (!this.mapSvg) {
throw new Error("Scene map SVG is not available");
}
return this.mapSvg;
}
getCanvas() {
this.bootstrap();
if (!this.canvas) {
throw new Error("Scene WebGL canvas is not available");
}
return this.canvas;
}
getDefsHost() {
this.bootstrap();
return this.defsHost!;
}
getRuntimeDefs() {
this.bootstrap();
return this.runtimeDefs!;
}
private requireMapContainer() {
const mapContainer = document.getElementById(MAP_CONTAINER_ID);
if (!(mapContainer instanceof HTMLElement)) {
throw new Error("Scene map container is not available");
}
return mapContainer;
}
private ensureSceneContainer(mapContainer: HTMLElement) {
const existingSceneContainer = document.getElementById(SCENE_CONTAINER_ID);
if (existingSceneContainer instanceof HTMLDivElement) {
return existingSceneContainer;
}
const sceneContainer = document.createElement("div");
sceneContainer.id = SCENE_CONTAINER_ID;
sceneContainer.style.position = "absolute";
sceneContainer.style.inset = "0";
mapContainer.prepend(sceneContainer);
return sceneContainer;
}
private ensureDefsHost(
mapContainer: HTMLElement,
sceneContainer: HTMLDivElement,
) {
const existingDefsHost = document.getElementById(RUNTIME_DEFS_HOST_ID);
if (existingDefsHost instanceof SVGSVGElement) {
if (sceneContainer.contains(existingDefsHost)) {
mapContainer.append(existingDefsHost);
}
return existingDefsHost;
}
const defsHost = document.createElementNS(SVG_NS, "svg");
defsHost.setAttribute("id", RUNTIME_DEFS_HOST_ID);
defsHost.setAttribute("width", "0");
defsHost.setAttribute("height", "0");
defsHost.setAttribute("aria-hidden", "true");
defsHost.style.position = "absolute";
defsHost.style.width = "0";
defsHost.style.height = "0";
defsHost.style.overflow = "hidden";
const defsElement = document.createElementNS(SVG_NS, "defs");
defsHost.append(defsElement);
mapContainer.append(defsHost);
return defsHost;
}
private ensureRuntimeDefs(defsHost: SVGSVGElement) {
const existingRuntimeDefs = defsHost.querySelector(`#${RUNTIME_DEFS_ID}`);
if (existingRuntimeDefs instanceof SVGGElement) {
return existingRuntimeDefs;
}
let defsElement = defsHost.querySelector("defs");
if (!(defsElement instanceof SVGDefsElement)) {
defsElement = document.createElementNS(SVG_NS, "defs");
defsHost.append(defsElement);
}
const runtimeDefs = document.createElementNS(SVG_NS, "g");
runtimeDefs.setAttribute("id", RUNTIME_DEFS_ID);
defsElement.append(runtimeDefs);
return runtimeDefs;
}
}
declare global {
var Scene: SceneModule;
}
window.Scene = new SceneModule();

View file

@ -1,5 +1,9 @@
import { Group, OrthographicCamera, Scene, WebGLRenderer } from "three";
import { byId } from "../utils";
import {
Group,
OrthographicCamera,
Scene as ThreeScene,
WebGLRenderer,
} from "three";
export interface WebGLLayerConfig {
id: string;
@ -13,25 +17,26 @@ interface RegisteredLayer {
}
export class WebGL2LayerClass {
private canvas = byId("webgl-canvas")!;
private renderer: WebGLRenderer | null = null;
private camera: OrthographicCamera | null = null;
private scene: Scene | null = null;
private scene: ThreeScene | null = null;
private layers: Map<string, RegisteredLayer> = new Map();
private pendingConfigs: WebGLLayerConfig[] = []; // queue for register() before init()
private rafId: number | null = null;
init(): boolean {
const canvas = Scene.getCanvas();
this.renderer = new WebGLRenderer({
canvas: this.canvas,
canvas,
antialias: false,
alpha: true,
});
this.renderer.setPixelRatio(window.devicePixelRatio || 1);
this.canvas.style.width = `${graphWidth}px`;
this.canvas.style.height = `${graphHeight}px`;
canvas.style.width = `${graphWidth}px`;
canvas.style.height = `${graphHeight}px`;
this.renderer.setSize(graphWidth, graphHeight, false);
this.scene = new Scene();
this.scene = new ThreeScene();
this.camera = new OrthographicCamera(0, graphWidth, 0, graphHeight, -1, 1);
// Process pre-init registrations (register() before init() is explicitly safe)

View file

@ -1,5 +1,6 @@
import type { Selection } from "d3";
import type { NameBase } from "../modules/names-generator";
import type { SceneModule } from "../modules/scene";
import type { PackedGraph } from "./PackedGraph";
declare global {
@ -91,4 +92,5 @@ declare global {
var viewY: number;
var changeFont: () => void;
var getFriendlyHeight: (coords: [number, number]) => string;
var Scene: SceneModule;
}