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,
LineCursor,
} 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}
>
<LineCursor />
<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
fromnumber—One bound in the linked y axis's units.
tonumber—The 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.
rolestring—Theme role — annotation.roles[role] supplies color / fillOpacity / dash.
selectablebooleanfalseRespond to hover + click-select.
selectedbooleanfalseControlled selection (brightens to the front). Needs selectable.
hoveredboolean—Controlled hover, OR'd with pointer hover. Needs selectable.
idstring—Stable 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​