import { clamp } from './modules/Util.js'; class Canvas { constructor(rootId) { const root = document.getElementById(rootId); this.element = document.createElement('canvas'); this.context = this.element.getContext('2d'); /* state variables */ this.scale = 1; this.zoom = 1; const ZOOM_SPEED = 1.2; this.mouse = { screenPos: { x: 0, y: 0 }, drawingPos: { x: 0, y: 0 }, }; this.pan = { x: 0, y: 0 }; this.panning = false; /* callbacks */ this.onDraw = null; this.onMouseMove = null; this.onMouseDown = null; this.onMouseUp = null; /* register event listeners */ /* mouse movement */ this.element.addEventListener('mousemove', e => { this.mouse.screenPos.x = e.offsetX; this.mouse.screenPos.y = e.offsetY; const [xd, yd] = this.screenToDrawingPos(e.offsetX, e.offsetY); /* compute movement */ const dx = e.movementX / (this.zoom * this.scale); const dy = e.movementY / (this.zoom * this.scale); /* pan? */ if (this.panning) { this.pan.x += dx; this.pan.y += dy; this.setTransform(); } this.mouse.drawingPos.x = xd; this.mouse.drawingPos.y = yd; if (this.onMouseMove) this.onMouseMove(this.mousePos); }); /* clicking */ this.element.addEventListener('mousedown', e => { e.preventDefault(); if (e.button === 1) this.panning = true; if (this.onMouseDown) this.onMouseDown(e); }); this.element.addEventListener('mouseup', e => { if (e.button === 1) this.panning = false; if (this.onMouseUp) this.onMouseUp(e); }); /* mouse leave */ this.element.addEventListener('mouseleave', e => { this.panning = false; }); /* mouse wheel */ this.element.addEventListener('wheel', e => { if (this.panning) return; // don't zoom and pan simultaneously const delta = e.deltaY < 0 ? ZOOM_SPEED : 1/ZOOM_SPEED; const alpha = (1/delta) - 1; /* zoom in */ this.zoom *= delta; this.zoom = clamp(this.zoom, 1, 1000); this.setTransform(); /* pan to keep mouse in the same place */ const [mouseX, mouseY] = this.screenToDrawingPos(this.mouse.screenPos.x, this.mouse.screenPos.y); this.pan.x += mouseX - this.mouse.drawingPos.x; this.pan.y += mouseY - this.mouse.drawingPos.y; this.setTransform(); this.draw(); }); /* finalize setup */ this.fillWindow(); window.addEventListener('resize', () => this.fillWindow()); root.appendChild(this.element); } setTransform() { /* clamp pan */ const xMax = this.pixelsToUnits(this.element.width) - 1; const yMax = this.pixelsToUnits(this.element.height) - 1; this.pan.x = clamp(this.pan.x, xMax, 0); this.pan.y = clamp(this.pan.y, yMax, 0); this.context.setTransform(1, 0, 0, 1, 0, 0); this.context.scale(this.zoom * this.scale, this.zoom * this.scale); this.context.translate(this.pan.x, this.pan.y); } fillWindow() { const width = window.innerWidth; const height = window.innerHeight; this.scale = Math.max(width, height); this.element.width = width; this.element.height = height; this.setTransform(); this.draw(); } pixelsToUnits(value) { return value / this.zoom / this.scale; } unitsToPixels(value) { return value * this.zoom * this.scale; } screenToDrawingPos(xs, ys) { const matrix = this.context.getTransform(); matrix.invertSelf(); /* compute drawing-space position */ const x = matrix.a*xs + matrix.b*ys + matrix.e; const y = matrix.c*xs + matrix.d*ys + matrix.f; return [ x, y ]; } draw() { this.context.clearRect(0, 0, 1, 1); if (this.onDraw) this.onDraw(this.context); } } export default Canvas;