Canvas JavaScript

Linear Interpolation with Adaptive Scaling

This JavaScript program demonstrates how to draw a linearly interpolated graph of a 2d function 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.

LinearInterpolationWithAdaptiveScaling.html

<!DOCTYPE html>
<html>
  <head>
    <title>XoaX.net's Javascript</title>
    <script type="text/javascript" src="LinearInterpolationWithAdaptiveScaling.js"></script>
  </head>
  <body onload="Initialize()">
  	<div style="width:708px;height:622px;">
    	<canvas id="idCanvas" width="600" height ="600" style="background-color:#F0F0F0;float:left;"></canvas>
    	<div style="width:108px;height:600px;float:right;">
    		<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>
    	<div style="width:600px;height:22px;float:left;">
    		<input id="idLowX" type="text" size="9" style="width:100px;height:16px;float:left;"/>
    		<div style="width:384px;height:22px;float:left;">
    			<div style="width:fit-content;margin-left:auto;margin-right:auto;">Function:
    			<select style="height: 22px;" id="idFunction" onchange="SelectFunction()">
    				<option value="kHyperbola">y = 1/x</option>
    				<option value="kParabola">y = x^2</option>
    				<option value="kSin">y = sin(x)</option>
    				<option value="kTan">y = tan(x)</option>
    			</select>
    			</div>
    		</div>
    		<input id="idHighX" type="text" size="9" style="width:100px;height:16px;float:left;"/>    		
    	</div>
    	<div style="width:108px;height:22px;float:right;"></div>
    </div>
  </body>
</html>

LinearInterpolationWithAdaptiveScaling.js

class GraphRange2D {
	constructor(dLowX, dHighX, dLowY, dHighY, iCanvasW, iCanvasH) {
		this.mdLowX = dLowX;
		this.mdHighX = dHighX;
		this.mdLowY = dLowY;
		this.mdHighY = dHighY;
		this.miCanvasW = iCanvasW;
		this.miCanvasH = iCanvasH;
	}
	Scale(dScale, dZoomPixelX, dZoomPixelY) {
		var dZoomX = gqGraphRange.PixelXToX(dZoomPixelX);
		var dZoomY = gqGraphRange.PixelYToY(dZoomPixelY);
		// Scale around the zoom center point, usually defined by the cursor
		this.mdLowX = dZoomX - (dZoomX - this.mdLowX)*dScale;
		this.mdHighX = dZoomX + (this.mdHighX - dZoomX)*dScale;
		this.mdLowY = dZoomY - (dZoomY - this.mdLowY)*dScale;
		this.mdHighY = dZoomY + (this.mdHighY - dZoomY)*dScale;
	}
	Pan(iDeltaPixelX, iDeltaPixelY) {
		// Get the pixel sizes
		var dPixelWidth = (this.mdHighX - this.mdLowX)/this.miCanvasW;
		var dPixelHeight = (this.mdHighY - this.mdLowY)/this.miCanvasH;
		this.mdLowX -= dPixelWidth*iDeltaPixelX;
		this.mdHighX -= dPixelWidth*iDeltaPixelX;
		this.mdLowY += dPixelHeight*iDeltaPixelY;
		this.mdHighY += dPixelHeight*iDeltaPixelY;
	}
	PixelW() {
		return (this.mdHighX - this.mdLowX)/this.miCanvasW;
	}
	PixelH() {
		return (this.mdHighY - this.mdLowY)/this.miCanvasH;
	}
	// The pixel x range is [-.5, CanvasW - .5]
	XtoPixelX(dX) {
		var dT = (dX - this.mdLowX)/(this.mdHighX - this.mdLowX);
		return (dT*this.miCanvasW - .5);
	}
	// The pixel y range is [-.5, CanvasH - .5], but the orientation is reversed
	YtoPixelY(dY) {
		var dT = (dY - this.mdLowY)/(this.mdHighY - this.mdLowY);
		return (1.0 - dT)*(this.miCanvasH - .5);
	}
	PixelXToX(dPixelX) {
		var dT = (dPixelX - .5)/(this.miCanvasW - 1.0);
		return (dT*(this.mdHighX - this.mdLowX) + this.mdLowX);
	}
	PixelYToY(dPixelY) {
		var dT = (dPixelY - .5)/(this.miCanvasH - 1.0);
		return ((1.0 - dT)*(this.mdHighY - this.mdLowY) + this.mdLowY);
	}

	FindPixelXOfEntryExit(dLowPixelX, dHighPixelX, fnF) {
		// The PixelX of the entry is between dLowPixelX and dHighPixelX
		var dLowPixX = dLowPixelX;
		var dHighPixX = dHighPixelX;
		// Create a test x-value close to the inside or high pixel (We are entering from the outside)
		var dLastPixX = dHighPixX;
		var dLastPixY = this.YtoPixelY(fnF(this.PixelXToX(dLastPixX)));
		var dT = 99.0/100.0;
		// Linearly interpolate close to inside location
		var dNextPixX = ((1.0 - dT)*dLowPixX + dT*dLastPixX);
		var dNextPixY = this.YtoPixelY(fnF(this.PixelXToX(dNextPixX)));
		// Is the graph increasing or decreasing?
		var bIsPixelYIncreasing = ((dNextPixY < dLastPixY) ? true : false);
		// If Pixel Y is increasing, find the Pixel X of Pixel Y =  0.0, since must be increasing from an outside Y value
		// If Pixel Y is decreasing, find the Pixel X of Pixel Y = this.miCanvasH - 1.0, since must be decreasing from an outside Y value
		var dTargetY = (bIsPixelYIncreasing ? 0.0 : (this.miCanvasH - 1.0));
		do {
			if (((dLowPixelX < dHighPixelX) && (dNextPixX < dLowPixelX || dNextPixX > dHighPixelX)) ||
					((dHighPixelX < dLowPixelX) && (dNextPixX > dLowPixelX || dNextPixX < dHighPixelX))) {
				return (dLowPixelX + dHighPixelX)/2.0;
			}
			// (y - y0)= m(x - x0) - solve for the crossing value by linear interpolation
			var dM = (dNextPixY - dLastPixY)/(dNextPixX - dLastPixX);
			dLastPixX = dNextPixX;
			dLastPixY = dNextPixY;
			// Linear interpolation x = (y - y0)/m + x0, where y = TargetY
			dNextPixX = (dTargetY - dNextPixY)/dM + dNextPixX;		
			dNextPixY = this.YtoPixelY(fnF(this.PixelXToX(dNextPixX)));
		} while (Math.abs(dNextPixX - dLastPixX) > .001);
		return dNextPixX;
	}
	// Check whether the point is within the bounds of the graph
	IsYInBounds(dY) {
		if ((dY >= this.mdLowY) && (dY <= this.mdHighY)) {
			return true;
		}
		return false;
	}
	IsPixelYInBounds(dPixelY) {
		if ((dPixelY >= 0) && (dPixelY <= this.miCanvasH - 1)) {
			return true;
		}
		return false;
	}
	DrawFunctionGraph(qContext, fnF, dLineWidth) {
		// The x pixel intervals of the graph that are inside the bounds
		var qaaBoundsIntervals = new Array();
		var qaaPointCurves = new Array();
		var qaLargeYDiff = new Array();
		var dPixelX = 0;
		var bIsInBounds = false;
		var daCurrInterval = null;
		// Start at 0 pixel value
		while (dPixelX < this.miCanvasW) {
			var dX = this.PixelXToX(dPixelX);
			var dY = fnF(dX);
			var dPixelY = this.YtoPixelY(dY);
			// Was the last point within the visible graph bounds?
			if (bIsInBounds) {
				// Did we exit the bounds?
				if (!this.IsPixelYInBounds(dPixelY)) { 
					// Find the actual endpoint of the exit.
					daCurrInterval[1] = this.FindPixelXOfEntryExit(dPixelX, dPixelX - 1, fnF);
					bIsInBounds = false;
				} else { // Otherwise, we are still inside the bounds
					// Extend the interval to the next pixel, possibly the last.
					daCurrInterval[1] = dPixelX;
				}
			} else { // The last point was not inside the graph bounds
				if (this.IsPixelYInBounds(dPixelY)) { // Entering visible interval
					// This is the first position within the current interval
					// If it isn't the first pixel, find the start position where the graph crosses into the visible region.
					if (dPixelX >= 1) {
						var dPixelXOfExit = this.FindPixelXOfEntryExit(dPixelX - 1, dPixelX, fnF);
						// We are starting a new inbounds inteval. Initialize it as one point.
						daCurrInterval = [dPixelXOfExit, dPixelXOfExit];
					} else {
						// Put the values into both positions, just in case we are at the end.
						daCurrInterval = [dPixelX, dPixelX];
					}
					qaaBoundsIntervals.push(daCurrInterval);
					bIsInBounds = true;
				}
			}
			// Move to the next pixel location
			dPixelX += 1.0;
		}
		
		// Draw the graph
		qContext.strokeStyle = "pink";
		qContext.lineWidth = dLineWidth;
		qContext.fillStyle = "pink";
		// Draw the graph for each continuous portion that lies inside the graph bounds.
		for (var iInterval = 0; iInterval < qaaBoundsIntervals.length; ++iInterval) {
			dPixelX = qaaBoundsIntervals[iInterval][0];
			var dX = this.PixelXToX(dPixelX);
			var dY = fnF(dX);
			var dPixelY = this.YtoPixelY(dY);
			qContext.beginPath();
			qContext.moveTo(dPixelX, dPixelY);
			// Graph the function within the bounds of the current interval
			for (var j = qaaBoundsIntervals[iInterval][0] + 1; j <= qaaBoundsIntervals[iInterval][1]; ++j) {
				dPixelX = j;
				dX = this.PixelXToX(dPixelX);
				dY = fnF(dX);
				dPixelY = this.YtoPixelY(dY);
				qContext.lineTo(dPixelX, dPixelY);
				// Add in the extra partial pixel
				if (j + 1 > qaaBoundsIntervals[iInterval][1]) {
					dPixelX = qaaBoundsIntervals[iInterval][1];
					dX = this.PixelXToX(dPixelX);
					dY = fnF(dX);
					dPixelY = this.YtoPixelY(dY);
					qContext.lineTo(dPixelX, dPixelY);
				}
			}
			qContext.stroke();
			// Draw the two arrow heads at the end. Make sure that the interval is more that 2 units in length
			if (qaaBoundsIntervals[iInterval][1] > (qaaBoundsIntervals[iInterval][0] + 2.0)) {
				var dX1 = qaaBoundsIntervals[iInterval][0] + 1;
				var dY1 = this.YtoPixelY(fnF(this.PixelXToX(dX1)));
				var dX2 = qaaBoundsIntervals[iInterval][0];
				var dY2 = this.YtoPixelY(fnF(this.PixelXToX(dX2)));
				DrawArrowhead(qContext, dX1, dY1, dX2, dY2);
				dX1 = qaaBoundsIntervals[iInterval][1] - 1;
				dY1 = this.YtoPixelY(fnF(this.PixelXToX(dX1)));
				dX2 = qaaBoundsIntervals[iInterval][1];
				dY2 = this.YtoPixelY(fnF(this.PixelXToX(dX2)));
				DrawArrowhead(qContext, dX1, dY1, dX2, dY2);
			}
		}
	}
	
	// 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(qContext) {
		// Draw grid lines of two types
		// Get an estimate for a value that will be drawn about every 100 pixels and every 20
		var dThickFreqX = this.FindSmallestGreaterPowerOfTwo(this.PixelXToX(100) - this.PixelXToX(0));
		var dThinFreqX = dThickFreqX/4;
		var dThickFreqY = this.FindSmallestGreaterPowerOfTwo(this.PixelYToY(0) - this.PixelYToY(100));
		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(this.mdLowX/dThickFreqX)*dThickFreqX;
		var dGridValue = dGLM + dThinFreqX;
		qContext.strokeStyle = "#E8E8E8";
		var i = 1;
		while (dGridValue < this.mdHighX) {
			if ((i % 4) != 0) {
				var dGridX = this.XtoPixelX(dGridValue);
				qContext.beginPath();
				qContext.moveTo(dGridX, 0);
				qContext.lineTo(dGridX, this.miCanvasH);
				qContext.stroke();
			}
			dGridValue += dThinFreqX;
			++i;
		}
		// Draw horizontal grid lines
		dGLM = Math.floor(this.mdLowY/dThickFreqY)*dThickFreqY;
		dGridValue = dGLM + dThinFreqY;
		i = 1;
		while (dGridValue < this.mdHighY) {
			if ((i % 4) != 0) {
				var dGridY = this.YtoPixelY(dGridValue);
				qContext.beginPath();
				qContext.moveTo(0, dGridY);
				qContext.lineTo(this.miCanvasW, dGridY);
				qContext.stroke();
			}
			dGridValue += dThinFreqY;
			++i;
		}
		
		// Draw the thick lines
		// Draw vertical grid lines
		qContext.strokeStyle = "#DDDDDD";
		dGLM = Math.floor(this.mdLowX/dThickFreqX)*dThickFreqX;
		dGridValue = dGLM + dThickFreqX;
		while (dGridValue < this.mdHighX) {
			var dGridX = this.XtoPixelX(dGridValue);
			qContext.beginPath();
			qContext.moveTo(dGridX, 0);
			qContext.lineTo(dGridX, this.miCanvasH);
			qContext.stroke();
			dGridValue += dThickFreqX;
		}
		// Draw horizontal grid lines
		dGLM = Math.floor(this.mdLowY/dThickFreqY)*dThickFreqY;
		dGridValue = dGLM + dThickFreqY;
		while (dGridValue < this.mdHighY) {
			var dGridY = this.YtoPixelY(dGridValue);
			qContext.beginPath();
			qContext.moveTo(0, dGridY);
			qContext.lineTo(this.miCanvasW, dGridY);
			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";
		if (this.mdLowX <= 0 && this.mdHighX >= 0) {
			var dPixelX = this.XtoPixelX(0);
			DrawArrow(qContext, dPixelX, this.miCanvasH, dPixelX, 0);
		}
		// Draw the X-Axis
		if (this.mdLowY <= 0 && this.mdHighY >= 0) {
			var dPixelY = this.YtoPixelY(0);
			DrawArrow(qContext, 0, dPixelY, this.miCanvasW, dPixelY);
		}
	}
	
	FillInRanges(qLowX, qHighX, qLowY, qHighY) {
		// Remove any trailing zeroes and decimal points
		qLowX.value = this.mdLowX.toFixed(4).replace(/\.?0+$/, "");
		qHighX.value = this.mdHighX.toFixed(4).replace(/\.?0+$/, "");
		qLowY.value = this.mdLowY.toFixed(4).replace(/\.?0+$/, "");
		qHighY.value = this.mdHighY.toFixed(4).replace(/\.?0+$/, "");
	}
}

var gqGraphRange = null;
var gqContext = null;
var gbIsDragging = false;
var giCursorX = 0;
var giCursorY = 0;
var gfnFunction = Hyperbola;

function Initialize() {
	var qCanvas = document.getElementById("idCanvas");
	qCanvas.addEventListener("wheel", fnZoom, { passive: false });
	qCanvas.addEventListener("mousedown", MouseDown);
	qCanvas.addEventListener("mouseup", MouseUp);
	qCanvas.addEventListener("mousemove", MouseMove);
	gqContext = qCanvas.getContext("2d");
	
	// Get the canvas size
	gqGraphRange = new GraphRange2D(-4.0, 4.0, -4.0, 4.0, qCanvas.width, qCanvas.height);
	gqGraphRange.DrawGridLinesAndAxes(gqContext);
	gqGraphRange.DrawFunctionGraph(gqContext, gfnFunction, 1.0);
	
	var qLowX = document.getElementById("idLowX");
	var qHighX = document.getElementById("idHighX");
	var qLowY = document.getElementById("idLowY");
	var qHighY = document.getElementById("idHighY");
	gqGraphRange.FillInRanges(qLowX, qHighX, qLowY, qHighY);
	qCanvas.addEventListener("wheel", fnZoom, { passive: false });
}

function 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
	DrawArrowhead(qContext,  dX1, dY1, dX2, dY2);
}

function DrawArrowhead(qContext,  dX1, dY1, dX2, dY2) {
	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();
}

function fnZoom(e) {
  e.preventDefault();
  var dFactor = (e.deltaY > 0.0) ? 1.1: .9090909090909;
  SetCursorCoordinates(e);
  // Zoom centered on the pixel location
  gqGraphRange.Scale(dFactor, giCursorX, giCursorY);
  
  // Draw the graph
  gqContext.clearRect(0, 0, 600, 600);
  gqGraphRange.DrawGridLinesAndAxes(gqContext);
	gqGraphRange.DrawFunctionGraph(gqContext, gfnFunction, 1.0);

	var qLowX = document.getElementById("idLowX");
	var qHighX = document.getElementById("idHighX");
	var qLowY = document.getElementById("idLowY");
	var qHighY = document.getElementById("idHighY");
	gqGraphRange.FillInRanges(qLowX, qHighX, qLowY, qHighY);
}

function MouseMove(e) {
	if (gbIsDragging) {
		var iLastCursorX = giCursorX;
		var iLastCursorY = giCursorY;
		SetCursorCoordinates(e);
		var iCurrCursorX = giCursorX;
		var iCurrCursorY = giCursorY;
		gqGraphRange.Pan(iCurrCursorX - iLastCursorX, iCurrCursorY - iLastCursorY);
		
		// Draw the graph
		gqContext.clearRect(0, 0, 600, 600);
		gqGraphRange.DrawGridLinesAndAxes(gqContext);
		gqGraphRange.DrawFunctionGraph(gqContext, gfnFunction, 1.0);
		
		var qLowX = document.getElementById("idLowX");
		var qHighX = document.getElementById("idHighX");
		var qLowY = document.getElementById("idLowY");
		var qHighY = document.getElementById("idHighY");
		gqGraphRange.FillInRanges(qLowX, qHighX, qLowY, qHighY);
	}
}

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;
}

function Parabola(dX) {
	return (dX*dX);
}

function Hyperbola(dX) {
	return 1.0/dX;
}

function Sin(dX) {
	return Math.sin(dX);
}

function Tan(dX) {
	return Math.tan(dX);
}

function SelectFunction() {
	var qFunction = document.getElementById("idFunction").value;
	switch (qFunction){
		case "kHyperbola":
			gfnFunction = Hyperbola;
			break;
		case "kParabola":
			gfnFunction = Parabola;
			break;
		case "kSin":
			gfnFunction = Sin;
			break;
		case "kTan":
			gfnFunction = Tan;
			break;
	}
	// Draw the graph
	gqContext.clearRect(0, 0, 600, 600);
	gqGraphRange.DrawGridLinesAndAxes(gqContext);
	gqGraphRange.DrawFunctionGraph(gqContext, gfnFunction, 1.0);
}
 

Output

 
 

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