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()
.push(end - start)
times }
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:
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', []);
.stdout.pipe(process.stdout);
gnuplot.stderr.pipe(process.stderr);
gnuplot
// Write commands to gnuplot's stdin
.stdin.write(`
gnuplotset 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
.forEach((value, index) => {
data.stdin.write(`${index} ${value}\n`);
gnuplot;
})
// End data input
.stdin.write('e\n');
gnuplot.stdin.end();
gnuplotreturn new Promise(resolve => {
.on('exit', resolve);
gnuplot;
}) }
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()
.push(end - start)
times
}
await plotWithGnuplot(times)