Docs / Build Workflow

Visualization — scatter

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 con chart.symbol_size y chart.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 usa chart.series_colors en su lugar.
  • chart.series_colors — sólo multi-serie. Un mapa de valor 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 (sin mapping.size). El default depende de la densidad del chart. No se puede combinar con mapping.size.
  • chart.size_range — sólo modo sized (mapping.size seteado). 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 cuando mapping.size está 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. linear mapea valor a diámetro directamente (sobre-enfatiza valores grandes). Inerte sin mapping.size.
  • chart.large_threshold — sólo multi-serie, default 2000. 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 con mapping.size — el dibujo masivo ignora el sizing por punto.
  • chart.cross_filter — boolean, default true. Setea a false para deshabilitar la emisión de click enteramente; en ese caso cross_filter_emit no 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 que chart.cross_filter no es explícitamente false; el schema rechaza un bloque chart que no tenga ninguno.
  • chart.legend — setea legend.show: true para mostrar la leyenda de series (los nombres de cohorte) en modo multi-serie.
  • chart.point_labels — boolean, default false. Fija el texto de mapping.label a 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_divider y y_dividerrequeridos. 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, default false. 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.x o format[<x_field_name>] — pattern para labels del eje x y valor x del tooltip.
  • format.y o format[<y_field_name>] — pattern para labels del eje y y valor y del tooltip.
  • format.size o format[<size_field_name>] — pattern para el valor size en el tooltip cuando mapping.size está seteado.
  • format en 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 emphasis puede 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_threshold mantiene 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.size son positivos — las filas con sizes negativos se saltean.
  • Setea siempre chart.x_axis.name y chart.y_axis.name — scatter es la viz donde la audiencia más necesita los labels de eje para leer la relación.