Scatter / bubble chart

A scatter plot maps two numeric measures (x vs y); a bubble chart adds a third via dot radius. type: scatter with series points [{ x, y, r }]. Spend vs revenue, sized by deals, coloured by region, with an average-revenue reference line. Filter the grid and the cloud re-plots.

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 in Svelte 5. SvGridChart renders a type scatter spec whose series carry points of x, y and r, so each sales rep is one bubble with marketing spend on x, revenue on y, the radius from deals closed and the colour from region, with a reference line at the average revenue. Filter the grid and the cloud re-plots from the displayed rows.

A scatter plot maps two numeric measures against each other (x vs y); a bubble chart adds a third via the dot radius. SvGridChart renders a type: 'scatter' spec whose series carry points: [{ x, y, r }]. Here each rep is one bubble: marketing spend (x) vs revenue (y), sized by deals closed, coloured by region. A reference line marks the average revenue.

Imports, features and API used

Imports: @svgrid/grid

Table features registered: rowSortingFeature, columnFilteringFeature

Columns: rep (Rep), region (Region), spend (Marketing), revenue (Revenue), deals (Deals)

SvGridApi methods called: api.getDisplayedRows()

Frequently asked questions

How are bubbles sized?

Each point's r is scaled to a radius range by the chart, so deals closed maps to area rather than raw pixels and small values stay visible.

How is colour assigned?

One series per region, so the legend lists regions and clicking one hides or isolates its bubbles.

Can axes use different scales?

Yes. Spend and revenue are independent numeric axes with their own ticks and formatValue, and yScale log is available for wide ranges.

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 (150-scatter-bubble.svelte)

<!-- Documented in: docs/help/charts/types.md -->
<script lang="ts">
  /**
   * 150. Scatter / bubble chart
   * ---------------------------
   * A scatter plot maps two numeric measures against each other (x vs y); a
   * bubble chart adds a third via the dot radius. `SvGridChart` renders a
   * `type: 'scatter'` spec whose series carry `points: [{ x, y, r }]`. Here
   * each rep is one bubble: marketing spend (x) vs revenue (y), sized by deals
   * closed, coloured by region. A reference line marks the average revenue.
   */
  import {
    SvGrid,
    SvGridChart,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    type GridColumns,
    type SvGridApi,
    type ChartSpec,
    type ScatterPoint,
  } from '@svgrid/grid'

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })

  type Row = { id: number; rep: string; region: string; spend: number; revenue: number; deals: number }
  const REGIONS = ['Americas', 'EMEA', 'APAC']
  const NAMES = ['Ada', 'Grace', 'Alan', 'Margaret', 'Linus', 'Donald', 'Brian', 'Dennis', 'Barbara', 'Ken', 'Edsger', 'Tim', 'Niklaus', 'John', 'Ada II']
  let seed = 0x5eed42
  const rnd = () => ((seed = (seed * 1103515245 + 12345) >>> 0) / 0xffffffff)
  const rows: Row[] = NAMES.map((rep, id) => {
    const spend = Math.round(5_000 + rnd() * 45_000)
    // Revenue loosely tracks spend (with noise) so the cloud trends upward.
    const revenue = Math.round(spend * (3 + rnd() * 4) + (rnd() - 0.5) * 40_000)
    return { id, rep, region: REGIONS[id % 3]!, spend, revenue: Math.max(8_000, revenue), deals: Math.round(3 + rnd() * 45) }
  })

  const columns: GridColumns<Row> = [
    { field: 'rep', header: 'Rep', width: 120 },
    { field: 'region', header: 'Region', width: 120 },
    { field: 'spend', header: 'Marketing', width: 130, align: 'right', format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },
    { field: 'revenue', header: 'Revenue', width: 140, align: 'right', format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },
    { field: 'deals', header: 'Deals', width: 90, align: 'right' },
  ]

  let api = $state<SvGridApi<typeof features, Row> | null>(null)
  let displayed = $state<Row[]>(rows)
  let bubble = $state(true)
  let showAvg = $state(true)

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

  const compactNum = (v: number) => {
    const a = Math.abs(v)
    if (a >= 1e6) return (v / 1e6).toFixed(a % 1e6 ? 1 : 0) + 'M'
    if (a >= 1e3) return (v / 1e3).toFixed(a % 1e3 ? 1 : 0) + 'k'
    return String(Math.round(v))
  }
  const fmtVal = (v: number) => '$' + compactNum(v)

  // One series per region so the points are coloured by region.
  const spec = $derived.by<ChartSpec>(() => {
    const byRegion = new Map<string, ScatterPoint[]>()
    for (const r of displayed) {
      const pts = byRegion.get(r.region) ?? byRegion.set(r.region, []).get(r.region)!
      pts.push({ x: r.spend, y: r.revenue, r: bubble ? r.deals : undefined, label: r.rep })
    }
    const avg = displayed.length ? displayed.reduce((a, r) => a + r.revenue, 0) / displayed.length : 0
    return {
      type: 'scatter',
      categories: [],
      series: [...byRegion.entries()].map(([label, points]) => ({ label, values: [], points })),
      width: 520,
      height: 320,
      xAxisTitle: 'Marketing spend',
      yAxisTitle: 'Revenue',
      referenceLines: showAvg ? [{ value: Math.round(avg), label: `Avg ${fmtVal(avg)}` }] : [],
    }
  })
  /** 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);">
      Scatter / bubble: spend vs revenue, sized by deals, coloured by region
    </p>
    <p class="mt-0.5 text-xs" style="color: var(--sg-muted);">
      Hover a bubble for its x / y · double-click a legend region to isolate it · filter the grid and the cloud re-plots.
    </p>
    <div class="mt-2 flex flex-wrap items-center gap-3 text-xs">
      <label class="ic-chk"><input type="checkbox" bind:checked={bubble} /> Bubble (size = deals)</label>
      <label class="ic-chk"><input type="checkbox" bind:checked={showAvg} /> Average revenue line</label>
    </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={32}
        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 560px; 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={fmtVal} width={paneW} height={paneH} />
        {/if}
      </div>
    </div>
  </div>
</section>

<style>
  .ic-chk {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    color: var(--sg-fg);
  }
  .ic-chk input { accent-color: var(--sg-accent); }
</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.