From 4a5a82d300ed01867a79b85f5c6fe27e04a45180 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Jun 2026 08:54:57 +0000 Subject: [PATCH 1/2] feat(charts): add dual-axis combo chart mode Adds a 'combo' chart type that mixes bar/line/area series across two independently configurable Y-axes. - schema: combo chart_type, per-series series_type + y_axis, top-level y_axes (label + domain) config - renderer: buildComboChart using recharts ComposedChart with optional right YAxis, per-series geometry, and axis labels/domains - propagate y_axes/series fields through story blocks, parsing, validation, MCP embed, PNG/SVG export and story HTML - edit dialog: combo option, per-series type/axis selectors, axis labels - system prompt guidance for combo/dual-axis - tests for combo SVG rendering and story round-trip --- .../backend/src/agents/tools/display-chart.ts | 2 +- .../src/components/ai/system-prompt.tsx | 8 + .../backend/src/components/generate-chart.tsx | 4 +- apps/backend/src/mcp/tools/helpers.ts | 13 +- apps/backend/src/utils/story-html.tsx | 1 + .../tests/generate-chart-combo.test.tsx | 83 +++++++++ .../src/components/mcp-app/chart-app-view.tsx | 3 + .../side-panel/story-chart-embed.tsx | 15 +- apps/frontend/src/components/story-embeds.tsx | 1 + .../tool-calls/display-chart-edit-dialog.tsx | 157 ++++++++++++++---- .../components/tool-calls/display-chart.tsx | 5 + .../src/components/tool-calls/mcp.tsx | 1 + apps/shared/src/chart-block.ts | 5 +- apps/shared/src/chart-builder.tsx | 139 ++++++++++++++++ apps/shared/src/mcp-embed.ts | 1 + apps/shared/src/story-segments.ts | 31 +++- apps/shared/src/story-validation.ts | 1 + apps/shared/src/tools/display-chart.ts | 39 +++++ apps/shared/tests/chart-block-combo.test.ts | 53 ++++++ 19 files changed, 513 insertions(+), 49 deletions(-) create mode 100644 apps/backend/tests/generate-chart-combo.test.tsx create mode 100644 apps/shared/tests/chart-block-combo.test.ts diff --git a/apps/backend/src/agents/tools/display-chart.ts b/apps/backend/src/agents/tools/display-chart.ts index b46b2f821..78292296f 100644 --- a/apps/backend/src/agents/tools/display-chart.ts +++ b/apps/backend/src/agents/tools/display-chart.ts @@ -12,7 +12,7 @@ export default createTool({ const { chart_type: chartType, x_axis_key: xAxisKey, series } = input; // Validate xAxisKey is provided for cartesian and polar charts - if (['bar', 'line', 'area', 'stacked_area', 'scatter', 'radar'].includes(chartType) && !xAxisKey) { + if (['bar', 'line', 'area', 'stacked_area', 'scatter', 'radar', 'combo'].includes(chartType) && !xAxisKey) { return { _version: '1', success: false, error: `xAxisKey is required for ${chartType} charts.` }; } diff --git a/apps/backend/src/components/ai/system-prompt.tsx b/apps/backend/src/components/ai/system-prompt.tsx index 390ec8ee7..30d9f1490 100644 --- a/apps/backend/src/components/ai/system-prompt.tsx +++ b/apps/backend/src/components/ai/system-prompt.tsx @@ -101,6 +101,14 @@ export function SystemPrompt({ desired (similar to "line"). Use "stacked_area" to show how multiple series compose a total over time (e.g. revenue by payment method, users by plan) — requires 2+ series and pivoted data. + + For display_chart chart_type: use "combo" to mix chart types and/or plot metrics on two Y-axes (e.g. + revenue as bars on the left axis vs. conversion rate as a line on the right axis). Set each series' + "series_type" ("bar", "line", or "area") and "y_axis" ("left" or "right"). A right axis is drawn + automatically when any series uses "y_axis": "right". Use the top-level "y_axes" object to label + each axis (e.g. y_axes.left.label, y_axes.right.label) and to fix a scale via "domain" ("auto" or{' '} + {'{ min, max }'}). Prefer "combo" with a right axis when comparing metrics on very different scales. + {hasClickHouse && ( When available, use indexes.md to see how the table is ordered and indexed (ORDER BY, PRIMARY diff --git a/apps/backend/src/components/generate-chart.tsx b/apps/backend/src/components/generate-chart.tsx index 77419681a..8dfa30ca7 100644 --- a/apps/backend/src/components/generate-chart.tsx +++ b/apps/backend/src/components/generate-chart.tsx @@ -7,7 +7,8 @@ import { renderToString } from 'react-dom/server'; import { createSvg, type LegendEntry, svgToPng } from '../utils/generate-chart'; export interface RenderChartInput { - config: Pick; + config: Pick & + Partial>; data: Record[]; width?: number; height?: number; @@ -42,6 +43,7 @@ export function renderChartToSvg(input: RenderChartInput): string { xAxisKey: config.x_axis_key, xAxisType: config.x_axis_type === 'number' ? 'number' : 'category', series: config.series, + yAxes: config.y_axes, colorFor, labelFormatter, showGrid: true, diff --git a/apps/backend/src/mcp/tools/helpers.ts b/apps/backend/src/mcp/tools/helpers.ts index 4ab10d108..0c7977059 100644 --- a/apps/backend/src/mcp/tools/helpers.ts +++ b/apps/backend/src/mcp/tools/helpers.ts @@ -129,8 +129,8 @@ export async function buildChartEmbedFromArtifact( ctx: McpContext, opts: { chatId: string | null; callLogId: string }, ): Promise<{ payload: ChartToolPayload; sandboxChartHtml: string | null } | { keyError: ChartKeyError } | null> { - const { query_id, chart_type, x_axis_key, x_axis_type, series, title } = artifact; - const block = buildStoryChartBlock({ query_id, chart_type, x_axis_key, x_axis_type, series, title }); + const { query_id, chart_type, x_axis_key, x_axis_type, series, y_axes, title } = artifact; + const block = buildStoryChartBlock({ query_id, chart_type, x_axis_key, x_axis_type, series, y_axes, title }); const queryData = await resolveChartQueryData({ queryId: query_id, @@ -159,7 +159,14 @@ export async function buildChartEmbedFromArtifact( chartEmbedId: id, queryId: query_id, projectId: ctx.projectId, - chartConfig: { chartType: chart_type, xAxisKey: x_axis_key, xAxisType: x_axis_type, series, title }, + chartConfig: { + chartType: chart_type, + xAxisKey: x_axis_key, + xAxisType: x_axis_type, + series, + yAxes: y_axes, + title, + }, sourceChatId: effectiveChatId, }); if (inserted) { diff --git a/apps/backend/src/utils/story-html.tsx b/apps/backend/src/utils/story-html.tsx index 002876095..f3475e8ac 100644 --- a/apps/backend/src/utils/story-html.tsx +++ b/apps/backend/src/utils/story-html.tsx @@ -329,6 +329,7 @@ function toChartConfig(chart: ParsedChartBlock) { x_axis_key: chart.xAxisKey, x_axis_type: chart.xAxisType as displayChart.XAxisType | null, series: chart.series, + y_axes: chart.yAxes, title: chart.title, }; } diff --git a/apps/backend/tests/generate-chart-combo.test.tsx b/apps/backend/tests/generate-chart-combo.test.tsx new file mode 100644 index 000000000..003ff8958 --- /dev/null +++ b/apps/backend/tests/generate-chart-combo.test.tsx @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; + +import type { RenderChartInput } from '../src/components/generate-chart'; +import { renderChartToSvg } from '../src/components/generate-chart'; + +const data = [ + { month: '2024-01-01', revenue: 1000, orders: 12 }, + { month: '2024-02-01', revenue: 1500, orders: 18 }, + { month: '2024-03-01', revenue: 1200, orders: 9 }, +]; + +function comboConfig(overrides: Partial = {}): RenderChartInput['config'] { + return { + chart_type: 'combo', + x_axis_key: 'month', + x_axis_type: 'date', + title: 'Revenue vs orders', + series: [ + { data_key: 'revenue', series_type: 'bar', y_axis: 'left' }, + { data_key: 'orders', series_type: 'line', y_axis: 'right' }, + ], + ...overrides, + }; +} + +describe('renderChartToSvg (combo)', () => { + it('renders mixed bar + line series', () => { + const svg = renderChartToSvg({ config: comboConfig(), data }); + + expect(svg).toContain('recharts-bar'); + expect(svg).toContain('recharts-line'); + }); + + it('renders a second Y-axis when a series uses the right axis', () => { + const svg = renderChartToSvg({ config: comboConfig(), data }); + const yAxes = svg.match(/recharts-yAxis/g) ?? []; + expect(yAxes.length).toBeGreaterThanOrEqual(2); + }); + + it('renders a single Y-axis when all series use the left axis', () => { + const svg = renderChartToSvg({ + config: comboConfig({ + series: [ + { data_key: 'revenue', series_type: 'bar', y_axis: 'left' }, + { data_key: 'orders', series_type: 'line', y_axis: 'left' }, + ], + }), + data, + }); + const yAxes = svg.match(/recharts-yAxis/g) ?? []; + expect(yAxes.length).toBe(1); + }); + + it('renders axis labels from y_axes config', () => { + const svg = renderChartToSvg({ + config: comboConfig({ + y_axes: { + left: { label: 'Revenue USD' }, + right: { label: 'Order count' }, + }, + }), + data, + }); + + expect(svg).toContain('Revenue USD'); + expect(svg).toContain('Order count'); + }); + + it('renders an area series in a combo chart', () => { + const svg = renderChartToSvg({ + config: comboConfig({ + series: [ + { data_key: 'revenue', series_type: 'bar', y_axis: 'left' }, + { data_key: 'orders', series_type: 'area', y_axis: 'right' }, + ], + }), + data, + }); + + expect(svg).toContain('recharts-bar'); + expect(svg).toContain('recharts-area'); + }); +}); diff --git a/apps/frontend/src/components/mcp-app/chart-app-view.tsx b/apps/frontend/src/components/mcp-app/chart-app-view.tsx index 4c7110ea3..162b28ad3 100644 --- a/apps/frontend/src/components/mcp-app/chart-app-view.tsx +++ b/apps/frontend/src/components/mcp-app/chart-app-view.tsx @@ -26,6 +26,8 @@ export const ChartAppView = memo(function ChartAppView({ config, data, naoUrl }: data_key: s.data_key, color: s.color ?? `var(--chart-${(i % 5) + 1})`, label: s.label, + series_type: s.series_type, + y_axis: s.y_axis, })), [config.series], ); @@ -54,6 +56,7 @@ export const ChartAppView = memo(function ChartAppView({ config, data, naoUrl }: xAxisKey={config.xAxisKey} xAxisType={xAxisType} series={series} + yAxes={config.yAxes} title={config.title} /> diff --git a/apps/frontend/src/components/side-panel/story-chart-embed.tsx b/apps/frontend/src/components/side-panel/story-chart-embed.tsx index d0d162118..9f2f66b84 100644 --- a/apps/frontend/src/components/side-panel/story-chart-embed.tsx +++ b/apps/frontend/src/components/side-panel/story-chart-embed.tsx @@ -2,6 +2,7 @@ import { memo, useMemo, useState } from 'react'; import { Pencil } from 'lucide-react'; import type { UIMessage } from '@nao/backend/chat'; import type { displayChart } from '@nao/shared/tools'; +import type { ParsedChartBlock } from '@nao/shared/story-segments'; import { Button } from '@/components/ui/button'; import { useOptionalAgentContext } from '@/contexts/agent.provider'; import { useStoryEmbedData } from '@/contexts/story-embed-data'; @@ -10,15 +11,7 @@ import { ChartDisplay } from '@/components/tool-calls/display-chart'; import { ChartConfigEditDialog } from '@/components/tool-calls/display-chart-edit-dialog'; import { sortByDateKey } from '@/lib/charts.utils'; -interface ChartBlock { - queryId: string; - chartType: string; - xAxisKey: string; - xAxisType: string | null; - series: Array<{ data_key: string; color: string; label?: string }>; - title: string; - rawTag?: string; -} +type ChartBlock = ParsedChartBlock; export const StoryChartEmbed = memo(function StoryChartEmbed({ chart }: { chart: ChartBlock }) { const agent = useOptionalAgentContext(); @@ -78,6 +71,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ chart }: { chart: xAxisKey={chart.xAxisKey} xAxisType={xAxisType} series={chart.series} + yAxes={chart.yAxes} title={chart.title} /> @@ -109,7 +103,10 @@ export function StoryChartEmbedShell({ chart, availableColumns, children }: Stor data_key: s.data_key, color: s.color || undefined, label: s.label, + series_type: s.series_type, + y_axis: s.y_axis, })), + y_axes: chart.yAxes, title: chart.title, }), [chart], diff --git a/apps/frontend/src/components/story-embeds.tsx b/apps/frontend/src/components/story-embeds.tsx index 11e2de4d6..9696afb7e 100644 --- a/apps/frontend/src/components/story-embeds.tsx +++ b/apps/frontend/src/components/story-embeds.tsx @@ -81,6 +81,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ xAxisKey={chart.xAxisKey} xAxisType={chart.xAxisType === 'number' ? 'number' : 'category'} series={chart.series} + yAxes={chart.yAxes} title={chart.title} /> diff --git a/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx b/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx index acab6cf14..8f78f6d30 100644 --- a/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx @@ -20,6 +20,18 @@ const CHART_TYPE_OPTIONS: { value: displayChart.ChartType; label: string }[] = [ { value: 'kpi_card', label: 'KPI card' }, { value: 'scatter', label: 'Scatter' }, { value: 'radar', label: 'Radar' }, + { value: 'combo', label: 'Combo (dual-axis)' }, +]; + +const SERIES_TYPE_OPTIONS: { value: displayChart.SeriesType; label: string }[] = [ + { value: 'bar', label: 'Bar' }, + { value: 'line', label: 'Line' }, + { value: 'area', label: 'Area' }, +]; + +const Y_AXIS_OPTIONS: { value: displayChart.YAxisSide; label: string }[] = [ + { value: 'left', label: 'Left axis' }, + { value: 'right', label: 'Right axis' }, ]; const X_AXIS_TYPE_OPTIONS: { value: NonNullable | 'auto'; label: string }[] = [ @@ -96,6 +108,18 @@ export function ChartConfigEditDialog({ })); }; + const isCombo = draft.chart_type === 'combo'; + + const updateYAxisLabel = (side: displayChart.YAxisSide, label: string) => { + setDraft((prev) => { + const nextSide = { ...prev.y_axes?.[side], label: label || undefined }; + const hasValue = nextSide.label !== undefined || nextSide.domain !== undefined; + const nextYAxes = { ...prev.y_axes, [side]: hasValue ? nextSide : undefined }; + const isEmpty = nextYAxes.left === undefined && nextYAxes.right === undefined; + return { ...prev, y_axes: isEmpty ? undefined : nextYAxes }; + }); + }; + const addSeries = () => { const used = new Set(draft.series.map((s) => s.data_key)); const selectableColumns = getSelectableColumns(availableColumns); @@ -207,44 +231,88 @@ export function ChartConfigEditDialog({
{draft.series.map((series, index) => ( -
- 0 ? availableColumns : [series.data_key]} - onChange={(value) => updateSeriesAt(index, { data_key: value })} - /> - updateSeriesAt(index, { label: e.target.value || undefined })} - placeholder='Label (optional)' - className='h-8 rounded-lg text-sm bg-panel' - /> - updateSeriesAt(index, { color: e.target.value })} - className='h-8 w-8 cursor-pointer overflow-hidden rounded-lg border-none bg-transparent p-0 [&::-moz-color-swatch]:rounded-lg [&::-moz-color-swatch]:border-none [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:rounded-lg [&::-webkit-color-swatch]:border-none' - /> - +
+
+ 0 ? availableColumns : [series.data_key]} + onChange={(value) => updateSeriesAt(index, { data_key: value })} + /> + + updateSeriesAt(index, { label: e.target.value || undefined }) + } + placeholder='Label (optional)' + className='h-8 rounded-lg text-sm bg-panel' + /> + updateSeriesAt(index, { color: e.target.value })} + className='h-8 w-8 cursor-pointer overflow-hidden rounded-lg border-none bg-transparent p-0 [&::-moz-color-swatch]:rounded-lg [&::-moz-color-swatch]:border-none [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:rounded-lg [&::-webkit-color-swatch]:border-none' + /> + +
+ {isCombo && ( +
+ + updateSeriesAt(index, { + series_type: value as displayChart.SeriesType, + }) + } + /> + + updateSeriesAt(index, { y_axis: value as displayChart.YAxisSide }) + } + /> +
+ )}
))}
+ {isCombo && ( +
+
+ Left axis label + updateYAxisLabel('left', e.target.value)} + placeholder='Left axis label (optional)' + className='h-8 bg-panel' + /> +
+
+ Right axis label + updateYAxisLabel('right', e.target.value)} + placeholder='Right axis label (optional)' + className='h-8 bg-panel' + /> +
+
+ )} + {error &&

{error}

} @@ -348,6 +416,29 @@ function ColumnSelect({ value, columns, onChange }: ColumnSelectProps) { ); } +interface EnumSelectProps { + value: string; + options: { value: string; label: string }[]; + onChange: (value: string) => void; +} + +function EnumSelect({ value, options, onChange }: EnumSelectProps) { + return ( + + ); +} + function getSelectableColumns(columns: string[]): string[] { return Array.from(new Set(columns.filter((column) => column.length > 0))); } diff --git a/apps/frontend/src/components/tool-calls/display-chart.tsx b/apps/frontend/src/components/tool-calls/display-chart.tsx index f3cf93915..cb0514892 100644 --- a/apps/frontend/src/components/tool-calls/display-chart.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart.tsx @@ -275,6 +275,7 @@ export const DisplayChartToolCall = ({ chartType={config.chart_type} xAxisKey={config.x_axis_key} series={config.series} + yAxes={config.y_axes} xAxisType={config.x_axis_type === 'number' ? 'number' : 'category'} title={config.title} /> @@ -289,6 +290,7 @@ export interface ChartDisplayProps { xAxisType: 'number' | 'category'; xAxisLabelFormatter?: (value: string) => string; series: displayChart.SeriesConfig[]; + yAxes?: displayChart.YAxesConfig; title?: string; showGrid?: boolean; } @@ -300,6 +302,7 @@ export const ChartDisplay = memo(function ChartDisplay({ xAxisType, xAxisLabelFormatter, series, + yAxes, title, showGrid = true, }: ChartDisplayProps) { @@ -366,6 +369,7 @@ export const ChartDisplay = memo(function ChartDisplay({ xAxisKey, xAxisType, series: visibleSeries, + yAxes, colorFor, labelFormatter, showGrid, @@ -394,6 +398,7 @@ export const ChartDisplay = memo(function ChartDisplay({ xAxisKey, xAxisType, visibleSeries, + yAxes, colorFor, labelFormatter, showGrid, diff --git a/apps/frontend/src/components/tool-calls/mcp.tsx b/apps/frontend/src/components/tool-calls/mcp.tsx index 52e5f7000..a787b4d28 100644 --- a/apps/frontend/src/components/tool-calls/mcp.tsx +++ b/apps/frontend/src/components/tool-calls/mcp.tsx @@ -68,6 +68,7 @@ const McpChartOutput = ({ chartBlock }: { chartBlock: string }) => { xAxisKey={chart.xAxisKey} xAxisType={chart.xAxisType === 'number' ? 'number' : 'category'} series={chart.series} + yAxes={chart.yAxes} title={chart.title} /> diff --git a/apps/shared/src/chart-block.ts b/apps/shared/src/chart-block.ts index 1b846ccac..a9dae094b 100644 --- a/apps/shared/src/chart-block.ts +++ b/apps/shared/src/chart-block.ts @@ -15,13 +15,16 @@ export type StoryChartBlockInput = Pick< 'query_id' | 'chart_type' | 'x_axis_key' | 'x_axis_type' | 'series' > & { title?: displayChart.Input['title']; + y_axes?: displayChart.Input['y_axes']; }; export function buildStoryChartBlock(input: StoryChartBlockInput): string { const xAxisTypeAttr = input.x_axis_type != null ? ` x_axis_type="${escapeDoubleQuotedStoryAttr(input.x_axis_type)}"` : ''; const seriesJson = escapeSingleQuotedStoryAttr(JSON.stringify(input.series)); + const yAxesAttr = + input.y_axes != null ? ` y_axes='${escapeSingleQuotedStoryAttr(JSON.stringify(input.y_axes))}'` : ''; const titleAttr = input.title != null && input.title !== '' ? ` title="${escapeDoubleQuotedStoryAttr(input.title)}"` : ''; - return ``; + return ``; } diff --git a/apps/shared/src/chart-builder.tsx b/apps/shared/src/chart-builder.tsx index 7493c0a5e..7d16b1dd1 100644 --- a/apps/shared/src/chart-builder.tsx +++ b/apps/shared/src/chart-builder.tsx @@ -5,7 +5,9 @@ import { Bar, BarChart, CartesianGrid, + ComposedChart, Customized, + Line, Pie, PieChart, PolarAngleAxis, @@ -69,6 +71,7 @@ export interface BuildChartProps { margin?: { top?: number; right?: number; bottom?: number; left?: number }; title?: string; maxXAxisTicks?: number; + yAxes?: displayChart.YAxesConfig; } /** @@ -89,6 +92,9 @@ export function buildChart(props: BuildChartProps) { if (resolved.chartType === 'line' || resolved.chartType === 'area' || resolved.chartType === 'stacked_area') { return buildAreaChart(resolved); } + if (resolved.chartType === 'combo') { + return buildComboChart(resolved); + } if (resolved.chartType === 'scatter') { return buildScatterChart(resolved); } @@ -286,6 +292,139 @@ function buildAreaChart(props: ResolvedProps) { ); } +function buildComboChart(props: ResolvedProps) { + const { + data, + xAxisKey, + xAxisType, + series, + colorFor, + labelFormatter, + showGrid, + children, + margin, + xAxisInterval, + yAxes, + } = props; + + const hasRightAxis = series.some((s) => s.y_axis === 'right'); + const areaSeries = series.filter((s) => s.series_type === 'area'); + + return ( + + {areaSeries.length > 0 && ( + + {series.map((s, i) => + s.series_type === 'area' ? ( + + + + + ) : null, + )} + + )} + {showGrid && } + + {hasRightAxis && ( + + )} + + {children} + {series.map((s, i) => { + const color = colorFor(s.data_key, i); + const yAxisId = s.y_axis === 'right' ? 'right' : 'left'; + if (s.series_type === 'line') { + return ( + + ); + } + if (s.series_type === 'area') { + return ( + + ); + } + return ( + + ); + })} + + ); +} + +function resolveAxisDomain(domain: displayChart.YAxisDomain | undefined): [number, number] | undefined { + if (domain && domain !== 'auto') { + return [domain.min, domain.max]; + } + return undefined; +} + +function axisLabel(label: string | undefined, side: 'left' | 'right') { + if (!label) { + return undefined; + } + return { + value: label, + angle: -90, + position: side === 'left' ? ('insideLeft' as const) : ('insideRight' as const), + style: { textAnchor: 'middle' as const, fontSize: 12, fill: 'var(--muted-foreground, #6b7280)' }, + }; +} + function buildScatterChart(props: ResolvedProps) { const { data, xAxisKey, xAxisType, series, colorFor, showGrid, children, margin } = props; diff --git a/apps/shared/src/mcp-embed.ts b/apps/shared/src/mcp-embed.ts index 1df4a2dca..a557ccd12 100644 --- a/apps/shared/src/mcp-embed.ts +++ b/apps/shared/src/mcp-embed.ts @@ -10,6 +10,7 @@ export type McpChartEmbedStoredConfig = { xAxisKey: displayChart.Input['x_axis_key']; xAxisType: displayChart.Input['x_axis_type']; series: displayChart.Input['series']; + yAxes?: displayChart.Input['y_axes']; title: string; }; diff --git a/apps/shared/src/story-segments.ts b/apps/shared/src/story-segments.ts index cbf4d5dd6..e2e0f4d57 100644 --- a/apps/shared/src/story-segments.ts +++ b/apps/shared/src/story-segments.ts @@ -1,9 +1,28 @@ +export interface ParsedChartSeries { + data_key: string; + color: string; + label?: string; + series_type?: 'bar' | 'line' | 'area'; + y_axis?: 'left' | 'right'; +} + +export interface ParsedYAxisConfig { + label?: string; + domain?: 'auto' | { min: number; max: number }; +} + +export interface ParsedYAxesConfig { + left?: ParsedYAxisConfig; + right?: ParsedYAxisConfig; +} + export interface ParsedChartBlock { queryId: string; chartType: string; xAxisKey: string; xAxisType: string | null; - series: Array<{ data_key: string; color: string; label?: string }>; + series: ParsedChartSeries[]; + yAxes?: ParsedYAxesConfig; title: string; /** The original `` tag this block was parsed from, when available. */ rawTag?: string; @@ -60,10 +79,20 @@ export function parseChartBlock(attrString: string): ParsedChartBlock | null { xAxisKey: attrs.x_axis_key, xAxisType: attrs.x_axis_type || null, series, + yAxes: attrs.y_axes ? (tryParseJsonObject(attrs.y_axes) as ParsedYAxesConfig | undefined) : undefined, title: attrs.title || '', }; } +function tryParseJsonObject(value: string): Record | null { + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + export function parseTableBlock(attrString: string): ParsedTableBlock | null { const attrs = parseChartAttributes(attrString); if (!attrs.query_id) { diff --git a/apps/shared/src/story-validation.ts b/apps/shared/src/story-validation.ts index 642683c71..d3eefeefd 100644 --- a/apps/shared/src/story-validation.ts +++ b/apps/shared/src/story-validation.ts @@ -20,6 +20,7 @@ const VALID_CHART_TYPES = new Set([ 'kpi_card', 'scatter', 'radar', + 'combo', ]); const VALID_X_AXIS_TYPES = new Set(['date', 'number', 'category']); diff --git a/apps/shared/src/tools/display-chart.ts b/apps/shared/src/tools/display-chart.ts index 970a00ab5..7f429c2cb 100644 --- a/apps/shared/src/tools/display-chart.ts +++ b/apps/shared/src/tools/display-chart.ts @@ -10,16 +10,49 @@ export const ChartTypeEnum = z.enum([ 'kpi_card', 'scatter', 'radar', + 'combo', ]); export const XAxisTypeEnum = z.enum(['date', 'number', 'category']); +export const SeriesTypeEnum = z.enum(['bar', 'line', 'area']); + +export const YAxisSideEnum = z.enum(['left', 'right']); + export const SeriesConfigSchema = z.object({ data_key: z.string().describe('Column name from SQL result to plot.'), color: z.string().describe('CSS color (defaults to theme colors).').optional(), label: z.string().describe('Label to display in the legend.').optional(), + series_type: SeriesTypeEnum.describe( + 'For "combo" charts only: how to render this series ("bar", "line", or "area"). Defaults to "bar".', + ).optional(), + y_axis: YAxisSideEnum.describe( + 'Which Y-axis this series is plotted against ("left" or "right"). Defaults to "left". A right axis is drawn whenever any series uses "right".', + ).optional(), +}); + +export const YAxisDomainSchema = z + .union([ + z.literal('auto'), + z.object({ + min: z.number().describe('Minimum value of the axis scale.'), + max: z.number().describe('Maximum value of the axis scale.'), + }), + ]) + .describe('Axis scale: "auto" to fit the data, or an explicit { min, max } range.'); + +export const YAxisConfigSchema = z.object({ + label: z.string().describe('Label displayed alongside this Y-axis.').optional(), + domain: YAxisDomainSchema.optional(), }); +export const YAxesConfigSchema = z + .object({ + left: YAxisConfigSchema.optional(), + right: YAxisConfigSchema.optional(), + }) + .describe('Optional independent configuration (label, scale) for the left and right Y-axes.'); + export const InputSchema = z.object({ query_id: z.string().describe("The id of a previous `execute_sql` tool call's output to get data from."), chart_type: ChartTypeEnum.describe('Type of chart to display.'), @@ -31,6 +64,7 @@ export const InputSchema = z.object({ .array(SeriesConfigSchema) .min(1) .describe('Columns to plot as data series (at least one series required).'), + y_axes: YAxesConfigSchema.optional(), title: z .string() .describe( @@ -46,6 +80,11 @@ export const OutputSchema = z.object({ export type ChartType = z.infer; export type XAxisType = z.infer; +export type SeriesType = z.infer; +export type YAxisSide = z.infer; export type SeriesConfig = z.infer; +export type YAxisDomain = z.infer; +export type YAxisConfig = z.infer; +export type YAxesConfig = z.infer; export type Input = z.infer; export type Output = z.infer; diff --git a/apps/shared/tests/chart-block-combo.test.ts b/apps/shared/tests/chart-block-combo.test.ts new file mode 100644 index 000000000..20b673551 --- /dev/null +++ b/apps/shared/tests/chart-block-combo.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { buildStoryChartBlock } from '../src/chart-block'; +import { parseChartBlock } from '../src/story-segments'; +import { validateStoryCode } from '../src/story-validation'; +import { InputSchema } from '../src/tools/display-chart'; + +function attrsOf(tag: string): string { + return tag.match(/^$/)?.[1] ?? ''; +} + +describe('combo chart story round-trip', () => { + const config = { + query_id: 'q_1', + chart_type: 'combo' as const, + x_axis_key: 'month', + x_axis_type: 'date' as const, + series: [ + { data_key: 'revenue', series_type: 'bar' as const, y_axis: 'left' as const }, + { data_key: 'orders', series_type: 'line' as const, y_axis: 'right' as const }, + ], + y_axes: { + left: { label: 'Revenue ($)' }, + right: { label: 'Orders', domain: { min: 0, max: 100 } as const }, + }, + title: 'Revenue vs orders', + }; + + it('is a valid display_chart input', () => { + expect(InputSchema.safeParse(config).success).toBe(true); + }); + + it('serializes and parses back series_type, y_axis and y_axes', () => { + const block = buildStoryChartBlock(config); + const parsed = parseChartBlock(attrsOf(block)); + + expect(parsed).not.toBeNull(); + expect(parsed?.chartType).toBe('combo'); + expect(parsed?.series).toEqual([ + { data_key: 'revenue', series_type: 'bar', y_axis: 'left' }, + { data_key: 'orders', series_type: 'line', y_axis: 'right' }, + ]); + expect(parsed?.yAxes).toEqual({ + left: { label: 'Revenue ($)' }, + right: { label: 'Orders', domain: { min: 0, max: 100 } }, + }); + }); + + it('passes story validation for combo chart_type', () => { + const block = buildStoryChartBlock(config); + expect(validateStoryCode(block)).toEqual([]); + }); +}); From 2c837b1130e5710009e049efca532f9489df9130 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Jun 2026 09:09:46 +0000 Subject: [PATCH 2/2] fix(charts): suppress misleading tooltip total for combo charts Dual-axis combo charts plot series on different scales, so summing them into a 'Total' row is meaningless. Skip the total in both the React tooltip and the exported story HTML tooltip when chart_type is combo. --- apps/backend/src/utils/story-html.tsx | 2 +- apps/frontend/src/components/tool-calls/display-chart.tsx | 7 ++++++- apps/frontend/src/components/ui/chart.tsx | 4 +++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/utils/story-html.tsx b/apps/backend/src/utils/story-html.tsx index f3475e8ac..9caa43ce5 100644 --- a/apps/backend/src/utils/story-html.tsx +++ b/apps/backend/src/utils/story-html.tsx @@ -503,7 +503,7 @@ const TOOLTIP_SCRIPT_TEMPLATE = ` +''+formatVal(val)+'' +''; }); - if(numericValues.length>1){ + if(numericValues.length>1&&cfg.chartType!=='combo'){ var total=numericValues.reduce(function(a,b){return a+b},0); html+='
' +'Total' diff --git a/apps/frontend/src/components/tool-calls/display-chart.tsx b/apps/frontend/src/components/tool-calls/display-chart.tsx index cb0514892..bf0fb01aa 100644 --- a/apps/frontend/src/components/tool-calls/display-chart.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart.tsx @@ -380,7 +380,12 @@ export const ChartDisplay = memo(function ChartDisplay({ animationDuration={150} animationEasing='linear' allowEscapeViewBox={{ y: true, x: false }} - content={ labelize(value, dateFormat)} />} + content={ + labelize(value, dateFormat)} + /> + } />, chartType !== 'pie' && ( & React.ComponentProps<'div'> & { hideLabel?: boolean; @@ -114,6 +115,7 @@ function ChartTooltipContent({ indicator?: 'line' | 'dot' | 'dashed'; nameKey?: string; labelKey?: string; + hideTotal?: boolean; }) { const { config } = useChart(); @@ -147,7 +149,7 @@ function ChartTooltipContent({ // Calculate total if there are multiple numeric values that can be summed const visiblePayload = payload.filter((item) => item.type !== 'none'); const numericValues = visiblePayload.map((item) => item.value).filter((v): v is number => typeof v === 'number'); - const showTotal = numericValues.length > 1; + const showTotal = !hideTotal && numericValues.length > 1; const total = showTotal ? numericValues.reduce((sum, v) => sum + v, 0) : 0; return (