Cuándo usar scatter
Scatter es la elección correcta cuando la pregunta es si dos measures continuos se mueven juntos entre una población — margin vs revenue entre marcas, tasa de conversión vs tráfico entre páginas, latency vs throughput entre endpoints. Cada fila de la query se vuelve un punto.
Usa line en su lugar cuando el eje x está ordenado (típicamente tiempo) y te importa trend. Usa heatmap cuando los datos son densos y necesitas ver distribución en vez de puntos individuales.
Mapping
mapping.x— requerido. Field numérico de coordenada x. Las filas donde este valor es no-finito se dropean silenciosamente.mapping.y— requerido. Field numérico de coordenada y. Mismo filtro de finite-only.mapping.label— opcional. Nombre de field usado como el label de tooltip por punto y como el payload de evento de cross-filter cuando la emisión está habilitada.mapping.series— opcional. Nombre de field categórico. Cuando está presente el chart cambia a modo multi-serie: cada valor distinto se vuelve su propio grupo de puntos coloreado con entrada en leyenda, todos compartiendo los mismos ejes x/y. Una fila sigue siendo un punto — el field sólo decide a qué grupo (y color) pertenece el punto.mapping.size— opcional. Field numérico codificado como diámetro del punto (bubble chart). Convierte el scatter en bubble chart: posición x × y más un tercer measure como tamaño. Funciona en modo de una serie y multi-serie; los tamaños usan un dominio global entre todas las series para que las cohortes sean comparables. Las filas con size no-finito o negativo se dropean, igual que coordenadas x/y inválidas. Mutuamente excluyente conchart.symbol_sizeychart.large_threshold— el schema rechaza la combinación.
mapping:
x: revenue
y: margin_pct
label: brand
series: cohort # optional — one colored group per distinct value
Shortcuts de chart
El bloque chart es tipado y cerrado.
chart.point_color— color base de los puntos (hex), usado en modo de una sola serie. El bloque emphasis (mira abajo) overridea esto para el punto resaltado. En modo multi-serie usachart.series_colorsen su lugar.chart.series_colors— sólo multi-serie. Un mapa devalor de serie → color, ej.{ Champions: "#6c47ff", Rest: "#94a3b8" }. Cualquier valor no listado cae al palette por defecto en orden. Usa colores hex/CSS, no nombres de clases brand de Tailwind.chart.symbol_size— tamaño base del punto en pixels para scatter sin size (sinmapping.size). El default depende de la densidad del chart. No se puede combinar conmapping.size.chart.size_range— sólo modo sized (mapping.sizeseteado). Diámetro mínimo y máximo del punto en pixels; el dominio del field size se escala a este rango. Default[8, 40]. Inerte cuandomapping.sizeestá ausente.chart.size_scale— sólo modo sized.sqrt(default) hace que el área del bubble sea proporcional al valor — la codificación perceptualmente correcta.linearmapea valor a diámetro directamente (sobre-enfatiza valores grandes). Inerte sinmapping.size.chart.large_threshold— sólo multi-serie, default2000. Cuando una serie tiene más puntos que esto, cambia a un modo de dibujo masivo más rápido; el trade-off es que el hover/emphasis por punto se apaga sólo para esa serie. Los grupos más chicos conservan el hover completo. Subilo si necesitas hover en un grupo grande y puedes pagar el dibujo más lento; bajalo para mantener grupos muy grandes responsivos. No se puede combinar conmapping.size— el dibujo masivo ignora el sizing por punto.chart.cross_filter— boolean, defaulttrue. Setea afalsepara deshabilitar la emisión de click enteramente; en ese casocross_filter_emitno es requerido.chart.cross_filter_emit—"label","x"o"series". Elige qué valor se emite al click;"series"emite el valor de grupo del punto clickeado y sólo tiene sentido en modo multi-serie. Requerido cada vez quechart.cross_filterno es explícitamentefalse; el schema rechaza un bloque chart que no tenga ninguno.chart.legend— setealegend.show: truepara mostrar la leyenda de series (los nombres de cohorte) en modo multi-serie.chart.point_labels— boolean, defaultfalse. Fija el texto demapping.labela cada punto para que se vea siempre, no sólo al hover. Un cuadrante casi siempre lo quiere activado.chart.quadrant— dibuja un cuadrante sobre el scatter (mira la sección Cuadrante abajo).chart.height— altura en pixels del container de la viz.
Legend & tooltip
chart.legend y chart.tooltip comparten el mismo shape que en bar. Para scatter el tooltip es más útil con trigger: item — hovereando revela el label y las dos coordenadas de un punto a la vez.
Ejes
chart.x_axis y chart.y_axis comparten el mismo shape (con un extra en x_axis):
name— título del eje. Setear los dos se recomienda para scatter así la audiencia puede leer la relación.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— entero ≥ 1. Restringe el rango x visible.
Cuadrante (magic quadrant)
El bloque opcional chart.quadrant convierte el scatter en un cuadrante: dos líneas divisorias parten el plano en cuatro, con un rótulo por cuadrante. Los puntos, el bubble sizing, la leyenda, el emphasis y el cross-filter siguen funcionando igual.
x_divideryy_divider— requeridos. Dónde caen las líneas: un número en las coordenadas del eje (un umbral que vos elegís), o"average"/"median"para derivarlo de las filas graficadas. No hay línea por defecto: si no la declarás, la viz se rechaza.labels— opcional. Un rótulo por esquina:top_right,top_left,bottom_right,bottom_left. Podés dar sólo las que quieras.tint— opcional, defaultfalse. Colorea cada cuadrante con un tono suave de fondo.
Combinalo con chart.point_labels: true para que el nombre de cada punto (mapping.label) se lea sin hover — un cuadrante casi siempre lo quiere.
Un cuadrante completo — un punto por producto, ingreso contra margen:
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: Ingreso
y_axis:
name: Margen %
quadrant:
x_divider: average # un número, o "average" / "median"
y_divider: average
tint: true
labels:
top_right: Estrellas
top_left: Nicho rentable
bottom_right: Volumen
bottom_left: Descontinuar
format:
total_revenue: "$#,##0.00"
margin_pct: "#,##0.00%"
format
format.xoformat[<x_field_name>]— pattern para labels del eje x y valor x del tooltip.format.yoformat[<y_field_name>]— pattern para labels del eje y y valor y del tooltip.format.sizeoformat[<size_field_name>]— pattern para el valor size en el tooltip cuandomapping.sizeestá seteado.formaten root — fallback.
Comportamiento de cross-filter
- Clickear un punto cross-filtra el resto del dashboard por el valor de label / series / x del punto.
- El field clickeado tiene que estar declarado como parámetro en una firma de source del archivo de model propio de esta viz, o el click se ignora silenciosamente.
- El bloque top-level
emphasispuede declarativamente resaltar el punto matcheante dentro de la misma viz cuando un cross-filter relacionado está activo. - Deshabilita por viz con
chart.cross_filter: false.
emphasis:
field: brand
value_from_param: highlight_brand
marker_color: "#6c47ff"
marker_size: 18
El punto cuyo brand iguala al valor de runtime de highlight_brand se renderiza en marker_color con marker_size (scatter sin size) o con un boost relativo de tamaño (bubble / scatter sized); el resto se quedan con chart.point_color / chart.symbol_size.
Ejemplos trabajados
Margin vs revenue entre marcas:
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
Con emphasis desde un pill de dashboard:
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-serie — comparando dos cohortes de clientes en un mismo plano frecuencia × ticket. Cada fila es un cliente; series los separa en grupos coloreados con escala compartida, así las dos cohortes son directamente comparables en una sola viz en vez de dos charts lado a lado. El model acompañante deriva los puntos por cliente y el label de cohorte:
# 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 — disponibilidad de inventario × velocidad de ventas × valor de inventario por categoría, con mapping.size codificando el tercer measure como diámetro del punto y emphasis resaltando la categoría que selecciona un filtro del dashboard. Este es un shape de producción corriendo contra el dataset público de ecommerce:
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
El model Malloy debería consultar bigquery-public-data.thelook_ecommerce (o una vista derivada). Los scatter sized funcionan mejor hasta unos pocos miles de puntos — más allá, los bubbles se solapan y baja la legibilidad; para cohortes muy grandes usa scatter sin size con large_threshold.
Notas de diseño
- Para legibilidad con volumen, pre-agrega en la query Malloy (bucket + heatmap) o filtra a top-N —
large_thresholdmantiene un grupo grande responsivo pero no lo despeja. Los bubble charts (mapping.size) se solapan más rápido; mantén los counts de puntos en pocos miles o usa scatter sin size. - Elige un modo de sizing por viz: data-driven (
mapping.size) o size estático + bulk draw adaptivo (chart.symbol_size/chart.large_threshold) — la validación fuerza la elección. - Filtra los outliers extremos en la query para que no compriman el resto del plot. Los valores de
mapping.sizeson positivos — las filas con sizes negativos se saltean. - Setea siempre
chart.x_axis.nameychart.y_axis.name— scatter es la viz donde la audiencia más necesita los labels de eje para leer la relación.