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.
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
| Prop | Type | Default | Purpose |
|---|---|---|---|
from | number | — | One bound in the linked y axis's units. |
to | number | — | The other bound. Order doesn't matter; either may be ±Infinity (open-ended). |
axis | string | row's default | Which <YAxis> (by id) to scale against. |
label | string | none | Chip at the band's vertical centre. Omit ⇒ no chip (a zone never auto-labels). |
labelSide | 'left' | 'right' | 'left' | Which edge the chip sits against. |
edges | boolean | false | Draw the horizontal boundary lines at from/to. |
role | string | — | Theme role — annotation.roles[role] supplies color / fillOpacity / dash. |
selectable | boolean | false | Respond to hover + click-select. |
selected | boolean | false | Controlled selection (brightens to the front). Needs selectable. |
hovered | boolean | — | Controlled hover, OR'd with pointer hover. Needs selectable. |
id | string | — | 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}—Regionoutlines 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.
Regionlabels itselffrom–tobecause 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 omittinglabelrenders no chip.
Bounds: ordering, clamping, open ends
- Order-free.
from/toare 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.
±Infinityfor 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 withedgeson. 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
Regiondoes in its span. This is whyselectableis 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
- The annotation model — registers, roles, depth.
- Region · Baseline · Marker — the other three marks.
- From a CSV to a banded chart — a worked end-to-end build: an air-quality export, the EPA category bands, and why the palette is a theme.
- Storybook:
Annotations/Zone— one story per prop and state (clamping, open ends, dual axis, the tiled set).