Analytics: trend, log, drill

Four story-telling chart features at once: overlay (SMA/EMA/linear regression), annotations pinned at named events, yScale: log for wide-range data, and onDrill that filters the grid to the rowIds of the clicked category. Click any day to drill.

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

About this example

Four story-telling chart features on one Svelte 5 line chart. overlay adds a smoothing or trend line, sma:7, ema:14 or linear regression, dashed and tinted over the source series; annotations pin text and markers at named events such as releases and incidents; yScale log plots base-10 for signups that run from dozens to thousands; and onDrill fires with the rowIds behind a clicked category so the demo filters the grid to that day's rows.

Four chart features that turn a raw line into a story:

  • overlay: 'sma:7' | 'ema:14' | 'linear' adds a smoothing / trend line on top of the source series, dashed and tinted.
  • annotations pins text + markers at named events (releases, incidents) so the chart explains itself.
  • yScale: 'log' plots base-10 - essential for wide-range data (here: signups that ride from a few dozen to many thousands).
  • onDrill fires with the rowIds that produced the clicked category - we filter the grid down to the source rows.

Click any point on the chart to drill the grid to that day's signups.

Imports, features and API used

Imports: @svgrid/grid

Table features registered: rowSortingFeature, columnFilteringFeature

Columns: day (Day), channel (Channel), signups (Signups)

SvGridApi methods called: api.getDisplayedRows()

Frequently asked questions

How do I add a moving average?

Set overlay on the series to sma:7 for a seven-point simple moving average, ema:14 for an exponential one or linear for a regression line; the chart computes and draws it from the series values.

What does onDrill receive?

The clicked category and the ids of the rows that were aggregated into it, so the grid can be filtered to exactly those rows without recomputing the grouping.

How are annotations placed?

Each annotation names a category and carries text and an optional marker; the chart positions it at that x with a callout so the event is explained on the plot.

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 (155-chart-analytics.svelte)

<!-- Documented in: docs/help/charts/axes-and-styling.md -->
<script lang="ts">
  /**
   * 155. Chart analytics: trendline, annotations, log scale, drill
   * --------------------------------------------------------------
   * Four chart features that turn a raw line into a story:
   *
   *  - `overlay: 'sma:7' | 'ema:14' | 'linear'` adds a smoothing / trend
   *    line on top of the source series, dashed and tinted.
   *  - `annotations` pins text + markers at named events (releases,
   *    incidents) so the chart explains itself.
   *  - `yScale: 'log'` plots base-10 - essential for wide-range data
   *    (here: signups that ride from a few dozen to many thousands).
   *  - `onDrill` fires with the `rowIds` that produced the clicked
   *    category - we filter the grid down to the source rows.
   *
   * Click any point on the chart to drill the grid to that day's signups.
   */
  import {
    SvGrid,
    SvGridChart,
    rowsToChartSpec,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    type GridColumns,
    type SvGridApi,
    type ChartSpec,
    type ChartSelection,
  } from '@svgrid/grid'

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })

  type Row = { id: number; day: string; signups: number; channel: string }
  const CHANNELS = ['Organic', 'Paid', 'Referral']
  let seed = 0xdead42
  const rnd = () => ((seed = (seed * 1103515245 + 12345) >>> 0) / 0xffffffff)
  // 90 days, exponential growth with weekly seasonality + product-launch spike.
  const rows: Row[] = Array.from({ length: 90 * 3 }, (_, i) => {
    const day = Math.floor(i / 3)
    const channel = CHANNELS[i % 3]!
    const d = new Date(2026, 2, 1 + day)
    const base = 25 * Math.pow(1.035, day)             // exponential climb
    const wkly = 1 + Math.sin((day / 7) * 2 * Math.PI) * 0.18
    const spike = day === 38 ? 4.5 : 1                  // product launch on Apr 8
    const mult = channel === 'Paid' ? 0.6 : channel === 'Referral' ? 0.35 : 1
    return {
      id: i,
      day: d.toISOString().slice(0, 10),
      channel,
      signups: Math.max(1, Math.round(base * wkly * spike * mult * (0.8 + rnd() * 0.4))),
    }
  })

  const columns: GridColumns<Row> = [
    { field: 'day', header: 'Day', width: 130 },
    { field: 'channel', header: 'Channel', width: 130 },
    { field: 'signups', header: 'Signups', width: 120, align: 'right' },
  ]

  let api = $state<SvGridApi<typeof features, Row> | null>(null)
  let displayed = $state<Row[]>(rows)
  let logScale = $state(true)
  let overlay = $state<'none' | 'sma:7' | 'sma:14' | 'linear'>('sma:7')
  let drilledTo = $state<string | null>(null)

  function sync() {
    displayed = (api?.getDisplayedRows() as Row[]) ?? rows
  }

  const spec = $derived.by<ChartSpec>(() => {
    const s = rowsToChartSpec(displayed, {
      type: 'line',
      category: 'day',
      value: 'signups',
      reduce: 'sum',
      idField: 'id',                  // <-- track row ids per category for drill
      width: 720,
      height: 320,
    })
    s.xType = 'time'
    s.xAxisTitle = 'Day'
    s.yAxisTitle = `Signups (${logScale ? 'log' : 'linear'})`
    s.yScale = logScale ? 'log' : 'linear'
    // Add the overlay on the (single) total-signups series.
    if (overlay !== 'none' && s.series[0]) {
      s.series[0].overlay = overlay
      s.series[0].overlayColor = '#94a3b8'
    }
    // Pinned annotations.
    s.annotations = [
      { at: { category: '2026-04-08' }, label: 'v2 launch', color: '#16a34a', placement: 'top' },
      { at: { category: '2026-05-05' }, label: 'Outage',    color: '#ef4444', placement: 'bottom' },
    ]
    return s
  })

  function onDrill(sel: ChartSelection) {
    if (!sel.rowIds?.length) return
    const ids = new Set(sel.rowIds.map(Number))
    displayed = rows.filter((r) => ids.has(r.id))
    drilledTo = sel.category
  }
  function clearDrill() {
    drilledTo = null
    sync()
  }
  /** 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);">
      Trendline · annotations · log axis · click-to-drill
    </p>
    <p class="mt-0.5 text-xs" style="color: var(--sg-muted);">
      The log axis flattens 90 days of exponential growth so the pattern is readable. Click any
      day on the chart to drill the grid to that day's signups. Toggle to linear to see the
      late-period spike dominate the small-value early days.
    </p>
    <div class="mt-2 flex flex-wrap items-center gap-2 text-xs">
      <label class="ic-chk"><input type="checkbox" bind:checked={logScale} /> Log scale</label>
      <select bind:value={overlay} class="ic-sel">
        <option value="none">No overlay</option>
        <option value="sma:7">7-day SMA</option>
        <option value="sma:14">14-day SMA</option>
        <option value="linear">Linear trend</option>
      </select>
      {#if drilledTo}
        <span class="ic-drill">Showing rows for <strong>{drilledTo}</strong>
          <button type="button" onclick={clearDrill}>clear</button>
        </span>
      {/if}
    </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={displayed}
        columns={columns}
        features={features}
        sortable
        filterable
        selectionMode="none"
        rowHeight={28}
        containerHeight="100%"
        fitColumns={true}
        onApiReady={(a) => { api = a; 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} {onDrill} 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;
  }
  .ic-chk { display: inline-flex; align-items: center; gap: 4px; color: var(--sg-fg); }
  .ic-chk input[type='checkbox'] { accent-color: var(--sg-accent); }
  .ic-drill {
    color: var(--sg-fg);
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 2px 6px;
    border: 1px solid var(--sg-border);
    border-radius: 5px;
  }
  .ic-drill button {
    color: var(--sg-accent, #2563eb);
    background: none;
    border: 0;
    cursor: pointer;
    text-decoration: underline;
    font-size: 11px;
    padding: 0;
  }
</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.