Source: Canvas.js

import { Util } from './Util'
import { Camera } from './Camera'
import { Layer } from './Layer'
import { Cache } from './Cache'
import { addSignals } from './Signals'

//// HELPERS

window.structuredClone = typeof (structuredClone) == "function" ? structuredClone : function (value) { return JSON.parse(JSON.stringify(value)); };


/**
 * Canvas class that manages WebGL context, layers, and scene rendering.
 * Handles layer management, WebGL context creation/restoration, and render timing.
 */
class Canvas {
	/**
	 * Creates a new Canvas instance with WebGL context and overlay support.
	 * @param {HTMLCanvasElement|string} canvas - Canvas DOM element or selector
	 * @param {HTMLElement|string} overlay - Overlay DOM element or selector for decorations (annotations, glyphs)
	 * @param {Camera} camera - Scene camera instance
	 * @param {Object} [options] - Configuration options
	 * @param {Object} [options.layers] - Layer configurations mapping layer IDs to Layer instances
	 * @param {boolean} [options.preserveDrawingBuffer=false] - Whether to preserve WebGL buffers until manually cleared
	 * @param {number} [options.targetfps=30] - Target frames per second for rendering
	 * @fires Canvas#update
	 * @fires Canvas#updateSize
	 * @fires Canvas#ready
	 */
	constructor(canvas, overlay, camera, options) {
		Object.assign(this, {
			canvasElement: null,
			preserveDrawingBuffer: false,
			gl: null,
			overlayElement: null,
			camera: camera,
			layers: {},
			targetfps: 30,
			fps: 0,
			timing: [16], //records last 30 frames time from request to next draw, rolling, primed to avoid /0
			timingLength: 5, //max number of timings.
			overBudget: 0, //fraction of frames that took too long to render.

			signals: { 'update': [], 'updateSize': [], 'ready': [] }
		});
		Object.assign(this, options);

		this.init(canvas, overlay);

		for (let id in this.layers)
			this.addLayer(id, new Layer(this.layers[id]));
		this.camera.addEvent('update', () => this.emit('update'));
	}

	/**
	 * Records render timing information and updates FPS statistics.
	 * @param {number} elapsed - Time elapsed since last frame in milliseconds
	 * @private
	 */
	addRenderTiming(elapsed) {
		this.timing.push(elapsed);
		while (this.timing.length > this.timingLength)
			this.timing.shift();
		this.overBudget = this.timing.filter(t => t > 1000 / this.targetfps).length / this.timingLength;
		this.fps = 1000 / (this.timing.reduce((sum, a) => sum + a, 0) / this.timing.length);
	}

	/**
	 * Initializes WebGL context and sets up event listeners.
	 * @param {HTMLCanvasElement|string} canvas - Canvas element or selector
	 * @param {HTMLElement|string} overlay - Overlay element or selector
	 * @throws {Error} If canvas or overlay elements cannot be found or initialized
	 * @private
	 */
	init(canvas, overlay) {
		if (!canvas)
			throw "Missing element parameter"

		if (typeof (canvas) == 'string') {
			canvas = document.querySelector(canvas);
			if (!canvas)
				throw "Could not find dom element.";
		}

		if (!overlay)
			throw "Missing element parameter"

		if (typeof (overlay) == 'string') {
			overlay = document.querySelector(overlay);
			if (!overlay)
				throw "Could not find dom element.";
		}

		if (!canvas.tagName)
			throw "Element is not a DOM element"

		if (canvas.tagName != "CANVAS")
			throw "Element is not a canvas element";

		this.canvasElement = canvas;
		this.overlayElement = overlay;

		/* test context loss */
		/* canvas = WebGLDebugUtils.makeLostContextSimulatingCanvas(canvas);
		canvas.loseContextInNCalls(1000); */


		let glopt = { antialias: false, depth: false, preserveDrawingBuffer: this.preserveDrawingBuffer };
		this.gl = this.gl ||
			canvas.getContext("webgl2", glopt) ||
			canvas.getContext("webgl", glopt) ||
			canvas.getContext("experimental-webgl", glopt);

		if (!this.gl)
			throw "Could not create a WebGL context";

		canvas.addEventListener("webglcontextlost", (event) => { console.log("Context lost."); event.preventDefault(); }, false);
		canvas.addEventListener("webglcontextrestored", () => { this.restoreWebGL(); }, false);
		document.addEventListener("visibilitychange", (event) => { if (this.gl.isContextLost()) { this.restoreWebGL(); } });
	}

	/**
	* Updates the state of the canvas and its components.
	* @param {Object} state - State object containing updates
	* @param {Object} [state.camera] - Camera state updates
	* @param {Object} [state.layers] - Layer state updates
	* @param {number} dt - Animation duration in milliseconds
	* @param {string} [easing='linear'] - Easing function for animations
	*/
	setState(state, dt, easing = 'linear') {
		if ('camera' in state) {
			const m = state.camera;
			this.camera.setPosition(dt, m.x, m.y, m.z, m.a, easing);
		}
		if ('layers' in state)
			for (const [k, layerState] of Object.entries(state.layers))
				if (k in this.layers) {
					const layer = this.layers[k];
					layer.setState(layerState, dt, easing);
				}
	}

	/**
	* Retrieves current state of the canvas and its components.
	* @param {Object} [stateMask=null] - Optional mask to filter returned state properties
	* @returns {Object} Current state object
	*/
	getState(stateMask = null) {
		let state = {};
		if (!stateMask || stateMask.camera) {
			let now = performance.now();
			let m = this.camera.getCurrentTransform(now);
			state.camera = { 'x': m.x, 'y': m.y, 'z': m.z, 'a': m.a };
		}
		state.layers = {};
		for (let layer of Object.values(this.layers)) {
			const layerMask = window.structuredClone(stateMask);
			if (stateMask && stateMask.layers) Object.assign(layerMask, stateMask.layers[layer.id]);
			state.layers[layer.id] = layer.getState(layerMask);
		}
		return state;
	}

	/**
 * Restores WebGL context after loss.
 * Reinitializes shaders and textures for all layers.
 * @private
 */
	restoreWebGL() {
		let glopt = { antialias: false, depth: false, preserveDrawingBuffer: this.preserveDrawingBuffer };
		this.gl = this.gl ||
			this.canvasElement.getContext("webgl2", glopt) ||
			this.canvasElement.getContext("webgl", glopt) ||
			this.canvasElement.getContext("experimental-webgl", glopt);

		for (let layer of Object.values(this.layers)) {
			layer.gl = this.gl;
			layer.clear();
			if (layer.shader)
				layer.shader.restoreWebGL(this.gl);
		}
		this.prefetch();
		this.emit('update');
	}

	/**
	 * Adds a layer to the canvas.
	 * @param {string} id - Unique identifier for the layer
	 * @param {Layer} layer - Layer instance to add
	 * @fires Canvas#update
	 * @fires Canvas#ready
	 * @throws {Error} If layer ID already exists
	 */
	addLayer(id, layer) {

		console.assert(!(id in this.layers), "Duplicated layer id");

		layer.id = id;
		layer.addEvent('ready', () => {
			if (Object.values(this.layers).every(l => l.status == 'ready'))
				this.emit('ready');
			this.prefetch();
		});
		layer.addEvent('update', () => { this.emit('update'); });
		layer.addEvent('updateSize', () => { this.updateSize(); });
		layer.gl = this.gl;
		layer.canvas = this;
		layer.overlayElement = this.overlayElement;
		this.layers[id] = layer;
		this.prefetch();
	}

	/**
 * Removes a layer from the canvas.
 * @param {Layer} layer - Layer instance to remove
 * @example
 * const layer = new Layer(options);
 * canvas.addLayer('map', layer);
 * // ... later ...
 * canvas.removeLayer(layer);
 */
	removeLayer(layer) {
		layer.clear(); //order is important.

		delete this.layers[layer.id];
		delete Cache.layers[layer];
		this.prefetch();
	}

	/**
	 * Updates canvas size and camera bounds based on layers.
	 * @fires Canvas#updateSize
	 * @private
	 */
	updateSize() {
		const discardHidden = false;
		let sceneBBox = Layer.computeLayersBBox(this.layers, discardHidden);
		let minScale = Layer.computeLayersMinScale(this.layers, discardHidden);

		if (sceneBBox != null && this.camera.viewport)
			this.camera.updateBounds(sceneBBox, minScale);
		this.emit('updateSize');
	}

	/**
 * Renders a frame at the specified time.
 * @param {number} time - Current time in milliseconds
 * @returns {boolean} True if all animations are complete
 * @private
 */
	draw(time) {
		let gl = this.gl;
		let view = this.camera.glViewport();
		gl.viewport(view.x, view.y, view.dx, view.dy);

		var b = [0, 0, 0, 0];
		gl.clearColor(b[0], b[1], b[2], b[3], b[4]);
		gl.clear(gl.COLOR_BUFFER_BIT);

		gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
		gl.enable(gl.BLEND);

		//TODO: getCurren shoudl redurn {position, done}
		let pos = this.camera.getGlCurrentTransform(time);
		//todo we could actually prefetch toward the future a little bit
		this.prefetch(pos);

		//pos layers using zindex.
		let ordered = Object.values(this.layers).sort((a, b) => a.zindex - b.zindex);

		//NOTICE: camera(pos) must be relative to the WHOLE canvas
		let done = true;
		for (let layer of ordered) {
			if (layer.visible)
				done = layer.draw(pos, view) && done;
		}

		//TODO not really an elegant solution to tell if we have reached the target, the check should be in getCurrentTransform.
		return done && pos.t >= this.camera.target.t;
	}

	/**
 * Schedules tile downloads based on current view.
 * @param {Object} [transform] - Optional transform override, defaults to current camera transform
 * @private
 */
	prefetch(transform) {
		if (!transform)
			transform = this.camera.getGlCurrentTransform(performance.now());
		for (let id in this.layers) {
			let layer = this.layers[id];
			//console.log(layer);
			//console.log(layer.layout.status);
			if (layer.visible && layer.status == 'ready') {
				layer.prefetch(transform, this.camera.glViewport());
			}
		}
	}
}

/**
 * Fired when canvas content is updated (layer changes, camera moves).
 * @event Canvas#update
 */

/**
 * Fired when canvas or layout size changes.
 * @event Canvas#updateSize
 */

/**
 * Fired when all layers are initialized and ready to display.
 * @event Canvas#ready
 */

addSignals(Canvas, 'update', 'updateSize', 'ready');

export { Canvas }