Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"

import { type ChartConfig, ChartContainer, ChartTooltip } from "@/components/ui/chart"
import { cn } from "@/lib/utils"
import { useResizeObserver } from "@/registry/hooks/use-resize-observer"
import { type ChartBumpData, type ChartBumpPoint, isRankedPoint } from "./chart-bump.contract"

export interface ChartBumpProps extends ChartBumpData {
  /** number → text for the tooltip and the data table (the axis shows positions, not scores) */
  formatValue?: (value: number) => string
  onRetry?: () => void

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-bump.json

Prompt

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 of path elements.

On This Page