Bump Chart
A four-state bump chart that plots position instead of value — reversed integer rows, competition ranking for ties, edge labels at both ends, and a broken line wherever an entity is off the board.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-bump.jsonPrompt
Build a React + TypeScript + Tailwind "ChartBump" dashboard card on the shadcn
chart primitives (ChartContainer/ChartTooltip over recharts) with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
unit?: string; order?: "desc" | "asc";
series: { key, label,
points: { x: string, value: number | null,
rank?: number | null }[] }[] }
- x is a period label rendered as-is, so the caller keeps control of
granularity (months, ISO weeks, season numbers).
- An entity is *on the board* for a period when that point carries a finite
rank or a finite value. Anything else means "not ranked this period" — it is
not zero and not last place.
- `value` is the score positions are derived from; `rank` is the escape hatch
for callers whose source already knows the position (server ORDER BY, an
official standings feed, a SERP tracker that never sees the scores).
- Refinements: every entity carries the same x sequence point for point
(positions are computed across all entities per period, so they must share
one grid); and within a period, either every ranked point carries an
explicit rank or none does — mixing them lets a caller's 2 and a derived 2
land on the same row for unrelated reasons.
- Cap the board at 10 entities: five chart tokens x two dash patterns is how
many distinct visual identities exist.
- Component props = z.infer of the schema, plus formatValue?, onRetry? and
className. No hand-written parallel interface.
Behavior
- Per period, resolve one position per entity. Ties use COMPETITION ranking
(1, 2, 2, 4): tied entities share the better row and the next distinct score
skips the rows the tie consumed, so a position always reads as "one more
than the number of entities strictly ahead of me" — the rule sport
standings and SQL RANK() use. Dense ranking (1, 2, 2, 3) answers a
different question and silently shortens the board on every draw, so the
same row means different things in different periods. Document the choice;
callers who need the other policy supply `rank` themselves.
- order: "desc" (default) means the highest score is position 1; "asc" is for
metrics where lower wins (lap time, error rate, price).
- The y scale is REVERSED and ordinal: domain is [min occupied position, max
occupied position], ticks are the positions that actually occur, so it can
never print a "1.5th place" and a sliced board (#4..#12) does not waste rows
1-3. Thin the ticks by pixel distance, not by every n-th entry — the rows
are a linear scale, so an every-other rule on a sparse set still lets two
ticks land 3px apart. Inset the scale a few px at both ends so row 1's
stroke is not drawn on the frame.
- Absence: connectNulls={false} and a dot renderer that draws nothing when the
point has no y. An entity that enters in period 3 and leaves after period 4
therefore gets one subpath spanning exactly those two periods, and one that
drops out mid-range and returns gets two. Carrying the last known position
forward instead would invent standings for every period the source never
reported.
- Edge labels: name the entities holding each row in the first and the last
period, grouped BY ROW rather than per entity, so a tie at an edge shares a
single box instead of two boxes drawn on top of each other. Render them as
axis ticks (one label per row) inside a foreignObject with CSS truncate, so
the browser measures the glyphs; a hand-rolled width estimator is wrong by
~30% in both directions on caps and digits.
- Narrow cards: keep the trailing names at every width (the current standings
are what the chart is usually opened for) and drop the leading names below
~520px, falling back to the position numbers alone. Drop both when the row
pitch is smaller than a label box. Every entity is still named in the
legend, the tooltip and the data table.
- Tooltip: hovering any period reads out the whole standings for it in
position order — row number, swatch, name, the move since the previous
period (up/down/level/new as glyph + count) and the score. Entities off the
board are listed last as "not ranked" rather than dropped, because that is
the reading a broken line is showing.
- Highlight: legend chips are real buttons. Hover or focus lifts one line
(the rest drop to strokeOpacity 0.25 and its own stroke thickens), click
pins it (aria-pressed), clicking again unpins. The effective key is
hovered ?? pinned, re-derived against the current series every render, so a
key left over from a previous payload simply stops matching.
- Four first-class state branches inside one bg-card panel: loading
(aria-hidden skeleton of crossing polylines at the real plot height), empty
(two crossing outlines + copy; a ready payload with nothing plottable lands
here too), error (message + a retry button only when onRetry exists), ready.
Rendering & styling
- Colours come only from the chart tokens. Identity is a PAIR: colour cycles
var(--chart-{(i % 5) + 1}) and the dash pattern advances only once the five
colours are used up (index 5 restarts chart-1 with a dash). Ten entities
therefore get ten different (colour, dash) identities, and the same pair is
repeated in the legend swatch, the tooltip swatch and the edge label — a
bump chart's neighbouring lines sit ~1.3:1 apart in a five-hue palette, so
colour can never be the only channel.
- Accessibility: the plot is role="img" with a generated aria-label (title,
entity and period counts, the span, the final standings, the biggest move,
how many entities are off the board for part of it). recharts'
accessibilityLayer is switched off so no focusable svg sits inside that
children-presentational subtree, and the exact positions live in a sibling
data table hidden by an sr-only *wrapper div* — a bare sr-only table keeps
auto table layout, ignores width:1px and drags the page into horizontal
scroll on a phone.
- recharts only lays a cartesian axis out when a series is bound to it, so the
trailing edge needs one invisible line (no stroke, no dots, no legend entry)
bound to that axis id.
- Animation: the draw-in runs unless prefers-reduced-motion is set, read
through useSyncExternalStore over matchMedia with a false server snapshot —
recharts animates in JS, so motion-reduce: classes cannot reach it.
- Panel: rounded-xl border bg-card, tabular-nums for every number, cn() merges
className. Card width comes from a ResizeObserver on the card itself, not
from the viewport, so the chart is right in a sidebar as well as full-bleed.
Customization levers
- Entity count: 2-10. Past 10, split the board or make the tail toggleable
rather than reusing a (colour, dash) pair.
- Tie policy: swap competition ranking for dense ranking in one comparator, or
push the decision to the caller by always supplying `rank`.
- Curve: type="monotone" gives the classic bump S-curve between rows;
"linear" if you would rather not imply anything between two periods.
- Density: a 280px plot with p-6 suits a dashboard grid; ~200px with p-4 for a
compact card. Drop CartesianGrid for a cleaner sheet, or add vertical grid
lines when periods matter as much as rows.
- Edge labels: the leading-edge breakpoint, the label width band and the
"drop when the rows get too tight" threshold are three constants; raise the
width band for long entity names, or turn the leading edge off entirely and
let the legend carry identity.
- Movement glyphs: the tooltip's up/down/level/new markers are text, so they
survive a monochrome print; swap them for lucide icons if the host UI
already uses them.
- Palette: replace the colour formula to pin a fixed token per key (us vs
everyone else); keep the dash channel whatever you do.Concepts
- Position, not value — the y axis is an ordinal scale of rows, reversed so that position 1 sits at the top and drawn only on the positions that actually occur. That is the whole difference from a line chart: a bump chart throws the magnitudes away on purpose, because the question is who is ahead, not by how much. The magnitudes come back in the tooltip and the data table.
- Competition ranking — a draw puts both entities on the better row and the next distinct score skips the rows the tie consumed (1, 2, 2, 4). It keeps a row meaning the same thing in every period: "one more than the number of entities strictly ahead". Dense ranking would compress the board on every draw, so row 3 would mean different things in different columns.
- Absence is a break, not a position — an entity that is not on the board has no row, so its line stops and starts again. Continuing it at the last known position is the characteristic bump-chart lie: it draws a standing the source never reported, and the reader has no way to tell it apart from a real result.
- Edge labelling — the standings at the first and last period are written into the margins, one label per row, so a reader can name a line without walking back to a legend; a tie at an edge collapses into one shared box instead of two overlapping ones. On a narrow card only the trailing edge survives, because "where does this end up" outranks "where did it start".
- Redundant identity — every entity owns a chart token and a dash pattern, and the pair only repeats after 25 combinations, so ten crossing lines stay separable in greyscale, under a colour-vision deficiency, or in a palette whose neighbouring hues are a step apart.
- Graphic plus data table — the plot is one labelled
role="img"graphic with no focus stops inside it, and every position is repeated in a visually hidden table, so assistive tech gets the summary and the exact standings instead of a pile ofpathelements.
Box Plot
A four-state box plot comparing distributions side by side — R-7 quartiles, Tukey 1.5×IQR whiskers that stop on real observations, de-overlapped outliers, and raw points for groups too small for quartiles.
Slope Chart
A four-state slope chart for two points in time — labelled at both ends, with a pooling label-avoidance pass, leader lines that go back to the real endpoint, and a same-unit check that refuses to draw a slope across two dimensions.