I built a restaurant analytics dashboard with twelve pages of charts and no charting
library. Not because Chart.js is bad β it isn't β but because the thing I was building
had to open from a file on disk, on a locked-down machine, with no npm install and no CDN.
That constraint removed every option except writing the charts myself.
The engine ended up at 878 lines and ~37 KB of plain JavaScript: nine chart types in one
file, plus a heatmap and a set of trend rows in the page script. It draws SVG with
createElementNS and nothing else.
Most of what I learned had nothing to do with SVG. It was about axes, and about the two
or three decisions that quietly decide whether a chart tells the truth.
The tick loop that lets a bar escape
Here is the first version of the function that picks the axis ticks:
const ticks = (max, count = 5) => {
const raw = max / count;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / mag;
const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag;
const out = [];
for (let v = 0; v <= max; v += step) out.push(v);
return out;
};
This is the standard "nice numbers" trick: take the raw interval, round it to the nearest
1, 2, 2.5, 5 or 10 times a power of ten, and you get ticks humans can read β 0, 2.5k, 5k
instead of 0, 2340, 4680.
It is also wrong, and the wrongness only shows up with certain data.
The loop stops at the last tick below or equal to max. If max is 11,200 and the
step is 2,500, the ticks are 0 β¦ 10,000. The top of the plot area is now 10,000 β but the
tallest bar is 11,200. It renders twelve percent above the plot, straight through the top
of the card.
The fix is one line of intent: always climb past the data.
/* Always climb past max β otherwise the tallest mark renders above the
plot area and escapes the card. */
const out = [];
for (let v = 0; ; v += step) {
out.push(+v.toFixed(10));
if (v >= max - step * 1e-9) break;
}
return out;
Two details in there cost me time:
-
+v.toFixed(10)β repeatedly adding0.1gives you0.30000000000000004as a tick label. Rounding the accumulator kills it. -
max - step * 1e-9instead ofmaxβ with the same float drift, the loop can stop one step early on a value that looks equal.
That one is a plain bug: wrong output, visible, fixed once. The mistakes that cost me more
were the ones where the chart rendered perfectly and still said nothing.
When a non-zero axis is honest
Averages are the awkward case. A twelve-week trend of average check moves from β¬44.10 to
β¬49.40 β a real 12 % climb. Drawn from zero it is a flat line in the top fifth of the card,
with four fifths of empty space underneath. Technically correct, and it tells you nothing.
So the line chart has an opt-out, and a second tick function behind it:
/* Ticks for a range that does NOT start at zero. Only for lines of averages,
rates and indices, where the trend β not the absolute size β is the point.
Never use this for bars: a bar's length must stay proportional to its value. */
const ticksRange = (min, max, count = 5) => {
if (!(max > min)) return ticks(max, count);
const raw = (max - min) / count;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / mag;
const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag;
const lo = Math.floor(min / step) * step;
const out = [];
for (let v = lo; ; v += step) {
out.push(+v.toFixed(10));
if (v >= max - step * 1e-9) break;
}
return out;
};
The comment is the important part, and it is in the shipped source for a reason.
The rule I settled on: a non-zero baseline is fine when the mark's position carries the
meaning, and dishonest when the mark's size does. A line sits at a height; your eye reads
where it is. A bar has length; your eye reads how long it is. Cut the bottom off a bar
chart and a 3 % difference becomes a doubling.
So ticks is the default everywhere, ticksRange is opt-in through a single flag, and
bars never get it:
const ty = zeroBased ? ticks(max, yTickCount)
: ticksRange(Math.min(...all), max, yTickCount);
When the axis isn't the answer at all
Then I hit a case the flag could not fix. The same average-check card had two series:
dine-in around β¬44β49, delivery around β¬28β33. Both climbing, both interesting.
Zero-based, they are two flat lines stacked in the upper half. Scaled to the data, they are
two flat lines with a canyon between them β because the gap between the series is far
bigger than the movement inside either one. The axis is not the problem. Sharing an axis
is the problem.
The fix was to stop drawing one chart. Each series got its own row, its own scale and its
own sparkline β small multiples β plus a third derived row for the gap itself, which turned
out to be the number an owner actually cares about:
const [dine, del] = DATA.avgCheckTrend.series;
const gap = dine.values.map((v, i) => +(v - del.values[i]).toFixed(2));
UI.trendRows('#chart-check', {
currency: C, decimals: 2,
rows: [
{ name: dine.name, color: dine.color, values: dine.values },
{ name: del.name, color: del.color, values: del.values },
{ name: 'Gap', values: gap, derived: true }
]
});
I spent a while tuning an axis before accepting that the chart type was wrong. And I only
accepted it the second time β I had already made the same "two averages, one axis, now
it's flat" mistake on a different card weeks earlier, fixed it, and then walked straight
into it again. Knowing the rule and applying it under deadline are apparently two different
skills.
The more useful lesson of the two: reach for a different form before you reach for a
different scale.
Labels that know when to leave
Direct labels on bars are better than a legend β the number sits where the eye already is.
Until the window narrows, the bars get thinner, and the labels start overlapping each
other into mush.
Every bar chart can carry a caption above each column. The whole of the logic that decides
whether to draw it is this:
const cap = topLabels ? topLabels[i] : (withPercent ? Math.round((totals[i] / grand) * 100) + '%' : null);
if (cap != null && slot >= 34) {
const p = el('text', { class: 'value-label', x: cx, y: Y(totals[i]) - 8, 'text-anchor': 'middle' });
// β¦
}
slot is the horizontal space one column gets β the plot width divided by the number of
columns. Below 34 pixels the caption cannot fit without touching its neighbour, so it is
simply not drawn.
That threshold is the entire feature, and the reasoning behind it is the one line of
comment next to it in the source: a collided label is worse than no label. A chart with
captions on twelve columns and none on thirty is not inconsistent, it is responsive. A
chart where thirty captions overlap into a grey smear is broken, and worse, it looks like
nobody checked.
The same instinct shows up in a few places once you go looking for it β share labels on a
donut are skipped on slices under 6 %, where the text would be wider than the slice it
belongs to.
Themes: redraw, don't invert
The dashboard ships dark and switches to light. The tempting shortcut is a CSS filter over
the whole page. Don't β it inverts your photographs, flips your semantic colours so green
"good" becomes magenta, and leaves the charts looking like a photo negative.
Because every colour in the engine is a CSS custom property read at draw time:
const css = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
β¦switching themes is just redrawing. Every chart registers its own render function when it
mounts:
function mount(selector, draw) {
const host = typeof selector === 'string' ? document.querySelector(selector) : selector;
if (!host) return null;
const render = () => {
const w = host.clientWidth;
if (!w) return;
host.innerHTML = '';
host.appendChild(draw(w));
};
registry.push(render);
render();
return host;
}
const redrawAll = () => registry.forEach(f => f());
The same registry powers responsive resizing, debounced so a window drag doesn't rebuild
forty SVGs per second:
let resizeTimer;
addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => registry.forEach(f => f()), 140);
});
And the theme button is three lines:
root.setAttribute('data-theme', next);
try { localStorage.setItem(KEY, next); } catch (e) { /* ignore */ }
if (window.Mesa) Mesa.redrawAll(); // charts re-read their CSS variables
The try/catch is not decoration: localStorage throws in some privacy modes, and a theme
toggle is not worth a broken page.
One tooltip serves every chart on the page β a single div created lazily and moved
around, rather than one node per chart:
let tipEl = null;
const tip = {
node() {
if (!tipEl) {
tipEl = document.createElement('div');
tipEl.className = 'tip';
tipEl.setAttribute('role', 'status');
document.body.appendChild(tipEl);
}
return tipEl;
},
// β¦show / move / hide
};
It also flips itself when it would run off the right edge of the viewport, which is the
kind of thing a library gives you for free and you forget you need until you hover the
last bar.
Colours, briefly
There is no colour cycling in the engine, and that is deliberate: each series names its own
CSS variable, so series four keeps its colour whether or not series two is filtered out.
Generated hues would quietly repaint the survivors every time a filter changed.
The palette itself is eight fixed steps, and the line above them in the stylesheet records
what they were checked for:
/* --- categorical series (validated: adjacent CVD ΞE β₯ 8) --- */
--s1: #2a78d6; /* blue */
--s2: #eb6834; /* orange */
--s3: #1baf7a; /* aqua */
--s4: #eda100; /* yellow */
Every adjacent pair stays distinguishable under deuteranopia, protanopia and tritanopia.
Status colours (good / warning / critical) are a separate set and always ship with a label,
never colour alone.
That last rule is the one most dashboards break. If the only thing marking a row as
critical is that it is red, a meaningful slice of your users sees an unmarked row.
Should you write your own?
Mostly no.
If you need zoom, brushing, animated transitions, a time axis that handles daylight saving,
or a hundred thousand points, use a library. That work is enormous and it is already done.
Write your own when:
- the deliverable must run with no network and no build step;
- you need a handful of chart types rather than a general-purpose grammar;
- the bundle budget is small enough that 40 KB of charting matters;
- you want to be able to read every line that renders your numbers.
What surprised me is how much of the work is not drawing. Drawing a bar is four lines of
path data. Deciding where the axis starts, which labels to drop when the column gets too
narrow, and when the honest answer is a different chart entirely β that is where the time
goes, and that is also what separates a chart that informs from a chart that decorates.
The engine lives inside a restaurant analytics dashboard template I sell. The free cut β
two full pages, the engine, MIT β is here:
github.com/Anatolii198/mesa-lite
(live demo).
The full twelve-page version is here if you want
to see all eleven chart types in one place.
Update: the engine is now a standalone library β mesa-charts, MIT, with a demo of all eleven chart types on one page.
Happy to argue about axis baselines in the comments.













