Scatter and bubble charts

Ninety products as points, marketing spend against revenue, one series per segment: a third value as the bubble size, a regression per series (linear, quadratic, exponential, logarithmic or power) fitted on x with its equation and R-squared in the tooltip, quadrant lines at the averages, a log x axis for spend that spans two orders of magnitude, and point selection that reaches the grid.

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

About this example

Scatter and bubble charts: points as { x, y, r, label } per series, r as the bubble radius, overlay fitting a regression of y on x per series (linear, poly:N, exp, log or power) drawn as a curve across the plot with its equation and R-squared in the tooltip, reference lines on both axes as quadrants, xAxis scale log for values spanning orders of magnitude, and a click that selects the point and filters the grid. Free, in @svgrid/grid.

Ninety products as points: marketing spend on x, revenue on y, one series per segment, and the bubble radius from the unit count.

  • points: [{ x, y, r, label }] per series; r makes it a bubble chart, drop it for plain dots. The label is what the tooltip names.
  • overlay fits a regression on each series: linear, poly:2, exp, log or power. The tooltip and the legend show the R-squared of the fit, so a curve that flatters the data is easy to catch.
  • Two reference lines at the averages split the plot into quadrants.
  • xAxis.scale: 'log' when spend spans two orders of magnitude, and a min / max on both axes to keep the frame still while you filter.
  • Click a point to select it; the grid follows.

Free, in @svgrid/grid.

Imports, features and API used

Imports: @svgrid/grid, ../shared/ChartDataGrid.svelte, ../shared/ViewSwitch.svelte

Table features registered: rowSortingFeature

Columns: name (Product), spend (Spend), revenue (Revenue), units (Units)

Frequently asked questions

How do I add a trend line to a scatter chart?

Set overlay on the series: linear, poly:2, exp, log or power. The fit is of y on the points' x values, drawn as a curve across the plot, and the tooltip shows the equation and the R-squared.

How do I make a bubble chart?

Give each point an r. The radius is scaled between the smallest and largest r across all series; leave it out for plain dots.

Can the x axis be logarithmic?

Yes: xAxis: { scale: log }. Points at or below zero cannot be placed and are left out; the regression is sampled evenly in log space so it reads right.

Related documentation

Source code (442-chart-scatter-bubble.svelte)

<!-- Documented in: docs/help/charts/gallery.md -->
<script lang="ts">
  /**
   * 442. Scatter and bubble charts
   * ------------------------------
   * Ninety products as points: marketing spend on x, revenue on y, one series
   * per segment, and the bubble radius from the unit count.
   *
   * - `points: [{ x, y, r, label }]` per series; `r` makes it a bubble chart,
   *   drop it for plain dots. The label is what the tooltip names.
   * - `overlay` fits a regression on each series: `linear`, `poly:2`, `exp`,
   *   `log` or `power`. The tooltip and the legend show the R-squared of the
   *   fit, so a curve that flatters the data is easy to catch.
   * - Two reference lines at the averages split the plot into quadrants.
   * - `xAxis.scale: 'log'` when spend spans two orders of magnitude, and a
   *   `min` / `max` on both axes to keep the frame still while you filter.
   * - Click a point to select it; the grid follows.
   *
   * Free, in @svgrid/grid.
   */
  import { SvChart, SvGrid, tableFeatures, rowSortingFeature, type ChartPointRef, type ChartSpec, type GridColumns, type SeriesOverlay } from '@svgrid/grid'
  import ChartDataGrid from '../shared/ChartDataGrid.svelte'
  import ViewSwitch from '../shared/ViewSwitch.svelte'

  const features = tableFeatures({ rowSortingFeature })
  type Row = { name: string; segment: string; spend: number; revenue: number; units: number }
  let seed = 23
  const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648)
  const SEGMENTS = [
    { name: 'Consumer', base: 8, slope: 2.4, noise: 22 },
    { name: 'SMB', base: 30, slope: 3.1, noise: 40 },
    { name: 'Enterprise', base: 90, slope: 4.2, noise: 90 },
  ]
  const rows: Row[] = []
  for (const seg of SEGMENTS) {
    for (let i = 0; i < 30; i += 1) {
      const spend = Math.round(Math.pow(10, 1 + rnd() * 2))
      const revenue = Math.round(seg.base + seg.slope * spend * (0.85 + rnd() * 0.3) + (rnd() - 0.5) * seg.noise)
      rows.push({ name: `${seg.name} ${String(i + 1).padStart(2, '0')}`, segment: seg.name, spend, revenue: Math.max(5, revenue), units: Math.round(20 + rnd() * 400) })
    }
  }
  const columns: GridColumns<Row> = [
    { field: 'name', header: 'Product', width: 130 },
    { field: 'spend', header: 'Spend', width: 70, align: 'right', cellDataType: 'number' },
    { field: 'revenue', header: 'Revenue', width: 80, align: 'right', cellDataType: 'number' },
    { field: 'units', header: 'Units', width: 64, align: 'right', cellDataType: 'number' },
  ]

  let bubbles = $state(true)
  let fit = $state<SeriesOverlay | ''>('linear')
  let logX = $state(false)
  let picked = $state<ChartPointRef[]>([])

  const avgSpend = Math.round(rows.reduce((s, r) => s + r.spend, 0) / rows.length)
  const avgRevenue = Math.round(rows.reduce((s, r) => s + r.revenue, 0) / rows.length)

  const spec = $derived<ChartSpec>({
    type: 'scatter',
    categories: [],
    series: SEGMENTS.map((seg) => ({
      label: seg.name,
      values: [],
      points: rows.filter((r) => r.segment === seg.name).map((r) => ({ x: r.spend, y: r.revenue, r: bubbles ? r.units : undefined, label: r.name })),
      overlay: fit || undefined,
    })),
    title: 'Revenue against marketing spend',
    subtitle: bubbles ? 'Bubble size is units sold' : 'One dot per product',
    xAxis: { title: 'Spend (k)', scale: logX ? 'log' : 'linear', min: logX ? 8 : 0, max: 1100, gridLines: true },
    yAxis: { title: 'Revenue (k)', min: 0, gridLines: true },
    referenceLines: [
      { value: avgRevenue, label: 'Avg revenue', dashed: true, color: '#94a3b8' },
      { value: avgSpend, axis: 'x', label: 'Avg spend', dashed: true, color: '#94a3b8' },
    ],
    height: 400,
  })

  const pickedName = $derived(picked[0]?.category ?? null)
  const shown = $derived(pickedName ? rows.filter((r) => r.name === pickedName) : rows)

  // Chart | Grid: the same spec as the chart or as the rows behind it.
  let view = $state<'chart' | 'grid'>('chart')
</script>

<section class="wrap">
  <header class="chrome">
    <ViewSwitch bind:value={view} options={[['chart', 'Chart'], ['grid', 'Grid']]} />
    <label class="chk"><input type="checkbox" bind:checked={bubbles} /> Bubble size from units</label>
    <label class="ctl">
      Fit
      <select bind:value={fit}>
        <option value="">None</option>
        <option value="linear">Linear</option>
        <option value="poly:2">Quadratic</option>
        <option value="exp">Exponential</option>
        <option value="log">Logarithmic</option>
        <option value="power">Power</option>
      </select>
    </label>
    <label class="chk"><input type="checkbox" bind:checked={logX} /> Log x</label>
    <span class="note">
      Points with a third value as the bubble size, a regression per series with its R-squared in the
      tooltip, quadrant lines at the averages, a log x axis, and selection that reaches the grid.
    </span>
  </header>

  <div class="row">
    <div class="pane pane-chart">
      {#if view === 'chart'}
        <SvChart {spec} legend="bottom" selectable bind:selected={picked} zoomable autosize />
      {:else}
        <ChartDataGrid spec={spec} />
      {/if}
    </div>
    <div class="grid-host">
      <div class="muted">{pickedName ? `${pickedName} selected` : `${rows.length} products; click a point`}</div>
      <SvGrid data={shown} {columns} {features} sortable rowHeight={26} containerHeight="100%" fitColumns responsive />
    </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);
  }
  .chk,
  .ctl {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    font-size: 12px;
    color: var(--sg-fg, #0f172a);
    white-space: nowrap;
  }
  .ctl select {
    font: inherit;
    border: 1px solid var(--sg-border, #e2e8f0);
    border-radius: 6px;
    background: var(--sg-bg, #fff);
    color: inherit;
    padding: 2px 6px;
  }
  .row {
    display: flex;
    flex: none;
    gap: 12px;
    flex-wrap: wrap;
    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-chart {
    flex: 1 1 480px;
  }
  .grid-host {
    flex: 1 1 400px;
    min-width: 0;
    min-height: 320px;
    display: flex;
    flex-direction: column;
    gap: 4px;
  }
</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.