Canvas JavaScript

A Basic 2D Graph with Pan and Zoom

This JavaScript program demonstrates how to draw a grid for a 2D graph with adaptive scaling. The mouse can be used to pan the graph by pressing the left mouse button and moving the cursor or zoom the graph by using the mousewheel to zoom in and out at the cursor location. This can be used as the basis for a 2D graph control.

BasicGraph2DWithPanAndZoom.html

<!DOCTYPE html>
<html>
  <head>
    <title>XoaX.net's Javascript</title>
    <script type="text/javascript" src="CAffineTransformation2D.js"></script>
    <script type="text/javascript" src="CGraphOnImage2D.js"></script>
    <script type="text/javascript" src="CCanvasGraph2D.js"></script>
    <script type="text/javascript" src="Main.js"></script>
  </head>
  <body onload="Initialize()">
 <!-- This is created in the code with a dynamic size
  	<div style="width:708px;height:622px;">
    	<div style="width:108px;height:600px;float:left;">
    		<input id="idHighY" type="text" size="9" style="width:100px;height:16px;"/>
    		<div style="width:108px;height:556px;"></div>
    		<input id="idLowY" type="text" size="9" style="width:100px;height:16px;"/>
    	</div>
    	<canvas id="idCanvas" width="600" height ="600" style="background-color:#F0F0F0;float:left;"></canvas>
    	<div style="width:600px;height:22px;float:right;">
    		<input id="idLowX" type="text" size="9" style="width:100px;height:16px;float:left;"/>
    		<div style="width:384px;height:22px;float:left;"></div>
    		<input id="idHighX" type="text" size="9" style="width:100px;height:16px;float:left;"/>    		
    	</div>
    </div>
-->
  </body>
</html>

CAffineTransformation2D.js

// Scaling followed by a Translation
// [ Sx 0  Tx ]   [ 1 0 Tx ][ Sx  0   0 ]
// [ 0  Sy Ty ] = [ 0 1 Ty ][ 0   Sy  0 ]
// [ 0  0  1  ]   [ 0 0 1  ][ 0   0   1 ]
// Inverse
// [ 1/Sx   0   -Tx/Sx ]   [1/Sx  0   0 ][ 1 0 -Tx ]
// [  0    1/Sy -Ty/Sy ] = [ 0   1/Sy 0 ][ 0 1 -Ty ]
// [  0     0      1   ]   [ 0    0   1 ][ 0 0  1  ]
class CAffineTransformation2D {
	constructor(dSx, dSy, dTx, dTy) {
		this.mdaM = [dSx, 0.0, dTx, 0.0, dSy, dTy];
	}
	Transform(daInP) {
		let mdaOutP = [0.0, 0.0];
		mdaOutP[0] = this.mdaM[0]*daInP[0] + this.mdaM[2];
		mdaOutP[1] = this.mdaM[4]*daInP[1] + this.mdaM[5];
		return mdaOutP;
	}
	Invert(daInP) {
		let mdaOutP = [0.0, 0.0];
		mdaOutP[0] = daInP[0]/this.mdaM[0] - this.mdaM[2]/this.mdaM[0];
		mdaOutP[1] = daInP[1]/this.mdaM[4] - this.mdaM[5]/this.mdaM[4];
		return mdaOutP;
	}
	// Scale the transformation and scale around the center point. This means that the translation must be adjusted
	ScaleAround(dScaleFactor, daCenter) {
		this.mdaM[0] *= dScaleFactor;
		this.mdaM[4] *= dScaleFactor;
		// This is LowX = CX - (CX - LowX)*ScalingFactor = CX*(1 -  dScaleFactor) + LowX*dScaleFactor
		this.mdaM[2] = daCenter[0] - (daCenter[0] - this.mdaM[2])*dScaleFactor;
		// This is LowY = CY - (CY - LowY)*ScalingFactor = CY*(1 -  dScaleFactor) + LowY*dScaleFactor
		this.mdaM[5] = daCenter[1] - (daCenter[1] - this.mdaM[5])*dScaleFactor;
	}
	TranslateBy(daDelta) {
		this.mdaM[2] += daDelta[0];
		this.mdaM[5] += daDelta[1];
	}
}

CGraphOnImage2D.js

// Use the affine transformation to transform from spatial coordinates to pixels
class CGraphOnImage2D {
	constructor(dLowX, dHighX, dLowY, dHighY, qCanvas) {
		this.mqCanvas = qCanvas;
		let iCanvasW = qCanvas.width;
		let iCanvasH = qCanvas.height;
		let dScaleX = (dHighX - dLowX)/iCanvasW;
		// The y-axis is flipped
		let dScaleY = -(dHighY - dLowY)/iCanvasH;
		// Add .5 for the pixels to get the locations correct for half pixel offsets
		// [ Sx 0  (Tx + .5Sx) ]   [ 1 0 Tx ][ Sx  0   0 ][ 1 0 .5 ]
		// [ 0  Sy (Ty + .5Sy) ] = [ 0 1 Ty ][ 0   Sy  0 ][ 0 1 .5 ]
		// [ 0  0      1       ]   [ 0 0 1  ][ 0   0   1 ][ 0 0 1  ]
		this.mqPixelToPoint = new CAffineTransformation2D(dScaleX, dScaleY, dLowX + .5*dScaleX, dHighY + .5*dScaleY);
	}
	// Use this to scale the space for mouse events
	Zoom(dScaleFactor, daPixel) {
		// Transform the pixel to spatial coordinates
		let daCenter = this.mqPixelToPoint.Transform(daPixel);
		this.mqPixelToPoint.ScaleAround(dScaleFactor, daCenter);
	}
	Pan(daDeltaPixels) {
		// Get the x and y sizes of a single pixel in spatial coordiantes
		let daOriginImage = this.mqPixelToPoint.Transform([0.0, 0.0]);
		let daOneOneImage = this.mqPixelToPoint.Transform([1.0, 1.0]);
		let dPixelWidth = (daOneOneImage[0] - daOriginImage[0]);
		let dPixelHeight = (daOneOneImage[1] - daOriginImage[1]);
		// Adjust the transformation
		this.mqPixelToPoint.TranslateBy([daDeltaPixels[0]*dPixelWidth, daDeltaPixels[1]*dPixelHeight]);
	}
	LowerLeft() {
		return this.mqPixelToPoint.Transform([-.5, this.mqCanvas.height -.5]);
	}
	UpperRight() {
		return this.mqPixelToPoint.Transform([this.mqCanvas.width - .5, -.5]);
	}
	// Find the smallest power greater than x
	FindSmallestGreaterPowerOfTwo(dX) {
		var dPow = 1.0;
		while (dPow < dX) {
			dPow *= 2;
		}
		if (dPow == 1.0) {
			while (dPow >= dX) {
				dPow /= 2;
			}
		}
		return dPow;
	}
	FindLeastMultipleBelow(dX, dLowX) {
		Math.floor(dLowX/dX)*dX;
	}
	DrawGridLinesAndAxes() {
		let daLowerLeft = this.LowerLeft();
		let daUpperRight = this.UpperRight();
		let qContext = this.mqCanvas.getContext("2d");
		// This for calculating a range for grid spacing
		let daZero = this.mqPixelToPoint.Transform([0.0, 0.0]);
		let daHundred = this.mqPixelToPoint.Transform([100.0, 100.0]);
		// Draw grid lines of two types
		// Get an estimate for a value that will be drawn about every 100 pixels and every 25
		var dThickFreqX = this.FindSmallestGreaterPowerOfTwo(Math.abs(daHundred[0] - daZero[0]));
		var dThinFreqX = dThickFreqX/4;
		// Reverse the order for the y-coordinates
		var dThickFreqY = this.FindSmallestGreaterPowerOfTwo(Math.abs(daZero[1] - daHundred[1]));
		var dThinFreqY = dThickFreqY/4;

		// Draw the thin lines first, then the thick ones, and the axes.
		// Draw the thin lines first
		// Draw vertical grid lines
		// Find the least frequency value below the low x (greatest lower multiple GLM)
		var dGLM = Math.floor(daLowerLeft[0]/dThickFreqX)*dThickFreqX;
		var dGridValue = dGLM + dThinFreqX;
		qContext.strokeStyle = "#E8E8E8";
		var i = 1;
		while (dGridValue < daUpperRight[0]) {
			if ((i % 4) != 0) {
				let daGridPt = this.mqPixelToPoint.Invert([dGridValue, 0]);
				qContext.beginPath();
				qContext.moveTo(daGridPt[0], 0);
				qContext.lineTo(daGridPt[0], this.mqCanvas.width);
				qContext.stroke();
			}
			dGridValue += dThinFreqX;
			++i;
		}
	//------------------------------------
		// Draw horizontal grid lines
		dGLM = Math.floor(daLowerLeft[1]/dThickFreqY)*dThickFreqY;
		dGridValue = dGLM + dThinFreqY;
		i = 1;
		while (dGridValue < daUpperRight[1]) {
			if ((i % 4) != 0) {
				let daGridPt = this.mqPixelToPoint.Invert([0, dGridValue]);
				qContext.beginPath();
				qContext.moveTo(0, daGridPt[1]);
				qContext.lineTo(this.mqCanvas.width, daGridPt[1]);
				qContext.stroke();
			}
			dGridValue += dThinFreqY;
			++i;
		}
		
		// Draw the thick lines
		// Draw vertical grid lines
		qContext.strokeStyle = "#DDDDDD";
		dGLM = Math.floor(daLowerLeft[0]/dThickFreqX)*dThickFreqX;
		dGridValue = dGLM + dThickFreqX;
		while (dGridValue < daUpperRight[0]) {
			let daGridPt = this.mqPixelToPoint.Invert([dGridValue, 0]);
			qContext.beginPath();
			qContext.moveTo(daGridPt[0], 0);
			qContext.lineTo(daGridPt[0], this.mqCanvas.height);
			qContext.stroke();
			dGridValue += dThickFreqX;
		}
		// Draw horizontal grid lines
		dGLM = Math.floor(daLowerLeft[1]/dThickFreqY)*dThickFreqY;
		dGridValue = dGLM + dThickFreqY;
		while (dGridValue < daUpperRight[1]) {
			let daGridPt = this.mqPixelToPoint.Invert([0, dGridValue]);
			qContext.beginPath();
			qContext.moveTo(0, daGridPt[1]);
			qContext.lineTo(this.mqCanvas.width, daGridPt[1]);
			qContext.stroke();
			dGridValue += dThickFreqY;
		}

		// Lastly, check whether zero is within each range in order to draw the axes.
		// Draw the Y-Axis
		qContext.strokeStyle = "gray";
		qContext.fillStyle = "gray";
		// Find the pixel location of the origin
		var daOriginPixel = this.mqPixelToPoint.Invert([0, 0]);
		if (-.5 <= daOriginPixel[0] && (this.mqCanvas.width - .5) >= daOriginPixel[0]) {
			this.DrawArrow(qContext, daOriginPixel[0], this.mqCanvas.height, daOriginPixel[0], 0);
		}
		// Draw the X-Axis
		if (-.5 <= daOriginPixel[1] && (this.mqCanvas.height - .5) >= daOriginPixel[1]) {
			this.DrawArrow(qContext, 0, daOriginPixel[1], this.mqCanvas.width, daOriginPixel[1]);
		}
	}
	// Arrow drawing function to draw the axes
	DrawArrow(qContext, dX1, dY1, dX2, dY2) {
		// Draw the line portion
		qContext.beginPath();
		qContext.moveTo(dX1, dY1);
		qContext.lineTo(dX2, dY2);
		qContext.stroke();
		// Draw the head portion
		this.DrawArrowhead(qContext,  dX1, dY1, dX2, dY2);
	}
	DrawArrowhead(qContext, dX1, dY1, dX2, dY2) {
		// The angle direction of the arrow
		var dTheta = Math.atan2(dY2-dY1,dX2-dX1);
		var dThicknessAngle = Math.PI/6;
		var dHeadLength = 10;
		qContext.beginPath();
		qContext.moveTo(dX2, dY2);
		qContext.lineTo(dX2 - dHeadLength*Math.cos(dTheta-dThicknessAngle), dY2 - dHeadLength*Math.sin(dTheta-dThicknessAngle));
		qContext.lineTo(dX2 - dHeadLength*Math.cos(dTheta+dThicknessAngle), dY2 - dHeadLength*Math.sin(dTheta+dThicknessAngle));
		qContext.closePath();
		qContext.fill();
	}
}

CCanvasGraph2D.js

// This is the external container for the HTML elements
class CCanvasGraph2D {
	constructor(iCanvasWidth, iCanvasHeight) {
		// Get the body element and create the other elements
		let qBodyElement = document.getElementsByTagName("body")[0];
		let qInstructions = document.createElement("p");
		qInstructions.innerHTML = "Hold the left mouse button and move the mouse to pan. Use the mouse wheel to zoom in and out at the cursor.";
		qBodyElement.appendChild(qInstructions);
		// Create the external div with width = (iCanvasWidth + 108) and height = (iCanvasHeight + 22) and add it to the body
		let qContainerDiv = document.createElement("div");
		qContainerDiv.style.width = (iCanvasWidth + 108) + "px";
		qContainerDiv.style.height = (iCanvasHeight + 22) + "px";
		qBodyElement.appendChild(qContainerDiv);
		//   Add a div for the y-range labels
		let qLabelYDiv = document.createElement("div");
		qLabelYDiv.style.width = "108px";
		qLabelYDiv.style.height = iCanvasHeight + "px";
		qLabelYDiv.style.float = "left";
		qContainerDiv.appendChild(qLabelYDiv);
		this.mqHighY = document.createElement("input");
		this.mqHighY.style.width = "100px";
		this.mqHighY.style.height = "16px";
		this.mqHighY.id = "idHighY";
		this.mqHighY.type = "text";
		this.mqHighY.size = "9";
		qLabelYDiv.appendChild(this.mqHighY);
		let qSpaceYDiv = document.createElement("div");
		qSpaceYDiv.style.width = "108px";
		qSpaceYDiv.style.height = (iCanvasHeight - 44) + "px";
		qSpaceYDiv.style.float = "left";
		qLabelYDiv.appendChild(qSpaceYDiv);
		this.mqLowY = document.createElement("input");
		this.mqLowY.style.width = "100px";
		this.mqLowY.style.height = "16px";
		this.mqLowY.id = "idLowY";
		this.mqLowY.type = "text";
		this.mqLowY.size = "9";
		qLabelYDiv.appendChild(this.mqLowY);
		//   Create the canvas and add it to that div
		let qCanvas = document.createElement("canvas");
		qCanvas.id = "idCanvas";
		qCanvas.width = iCanvasWidth + "";
		qCanvas.height = iCanvasHeight + "";
		qCanvas.style.backgroundColor = "#F0F0F0";
		qCanvas.style.float = "left";
		qContainerDiv.appendChild(qCanvas);
		//   Add a div for the x-range labels
		let qLabelXDiv = document.createElement("div");
		qLabelXDiv.style.width = iCanvasWidth + "px";
		qLabelXDiv.style.height = "22px";
		qLabelXDiv.style.float = "right";
		qContainerDiv.appendChild(qLabelXDiv);
		this.mqLowX = document.createElement("input");
		this.mqLowX.style.width = "100px";
		this.mqLowX.style.height = "16px";
		this.mqLowX.style.float = "left";
		this.mqLowX.id = "idLowX";
		this.mqLowX.type = "text";
		this.mqLowX.size = "9";
		qLabelXDiv.appendChild(this.mqLowX);
		let qSpaceXDiv = document.createElement("div");
		qSpaceXDiv.style.width = (iCanvasWidth - 216) + "px";
		qSpaceXDiv.style.height = "22px";
		qSpaceXDiv.style.float = "left";
		qLabelXDiv.appendChild(qSpaceXDiv);
		this.mqHighX = document.createElement("input");
		this.mqHighX.style.width = "100px";
		this.mqHighX.style.height = "16px";
		this.mqHighX.id = "idHighX";
		this.mqHighX.type = "text";
		this.mqHighX.size = "9";
		qLabelXDiv.appendChild(this.mqHighX);
		
		// Create the graphing object
		this.mqGraph2D = new CGraphOnImage2D(-4.0, 4.0, -4.0, 4.0, qCanvas);
	}
	DrawTheGraphAndBounds() {
		let qCanvas = document.getElementById("idCanvas");
		let qContext = qCanvas.getContext("2d");
		qContext.clearRect(0, 0, qCanvas.width, qCanvas.height);
		this.mqGraph2D.DrawGridLinesAndAxes();
		// Fill in the range bounds
		let daLowerLeft = this.mqGraph2D.LowerLeft();
		let daUpperRight = this.mqGraph2D.UpperRight();
		// Remove any trailing zeroes and decimal points
		this.mqLowX.value = daLowerLeft[0].toFixed(4).replace(/\.?0+$/, "");
		this.mqHighX.value = daUpperRight[0].toFixed(4).replace(/\.?0+$/, "");
		this.mqLowY.value = daLowerLeft[1].toFixed(4).replace(/\.?0+$/, "");
		this.mqHighY.value = daUpperRight[1].toFixed(4).replace(/\.?0+$/, "");
	}
	GetGraph() {
		return this.mqGraph2D;
	}
}

Main.js

// Global variables
var gqCanvasGraph = null;
var gbIsDragging = false;
var giCursorX = 0;
var giCursorY = 0;

function Initialize() {
	gqCanvasGraph = new CCanvasGraph2D(600, 600);
	let qCanvas = document.getElementById("idCanvas");
	// With the elements created, add the event listeners to the canvas
	qCanvas.addEventListener("wheel", fnZoom, { passive: false });
	qCanvas.addEventListener("mousedown", MouseDown);
	qCanvas.addEventListener("mouseup", MouseUp);
	qCanvas.addEventListener("mousemove", MouseMove);
	gqCanvasGraph.DrawTheGraphAndBounds();
}

// Global mouse event handler functions
function fnZoom(e) {
  e.preventDefault();
  var dFactor = (e.deltaY > 0.0) ? 1.1: .9090909090909;
  SetCursorCoordinates(e);
  // Zoom centered on the pixel location
  gqCanvasGraph.GetGraph().Zoom(dFactor, [giCursorX, giCursorY]);
  gqCanvasGraph.DrawTheGraphAndBounds();
}

function MouseMove(e) {
	if (gbIsDragging) {
		var iLastCursorX = giCursorX;
		var iLastCursorY = giCursorY;
		SetCursorCoordinates(e);
		var iCurrCursorX = giCursorX;
		var iCurrCursorY = giCursorY;
		gqCanvasGraph.GetGraph().Pan([iLastCursorX - iCurrCursorX, iLastCursorY - iCurrCursorY]);
		gqCanvasGraph.DrawTheGraphAndBounds();
	}
}

function MouseDown(e) {
	SetCursorCoordinates(e);
	gbIsDragging = true;
}

function MouseUp(e) {
	gbIsDragging = false;
}

function SetCursorCoordinates(e) {
	var dElementOffsetX = 0;
	var dElementOffsetY = 0;
	// Get the element that triggered the event
	var qElement = e.target;

	do{
		dElementOffsetX += qElement.offsetLeft - qElement.scrollLeft;
		dElementOffsetY += qElement.offsetTop - qElement.scrollTop;
		qElement = qElement.offsetParent;
	} while(qElement != document.body)
	
	giCursorX = e.pageX - dElementOffsetX;
	giCursorY = e.pageY - dElementOffsetY;
}
 

Output

 
 

© 2007–2025 XoaX.net LLC. All rights reserved.