Skip to content

Show visualization brush filters in the filter toolbar#37

Open
Dev-Lan wants to merge 10 commits into
mainfrom
feature/show-viz-filters-in-toolbar-and-chat
Open

Show visualization brush filters in the filter toolbar#37
Dev-Lan wants to merge 10 commits into
mainfrom
feature/show-viz-filters-in-toolbar-and-chat

Conversation

@Dev-Lan

@Dev-Lan Dev-Lan commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Mirror each visualization's own brush selection from selectionsStore into dataFiltersStore.internalDataSelections under a viz-brush-<uuid> key so brushes render as removable chips in FilterToolbar alongside LLM-originated filters.
  • Clicking a brush chip's X button clears both stores: selectionsStore (the authoritative brush state that UDIVis reads, set to null) and dataFiltersStore via the existing clearFilter action.
  • Cross-chart filtering is unchanged — it still flows through the Pinia DataSourcesStore + named-filter entries in each viz's interactiveSpec.transformation. The mirror is purely for toolbar display.

Refs hms-dbmi/udi-yac#24

Partial: chat adjustment widget deferred

The spec also asked for a chat-side adjustment widget for brush filters. Each FilterComponent today is tied to a FilterData tool-call message (messageFilterKey), so surfacing brush filters there needs a design decision (synthetic message? floating widget? new sibling panel?). I left the issue open (Refs hms-dbmi/udi-yac#24 rather than Fixes) so the remaining half can be scoped and shipped in a follow-up — happy to sketch options if useful.

Changes

  • src/features/dashboard/components/DashboardCard.tsxhandleSelectionChange now additionally calls dataFiltersStore.updateInternalDataSelections({ 'viz-brush-<uuid>': own }) with the viz's own brush (empty selection when the brush clears). Comment updated to describe the new display-only mirror.
  • src/features/dashboard/components/FilterToolbar.tsx — add a BRUSH_KEY_PREFIX constant and a handleClearChip helper that clears selectionsStore as well when the chip's key starts with viz-brush-, then delegates to the existing clearFilter.

Spec

.claude/todo/done/show-viz-filters-in-toolbar-and-chat.md

Test plan

  • Brush on a dashboard visualization: a chip appears in the filter toolbar with the viz's source/field/range.
  • Click the chip's X: the chart's brush clears and the chip disappears.
  • Clear the brush by clicking empty space inside the chart: chip disappears without leaving stale state.
  • Create an LLM-originated filter via the chat (FilterData tool call): it continues to appear in the toolbar as before, no regression.
  • Multiple vizzes brushed at once: each produces its own chip; clearing one does not clear others.
  • Reset the conversation: all brush chips disappear.
  • npm run test passes (145 tests).

Notes

  • Pre-existing typecheck errors on main (17 errors in dashboard/data-package stores and tests) are unrelated; no new typecheck errors introduced.
  • The as any cast in FilterToolbar.handleClearChip is narrowly scoped and documented — it exists because selectionsStore.updateSelections uses udi-toolkit's stricter DataSelections shape, while the clear path only needs to write { selection: null } to trigger a remove.

🤖 Generated with Claude Code

Mirror each viz's own brush selection from selectionsStore into
dataFiltersStore.internalDataSelections under a `viz-brush-<uuid>` key so
brushes appear as removable chips alongside LLM-originated filters.

When a user clicks a brush chip's X button, the FilterToolbar clears both
stores: selectionsStore (the authoritative brush state that UDIVis reads)
by setting selection to null, and dataFiltersStore via clearFilter.
Cross-chart filtering continues to flow through the Pinia DataSourcesStore
+ named-filter entries in each viz's interactiveSpec.transformation —
the mirror is display-only.

Deferred: a chat-side adjustment widget for brush filters. Each
FilterComponent today is tied to a FilterData tool-call message, so
surfacing brush filters there needs more design work and is out of scope.

Refs #29

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines +55 to +56
// eslint-disable-next-line @typescript-eslint/no-explicit-any
selectionsStore.getState().updateSelections({ [uuid]: { selection: null } as any });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could we annotate this with as ActiveDataSelection instead? That should help avoid the any.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

actually may be worth it to even just add a updateSelection function to the selectionsStore which just takes a uuid and an ActiveDataSelection so we don't have to do these assertions anywhere

Dev-Lan and others added 2 commits April 29, 2026 21:09
The X button on a viz-brush chip clears selectionsStore[viz.uuid] and
the chip-mirror in dataFiltersStore, but Vega manages its own brush
rectangle internally and there's no API on UDIVis to ask the chart to
drop the rectangle. Detect the own-brush-going-from-present-to-absent
transition during render and bump a brushClearedCount that's part of
the UDIVis key. The chart then remounts with no brush state, so the
visible rectangle disappears together with the chip.

Uses React's "store information from previous renders" pattern
(conditional setState during render gated by a transition check) so
the cascade-renders lint rule doesn't fire.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Brushes now appear in the chat as user-side filter bubbles that share
the same FilterComponent UI as LLM-originated filter widgets. The flow:

- DashboardCard's brush mirror now writes to dataFiltersStore.dataSelections
  under `viz-brush-<uuid>` (instead of internalDataSelections), so
  FilterComponent — which keys off dataSelections — can read brush state
  from the existing lookup.
- FilterToolbar's chip filter now lets `viz-brush-*` entries through the
  prefix check; previous reliance on internalDataSelections is preserved
  for any other internal filter sources.
- MessageList synthesizes a Message via generateFilterMessage for each
  viz-brush entry with non-empty selection and renders it through
  MessageBubble at the end of the conversation. The synthetic messages
  live only at render time — they aren't appended to conversationStore,
  so they don't bleed into the LLM prompt.
- FilterComponent disables tweakable for brush-derived widgets so the
  entity/field dropdowns are hidden; rebinding a brush to a different
  entity would silently detach it from its source viz.
- UDIChat adds a mirror effect that round-trips dataSelections[viz-brush-*]
  back to selectionsStore so chat-side slider drags propagate to
  cross-chart filtering. updateSelections' JSON-equality check
  short-circuits no-op writes.
- generateFilterMessage is exported from the dashboard feature barrel.

Limitation: Vega manages each chart's brush rectangle internally, so a
chat-side slider drag updates the data filtering but does not move the
visible rectangle on the source viz. Clearing via the FilterToolbar X
remounts the source viz (handled in 381628e), but no equivalent path
exists for incremental adjustments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dev-Lan and others added 7 commits June 25, 2026 10:50
…ers-in-toolbar-and-chat

# Conflicts:
#	src/app/UDIChat.tsx
#	src/features/chat/components/MessageList.tsx
#	src/features/dashboard/components/DashboardCard.tsx
#	src/features/dashboard/components/FilterToolbar.tsx
Synthetic brush-filter adjustment widgets were all rendered in a single
block after the entire conversation. Map each brush's source-viz UUID to
the message index that produced it and render the widget inline right
after that visualization, so it keeps its position in the conversation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Vega re-emits a viz's brush selection on every data update, and
DashboardCard mirrors each emit into dataFiltersStore via setDataSelection,
which unconditionally allocated a new dataSelections object. That reference
change re-rendered every dashboard card, prompting Vega to re-emit again —
a feedback loop that locked up the page after interacting with a couple of
visualizations.

Guard setDataSelection so an identical value is a no-op, breaking the loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The brush->selections mirror skipped any viz-brush entry whose selection
was empty, so unchecking every value in a categorical filter widget (or
clearing a range) never reached selectionsStore: the stale filter stayed
applied and the adjustment appeared to do nothing. Mirror an emptied
selection as an explicit null instead, which updateSelections turns into a
filter removal (and lets DashboardCard remount to drop the chart's brush).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The stack trace pinned the "Maximum update depth exceeded" crash to
IntervalFilterComponent's base-ui Slider: a quantitative brush widget would
hard-crash the app (taking down the whole UI via the ErrorBoundary) as soon
as it mounted.

Cause: localRange was synced from the store via
  useEffect(() => setLocalRange(storeRange), [storeRange])
where storeRange was a freshly-allocated array memoized on
dataSelection.selection. That selection object gets replaced with an
equal-valued copy on unrelated store updates, so the effect fired every
render and handed the controlled Slider a new value array each time,
driving base-ui's thumb effect into an unbounded setState loop.

Sync during render instead (React's "info from previous render" pattern,
as already used in DashboardCard), keyed on the primitive min/max bounds so
equal-valued churn no longer reaches the Slider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…op crash)

thumbAlignment="edge" puts base-ui's Slider in "inset" mode, which:
  - positions the filled Indicator from a DOM-measured indicatorPosition
    rather than the values, and that measurement was rendering the range
    fill inverted (selected middle empty, outside filled); and
  - runs a layout effect (getInsetPosition) that calls setIndicatorPosition
    /setPositionPercent off getBoundingClientRect on every value change. When
    a viz brush streams rapid value updates into the controlled Slider, that
    effect re-fires unboundedly -> "Maximum update depth exceeded", which the
    ErrorBoundary turns into a full-app crash.

Removing the prop falls back to the default "center" alignment (inset=false):
the Indicator fill is computed directly from valueToPercent(values) — filling
between the two thumbs — and the inset measurement effect is inert, so there
is no setState loop. Fixes the inverted range fill and the crash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Temporary visual marker to confirm the running app reflects the latest
pushed commits. Revert once verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants