From 88b27c3eb0685b06bdf66d70f320f18ce78f6e73 Mon Sep 17 00:00:00 2001 From: socallmebertille Date: Wed, 22 Jul 2026 13:44:49 +0200 Subject: [PATCH 1/6] implementation of dual y axis for bar, line or area chart --- .../src/components/ai/system-prompt.tsx | 7 + .../backend/src/components/generate-chart.tsx | 8 + apps/backend/src/mcp/tools/asset-tools.ts | 32 ++- apps/backend/src/mcp/tools/helpers.ts | 23 +- apps/backend/src/utils/story-html.tsx | 7 +- .../src/components/automations-feed.tsx | 4 + .../src/components/mcp-app/chart-app-view.tsx | 6 + .../side-panel/story-chart-embed.tsx | 25 +-- apps/frontend/src/components/story-embeds.tsx | 4 + .../tool-calls/display-chart-edit-dialog.tsx | 208 +++++++++++++++--- .../components/tool-calls/display-chart.tsx | 25 +++ .../src/components/tool-calls/mcp.tsx | 4 + apps/frontend/src/components/ui/chart.tsx | 4 +- apps/shared/src/chart-block.ts | 12 +- apps/shared/src/chart-builder.tsx | 4 + apps/shared/src/mcp-embed.ts | 4 + apps/shared/src/story-segments.ts | 22 +- apps/shared/src/story-validation.ts | 42 ++-- apps/shared/src/tools/display-chart.ts | 54 ++++- 19 files changed, 420 insertions(+), 75 deletions(-) diff --git a/apps/backend/src/components/ai/system-prompt.tsx b/apps/backend/src/components/ai/system-prompt.tsx index 7e80ecbe8..7dadb604e 100644 --- a/apps/backend/src/components/ai/system-prompt.tsx +++ b/apps/backend/src/components/ai/system-prompt.tsx @@ -129,6 +129,13 @@ export function SystemPrompt({ For display_chart y_axis_min/y_axis_max: use them to fix the Y-axis scale when needed; by default, line and scatter charts auto-scale to a readable range rather than always starting at zero. + + To compare metrics on different scales, set per-series "series_type" ("bar", "line" or "area") to + mix chart types and "y_axis" ("left" or "right") to add a second Y-axis (drawn automatically when + any series uses {'"y_axis": "right"'}) — e.g. revenue as bars on the left, conversion rate as a line + on the right. Label axes with y_axis_label / y_axis_right_label and fix scales with + y_axis_min/y_axis_max and y_axis_right_min/y_axis_right_max. + Use the display_chart tool with {'chart_type: "table"'} to present a table from a previous execute_sql result with conditional formatting on specific columns. diff --git a/apps/backend/src/components/generate-chart.tsx b/apps/backend/src/components/generate-chart.tsx index b58d47a6b..5155a3ea3 100644 --- a/apps/backend/src/components/generate-chart.tsx +++ b/apps/backend/src/components/generate-chart.tsx @@ -21,6 +21,10 @@ export interface RenderChartInput { | 'series' | 'y_axis_min' | 'y_axis_max' + | 'y_axis_label' + | 'y_axis_right_min' + | 'y_axis_right_max' + | 'y_axis_right_label' | 'title' | 'show_data_labels' >; @@ -87,6 +91,10 @@ export function renderChartToSvg(input: RenderChartInput): string { backgroundColor: '#ffffff', yAxisMin: config.y_axis_min, yAxisMax: config.y_axis_max, + yAxisLabel: config.y_axis_label, + yAxisRightMin: config.y_axis_right_min, + yAxisRightMax: config.y_axis_right_max, + yAxisRightLabel: config.y_axis_right_label, }); const html = renderToString(React.cloneElement(chart, { width: chartWidth, height })); diff --git a/apps/backend/src/mcp/tools/asset-tools.ts b/apps/backend/src/mcp/tools/asset-tools.ts index ab4f141cd..8cc25e145 100644 --- a/apps/backend/src/mcp/tools/asset-tools.ts +++ b/apps/backend/src/mcp/tools/asset-tools.ts @@ -105,8 +105,21 @@ function registerDisplayChart(server: McpServer, ctx: McpContext): void { mapInput: ({ chat_id: _chatId, ...input }) => input, resolveChatId: (input) => input.chat_id ?? null, formatResult: async ({ input, output, callLogId }) => { - const { query_id, chart_type, x_axis_key, x_axis_type, series, y_axis_min, y_axis_max, title, chat_id } = - input; + const { + query_id, + chart_type, + x_axis_key, + x_axis_type, + series, + y_axis_min, + y_axis_max, + y_axis_label, + y_axis_right_min, + y_axis_right_max, + y_axis_right_label, + title, + chat_id, + } = input; if (!output.success) { return { content: [{ type: 'text' as const, text: output.error ?? 'Chart config is invalid.' }], @@ -116,7 +129,20 @@ function registerDisplayChart(server: McpServer, ctx: McpContext): void { const validatedChatId = await resolveChartChatId(chat_id, ctx); const result = await buildChartEmbedFromArtifact( - { query_id, chart_type, x_axis_key, x_axis_type, series, y_axis_min, y_axis_max, title }, + { + query_id, + chart_type, + x_axis_key, + x_axis_type, + series, + y_axis_min, + y_axis_max, + y_axis_label, + y_axis_right_min, + y_axis_right_max, + y_axis_right_label, + title, + }, ctx, { chatId: validatedChatId ?? null, callLogId }, ); diff --git a/apps/backend/src/mcp/tools/helpers.ts b/apps/backend/src/mcp/tools/helpers.ts index f2a62b384..e5bbbe5d0 100644 --- a/apps/backend/src/mcp/tools/helpers.ts +++ b/apps/backend/src/mcp/tools/helpers.ts @@ -129,7 +129,20 @@ 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, y_axis_min, y_axis_max, title } = artifact; + const { + query_id, + chart_type, + x_axis_key, + x_axis_type, + series, + y_axis_min, + y_axis_max, + y_axis_label, + y_axis_right_min, + y_axis_right_max, + y_axis_right_label, + title, + } = artifact; const block = buildStoryChartBlock({ query_id, chart_type, @@ -138,6 +151,10 @@ export async function buildChartEmbedFromArtifact( series, y_axis_min, y_axis_max, + y_axis_label, + y_axis_right_min, + y_axis_right_max, + y_axis_right_label, title, }); @@ -175,6 +192,10 @@ export async function buildChartEmbedFromArtifact( series, yAxisMin: y_axis_min, yAxisMax: y_axis_max, + yAxisLabel: y_axis_label, + yAxisRightMin: y_axis_right_min, + yAxisRightMax: y_axis_right_max, + yAxisRightLabel: y_axis_right_label, title, }, sourceChatId: effectiveChatId, diff --git a/apps/backend/src/utils/story-html.tsx b/apps/backend/src/utils/story-html.tsx index b730c6a73..93893736d 100644 --- a/apps/backend/src/utils/story-html.tsx +++ b/apps/backend/src/utils/story-html.tsx @@ -343,6 +343,10 @@ function toChartConfig(chart: ParsedChartBlock) { series: chart.series, y_axis_min: chart.yAxisMin, y_axis_max: chart.yAxisMax, + y_axis_label: chart.yAxisLabel, + y_axis_right_min: chart.yAxisRightMin, + y_axis_right_max: chart.yAxisRightMax, + y_axis_right_label: chart.yAxisRightLabel, title: chart.title, show_data_labels: chart.showDataLabels, }; @@ -499,6 +503,7 @@ const TOOLTIP_SCRIPT_TEMPLATE = ` var html='
'+labelize(label!=null?label:'')+'
'; html+='
'; var isPercent=cfg.chartType==='stacked_bar_100'||cfg.chartType==='stacked_area_100'; + var isDualAxis=(cfg.series||[]).some(function(s){return s.y_axis==='right'}); var seriesTotal=0; cfg.series.forEach(function(s){var sv=row[s.data_key];if(typeof sv==='number'&&!s.is_total)seriesTotal+=sv;}); function pctShare(v){if(typeof v!=='number'||!seriesTotal)return '0%';var sh=Math.round(v/seriesTotal*1000)/10;return (sh%1===0?sh:sh.toFixed(1))+'%';} @@ -525,7 +530,7 @@ const TOOLTIP_SCRIPT_TEMPLATE = ` +''+(isPercent?pctShare(val):formatVal(val))+'' +'
'; }); - if(numericValues.length>1 && (isPercent || (!hasTotalSeries && !cfg.hideTotal))){ + if(numericValues.length>1 && !isDualAxis && (isPercent || (!hasTotalSeries && !cfg.hideTotal))){ var total=numericValues.reduce(function(a,b){return a+b},0); html+='
' +'Total' diff --git a/apps/frontend/src/components/automations-feed.tsx b/apps/frontend/src/components/automations-feed.tsx index 910b93af7..bd4ad22ae 100644 --- a/apps/frontend/src/components/automations-feed.tsx +++ b/apps/frontend/src/components/automations-feed.tsx @@ -773,6 +773,10 @@ function ChartSlide({ chart }: { chart: AutomationFeedChart }) { title={chart.config.title} yAxisMin={chart.config.y_axis_min} yAxisMax={chart.config.y_axis_max} + yAxisLabel={chart.config.y_axis_label} + yAxisRightMin={chart.config.y_axis_right_min} + yAxisRightMax={chart.config.y_axis_right_max} + yAxisRightLabel={chart.config.y_axis_right_label} />
); 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 2fcc222ea..0703707bf 100644 --- a/apps/frontend/src/components/mcp-app/chart-app-view.tsx +++ b/apps/frontend/src/components/mcp-app/chart-app-view.tsx @@ -27,6 +27,8 @@ export const ChartAppView = memo(function ChartAppView({ config, data, naoUrl }: color: s.color ?? `var(--chart-${(i % 5) + 1})`, label: s.label, is_total: s.is_total, + series_type: s.series_type, + y_axis: s.y_axis, })), [config.series], ); @@ -58,6 +60,10 @@ export const ChartAppView = memo(function ChartAppView({ config, data, naoUrl }: title={config.title} yAxisMin={config.yAxisMin} yAxisMax={config.yAxisMax} + yAxisLabel={config.yAxisLabel} + yAxisRightMin={config.yAxisRightMin} + yAxisRightMax={config.yAxisRightMax} + yAxisRightLabel={config.yAxisRightLabel} /> ); 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 2eb8ae91b..4c58adcfb 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 { Pencil } from 'lucide-react'; import { memo, useMemo, useState } from 'react'; import { StoryEmbedFallback } from './story-embed-fallback'; import type { UIMessage } from '@nao/backend/chat'; +import type { ParsedChartBlock } from '@nao/shared/story-segments'; import type { displayChart } from '@nao/shared/tools'; import { ChartDisplay } from '@/components/tool-calls/display-chart'; @@ -14,19 +15,7 @@ import { sortByDateKey } from '@/lib/charts.utils'; const STORY_CHART_HEIGHT_CLASS = 'h-72'; -interface ChartBlock { - queryId: string; - chartType: string; - xAxisKey: string; - xAxisType: string | null; - series: Array<{ data_key: string; color: string; label?: string; is_total?: boolean }>; - yAxisMin?: number; - yAxisMax?: number; - title: string; - showDataLabels?: boolean; - hideTotal?: boolean; - rawTag?: string; -} +type ChartBlock = ParsedChartBlock; export const StoryChartEmbed = memo(function StoryChartEmbed({ chart, @@ -91,6 +80,10 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ title={chart.title} yAxisMin={chart.yAxisMin} yAxisMax={chart.yAxisMax} + yAxisLabel={chart.yAxisLabel} + yAxisRightMin={chart.yAxisRightMin} + yAxisRightMax={chart.yAxisRightMax} + yAxisRightLabel={chart.yAxisRightLabel} showDataLabels={chart.showDataLabels} normalSize hideTotal={chart.hideTotal} @@ -126,9 +119,15 @@ export function StoryChartEmbedShell({ chart, availableColumns, dragHandle, chil color: s.color || undefined, label: s.label, is_total: s.is_total, + series_type: s.series_type, + y_axis: s.y_axis, })), y_axis_min: chart.yAxisMin, y_axis_max: chart.yAxisMax, + y_axis_label: chart.yAxisLabel, + y_axis_right_min: chart.yAxisRightMin, + y_axis_right_max: chart.yAxisRightMax, + y_axis_right_label: chart.yAxisRightLabel, title: chart.title, show_data_labels: chart.showDataLabels, hide_total: chart.hideTotal, diff --git a/apps/frontend/src/components/story-embeds.tsx b/apps/frontend/src/components/story-embeds.tsx index 185c1c5a5..084eaa317 100644 --- a/apps/frontend/src/components/story-embeds.tsx +++ b/apps/frontend/src/components/story-embeds.tsx @@ -85,6 +85,10 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ title={chart.title} yAxisMin={chart.yAxisMin} yAxisMax={chart.yAxisMax} + yAxisLabel={chart.yAxisLabel} + yAxisRightMin={chart.yAxisRightMin} + yAxisRightMax={chart.yAxisRightMax} + yAxisRightLabel={chart.yAxisRightLabel} showDataLabels={chart.showDataLabels} normalSize hideTotal={chart.hideTotal} 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 7de12cf5b..734a086a1 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 @@ -32,6 +32,17 @@ const X_AXIS_TYPE_OPTIONS: { value: NonNullable | 'auto' { value: 'number', label: 'Number' }, ]; +const SERIES_TYPE_OPTIONS: { value: displayChart.SeriesType; label: string }[] = [ + { value: 'bar', label: 'Bar' }, + { value: 'line', label: 'Line' }, + { value: 'area', label: 'Area' }, +]; + +const Y_AXIS_SIDE_OPTIONS: { value: displayChart.YAxisSide; label: string }[] = [ + { value: 'left', label: 'Left axis' }, + { value: 'right', label: 'Right axis' }, +]; + const Y_AXIS_RANGE_UNSUPPORTED_CHART_TYPES = new Set(['pie', 'kpi_card', 'radar']); /** Maps a 100% stacked type back to its absolute-stacked counterpart, so the type dropdown stays clean. */ @@ -79,14 +90,21 @@ export function ChartConfigEditDialog({ const [draft, setDraft] = useState(config); const [yAxisMinText, setYAxisMinText] = useState(toRangeString(config.y_axis_min)); const [yAxisMaxText, setYAxisMaxText] = useState(toRangeString(config.y_axis_max)); + const [yAxisRightMinText, setYAxisRightMinText] = useState(toRangeString(config.y_axis_right_min)); + const [yAxisRightMaxText, setYAxisRightMaxText] = useState(toRangeString(config.y_axis_right_max)); const [error, setError] = useState(null); const supportsYAxisRange = !Y_AXIS_RANGE_UNSUPPORTED_CHART_TYPES.has(draft.chart_type); + const isCombo = displayChart.chartTypeSupportsComboSeries(draft.chart_type); + const hasRightAxis = isCombo && displayChart.hasRightAxisSeries(draft.series); + const hasLeftAxis = draft.series.some((s) => s.y_axis === 'left'); useEffect(() => { if (open) { setDraft(config); setYAxisMinText(toRangeString(config.y_axis_min)); setYAxisMaxText(toRangeString(config.y_axis_max)); + setYAxisRightMinText(toRangeString(config.y_axis_right_min)); + setYAxisRightMaxText(toRangeString(config.y_axis_right_max)); setError(null); } }, [open, config]); @@ -156,6 +174,18 @@ export function ChartConfigEditDialog({ setDraft((prev) => ({ ...prev, y_axis_max: parsed })); }; + const updateYAxisRightMin = (value: string) => { + setYAxisRightMinText(value); + const parsed = parseRangeInput(value); + setDraft((prev) => ({ ...prev, y_axis_right_min: parsed })); + }; + + const updateYAxisRightMax = (value: string) => { + setYAxisRightMaxText(value); + const parsed = parseRangeInput(value); + setDraft((prev) => ({ ...prev, y_axis_right_max: parsed })); + }; + return ( @@ -285,47 +315,82 @@ 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 }) + } + /> +
+ )}
))}
- {supportsYAxisRange && ( + {supportsYAxisRange && hasLeftAxis && (
- Y-axis range + + {isCombo ? 'Left Y-axis' : 'Y-axis range'} + + {isCombo && ( + + setDraft((prev) => ({ ...prev, y_axis_label: e.target.value || undefined })) + } + /> + )}
)} + + {hasRightAxis && ( +
+ Right Y-axis + + setDraft((prev) => ({ ...prev, y_axis_right_label: e.target.value || undefined })) + } + /> +
+
+ + updateYAxisRightMin(e.target.value)} + /> +
+
+ + updateYAxisRightMax(e.target.value)} + /> +
+
+
+ )}
Options
@@ -475,6 +590,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 87adb9398..9138d6995 100644 --- a/apps/frontend/src/components/tool-calls/display-chart.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart.tsx @@ -373,6 +373,10 @@ export const DisplayChartToolCall = ({ title={chartConfig.title} yAxisMin={chartConfig.y_axis_min} yAxisMax={chartConfig.y_axis_max} + yAxisLabel={chartConfig.y_axis_label} + yAxisRightMin={chartConfig.y_axis_right_min} + yAxisRightMax={chartConfig.y_axis_right_max} + yAxisRightLabel={chartConfig.y_axis_right_label} showDataLabels={chartConfig.show_data_labels} hideTotal={chartConfig.hide_total} /> @@ -392,6 +396,10 @@ export interface ChartDisplayProps { showGrid?: boolean; yAxisMin?: number; yAxisMax?: number; + yAxisLabel?: string; + yAxisRightMin?: number; + yAxisRightMax?: number; + yAxisRightLabel?: string; showDataLabels?: boolean; normalSize?: boolean; hideTotal?: boolean; @@ -408,6 +416,10 @@ export const ChartDisplay = memo(function ChartDisplay({ showGrid = true, yAxisMin, yAxisMax, + yAxisLabel, + yAxisRightMin, + yAxisRightMax, + yAxisRightLabel, showDataLabels, normalSize = false, hideTotal, @@ -525,6 +537,9 @@ export const ChartDisplay = memo(function ChartDisplay({ [isPie, dateFormat], ); + const isDualAxis = + displayChart.isComboChart(chartType, visibleSeries) && displayChart.hasRightAxisSeries(visibleSeries); + const chartElement = useMemo( () => buildChart({ @@ -544,6 +559,10 @@ export const ChartDisplay = memo(function ChartDisplay({ margin: { top: 0, right: 0, bottom: 0, left: 0 }, yAxisMin, yAxisMax, + yAxisLabel, + yAxisRightMin, + yAxisRightMax, + yAxisRightLabel, children: [ @@ -595,6 +615,11 @@ export const ChartDisplay = memo(function ChartDisplay({ showGrid, yAxisMin, yAxisMax, + yAxisLabel, + yAxisRightMin, + yAxisRightMax, + yAxisRightLabel, + isDualAxis, showDataLabels, gradientIdPrefix, hideTotal, diff --git a/apps/frontend/src/components/tool-calls/mcp.tsx b/apps/frontend/src/components/tool-calls/mcp.tsx index bf225c2f2..8df683bce 100644 --- a/apps/frontend/src/components/tool-calls/mcp.tsx +++ b/apps/frontend/src/components/tool-calls/mcp.tsx @@ -77,6 +77,10 @@ const McpChartOutput = ({ chartBlock }: { chartBlock: string }) => { title={chart.title} yAxisMin={chart.yAxisMin} yAxisMax={chart.yAxisMax} + yAxisLabel={chart.yAxisLabel} + yAxisRightMin={chart.yAxisRightMin} + yAxisRightMax={chart.yAxisRightMax} + yAxisRightLabel={chart.yAxisRightLabel} />
diff --git a/apps/frontend/src/components/ui/chart.tsx b/apps/frontend/src/components/ui/chart.tsx index 010679449..8a7f3f1a5 100644 --- a/apps/frontend/src/components/ui/chart.tsx +++ b/apps/frontend/src/components/ui/chart.tsx @@ -109,6 +109,7 @@ function ChartTooltipContent({ nameKey, labelKey, percent = false, + isDualAxis = false, hideTotal = false, }: React.ComponentProps & React.ComponentProps<'div'> & { @@ -118,6 +119,7 @@ function ChartTooltipContent({ nameKey?: string; labelKey?: string; percent?: boolean; + isDualAxis?: boolean; hideTotal?: boolean; }) { const { config } = useChart(); @@ -165,7 +167,7 @@ function ChartTooltipContent({ .map((item) => ({ value: item.value as number, isTotal: isTotalItem(item) })), ); // In 100% stacked mode every category totals 100%, so ignore already-aggregated total series. - const showTotal = numericValues.length > 1 && (percent || (!hasTotalSeries && !hideTotal)); + const showTotal = !isDualAxis && numericValues.length > 1 && (percent || (!hasTotalSeries && !hideTotal)); const formatValue = (value: number) => percent ? formatPercentShare(value, shareBase) : formatCompactNumber(value); diff --git a/apps/shared/src/chart-block.ts b/apps/shared/src/chart-block.ts index 9037bdac4..e7ae4c8b0 100644 --- a/apps/shared/src/chart-block.ts +++ b/apps/shared/src/chart-block.ts @@ -19,6 +19,10 @@ export type StoryChartBlockInput = Pick< | 'series' | 'y_axis_min' | 'y_axis_max' + | 'y_axis_label' + | 'y_axis_right_min' + | 'y_axis_right_max' + | 'y_axis_right_label' | 'show_data_labels' | 'hide_total' > & { @@ -30,12 +34,18 @@ export function buildStoryChartBlock(input: StoryChartBlockInput): string { input.x_axis_type != null ? ` x_axis_type="${escapeDoubleQuotedStoryAttr(input.x_axis_type)}"` : ''; const yMinAttr = input.y_axis_min !== undefined ? ` y_axis_min="${input.y_axis_min}"` : ''; const yMaxAttr = input.y_axis_max !== undefined ? ` y_axis_max="${input.y_axis_max}"` : ''; + const yLabelAttr = input.y_axis_label ? ` y_axis_label="${escapeDoubleQuotedStoryAttr(input.y_axis_label)}"` : ''; + const yRightMinAttr = input.y_axis_right_min !== undefined ? ` y_axis_right_min="${input.y_axis_right_min}"` : ''; + const yRightMaxAttr = input.y_axis_right_max !== undefined ? ` y_axis_right_max="${input.y_axis_right_max}"` : ''; + const yRightLabelAttr = input.y_axis_right_label + ? ` y_axis_right_label="${escapeDoubleQuotedStoryAttr(input.y_axis_right_label)}"` + : ''; const seriesJson = escapeSingleQuotedStoryAttr(JSON.stringify(input.series)); const titleAttr = input.title != null && input.title !== '' ? ` title="${escapeDoubleQuotedStoryAttr(input.title)}"` : ''; const dataLabelsAttr = input.show_data_labels ? ' show_data_labels="true"' : ''; const hideTotalAttr = input.hide_total ? ' hide_total="true"' : ''; - return ``; + return ``; } export type StoryTableBlockInput = Pick; diff --git a/apps/shared/src/chart-builder.tsx b/apps/shared/src/chart-builder.tsx index 80b442b78..dc4ef1931 100644 --- a/apps/shared/src/chart-builder.tsx +++ b/apps/shared/src/chart-builder.tsx @@ -166,6 +166,10 @@ export interface BuildChartProps { xAxisMaxLabelChars?: number; yAxisMin?: number; yAxisMax?: number; + yAxisLabel?: string; + yAxisRightMin?: number; + yAxisRightMax?: number; + yAxisRightLabel?: string; /** Chart background color, used as the separator between stacked segments. Pass a concrete color on surfaces where CSS vars do not resolve (backend PNG/HTML export). */ backgroundColor?: string; /** Prefix for SVG gradient ids so multiple charts on one page (and drag clones) don't collide. */ diff --git a/apps/shared/src/mcp-embed.ts b/apps/shared/src/mcp-embed.ts index ba714cab9..ac817c4c1 100644 --- a/apps/shared/src/mcp-embed.ts +++ b/apps/shared/src/mcp-embed.ts @@ -12,6 +12,10 @@ export type McpChartEmbedStoredConfig = { series: displayChart.ChartInput['series']; yAxisMin?: displayChart.ChartInput['y_axis_min']; yAxisMax?: displayChart.ChartInput['y_axis_max']; + yAxisLabel?: displayChart.ChartInput['y_axis_label']; + yAxisRightMin?: displayChart.ChartInput['y_axis_right_min']; + yAxisRightMax?: displayChart.ChartInput['y_axis_right_max']; + yAxisRightLabel?: displayChart.ChartInput['y_axis_right_label']; title: string; }; diff --git a/apps/shared/src/story-segments.ts b/apps/shared/src/story-segments.ts index a740bdbde..e3963a795 100644 --- a/apps/shared/src/story-segments.ts +++ b/apps/shared/src/story-segments.ts @@ -1,5 +1,15 @@ import { buildStoryTableBlock } from './chart-block'; import { type ColumnConditionalFormats, sanitizeConditionalFormats } from './conditional-formatting'; +import type { SeriesType, YAxisSide } from './tools/display-chart'; + +export interface ParsedChartSeries { + data_key: string; + color?: string; + label?: string; + is_total?: boolean; + series_type?: SeriesType; + y_axis?: YAxisSide; +} const GRID_SPAN_DIV_PATTERN = ']*style\\s*=\\s*"[^"]*grid-column\\s*:\\s*span\\s+(\\d+)[^"]*"[^>]*>([\\s\\S]*?)<\\/div>'; @@ -9,9 +19,13 @@ export interface ParsedChartBlock { chartType: string; xAxisKey: string; xAxisType: string | null; - series: Array<{ data_key: string; color: string; label?: string; is_total?: boolean }>; + series: ParsedChartSeries[]; yAxisMin?: number; yAxisMax?: number; + yAxisLabel?: string; + yAxisRightMin?: number; + yAxisRightMax?: number; + yAxisRightLabel?: string; title: string; showDataLabels?: boolean; hideTotal?: boolean; @@ -86,6 +100,8 @@ export function parseChartBlock(attrString: string): ParsedChartBlock | null { const yAxisMin = parseOptionalNumberAttr(attrs.y_axis_min); const yAxisMax = parseOptionalNumberAttr(attrs.y_axis_max); + const yAxisRightMin = parseOptionalNumberAttr(attrs.y_axis_right_min); + const yAxisRightMax = parseOptionalNumberAttr(attrs.y_axis_right_max); return { queryId: attrs.query_id, @@ -95,6 +111,10 @@ export function parseChartBlock(attrString: string): ParsedChartBlock | null { series, yAxisMin, yAxisMax, + yAxisLabel: attrs.y_axis_label || undefined, + yAxisRightMin, + yAxisRightMax, + yAxisRightLabel: attrs.y_axis_right_label || undefined, title: attrs.title || '', showDataLabels: attrs.show_data_labels === 'true', hideTotal: attrs.hide_total === 'true', diff --git a/apps/shared/src/story-validation.ts b/apps/shared/src/story-validation.ts index cf0ee33ec..d7a574a6e 100644 --- a/apps/shared/src/story-validation.ts +++ b/apps/shared/src/story-validation.ts @@ -1,4 +1,5 @@ import { parseChartAttributes, parseGridColumns, parseSeriesJsonArray, TAG_ATTRS } from './story-segments'; +import { ChartTypeEnum, SeriesTypeEnum, XAxisTypeEnum, YAxisSideEnum } from './tools/display-chart'; export interface StoryValidationError { message: string; @@ -10,22 +11,13 @@ export interface StoryValidationError { const REQUIRED_CHART_ATTRS = ['query_id', 'chart_type', 'x_axis_key'] as const; const REQUIRED_TABLE_ATTRS = ['query_id'] as const; -const VALID_CHART_TYPES = new Set([ - 'bar', - 'stacked_bar', - 'stacked_bar_100', - 'line', - 'area', - 'stacked_area', - 'stacked_area_100', - 'pie', - 'donut', - 'kpi_card', - 'scatter', - 'radar', -]); - -const VALID_X_AXIS_TYPES = new Set(['date', 'number', 'category']); +const VALID_CHART_TYPES = new Set(ChartTypeEnum.options); + +const VALID_X_AXIS_TYPES = new Set(XAxisTypeEnum.options); + +const VALID_SERIES_TYPES = new Set(SeriesTypeEnum.options); + +const VALID_Y_AXIS_SIDES = new Set(YAxisSideEnum.options); /** * Validates the structure of a story's markdown code, looking for common @@ -218,6 +210,24 @@ function validateChartSeries( length, }; } + + const { series_type: seriesType, y_axis: yAxis } = item as { series_type?: unknown; y_axis?: unknown }; + if (seriesType !== undefined && !VALID_SERIES_TYPES.has(seriesType as string)) { + return { + message: `Invalid series \`series_type\` "${String(seriesType)}". Valid values: ${[...VALID_SERIES_TYPES].join(', ')}.`, + line: position.line, + column: position.column, + length, + }; + } + if (yAxis !== undefined && !VALID_Y_AXIS_SIDES.has(yAxis as string)) { + return { + message: `Invalid series \`y_axis\` "${String(yAxis)}". Valid values: ${[...VALID_Y_AXIS_SIDES].join(', ')}.`, + line: position.line, + column: position.column, + length, + }; + } } return null; diff --git a/apps/shared/src/tools/display-chart.ts b/apps/shared/src/tools/display-chart.ts index 74495589f..7c34a0809 100644 --- a/apps/shared/src/tools/display-chart.ts +++ b/apps/shared/src/tools/display-chart.ts @@ -17,6 +17,10 @@ export const ChartTypeEnum = z.enum([ 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(), @@ -27,6 +31,12 @@ export const SeriesConfigSchema = z.object({ 'Set to true when this series is an already-aggregated total of the other series (e.g. a grand total, rollup, subtotal, or sum-of-parts column), so the tooltip must not sum it again. Decide this from the meaning of the column, not its name — it applies in any language.', ) .optional(), + series_type: SeriesTypeEnum.describe( + 'How this series is drawn ("bar", "line" or "area"), overriding the chart_type. Use it to mix types in one chart, e.g. bars for revenue and a line for a rate. Defaults to the chart_type.', + ).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" — use it to compare metrics with very different scales/units.', + ).optional(), }); export const ColorScaleRuleSchema = z.object({ @@ -101,10 +111,20 @@ export const ChartInputSchema = z y_axis_min: z .number() .describe( - 'Fixes the Y-axis lower bound. Leave unset to auto-scale for readability (line and scatter charts do not force a zero baseline).', + 'Fixes the left Y-axis lower bound. Leave unset to auto-scale for readability (line and scatter charts do not force a zero baseline).', ) .optional(), - y_axis_max: z.number().describe('Fixes the Y-axis upper bound. Leave unset to auto-scale.').optional(), + y_axis_max: z.number().describe('Fixes the left Y-axis upper bound. Leave unset to auto-scale.').optional(), + y_axis_label: z.string().describe('Label displayed alongside the left Y-axis.').optional(), + y_axis_right_min: z + .number() + .describe('Fixes the right Y-axis lower bound. Leave unset to auto-scale.') + .optional(), + y_axis_right_max: z + .number() + .describe('Fixes the right Y-axis upper bound. Leave unset to auto-scale.') + .optional(), + y_axis_right_label: z.string().describe('Label displayed alongside the right Y-axis.').optional(), show_data_labels: z .boolean() .describe( @@ -127,7 +147,16 @@ export const ChartInputSchema = z (input) => input.y_axis_min === undefined || input.y_axis_max === undefined || input.y_axis_min < input.y_axis_max, { - message: 'The Y-axis minimum must be less than the maximum.', + message: 'The left Y-axis minimum must be less than the maximum.', + }, + ) + .refine( + (input) => + input.y_axis_right_min === undefined || + input.y_axis_right_max === undefined || + input.y_axis_right_min < input.y_axis_right_max, + { + message: 'The right Y-axis minimum must be less than the maximum.', }, ); @@ -188,6 +217,8 @@ 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 ColorScaleRule = z.infer; export type ThresholdRule = z.infer; @@ -225,3 +256,20 @@ export function chartTypeRequiresXAxisKey(type: ChartType): boolean { export function isPieChart(chartType: ChartType): boolean { return chartType === 'pie' || chartType === 'donut'; } + +const COMBO_SERIES_CHART_TYPES = new Set(['bar', 'line', 'area']); + +export function chartTypeSupportsComboSeries(type: ChartType): boolean { + return COMBO_SERIES_CHART_TYPES.has(type); +} + +export function hasRightAxisSeries(series: Pick[]): boolean { + return series.some((s) => s.y_axis === 'right'); +} + +export function isComboChart(type: ChartType, series: Pick[]): boolean { + if (!chartTypeSupportsComboSeries(type)) { + return false; + } + return hasRightAxisSeries(series) || series.some((s) => s.series_type != null); +} From 027eb259989c6fa0d5442cf3dcda9bdb51e0c4fd Mon Sep 17 00:00:00 2001 From: socallmebertille Date: Wed, 22 Jul 2026 13:56:37 +0200 Subject: [PATCH 2/6] implementation of dual y axis for bar, line or area chart --- apps/shared/src/chart-builder.tsx | 177 ++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/apps/shared/src/chart-builder.tsx b/apps/shared/src/chart-builder.tsx index dc4ef1931..a3bc0136f 100644 --- a/apps/shared/src/chart-builder.tsx +++ b/apps/shared/src/chart-builder.tsx @@ -192,6 +192,9 @@ export function buildChart(props: BuildChartProps) { if (displayChart.isPieChart(resolved.chartType)) { return buildPieChart(resolved); } + if (displayChart.isComboChart(resolved.chartType, resolved.series)) { + return buildComboChart(resolved); + } if ( resolved.chartType === 'line' || resolved.chartType === 'area' || @@ -652,6 +655,180 @@ function buildAreaChart(props: ResolvedProps) { ); } +function comboSeriesType(series: displayChart.SeriesConfig, baseType: displayChart.ChartType): displayChart.SeriesType { + if (series.series_type) { + return series.series_type; + } + return baseType === 'line' || baseType === 'area' ? baseType : 'bar'; +} + +function comboAxisSide(series: displayChart.SeriesConfig): displayChart.YAxisSide { + return series.y_axis === 'right' ? 'right' : 'left'; +} + +function buildComboChart(props: ResolvedProps) { + const { + data, + chartType, + xAxisKey, + xAxisType, + series, + colorFor, + labelFormatter, + showGrid, + children, + margin, + xAxisInterval, + yAxisMin, + yAxisMax, + yAxisLabel, + yAxisRightMin, + yAxisRightMax, + yAxisRightLabel, + showDataLabels, + idPrefix, + } = props; + + const leftSeries = series.filter((s) => comboAxisSide(s) === 'left'); + const rightSeries = series.filter((s) => comboAxisSide(s) === 'right'); + const leftDomain = resolveComboAxisDomain(data, leftSeries, yAxisMin, yAxisMax); + const rightDomain = resolveComboAxisDomain(data, rightSeries, yAxisRightMin, yAxisRightMax); + const areaSeries = series.filter((s) => comboSeriesType(s, chartType) === 'area'); + + return ( + + {areaSeries.length > 0 && ( + + {series.map((s, i) => + comboSeriesType(s, chartType) === 'area' ? ( + + + + + ) : null, + )} + + )} + {showGrid && } + {leftSeries.length > 0 && ( + + )} + {rightSeries.length > 0 && ( + + )} + {renderCategoryXAxis({ xAxisKey, xAxisType, xAxisInterval, labelFormatter })} + {children} + {series.map((s, i) => renderComboSeries(s, i, chartType, colorFor, showDataLabels, idPrefix))} + + ); +} + +function resolveComboAxisDomain( + data: Record[], + axisSeries: displayChart.SeriesConfig[], + explicitMin: number | undefined, + explicitMax: number | undefined, +) { + const values = collectAxisValues( + data, + axisSeries.map((s) => s.data_key), + ); + return resolveYAxisDomain(explicitMin, explicitMax, values, true); +} + +function renderComboSeries( + series: displayChart.SeriesConfig, + index: number, + baseType: displayChart.ChartType, + colorFor: (key: string, index: number) => string, + showDataLabels: boolean | undefined, + idPrefix: string, +) { + const color = colorFor(series.data_key, index); + const yAxisId = comboAxisSide(series); + const type = comboSeriesType(series, baseType); + + if (type === 'line') { + return ( + + ); + } + if (type === 'area') { + return ( + + ); + } + return ( + + {showDataLabels && } + + ); +} + +function axisLabel(label: string | undefined, side: displayChart.YAxisSide) { + 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, yAxisMin, yAxisMax } = props; const axisValues = collectAxisValues( From 1e865355b1da50e6a7d8936ae8b6fc1018405dcb Mon Sep 17 00:00:00 2001 From: socallmebertille Date: Wed, 22 Jul 2026 14:55:48 +0200 Subject: [PATCH 3/6] add test --- .../tests/generate-chart-combo.test.tsx | 74 ++++++++++ apps/shared/src/chart-builder.tsx | 9 +- apps/shared/tests/chart-block-combo.test.ts | 105 ++++++++++++++ .../shared/tests/chart-builder-combo.test.tsx | 136 ++++++++++++++++++ 4 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 apps/backend/tests/generate-chart-combo.test.tsx create mode 100644 apps/shared/tests/chart-block-combo.test.ts create mode 100644 apps/shared/tests/chart-builder-combo.test.tsx 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..6e1e5e505 --- /dev/null +++ b/apps/backend/tests/generate-chart-combo.test.tsx @@ -0,0 +1,74 @@ +import type { displayChart } from '@nao/shared/tools'; +import { describe, expect, it } from 'vitest'; + +import { renderChartToSvg } from '../src/components/generate-chart'; + +const data = [ + { month: '2024-01', revenue: 1000, orders: 12 }, + { month: '2024-02', revenue: 1500, orders: 18 }, +]; + +function render( + config: Partial[0]['config']> & { series: displayChart.SeriesConfig[] }, +) { + return renderChartToSvg({ + config: { + chart_type: 'bar', + x_axis_key: 'month', + x_axis_type: 'category', + title: 'Test', + ...config, + }, + data, + }); +} + +function countYAxes(svg: string): number { + return (svg.match(/recharts-yAxis/g) ?? []).length; +} + +describe('renderChartToSvg (combo)', () => { + it('renders mixed bar and line series', () => { + const svg = render({ + series: [ + { data_key: 'revenue', series_type: 'bar' }, + { data_key: 'orders', series_type: 'line' }, + ], + }); + expect(svg).toContain('recharts-bar'); + expect(svg).toContain('recharts-line'); + }); + + it('draws a second Y-axis when a series uses the right axis', () => { + const svg = render({ + series: [ + { data_key: 'revenue', series_type: 'bar', y_axis: 'left' }, + { data_key: 'orders', series_type: 'line', y_axis: 'right' }, + ], + }); + expect(countYAxes(svg)).toBe(2); + }); + + it('keeps a single Y-axis when every series stays on the left', () => { + const svg = render({ + series: [ + { data_key: 'revenue', series_type: 'bar', y_axis: 'left' }, + { data_key: 'orders', series_type: 'line', y_axis: 'left' }, + ], + }); + expect(countYAxes(svg)).toBe(1); + }); + + it('renders independent axis labels', () => { + const svg = render({ + series: [ + { data_key: 'revenue', series_type: 'bar', y_axis: 'left' }, + { data_key: 'orders', series_type: 'line', y_axis: 'right' }, + ], + y_axis_label: 'Revenue', + y_axis_right_label: 'Orders', + }); + expect(svg).toContain('Revenue'); + expect(svg).toContain('Orders'); + }); +}); diff --git a/apps/shared/src/chart-builder.tsx b/apps/shared/src/chart-builder.tsx index a3bc0136f..49f95d22c 100644 --- a/apps/shared/src/chart-builder.tsx +++ b/apps/shared/src/chart-builder.tsx @@ -5,8 +5,10 @@ import { Bar, BarChart, CartesianGrid, + ComposedChart, Customized, LabelList, + Line, Pie, PieChart, PolarAngleAxis, @@ -280,7 +282,10 @@ export function clampNegativeSeriesValues( } type ResolvedProps = BuildChartProps & - Required> & { xAxisInterval?: number }; + Required> & { + xAxisInterval?: number; + idPrefix?: string; + }; function buildChartMargin(props: BuildChartProps, showTitle: boolean) { const titleTop = showTitle ? 30 : 0; @@ -686,7 +691,7 @@ function buildComboChart(props: ResolvedProps) { yAxisRightMax, yAxisRightLabel, showDataLabels, - idPrefix, + idPrefix = '', } = props; const leftSeries = series.filter((s) => comboAxisSide(s) === 'left'); 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..4cdeb45ea --- /dev/null +++ b/apps/shared/tests/chart-block-combo.test.ts @@ -0,0 +1,105 @@ +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 { ChartInputSchema } from '../src/tools/display-chart'; + +describe('combo chart input schema', () => { + it('accepts a mixed-series dual-axis chart', () => { + const result = ChartInputSchema.safeParse({ + query_id: 'q1', + chart_type: 'bar', + x_axis_key: 'month', + x_axis_type: 'category', + series: [ + { data_key: 'revenue', series_type: 'bar', y_axis: 'left' }, + { data_key: 'conversion_rate', series_type: 'line', y_axis: 'right' }, + ], + y_axis_label: 'Revenue', + y_axis_right_min: 0, + y_axis_right_max: 100, + y_axis_right_label: 'Conversion rate', + title: 'Revenue vs. conversion rate', + }); + expect(result.success).toBe(true); + }); + + it('rejects an inverted right-axis range', () => { + const result = ChartInputSchema.safeParse({ + query_id: 'q1', + chart_type: 'bar', + x_axis_key: 'month', + x_axis_type: 'category', + series: [{ data_key: 'orders', series_type: 'line', y_axis: 'right' }], + y_axis_right_min: 100, + y_axis_right_max: 0, + title: 'Orders', + }); + expect(result.success).toBe(false); + }); +}); + +describe('combo chart block round-trip', () => { + it('preserves series_type, y_axis and per-axis configuration', () => { + const block = buildStoryChartBlock({ + query_id: 'q1', + chart_type: 'bar', + x_axis_key: 'month', + x_axis_type: 'category', + series: [ + { data_key: 'revenue', series_type: 'bar', y_axis: 'left' }, + { data_key: 'orders', series_type: 'line', y_axis: 'right' }, + ], + y_axis_label: 'Revenue', + y_axis_right_min: 0, + y_axis_right_max: 100, + y_axis_right_label: 'Orders', + title: 'Revenue vs. orders', + }); + + const attrString = block.match(/^$/)?.[1]; + expect(attrString).toBeDefined(); + + const parsed = parseChartBlock(attrString ?? ''); + expect(parsed?.series).toEqual([ + { data_key: 'revenue', series_type: 'bar', y_axis: 'left' }, + { data_key: 'orders', series_type: 'line', y_axis: 'right' }, + ]); + expect(parsed?.yAxisLabel).toBe('Revenue'); + expect(parsed?.yAxisRightMin).toBe(0); + expect(parsed?.yAxisRightMax).toBe(100); + expect(parsed?.yAxisRightLabel).toBe('Orders'); + }); +}); + +describe('combo chart story validation', () => { + it('passes validation for a valid combo chart', () => { + const block = buildStoryChartBlock({ + query_id: 'q1', + chart_type: 'bar', + x_axis_key: 'month', + x_axis_type: 'category', + series: [ + { data_key: 'revenue', series_type: 'bar', y_axis: 'left' }, + { data_key: 'orders', series_type: 'line', y_axis: 'right' }, + ], + title: 'Revenue vs. orders', + }); + expect(validateStoryCode(block)).toEqual([]); + }); + + it('flags an invalid series_type', () => { + const code = + ''; + const errors = validateStoryCode(code); + expect(errors.some((error) => error.message.includes('series_type'))).toBe(true); + }); + + it('flags an invalid y_axis', () => { + const code = + ''; + const errors = validateStoryCode(code); + expect(errors.some((error) => error.message.includes('y_axis'))).toBe(true); + }); +}); diff --git a/apps/shared/tests/chart-builder-combo.test.tsx b/apps/shared/tests/chart-builder-combo.test.tsx new file mode 100644 index 000000000..bc14b2c00 --- /dev/null +++ b/apps/shared/tests/chart-builder-combo.test.tsx @@ -0,0 +1,136 @@ +import type { ReactElement } from 'react'; +import { describe, expect, it } from 'vitest'; + +import { buildChart } from '../src/chart-builder'; +import type { SeriesConfig } from '../src/tools/display-chart'; + +const data = [ + { month: '2024-01', revenue: 1000, orders: 12 }, + { month: '2024-02', revenue: 1500, orders: 18 }, +]; + +function combo(series: SeriesConfig[], overrides: Record = {}) { + return buildChart({ + data, + chartType: 'bar', + xAxisKey: 'month', + xAxisType: 'category', + series, + renderTitle: false, + ...overrides, + }); +} + +describe('buildChart (combo)', () => { + it('draws a second Y-axis when a series uses the right axis', () => { + const axes = getByType( + combo([ + { data_key: 'revenue', series_type: 'bar', y_axis: 'left' }, + { data_key: 'orders', series_type: 'line', y_axis: 'right' }, + ]), + 'YAxis', + ); + expect(axes.map((a) => a.props.yAxisId).sort()).toEqual(['left', 'right']); + }); + + it('renders both bar and line series in one chart', () => { + const chart = combo([ + { data_key: 'revenue', series_type: 'bar' }, + { data_key: 'orders', series_type: 'line' }, + ]); + expect(getByType(chart, 'Bar')).toHaveLength(1); + expect(getByType(chart, 'Line')).toHaveLength(1); + }); + + it('keeps a single (left) Y-axis when every series stays on the left', () => { + const axes = getByType( + combo([ + { data_key: 'revenue', series_type: 'bar', y_axis: 'left' }, + { data_key: 'orders', series_type: 'line', y_axis: 'left' }, + ]), + 'YAxis', + ); + expect(axes).toHaveLength(1); + expect(axes[0].props.yAxisId).toBe('left'); + }); + + it('does not draw an empty left axis when all series are on the right', () => { + const axes = getByType( + combo([ + { data_key: 'revenue', series_type: 'bar', y_axis: 'right' }, + { data_key: 'orders', series_type: 'line', y_axis: 'right' }, + ]), + 'YAxis', + ); + expect(axes).toHaveLength(1); + expect(axes[0].props.yAxisId).toBe('right'); + }); + + it('resolves the right (line) axis with the same logic as the left, deferring to Recharts', () => { + const axes = getByType( + combo([ + { data_key: 'revenue', series_type: 'bar', y_axis: 'left' }, + { data_key: 'orders', series_type: 'line', y_axis: 'right' }, + ]), + 'YAxis', + ); + const right = axes.find((a) => a.props.yAxisId === 'right'); + const left = axes.find((a) => a.props.yAxisId === 'left'); + expect(right?.props.domain).toBeUndefined(); + expect(left?.props.domain).toBeUndefined(); + }); + + it('applies independent axis labels and manual right-axis bounds', () => { + const chart = combo( + [ + { data_key: 'revenue', series_type: 'bar', y_axis: 'left' }, + { data_key: 'orders', series_type: 'line', y_axis: 'right' }, + ], + { yAxisLabel: 'Revenue', yAxisRightLabel: 'Orders', yAxisRightMin: 0, yAxisRightMax: 100 }, + ); + const axes = getByType(chart, 'YAxis'); + const right = axes.find((a) => a.props.yAxisId === 'right'); + const left = axes.find((a) => a.props.yAxisId === 'left'); + expect(left?.props.label?.value).toBe('Revenue'); + expect(right?.props.label?.value).toBe('Orders'); + expect(right?.props.domain).toEqual([0, 100]); + }); + + it('isolates gradient ids per chart via idPrefix', () => { + const series: SeriesConfig[] = [ + { data_key: 'revenue', series_type: 'bar' }, + { data_key: 'orders', series_type: 'area', y_axis: 'right' }, + ]; + const first = getByType(combo(series, { idPrefix: 'a' }), 'Area')[0]; + const second = getByType(combo(series, { idPrefix: 'b' }), 'Area')[0]; + expect(first.props.fill).not.toBe(second.props.fill); + }); +}); + +interface ChartElementProps { + yAxisId?: string; + domain?: [number, number]; + label?: { value?: string }; + fill?: string; +} + +function getByType(chart: ReactElement, displayName: string): ReactElement[] { + const children = (chart.props as { children?: unknown }).children; + return flattenChildren(children).filter( + (child) => (child.type as { displayName?: string })?.displayName === displayName, + ) as ReactElement[]; +} + +function flattenChildren(children: unknown): ReactElement[] { + if (Array.isArray(children)) { + return children.flatMap(flattenChildren); + } + if (isReactElement(children)) { + return [children]; + } + return []; +} + +function isReactElement(value: unknown): value is ReactElement { + return typeof value === 'object' && value !== null && 'props' in value && 'type' in value; +} From 88b201cc278f6429d4c2546a09246d824ab6bcca Mon Sep 17 00:00:00 2001 From: socallmebertille Date: Wed, 22 Jul 2026 18:20:17 +0200 Subject: [PATCH 4/6] improve edit dialog and add a chart type category as mixed that can draw bar line or area chart mixed together --- .../src/components/ai/system-prompt.tsx | 9 +- .../tests/generate-chart-combo.test.tsx | 2 +- .../tool-calls/display-chart-edit-dialog.tsx | 361 +++++++++++------- .../components/tool-calls/display-chart.tsx | 5 +- apps/shared/src/chart-builder.tsx | 37 +- apps/shared/src/tools/display-chart.ts | 35 +- apps/shared/tests/chart-block-combo.test.ts | 6 +- .../shared/tests/chart-builder-combo.test.tsx | 2 +- 8 files changed, 281 insertions(+), 176 deletions(-) diff --git a/apps/backend/src/components/ai/system-prompt.tsx b/apps/backend/src/components/ai/system-prompt.tsx index 7dadb604e..737466432 100644 --- a/apps/backend/src/components/ai/system-prompt.tsx +++ b/apps/backend/src/components/ai/system-prompt.tsx @@ -130,11 +130,10 @@ export function SystemPrompt({ line and scatter charts auto-scale to a readable range rather than always starting at zero. - To compare metrics on different scales, set per-series "series_type" ("bar", "line" or "area") to - mix chart types and "y_axis" ("left" or "right") to add a second Y-axis (drawn automatically when - any series uses {'"y_axis": "right"'}) — e.g. revenue as bars on the left, conversion rate as a line - on the right. Label axes with y_axis_label / y_axis_right_label and fix scales with - y_axis_min/y_axis_max and y_axis_right_min/y_axis_right_max. + Use "mixed" to show multiple metrics on different scales in one chart: give each series its own + "series_type" ("bar", "line" or "area", defaults to "bar") and "y_axis" ("left" or "right", defaults + to "left"). A second Y-axis is drawn whenever any series uses {'"y_axis": "right"'}. These + per-series settings only apply to "mixed" charts. Use the display_chart tool with {'chart_type: "table"'} to present a table diff --git a/apps/backend/tests/generate-chart-combo.test.tsx b/apps/backend/tests/generate-chart-combo.test.tsx index 6e1e5e505..8b5866fc5 100644 --- a/apps/backend/tests/generate-chart-combo.test.tsx +++ b/apps/backend/tests/generate-chart-combo.test.tsx @@ -13,7 +13,7 @@ function render( ) { return renderChartToSvg({ config: { - chart_type: 'bar', + chart_type: 'mixed', x_axis_key: 'month', x_axis_type: 'category', title: 'Test', 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 734a086a1..e68f7b567 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 @@ -1,6 +1,7 @@ +import { DEFAULT_COLORS } from '@nao/shared'; import { displayChart } from '@nao/shared/tools'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Trash2 } from 'lucide-react'; +import { ChartArea, ChartBar, ChartColumn, ChartColumnIncreasing, ChartLine, Plus, Trash2 } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { Button } from '../ui/button'; @@ -8,6 +9,8 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { Input } from '../ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; import { Switch } from '../ui/switch'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; +import type { LucideIcon } from 'lucide-react'; import type { UIMessage, UIToolPart } from '@nao/backend/chat'; import { trpc } from '@/main'; import { useAgentContext } from '@/contexts/agent.provider'; @@ -18,6 +21,7 @@ const CHART_TYPE_OPTIONS: { value: displayChart.ChartType; label: string }[] = [ { value: 'line', label: 'Line' }, { value: 'area', label: 'Area' }, { value: 'stacked_area', label: 'Stacked area' }, + { value: 'mixed', label: 'Mixed' }, { value: 'pie', label: 'Pie' }, { value: 'donut', label: 'Donut' }, { value: 'kpi_card', label: 'KPI card' }, @@ -32,15 +36,10 @@ const X_AXIS_TYPE_OPTIONS: { value: NonNullable | 'auto' { value: 'number', label: 'Number' }, ]; -const SERIES_TYPE_OPTIONS: { value: displayChart.SeriesType; label: string }[] = [ - { value: 'bar', label: 'Bar' }, - { value: 'line', label: 'Line' }, - { value: 'area', label: 'Area' }, -]; - -const Y_AXIS_SIDE_OPTIONS: { value: displayChart.YAxisSide; label: string }[] = [ - { value: 'left', label: 'Left axis' }, - { value: 'right', label: 'Right axis' }, +const SERIES_TYPE_OPTIONS: { value: displayChart.SeriesType; label: string; icon: LucideIcon }[] = [ + { value: 'bar', label: 'Bar', icon: ChartColumnIncreasing }, + { value: 'line', label: 'Line', icon: ChartLine }, + { value: 'area', label: 'Area', icon: ChartArea }, ]; const Y_AXIS_RANGE_UNSUPPORTED_CHART_TYPES = new Set(['pie', 'kpi_card', 'radar']); @@ -93,10 +92,13 @@ export function ChartConfigEditDialog({ const [yAxisRightMinText, setYAxisRightMinText] = useState(toRangeString(config.y_axis_right_min)); const [yAxisRightMaxText, setYAxisRightMaxText] = useState(toRangeString(config.y_axis_right_max)); const [error, setError] = useState(null); + // Chart palette resolved to hex so a series without an explicit color shows + // the same swatch the chart draws for it. Refreshed on open for the theme. + const [paletteHexes, setPaletteHexes] = useState(DEFAULT_COLORS); const supportsYAxisRange = !Y_AXIS_RANGE_UNSUPPORTED_CHART_TYPES.has(draft.chart_type); const isCombo = displayChart.chartTypeSupportsComboSeries(draft.chart_type); const hasRightAxis = isCombo && displayChart.hasRightAxisSeries(draft.series); - const hasLeftAxis = draft.series.some((s) => s.y_axis === 'left'); + const hasLeftAxis = !isCombo || draft.series.some((s) => s.y_axis !== 'right'); useEffect(() => { if (open) { @@ -105,6 +107,7 @@ export function ChartConfigEditDialog({ setYAxisMaxText(toRangeString(config.y_axis_max)); setYAxisRightMinText(toRangeString(config.y_axis_right_min)); setYAxisRightMaxText(toRangeString(config.y_axis_right_max)); + setPaletteHexes(resolveChartPaletteHexes()); setError(null); } }, [open, config]); @@ -314,9 +317,11 @@ export function ChartConfigEditDialog({
- {draft.series.map((series, index) => ( -
-
+ {draft.series.map((series, index) => { + const row = ( +
0 ? availableColumns : [series.data_key]} @@ -330,10 +335,19 @@ export function ChartConfigEditDialog({ placeholder='Label (optional)' className='h-8 rounded-lg text-sm bg-panel' /> + {isCombo && ( + updateSeriesAt(index, { y_axis: value })} + /> + )} 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' /> @@ -349,130 +363,71 @@ export function ChartConfigEditDialog({
- {isCombo && ( -
- - updateSeriesAt(index, { - series_type: value as displayChart.SeriesType, - }) - } - /> - - updateSeriesAt(index, { y_axis: value as displayChart.YAxisSide }) - } - /> + ); + + if (!isCombo) { + return ( +
+ {row}
- )} -
- ))} + ); + } + + return ( +
+ + updateSeriesAt(index, { series_type: value })} + /> + + {row} +
+ ); + })}
- {supportsYAxisRange && hasLeftAxis && ( -
- - {isCombo ? 'Left Y-axis' : 'Y-axis range'} - - {isCombo && ( - - setDraft((prev) => ({ ...prev, y_axis_label: e.target.value || undefined })) + {isCombo && (hasLeftAxis || hasRightAxis) && ( +
+ Axis + {hasLeftAxis && ( + + setDraft((prev) => ({ ...prev, y_axis_label: value || undefined })) } + minId='chart-y-axis-min' + maxId='chart-y-axis-max' + minValue={yAxisMinText} + maxValue={yAxisMaxText} + onMinChange={updateYAxisMin} + onMaxChange={updateYAxisMax} + /> + )} + {hasRightAxis && ( + + setDraft((prev) => ({ ...prev, y_axis_right_label: value || undefined })) + } + minId='chart-y-axis-right-min' + maxId='chart-y-axis-right-max' + minValue={yAxisRightMinText} + maxValue={yAxisRightMaxText} + onMinChange={updateYAxisRightMin} + onMaxChange={updateYAxisRightMax} /> )} -
-
- - updateYAxisMin(e.target.value)} - /> -
-
- - updateYAxisMax(e.target.value)} - /> -
-
)} - {hasRightAxis && ( -
- Right Y-axis - - setDraft((prev) => ({ ...prev, y_axis_right_label: e.target.value || undefined })) - } - /> -
-
- - updateYAxisRightMin(e.target.value)} - /> -
-
- - updateYAxisRightMax(e.target.value)} - /> -
-
-
- )}
Options
@@ -590,22 +545,97 @@ function ColumnSelect({ value, columns, onChange }: ColumnSelectProps) { ); } -interface EnumSelectProps { - value: string; - options: { value: string; label: string }[]; - onChange: (value: string) => void; +interface AxisFieldsProps { + name: string; + showRange: boolean; + labelPlaceholder: string; + labelValue: string; + onLabelChange: (value: string) => void; + minId: string; + maxId: string; + minValue: string; + maxValue: string; + onMinChange: (value: string) => void; + onMaxChange: (value: string) => void; } -function EnumSelect({ value, options, onChange }: EnumSelectProps) { +/** One axis on a single row: label field with its min/max fields underneath their headers. */ +function AxisFields({ + name, + showRange, + labelPlaceholder, + labelValue, + onLabelChange, + minId, + maxId, + minValue, + maxValue, + onMinChange, + onMaxChange, +}: AxisFieldsProps) { return ( - onLabelChange(e.target.value)} + /> +
+ {showRange && ( + <> +
+ + onMinChange(e.target.value)} + /> +
+
+ + onMaxChange(e.target.value)} + /> +
+ + )} +
+ ); +} + +interface SeriesTypeSelectProps { + value: displayChart.SeriesType; + onChange: (value: displayChart.SeriesType) => void; +} + +function SeriesTypeSelect({ value, onChange }: SeriesTypeSelectProps) { + return ( + onMinChange(e.target.value)} - /> -
-
- - onMaxChange(e.target.value)} - /> -
- + )}
); } +interface MinMaxFieldsProps { + minId: string; + maxId: string; + minValue: string; + maxValue: string; + onMinChange: (value: string) => void; + onMaxChange: (value: string) => void; +} + +function MinMaxFields({ minId, maxId, minValue, maxValue, onMinChange, onMaxChange }: MinMaxFieldsProps) { + return ( + <> +
+ + onMinChange(e.target.value)} + /> +
+
+ + onMaxChange(e.target.value)} + /> +
+ + ); +} + interface SeriesTypeSelectProps { value: displayChart.SeriesType; onChange: (value: displayChart.SeriesType) => void; @@ -660,6 +702,7 @@ function YAxisSideToggle({ value, onChange }: YAxisSideToggleProps) { variant='outline' size='icon-sm' className='size-8 bg-panel' + aria-label={isRight ? 'Right Y-axis' : 'Left Y-axis'} onClick={() => { onChange(isRight ? 'left' : 'right'); setOpen(true); @@ -708,14 +751,22 @@ function resolveChartPaletteHexes(): string[] { if (!value || !context) { return fallback; } - const sentinel = '#010203'; - context.fillStyle = sentinel; - context.fillStyle = value; - const resolved = context.fillStyle; - return resolved !== sentinel && HEX_RE.test(resolved) ? resolved : fallback; + return cssColorToHex(context, value) ?? fallback; }); } +function cssColorToHex(context: CanvasRenderingContext2D, color: string): string | null { + const sentinel = '#010203'; + context.fillStyle = sentinel; + context.fillStyle = color; + if (context.fillStyle === sentinel && color.toLowerCase() !== sentinel) { + return null; + } + context.fillRect(0, 0, 1, 1); + const [r, g, b] = context.getImageData(0, 0, 1, 1).data; + return `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, '0')).join('')}`; +} + function applyChartConfigToMessages( messages: UIMessage[], toolCallId: string, diff --git a/apps/shared/src/chart-builder.tsx b/apps/shared/src/chart-builder.tsx index 243ffc898..a79c74480 100644 --- a/apps/shared/src/chart-builder.tsx +++ b/apps/shared/src/chart-builder.tsx @@ -313,7 +313,8 @@ function isCartesianLabelChart(chartType: displayChart.ChartType): boolean { chartType === 'stacked_bar' || chartType === 'line' || chartType === 'area' || - chartType === 'stacked_area' + chartType === 'stacked_area' || + chartType === 'mixed' ); } @@ -466,6 +467,7 @@ function buildBarChart(props: ResolvedProps) { data, chartType, xAxisKey, + xAxisType, colorFor, labelFormatter, showGrid, @@ -506,7 +508,7 @@ function buildBarChart(props: ResolvedProps) { )} {renderCategoryXAxis({ xAxisKey, - xAxisType: 'category', + xAxisType: xAxisType ?? 'category', xAxisInterval, labelFormatter, compact: compactXAxis, @@ -592,6 +594,7 @@ function buildAreaChart(props: ResolvedProps) { yAxisMin, yAxisMax, showDataLabels, + idPrefix = '', } = props; const gradientIdPrefix = props.gradientIdPrefix ?? ''; const gradientIdFor = (index: number) => `${gradientIdPrefix}grad-${index}`; @@ -698,7 +701,6 @@ function buildComboChart(props: ResolvedProps) { const leftDomain = resolveComboAxisDomain(data, leftSeries, yAxisMin, yAxisMax); const rightDomain = resolveComboAxisDomain(data, rightSeries, yAxisRightMin, yAxisRightMax); const areaSeries = series.filter((s) => comboSeriesType(s, chartType) === 'area'); - const hasBarSeries = series.some((s) => comboSeriesType(s, chartType) === 'bar'); const pointLabelContent = showDataLabels ? buildPointLabelContentBySeries(data, series) : new Map(); return ( @@ -752,7 +754,7 @@ function buildComboChart(props: ResolvedProps) { )} {renderCategoryXAxis({ xAxisKey, - xAxisType: hasBarSeries ? 'category' : xAxisType, + xAxisType: xAxisType ?? 'category', xAxisInterval, labelFormatter, })} diff --git a/apps/shared/src/story-segments.ts b/apps/shared/src/story-segments.ts index e3963a795..0f321df1b 100644 --- a/apps/shared/src/story-segments.ts +++ b/apps/shared/src/story-segments.ts @@ -1,15 +1,8 @@ import { buildStoryTableBlock } from './chart-block'; import { type ColumnConditionalFormats, sanitizeConditionalFormats } from './conditional-formatting'; -import type { SeriesType, YAxisSide } from './tools/display-chart'; +import type { SeriesConfig } from './tools/display-chart'; -export interface ParsedChartSeries { - data_key: string; - color?: string; - label?: string; - is_total?: boolean; - series_type?: SeriesType; - y_axis?: YAxisSide; -} +export type ParsedChartSeries = SeriesConfig; const GRID_SPAN_DIV_PATTERN = ']*style\\s*=\\s*"[^"]*grid-column\\s*:\\s*span\\s+(\\d+)[^"]*"[^>]*>([\\s\\S]*?)<\\/div>'; From be86caa42e1a6840858186e8e8e0d8e09a834796 Mon Sep 17 00:00:00 2001 From: socallmebertille Date: Thu, 23 Jul 2026 14:49:35 +0200 Subject: [PATCH 6/6] tooltip at the top instead of the bottom --- .../src/components/tool-calls/display-chart-edit-dialog.tsx | 2 +- apps/shared/src/chart-builder.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) 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 337b75861..fca0d8922 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 @@ -711,7 +711,7 @@ function YAxisSideToggle({ value, onChange }: YAxisSideToggleProps) { - {isRight ? 'Right Y-axis' : 'Left Y-axis'} + {isRight ? 'Right Y-axis' : 'Left Y-axis'} ); } diff --git a/apps/shared/src/chart-builder.tsx b/apps/shared/src/chart-builder.tsx index a79c74480..e9090595b 100644 --- a/apps/shared/src/chart-builder.tsx +++ b/apps/shared/src/chart-builder.tsx @@ -594,7 +594,6 @@ function buildAreaChart(props: ResolvedProps) { yAxisMin, yAxisMax, showDataLabels, - idPrefix = '', } = props; const gradientIdPrefix = props.gradientIdPrefix ?? ''; const gradientIdFor = (index: number) => `${gradientIdPrefix}grad-${index}`;