Skip to main content

Pan, zoom & range selection

Three related mechanisms move the view range — the slice of the x axis a chart shows. They compose but are independent: drag-to-select a range, wire that into a controlled zoom, and/or enable free pan/zoom. All of them speak the same neutral range contract: a [start, end] pair in axis units.

Chapter 6 teaches the select-to-zoom loop as a payoff; this page is the full prop reference. See Learn charts, chapter 6 for the narrative and the Cursors/Region Storybook group for the per-variant walk.

Range selection — cursor="region"

Setting cursor="region" and providing onRegionSelect makes the region cursor draggable: the user drags across the plot and, on release, you get the selected range once.

cursor="region"
cursorSequence?: Sequence | BoundedSequence; // bucket snapping (time axis only)
onRegionSelect?: (range: readonly [number, number]) => void; // [lo, hi] on release
  • onRegionSelect fires once, on release, with [lo, hi] — always ordered low-to-high regardless of drag direction. Providing it is what turns the region cursor draggable; it's a notification — the container never zooms itself in response.
  • cursorSequence snaps the shaded band to buckets (whole minutes, whole sessions) as the drag extends, and is time-axis only — a "minute" is meaningless on a value axis. With no cursorSequence the region cursor is the degenerate free-drag case (pixel-precise, no snapping), which is what a value-axis selection uses.
  • Range selection works on a time or value x axis; it's excluded on a category axis.

The select-to-zoom loop

Zooming is onRegionSelect → your state → controlled range. The container draws whatever range you give it; the selection callback proposes a new one; you decide whether to apply it. A "reset" is just setting range back to the full extent.

const [range, setRange] = useState(full);

<ChartContainer
range={range}
cursor="region"
onRegionSelect={(r) => setRange(r)}
>
{/* … */}
</ChartContainer>;
src/examples/learn-06-zoom.tsx
import { useState } from 'react';
import {
ChartContainer,
ChartRow,
Layers,
LineChart,
YAxis,
} from '@pond-ts/charts';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
import { singleHostSeries } from './lib/server-metrics';

export default function LearnZoom() {
const theme = useSiteChartTheme();
const series = singleHostSeries();
// singleHostSeries() always returns a non-empty, fixed-length series, so
// timeRange() is never undefined here.
const bounds = series.timeRange()!;
const fullRange: readonly [number, number] = [bounds.begin(), bounds.end()];
const [range, setRange] = useState<readonly [number, number]>(fullRange);
const zoomed = range[0] !== fullRange[0] || range[1] !== fullRange[1];

return (
<div>
<div style={{ marginBottom: 10 }}>
<button
onClick={() => setRange(fullRange)}
disabled={!zoomed}
style={{
padding: '4px 12px',
borderRadius: 6,
border: '1px solid var(--site-surface-border)',
background: 'transparent',
cursor: zoomed ? 'pointer' : 'default',
opacity: zoomed ? 1 : 0.5,
fontSize: 13,
}}
>
← Reset zoom
</button>
<span style={{ marginLeft: 10, fontSize: 12, opacity: 0.7 }}>
drag on the chart to select a range
</span>
</div>
<ChartContainer
range={range}
width={560}
theme={theme}
cursor="region"
onRegionSelect={(r) => setRange(r)}
>
<ChartRow height={200}>
<YAxis id="pct" side="right" format=".0%" />
<Layers>
<LineChart series={series} column="cpu" axis="pct" />
</Layers>
</ChartRow>
</ChartContainer>
</div>
);
}

Because the container never zooms on its own, the same onRegionSelect can drive anything a range means in your app — a filter, a fetch, a linked chart — not only a zoom.

Free pan & zoom — panZoom

Separately from range-select, panZoom enables direct manipulation: drag the plot to pan, wheel to zoom around the cursor.

PropTypeDefaultPurpose
panZoombooleanfalseDrag-to-pan + wheel-to-zoom. Off by default so it doesn't capture drag/scroll unless asked.
onTimeRangeChange([start, end]) => voidFires on pan/zoom with the new range. Wire back to range for controlled; omit for uncontrolled.
minDurationnumber1Zoom-in floor — the minimum visible duration in ms.
range[number, number] | TimeRangeThe controlled view range (shared by all three mechanisms).

Controlled vs. uncontrolled matters here. Uncontrolled (panZoom on, no onTimeRangeChange), the container holds the view internally and seeds it from range whenever it isn't actively holding one — but later range changes are then ignored so they can't fight the user's pan. To drive the range externally — or to follow a live sliding window — use controlled mode: handle onTimeRangeChange and feed range yourself.

Sharp edges

  • The container never zooms itself. onRegionSelect and onTimeRangeChange are notifications; a controlled range is the only thing that actually changes what's shown. This is deliberate — a range means different things in different apps.
  • cursorSequence is time-axis only. Bucket snapping needs a temporal grid; value-axis and category selections don't take one.
  • Uncontrolled panZoom stops tracking range once the user grabs it — if you need the view to follow live data, go controlled.
  • Mouse-only — pan is drag, zoom is wheel; no keyboard or touch gestures yet.

See also