Docs / Build Workflow

Cross-filtering

What cross-filtering is in Looky

Inside a dashboard, clicking a data point in one visualization narrows every other visualization on the page. The clicked field/value pair becomes a removable "pill" the user sees at the top of the dashboard, and Looky applies that field/value to every other viz as a parameter on its next run.

Cross-filtering is a runtime behavior, not a YAML feature. There is no dashboard-YAML cross_filter block — the wiring is automatic, driven by which fields the underlying models declare as parameters.

Where cross-filtering works

  • In dashboards. Any chart click anywhere inside a dashboard adds (or removes) a pill.
  • Not on the standalone visualization page. Clicking a chart that is open as its own page does not produce a pill.
  • Not in document mode. A dashboard with layout_mode: document renders for reading and PDF export — click wiring is suppressed entirely.

If you want cross-filter behavior, the user must be looking at the chart through a fluid-grid dashboard.

Which viz types emit clicks

  • bar — clicking a bar emits the x-axis category.
  • line — clicking a point emits the x value (single / dual-axis) or the series name (multi-series).
  • pie — clicking a slice emits the slice label.
  • scatter — clicking a point emits what chart.cross_filter_emit names: "label", "x", or "series". The emit knob is required whenever the viz has a chart block and cross_filter is not explicitly false.
  • funnel — clicking a stage emits the stage label.
  • heatmap — emits what chart.cross_filter_emit names: "x" or "y". Same required-knob rule as scatter.
  • sankey — clicking a node emits what chart.cross_filter_emit names: "source" or "target". Same required-knob rule as scatter.
  • grid — clicking a cell in a column configured as clickable emits the column name and the cell value.
  • report_matrix — clicking a body cell emits the column name and the cell value, same shape as grid. Totals rows, trend cells, and section headers are not clickable; document-mode rendering suppresses all click wiring.
  • kpi — does not emit clicks (no categorical click target by structure), but consumes pills: its query re-runs with the active params just like every other viz.
  • text — does not emit clicks; a dynamic note's query re-runs with the active params like any other consumer.

Pills

Pills are the visual representation of active cross-filters at the top of a dashboard. The lifecycle:

  1. User clicks a chart — the field / value pair becomes a pill.
  2. Pills are additive and combine as AND — clicking on a second chart adds another pill, and both apply to every viz on the next run. For OR semantics, encode it in the model parameter (e.g. accept a list, default to "all").
  3. Clicking a pill's remove button drops that filter and re-runs the dashboard without it.
  4. Pills last for the dashboard session — reloading the page clears them. For shareable filter state, use a declared filter (Filters) — those are reflected in the URL.

A click becomes a pill when the clicked field is a parameter declared on the source signature of that viz's own model file. To make a field cross-filterable, declare a parameter for it in the model behind the viz the user clicks (see Malloy support).

Emitting a pill and reacting to one are separate steps: the pill is offered to every viz on the dashboard, and each viz narrows if its own model declares that parameter — covered next.

How cross-filter values reach the underlying queries

Active pill values are merged with any dashboard-level filter values and passed as parameters to every viz's query on the next run. From the model's point of view, a cross-filter pill is indistinguishable from a value the user typed in a filter control — the same parameter binding applies.

Pills override declared filter values when both set the same parameter. So a click on a chart that emits {country: "MX"} wins over the dashboard's "country" select that was set to "all". Removing the pill restores the declared value.

Declaring the parameter

Declare the parameter on the first source signature of each model file whose vizs should react, and reference it from the views in that same file. A viz binds the parameters declared in the model file its own query points at, so keeping the declaration local to that file is what wires it up.

Matching is by parameter name, which is how one click spreads: every model that declares p_country narrows together, across as many files as you like. When the semantics live in a shared model you import, re-declare the signature in the importing file — see Shared semantics, per-model signature under Patterns below for the shape.

A parameter can be consumed at two levels. Malloy-levelp_country referenced in a Malloy where: clause, available to the views inside its source's extend { … } block. SQL-level@country written inside a .sql("""…""") block, declared on the signature as p_country, which is what gives it its type.

Give it a default that means "no filter" — typically a sentinel like "all", an empty string, or the broadest valid value — so the query runs unfiltered until a pill sets it.

# in a Malloy model
##! experimental.parameters

source: ec_orders(
  p_country::string is "all",
  p_brand::string   is "all"
) is ecommerce.table('bigquery-public-data.thelook_ecommerce.order_items') extend {
  join_one: products is ecommerce.table('bigquery-public-data.thelook_ecommerce.products')
    on product_id = products.id
  join_one: users is ecommerce.table('bigquery-public-data.thelook_ecommerce.users')
    on user_id = users.id
  dimension: country is users.country
  dimension: brand   is products.brand

  view: by_category is {
    where:
      (p_country = "all" or country = p_country)
      and (p_brand = "all" or brand = p_brand)
    group_by: products.category
    aggregate:
      revenue is sum(sale_price)
  }
}

With the parameters above, clicking a country bar adds a {country: "MX"} pill, the dashboard re-runs with p_country = "MX", and every chart using this view narrows accordingly.

Writing an SQL-level predicate

Inside a .sql("""…""") block, write the predicate so that "no value" means "no filter":

WHERE (@country IS NULL OR @country = '' OR @country = 'all' OR country = @country)

The guard covers the three states the parameter can be in — absent, cleared by a filter control, and the "all" sentinel — and makes the unfiltered result the default until a pill sets a value. For numeric and date parameters, wrap the cast and supply the default: COALESCE(SAFE_CAST(@page_size AS INT64), 25).

Per-viz opt-out

Suppress click emission for a specific viz by setting the cross-filter flag to false in its YAML. The flag lives in the viz's primary config block — chart for ECharts-based vizs, the type-named block for DOM-based vizs (because they are not "charts" in the ECharts sense).

  • bar, line, pie, scatter, heatmap, funnel, sankeychart.cross_filter: false
  • gridgrid.cross_filter: false
  • report_matrixmatrix.cross_filter: false
  • kpi, text → no flag; neither emits by structure.
# ECharts-based viz (bar, line, pie, scatter, heatmap, funnel, sankey)
chart:
  cross_filter: false

# grid
grid:
  cross_filter: false

# report_matrix
matrix:
  cross_filter: false

Use opt-out for "headline" charts that should always show the unfiltered total — a top-line revenue chart that should not change when the user clicks elsewhere, for instance.

Adapter differences

Cross-filter routing itself is the same on all three adapters. Pill values become query parameters; from that point the same adapter caveats apply as for filter values — see Source adapter differences. Numeric and string pills are unaffected; date / timestamp pills follow the Postgres / MySQL pattern.

Patterns

One drill path per dashboard

Decide which fields users should be able to drill on (typically the dimensions in your group-by clauses) and declare parameters for those in every model used by the dashboard. Skip parameters for fields that should not drive cross-filter — clicks on them silently fall through.

Headline + drill grid

Use a KPI at the top showing the total (KPI does not react to pills, by design). Below it, a bar chart that emits clicks. Below the bar, a grid that consumes the cross-filter and shows the underlying rows. Each click on the bar narrows the grid to the matching subset.

Cross-filter + declared filters together

Mix a date filter at the top of the dashboard (e.g. date_range_preset) with implicit cross-filtering on the chart layout. Both end up as parameters on the same queries.

Shared semantics, per-model signature

Cross-filtering does not force you to keep a dashboard's models in one file. Keep the table, joins, dimensions and measures in a shared model, import it, and re-declare the parameter signature on the source each viz actually runs against. The duplication is one signature line per entry-point model, not the semantics:

# shared/ecommerce.malloy — imported, never targeted by a viz
##! experimental.parameters

source: base() is ecommerce.table('bigquery-public-data.thelook_ecommerce.order_items') extend {
  join_one: users is ecommerce.table('bigquery-public-data.thelook_ecommerce.users')
    on user_id = users.id
  dimension: country is users.country
  measure:   revenue is sum(sale_price)
}
# models/by_category.malloy — a viz's `query` points here
##! experimental.parameters
import "shared/ecommerce.malloy"

source: by_category_src(p_country::string is "all") is base extend {
  view: main is {
    where: (p_country = "all" or country = p_country)
    group_by: category
    aggregate: revenue
  }
}

The pill binds because by_category_src is the first parameterized source in this file and declares p_country here. Keep the signature in the importing file and the shared model stays free of filter plumbing.

A grid that both paginates and cross-filters

This is the one combination that does need its own model file. Row pagination has to happen in SQL (LIMIT / OFFSET over the aggregated result, plus the total-row count), so the model is a .sql("""…""") source — which cannot extend an imported table source, and works at a different grain than the shared line-level source. Give that grid a dedicated model that combines SQL pagination with a NULL-safe SQL-level filter parameter, and let the rest of the dashboard keep using the shared model. See grid for the full shape.