⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1516+8a4b5424dWhat's newOn an older Zig?

A Bar Chart

A chart is the first drawing in this section whose shape is not chosen by the programmer. The bars are as tall as the data says, which means there is a function from a number to a pixel height, and that function is where charts go wrong.

It is one division. Everything interesting is in what you divide by.

The program

const std = @import("std");

const width: usize = 48;
const height: usize = 17;
var canvas: [width * height]u8 = @splat(' ');

const plot_left: usize = 5;
// Two rows spare below the plot: one for the axis, one for the labels.
const plot_bottom: usize = height - 3;
const plot_top: usize = 1;

fn put(x: usize, y: usize, ink: u8) void {
    if (x >= width or y >= height) return;
    canvas[y * width + x] = ink;
}

/// Map a value onto pixels. The denominator is the decision: dividing by the
/// largest value makes the tallest bar fill the plot, which is what makes two
/// charts side by side incomparable unless they share a scale.
fn barHeight(value: u32, max: u32, rows: usize) usize {
    if (max == 0) return 0;
    return @intCast(@as(usize, value) * rows / max);
}

fn drawAxes() void {
    var y = plot_top;
    while (y <= plot_bottom) : (y += 1) put(plot_left - 1, y, '|');
    var x = plot_left - 1;
    while (x < width - 1) : (x += 1) put(x, plot_bottom + 1, '-');
    put(plot_left - 1, plot_bottom + 1, '+');
}

fn drawBar(slot: usize, value: u32, max: u32, ink: u8) void {
    const rows = plot_bottom - plot_top + 1;
    const h = barHeight(value, max, rows);
    const x = plot_left + slot * 5;

    var i: usize = 0;
    while (i < h) : (i += 1) {
        const y = plot_bottom - i;
        put(x, y, ink);
        put(x + 1, y, ink);
        put(x + 2, y, ink);
    }
}

fn dump(out: *std.Io.Writer) !void {
    for (0..height) |y| try out.print("{s}\n", .{canvas[y * width ..][0..width]});
}

pub fn main(init: std.process.Init) !void {
    var buf: [4096]u8 = undefined;
    var file_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
    const out = &file_writer.interface;

    const labels = [_]u8{ 'a', 'b', 'c', 'd', 'e', 'f', 'g' };
    const values = [_]u32{ 12, 31, 24, 8, 40, 19, 27 };

    var max: u32 = 0;
    for (values) |v| max = @max(max, v);

    drawAxes();
    for (values, 0..) |v, i| {
        drawBar(i, v, max, '#');
        put(plot_left + i * 5 + 1, plot_bottom + 2, labels[i]);
    }
    // The scale, written down. A chart without one is a picture of a shape.
    put(0, plot_top, '4');
    put(1, plot_top, '0');
    put(0, plot_bottom, '0');

    try dump(out);

    try out.print("\nmax is {d}, plot is {d} rows tall\n", .{ max, plot_bottom - plot_top + 1 });
    for (values, 0..) |v, i| {
        try out.print("  {c} = {d: >3}  ->  {d} rows\n", .{
            labels[i],
            v,
            barHeight(v, max, plot_bottom - plot_top + 1),
        });
    }

    try out.flush();
}

What just happened

Each bar is value * rows / max. The largest value fills the plot exactly, because it is the denominator. That is a choice, not an obligation, and it has a consequence: two charts drawn this way cannot be compared, because each one silently used a different scale.

The integer division truncates, and the table shows it. 12 of 40 over 14 rows is 4.2, drawn as 4. Every bar is up to one row shorter than it should be, never taller. That bias is invisible in the picture and is why a chart with a handful of pixels per bar can misrepresent small differences.

The axis is drawn, and so is the scale. 40 at the top and 0 at the bottom. Without them the picture shows a shape and claims nothing: the reader cannot tell whether the tallest bar is 40 or 40,000, or whether the baseline is zero at all.

The lie a bar chart can tell

Start the vertical axis somewhere other than zero and the bars stop being proportional to their values. A bar twice as tall as another no longer means twice as much, and the reader has no way to see it except by reading the axis labels, which is exactly what a glance at a chart does not do.

For a bar chart this is a genuine error, because the bar’s length is the encoding: the whole point is that area and value are proportional. For a line chart it is often correct, since a line encodes change rather than magnitude and a truncated axis is how you see a small change at all.

The rule falls out of what the mark means, which is why it is worth writing the mapping function by hand once.

Check yourself

The chart divides by the largest value in the data. When is that wrong?

Whenever the reader will compare this chart with another one, which includes every dashboard, every set of small multiples, and every chart that updates over time. If the scale changes when the data changes, a bar can grow while its value shrinks.

The fix is to fix the denominator: a scale chosen for the domain rather than the data, shared by every chart drawn against it. That is the same reasoning as a shared axis on a set of small multiples, and it is the most common thing missing from charts assembled by code.

If you have written C

The drawing is trivial and the arithmetic is where the care goes. value * rows / max overflows if value * rows exceeds the type, which for a u32 count and a large plot is closer than it looks, so the multiplication wants a wider type or a different order of operations.

The other C-specific hazard is the empty case. max is 0 when every value is 0, and the division is then by zero: in C undefined behaviour, in a safety build here a caught panic. The function above returns 0 for that case explicitly, because a chart of nothing is a legitimate chart and crashing on it is not.