Skip to content

feat: support brush selection in vega-lite chart - #689

Closed
lixun910 wants to merge 3 commits into
mainfrom
xli-support-vega-brush-selection
Closed

lixun910 wants to merge 3 commits into
mainfrom
xli-support-vega-brush-selection

Conversation

@lixun910

@lixun910 lixun910 commented Jun 4, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Add brush selection functionality to Vega-Lite chart components and integrate with map context.

Cherry-picked from commit ef42ab5dc964c084f6791e62e052fbc4fca971e4.

  1. AI creates a chart with e.g.:
    sqlQuery: "SELECT city, AVG(price) AS avg_price FROM sales GROUP BY city"
    vegaLiteSpec: bar chart with x=city (nominal), y=avg_price (quantitative)
│ city     │ avg_price │
│ NYC      │ 35        │
│ LA       │ 22        │
│ SF       │ 45        │
│ Chicago  │ 18        │

Note: if there are more than 1 table involved in vega-chart, then brush disabled. We can instruct AI to always save the complex query to one table for charting.

  1. VegaLiteArrowChart detects:
    x is nominal, y is quantitative
    → injects interval brush on y-axis only

  2. User drags a brush on the y-axis selecting avg_price range [20, 40]
    → Vega emits signal: { avg_price: [20, 40] }

Note: for e.g. pie chart without x, y encoding, it will fall back to point selection and normalizePointSignal() will be used to return all matching source row indices

  1. KeplerVegaChartToolResult receives the signal:
    First time → calls computeBrushMapping(sqlQuery)
    - Extracts table: "sales"
    - Extracts non-aggregate aliases: { city → "city" }
    (avg_price is skipped — it's an aggregate)
    - Queries: SELECT row_number()-1 AS __row_idx, city FROM sales
    - Builds mapping: { city: { "NYC": [0,3,7], "LA": [1,4], ... } }

Note: if there is no matching map layer: A dimmed/muted mouse-pointer icon with tooltip "No map layer linked". No brush interaction is possible on the chart.

  1. resolveBrushToRowIndices(mapping, { avg_price: [20, 40] }, chartData)

Step A — avg_price is NOT in the mapping → aggregate bridge kicks in
Step B — Scan the chart's Arrow table for rows where avg_price ∈ [20, 40]:

       NYC     → 35 ✓
       LA      → 22 ✓
       SF      → 45 ✗
       Chicago → 18 ✗

Step C — Collect the group-by keys (fields that ARE in the mapping):

       Matching groups: ["NYC", "LA"]

Step D — Look up group keys in the existing mapping:

       city.NYC     → source rows [0, 3, 7]
       city.LA      → source rows [1, 4]

Step E — Union: [0, 1, 3, 4, 7]

  1. highlightRows() sets dataset.filteredIndex = [0, 1, 3, 4, 7]
    → Kepler re-renders showing only the matching points on the map

  2. User clears the brush (clicks background)
    → Vega emits signal: {}
    → resolveBrushToRowIndices returns []
    → highlightRows resets filteredIndex to allIndexes
    → Map shows all points again

Changes

  • Add brush selection support to VegaLiteArrowChart and VegaChartDisplay components
  • Add brushMappingUtils for mapping brush ranges to data fields
  • Add KeplerVegaChartToolResult component for Kepler-Vega integration
  • Expose onBrushSelection and brushAvailable props in VegaChartToolResult
  • Update exports in kepler and vega packages

Summary by CodeRabbit

Release Notes

  • New Features

    • Added interactive chart brush selection with visual indicator showing map integration availability
    • Chart selections now automatically synchronize and highlight corresponding dataset rows in Kepler.gl map layers
  • Improvements

    • Enhanced context message formatting in summarization workflow

@coderabbitai

coderabbitai Bot commented Jun 4, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds Vega brush selection support with DuckDB-backed row-index mapping, threads brush callbacks through the Vega editor context and tool result UI, and synchronizes brush selections to Kepler.gl via a new KeplerVegaChartToolResult wrapper; plus a small prompt-text reorder in ai-core.

Changes

Vega Brush Selection Infrastructure

Layer / File(s) Summary
Brush type contracts and foundations
packages/vega/src/VegaChartTool.tsx, packages/vega/src/brushMappingUtils.ts
Introduces BrushFieldMapping and DuckDB-backed utilities (extractTableNameFromSql, computeBrushMapping, resolveBrushToRowIndices) for mapping chart fields to source-row indices.
VegaLiteArrowChart brush signal integration
packages/vega/src/VegaLiteArrowChart.tsx
Injects brush selection params into Vega-Lite specs, infers interval vs point mode, registers debounced signal listeners, and emits VegaBrushSelectionRanges to consumers.
Vega editor context threading
packages/vega/src/editor/*
Adds VegaBrushSelectionRanges type, extends VegaEditorContext with optional onBrushSelection, threads the callback via VegaChartContainer to VegaChartDisplay and down to charts.
VegaChartToolResult brush UI and integration
packages/vega/src/VegaChartToolResult.tsx
Adds onBrushSelection and brushAvailable props, wires the callback into the container, and conditionally renders a tooltip indicator for brush-to-map availability.
Vega public API exports
packages/vega/src/index.ts
Re-exports brush types and utilities (VegaChartToolResultProps, VegaBrushSelectionRanges, BrushFieldMapping, extractTableNameFromSql, computeBrushMapping, resolveBrushToRowIndices).

Kepler.gl Map Integration

Layer / File(s) Summary
KeplerVegaChartToolResult component
packages/kepler/src/components/KeplerVegaChartToolResult.tsx
Wraps VegaChartToolResult to resolve table names from SQL, lazily compute/cache brush mappings, translate brush ranges to row indices, set filteredIndex on the Kepler dataset, and mark related layers valid while reformatting layer data.
Kepler package exports and dependency
packages/kepler/src/index.ts, packages/kepler/package.json
Exports KeplerVegaChartToolResult and adds @sqlrooms/vega as a workspace dependency.

Minor Updates

Layer / File(s) Summary
Context usage indicator prompt reordering
packages/ai-core/src/components/ContextUsageIndicator.tsx
Reorders the follow-up prompt to place the summarized context first, then instructs the next reply to be exactly "Got it".

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • sqlrooms/sqlrooms#488: Adds notebook-cell crossfilter state management and SelectionListener driven by the same brush signal infrastructure introduced in this PR.

Suggested reviewers

  • ilyabo
  • dmitriy-kostianetskiy

"🐰 I sniff the SQL rows,
I nudge the Vega brush with care,
Kepler lights the places,
Filters hum and layers share,
Got it — a hop, a map, a stare."

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main feature being added: brush selection support for Vega-Lite charts, which is the primary objective of this PR across all modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch xli-support-vega-brush-selection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
packages/vega/src/brushMappingUtils.ts (2)

61-80: ⚡ Quick win

SQL string escaping is correct; static analysis flags are false positives.

The escapeSqlLiteral function properly doubles single quotes (SQL standard), and json_serialize_sql is used for parsing, not execution. The table name derived from the parsed AST is then properly identifier-quoted (line 246). However, this pattern assumes the chart SQL comes from a trusted source (the LLM tool).

Consider adding a comment documenting the trust boundary:

/**
 * Parse a single SQL SELECT statement into DuckDB's JSON AST. Returns `null`
 * when the SQL is invalid or does not contain a SELECT statement.
 * 
 * `@param` sql - Chart SQL from the Vega tool (trusted LLM output)
 */
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vega/src/brushMappingUtils.ts` around lines 61 - 80, The
parseSelectStatement function currently uses escapeSqlLiteral and
json_serialize_sql to parse chart SQL but the trust boundary isn’t documented;
add a JSDoc comment immediately above parseSelectStatement explaining it parses
a single SQL SELECT into DuckDB’s JSON AST, returns null on invalid/non-SELECT
SQL, and that the input sql is expected to be trusted chart SQL produced by the
Vega/LLM tool (note that escapeSqlLiteral doubles single quotes and identifiers
are quoted later); reference parseSelectStatement, escapeSqlLiteral, and
json_serialize_sql in the comment for clarity.

346-350: 💤 Low value

Consider optimizing Set intersection for large row sets.

The current approach creates a new Set and spreads arrays on each iteration. For large row sets, a mutating approach would be more efficient:

⚡ Optional optimization
 let intersection = new Set(fieldResults[0]!);
 for (let i = 1; i < fieldResults.length; i++) {
   const next = new Set(fieldResults[i]!);
-  intersection = new Set([...intersection].filter((x) => next.has(x)));
+  for (const x of intersection) {
+    if (!next.has(x)) intersection.delete(x);
+  }
 }
 return [...intersection];

The current code is correct and readable; this optimization is only beneficial if brush mappings involve thousands of rows across multiple fields.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vega/src/brushMappingUtils.ts` around lines 346 - 350, The current
Set intersection in brushMappingUtils.ts creates new Sets and spreads elements
on each loop (variables: intersection, fieldResults), which is inefficient for
very large row sets; change to an in-place/mutating intersection: initialize
intersection from the smallest fieldResults[i] (choose the smallest array length
to start), then for each subsequent fieldResults create a Set or use its Set
view and iterate over intersection with for...of, deleting entries not present
in the next set (i.e., if (!next.has(val)) intersection.delete(val)); this
avoids allocating a new Set on every iteration and reduces work for large
datasets while preserving correctness.
packages/kepler/src/components/KeplerVegaChartToolResult.tsx (2)

59-61: ⚖️ Poor tradeoff

Verify selector stability to avoid unnecessary effect re-runs.

The getConnector selector at line 59-61 is used as a dependency in the effect at lines 66-85. Per coding guidelines, Zustand selectors should select stable raw state first rather than derived values. If state.db.getConnector changes reference on store updates (even when the function itself hasn't changed), this effect will cancel and re-run unnecessarily.

Consider verifying that state.db.getConnector is a stable reference in the store implementation. If not, you may need custom equality or selector refactoring.

As per coding guidelines: "When using Zustand or room-store selectors in React, do not create derived arrays, objects, or functions inside the selector; instead select stable raw state first and derive values with React.useMemo"

Also applies to: 66-85

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/kepler/src/components/KeplerVegaChartToolResult.tsx` around lines 59
- 61, The selector passed to useStoreWithKepler pulls a derived function
(state.db.getConnector) which may change reference and cause the effect that
depends on getConnector to re-run; change the selector to select a stable raw
value (e.g., select state.db or another primitive stable property) and then
derive the getConnector function inside the component with React.useMemo or
useCallback before using it in the effect, or supply a custom equality
comparator to useStoreWithKepler to compare by identity; update references to
getConnector in the effect that currently uses getConnector so they use the
memoized/derived function instead.

17-39: ⚖️ Poor tradeoff

Kepler.gl doesn’t provide a public action to set dataset.filteredIndex, so this mutation in highlightRows is likely the intended workaround

Kepler’s filtering is managed via visState/filters, and there’s no setFilteredIndex/updateFilteredIndex-style action available in the API. Given that, the direct dataset.filteredIndex = ... mutation followed by layer.formatLayerData(datasets) and layerSetIsValid is a reasonable approach here. The main improvement would be to add an explicit comment in highlightRows explaining why filteredIndex must be set directly (and why it can’t be replaced with setFilter/related actions).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/kepler/src/components/KeplerVegaChartToolResult.tsx` around lines 17
- 39, The function highlightRows directly mutates dataset.filteredIndex (in
highlightRows) because Kepler.gl exposes no public action to set filteredIndex
via visState/filters; add a concise inline comment above the assignment to
explain this explicit mutation as an intentional workaround (mention that there
is no setFilteredIndex/setFilter action available and that we call
layer.formatLayerData(datasets) and dispatchLayerSetIsValid(layer, true)
afterwards to refresh layers), so future readers understand why we must set
dataset.filteredIndex directly instead of using visState actions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/vega/src/VegaChartToolResult.tsx`:
- Around line 95-111: The TooltipTrigger currently wraps a non-focusable div so
keyboard users cannot reach the tooltip; change the TooltipTrigger child from a
div to a focusable element (e.g., a <button type="button">) or add tabIndex and
proper role, and keep the existing aria-label logic tied to brushAvailable;
ensure the element uses aria-disabled when brushAvailable is false (or disabled
on button), preserves the cn classes and the SquareDashedMousePointer icon, and
includes type="button" if using a button to avoid form submission.

---

Nitpick comments:
In `@packages/kepler/src/components/KeplerVegaChartToolResult.tsx`:
- Around line 59-61: The selector passed to useStoreWithKepler pulls a derived
function (state.db.getConnector) which may change reference and cause the effect
that depends on getConnector to re-run; change the selector to select a stable
raw value (e.g., select state.db or another primitive stable property) and then
derive the getConnector function inside the component with React.useMemo or
useCallback before using it in the effect, or supply a custom equality
comparator to useStoreWithKepler to compare by identity; update references to
getConnector in the effect that currently uses getConnector so they use the
memoized/derived function instead.
- Around line 17-39: The function highlightRows directly mutates
dataset.filteredIndex (in highlightRows) because Kepler.gl exposes no public
action to set filteredIndex via visState/filters; add a concise inline comment
above the assignment to explain this explicit mutation as an intentional
workaround (mention that there is no setFilteredIndex/setFilter action available
and that we call layer.formatLayerData(datasets) and
dispatchLayerSetIsValid(layer, true) afterwards to refresh layers), so future
readers understand why we must set dataset.filteredIndex directly instead of
using visState actions.

In `@packages/vega/src/brushMappingUtils.ts`:
- Around line 61-80: The parseSelectStatement function currently uses
escapeSqlLiteral and json_serialize_sql to parse chart SQL but the trust
boundary isn’t documented; add a JSDoc comment immediately above
parseSelectStatement explaining it parses a single SQL SELECT into DuckDB’s JSON
AST, returns null on invalid/non-SELECT SQL, and that the input sql is expected
to be trusted chart SQL produced by the Vega/LLM tool (note that
escapeSqlLiteral doubles single quotes and identifiers are quoted later);
reference parseSelectStatement, escapeSqlLiteral, and json_serialize_sql in the
comment for clarity.
- Around line 346-350: The current Set intersection in brushMappingUtils.ts
creates new Sets and spreads elements on each loop (variables: intersection,
fieldResults), which is inefficient for very large row sets; change to an
in-place/mutating intersection: initialize intersection from the smallest
fieldResults[i] (choose the smallest array length to start), then for each
subsequent fieldResults create a Set or use its Set view and iterate over
intersection with for...of, deleting entries not present in the next set (i.e.,
if (!next.has(val)) intersection.delete(val)); this avoids allocating a new Set
on every iteration and reduces work for large datasets while preserving
correctness.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 00e5baf5-e3e2-403b-b793-d8ecd79091bd

📥 Commits

Reviewing files that changed from the base of the PR and between e8bbdcd and 8c349f7.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (12)
  • packages/ai-core/src/components/ContextUsageIndicator.tsx
  • packages/kepler/package.json
  • packages/kepler/src/components/KeplerVegaChartToolResult.tsx
  • packages/kepler/src/index.ts
  • packages/vega/src/VegaChartTool.tsx
  • packages/vega/src/VegaChartToolResult.tsx
  • packages/vega/src/VegaLiteArrowChart.tsx
  • packages/vega/src/brushMappingUtils.ts
  • packages/vega/src/editor/VegaChartContainer.tsx
  • packages/vega/src/editor/VegaChartDisplay.tsx
  • packages/vega/src/editor/types.ts
  • packages/vega/src/index.ts

Comment on lines +95 to +111
<TooltipTrigger asChild>
<div
className={cn(
'flex h-6 w-6 items-center justify-center rounded-sm',
brushAvailable
? 'text-primary'
: 'text-muted-foreground/40',
)}
aria-label={
brushAvailable
? 'Brush to highlight map layers'
: 'No map layer linked'
}
>
<SquareDashedMousePointer className="h-3.5 w-3.5" />
</div>
</TooltipTrigger>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use a focusable trigger for brush-status tooltip.

Line 95 uses a non-focusable div as the TooltipTrigger child, so keyboard users can’t reach the tooltip content.

Suggested fix
-                    <TooltipTrigger asChild>
-                      <div
+                    <TooltipTrigger asChild>
+                      <button
+                        type="button"
                         className={cn(
                           'flex h-6 w-6 items-center justify-center rounded-sm',
                           brushAvailable
                             ? 'text-primary'
                             : 'text-muted-foreground/40',
                         )}
                         aria-label={
                           brushAvailable
                             ? 'Brush to highlight map layers'
                             : 'No map layer linked'
                         }
                       >
                         <SquareDashedMousePointer className="h-3.5 w-3.5" />
-                      </div>
+                      </button>
                     </TooltipTrigger>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<TooltipTrigger asChild>
<div
className={cn(
'flex h-6 w-6 items-center justify-center rounded-sm',
brushAvailable
? 'text-primary'
: 'text-muted-foreground/40',
)}
aria-label={
brushAvailable
? 'Brush to highlight map layers'
: 'No map layer linked'
}
>
<SquareDashedMousePointer className="h-3.5 w-3.5" />
</div>
</TooltipTrigger>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'flex h-6 w-6 items-center justify-center rounded-sm',
brushAvailable
? 'text-primary'
: 'text-muted-foreground/40',
)}
aria-label={
brushAvailable
? 'Brush to highlight map layers'
: 'No map layer linked'
}
>
<SquareDashedMousePointer className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vega/src/VegaChartToolResult.tsx` around lines 95 - 111, The
TooltipTrigger currently wraps a non-focusable div so keyboard users cannot
reach the tooltip; change the TooltipTrigger child from a div to a focusable
element (e.g., a <button type="button">) or add tabIndex and proper role, and
keep the existing aria-label logic tied to brushAvailable; ensure the element
uses aria-disabled when brushAvailable is false (or disabled on button),
preserves the cn classes and the SquareDashedMousePointer icon, and includes
type="button" if using a button to avoid form submission.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/vega/src/VegaLiteArrowChart.tsx (1)

100-144: 💤 Low value

mode property leaks into Vega-Lite spec params.

buildBrushParam returns {name, select, mode} where mode is only used internally for selecting the signal handler type. However, the entire object is added to params at line 240, including mode which isn't a valid Vega-Lite param property.

While Vega-Lite ignores unknown properties, consider stripping mode when injecting the param:

♻️ Suggested fix
 if (onBrushSelection) {
   const brushParam = buildBrushParam(base as Record<string, unknown>);
   if (brushParam) {
+    const {mode: _, ...paramForSpec} = brushParam;
     const existingParams = Array.isArray(base.params) ? base.params : [];
     const hasBrush = existingParams.some(
       (p: {name?: string}) => p.name === BRUSH_PARAM_NAME,
     );
     if (!hasBrush) {
-      base.params = [...existingParams, brushParam];
+      base.params = [...existingParams, paramForSpec];
     }
   }
 }

Also applies to: 232-243

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vega/src/VegaLiteArrowChart.tsx` around lines 100 - 144,
buildBrushParam currently returns an object containing a mode property which is
internal and gets injected into the Vega-Lite params (via the code that pushes
the returned object into params), leaking an invalid property; change the
implementation to keep mode internal by returning only {name, select} from
buildBrushParam (or keep buildBrushParam as-is but strip mode before adding to
the params array), and surface the mode separately to the code that installs the
signal handler (use BRUSH_PARAM_NAME to map back to the mode when needed) so
that the object pushed into params contains no mode field.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/vega/src/VegaLiteArrowChart.tsx`:
- Around line 100-144: buildBrushParam currently returns an object containing a
mode property which is internal and gets injected into the Vega-Lite params (via
the code that pushes the returned object into params), leaking an invalid
property; change the implementation to keep mode internal by returning only
{name, select} from buildBrushParam (or keep buildBrushParam as-is but strip
mode before adding to the params array), and surface the mode separately to the
code that installs the signal handler (use BRUSH_PARAM_NAME to map back to the
mode when needed) so that the object pushed into params contains no mode field.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 27abaf94-7ef5-428a-a33b-07b1958546e2

📥 Commits

Reviewing files that changed from the base of the PR and between 8c349f7 and 57028a8.

📒 Files selected for processing (3)
  • packages/vega/src/VegaLiteArrowChart.tsx
  • packages/vega/src/editor/VegaChartContainer.tsx
  • packages/vega/src/editor/types.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/vega/src/editor/types.ts
  • packages/vega/src/editor/VegaChartContainer.tsx

@ilyabo

ilyabo commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

@lixun910 this is looking pretty cool, only I am bit worried that it is adding confusion because we are currently working on mosaic-based charts and cross-filtering dashboards.

I asked gpt-5.5 about it and also to review the PR:


Yes, I think it risks adding confusion right now, especially in the shadow of Mosaic-based charts and dashboard cross-filtering.

The feature itself is desirable: chart selection driving map/dashboard state is absolutely in SQLRooms’ direction. But this PR implements a fairly bespoke Vega-to-Kepler bridge: parse chart SQL, infer one source table, rewrite SQL into a row-index mapping query, mutate Kepler dataset filtering, and expose that through a specialized KeplerVegaChartToolResult. That is a lot of local machinery for one integration path.

My concern is not “brush selection is bad.” It is that this creates a second cross-filtering model before the Mosaic/dashboard story has settled. If Mosaic charts and dashboards are where cross-filtering should live, then this PR may become an attractive but awkward parallel path: Vega chart brush signals go through SQL AST mapping into Kepler row indices, while Mosaic/dashboard filters likely want shared, inspectable filter state and composable query predicates.

So my read:

  • Net positive as a prototype: yes, it explores a useful interaction.
  • Net positive to land as-is: no, I would not.
  • Accidental complexity risk: high.
  • Implementation soundness: not yet, because the aggregate example and multi-table guard do not match the code.
  • Strategic fit: shaky unless it is reframed around a common selection/filter primitive.

The better direction, I think, is to extract the core idea into a small shared concept: “chart emits selection over fields,” then let dashboards/maps/Mosaic consume that through common filter state or commands. Vega and Mosaic can both be producers. Kepler can be one consumer. That keeps the behavior composable instead of baking cross-filtering into a Vega-Kepler one-off.


As written, I would not land this PR. The direction is net-positive and the base Vega behavior is mostly opt-in: VegaChartToolResult only injects brush params when onBrushSelection is provided. It is not wholesale for all charts.

But the Kepler wrapper makes the new behavior effectively automatic for charts whose SQL table name matches a map layer, and the implementation is not sound enough yet. The main aggregate use case in the PR description is missing in code, and joins/multi-table queries are not disabled despite the PR saying they are.

Findings

  1. Blocker: the headline aggregate brush case does not work. In packages/vega/src/brushMappingUtils.ts:306, resolveBrushToRowIndices() only resolves fields that already exist in mapping. But computeBrushMapping() intentionally skips aggregate fields at packages/vega/src/brushMappingUtils.ts:233, so a brush like { avg_price: [20, 40] } is ignored and returns []. KeplerVegaChartToolResult calls it with only mapping and ranges at packages/kepler/src/components/KeplerVegaChartToolResult.tsx:173, so the “aggregate bridge” described in the PR body is not implemented.

  2. Blocker: multi-table/join SQL is not actually disabled. findBaseTableName() just returns the leftmost table at packages/vega/src/brushMappingUtils.ts:86, and hasMatchingLayer marks the chart brushable if that table has a map layer at packages/kepler/src/components/KeplerVegaChartToolResult.tsx:92. But computeBrushMapping() then builds row indices from the rewritten full query at packages/vega/src/brushMappingUtils.ts:256, so joins can produce row numbers that do not correspond to the Kepler dataset’s source rows. That can highlight wrong map rows.

  3. Correctness risk: chart SQL filters are stripped. The mapping query explicitly drops WHERE, HAVING, QUALIFY, and grouping at packages/vega/src/brushMappingUtils.ts:263. That means brushing a chart built from filtered data can highlight rows that were not part of the chart. Maybe that is intended, but it is surprising and should be made explicit or fixed.

  4. Accidental complexity: lockfile churn looks unrelated. pnpm-lock.yaml changes much more than adding @sqlrooms/vega to @sqlrooms/kepler, including peer resolution and optional platform metadata changes. I would regenerate it with the repo’s expected pnpm version and keep only necessary dependency changes.


@lixun910
lixun910 marked this pull request as draft June 5, 2026 18:14
@lixun910 lixun910 closed this Jun 19, 2026
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