Chart zoom + brush mini-map

Drag a rectangle over a 180-day series to zoom in; double-click resets. A compact brush below shows the full range with a draggable window - drag the body to pan, edges to resize. Pairs with the crosshair tooltip + PNG/SVG export.

A live, editable Svelte 5 data grid example from the SvGrid gallery (Charts). See the SvGrid documentation for the full API.

About this example

Zoom and a brush mini-map on a Svelte 5 chart. zoomable lets the user drag a rectangle over a 180-day series to zoom in with double-click to reset, and brush adds a compact overview below the plot with a draggable window: drag the body to pan and an edge to resize, with brushHeight setting its size. The two stay in sync through the chart's zoom state, and a crosshair tooltip and PNG or SVG export complete the surface.

Long time-series get unreadable when every point fights for the same horizontal pixels. zoomable lets the user drag a rect over the plot area to zoom in (double-click resets). brush adds a compact mini-map below the chart with a draggable window - drag the window body to pan, drag either edge to resize. The two interactions stay in sync via the chart's internal zoom state. Crosshair tooltip + PNG/SVG export round out the interactive surface.

Imports, features and API used

Imports: @svgrid/grid

Table features registered: rowSortingFeature, columnFilteringFeature

Columns: day (Day), sessions (Sessions), revenue (Revenue)

SvGridApi methods called: api.getDisplayedRows()

Frequently asked questions

How do I zoom?

With zoomable on, drag a rectangle over the plot area; the axes rescale to that range. Double-click anywhere in the plot to reset.

How does the brush relate to zoom?

The brush shows the full series with a window that mirrors the zoomed range; moving or resizing the window zooms the main plot, and zooming the plot moves the window.

Does the brush work with the grid filter?

Yes. The series comes from the displayed rows, so filtering the grid shortens the series in both the plot and the brush.

Related documentation

Related articles

  • Sparkline Cells in a Svelte Data Grid - Show inline trend sparklines inside grid cells using SvGrid's built-in sparkline column property - no charting library needed, just a field that holds a number array.

Source code (153-chart-zoom-brush.svelte)

<!-- Documented in: docs/help/charts/interaction.md -->
<script lang="ts">
  /**
   * 153. Chart zoom + brush (mini-map)
   * ----------------------------------
   * Long time-series get unreadable when every point fights for the same
   * horizontal pixels. `zoomable` lets the user drag a rect over the plot
   * area to zoom in (double-click resets). `brush` adds a compact mini-map
   * below the chart with a draggable window - drag the window body to pan,
   * drag either edge to resize. The two interactions stay in sync via the
   * chart's internal zoom state. Crosshair tooltip + PNG/SVG export round
   * out the interactive surface.
   */
  import {
    SvGrid,
    SvGridChart,
    rowsToChartSpec,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    type GridColumns,
    type SvGridApi,
    type ChartSpec,
  } from '@svgrid/grid'

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })

  type Row = { id: number; day: string; revenue: number; sessions: number }
  // 180 days of synthetic web traffic so zoom + brush actually earn their keep.
  let seed = 0xabc123
  const rnd = () => ((seed = (seed * 1103515245 + 12345) >>> 0) / 0xffffffff)
  const rows: Row[] = Array.from({ length: 180 }, (_, i) => {
    const d = new Date(2026, 0, 1 + i)
    const trend = 1200 + i * 6                  // slow climb
    const weekly = Math.sin((i / 7) * 2 * Math.PI) * 180   // weekly cycle
    const noise = (rnd() - 0.5) * 240
    const sessions = Math.max(200, Math.round(trend + weekly + noise))
    return {
      id: i,
      day: d.toISOString().slice(0, 10),
      sessions,
      revenue: Math.round(sessions * (8 + rnd() * 4)),
    }
  })

  const columns: GridColumns<Row> = [
    { field: 'day', header: 'Day', width: 130 },
    { field: 'sessions', header: 'Sessions', width: 130, align: 'right' },
    { field: 'revenue', header: 'Revenue', width: 140, align: 'right',
      format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },
  ]

  let api = $state<SvGridApi<typeof features, Row> | null>(null)
  let displayed = $state<Row[]>(rows)
  let metric = $state<'sessions' | 'revenue'>('sessions')
  function sync() { displayed = (api?.getDisplayedRows() as Row[]) ?? rows }

  const compact = (v: number) => (Math.abs(v) >= 1e6 ? (v / 1e6).toFixed(1) + 'M' : Math.abs(v) >= 1e3 ? (v / 1e3).toFixed(v % 1e3 ? 1 : 0) + 'k' : String(Math.round(v)))

  const spec = $derived.by<ChartSpec>(() => {
    const s = rowsToChartSpec(displayed, {
      type: 'line',
      category: 'day',
      value: metric,
      reduce: 'sum',
      width: 720,
      height: 320,
    })
    s.xType = 'time'
    s.xAxisTitle = 'Day'
    s.yAxisTitle = metric === 'revenue' ? 'Revenue (USD)' : 'Sessions'
    return s
  })
  /** Pane size, so the chart fills its card rather than a fixed viewBox. */
  let paneW = $state(0)
  let paneH = $state(0)
</script>

<section class="flex flex-col flex-1 min-h-0 gap-3">
  <div class="shrink-0 rounded-lg border px-4 py-3" style="border-color: var(--sg-border); background: var(--sg-header-bg);">
    <p class="text-sm font-semibold" style="color: var(--sg-fg);">
      Drag-rect zoom + brush mini-map for long time-series
    </p>
    <p class="mt-0.5 text-xs" style="color: var(--sg-muted);">
      Drag a rectangle over the chart to zoom in. Double-click to reset. The brush below shows
      the full series with a draggable window - drag the body to pan, drag the edges to resize.
    </p>
    <div class="mt-2 flex flex-wrap items-center gap-2 text-xs">
      <select bind:value={metric} class="ic-sel">
        <option value="sessions">Sessions</option>
        <option value="revenue">Revenue</option>
      </select>
    </div>
  </div>

  <div class="flex flex-1 min-h-0 gap-3">
    <div class="flex-1 min-w-0 min-h-0">
      <SvGrid responsive={true}
      columnResize
        data={rows}
        columns={columns}
        features={features}
        sortable
        filterable
        selectionMode="none"
        rowHeight={28}
        containerHeight="100%"
        fitColumns={true}
        onApiReady={(a) => { api = a; sync() }}
        onFiltersChange={sync}
        onSortingChange={sync}
      />
    </div>
    <div class="rounded-lg border p-3" style="flex: 0 1 760px; min-width: 0; min-height: 0; border-color: var(--sg-border); background: var(--sg-bg);">
      <!-- Measured box, not the card: its height comes from the parent, so the
           chart cannot push the thing it is sized against. -->
      <div style="width: 100%; height: 100%; min-height: 0;" bind:clientWidth={paneW} bind:clientHeight={paneH}>
        {#if paneW > 40 && paneH > 40}
          <SvGridChart {spec} formatValue={compact} zoomable brush brushHeight={96} width={paneW} height={paneH} />
        {/if}
      </div>
    </div>
  </div>
</section>

<style>
  .ic-sel {
    border: 1px solid var(--sg-input-border, var(--sg-border));
    background: var(--sg-input-bg, var(--sg-bg));
    color: var(--sg-fg);
    border-radius: 6px;
    padding: 4px 8px;
    font-size: 12px;
  }
</style>

View this example on GitHub

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.