diff --git a/public/main.js b/public/main.js index 252973f9..d71db237 100644 --- a/public/main.js +++ b/public/main.js @@ -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"); diff --git a/public/modules/io/load.js b/public/modules/io/load.js index 415c231b..29e63e81 100644 --- a/public/modules/io/load.js +++ b/public/modules/io/load.js @@ -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"); diff --git a/src/modules/index.ts b/src/modules/index.ts index 5998d89a..2047585f 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -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"; diff --git a/src/modules/scene.ts b/src/modules/scene.ts new file mode 100644 index 00000000..b36f4e51 --- /dev/null +++ b/src/modules/scene.ts @@ -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(); diff --git a/src/modules/webgl-layer.ts b/src/modules/webgl-layer.ts index 89536c3a..4beda1c8 100644 --- a/src/modules/webgl-layer.ts +++ b/src/modules/webgl-layer.ts @@ -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 = 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) diff --git a/src/types/global.ts b/src/types/global.ts index d3fb3f0a..cd5694e8 100644 --- a/src/types/global.ts +++ b/src/types/global.ts @@ -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; }