Reading and selecting values: cursors, readouts, zoom
- The five cursor components, and when to reach for each
onTrackerChanged— reading the hovered value outside the chart- The select-to-zoom loop:
<RangeCursor onDragRelease>→range
Every chart in this track has mounted a <LineCursor /> — the synced
vertical line you've been hovering since chapter 1. A cursor is a component
you mount inside <ChartContainer>: mount one to get a cursor, mount none for
no cursor. This chapter is the rest of that family.
Five cursors, one slot
import { useState } from 'react';
import {
ChartContainer,
ChartRow,
Layers,
LineChart,
YAxis,
LineCursor,
PointCursor,
InlineCursor,
FlagCursor,
CrosshairCursor,
} from '@pond-ts/charts';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
import { singleHostSeries } from './lib/server-metrics';
// One cursor component per mode — mount one to get that cursor.
const CURSORS = {
line: <LineCursor />,
point: <PointCursor />,
inline: <InlineCursor />,
flag: <FlagCursor />,
crosshair: <CrosshairCursor />,
} as const;
type CursorMode = keyof typeof CURSORS;
const MODES = Object.keys(CURSORS) as CursorMode[];
export default function LearnCursorModes() {
const theme = useSiteChartTheme();
const series = singleHostSeries();
const [mode, setMode] = useState<CursorMode>('line');
return (
<div>
<div
style={{ display: 'flex', gap: 6, marginBottom: 10, flexWrap: 'wrap' }}
>
{MODES.map((m) => (
<button
key={m}
onClick={() => setMode(m)}
style={{
padding: '4px 10px',
borderRadius: 6,
border: '1px solid var(--site-surface-border)',
background:
m === mode ? 'var(--ifm-color-primary)' : 'transparent',
color: m === mode ? '#fff' : 'inherit',
cursor: 'pointer',
fontSize: 13,
}}
>
{m}
</button>
))}
</div>
<ChartContainer range={series.timeRange()} width={560} theme={theme}>
{CURSORS[mode]}
<ChartRow height={220}>
<YAxis id="pct" side="right" format=".0%" />
<Layers>
<LineChart series={series} column="cpu" axis="pct" />
</Layers>
</ChartRow>
</ChartContainer>
</div>
);
}
<ChartContainer>
{/* or LineCursor | PointCursor | InlineCursor | FlagCursor */}
<CrosshairCursor />
{/* rows */}
</ChartContainer>
<LineCursor />— a synced vertical line across every row.<PointCursor />— a dot on the series itself, no line.<InlineCursor />— an in-chart value readout drawn near the cursor.<FlagCursor />— an in-chart readout in a flag-shaped chip.<CrosshairCursor />— a vertical and horizontal line, with axis-pill readouts on both axes. Snaps to the nearest data point by default (<CrosshairCursor snap={false} />for a free reticle).
Mount nothing and the chart has no cursor. There's a sixth component,
<RangeCursor /> — that one is this chapter's payoff, below.
Reading the value outside the chart
onTrackerChanged fires with the hovered value(s) — or null when the
cursor leaves — so you can drive a readout that lives anywhere on the
page, not just inside the chart:
import { useState } from 'react';
import {
ChartContainer,
ChartRow,
Layers,
LineChart,
YAxis,
type TrackerInfo,
LineCursor,
} from '@pond-ts/charts';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
import { singleHostSeries } from './lib/server-metrics';
export default function LearnTrackerReadout() {
const theme = useSiteChartTheme();
const series = singleHostSeries();
const [info, setInfo] = useState<TrackerInfo | null>(null);
return (
<div>
<div
style={{
marginBottom: 10,
fontSize: 13,
fontFamily: 'ui-monospace, monospace',
minHeight: 20,
}}
>
{info === null
? 'hover the chart →'
: info.values.map((v) => (
<span key={v.label} style={{ color: v.color, marginRight: 16 }}>
{v.label}: {(v.value * 100).toFixed(1)}%
</span>
))}
</div>
<ChartContainer
range={series.timeRange()}
width={560}
theme={theme}
onTrackerChanged={setInfo}
>
<LineCursor />
<ChartRow height={200}>
<YAxis id="pct" side="right" format=".0%" />
<Layers>
<LineChart series={series} column="cpu" axis="pct" />
</Layers>
</ChartRow>
</ChartContainer>
</div>
);
}
const [info, setInfo] = useState<TrackerInfo | null>(null);
<ChartContainer onTrackerChanged={setInfo}>
<LineCursor />
{/* rows */}
</ChartContainer>;
{
info === null ? 'hover the chart' : info.values.map((v) => v.value);
}
TrackerInfo carries time plus one TrackerSample (x, value,
color, label) per series under the cursor — the same data every
in-chart readout draws from, just handed to you instead. showTime
(a boolean on the cursor component — <FlagCursor showTime />) shows the
cursor's time atop an in-chart flag/inline readout, if you want it there too. For programmatic
control rather than mouse-driven — highlighting a specific instant from
outside, say a table row hover — a controlled trackerPosition (epoch
ms) drives the same cursor without a real pointer event. It's a
followed position a live hover on the chart overrides, so it also
powers cross-chart cursor sync (see
Cursors & readouts);
omit it or pass null for no controlled position.
The payoff: select-to-zoom
<RangeCursor /> is a drag-to-select cursor. Give it an
onDragRelease and a bit of state, and you have the whole zoom loop in
about a dozen lines:
import { useState } from 'react';
import {
ChartContainer,
ChartRow,
Layers,
LineChart,
YAxis,
RangeCursor,
} 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}>
<RangeCursor onDragRelease={(span) => setRange(span.x)} />
<ChartRow height={200}>
<YAxis id="pct" side="right" format=".0%" />
<Layers>
<LineChart series={series} column="cpu" axis="pct" />
</Layers>
</ChartRow>
</ChartContainer>
</div>
);
}
const fullRange = series.timeRange();
const [range, setRange] = useState(fullRange);
<ChartContainer range={range}>
<RangeCursor onDragRelease={(span) => setRange(span.x)} />
{/* rows */}
</ChartContainer>;
<button onClick={() => setRange(fullRange)}>Reset zoom</button>;
onDragRelease hands back { x: [lo, hi] } — the container
never zooms itself, your setRange closes the loop by feeding the
selection's x straight back into range. A "reset" button is just
setRange(fullRange). This is the same range prop every chart in this
track has passed a static value to since chapter 1 — controlling it is
nothing new, just a new source for the value.
Two drags that look alike
Worth separating now, because they use the same gesture and you will meet both:
<RangeCursor> drags out a time range — a view concern, which is why it
pairs with setRange. A <MultiSelector> drag looks identical but selects the
data marks inside a range instead, and hands you those.
Same gesture, same drag band on screen (one renderer, so they can't drift), two different questions. This chapter is the range one. The marks one is reference-only: selection & hover and sweeps & multi-select.
One conflict to know about: <RangeCursor dragModifier="shift"> claims the shift
chord, so if you later want shift to mean something in a selection handler on the
same chart, decide which one owns it.
Recap
Five cursor components cover reading a hovered value in five different visual
styles; onTrackerChanged moves that same reading outside the chart;
<RangeCursor> is a drag-to-select cursor, and its onDragRelease + a
controlled range is the whole zoom loop.
Next: Marking up charts — placing a mark on a chart, rather than just reading what's there.