Skip to main content

Zone

A shaded band between two y values, spanning the full plot width — the value-axis counterpart of Region.

Where the other three marks say "at this moment", "at this level" and "during this window", a Zone says "in this range of values". It's the mark for a classification of the value axis: US EPA AQI categories, heart-rate or power zones, an SLO band, a control chart's spec limits — anywhere a reading only means something read against a scale.

src/examples/charts-annotation-zone.tsx
import {
ChartContainer,
ChartRow,
Layers,
LineChart,
YAxis,
Zone,
type ChartTheme,
} from '@pond-ts/charts';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
import { singleHostSeries } from './lib/server-metrics';

/** The bands' palette — one theme role per band, so the *scale* lives in one
* place and the call site only names which band it is. */
function bandedTheme(base: ChartTheme): ChartTheme {
return {
...base,
annotation: {
...base.annotation!,
roles: {
healthy: { color: '#1f9d63', fillOpacity: 0.14 },
warning: { color: '#c2a20f', fillOpacity: 0.16 },
critical: { color: '#d8473f', fillOpacity: 0.12 },
},
},
};
}

/** CPU utilisation banded into healthy / warning / critical — the zone set
* turns the y axis into a scale you can read a verdict off. The top band is
* open-ended (`to={Infinity}`), so it reaches the plot edge without an
* invented ceiling. */
const BANDS = [
{ role: 'healthy', from: 0, to: 0.6, label: 'healthy' },
{ role: 'warning', from: 0.6, to: 0.85, label: 'warning' },
{ role: 'critical', from: 0.85, to: Infinity, label: 'critical' },
];

/** A `<Zone>` set — shaded **y** spans, the value-axis counterpart of
* `<Region>`. Inert background context by default: no boundary lines, no
* pointer response, so the traces read over an unbroken wash of colour. */
export default function ChartsAnnotationZone({ width }: { width: number }) {
const theme = bandedTheme(useSiteChartTheme());
const series = singleHostSeries();
// Pan/zoom the time axis; the bands don't move, because they're anchored to
// the *value* axis — which is the point of them.
const range = series.timeRange()!;

return (
<ChartContainer
range={range}
width={width}
theme={theme}
panZoom
bounds={[range.begin(), range.end()]}
minDuration={5 * 60 * 1000}
>
<ChartRow height={200}>
<YAxis id="pct" side="right" format=".0%" min={0} max={1} />
<Layers>
{BANDS.map((b) => (
<Zone
key={b.role}
from={b.from}
to={b.to}
axis="pct"
role={b.role}
label={b.label}
/>
))}
<LineChart series={series} column="cpu" axis="pct" />
</Layers>
</ChartRow>
</ChartContainer>
);
}

Drag to pan the time axis and scroll to zoom — the bands stay put, because they're anchored to the value axis, which is the whole point of them.

<Zone from={0.85} to={Infinity} axis="pct" role="critical" label="critical" />

Props

PropTypeDefaultPurpose
fromnumberOne bound in the linked y axis's units.
tonumberThe other bound. Order doesn't matter; either may be ±Infinity (open-ended).
axisstringrow's defaultWhich <YAxis> (by id) to scale against.
labelstringnoneChip at the band's vertical centre. Omit ⇒ no chip (a zone never auto-labels).
labelSide'left' | 'right''left'Which edge the chip sits against.
edgesbooleanfalseDraw the horizontal boundary lines at from/to.
rolestringTheme role — annotation.roles[role] supplies color / fillOpacity / dash.
selectablebooleanfalseRespond to hover + click-select.
selectedbooleanfalseControlled selection (brightens to the front). Needs selectable.
hoveredbooleanControlled hover, OR'd with pointer hover. Needs selectable.
idstringStable id reported by onSelectAnnotation. Needs selectable.

Three defaults that invert the family's

Zone deliberately departs from Region / Baseline / Marker on its defaults, because a y-band is a different animal: it spans the full plot width, and a zone set tiles the whole row.

  • selectable={false} — the rest of the register is interactive by default. Since the pointer is always inside some zone, interactive-by-default bands would light up on every mousemove, and their hit areas would swallow the plot's own clicks. A zone is background context first (drawn at depth level 3, pointer-transparent). Opt one in when a band is genuinely a thing to point at.
  • edges={false}Region outlines its span by default. Zone sets are usually contiguous, so every interior boundary is shared by two bands and edges-on draws each one twice at double opacity. Turn it on for an isolated band (a target range), where an outline reads well.
  • No auto-label. Region labels itself from–to because a time span's bounds aren't otherwise legible; a zone's bounds are already written down the y axis it spans. The label worth showing is a name ("Good", "Z4 threshold"), which only you have — so omitting label renders no chip.

Bounds: ordering, clamping, open ends

  • Order-free. from/to are ordered internally; high-to-low is the same band.
  • Clamped to the plot. A band running past the axis domain is cut at the plot edge rather than painting into the axis gutter, and a band entirely outside culls itself. This means you can render a whole category table and let the axis decide which bands are visible, instead of maintaining a hand-pruned copy that goes stale when the axis changes.
  • ±Infinity for open ends. to={Infinity} is the honest spelling of a top band with no ceiling (AQI's "Hazardous", a "critical and above" alert band). It resolves against the axis domain, reaches the plot edge, and — having no real upper bound — draws no upper boundary even with edges on. The same goes for a clamped bound: a boundary line is only drawn where the band has a real, in-plot edge.

Colour: the role is the scale

There is no colour prop. A band names a role and the theme resolves it — which is exactly right here, because a zone set's colours aren't six arbitrary choices, they're a scale, and a scale belongs in one place:

const theme = {
...base,
annotation: {
...base.annotation!,
roles: {
good: { color: '#00e400', fillOpacity: 0.16 },
moderate: { color: '#ffff00', fillOpacity: 0.22 },
unhealthy: { color: '#ff0000', fillOpacity: 0.12 },
},
},
};

Keep the fills light. A zone paints in the annotation overlay above the data canvas (as every mark does), so the register's fillOpacity — around 0.1–0.2 — is what lets the traces read cleanly through it. Per-role opacities usually aren't uniform, because the hues aren't equally strong: pure yellow needs more alpha than red to register at all.

Sharp edges

  • A zone is not editable. Unlike the other three marks it has no onChange. A drag-to-edit zone (a zone editor) is a coherent feature with no consumer yet, so the band stays declarative until one turns up.
  • The fill sits over the data, not under it. At the intended opacities this reads as a wash behind the story; at high opacity it will visibly tint the traces. That's a property of the shared annotation overlay, not of Zone.
  • A selectable zone takes the plot's clicks in its band, the same way a Region does in its span. This is why selectable is opt-in.
  • No cross-row guide. Like Baseline, a horizontal mark casts no vertical guideline on other rows, and is not a drag-snap target.

See also