Skip to content

fix(altair): selected_rows() partitions multi-dataset selections by view - #477

Open
cpsievert wants to merge 20 commits into
mainfrom
fix/altair-multi-dataset-selected-rows
Open

cpsievert wants to merge 20 commits into
mainfrom
fix/altair-multi-dataset-selected-rows

Conversation

@cpsievert

Copy link
Copy Markdown
Collaborator

Fixes #471.

Summary

@render_altair's selected_rows() silently mis-filtered selections whose Vega store spans several views: on a layered or concatenated chart whose subcharts use different data frames, every view's selection entries were applied to a single frame, returning the wrong rows when the datasets shared column names (or raising when they didn't).

Now the store is partitioned by the entries' unit (the view name, readable from the chart object), and each view's own data frame is filtered:

  • A selection that touches several views with different data returns a dict of frames keyed by view name. Give subcharts name= for readable keys; altair's generated view_... names are used otherwise.
  • df= also accepts such a dict; passing a single frame for a multi-dataset selection raises instead of silently mis-filtering.
  • Selections touching a single view — or views sharing the identical data frame object — return a single frame, exactly as before. A per-layer selection on a multi-data layered chart (previously unsupported, since chart.data is undefined there) now filters that layer's data.
  • Bare point selections (alt.selection_point() without fields=/encodings=) raise an actionable error on multi-dataset charts: their store holds positional _vgsid_ ids, which Vega assigns globally across the whole chart's dataflow (verified in a live browser: clicking the right chart's 6th point of a two-frame hconcat yields _vgsid_: 16), so they can't be mapped back to one view's rows. Declaring fields=/encodings= makes the selection value-based and fully supported.

Implementation notes

  • named_subcharts() walks layer/concat charts to map view name → subchart (duplicate names raise; facet/repeat charts are out of scope and fail loudly rather than mis-filtering).
  • selection_views() reads the hoisted selection param's views to learn which views the selection participates in, falling back to the store's units when the param isn't readable (e.g. alt.Chart.from_dict charts).
  • The narwhals replay of Vega's selectionTest is unchanged — it's just applied per view via filter_selection_by_unit().

Verification

  • 14 new unit tests: per-unit partitioning, dict return and empty-selection key stability, shared-data-stays-single-frame regression guard, facet regression guard, df= dict/scalar rules, duplicate and unresolvable view errors, vegafusion transformer.
  • A new Playwright test drives real gestures on a multi-dataset hconcat: a brush in the left chart filters only the left frame, and a click in the right chart selects exactly one right row (tests/playwright/altair_accessors).
  • make check is clean (ruff, pyright, 1157 unit tests passing).
import altair as alt
import pandas as pd
from shiny.express import render
from shinywidgets import render_altair

left_df = pd.DataFrame({"x": range(10), "y": range(10)})
right_df = pd.DataFrame({"x": range(10), "z": range(10)})

brush = alt.selection_interval(name="brush")


@render_altair
def chart():
    left = alt.Chart(left_df, name="left").mark_point().encode(x="x", y="y").add_params(brush)
    right = alt.Chart(right_df, name="right").mark_point().encode(x="x", y="z").add_params(brush)
    return alt.hconcat(left, right)


@render.text
def selected():
    rows = chart.selected_rows("brush")  # a dict of frames keyed by view name
    return str({name: len(frame) for name, frame in rows.items()})

This comment was marked as resolved.

…e real selection names (#471)

Two review corrections:
- filter_selection_by_unit() raises the actionable fields=/encodings=
  error for an IndexSelection before partitioning store entries, so a
  bare point selection never surfaces the confusing unknown-unit error.
- Selection fixtures in the new selected_rows tests now carry their
  param's name (as altair's JupyterChart.selections always emits), so
  the tests exercise the primary hoisted-param view resolution rather
  than silently taking the store-unit fallback.
- view_frames() (replacing named_subcharts()/view_frame()) resolves each
  view to the nearest data frame declared on its subchart or an ancestor,
  so a view whose data is hoisted onto an intermediate compound chart no
  longer raises or falls back to the wrong root frame; facet specs are
  walked explicitly.
- A bare point selection (IndexSelection) on any chart with several
  datasets now raises the fields=/encodings= error even when it
  participates in a single view or a scalar df is passed: Vega's _vgsid_
  ids are global to the chart's dataflow, so they aren't row positions
  of any one view's frame.
- A scalar df= is accepted again when a multi-view selection's views all
  share the identical data frame object (restoring pre-PR behavior).
@cpsievert
cpsievert force-pushed the fix/altair-multi-dataset-selected-rows branch from 59a90c5 to a9d3384 Compare November 12, 2026 22:18
@cpsievert
cpsievert requested a balanced review from Copilot November 12, 2026 22:35
@cpsievert
cpsievert marked this pull request as ready for review November 12, 2026 22:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved data-source handling can incorrectly reuse a parent frame and permit unsafe positional selections across mixed sources, causing selected_rows() to return incorrect or empty results.

2 open issues
3 resolved since last review

Review effort: Balanced

Comment thread shinywidgets/_altair.py Resolved
Comment thread shinywidgets/_altair.py Resolved
…ds (#471)

Addresses a second round of Copilot review:

- view_frames() inherits a parent's data frame only when a subchart
  declares no data of its own. An explicit URL or inline alt.Data source
  now resolves to None (requiring df=) instead of silently filtering the
  parent frame, and the single-view selected_rows() path no longer
  substitutes the chart's root frame for an unresolved view.
- chart_datasets() counts every distinct data source (frames by
  identity, URLs by value, anything else explicit by identity), so a
  bare point selection on a mixed frame/URL chart also raises the
  fields=/encodings= error instead of treating global _vgsid_ ids as
  row positions.
@cpsievert

Copy link
Copy Markdown
Collaborator Author

@copilot please re-review

Copilot AI commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

@copilot please re-review

Re-review found two regressions:

  • shinywidgets/_altair.py:106: repeat charts aren't traversed, so selections spanning repeated views now fail even with a scalar df=; previously they filtered that frame.
  • shinywidgets/_render_widget.py:693: views sharing the same URL are treated as different datasets, rejecting a scalar df= that previously worked.

The current PR CI checks pass.

…471)

Addresses a third round of Copilot review (two regressions vs main):

- A selection spanning a repeat chart's generated views failed even
  with a scalar df=. Views view_frames() can't name now resolve to the
  chart's own frame: chart_frame() descends into a repeat chart's spec
  (repeated subcharts share one dataset), and chart_datasets() counts
  data declared on the spec.
- Views sharing a single non-frame dataset (e.g. subcharts over one
  URL) rejected a scalar df= that main accepted. A scalar df= now
  stands in whenever the whole chart declares exactly one dataset.
@cpsievert

Copy link
Copy Markdown
Collaborator Author

@copilot please re-review

Copilot AI commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

@copilot please re-review

Re-review found a remaining silent mis-filter in shinywidgets/_render_widget.py:704–711: if a concatenation has root data frame A and a nested repeat uses frame B, its generated views aren't in view_frames(), so they are assigned A. Brushing the repeat then returns rows from A instead of B. Resolve the repeat views against their own inherited/declared source, or fail rather than falling back to the root frame.

The latest CI run is still in progress; the preceding run passed.

…-dataset chart (#471)

Addresses the latest Copilot re-review: a nested repeat that declares
its own data frame inside a concatenation with root data had its
generated views resolved to the root frame, so a brush on the repeat
silently filtered the wrong rows. Views view_frames() can't name now
resolve to the chart's frame only when the whole chart declares
exactly one dataset; otherwise selected_rows() raises, pointing at
df=.
@cpsievert

Copy link
Copy Markdown
Collaborator Author

@copilot please re-review

Copilot AI commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

@copilot please re-review

Re-review found one remaining mis-filter: in shinywidgets/_render_widget.py:668–697, a compound alt.Chart.from_dict() selection without readable views falls back to the currently populated store units. A global selection can have only the right view in its store even though it participates in both views; selected_rows(df=left_frame) then silently filters the left frame instead of rejecting an ambiguous scalar. Imported parameter names can also be ParameterName objects, so selection_views() may miss even explicitly declared views. Normalize those names and handle unknown participation conservatively on multi-dataset charts.

All Python and Playwright CI jobs passed on the current commit.

…#471)

Addresses the latest Copilot re-review:

- selection_views() missed selections on charts built with
  alt.Chart.from_dict, whose parameter names are schema objects
  (ParameterName) rather than plain strings. Names are now unwrapped
  via to_dict().
- When selection participation is unreadable and selected_rows() falls
  back to the store's units, a scalar df= on a chart with several
  datasets was filtered against whatever units happened to report
  entries -- a silent mis-filter when the selection participates in
  more views than the store shows. It now raises, pointing at a dict
  df= keyed by view name.
@cpsievert

Copy link
Copy Markdown
Collaborator Author

@copilot please re-review

Copilot AI commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

@copilot please re-review

Re-review found two remaining cases:

  • shinywidgets/_render_widget.py:685–689: alt.SelectionParameter(...) naturally has views=[]. Vega applies it to both views of a concatenation, but a selection in the right view filters the root/left frame instead. Treat an empty list as unknown participation too.
  • shinywidgets/_render_widget.py:668–672: with missing view metadata and no df=, inferring views from active store entries still produces an incomplete, unstable result (a single frame or an error before interaction rather than a per-view dict). Resolve participation or reject the ambiguity.

All Python and Playwright CI jobs passed on the current commit.

…lti-dataset charts (#471)

Addresses the latest Copilot re-review:

- An empty views list on a selection param doesn't mean the selection
  participates in no views -- Vega applies it to any view. Treat it as
  unreadable participation (store fallback) instead of filtering the
  chart's root frame.
- The store-unit fallback now rejects multi-dataset charts outright
  (with or without a scalar df=): the store only shows views with
  active selections, so results were incomplete and their shape
  depended on interaction state. The error points at a dict df= keyed
  by view name.
@cpsievert

Copy link
Copy Markdown
Collaborator Author

@copilot please re-review

Copilot AI commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

@copilot please re-review

Re-review found one compatibility regression in shinywidgets/_altair.py:171: two imported NamedData references to the same dataset are counted separately by identity. Consequently, a shared-source hconcat rejects selected_rows(df=frame) as multi-dataset, although Vega uses one source and the scalar frame worked before this PR. Deduplicate named references by dataset name.

CI’s ipydatagrid lifecycle test failed with a transient view count of 6 rather than 5; a retry is already in progress. The Python jobs passed.

Addresses the latest Copilot re-review: two alt.NamedData references to
the same dataset (including on imported charts) were counted as
distinct sources by identity, so a shared-source hconcat rejected a
scalar df= that worked before multi-dataset support. Named references
are now deduplicated by dataset name, like URLs are by value.
Comment thread CHANGELOG.md Resolved
@netlify

netlify Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

❌ Deploy Preview for shinywidgets failed.

Name Link
🔨 Latest commit 63dd16a
🔍 Latest deploy log https://app.netlify.com/projects/shinywidgets/deploys/6af7726de496126f60e27c6f

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

altair: selected_rows(df=) silently mis-filters selections spanning multiple datasets

3 participants