Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { useResizeObserver } from "@/registry/hooks/use-resize-observer"
import {
  buildDifferenceModel,
  buildStops,
  gapPath,
  isolatedSamples,
  niceValueScale,
  pickTickIndices,
  prepareDifferenceSamples,

Installation

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

Prompt

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.

On This Page