Bar Graph Library LLD: A Chart Is a Function from Numbers to Pictures
A low-level design walkthrough of a bar graph chart library: series data, a linear scale with nice ceilings, and pluggable renderers that draw the same chart in the console and as SVG.
"Design a bar graph library." It sounds like a frontend question that wandered into the wrong room — and that's the trap. The interviewer doesn't care whether you can draw rectangles. They're watching for one separation: do you know that a chart is data, one piece of math, and a skin — three things with completely different reasons to change? Candidates who reach for pixels in the first five minutes fail this one while producing working output, which makes it one of the sneakiest questions in the queue.
Let's start nowhere near a computer
A cartographer is drawing two maps of the same valley: a pocket map and a wall map. She never re-surveys the valley for the second map, and she never eyeballs where a village lands on the paper. She fixes one ratio in the corner — 1 cm = 5 km — and every distance on the sheet is computed from it. The wall map differs in exactly one way: a different ratio in the corner. The villages didn't move.
And one more professional habit: her scale bar reads 0, 5, 10 km — never 0, 4.37, 8.74 km. She rounds the edge of the map up to a clean number first, because a map you can't read aloud is a map you can't trust.
That's the whole design. The valley is your data. The ratio in the corner is a scale. The pocket map and the wall map are renderers. And the clean number is called a nice ceiling — it has an algorithm, and interviewers light up when you know it.
Where the ratio-in-the-corner runs
- Every charting library you've ever used — Chart.js, matplotlib, Excel — has a scale object at its heart; it's the part that survives every rewrite of the drawing layer.
- Grafana dashboards — the same query renders as a line, a gauge, or a table; the data pipeline never knows which.
- The logging library's appenders — one log event, many destinations. Appenders are renderers wearing ops clothes; this question is the same shape in party clothes.
Step 1 — Functional requirements (sentences first)
What the library must do, as plain sentences — the functional requirements.
- A chart shows a series: labels, each with a non-negative value.
- The drawing must fit a fixed area, no matter how large the values are.
- The axis should end on a round number, the way a hand-drawn axis would.
- The same chart can be drawn as console text or as SVG markup — and adding a third style must not touch the data.
That last sentence is a deliberate stance. "Adding a style" is the thing this question is secretly about: the requirement isn't more output formats, it's that the data and the math don't move when a new one arrives. The separation is the feature.
Step 2 — Non-functional requirements
At class level the non-functional requirements are different words for the same idea — how well, not just what — and for a chart library they decide whether the design survives its second output format:
- Correctness. Bars map to data faithfully: the value→pixel scale is exact and monotonic (a bigger value is never a shorter bar), the axis ticks are "nice" and cover the whole data range, and nothing clips past the drawing area or overlaps.
- Performance. Rendering is O(data points) — one pass — and the layout (the scale, the ticks) is computed once per render, not re-derived per bar.
- Extensibility. New chart types, new output backends (SVG, Canvas, PNG), new themes plug in through a renderer Strategy; adding a backend doesn't touch the layout math.
- Usability / API clarity. The simple case is one call with sensible defaults, while every knob — colors, labels, scale range — stays overridable. What to draw is separated from how to draw it.
Listing them is the easy half; the design only earns them if it fulfills them. Here's the contract — each requirement and the mechanism that keeps it:
| Requirement | How this design fulfills it |
|---|---|
| Correctness | one LinearScale owns the only math; niceCeiling covers the range, apply is monotonic — Step 4 |
| Performance | LinearScale.fit is computed once per render; each renderer makes a single O(points) pass — Steps 4, 5 |
| Extensibility | the Renderer Strategy is the seam; a new backend is a new class, never a new branch — Step 5 |
| Usability / API clarity | BarChart.drawWith(renderer) is the one-call default; the Series/scale/renderer split keeps every knob separate — Steps 3, 5 |
Every trade-off below is chosen to keep one of these.
Step 3 — Nouns: the one everyone misses
- Series — labels and values. Immutable, and it knows nothing about drawing. Not a pixel, not a color, not a width.
- LinearScale — the hidden noun: the ratio in the corner. Owns the only math in the system: value → units.
- Renderer — the artist. Console today, SVG tomorrow, PNG when someone pays for it.
- BarChart — the commission: hands the series and a fitted scale to whichever artist you choose.
The classic mistake is a Bar class with int pixelHeight on it — pixels stored on data, the book reader's dog-ear all over again. The moment pixels live on the data, every renderer with a different height fights over the same field. Pixels are derived, and the scale is where they're derived from — derive, don't store.
Which structure — and why. Series is an immutable record of parallel labels/values lists, not a list of mutable Bar objects — immutability is what makes correctness cheap (the same series re-renders identically on every backend) and what lets a live dashboard reuse the pipeline by snapshotting. LinearScale is a separate value object rather than a method on Series precisely so the math has one home — the extensibility and performance NFRs both lean on the scale being computed once and shared, not smeared across renderers. And Renderer is an interface, not an enum because that's the seam extensibility is named for: a backend is a class, never a switch. The split — model (series/values) vs. layout (scale, ticks) vs. renderer (backend) — is the whole design.
The edges this shape has to survive are all data edges, and the scale absorbs most of them: an empty series (no max, so the axis falls back to a floor of 1 rather than dividing by zero), a single data point (a degenerate scale that must still pick a nice ceiling), all-equal values (zero range — the bars are full-height but must not divide by a zero span), negative values (a baseline that moves off the floor, named in the corner), and very long labels (the renderer's problem, not the scale's — it pads or truncates, the math is untouched). Each one is a reason the math lives in exactly one place.
Step 4 — The scale: the only math in the building
Two jobs: round the top of the axis up to a clean number, then map values proportionally.
/** The smallest of 1, 2, 5 × 10^k that covers max — the hand-drawn axis rule. */
static long niceCeiling(long max) {
if (max <= 0) return 1;
long pow = 1;
while (pow * 10 <= max) pow *= 10; // largest power of ten ≤ max
for (long mult : new long[] {1, 2, 5, 10}) {
if (pow * mult >= max) return pow * mult;
}
throw new AssertionError("10 × pow always covers");
}
public int apply(long value) { // value → units, rounded ONCE
return Math.toIntExact(Math.round(value * (double) range / niceMax));
}niceCeiling(13) walks: largest power of ten under 13 is 10; then 10 < 13, but 20 ≥ 13 — so the axis tops at 20. A max of 1300 tops at 2000, of 99 at 100. The 1-2-5 ladder is exactly the set of numbers humans can halve and eyeball, which is why every hand-drawn axis in every notebook already uses it.
Why not just top the axis at the max value? Because then the tallest bar always touches the ceiling, every chart looks identically dramatic, and two charts of the same metric on different days can't be compared at a glance. The nice ceiling is not cosmetics — it's what makes two charts commensurable.
Step 5 — Renderers: the same chart, twice
The renderer interface is small on purpose: tell me your resolution, and I'll hand you a fitted scale.
public interface Renderer {
int units(); // my resolution: 20 chars, 200 px…
String render(Series series, LinearScale scale);
}
public final class ConsoleRenderer implements Renderer {
@Override
public int units() { return 20; }
@Override
public String render(Series series, LinearScale scale) {
// one row per label: name, a bar of █, the raw value
// "Q3 │█████████████ 1300"
...
}
}Here's the strategy pattern earning its keep (the cheat sheet's first row): the console renderer draws horizontal bars because terminals are row-oriented; the SVG renderer draws vertical ones because screens are portrait-of-numbers. Same series, same scale, different geometry — and BarChart contains zero if (console) statements. When the PNG renderer arrives, it's a new file, not a new branch.
Notice what units() does: the chart fits the scale to the renderer's
resolution, so a value of 1300 becomes 13 of the console's 20 characters, or
130 of the SVG's 200 pixels — same ratio, different paper. That's the pocket
map and the wall map.
Step 6 — Trade-offs (each one keeping an NFR)
The last column is the discipline: every choice keeps one of the promises from Step 2 — that's what designing to the non-functional requirements looks like.
| Decision | The obvious alternative | Why ours wins | Keeps |
|---|---|---|---|
LinearScale object | inline v * h / max everywhere | one rounding site, one nice-ceiling site — change axis policy in one file | correctness |
Renderer strategy | switch (outputType) in chart | new output = new class; the switch grows forever and tests nothing alone | extensibility |
| round once, at the edge | round at every arithmetic step | repeated rounding drifts; bars of 4.4 + 4.4 must not draw as 4 + 5 | correctness |
| scale fitted once, then reused | re-derive the scale per bar | layout is O(1) per render, drawing stays O(points) — no per-bar math | performance |
| nice ceiling 1-2-5 | axis tops at exact max | charts become comparable; the tallest bar stops lying | usability |
The complete implementation
package dev.fiveyear.chart;
import java.util.ArrayList;
import java.util.List;
/** The data: labels and values — nothing about pixels. */
public record Series(String name, List<String> labels, List<Long> values) {
public Series {
if (labels.size() != values.size()) {
throw new IllegalArgumentException("every label needs a value");
}
for (long v : values) {
if (v < 0) {
throw new IllegalArgumentException("bars cannot be negative (yet)");
}
}
labels = List.copyOf(labels);
values = List.copyOf(values);
}
public long max() {
long best = 0;
for (long v : values) {
best = Math.max(best, v);
}
return best;
}
}
/** The ratio in the corner: 0…niceMax mapped onto 0…range units. */
public final class LinearScale {
private final long niceMax;
private final int range;
private LinearScale(long niceMax, int range) {
this.niceMax = niceMax;
this.range = range;
}
public static LinearScale fit(long max, int range) {
return new LinearScale(niceCeiling(max), range);
}
/** Value → units, rounded once, at the edge. */
public int apply(long value) {
return Math.toIntExact(Math.round(value * (double) range / niceMax));
}
public long niceMax() {
return niceMax;
}
/** Gridline values: the most parts (5, 4, 2, 1) that divide niceMax cleanly. */
public List<Long> ticks() {
for (int parts : new int[] {4, 5, 2, 1}) {
if (niceMax % parts == 0) {
List<Long> ticks = new ArrayList<>();
for (int i = 0; i <= parts; i++) {
ticks.add(niceMax / parts * i);
}
return ticks;
}
}
throw new AssertionError("1 always divides");
}
/** The smallest of 1, 2, 5 × 10^k that covers max. */
static long niceCeiling(long max) {
if (max <= 0) {
return 1;
}
long pow = 1;
while (pow * 10 <= max) {
pow *= 10;
}
for (long mult : new long[] {1, 2, 5, 10}) {
if (pow * mult >= max) {
return pow * mult;
}
}
throw new AssertionError("10 × pow always covers");
}
}package dev.fiveyear.chart;
/** An artist: declares its resolution, draws a fitted chart. */
public interface Renderer {
int units();
String render(Series series, LinearScale scale);
}
/** Horizontal █ bars — terminals are row-oriented. */
public final class ConsoleRenderer implements Renderer {
@Override
public int units() {
return 20;
}
@Override
public String render(Series series, LinearScale scale) {
int widest = 0;
for (String label : series.labels()) {
widest = Math.max(widest, label.length());
}
StringBuilder out = new StringBuilder();
for (int i = 0; i < series.labels().size(); i++) {
long value = series.values().get(i);
out.append(pad(series.labels().get(i), widest))
.append(" │")
.append("█".repeat(scale.apply(value)))
.append(" ")
.append(value)
.append("\n");
}
out.append(pad("", widest)).append(" └ axis 0…").append(scale.niceMax());
return out.toString();
}
private static String pad(String s, int width) {
return s + " ".repeat(width - s.length());
}
}
/** Vertical bars as SVG markup — screens are portraits of numbers. */
public final class SvgRenderer implements Renderer {
@Override
public int units() {
return 200;
}
@Override
public String render(Series series, LinearScale scale) {
int barWidth = 40;
int gap = 20;
StringBuilder svg = new StringBuilder("<svg>");
for (int i = 0; i < series.values().size(); i++) {
int h = scale.apply(series.values().get(i));
int x = gap + i * (barWidth + gap);
svg.append("<rect x=\"").append(x)
.append("\" y=\"").append(200 - h)
.append("\" width=\"").append(barWidth)
.append("\" height=\"").append(h)
.append("\"/>");
}
return svg.append("</svg>").toString();
}
}
/** The commission: fit the scale to the artist, hand both over. */
public final class BarChart {
private final Series series;
public BarChart(Series series) {
this.series = series;
}
public String drawWith(Renderer renderer) {
return renderer.render(series, LinearScale.fit(series.max(), renderer.units()));
}
}At the drafting table:
Series quarters = new Series("revenue",
List.of("Q1", "Q2", "Q3", "Q4"),
List.of(400L, 900L, 1300L, 700L));
BarChart chart = new BarChart(quarters);
System.out.println(chart.drawWith(new ConsoleRenderer()));
// Q1 │████ 400
// Q2 │█████████ 900
// Q3 │█████████████ 1300
// Q4 │███████ 700
// └ axis 0…2000
chart.drawWith(new SvgRenderer());
// …<rect x="120" y="70" width="40" height="130"/>… — same 1300, now 130 of 200 px
LinearScale.niceCeiling(13); // 20
LinearScale.fit(13, 20).ticks(); // [0, 5, 10, 15, 20] — readable aloudThe interview corner
Clarify before you code: One series or several (grouped bars)? Can values be negative? Which output targets matter — and is live updating in scope, or is this render-once?
The follow-up ladder:
- "Add a line chart." The trap is class multiplication:
LineConsoleRenderer,LineSvgRenderer… Name the two independent axes — chart type and output medium — and keep them separate (the bridge idea): a chart type produces geometry, a renderer paints geometry. Four classes become two-plus-two, not two-times-two. - "Negative values." The domain becomes
[niceFloor(min), niceCeiling(max)]and the baseline moves toscale.apply(0)— bars grow both ways from it. The scale absorbs the whole feature; renderers just draw from the baseline. - "Real axis ticks." The 1-2-5 ladder generalizes: pick the step so you get 4–6 ticks. And label formatting —
1.5k,₹2L— is presentation, so it belongs to the renderer, never the scale. - "Live dashboard, one update per second."
Seriesstays immutable: each tick is a new snapshot through the same pipeline (the splitwise ledger's instinct). Diffing and partial redraws are a renderer optimization, invisible to the data. - "A million points." No renderer should ever see them — aggregate into buckets (min/max/avg per pixel column) before the chart. The scale maps buckets; the HLD version of this question is a metrics pipeline, and this is its last mile.
Mistakes that fail the round: pixel fields on data objects; a switch (format) inside the chart; rounding at every step instead of once at the edge; an axis that tops at the exact max, so every chart screams equally.
Where to go from here
Pocket version: data knows no pixels, the scale owns the only math (and rounds the axis to a 1-2-5 number), renderers are swappable artists, and pixels are derived at the last moment — never stored.
- Write the
niceFloortwin and make negative bars work — it's one evening and one baseline. - Add a
TableRendererthat prints values with percent-of-total — notice it needs zero new math. - Next in the queue: the internet download manager — from drawing data to moving it, where the hidden noun is a byte range and a power cut is part of the spec.
One ratio in the corner, artists who can't touch the survey data, and an axis you can read aloud — that's a chart library, and now it's yours.