Repository navigation
Conversation
…integrate with context
📝 WalkthroughWalkthroughThis 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. ChangesVega Brush Selection Infrastructure
Kepler.gl Map Integration
Minor Updates
🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (4)
packages/vega/src/brushMappingUtils.ts (2)
61-80: ⚡ Quick winSQL string escaping is correct; static analysis flags are false positives.
The
escapeSqlLiteralfunction properly doubles single quotes (SQL standard), andjson_serialize_sqlis 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 valueConsider 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 tradeoffVerify selector stability to avoid unnecessary effect re-runs.
The
getConnectorselector 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. Ifstate.db.getConnectorchanges 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.getConnectoris 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 tradeoffKepler.gl doesn’t provide a public action to set
dataset.filteredIndex, so this mutation inhighlightRowsis likely the intended workaroundKepler’s filtering is managed via
visState/filters, and there’s nosetFilteredIndex/updateFilteredIndex-style action available in the API. Given that, the directdataset.filteredIndex = ...mutation followed bylayer.formatLayerData(datasets)andlayerSetIsValidis a reasonable approach here. The main improvement would be to add an explicit comment inhighlightRowsexplaining whyfilteredIndexmust be set directly (and why it can’t be replaced withsetFilter/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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (12)
packages/ai-core/src/components/ContextUsageIndicator.tsxpackages/kepler/package.jsonpackages/kepler/src/components/KeplerVegaChartToolResult.tsxpackages/kepler/src/index.tspackages/vega/src/VegaChartTool.tsxpackages/vega/src/VegaChartToolResult.tsxpackages/vega/src/VegaLiteArrowChart.tsxpackages/vega/src/brushMappingUtils.tspackages/vega/src/editor/VegaChartContainer.tsxpackages/vega/src/editor/VegaChartDisplay.tsxpackages/vega/src/editor/types.tspackages/vega/src/index.ts
| <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> |
There was a problem hiding this comment.
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.
| <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.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/vega/src/VegaLiteArrowChart.tsx (1)
100-144: 💤 Low value
modeproperty leaks into Vega-Lite spec params.
buildBrushParamreturns{name, select, mode}wheremodeis only used internally for selecting the signal handler type. However, the entire object is added toparamsat line 240, includingmodewhich isn't a valid Vega-Lite param property.While Vega-Lite ignores unknown properties, consider stripping
modewhen 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
📒 Files selected for processing (3)
packages/vega/src/VegaLiteArrowChart.tsxpackages/vega/src/editor/VegaChartContainer.tsxpackages/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
|
@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 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:
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: 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
|
Summary
Add brush selection functionality to Vega-Lite chart components and integrate with map context.
Cherry-picked from commit
ef42ab5dc964c084f6791e62e052fbc4fca971e4.sqlQuery: "SELECT city, AVG(price) AS avg_price FROM sales GROUP BY city"
vegaLiteSpec: bar chart with x=city (nominal), y=avg_price (quantitative)
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.
VegaLiteArrowChart detects:
x is nominal, y is quantitative
→ injects interval brush on y-axis only
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 selectionand normalizePointSignal() will be used to return all matching source row indicesFirst 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.
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]:
Step C — Collect the group-by keys (fields that ARE in the mapping):
Step D — Look up group keys in the existing mapping:
Step E — Union: [0, 1, 3, 4, 7]
highlightRows() sets dataset.filteredIndex = [0, 1, 3, 4, 7]
→ Kepler re-renders showing only the matching points on the map
User clears the brush (clicks background)
→ Vega emits signal: {}
→ resolveBrushToRowIndices returns []
→ highlightRows resets filteredIndex to allIndexes
→ Map shows all points again
Changes
VegaLiteArrowChartandVegaChartDisplaycomponentsbrushMappingUtilsfor mapping brush ranges to data fieldsKeplerVegaChartToolResultcomponent for Kepler-Vega integrationonBrushSelectionandbrushAvailableprops inVegaChartToolResultSummary by CodeRabbit
Release Notes
New Features
Improvements