CandyGraph Tutorial

Installation and Initialization

First, install CandyGraph:

npm i --save candygraph

Then import it and create an instance. Here we’ll also size the context’s backing canvas to be large enough for all our later examples. Note that the canvas element has not been added to the DOM. If you wish to display it directly, that’s fine (there’s reason to in many cases!), but you’ll need to add it to the DOM yourself, it’s not automatic.

import CandyGraph from "candygraph";

async function main() {
  const cg = new CandyGraph();
  cg.canvas.width = 1024;
  cg.canvas.height = 1024;
}

main();

Viewport, Scale, and Coordinates

Let’s dive into CandyGraph with a line plot example. We’ll start by simply rendering the line without any extra “chrome” like axes. Before we create our data and render it, though, we’ll need to set the stage. First up, our viewport.

Viewports define the region of the canvas we’re going to be rendering to. They provide an x- and y-offset, measured in pixels from the bottom left of the canvas, and a width and a height, also in pixels. Behind the scenes we’ve added a canvas that is 512 pixels in width and 384 pixels in height to this page, so we’ll define our viewport like this:

  const viewport = { x: 0, y: 0, width: 512, height: 384 };

Scales in CandyGraph are very similar in concept to scales in D3 - they map a value from a domain (usually the space your data exists in) to a range (usually pixels on the screen). CandyGraph adds the capability of utilizing the scale in both javascript/typescript and on the GPU in GLSL. We’re going to render a simple sine wave, so the domain for our x-scale will be from 0 to 2π, and for our y-scale from -1 to 1. At first we’ll map these to the full width and height of our viewport. Here they are:

  const xscale = new LinearScale([0, 2 * Math.PI], [0, viewport.width]);
  const yscale = new LinearScale([-1, 1], [0, viewport.height]);

Now that we have our scales, we can create a coordinate system. Coordinate systems in CandyGraph wrap scales and add a little more GLSL glue code for use on the GPU. Here we’ll create a cartesian coordinate system:

  const coords = new CartesianCoordinateSystem(cg, xscale, yscale);

Next we’re going to make some data for our plot. We’ll loop through 0 to 2π with a small increment for our x-values, and calculate the sine of each of those to determine our y-values:

  const xs = [];
  const ys = [];
  for (let x = 0; x <= 2 * Math.PI; x += 0.01) {
    xs.push(x);
    ys.push(Math.sin(x));
  }

Next we’ll clear the canvas. Note that this function clears the entire canvas - it is not influenced by the viewport we just defined.

  cg.clear([1, 1, 1, 1]);

Now we can use the coordinate system, viewport, and line data we defined to render a line strip. We’ll render it with width 2 pixels and in red (colors format is [red, green, blue, alpha]).

  cg.render(coords, viewport, [
    new OpaqueLineStrip(cg, xs, ys, {
      colors: [1, 0, 0],
      widths: 2,
    }),
  ]);

Our CandyGraph context cg has a canvas associated with it, but it hasn’t been added to the DOM. Instead of doing that, we’ll use the copyTo utility function to copy it to the canvas we mentioned earlier, which has id="doc_00100" defined:

  cg.copyTo(viewport, document.getElementById("doc_00100") as HTMLCanvasElement);

Axes

Congratulations, you’ve just rendered your first CandyGraph plot! There’s some stuff missing, though. Let’s add some axes.

While you can build axes out of CandyGraph primitives yourself, CandyGraph also provides some handy helpers for common tasks. One of those is the OrthoAxis. Let’s try adding one for each axis. Note that we’re rendering the axes after the line strip. We do this so that we don’t obscure our axes with our line plot. This is simply an arbitrary artistic choice we’re making, but the point is that CandyGraph uses the painter’s algorithm to render items - whatever you render first can be occluded by what you render later. First we’ll grab the default font:

  const font = await createDefaultFont(cg);

Then render our data and axes:

  cg.render(coords, viewport, [
    new OpaqueLineStrip(cg, xs, ys, {
      colors: [1, 0, 0],
      widths: 2,
    }),
    new OrthoAxis(cg, coords, "x", font),
    new OrthoAxis(cg, coords, "y", font),
  ]);

Wait, don’t run away! It looks ugly, but there’s a method to this madness. Let’s see if we can patch this up. First off, the axes are right at the edge of the canvas. To address this, we can adjust our scales so that there’s some padding in the range. Let’s add 24 pixels of padding to the left and bottom of our plot, and 16 pixels to the top and right:

  xscale.range = [24, viewport.width - 16];
  yscale.range = [24, viewport.height - 16];

Okay, we can at least see our axes now. There’s still issues, though. The labels for the x-axis are in an inconvenient place. We can adjust that with the labelSide option, which takes a 1 or -1 to indicate which side of the axis we want to place the labels.

  cg.render(coords, viewport, [
    new OpaqueLineStrip(cg, xs, ys, {
      colors: [1, 0, 0],
      widths: 2,
    }),
    new OrthoAxis(cg, coords, "x", font, { labelSide: 1 }),
    new OrthoAxis(cg, coords, "y", font),
  ]);

Better! The y-axis tick density is a little low though. Let’s give ourself a little more padding on the left and change the tickStep parameter to something more dense than the default of one:

  xscale.range = [40, viewport.width - 16];

  cg.render(coords, viewport, [
    new OpaqueLineStrip(cg, xs, ys, {
      colors: [1, 0, 0],
      widths: 2,
    }),
    new OrthoAxis(cg, coords, "x", font, { labelSide: 1 }),
    new OrthoAxis(cg, coords, "y", font, { tickStep: 0.25 }),
  ]);

Hmm. The format of the numbers on the y-axis is inconsistent. Let’s provide a function to the labelFormatter parameter to clean that up:

  cg.render(coords, viewport, [
    new OpaqueLineStrip(cg, xs, ys, {
      colors: [1, 0, 0],
      widths: 2,
    }),
    new OrthoAxis(cg, coords, "x", font, { labelSide: 1 }),
    new OrthoAxis(cg, coords, "y", font, {
      tickStep: 0.25,
      labelFormatter: (n: number) => n.toFixed(2),
    }),
  ]);

The way the end of the x-axis hangs off the last tick isn’t particularly appealing. Let’s fix that:

  cg.render(coords, viewport, [
    new OpaqueLineStrip(cg, xs, ys, {
      colors: [1, 0, 0],
      widths: 2,
    }),
    new OrthoAxis(cg, coords, "x", font, {
      labelSide: 1,
      tickStep: 0.25 * Math.PI,
      labelFormatter: (n: number) => n.toFixed(2),
    }),
    new OrthoAxis(cg, coords, "y", font, {
      tickStep: 0.25,
      labelFormatter: (n: number) => n.toFixed(2),
    }),
  ]);

Think the default ticks are a little meh? We can adjust those too:

  cg.render(coords, viewport, [
    new OpaqueLineStrip(cg, xs, ys, {
      colors: [1, 0, 0],
      widths: 2,
    }),
    new OrthoAxis(cg, coords, "x", font, {
      labelSide: 1,
      tickStep: 0.25 * Math.PI,
      tickLength: 5,
      tickOffset: -2,
      labelFormatter: (n: number) => n.toFixed(2),
    }),
    new OrthoAxis(cg, coords, "y", font, {
      tickStep: 0.25,
      tickLength: 5,
      tickOffset: 2,
      labelFormatter: (n: number) => n.toFixed(2),
    }),
  ]);

There are a lot more ways to configure axes. Take a look at the OrthoAxis and Axis API documentation for more details, or keep reading, we’ll hit more use cases below!

Semi-Log Plot

A semilogarithmic plot is one in which one axis uses a log scale and the other a linear scale. Let’s take a look at how we’d do that in CandyGraph.

We’ll start with our data. We’ll make our x-scale linear and our y-scale logarithmic, so we’ll make our y-coordinates span multiple orders of magnitude and our x-coordinates we’ll keep between zero and one:

  const xs = [];
  const ys = [];
  for (let x = 0; x <= 1; x += 0.00001) {
    const y = 100000 * x;
    xs.push(x);
    ys.push(y);
  }

Then we’ll set up our scales. The x-scale should be linear and have a domain of 0 to 1:

  const xscale = new LinearScale([0, 1], [40, viewport.width - 16]);

The y-scale should be logarighmic. Here we’ll use a base of 10 and a domain of 1 to 100000:

  const yscale = new LogScale(10, [1, 100000], [24, viewport.height - 16]);

Then we’ll create our coordinate system, grab the default font, clear the canvas, and render our data with axes:

  const coords = new CartesianCoordinateSystem(cg, xscale, yscale);
  const font = await createDefaultFont(cg);

  cg.clear([1, 1, 1, 1]);

  cg.render(coords, viewport, [
    new OpaqueLineStrip(cg, xs, ys, {
      colors: [1, 0, 0],
      widths: 2,
    }),
    new OrthoAxis(cg, coords, "x", font, {
      labelSide: 1,
      tickLength: 5,
      tickOffset: -2,
    }),
    new OrthoAxis(cg, coords, "y", font, {
      tickLength: 5,
      tickOffset: 2,
    }),
  ]);

Note that the OrthoAxis detected that we’re using a logarithmic scale on the y-axis and changed its behavior accordingly. We’ll still need to improve the axis rendering, though. Let’s make the y-axis more human readable with a labelFormatter function and create more dense ticks on the x-axis:

  cg.render(coords, viewport, [
    new OpaqueLineStrip(cg, xs, ys, {
      colors: [1, 0, 0],
      widths: 2,
    }),
    new OrthoAxis(cg, coords, "x", font, {
      labelSide: 1,
      tickLength: 5,
      tickOffset: -2,
      tickStep: 0.2,
      labelFormatter: (n) => n.toFixed(1),
    }),
    new OrthoAxis(cg, coords, "y", font, {
      tickLength: 5,
      tickOffset: 2,
      labelFormatter: (n) => (n >= 1000 ? Math.round(n / 1000).toString() + "K" : n.toString()),
    }),
  ]);

We can make the logarithmic nature of the y-axis a little more obvious by adding some minor ticks:

  cg.render(coords, viewport, [
    new OpaqueLineStrip(cg, xs, ys, {
      colors: [1, 0, 0],
      widths: 2,
    }),
    new OrthoAxis(cg, coords, "x", font, {
      labelSide: 1,
      tickLength: 5,
      tickOffset: -2,
      tickStep: 0.2,
      labelFormatter: (n) => n.toFixed(1),
    }),
    new OrthoAxis(cg, coords, "y", font, {
      minorTickCount: 5,
      minorTickLength: 3,
      minorTickOffset: 2,
      tickLength: 5,
      tickOffset: 2,
      labelFormatter: (n) => (n >= 1000 ? Math.round(n / 1000).toString() + "K" : n.toString()),
    }),
  ]);

Sometimes it’s helpful to display a grid on your plot to make it easier to estimate data values. Let’s add one here. First we’ll pull out the axes from the render function into a separate variable:

  const axes = [
    new OrthoAxis(cg, coords, "x", font, {
      labelSide: 1,
      tickLength: 5,
      tickOffset: -2,
      tickStep: 0.2,
      labelFormatter: (n) => n.toFixed(1),
    }),
    new OrthoAxis(cg, coords, "y", font, {
      minorTickCount: 5,
      minorTickLength: 3,
      minorTickOffset: 2,
      tickLength: 5,
      tickOffset: 2,
      labelFormatter: (n) => (n >= 1000 ? Math.round(n / 1000).toString() + "K" : n.toString()),
    }),
  ];

Then we’ll access the computed object on that variable to build grids with the Grid helper CandyGraph provides. First we’ll make a grid with our major ticks on both the x- and y-axes:

  const majorGrid = new Grid(
    cg,
    axes[0].computed.ticks,
    axes[1].computed.ticks,
    coords.xscale.domain,
    coords.yscale.domain,
    {
      color: [0.25, 0.25, 0.25, 1],
      width: 1,
    }
  );

Then we’ll create a grid for the minor ticks, which we only have on the y-axis, so we’ll pass an empty array for the x-axis ticks:

  const minorGrid = new Grid(cg, [], axes[1].computed.minorTicks, coords.xscale.domain, coords.yscale.domain, {
    color: [0.75, 0.75, 0.75, 1],
  });

And finally render our graph:

  cg.render(coords, viewport, [
    minorGrid,
    majorGrid,
    new OpaqueLineStrip(cg, xs, ys, {
      colors: [1, 0, 0],
      widths: 2,
    }),
    axes,
  ]);

Animation

Now we’ll cover basic animation in CandyGraph. It’s nothing surprising - each animation frame we’ll clear the canvas, update the data we’re going to render, and then render it. While this isn’t the absolute fastest way to render animations in CandyGraph, it’s still very fast and can handle even large datasets (up to ~400K points at 60 fps) on a midrange 2020 desktop PC.

For this example, we’ll plot a smooth noise function over time. We’ll not go into the details of how the following function works, but you can find the inspiration for it here.

  const primes = [1 / 2, 1 / 3, 1 / 5, 1 / 7, 1 / 11, 1 / 13, 1 / 17, 1 / 19];
  function primenoise(t: number) {
    let sum = 0;
    for (const p of primes) {
      sum += Math.sin(t * p);
    }
    return sum / primes.length;
  }

For each trace in our plot, we’ll use 1000 data points covering 10 seconds of data:

  const pointCount = 1000;
  const history = 10;

We’ll define a wide viewport and a linear/linear cartesian coordinate system with a bit of padding for our axes:

  const viewport = { x: 0, y: 0, width: 1024, height: 384 };
  const coords = new CartesianCoordinateSystem(
    cg,
    new LinearScale([-history, 0], [40, viewport.width - 16]),
    new LinearScale([-1, 1], [32, viewport.height - 16])
  );

Next we’ll create some arrays to store our plot data:

  const xs = new Float32Array(pointCount);
  const ys0 = new Float32Array(pointCount);
  const ys1 = new Float32Array(pointCount);

And a couple of variables to keep track of the time:

  let time = 0;
  let lastTime = performance.now() / 1000;

Grab the default font.

  const font = await createDefaultFont(cg);

Next up is our render function. We’ll track the wall-clock time in order to handle displays that don’t update at a fixed 60 fps, but we’ll clamp to a maximum timestep of 1/60 seconds.

  function render() {
    const now = performance.now() / 1000;
    const dt = Math.min(1 / 60, now - lastTime);
    lastTime = now;
    time += dt;

We’ll just completely rebuild our data each frame. This is slow, but fast enough for our purposes here.

    for (let i = 0; i < pointCount; i++) {
      xs[i] = time - history + (history * (i + 1)) / pointCount;
      ys0[i] = primenoise(xs[i] * 16);
      ys1[i] = primenoise(xs[i] * 16 + 5000);
    }

We need to shift the domain of the x-scale as time progresses in order to keep the last history seconds in view:

    coords.xscale.domain = [time - history, time];

Now we’ll clear our canvas and render:

    cg.clear([1, 1, 1, 1]);

    const renderables = [

First we’ll render our plot data. We have two traces to render, ys0 and ys1. We’ll get a little fancy and render a black border around each trace by first rendering a thick black line, then a thinner line in our desired color. We’ll also apply a scissor in screen space to confine the lines to the plot region. First the ys0 trace in ~orange:

      new Scissor(cg, 40, 32, viewport.width - 56, viewport.height - 48, true, [
        new OpaqueLineStrip(cg, xs, ys0, {
          colors: [0, 0, 0],
          widths: 17,
        }),
        new OpaqueLineStrip(cg, xs, ys0, {
          colors: [1, 0.5, 0],
          widths: 9,
        }),

Then our ys1 trace in ~blue:

        new OpaqueLineStrip(cg, xs, ys1, {
          colors: [0, 0, 0],
          widths: 17,
        }),
        new OpaqueLineStrip(cg, xs, ys1, {
          colors: [0, 0.5, 1],
          widths: 9,
        }),
      ]),

Then we’ll render our axes. Note that for the y-axis we’re shifting the axisIntercept to keep up with the current time:

      new OrthoAxis(cg, coords, "x", font, {
        labelSide: 1,
        tickLength: 5,
        tickOffset: -2,
        tickStep: 1,
        labelFormatter: (n) => n.toFixed(0),
      }),
      new OrthoAxis(cg, coords, "y", font, {
        axisIntercept: time - history,
        tickStep: 0.5,
        tickLength: 5,
        tickOffset: 2,
        labelFormatter: (n) => n.toFixed(1),
      }),
    ];

    cg.render(coords, viewport, renderables);

Finally, we’ll copy our rendered plot to a canvas that’s already been added to this document:

    cg.copyTo(viewport, document.getElementById("doc_00400") as HTMLCanvasElement);

At the end, we’ll dispose of GPU resources, preventing memory leaks:

    renderables.forEach((renderable) => {
      renderable.dispose();
    });
  }

Here’s some interaction and animation loop odds and ends to tie everything up:

  let animating = false;

  function animate() {
    requestAnimationFrame(animate);
    if (!animating) {
      return;
    }
    render();
  }

  document.getElementById("doc_00400")?.addEventListener("click", function () {
    animating = !animating;
  });

  render();
  animate();

And here’s our animated plot! Click/tap on the plot to toggle animation:

Data Reuse

So far we’ve been uploading our data to the GPU each time we render a plot. If we’re rendering a single, static plot that doesn’t change, this works out fine: the data and associated buffers on the GPU will be garbage collected and we’re only paying the cost of the upload once. Sometimes, however, we’ll want to reuse the same data and render it in a different way, e.g. on a log scale instead of a linear one, or for an animated or interactive plot. Let’s take a look at how to do that.

First we’ll create some data. Nothing exciting here, just noise added to the line y = x.

  const xsRaw: number[] = [];
  const ysRaw: number[] = [];
  for (let i = 0; i < 10000; i++) {
    const pn = primenoise(i) * 1000;
    xsRaw.push(i);
    ysRaw.push(i + pn);
  }

Previously we’d have fed xsRaw and ysRaw directly into primitives like OpaqueLineStrip. This time, however, we’ll upload them to the GPU and keep a handle to them using the Dataset class. Once we’ve done so, we can continue to use them until we invoke their dispose() functions.

  const xs = new Dataset(cg, xsRaw);
  const ys = new Dataset(cg, ysRaw);

Next we’ll create some scales and coordinate systems. In this example, we’re going to allow the user to switch between a linear and logarithmic y-axis:

  const linx = new LinearScale([0, 10000], [32, viewport.width - 16]);
  const liny = new LinearScale([0, 10000], [24, viewport.height - 16]);
  const logy = new LogScale(10, [1, 10000], [24, viewport.height - 16]);

  const linlin = new CartesianCoordinateSystem(cg, linx, liny);
  const linlog = new CartesianCoordinateSystem(cg, linx, logy);

We’ll also hold onto our higher-level constructs by assigning them to a variable and keeping that reference to them, preventing garbage collection.

  const linlinAxis = [
    new OrthoAxis(cg, linlin, "x", font, {
      labelSide: 1,
      tickStep: 1000,
      tickLength: 5,
      tickOffset: -2,
      labelFormatter: (n: number) => `${n / 1000}K`,
    }),
    new OrthoAxis(cg, linlin, "y", font, {
      tickStep: 1000,
      tickLength: 5,
      tickOffset: 2,
      labelFormatter: (n: number) => `${n / 1000}K`,
    }),
  ];

  const linlogAxis = [
    new OrthoAxis(cg, linlog, "x", font, {
      labelSide: 1,
      tickStep: 1000,
      tickLength: 5,
      tickOffset: -2,
      labelFormatter: (n: number) => `${n / 1000}K`,
    }),
    new OrthoAxis(cg, linlog, "y", font, {
      tickStep: 1,
      tickLength: 5,
      tickOffset: 2,
      labelFormatter: (n: number) => (n >= 1000 ? `${n / 1000}K` : n.toString()),
    }),
  ];

Now we’ll define a render function that will get invoked when the user changes plot settings. First we’ll grab the setting for whether or not the y-axis is linear or logarithmic:

  function render() {
    const linear =
      Array.prototype.filter.call(document.getElementsByName("radio-y-axis-500"), (e) => e.checked)[0].value ===
      "linear";

Then we’ll determine whether or not this is a scatter plot or a line plot:

    const scatter =
      Array.prototype.filter.call(document.getElementsByName("radio-plot-type-500"), (e) => e.checked)[0].value ===
      "scatter";

We’ll use the value of linear to get the correct coordinate system and axes renderable.

    const coords = linear ? linlin : linlog;
    const axes = linear ? linlinAxis : linlogAxis;

Next we’ll use (and reuse!) our xs and ys Dataset objects in a Circles or OpaqueLineStrip renderable according to the value of scatter:

    const data = scatter
      ? new Circles(cg, xs, ys, {
          colors: [1, 0, 0, 0.1],
          radii: 3,
          borderWidths: 0,
        })
      : new OpaqueLineStrip(cg, xs, ys, {
          colors: [1, 0, 0],
          widths: 0.25,
        });

Finally we’ll clear our canvas, render the axes and data objects, and copy them to a conveniently prepared canvas:

    cg.clear([1, 1, 1, 1]);
    cg.render(coords, viewport, [data, axes]);

    cg.copyTo(viewport, document.getElementById("doc_00500") as HTMLCanvasElement);
  }

When the user changes the form, re-render:

  document.getElementById("form-500")?.addEventListener("change", function () {
    render();
  });

Perform the initial render:

  render();

Okay, here’s our plot - change options in the form to see it re-render the same data with different views:

Y-Axis

Plot Type


Note that since this data needs to survive for as long as this tutorial page exists, we never call dispose() on the renderables we created. For the sake of completeness, we’ll pretend to do that here:

linlinAxis[0].dispose();
linlinAxis[1].dispose();
linlogAxis[0].dispose();
linlogAxis[1].dispose();
xs.dispose();
ys.dispose();