Codementor Events

HTML Canvas for your own applications - plot 2D and 3D functions yourself

Published May 16, 2021Last updated Nov 11, 2021
HTML Canvas for your own applications - plot 2D and 3D functions yourself

In the enterprise, people quickly focus on plot libraries to visualize e.g. data, forecasts, graphs, flows or correlations. There are dozens of them (JSXGraph, Plotta.js, JSPlot, Plotly.js, Highcharts, D3.js, C3.js, Chart.js, etc.). But basically you just want to display a simple plot. But what if special plots are needed in the further course? What to do if the plot library does not support a special setting?

In this article I would like to show how you can write plots yourself with little code and thus have the possibility to fully satisfy your individual needs.

Create visualizations with HTML Canvas

During my studies I worked on the university project math4u2, which describes mathematical facts visually. This resulted in a set of visualization components, which is also publicly available at my github page. Unfortunately everything is written in Java-Swing and is not developed further. These components are very handy for me in many situations. Especially useful are the following components:

  • Drawing area with a grid representation
  • Representation of 2D functions (function with one variable)
  • Representation of 3D functions (function with two variables) by means of a heatmap (or color contour plot)
  • Representation of parameterized curves
  • Representation of direction fields

With these components you have a basic set of simple visualizations and you can easily create special visualizations in a modular way. Interactivity can also be done without much effort by re-rendering the canvas with the new parameters.

Whenever I had a new algorithm or a special visualization in mind, I started looking for an HTML library that has a similar feature set. Unfortunately, I have not found one until today.

For example, I find the following visualizations useful:

Evaluation points of a 3D function
Evaluation points of a 3D function

Position and direction of particles of a particle swarm on a 3D function
Position and direction of particles of a particle swarm on a 3D function

History of a search
History of a search

Histogram in the lower area of the function evaluations
screenshot.png

Some time ago I wanted to visualize an algorithm again and finally decided to move the most important parts of math4u2 to HTML Canvas.

I already used HTML Canvas to render well compressed images with alpha channel, because the PNG format in our project had too bad compression rates and we didn't want to use tools like TinyPNG. In this case, the actual image was transferred as a JPG image and the alpha channel as another JPG grayscale image. In HTML Canvas, both images were combined.

HTML Canvas has similar concepts as the Swing Canvas, and transferring was therefore not very difficult. I was able to build the drawing area, as well as 2D and 3D functions, with only 200 lines of JavaScript. In the following I will describe how to create your own visualizations quickly and easily. The current state can be viewed at https://github.com/fennstef/graphplot.

Grid of the drawing area

First, a grid for the drawing area has to be drawn. Its usage is relatively simple.

<canvas id="grid" width="600px" height="600px"></canvas>

<script src="graphplot.js" type="module"></script>
<script type="module">
    import {drawGrid} from './graphplot.js';

    const canvas = document.getElementById("grid");
    const config = {
        xMin: -3,
        xMax: 5,
        yMin: -1,
        yMax: 2,
        ctx: canvas.getContext("2d")
    };

    drawGrid(config);
</script>

We start by defining a canvas. After that, the JavaScript code is loaded, which will be discussed in detail later. All configurations are stored as a JavaScript object - called Config-Map for short.

  • xMin, xMax, yMin, yMax define the area of the canvas.
  • Ctx is the canvas context and is needed for calculation and drawing.
    Finally, the drawGrid function is called, which draws the grid on the canvas.

To draw a grid, the following calculations have to be done for the x- and y-direction respectively.

Calculation of the grid distance

The grid distance is calculated depending on the definition range (diff) with as good a fit as possible (not too many and not too few grid lines).

export function getGridPointDist(min, max, factor) {
   const diff = max - min;
   let result = Math.pow(10.0, Math.ceil(Math.log(diff) / Math.log(10.0)) - 1);
   switch (diff / result) {
       case 7:
       case 6:
       case 5:
       case 4:
           result /= 2;
           break;
       case 3:
           result /= 4;
           break;
       case 2:
           result /= 5;
           break;
       case 1:
           result /= 10;
           break;
   }
   result *= factor;
   return result;
}

The formula in the third line finds the correct power of 10 for the grid representation.

Example 1 with diff=5: Math.pow(10.0, Math.ceil(Math.log(5) / Math.log(10.0)) - 1) = Math.pow(10.0, Math.ceil(0.69) - 1) = Math.pow(10.0, 1 - 1) = Math.pow(10.0, 0) = 1

Result: For a range of e.g. [0..5], the grid lines are drawn at each integer.

Example 2 with diff=11: Math.pow(10.0, Math.ceil(Math.log(11) / Math.log(10.0)) - 1) = Math.pow(10.0, Math.ceil(1.04) - 1) = Math.pow(10.0, 2 - 1) = Math.pow(10.0, 1) = 10

Result: For a range of, say, [0..11], the grid lines are drawn at each whole unit of 10.

Example 2, however, would then only get one grid line. This shows that the jump from units of 1 to units of 10 is too large. Therefore, the number is increased in the switch for "few grid elements". Only fractions with short decimal representation are used (0.5, 0.25, 0.2). With the variable factor the fine mesh of the grid can be adjusted even further.

Calculation of the first grid line

The first grid line is the grid line closest to the minimum value of the range.

startValue = Math.ceil(min / gridDist) * gridDist

This is divided by the "unit" gridDist, then rounded up and multiplied again by gridDist. In example 1 with the range [1.2 ... 6.2] the calculation results in: Math.ceil(1.2 / 1) * 1 = 2. So the first grid line is drawn at 2.

With these two values it is now quite easy to build up a grid.

Example grid of the drawing area:
screenshot.jpg

The following Canvas draw-functions are used:

  • clearRect: Resets the drawn area, i.e. the area is deleted.
  • fillText: Draws the grid line label.
  • beginPath, moveTo, lineTo, stroke: Draws a path; here only a simple line is drawn.

2D functions

To draw a 2D function - i.e. a function with a variable - all that is needed is to call the drawFunction function. Nevertheless, some setup is necessary. In order to work with different layers in the canvas and not have to draw everything again and again, several HTML canvases with the same size are layered on top of each other. This is done directly with CSS position:absolute.

Example 2D function:
screenshot.png

<style>
    .canvasContainer {
        position: relative;
    }

    .canvasContainer canvas {
        position: absolute;
        left: 0;
        top: 0;
    }
</style>

<div class="canvasContainer">
    <canvas id="grid" width="600px" height="600px"></canvas>
    <canvas id="fn2" width="600px" height="600px"></canvas>
</div>

<script src="graphplot.js" type="module"></script>
<script type="module">
    import {drawGrid, drawFunction} from './graphplot.js';

    const canvas = document.getElementById("grid");
    const config = {
        xMin: -3,
        xMax: 5,
        yMin: -1,
        yMax: 2,
        ctx: canvas.getContext("2d")
    };

    const canvasF2 = document.getElementById("fn2");
    const configF2 = Object.assign({}, config);
    configF2.ctx = canvasF2.getContext("2d");

    drawGrid(config);
    drawFunction(
        configF2,
        "#000000",
        x => ((x * x) / 10) * Math.sin(x) + 0.4 + Math.sin(x * 100) / 100
    );
</script>

The drawFunction function receives as parameters the canvas context of the layer, the graph color and the function in the form of a lambda expression. The configF2 configuration is copied from config to the canvas context. The drawFunction function is relatively simple.

export function drawFunction(c, strokeStyle, func) {
    clear(c);
    c.ctx.strokeStyle = strokeStyle;
    c.ctx.lineWidth = 3;
    c.ctx.beginPath();
    for (let w = 0; w <= c.ctx.canvas.width; w++) {
        const y = func(xPixToCoord(c, w));
        const h = yCoordToPix(c, y);
        if (w === 0) {
            c.ctx.moveTo(w, h);
            continue;
        }
        c.ctx.lineTo(w, h);
    }
    c.ctx.stroke();
}

First, the canvas is completely reset and the drawing property stroke is set. Then, for each x-pixel value - here w for width - with xPixToCoord the x-coordinate is calculated. Similarly, the functions xCoordToPix, yPixToCoord, yCoordToPix exist, and these functions are the key point for all self-created plots. Then the function is called, and the y-coordinate value is obtained. Now y is converted to the y-pixel value - here h for height. If it is the first point, then moveTo is used as path operation, otherwise lineTo.

Of course you can create even more sophisticated drawing methods. For example, no line should be drawn for poles. Also, one could evaluate only every fifth point and draw a smooth curve transition with quadraticCurveTo.

3D functions

3D functions - meaning a function with two variables - are usually represented as a three-dimensional surface. For many use cases this is insufficient, because e.g. the enrichment/overlay of further plots is difficult. Also, a "mountain range" can hide important aspects.

Therefore, I usually prefer a heatmap diagram. Here the function value z is calculated for each point (x,y). The z value is colored using a gradient plot, and the color map of the function is created. To determine zMin and zMax, all points of the map would need to have already been evaluated. Since this is usually not possible, the zMin and zMax values must be specified when plotting the heat map. If a function value is above or below the limits, the color value is plotted with increased transparency. Here is an example.

screenshot.png

Although the same function is shown three times here, the color gradient creates different images. In the first image, the height gradient is very well reproduced, and the focus is on four areas: Blue, Green, Yellow and Red. In the second image, the focus is more on the particularly dark and turquoise areas. In the third image, you get an impression of the contour lines of the feature.

Generation of the gradient

For a gradient, the color values are stored for certain positions between 0 and 1. The black-turquoise gradient is described as follows.

{
    0.0: 'rgb(0, 0, 0)',
    0.6: 'rgb(24, 53, 103)',
    0.75: 'rgb(46, 100, 158)',
    0.9: 'rgb(23, 173, 203)',
    1.0: 'rgb(0, 250, 250)'
};

Using this information (referred to as gradientColors[pos] in the code), the gradient can be drawn on an auxiliary canvas with a resolution of 1 x levels.

const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = levels;
const ctx2 = canvas.getContext("2d");
const gradient = ctx2.createLinearGradient(0, 0, 0, levels);
for (let pos of Object.keys(gradientColors)) {
    gradient.addColorStop(parseFloat(pos), gradientColors[pos]);
}
ctx2.fillStyle = gradient;
ctx2.fillRect(0, 0, 1, levels);
return ctx2.getImageData(0, 0, 1, levels).data;

This draws a new canvas and returns the image data as an array.

Plot of a pixel

If the current pixel position is drawn with coordinate (w, h), the function value is calculated, the corresponding gradient color value is determined and entered in the image data array (where c is the config map).

const z = func(xPixToCoord(c, w), yPixToCoord(c, h));
const pixelCount = gv.length / 4;
let gradientIndex = Math.round(((z - zMin) / (zMax - zMin)) * pixelCount);
if (gradientIndex < 0) {
    gradientIndex = 0;
}
if (gradientIndex >= pixelCount) {
    gradientIndex = pixelCount - 1;
}
const r = gv[gradientIndex * 4];
const g = gv[gradientIndex * 4 + 1];
const b = gv[gradientIndex * 4 + 2];
let a = Math.round(alpha * 255);
if (z < zMin || z > zMax) {
    a *= 0.5;
}
const index = h * c.ctx.canvas.width * 4 + w * 4;
data.data[index] = r;
data.data[index + 1] = g;
data.data[index + 2] = b;
data.data[index + 3] = a;

In the first line the z-value is determined. The gradient array always contains the RGBA values for each pixel. Since the image has only a height of 1, the width can be calculated with length/4.

Then z is transformed from the range [zMin, zMax] to the range [0;pixelCount-1]. From the gradientIndex the RGBA gradient color is determined.

The alpha value is adjusted if z is out of range, and finally the color value is entered on the canvas image array. Again, the correct array position must be calculated for (w, h).

Contour lines can also be drawn in here relatively easily by using the contour line color for specified z values including a small indentation range.

Plot of the Heatmap

With a naive approach, one would now run through two for loops and calculate all pixel values. Unfortunately, the rendering of the rest of the page stalls or hangs during the calculation of complex functions. Therefore, the process is aborted after a period of time and resumed later with setTimeout. The state of the calculation progress is stored with the current position (w, h) in the image.

export function draw3dFunction(c, zMin, zMax, alpha, gv, func) {
    clear(c);
    const data = c.ctx.createImageData(c.ctx.canvas.width, c.ctx.canvas.height);

    let w = 0;
    let h = -1;

    const drawInTimeSlot = function () {
        const start = +new Date();
        while (+new Date() - start < 70) {
            if (w >= c.ctx.canvas.width) {
                break;
            }
            h++;
            if (h === c.ctx.canvas.height + 1) {
                w++;
                h = 0;
            }
            //drawSinglePixel (w,h)
        }
        c.ctx.putImageData(data, 0, 0);
        if (w < c.ctx.canvas.width) {
            setTimeout(()=> drawInTimeSlot());
        }
    };

    drawInTimeSlot();
    c.ctx.putImageData(data, 0, 0);
}

At the beginning the drawing area is deleted, the image array is created and w, h are initialized. In drawInTimeSlot the while loop is run as long as 70ms have not elapsed. In the while loop, w and h are incremented accordingly and the single pixel is drawn. After the while loop, the current state is drawn on the canvas, and if not everything has been calculated yet, the function drawInTimeSlot is called with a time delay using setTimeout.

Conclusion

HTML Canvas is great for all pixel-perfect tasks. There were few stumbling blocks when I worked with HTML Canvas, and there is enough sample code to quickly implement your own ideas.

The display components grid, 2D function and heatmap are a good foundation to create your own plots based on. I believe that only with the right visualization you get a real understanding of the domain under consideration. For example, it has only been with the plot of an optimization algorithm that I have discovered an error in a particular constellation that I probably otherwise would have had a hard time coming across.

I hope that in the future there will be more plot libraries that allow you to easily integrate your own plots and interactivity. The math4u2 project was described in a c't article as an application with "lovingly designed interactive math lessons", and I would be very happy if math4u2 or an equivalent project on the web brings the world of mathematics, physics, numerics, data science and algorithms closer to the interested user.

In addition, for many desktop applications with "self-written" representations, the use of HTML Canvas can mean the jump to the web app, since for this the web conversion is possible in a relatively short time.

Discover and read more posts from Stefan Fenn
get started
post commentsBe the first to share your opinion
Show more replies