DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our chart is a pure function, so "no two labels overlap" is a unit test

CogniPrep's practice tests include data interpretation items, which means they include charts. Those charts are drawn by one shared component, and the thing that makes it pleasant to work on is a decision made before any of it was written: layout is a pure function, and the SVG is a thin pass over its output.

layoutPlottedChart(input: ChartInput, width: number): ChartLayout
Enter fullscreen mode Exit fullscreen mode

ChartLayout is plain data. Bars with x, y, width, height. Points with x, y, value, category. Gridlines, tick labels, and valueLabels as { x, y, text, anchor }. No DOM, no React, no measurement. The component calls it with the width it measured for its container and maps the result to <rect> and <text>.

That shape is why the test file is 29 tests of arithmetic rather than a folder of screenshots.

Why geometry is not cosmetic here

The test file opens with the reason:

On a chart item the geometry is the answer key. A bar that stops half a gridline short of its value marks a correct reading wrong, and nothing else in the pipeline would notice.

Everything downstream of a chart deals in numbers. If the picture disagrees with the number it is drawn from, nothing but a human eye can catch it, and the person whose eye catches it is a candidate who read the chart correctly and was told they were wrong.

So the baseline assertions are not "does it look right". They are:

  • every bar ends exactly on the gridline for its value, at phone and desktop widths
  • every line point sits on the gridline for its value and the centre of its category
  • nothing is drawn outside the chart box, at any width
  • tick labels stay inside the chart and apart from each other at 320px
  • a bad axis step cannot produce an unbounded number of gridlines

Those are one-line expectations against returned coordinates. The same assertions through a rendered DOM would need a layout engine, a font, and a tolerance, and would still be slower than the whole file is now.

The labels that collided

Grouped bars are capped at 24px thick. A label like 12,345 is wider than that, and wider than the pitch between neighbouring bars, so with three series the labels for A, B and C at the same category were drawn on top of each other.

The fix is a row allocator. Each label takes the first row above its bar that is clear of every label already placed:

const at = (row: number) =>
  above ? yv - 5 - row * labelRowHeight : yv + FONT_SIZE + 3 + row * labelRowHeight;
const clear = (row: number) =>
  placed.every(
    (p) => Math.abs(p.x - labelX) >= (p.w + w) / 2 || Math.abs(p.y - at(row)) >= labelRowHeight
  );
let row = 0;
while (row < labelRows && !clear(row)) row++;
placed.push({ x: labelX, y: at(row), w });
Enter fullscreen mode Exit fullscreen mode

Two details I would have got wrong without the tests.

placed holds every label on the chart, not just the ones in the current group. At phone widths the last bar of one category group and the first bar of the next are also closer together than a label is wide, so grouping the collision check by category still produces overlaps, just in a different place.

And the number of rows has to be known before the plot area is sized. labelRows is computed up front from the widest formatted value and the bar pitch, capped at three, and the top margin reserves that many rows. Allocate rows lazily during drawing and the top row of labels gets clipped off the top of the SVG, which is a worse bug than the overlap, because it removes information instead of tangling it.

Line charts needed a different rule. Two series whose values are close at the same category print one label over the other, and there is no pitch to spread across. Working down from the highest point, a label that would sit within a line of the previous one moves below its own point instead, and if that would collide with the category labels, the earlier one moves up.

The test that checks all 66 pairs

The interesting test is not "label B moved". It is that no two labels overlap anywhere, for any input, at several widths:

const box = (l: { x: number; y: number; text: string }) => ({
  left: l.x - (l.text.length * FONT * 0.6) / 2,
  right: l.x + (l.text.length * FONT * 0.6) / 2,
  top: l.y - FONT,
  bottom: l.y,
});

function expectNoOverlap(layout: ChartLayout, note: string) {
  const boxes = layout.valueLabels.map(box);
  for (let i = 0; i < boxes.length; i++) {
    for (let j = i + 1; j < boxes.length; j++) {
      const a = boxes[i], b = boxes[j];
      const overlaps =
        a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
      expect(overlaps, `${note}: "${layout.valueLabels[i].text}" over "${layout.valueLabels[j].text}"`)
        .toBe(false);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Three series across four categories is twelve labels, so 66 pairs, and the same assertion runs at the default width, at 390, and at 1000. The failure message names the two labels by their text, which is the difference between a red test you can fix and a red test you have to debug.

There is an honest limitation in there. Both the layout and the test estimate text width as 0.6em per character rather than measuring a font. So the test proves the allocator is self-consistent, not that a real typeface at 12px fits in the space reserved. Pairing it with a visual check at 320px is what covers the rest, and a shared estimate is the only way to test placement at all without pulling a font metrics library into a unit test. Saying which one a test does and does not prove costs a sentence.

A chart in a test is not a chart in a dashboard

Some decisions in the component look wrong until you remember what the chart is for. There is no hover tooltip. Value labels are off unless a particular test's real charts print them. Bar ends are square, not rounded, because a rounded end is harder to line up against a gridline. The value axis starts at zero unless told otherwise, because a truncated axis makes a difference look bigger than it is.

The accessibility work is the part I would lift into any dataviz code. Every series carries a colour and a second channel that survives without it: bars get a fill pattern (solid, two hatch directions, dots, cross-hatch), lines get a dash pattern and a marker shape. Both channels appear in the legend. That is what makes the chart readable with a colour vision deficiency, and on a greyscale printout.

The palette is not the app's theme tokens, which put a coral and an orange next to each other that are hard to separate with full colour vision and nearly identical with a deficiency. Five colours were chosen against lightness, chroma, separation under protanopia and deuteranopia, and contrast on both the light and dark backgrounds, with a separate set for dark mode. Hence a hard cap of five series, asserted by its own test: a sixth would have to repeat a colour, and the component refuses rather than quietly reusing one.

One more that is width rather than colour: before the container has been measured, and on the server, the chart lays out at 320px. The first paint is the phone layout, not a desktop layout squeezed into a phone column, so text is 12px at every width and only the plot area changes size.

See it

The charts live inside the practice tests, so the ones to look at are the data interpretation sittings on cogniprep.app/games/epso and cogniprep.app/games/police-scotland: sign up, start a numerical test, and resize the window while a chart item is on screen. The text stays 12px, the plot area moves, and nothing scrolls sideways. If you have a greyscale filter handy, turn it on and check you can still tell the series apart.

If you draw charts in a product, the transferable part is not the label allocator. It is that the function returning coordinates and the function returning SVG are two different functions.

Top comments (1)

Collapse
 
svgicons profile image
Svg/icons •

The pattern and marker channels are great for color independence. Do you also expose the underlying chart data textually or as a table for screen-reader users?