When to use scatter
Scatter is the right choice when the question is whether two continuous measures move together across a population — margin vs revenue across brands, conversion rate vs traffic across pages, latency vs throughput across endpoints. Each row of the query becomes one point.
Use line instead when the x-axis is ordered (typically time) and you care about trend. Use heatmap when the data is dense and you need to see distribution rather than individual points.
Mapping
mapping.x— required. Numeric x-coordinate field. Rows where this value is non-finite are dropped silently.mapping.y— required. Numeric y-coordinate field. Same finite-only filter.mapping.label— optional. Field name used as the per-point tooltip label and as the cross-filter event payload when emission is enabled.mapping.series— optional. Categorical field name. When present the chart switches to multi-series mode: every distinct value becomes its own colored group of points with a legend entry, all sharing the same x/y axes. One row is still one point — the field only decides which group (and color) the point belongs to.mapping.size— optional. Numeric field encoded as point diameter (bubble chart). Turns the scatter into a bubble chart: x × y position plus a third measure as size. Works in both single-series and multi-series mode; bubble sizes use a global domain across all series so cohorts stay comparable. Rows with non-finite or negative size values are dropped, same as bad x/y coordinates. Mutually exclusive withchart.symbol_sizeandchart.large_threshold— validation rejects the combination.
mapping:
x: revenue
y: margin_pct
label: brand
series: cohort # optional — one colored group per distinct value
chart shortcuts
The chart block is typed and closed.
chart.point_color— base color for the points (hex), used in single-series mode. The emphasis block (see below) overrides this for the highlighted point. In multi-series mode usechart.series_colorsinstead.chart.series_colors— multi-series only. A map ofseries value → color, e.g.{ Champions: "#6c47ff", Rest: "#94a3b8" }. Any value not listed falls back to the default palette in order. Use hex/CSS colors, not Tailwind brand class names.chart.symbol_size— base point size in pixels for unsized scatter (nomapping.size). Default depends on the chart density. Cannot be combined withmapping.size.chart.size_range— sized mode only (mapping.sizeset). Min and max point diameter in pixels; the size field's domain is scaled into this range. Default[8, 40]. Inert whenmapping.sizeis absent.chart.size_scale— sized mode only.sqrt(default) makes bubble area proportional to the value — the perceptually correct encoding.linearmaps value to diameter directly (over-emphasizes large values). Inert withoutmapping.size.chart.large_threshold— multi-series only, default2000. When a single series has more points than this, it switches to a faster bulk-draw mode; the trade-off is that per-point hover/emphasis is turned off for that series only. Smaller groups keep full hover. Raise it if you need hover on a big group and can afford the slower draw; lower it to keep very large groups responsive. Cannot be combined withmapping.size— bulk draw ignores per-point sizing.chart.cross_filter— boolean, defaulttrue. Set tofalseto disable click emission entirely; in that casecross_filter_emitis not required.chart.cross_filter_emit—"label","x", or"series". Picks which value is emitted on click;"series"emits the clicked point's group value and is only meaningful in multi-series mode. Required wheneverchart.cross_filteris not explicitlyfalse; the schema rejects a chart block that has neither.chart.legend— setlegend.show: trueto display the series legend (the cohort names) in multi-series mode.chart.point_labels— boolean, defaultfalse. Pins themapping.labeltext to each point so it is always visible, not only on hover. A quadrant almost always wants this on.chart.quadrant— draws a quadrant over the scatter (see the Quadrant section below).chart.height— pixel height of the viz container.
Legend & tooltip
chart.legend and chart.tooltip share the same shape as on bar. For scatter the tooltip is most useful with trigger: item — hovering reveals the label and both coordinates of one point at a time.
Axes
chart.x_axis and chart.y_axis share the same shape (with one extra on x_axis):
name— axis title. Setting both is recommended for scatter so the audience can read the relationship.name_location,name_gap.axis_label.show,axis_label.rotate,axis_label.interval,axis_label.color,axis_label.font_size,axis_label.font_weight,axis_label.formatter,axis_label.max_chars.x_axis.visible_window— integer ≥ 1. Restricts the visible x range.
Quadrant (magic quadrant)
The optional chart.quadrant block turns the scatter into a quadrant: two divider lines split the plane into four, with a caption per quadrant. Points, bubble sizing, the legend, emphasis and cross-filter all keep working.
x_dividerandy_divider— required. Where the lines fall: a number in axis coordinates (a threshold you choose), or"average"/"median"to derive it from the plotted rows. There is no default line — omit one and the viz is rejected.labels— optional. A caption per corner:top_right,top_left,bottom_right,bottom_left. Give only the ones you want.tint— optional, defaultfalse. Fills each quadrant with a soft background tone.
Pair it with chart.point_labels: true so each point's name (mapping.label) reads without hovering — a quadrant almost always wants that.
A complete quadrant scatter — one point per product, revenue against margin:
type: scatter
query: "models/portfolio.malloy::products"
mapping:
x: total_revenue
y: margin_pct
label: product
chart:
cross_filter: false
point_labels: true
x_axis:
name: Revenue
y_axis:
name: Margin %
quadrant:
x_divider: average # a number, or "average" / "median"
y_divider: average
tint: true
labels:
top_right: Leaders
top_left: Niche
bottom_right: Volume
bottom_left: Laggards
format:
total_revenue: "$#,##0.00"
margin_pct: "#,##0.00%"
format
format.xorformat[<x_field_name>]— pattern for x-axis labels and tooltip x value.format.yorformat[<y_field_name>]— pattern for y-axis labels and tooltip y value.format.sizeorformat[<size_field_name>]— pattern for the size value in the tooltip whenmapping.sizeis set.formatat the root — fallback.
Cross-filter behavior
- Clicking a point cross-filters the rest of the dashboard by the point's label / series / x value.
- The clicked field must be declared as a parameter on a source signature in this viz's own model file, or the click is silently ignored.
- The top-level
emphasisblock can declaratively highlight the matching point inside the same viz when a related cross-filter is active. - Disable per viz with
chart.cross_filter: false.
emphasis:
field: brand
value_from_param: highlight_brand
marker_color: "#6c47ff"
marker_size: 18
The point whose brand equals the runtime value of highlight_brand is rendered in marker_color at marker_size (unsized scatter) or with a relative size boost (bubble / sized scatter); the rest stay at chart.point_color / chart.symbol_size.
Worked examples
Margin vs revenue across brands:
id: brand_margin_vs_revenue
title: Margin vs Revenue by Brand
query: "models/ec_revenue.malloy::by_brand"
type: scatter
mapping:
x: revenue
y: margin_pct
label: brand
chart:
height: 360
point_color: "#0f766e"
symbol_size: 12
x_axis:
name: Revenue
y_axis:
name: Margin
tooltip:
trigger: item
formatter: "{b} — {c0} / {c1}"
format:
revenue: "$#,##0"
margin_pct: "#,##0.00%"
published: true
With emphasis from a dashboard pill:
type: scatter
mapping:
x: revenue
y: margin_pct
label: brand
chart:
point_color: "#94a3b8"
symbol_size: 10
emphasis:
field: brand
value_from_param: highlight_brand
marker_color: "#6c47ff"
marker_size: 18
Multi-series — comparing two customer cohorts on one frequency × ticket plane. Each row is one customer; series splits them into colored groups with a shared scale, so the two cohorts are directly comparable in a single viz instead of two side-by-side charts. The companion model derives the per-customer points and the cohort label:
# models/ec_rfm.malloy — one row per customer with a cohort label.
##! experimental.parameters
source: ec_rfm() is ecommerce.sql("""
WITH per_customer AS (
SELECT
user_id AS customer_id,
COUNT(DISTINCT order_id) AS order_frequency,
SUM(sale_price) / COUNT(DISTINCT order_id) AS avg_ticket,
SUM(sale_price) AS lifetime_revenue
FROM `bigquery-public-data.thelook_ecommerce.order_items`
WHERE status NOT IN ('Cancelled', 'Returned')
GROUP BY 1
)
SELECT
customer_id,
order_frequency,
avg_ticket,
lifetime_revenue,
IF(order_frequency >= 3 AND avg_ticket >= 100, 'Champions', 'Rest') AS cohort
FROM per_customer
""") extend {
view: cohort_points is {
select: customer_id, order_frequency, avg_ticket, cohort
}
}
id: cohorts_freq_vs_ticket
title: Frequency vs Ticket by cohort
query: "models/ec_rfm.malloy::cohort_points"
type: scatter
mapping:
x: order_frequency
y: avg_ticket
label: customer_id
series: cohort # e.g. "Champions" vs "Rest"
chart:
height: 360
series_colors:
Champions: "#6c47ff"
Rest: "#94a3b8"
large_threshold: 2000 # the large "Rest" group draws fast; small "Champions" keeps hover
legend:
show: true
cross_filter: true
cross_filter_emit: series
x_axis:
name: Order frequency
y_axis:
name: Avg ticket
format:
order_frequency: "#,##0"
avg_ticket: "$#,##0.00"
published: true
Bubble chart — inventory availability × sales velocity × inventory value per category, with mapping.size encoding the third measure as point diameter and emphasis highlighting the category a dashboard filter selects. This is a production shape running against the public ecommerce dataset:
id: ec_supply_priority_matrix_scatter
title: Supply priority matrix
query: "models/ec_supply_priority.malloy::priority_matrix"
type: scatter
mapping:
x: available_count
y: sales_velocity
size: inventory_value
label: category
series: priority # optional — multi-series bubble
chart:
height: 500
size_range: [14, 52]
size_scale: sqrt # default — area proportional to value
cross_filter: true
cross_filter_emit: label
series_colors:
High: "#6c47ff"
Medium: "#64748b"
Low: "#cbd5e1"
legend:
show: true
position: top
tooltip:
trigger: item
x_axis:
name: Available inventory (units)
y_axis:
name: Sales velocity (units/month)
format:
available_count: "#,##0"
sales_velocity: "#,##0.0"
inventory_value: "$#,##0a"
emphasis:
field: category
value_from_param: category
marker_color: "#4F46E5"
published: true
The Malloy model should query bigquery-public-data.thelook_ecommerce (or a view derived from it). Sized scatters work best up to a few thousand points — beyond that, bubbles overlap and readability drops; for very large cohorts use unsized scatter with large_threshold instead.
Design notes
- For readability at volume, pre-aggregate in the Malloy query (bucket + heatmap) or filter to top-N —
large_thresholdkeeps a big group responsive but does not declutter it. Bubble charts (mapping.size) overlap faster; keep point counts in the low thousands or use unsized scatter. - Pick one sizing mode per viz: data-driven (
mapping.size) or static size + adaptive bulk draw (chart.symbol_size/chart.large_threshold) — validation enforces the choice. - Filter extreme outliers in the query so they don't compress the rest of the plot.
mapping.sizevalues are positive — rows with negative sizes are skipped. - Always set
chart.x_axis.nameandchart.y_axis.name— scatter is the viz where the audience most needs axis labels to read the relationship.