Ad-Hoc Data Visualisation in the Terminal with gnuplot

November 22, 2024

I often find myself doing ad-hoc data visualisation in the terminal. For example, I was recently trying to find out why a particular code path in automerge runs slowly and I had written a short (10 line) JavaScript program which ran the code path 100 times. I expected each run to be independent of the others and I wanted to quickly visualise all 100 runs to make sure.

I had something like this:

function doSlowThing() {
    ...
}

// Collect the time of each run
const times = []

// Run 100 times
for (let i = 0; i < 100; i++) {
    const start = performance.now()
    doSlowThing()
    const end = performance.now()
    times.push(end - start)
}

How do I visualise this? Of course, I could write this data to a CSV and load it into a spreadsheet, or run everything in Jupyter, or other such things, but that feels really heavy compared to the tiny script I just wrote and it takes me out of text editor + terminal flow I’m in.

In the past I’ve often written little functions to output a basic ASCII line chart to the terminal but earlier today - due to an error message I saw when running criterion - I remembered that gnuplot exists. gnuplot is a command line application for drawing plots, I’ve used it before to render things to a PNG but these days my terminal emulator is kitty and kitty has a protocol for rendering graphics in the terminal. I wondered if there was an easy way to hook up gnuplot to this protocol and it turns out there is.

gnuplot

gnuplot expects data to be in files containing newline delimited coordinate pairs, where each coordinate is sequence of numbers separated by whitespace. E.g:

0 0
1 1
2 2

Most of the time you use gnuplot interactively. Running gnuplot in the terminal will drop you into a prompt. There are many many things you can do here, mainly what I am familiar with is this line

gnuplot> plot "data.dat" with lines

This will render a line chart of the x,y coordinates in data.dat. On my machine this pops up a little graph viewer. If instead I want to render this graph to a file I can do this:

gnuplot> set term  png # use the png renderer
gnuplot> set output 'graph.png'
gnuplot> plot "data.dat" with lines

Now there is a graph.png in the working directory containing the rendered line chart.

Interactive usage isn’t what we want though, we want to render to the terminal. We can achieve this by feeding the commands directly to gnuplot on stdin

gnuplot <<EOF
    set term png
    set output 'graph.png'
    plot "data.dat" with lines  
EOF

Now, this still creates a graph.png file in the working directory but what I want is to output to the terminal directly. We can achieve this by setting the term to kittycairo:

gnuplot <<EOF
    set term kittycairo scroll
    plot "data.dat" with lines  
EOF

(The scoll option makes sure the image is rendered inline with the current prompt, otherwise it’s rendered in the top left of the terminal which looks super weird).

Okay, we’re getting somewhere. The final piece of this puzzle is that I do not want to save my data to a data file before rendering it. We can instead render points fed in on stdin like this:

gnuplot <<EOF
    set term kittycairo scroll
    plot "-" with lines 
    1 1
    2 2
EOF

And we get this:

A screen shot of the above script running in a terminal with a graph rendered beneath the script

Hoorah!

gnuplot in JavaScript

We can put this all together to write a little JS function that (in NodeJS) will render a bunch of 2D coordinates to a line graph in the terminal:

import { spawn } from "subprocess"

const plotWithGnuplot = (data) => {
  const gnuplot = spawn('gnuplot', []);

  gnuplot.stdout.pipe(process.stdout);
  gnuplot.stderr.pipe(process.stderr);

  // Write commands to gnuplot's stdin
  gnuplot.stdin.write(`
set terminal kittycairo
set title "Run Time Chart (milliseconds)"
set xlabel "Run Number"
set ylabel "Time (ms)"
set grid                        # draw grid lines
set style data linespoints      # draw a line graph but also render the points
plot "-" notitle
`);

  // Write data points directly to stdin
  data.forEach((value, index) => {
    gnuplot.stdin.write(`${index} ${value}\n`);
  });

  // End data input
  gnuplot.stdin.write('e\n');
  gnuplot.stdin.end();
  return new Promise(resolve => {
    gnuplot.on('exit', resolve);
  });
}

I can just stick this in the top of my benchmarking script and write:

function doSlowThing() {
    ...
}

// Collect the time of each run
const times = []

// Run 100 times
for (let i = 0; i < 100; i++) {
    const start = performance.now()
    doSlowThing()
    const end = performance.now()
    times.push(end - start)
}

await plotWithGnuplot(times)