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;rmakes it a bubble chart, drop it for plain dots. The label is what the tooltip names.overlayfits a regression on each series:linear,poly:2,exp,logorpower. 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 amin/maxon 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>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.