import { DifferenceChart } from "semiotic"
DifferenceChart fills the region between two series with a color that switches based on which series is higher at each x. Crossovers are linearly interpolated, so segments meet at zero-width vertices — no jagged seams. Both series are drawn as overlay lines on top of the fill by default.
Classic uses: temperature anomaly (actual vs. normal), forecast accuracy (actual vs. predicted), budget variance, and any A/B comparison where the direction of the difference carries information.
Quick Start
Provide an array of { x, a, b } rows and the two series accessors. The chart computes crossovers and switches fill color automatically.
import { Frame } from "semiotic" const frameProps = { /* --- Data --- */ data: [ { month: 1, actual: 38, normal: 32 }, { month: 2, actual: 41, normal: 36 }, // ...more rows ], /* --- Process --- */ xAccessor: "month", /* --- Customize --- */ xLabel: "Month", yLabel: "°F", /* --- Other --- */ seriesAAccessor: "actual", seriesBAccessor: "normal", seriesALabel: "Actual", seriesBLabel: "Normal" } export default () => { return <Frame {...frameProps} /> }
Examples
Forecast accuracy with multiple crossovers
When two series cross repeatedly, the fill flips at every crossover. Useful for showing where a forecast model over- or under-predicted over time. Custom semantic colors viaseriesAColor / seriesBColor.
import { Frame } from "semiotic" const frameProps = { /* --- Data --- */ data: forecastData, /* --- Process --- */ xAccessor: "x", /* --- Customize --- */ xLabel: "Week", yLabel: "Demand", /* --- Other --- */ seriesAAccessor: "actual", seriesBAccessor: "forecast", seriesALabel: "Actual", seriesBLabel: "Forecast", seriesAColor: "var(--semiotic-success)", seriesBColor: "var(--semiotic-warning)" } export default () => { return <Frame {...frameProps} /> }
Curve interpolation
Pass a curve to smooth the segments and overlay lines. Crossovers are still computed at the linear-interpolation crossover x — the smoothed curve just rounds the shape between vertices.
Heads-up: with a non-linear curve, the overlay lines and the fill boundary drift a few pixels apart near crossovers. Each side has different control-point context (a segment starts at the crossover; the continuous line has a real prior neighbor there), so the curve tangents diverge. Linear curve gives perfect alignment; if you want smooth boundaries, paircurve="monotoneX" with showLines={false}— the classic NYT-style look has no overlay lines to compare against.
import { Frame } from "semiotic" const frameProps = { /* --- Data --- */ data: tempData, /* --- Process --- */ xAccessor: "month", /* --- Customize --- */ xLabel: "Month", yLabel: "°F", curve: "monotoneX", /* --- Other --- */ seriesAAccessor: "actual", seriesBAccessor: "normal", seriesALabel: "Actual", seriesBLabel: "Normal" } export default () => { return <Frame {...frameProps} /> }
Gradient fill
Pass gradientFill for a tip→base opacity gradient that adds depth to each segment. The gradient runs along the segment boundary, fading from the dominant series toward the crossover line.
import { Frame } from "semiotic" const frameProps = { /* --- Data --- */ data: budgetData, /* --- Process --- */ xAccessor: "week", /* --- Customize --- */ xLabel: "Week", yLabel: "$K", gradientFill: true, curve: "monotoneX", /* --- Other --- */ seriesAAccessor: "spend", seriesBAccessor: "budget", seriesALabel: "Spend", seriesBLabel: "Budget" } export default () => { return <Frame {...frameProps} /> }
Fill only (no overlay lines)
Set showLines={false} for a clean fill without the boundary lines — the classic NYT-style temperature-anomaly look.
import { Frame } from "semiotic" const frameProps = { /* --- Data --- */ data: tempData, /* --- Process --- */ xAccessor: "month", /* --- Customize --- */ xLabel: "Month", yLabel: "°F", areaOpacity: 0.85, /* --- Other --- */ seriesAAccessor: "actual", seriesBAccessor: "normal", seriesALabel: "Actual", seriesBLabel: "Normal", showLines: false } export default () => { return <Frame {...frameProps} /> }
Annotations
Add x-threshold / y-threshold annotations to mark important moments or target values — same annotation surface as the other XY charts.
import { Frame } from "semiotic" const frameProps = { /* --- Data --- */ data: budgetData, /* --- Process --- */ xAccessor: "week", /* --- Customize --- */ xLabel: "Week", yLabel: "$K", /* --- Annotate --- */ annotations: [ { type: "x-threshold", value: 11, label: "HW order", color: "var(--semiotic-text)" } ], /* --- Other --- */ seriesAAccessor: "spend", seriesBAccessor: "budget", seriesALabel: "Spend", seriesBLabel: "Budget" } export default () => { return <Frame {...frameProps} /> }
Marked vertices
Set showPoints to draw a dot at each data vertex on the overlay lines. Useful for sparse data where the reader needs to know exactly where each measurement landed.
import { Frame } from "semiotic" const frameProps = { /* --- Data --- */ data: budgetData, /* --- Process --- */ xAccessor: "week", /* --- Customize --- */ xLabel: "Week", yLabel: "$K", curve: "linear", showPoints: true, /* --- Other --- */ seriesAAccessor: "spend", seriesBAccessor: "budget", seriesALabel: "Spend", seriesBLabel: "Budget" } export default () => { return <Frame {...frameProps} /> }
Streaming via push API
DifferenceChart supports the standard semiotic push API: omitdata, attach a ref, and push raw{ x, a, b } rows. The HOC recomputes crossovers and re-renders each push. Use the top-level windowSizeprop to cap the raw-data buffer — older rows evict FIFO so the per-render segment recomputation stays bounded.
JSX
<DifferenceChart ref={chartRef} xAccessor="x" seriesAAccessor="a" seriesBAccessor="b" windowSize={100} /> // In an effect: chartRef.current.push({ x: t, a: aValue, b: bValue }) chartRef.current.pushMany([...rows]) chartRef.current.clear() // remove()/update() require pointIdAccessor: <DifferenceChart ref={chartRef} pointIdAccessor="id" ... /> chartRef.current.remove("row-42") chartRef.current.update("row-42", d => ({ ...d, a: 99 }))
Throughput: the HOC stores raw rows in React state and recomputes segments via useMemo on each render. This is fine for forecast / KPI / anomaly streams (≤1 Hz). For high-frequency streams, consider down-sampling before pushing.
Props
| Prop | Type | Required | Default | Description |
|---|
data | array | — | — | Array of {x, a, b} objects. Omit for push API mode. |
xAccessor | string | function | — | "x" | Accessor for the x value. |
seriesAAccessor | string | function | — | "a" | Accessor for the series A value. |
seriesBAccessor | string | function | — | "b" | Accessor for the series B value. |
seriesALabel | string | — | "A" | Display label for series A (legend + tooltip). |
seriesBLabel | string | — | "B" | Display label for series B. |
seriesAColor | string | — | var(--semiotic-danger) | Fill color used when series A is above series B. |
seriesBColor | string | — | var(--semiotic-info) | Fill color used when series B is above series A. |
showLines | boolean | — | true | Draw the two series as overlay lines on top of the filled difference. |
lineWidth | number | — | 1.5 | Stroke width for the overlay lines. |
showPoints | boolean | — | false | Show data points at each vertex on the overlay lines. |
pointRadius | number | — | 3 | Radius for the overlay-line points. |
curve | string | — | "linear" | Curve interpolation: linear, monotoneX, monotoneY, step, stepAfter, stepBefore, basis, cardinal, catmullRom. |
areaOpacity | number | — | 0.6 | Opacity of the difference fill (0–1). |
gradientFill | boolean | { topOpacity, bottomOpacity } | { colorStops } | — | — | Tip→base gradient across each segment. Same shape as AreaChart.gradientFill. |
xExtent | [number, number] | — | — | Fixed x domain. Either bound may be undefined. |
yExtent | [number, number] | — | — | Fixed y domain. Either bound may be undefined. |
axisExtent | "nice" | "exact" | — | "nice" | Tick endpoint mode (see /features/axes#axis-extent). |
pointIdAccessor | string | function | — | — | Stable ID accessor for push-mode remove()/update(). |
windowSize | number | — | — | Maximum number of raw rows kept in the push buffer. When exceeded, oldest rows are evicted FIFO. Recommended for long-running streams so segment recomputation stays bounded. |
annotations | array | — | — | Annotation objects (x-threshold, y-threshold, etc.). |
tooltip | boolean | function | object | — | — | Custom tooltip; default tooltip shows both series + Δ at the hovered x. |
frameProps | object | — | — | Pass-through props to StreamXYFrame. |
- AreaChart — single or multi-series filled areas with a fixed baseline (or ribbon via
y0Accessor). - StackedAreaChart — cumulative-sum visualization where part-to-whole relationships matter more than divergence.
- LineChart — two-line comparison without the filled difference; reach for this when the values matter more than the gap between them.