render_Graph.js

const vec = require("../geometry/vec");
const { merge } = require("../core/Common");
const RenderMethods = require("../render/RenderMethods");

/**
 * Graph for tracking variables
 */
class Graph {
	static defaultOptions = {
		title: "",
		titleSize: 14,
		titleColor: "white",
		anchorX: "left",
		anchorY: "top",
		background: "#0D0D0DE6",
		maxLength: 200,
		scaleRange: 100,
		lineColor: "#9C9C9C",
		lineWidth: 1,
		padding: 8,
		round: 5,
	}
	/**
	 * If the graph is enabled
	 * @type {boolean}
	 * @readonly
	 */
	enabled = true;
	canvas;
	ctx;
	data = {};

	/**
	 * Creates a graph
	 * @param {number} width - Width of the graph
	 * @param {number} height - Height of the graph
	 * @param {vec} position - Position of the graph
	 * @param {object} options - Other graph options
	 * @param {string} [options.title=""] - Title of the graph
	 * @param {number} [options.titleSize=14] - Font size of title
	 * @param {string} [options.titleColor="white"] - Color of title
	 * @param {boolean} [options.enabled=true] - If graph starts enabled
	 * @param {("left"|"right"|"center")} [options.anchorX="left"] - Relative x position of graph on the screen
	 * @param {("top"|"bottom"|"center")} [options.anchorY="top"] - Relative y position of graph on the screen
	 * @param {string} [options.background="#0D0D0DE6"] - Background color of graph
	 * @param {number} [options.padding=8] - Amount of padding around the graph
	 * @param {number} [options.round=5] - Amount of round around the graph
	 * @param {number} [options.scaleRange=100] - Minimum y range. When specified as an integer rather than an array of min and max values, it uses auto scaling
	 * @param {Array} [options.scaleRange=undefined] - Minimum and maximum y values of the graph, as Array of `[min, max]`. Leaving `undefined` or specifying a number uses auto scaling.
	 * @param {number} [options.maxLength=200] - Maximum number of points the graph can have
	 * @param {string} [options.lineColor="#9C9C9C"] - Color of the line. Use this if you only have 1 value you're graphing
	 * @param {object} [options.lineColor={ default: "#9C9C9C" }] - Colors of each line name. Use this notation if you have multiple lines on one graph
	 * @param {number} [options.lineWidth=1] - Width of the graph lines
	 * @example
	 * let graph = new Graph(200, 150, new vec(20, 20), {
	 * 	maxLength: 800,
	 * 	title: "Hello graph",
	 * 	titleSize: 12,
	 * 	background: "transparent",
	 * 	lineColor: {
	 * 		itemA: "#9D436C",
	 * 		itemB: "#5EA8BA",
	 * 	},
	 * 	padding: 10,
	 * 	scaleRange: [0, 144 * 2],
	 * });
	 */
	
	constructor(width = 200, height = 200, position = new vec(0, 0), options = {}) {
		let mergedOptions = { ...Graph.defaultOptions };
		merge(mergedOptions, options, 1);
		let { anchorX, anchorY } = mergedOptions;
		
		if (typeof mergedOptions.lineColor === "string") {
			mergedOptions.lineColor = { default: mergedOptions.lineColor };
		}
		merge(this, mergedOptions, 1);
		this.width = width;
		this.height = height;

		// Create canvas
		let scale = this.scale = devicePixelRatio ?? 1;
		let canvas = this.canvas = document.createElement("canvas");
		this.ctx = canvas.getContext("2d");
		canvas.style.position = "absolute";
		canvas.style.zIndex = "2";

		if (anchorX === "center") {
			canvas.style.left = `calc(50vw + ${ position.x }px)`;
			canvas.style.transform = `translateX(-50%)`;
		}
		else {
			canvas.style[anchorX] = `${position.x}px`;
		}

		if (anchorY === "center") {
			canvas.style.top = `calc(50vh + ${ position.y }px)`;
			canvas.style.transform = `translateY(-50%)`;
		}
		else {
			canvas.style[anchorY] =  `${position.y}px`;
		}
		canvas.style.transformOrigin = `${anchorX} ${anchorY}`;
		canvas.style.transform += ` scale(${1 / scale}, ${1 / scale})`;

		canvas.style.top =  `${position.x}px`;
		canvas.width =  scale * width;
		canvas.height = scale * height;
		canvas.style.background = "transparent";
		// canvas.style.pointerEvents = "none";
		document.body.appendChild(canvas);

		// Set up rendering
		this.update = this.update.bind(this);

		if (this.enabled) {
			this.animationFrame = requestAnimationFrame(this.update);
		}
	}

	/**
	 * Set if the graph is enabled
	 */
	setEnabled(enabled) {
		this.enabled = enabled;

		if (this.animationFrame != undefined) { // prevent multiple render updates running at once
			cancelAnimationFrame(this.animationFrame);
			delete this.animationFrame;
		}

		if (this.enabled) { // start rendering
			this.canvas.style.display = "block";
			this.update();
		}
		else {
			this.canvas.style.display = "none";
		}
	}

	_getStats(data) {
		let max = 0;
		let min = Infinity;
		let avg = (() => {
			let v = 0;
			for (let i = 0; i < data.length; i++) {
				let cur = data[i];
				v += cur;
				max = Math.max(max, cur);
				min = Math.min(min, cur);
			}
			return v / data.length;
		})();

		return {
			max: max,
			min: min,
			average: avg,
		};
	}
	update() {
		let { canvas, ctx, enabled, scale, width, height, title, titleSize, titleColor, background, round, padding, lineColor: allLineColors, lineWidth, maxLength, scaleRange } = this;
		let { data: allData } = this;

		ctx.clearRect(0, 0, canvas.width, canvas.height);
		if (enabled) {
			ctx.save();
			ctx.scale(scale, scale);

			// background
			ctx.beginPath();
			RenderMethods.roundedRect(width, height, new vec(width/2, height/2), round, ctx);
			ctx.fillStyle = background;
			ctx.fill();

			// title text
			ctx.beginPath();
			ctx.fillStyle = titleColor;
			ctx.textAlign = "left";
			ctx.font = `400 ${titleSize}px Arial`;
			ctx.fillText(title, padding, padding + titleSize - 4);
			

			// Find scale
			let valueRanges = {
				min: Infinity,
				max: -Infinity
			};
			if (Array.isArray(scaleRange)) {
				valueRanges = {
					min: scaleRange[0],
					max: scaleRange[1]
				};
			}
			else {
				for (let data of Object.values(allData)) {
					let { min, max } = this._getStats(data);
					valueRanges.min = Math.min(valueRanges.min, min);
					valueRanges.max = Math.max(valueRanges.max, max);
				}
				valueRanges.min = Math.min(valueRanges.min, (valueRanges.max + valueRanges.min - scaleRange) / 2);
				valueRanges.max = Math.max(valueRanges.max, (valueRanges.max + valueRanges.min + scaleRange) / 2);
			}
			
			let bounds = {
				min: new vec(padding, titleSize + padding + 5),
				max: new vec(width - padding, height - padding),
			};
			let boundSize = bounds.max.sub(bounds.min);
			function getPosition(point, i) {
				// point = Math.max(valueRanges.min, Math.min(valueRanges.max, point));
				const range = valueRanges.max - valueRanges.min;
				let x = bounds.min.x + (i / maxLength) * boundSize.x;
				let y = bounds.max.y - ((point - valueRanges.min) / range) * boundSize.y;
				return [x, y];
			}

			for (let dataName in allData) {
				// get data stats
				let data = allData[dataName];
				let lineColor = allLineColors[dataName];
				
				// graph line
				if (data.length > 1) {
					ctx.beginPath();
					ctx.moveTo(...getPosition(data[0], 0))
					for (let i = 1; i < data.length; i++) {
						ctx.lineTo(...getPosition(data[i], i));
					}
					ctx.lineWidth = lineWidth;
					ctx.lineJoin = "bevel";
					ctx.strokeStyle = lineColor;
					ctx.stroke();
				}
			}
			
			
			ctx.restore();
			this.animationFrame = requestAnimationFrame(this.update);
		}
	}

	/**
	 * Adds value to the graph
	 * @param {number} value - Value to add
	 * @param {string} [name="default"] - Name of line
	 * @example
	 * graph.addData(20); // Adds value 20. Only works if you used a string (not object) to set lineColor
	 * graph.addData(102.4, "itemA"); // Adds value 102.4 to the line named itemA
	 */
	addData(value, name = "default") {
		if (!this.lineColor[name]) {
			console.error(this.lineColor);
			throw new Error(`No data named ${name} in graph`);
		}
		
		if (!this.data[name]) this.data[name] = [];
		let data = this.data[name];
		data.push(value);
		while (data.length > 0 && data.length > this.maxLength) {
			data.shift();
		}
	}
}

module.exports = Graph;