Difference Area Chart
A four-state difference area: two series with the gap between them shaded, the fill and hatch flipping wherever the lead changes hands, and every crossing marked, counted and keyboard-reachable.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-difference-area.jsonPrompt
Build a React + TypeScript + Tailwind "Difference Area Chart" component (zod, a cn() class merger, a
useResizeObserver hook; hand-rolled SVG, no chart library). It draws two series on one x axis and
shades the gap between them, flipping the shading wherever the lead changes hands.
Contract
- zod schema, one source of truth (props are z.infer of it plus the render options below):
status: "loading" | "empty" | "error" | "ready"
title: string, caption?: string
a: { label: string }, b: { label: string } // the two series, named once
unit?: string // ONE unit for both: a gap between two units is not a quantity
points: { x: number; label: string; a: number | null; b: number | null }[]
x is NUMERIC (epoch ms, week index, sprint number), never a category: a crossing lands BETWEEN two
samples, and an interpolated x has nowhere to sit on a band scale. `label` is what the axis tick, the
readout and the table print. `null` means measured and missing.
- Render options on top of the contract: mode ("overlay" | "delta", default "overlay"), height (px,
clamped 140-480), maxTicks (3-8), formatValue, onRetry, className, ...div props. forwardRef to the card.
- All the maths live in exported pure functions beside the component, so a test can print the same
numbers the picture is made of: prepareDifferenceSamples, buildDifferenceModel, toDeltaSamples,
niceValueScale, gapPath, seriesPath, isolatedSamples, buildStops, stopBands, pickTickIndices,
separateLabels.
Behavior
- Lead runs are the whole component. Walk consecutive samples where BOTH series report at BOTH ends.
Per segment take d0 = a0 - b0 and d1 = a1 - b1; when their signs are strictly opposite the lead turns
over inside the segment at t = d0 / (d0 - d1), and the segment splits there. Same-lead parts merge
across segments into one run; a run's polygon is its vertices forward along the leader and back along
the follower, which is why the fill closes on the crossing point instead of on a hairline sliver.
At an interior split set BOTH series to the average of the two interpolations - they are equal by
definition, and this makes them equal in floating point too.
- A segment that only touches zero at one end keeps the sign of its other end. A segment that is zero at
BOTH ends is a genuine tie: it shades nothing, closes the open run, and its x span is counted as tied
rather than handed to either side.
- Derive crossings from run BOUNDARIES, not from the sign test alone: that also catches a lead change
landing exactly on a sample and a lead change on the far side of a tie. Two runs only produce a
crossing when they belong to the same unbroken stretch - nobody overtakes anybody across a hole.
- Missing data breaks everything it should break: the two lines, the band and the run. Never bridge a
null; a bridged gap invents a lead nobody observed. Count the stretches and say how many there are.
- Feed hygiene, three problems and three different answers, each counted and disclosed in a note under
the chart: non-finite or duplicated x drops the point; a NaN / Infinity reading becomes a gap while
the point survives (the other series still has something to say at that x); unsorted input is sorted
by x, because every crossing is interpolated between neighbours and neighbours have to be neighbours
first.
- Degenerate input must produce a picture, not an exception: zero points (fall through to the empty
branch even when status is "ready"), one point (axis, dots and a note - a shaded gap needs two paired
readings), every value identical (a padded band, never a zero-height plot), all-negative values, and a
reading with no plottable neighbour (drawn as a dot, because a one-command subpath paints nothing).
- Lead share is weighted by x, not by sample count: with irregular sampling, counting samples reports
the wrong winner. Print both shares plus the tied share in the legend.
- Reading it with a pointer: the plot is divided into hit bands running to the midpoint between
neighbouring stops, so anywhere in the plot selects the nearest stop and fills a readout line.
- Reading it with a keyboard: the plot is ONE tab stop. The stops are the samples PLUS the crossings,
left to right, with a roving tabindex. Arrow Left / Arrow Right step, Home / End jump to the ends,
Page Up / Page Down jump between lead changes; movement clamps and never wraps. A gesture is never
the only path to a number. If a feed update removes the stop that had focus, the tab stop takes focus
back so it never lands on <body>.
- Four states are first-class branches: loading (skeleton only), empty, error (retry button only when
onRetry is passed), ready.
Rendering & styling
- Semantic tokens only: var(--chart-1) for series a, var(--chart-2) for series b, plus --card,
--foreground, --muted-foreground, --border, --ring. No hex, no rgb(), no oklch() anywhere, fills
included.
- Colour is never the only channel. Series a is a solid line, series b is dashed. The a-ahead band is a
45 degree hatch in chart-1, the b-ahead band a -45 degree hatch in chart-2 (an SVG pattern: a token
rect at 14% plus a token stripe at 50%), so the two bands stay apart in greyscale. The widest run of
each lead carries the leader's NAME as text inside the band, and every crossing is a diamond outline -
a shape channel, not a second colour.
- Direct labels at the right end of both lines, each with a leader line back to its real endpoint and a
two-label separation pass that moves the LABELS and never the points, so a flat line stays flat.
Labels truncate with a title attribute; nothing overflows the card.
- Axis text is text-muted-foreground text-xs, gridlines use the border token, and the zero rule is a
dashed muted-foreground line drawn only when zero is inside the domain. Value ticks come from a
1 / 2 / 2.5 / 5 nice-step scale; x ticks are a subset of the sample labels, never more than fit.
- SSR-stable and deterministic: the viewBox always matches the width the geometry was computed for, so
the frame before the ResizeObserver reports is drawn scaled to fit rather than clipped, and once the
measured width arrives the scale is exactly 1. No Math.random and no Date.now at render - the skeleton
silhouette is a constant.
- Accessibility: the svg is role="group", named by the card heading and described by an sr-only summary
sentence. NOT role="img" - that is children-presentational and would silence the focusable stops. The
stop layer is a listbox whose options each carry a full-sentence aria-label ("Mar 17: Organic 45.2%,
Paid 41.8% - Organic ahead by 3.4%"). The visible readout line is aria-hidden, because a focused stop
already announces itself, and a separate polite live region carries the POINTER-driven readout, which
no focus event announces. An sr-only table repeats every number; the sr-only class goes on a wrapper
div, never on the table, because CSS width is only a lower bound for a table box.
- Motion: the only animation is the loading pulse, gated with motion-reduce:animate-none, plus a
motion-reduce-gated transition on the crossing marker. The chart is complete with animation off.
Customization levers
- Colour: swap --chart-1 / --chart-2 for any token pair that differs in hue AND lightness; if your theme
only has one hue, the hatch angles and the dashed line already carry the distinction. Raise the band
fill opacity for a heavier chart, drop the stripe opacity for a quieter one.
- Density: height (140-480) and maxTicks are the two knobs. Shrink the right gutter towards 64px when
the series names are short, raise it towards 180px when they are long.
- Emphasis: mode="delta" when both series sit at a similar level and the gap is the story;
mode="overlay" when the levels themselves matter. Same contract, same runs, same crossings.
- Trim: drop the in-band run labels for a sparse look, drop the share percentages when the window is
not meaningful, drop the crossing diamonds when there are dozens of them and the shading alone reads.
- Extend: colour the band by valence instead of by series when one side is a target rather than a peer
(ahead of budget good, behind budget bad); keep it to two series - a difference chart is a two-body
problem, and a third line has no single gap to shade.Concepts
- Lead run — one unbroken stretch where the same series is on top. Runs, not samples, are what gets shaded, and they start and end exactly where the lead does: at an interpolated crossing, at a tie, or at the edge of a hole in the feed.
- Interpolated crossing — the lead changes between two readings, not at one. The crossing sits at
t = d0 / (d0 − d1)along the segment, both series are pinned to the same value there, and that shared point is what closes both polygons. - Tie — a stretch where the two series are exactly equal. It shades nothing and hands the lead to nobody; its x span is reported separately, so 40% / 40% / 20% tied never quietly becomes 50 / 50.
- x-weighted share — how much of the compared window each side led, measured in x, not in samples. A feed that samples the busy weeks twice as often would otherwise elect the wrong winner.
- Overlay vs delta — the same contract read two ways. Overlay keeps the levels and shades the gap; delta subtracts the shared level so a 2-unit gap between two lines near 900 stops being a hairline. Both run the same run-and-crossing maths, so they can never disagree about who led when.
- Stop — anything the reader can land on: every sample and every crossing. Making crossings stops is what puts the question this chart answers, when did the lead change, one Page Down away instead of behind a hover.
Market Depth
A four-state order-book depth chart: mirrored cumulative bid and ask staircases meeting at the spread, an exact step outline, a hover and keyboard readout of price, size and cumulative total at any level, and a refusal when the book crosses itself.
Pictogram
A four-state ISOTYPE unit chart: one row per category, icons repeated at a stated one-icon-equals-N scale, the last icon clipped to the leftover instead of shrunk, and unusable rows dropped and counted in view.