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 { createCandyGraph } from "candygraph";

async function main() {
  const cg = await createCandyGraph();
  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 = cg.scale.linear([0, 2 * Math.PI], [0, viewport.width]);
  const yscale = cg.scale.linear([-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 = cg.coordinate.cartesian(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, [
    cg.lineStrip(xs, ys, {
      colors: [1, 0, 0, 1],
      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 cg.defaultFont;

Then render our data and axes:

  cg.render(coords, viewport, [
    cg.lineStrip(xs, ys, {
      colors: [1, 0, 0, 1],
      widths: 2,
    }),
    cg.orthoAxis(coords, "x", font),
    cg.orthoAxis(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, [
    cg.lineStrip(xs, ys, {
      colors: [1, 0, 0, 1],
      widths: 2,
    }),
    cg.orthoAxis(coords, "x", font, { labelSide: 1 }),
    cg.orthoAxis(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, [
    cg.lineStrip(xs, ys, {
      colors: [1, 0, 0, 1],
      widths: 2,
    }),
    cg.orthoAxis(coords, "x", font, { labelSide: 1 }),
    cg.orthoAxis(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, [
    cg.lineStrip(xs, ys, {
      colors: [1, 0, 0, 1],
      widths: 2,
    }),
    cg.orthoAxis(coords, "x", font, { labelSide: 1 }),
    cg.orthoAxis(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, [
    cg.lineStrip(xs, ys, {
      colors: [1, 0, 0, 1],
      widths: 2,
    }),
    cg.orthoAxis(coords, "x", font, {
      labelSide: 1,
      tickStep: 0.25 * Math.PI,
      labelFormatter: (n: number) => n.toFixed(2),
    }),
    cg.orthoAxis(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, [
    cg.lineStrip(xs, ys, {
      colors: [1, 0, 0, 1],
      widths: 2,
    }),
    cg.orthoAxis(coords, "x", font, {
      labelSide: 1,
      tickStep: 0.25 * Math.PI,
      tickLength: 5,
      tickOffset: -2,
      labelFormatter: (n: number) => n.toFixed(2),
    }),
    cg.orthoAxis(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 = cg.scale.linear([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 = cg.scale.log(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 = cg.coordinate.cartesian(xscale, yscale);
  const font = await cg.defaultFont;

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

  cg.render(coords, viewport, [
    cg.lineStrip(xs, ys, {
      colors: [1, 0, 0, 1],
      widths: 2,
    }),
    cg.orthoAxis(coords, "x", font, {
      labelSide: 1,
      tickLength: 5,
      tickOffset: -2,
    }),
    cg.orthoAxis(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, [
    cg.lineStrip(xs, ys, {
      colors: [1, 0, 0, 1],
      widths: 2,
    }),
    cg.orthoAxis(coords, "x", font, {
      labelSide: 1,
      tickLength: 5,
      tickOffset: -2,
      tickStep: 0.2,
      labelFormatter: (n) => n.toFixed(1),
    }),
    cg.orthoAxis(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, [
    cg.lineStrip(xs, ys, {
      colors: [1, 0, 0, 1],
      widths: 2,
    }),
    cg.orthoAxis(coords, "x", font, {
      labelSide: 1,
      tickLength: 5,
      tickOffset: -2,
      tickStep: 0.2,
      labelFormatter: (n) => n.toFixed(1),
    }),
    cg.orthoAxis(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 = [
    cg.orthoAxis(coords, "x", font, {
      labelSide: 1,
      tickLength: 5,
      tickOffset: -2,
      tickStep: 0.2,
      labelFormatter: (n) => n.toFixed(1),
    }),
    cg.orthoAxis(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 info objects 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 = cg.grid(
    axes[0].info.ticks,
    axes[1].info.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 = cg.grid(
    [],
    axes[1].info.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,
    cg.lineStrip(xs, ys, {
      colors: [1, 0, 0, 1],
      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 = cg.coordinate.cartesian(
    cg.scale.linear([-history, 0], [40, viewport.width - 16]),
    cg.scale.linear([-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 cg.defaultFont;

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]);
    cg.render(coords, viewport, [

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. First the ys0 trace in ~orange:

      cg.lineStrip(xs, ys0, {
        colors: [0, 0, 0, 1],
        widths: 7,
      }),
      cg.lineStrip(xs, ys0, {
        colors: [1, 0.5, 0, 1],
        widths: 3,
      }),

Then our ys1 trace in ~blue:

      cg.lineStrip(xs, ys1, {
        colors: [0, 0, 0, 1],
        widths: 7,
      }),
      cg.lineStrip(xs, ys1, {
        colors: [0, 0.5, 1, 1],
        widths: 3,
      }),

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

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

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

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

The default memory management scheme in CandyGraph is to destroy any GPU-side data immediately after it’s been rendered once. This is handy for ensuring that we’re not leaking data, but it’s not the best for performance if you’re re-uploading unchanged data to the GPU. In this example we’ll take a look at a couple of mechanisms for retaining this data so that it’s not immediately destroyed.

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 functions like cg.lineStrip. This time, however, we’ll upload them to the GPU and keep a handle to them using the cg.reusableData function. Once we’ve done so, we can continue to use them until we invoke their dispose() functions. The cg.reusableData function returns a Dataset object:

  const xs = cg.reusableData(xsRaw);
  const ys = cg.reusableData(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 = cg.scale.linear([0, 10000], [32, viewport.width - 16]);
  const liny = cg.scale.linear([0, 10000], [24, viewport.height - 16]);
  const logy = cg.scale.log(10, [1, 10000], [24, viewport.height - 16]);

  const linlin = cg.coordinate.cartesian(linx, liny);
  const linlog = cg.coordinate.cartesian(linx, logy);

We can also retain higher level constructs, such as an OrthoAxis. To do so, we simply invoke its retain() function. Doing so will allow us to continue to use them until we invoke their dispose() functions. Let’s create a set of x and y axes for our linear-linear and linear-log plots:

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

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

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. Note that since we invoked retain() on the elements of each axes renderable, we can simply reuse them each time the render function is called.

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

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

    const data = scatter
      ? cg.circles(xs, ys, {
          colors: [1, 0, 0, 0.1],
          radii: 3,
          borderWidths: 0,
        })
      : cg.lineStrip(xs, ys, {
          colors: [1, 0, 0, 1],
          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();