Synchronized charts with zoom, pan and presets
Two years of daily prices and volumes as two charts that move as one: hover either for a shared crosshair, Ctrl + wheel or pinch to zoom around the pointer, Shift + drag or the hand button to pan, 1W / 1M / 3M / 6M / YTD / 1Y / All presets, and a grid that shows exactly the rows in the window. Right-click for a context menu with export, zoom and series items plus your own. Below: bars that grow in and slide on every data change with multi-selection, and a sunburst that drills down on click with a breadcrumb back up.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Charts). See the SvGrid documentation for the full API.
About this example
Two years of daily prices and volumes as two Svelte 5 charts that move as one. A shared syncGroup gives them one crosshair and one zoom window; Ctrl + wheel or a pinch zooms around the pointer, Shift + drag or the hand button pans, and 1W, 1M, 3M, 6M, YTD, 1Y and All presets jump. The zoom window is a bindable prop, so the grid under the charts shows exactly the rows in it. Right-click opens a context menu with export, zoom and series items plus your own. Below, bars that grow in and slide on every data change with multi-selection, and a sunburst that drills down on click with a breadcrumb back up. Free, in @svgrid/grid.
Two years of daily prices and volumes, drawn as two charts that behave as one:
syncGroup="ticker"on both. Hover either and both show the crosshair; zoom either and both follow. Nothing else is wired between them.zoomable={{ wheel: 'modifier', pinch: true, pan: true }}: Ctrl + wheel zooms around the pointer, a pinch does the same on touch, Shift + drag (or the hand button) pans a zoomed chart, a plain drag still rubber-bands.rangePresetsputs 1W / 1M / 3M / 6M / YTD / 1Y / All in the toolbar.bind:zoomhands the window back as category indices, and the grid under the charts shows exactly the rows in it.contextMenuadds a right-click menu with the built-in export / zoom / series items plus one of ours, built from the clicked day.animate={{ enter: 'wipe' }}reveals the price line left to right.tooltipPosition="top-right"on the volume chart parks its tooltip in the corner so it never covers the bar being read, andtooltipStickypins it on a click (Escape or a click outside lets go).
Below: a bar chart whose data reshuffles with a grow enter and a tween on every update, with multi-selection; and a sunburst that drills down on click with a breadcrumb back up.
Free, in @svgrid/grid.
Imports, features and API used
Imports: @svgrid/grid
Table features registered: rowSortingFeature
Columns: date (Date), close (Close), change (Change), volume (Volume)
Frequently asked questions
How do two charts share a crosshair?
Give both the same syncGroup name. Categories are matched by label first, then by date when both parse, so a daily and a weekly chart still meet at the right week. There is nothing else to wire.
Why does a plain wheel scroll the page?
zoomable.wheel is set to modifier, so the chart only takes the wheel with Ctrl or Cmd held. Set it to true and the chart takes every wheel event over the plot.
Is the zoom window a pixel range?
No, it is a pair of category indices into the full spec, i0 and i1, or null for everything. That is why the grid can slice its rows from it and why the window survives a resize.
Related documentation
Source code (436-chart-sync-zoom.svelte)
<!-- Documented in: docs/help/charts/interaction.md -->
<script lang="ts">
/**
* 436. Synchronized charts with zoom, pan and presets
* ----------------------------------------------------
* Two years of daily prices and volumes, drawn as two charts that behave as
* one:
*
* - `syncGroup="ticker"` on both. Hover either and both show the crosshair;
* zoom either and both follow. Nothing else is wired between them.
* - `zoomable={{ wheel: 'modifier', pinch: true, pan: true }}`: Ctrl + wheel
* zooms around the pointer, a pinch does the same on touch, Shift + drag
* (or the hand button) pans a zoomed chart, a plain drag still rubber-bands.
* - `rangePresets` puts 1W / 1M / 3M / 6M / YTD / 1Y / All in the toolbar.
* - `bind:zoom` hands the window back as category indices, and the grid
* under the charts shows exactly the rows in it.
* - `contextMenu` adds a right-click menu with the built-in export / zoom /
* series items plus one of ours, built from the clicked day.
* - `animate={{ enter: 'wipe' }}` reveals the price line left to right.
* - `tooltipPosition="top-right"` on the volume chart parks its tooltip in
* the corner so it never covers the bar being read, and `tooltipSticky`
* pins it on a click (Escape or a click outside lets go).
*
* Below: a bar chart whose data reshuffles with a `grow` enter and a tween
* on every update, with multi-selection; and a sunburst that drills down on
* click with a breadcrumb back up.
*
* Free, in @svgrid/grid.
*/
import {
SvChart, SvGrid, tableFeatures, rowSortingFeature,
type ChartPointRef, type ChartSpec, type ChartZoomWindow, type GridColumns,
} from '@svgrid/grid'
const features = tableFeatures({ rowSortingFeature })
// ---- Two years of daily data, seeded so every load draws the same chart.
type Day = { date: string; close: number; volume: number; change: number }
const days: Day[] = (() => {
let seed = 11
const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648)
const start = Date.UTC(2024, 8, 12)
const out: Day[] = []
let price = 148
for (let i = 0; i < 730; i += 1) {
const prev = price
price = Math.max(40, price + (rnd() - 0.48) * 4 + Math.sin(i / 60) * 0.6)
out.push({
date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
close: Math.round(price * 100) / 100,
volume: Math.round(600 + rnd() * 900 + Math.abs(price - prev) * 400),
change: Math.round((price - prev) * 100) / 100,
})
}
return out
})()
let zoom = $state<ChartZoomWindow | null>(null)
let note = $state('Right-click a day for the menu; Ctrl + wheel to zoom, Shift + drag to pan.')
const price = $derived<ChartSpec>({
type: 'line',
title: 'ACME daily close',
categories: days.map((d) => d.date),
series: [{ label: 'Close', values: days.map((d) => d.close), marker: 'none', strokeWidth: 1.5, gradient: true, type: 'area' }],
xType: 'ordinal-time',
yAxis: { formatter: (v) => `$${v}` },
height: 260,
})
const volume = $derived<ChartSpec>({
type: 'bar',
categories: days.map((d) => d.date),
series: [{ label: 'Volume', values: days.map((d) => d.volume), colors: days.map((d) => (d.change < 0 ? '#ef4444' : '#16a34a')) }],
xType: 'ordinal-time',
yAxis: { formatter: (v) => `${(v / 1000).toFixed(1)}k` },
height: 150,
})
// The grid shows the rows in the window, newest first.
const visibleRows = $derived(zoom ? days.slice(zoom.i0, zoom.i1 + 1) : days)
const columns: GridColumns<Day> = [
{ field: 'date', header: 'Date', width: 110 },
{ field: 'close', header: 'Close', width: 90, align: 'right', cell: (c) => `$${(c.getValue() as number).toFixed(2)}` },
{ field: 'change', header: 'Change', width: 90, align: 'right', cell: (c) => { const v = c.getValue() as number; return `${v > 0 ? '+' : ''}${v.toFixed(2)}` } },
{ field: 'volume', header: 'Volume', width: 90, align: 'right' },
]
// ---- Animated, selectable bars.
const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
let orders = $state([42, 55, 61, 48, 70, 33, 21])
let picked = $state<ChartPointRef[]>([])
const shuffle = () => (orders = orders.map((v) => Math.max(8, Math.round(v + (Math.random() - 0.5) * 40))))
const ordersSpec = $derived<ChartSpec>({
type: 'bar',
categories: weekdays,
series: [{ label: 'Orders', values: orders }],
height: 220,
})
// ---- Drillable sunburst.
const salesTree = {
name: 'Sales',
children: [
{ name: 'EMEA', children: [
{ name: 'UK', children: [{ name: 'London', value: 62 }, { name: 'Manchester', value: 21 }] },
{ name: 'DE', value: 88 }, { name: 'FR', value: 54 }, { name: 'ES', value: 31 },
] },
{ name: 'Americas', children: [
{ name: 'US', children: [{ name: 'East', value: 120 }, { name: 'West', value: 96 }, { name: 'Central', value: 44 }] },
{ name: 'CA', value: 38 }, { name: 'BR', value: 27 },
] },
{ name: 'APAC', children: [{ name: 'JP', value: 71 }, { name: 'AU', value: 33 }, { name: 'SG', value: 19 }] },
],
}
let drillPath = $state<string[]>([])
const sunburst: ChartSpec = { type: 'sunburst', categories: [], series: [], tree: salesTree, height: 300 }
</script>
<section class="wrap">
<header class="chrome">
<span class="note">{note}</span>
</header>
<div class="body">
<div class="pane pane-charts">
<div class="demo-price">
<SvChart
spec={price}
syncGroup="ticker"
zoomable={{ wheel: 'modifier', pinch: true, pan: true }}
rangePresets
bind:zoom
legend={false}
animate={{ enter: 'wipe' }}
contextMenu={(at) => [
{
label: at.category ? `Explain ${at.category}` : 'Explain this chart',
onSelect: () => {
const d = at.index != null ? days[at.index] : null
note = d ? `${d.date}: closed at $${d.close} (${d.change > 0 ? '+' : ''}${d.change}) on ${d.volume} shares.` : 'Two years of daily closes.'
},
},
]}
/>
</div>
<div class="demo-volume">
<SvChart spec={volume} syncGroup="ticker" zoomable legend={false} toolbar={false} tooltipPosition="top-right" tooltipSticky />
</div>
</div>
<div class="grid-host">
<div class="grid-title">
{visibleRows.length} of {days.length} days
{#if zoom}<span class="muted">({days[zoom.i0]?.date} to {days[zoom.i1]?.date})</span>{/if}
</div>
<SvGrid data={visibleRows} {columns} {features} sortable rowHeight={26} containerHeight="100%" fitColumns responsive />
</div>
</div>
<div class="row">
<div class="pane demo-orders">
<div class="bar">
<button type="button" class="btn" onclick={shuffle}>New data</button>
<span class="note">
{picked.length ? `Selected: ${picked.map((p) => p.category).join(', ')}` : 'Bars grow in, slide on every update, and select on click (Ctrl adds).'}
</span>
</div>
<SvChart spec={ordersSpec} animate={{ enter: 'grow', duration: 600 }} selectable="multi" bind:selected={picked} legend={false} />
</div>
<div class="pane demo-sunburst">
<div class="bar">
<span class="note">
{drillPath.length ? `Drilled into ${drillPath.join(' / ')}` : 'Click a region to drill in; the breadcrumb climbs back.'}
</span>
</div>
<SvChart spec={sunburst} drillable bind:drillPath legend={false} />
</div>
</div>
</section>
<style>
.wrap {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
gap: 10px;
overflow: auto;
}
.chrome {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
flex: none;
}
.note,
.muted {
font-size: 12px;
color: var(--sg-muted, #64748b);
}
.body,
.row {
display: flex;
flex: none;
gap: 12px;
min-height: 0;
}
.pane {
border: 1px solid var(--sg-border, #e2e8f0);
border-radius: 10px;
background: var(--sg-bg, #fff);
padding: 8px 10px;
min-width: 0;
}
.pane-charts {
flex: 1 1 62%;
}
.grid-host {
flex: 1 1 38%;
min-width: 0;
min-height: 260px;
display: flex;
flex-direction: column;
}
.grid-title {
font-size: 12px;
font-weight: 600;
color: var(--sg-fg, #0f172a);
padding: 0 0 6px;
}
.row > .pane {
flex: 1 1 50%;
}
.bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.btn {
font: inherit;
font-size: 12px;
border: 1px solid var(--sg-border, #e2e8f0);
border-radius: 6px;
background: var(--sg-bg, #fff);
color: var(--sg-fg, #0f172a);
padding: 3px 10px;
cursor: pointer;
}
@media (max-width: 720px) {
.body,
.row {
flex-direction: column;
}
}
</style>More Charts examples
- Chart a selection (context menu) - With integrated charting enabled, the right-click menu gains a Chart selected range item: select a block of cells, right-click, and the chart panel opens scoped to that range - the Excel chart-this gesture, built in. The item appears only when charting is on and is appended to the default context menu automatically.
- Chart view of the grid - The `chart` prop turns the same <SvGrid> into a chart, driven by the grid’s filtered + sorted rows (search + sort flow through). A view of the grid like board and scheduler, but the renderer is free: the grid lazy-loads a built-in view wrapping the standalone SvChart via rowsToChartSpec. Flip Table <-> Chart (bar / line / area) over one source of truth.
- Candlestick / OHLC - Candlestick and OHLC price marks with an ordinal date axis. ChartSeries.ohlc carries the four prices while values keeps the closes, so the CSV export, the screen-reader table and a 10-day moving average all work with no candle-specific code. Toggle the axis: a real time axis opens a gap over every weekend, an ordinal one spaces sessions evenly and still labels them by date. The strip under the plot is the chart brush: drag its window to pan, drag an edge to resize.
- Box plot + error bars - Distribution rather than average: box plots with the 1.5 IQR whisker rule and individual outliers, next to the same data as a bar chart of the means with error bars. two regions with nearly identical means sit side by side in the bars and look nothing alike in the boxes. boxStats() summarises a raw sample, rowsToBoxSpec() does it per group, and ChartSeries.errors annotates any existing mark without changing its type.
- Axes, titles and styling - The chart hands the decisions back: a numeric x axis that spreads payload sizes the way the numbers do, pinned domains with a fixed tick interval and a formatter per axis, grid lines on or off, a shaded budget band, a vertical reference line, per-point markers and colours, a stepped dashed series on the right axis, a title / subtitle / caption, a legend on any side, and a tooltip snippet in single-series mode. Below it, 50,000 monitoring readings decimated to one point per pixel with LTTB or min / max - toggle it off to see the cost.