Skip to content

feat(charts): add dual-axis combo chart mode#943

Closed
Bl3f wants to merge 2 commits into
mainfrom
cursor/dual-axis-charts-91b8
Closed

feat(charts): add dual-axis combo chart mode#943
Bl3f wants to merge 2 commits into
mainfrom
cursor/dual-axis-charts-91b8

Conversation

@Bl3f

@Bl3f Bl3f commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements dual-axis charts with mixed bar + line (+ area) series, closing #873.

Adds a new combo chart type to display_chart in a backward-compatible way. Existing single-axis charts are unchanged; the work is concentrated in the shared schema and the single buildChart renderer, so chat, stories, PNG export, and MCP embeds all stay in sync.

Walkthrough

dual_axis_combo_chart_tooltip_demo.mp4

Combo chart: dark-teal bars (Revenue, left axis) and an orange line (conversion rate, right axis), each with its own labeled Y-axis; tooltips show both series with no misleading cross-axis "Total".

Dual-axis combo chart

How this maps to the issue's expected behavior

Requirement Approach
Secondary Y-axis Per-series y_axis: "left" | "right" (default left). A right YAxis is rendered whenever any series uses right.
Mixed bar + line New chart_type: "combo"; each series sets series_type: "bar" | "line" | "area".
Series → axis y_axis on each series[] entry.
Independent axis labels/scales Optional top-level y_axes.left / y_axes.right with label and domain ("auto" or { min, max }).

Example tool input

{
  "query_id": "query_…",
  "chart_type": "combo",
  "x_axis_key": "month",
  "x_axis_type": "date",
  "title": "Monthly revenue vs order count",
  "y_axes": {
    "left": { "label": "Revenue (USD)" },
    "right": { "label": "Orders" }
  },
  "series": [
    { "data_key": "total_revenue", "series_type": "bar", "y_axis": "left" },
    { "data_key": "order_count", "series_type": "line", "y_axis": "right" }
  ]
}

Changes

  • Schema (apps/shared/src/tools/display-chart.ts): combo chart type, per-series series_type + y_axis, top-level y_axes (label + domain).
  • Renderer (apps/shared/src/chart-builder.tsx): buildComboChart using recharts ComposedChart with an optional right YAxis, per-series Bar/Line/Area geometry, and configurable axis labels/domains.
  • Story + MCP propagation: serialize/parse/validate y_axes and the new series fields through story <chart> blocks, MCP embed config, PNG/SVG export, and story HTML export.
  • Edit dialog: combo option plus per-series type/axis selectors and left/right axis label inputs.
  • Tooltip: suppress the cross-axis "Total" row for combo charts (React tooltip + exported story HTML tooltip).
  • System prompt: guidance on when and how to use combo / dual-axis charts.
  • Tests: combo SVG rendering (mixed series, dual axis, labels) and story block round-trip + validation.

Testing

  • npm run lint — tsc + eslint across all workspaces (pass)
  • npm run -w @nao/shared test — 74 pass (incl. combo story round-trip)
  • npm run -w @nao/backend test -- generate-chart-combo — 5 pass
  • Manual UI verification of the rendered interactive combo chart (see walkthrough video/screenshot).

To show artifacts inline, enable in settings.

Open in Web Open in Cursor 

Review in cubic

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
@github-actions

github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

🧹 Preview Removed

The preview deployment for this PR has been cleaned up.

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.
@Bl3f
Bl3f requested a review from socallmebertille July 2, 2026 14:45
@Bl3f
Bl3f marked this pull request as ready for review July 2, 2026 14:45

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

5 issues found across 20 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/shared/src/story-segments.ts">

<violation number="1" location="apps/shared/src/story-segments.ts:3">
P3: Parsed combo story series can omit `color`, but `ParsedChartSeries.color` is typed as required. This makes downstream code believe a parsed value is always a string even though generated combo blocks and the display-chart schema allow it to be absent; `color?: string` would match runtime behavior.</violation>

<violation number="2" location="apps/shared/src/story-segments.ts:82">
P2: Malformed `y_axes` story attributes can now reach chart rendering as valid axis config because the parser only checks that the JSON is an object before casting. Consider validating `left/right.domain` as either `'auto'` or numeric `{ min, max }` before returning `yAxes`, so edited stories fail safely instead of passing undefined bounds into Recharts.</violation>
</file>

<file name="apps/shared/src/tools/display-chart.ts">

<violation number="1" location="apps/shared/src/tools/display-chart.ts:37">
P2: Manual axis ranges can validate even when `min` is greater than `max`, which can produce inverted or broken Y-axis scaling at render time. Adding a schema refinement for `min <= max` would reject invalid domain configs early.</violation>
</file>

<file name="apps/shared/src/story-validation.ts">

<violation number="1" location="apps/shared/src/story-validation.ts:23">
P2: Story validation doesn't check combo-specific series fields (`series_type`, `y_axis`). When `chart_type="combo"`, the validator could reject invalid `series_type` values (must be "bar"|"line"|"area") and `y_axis` values (must be "left"|"right"). Without this, story authors get no editor feedback for typos or invalid combo config — the chart would silently apply defaults or potentially misrender.</violation>
</file>

<file name="apps/shared/src/chart-builder.tsx">

<violation number="1" location="apps/shared/src/chart-builder.tsx:319">
P2: Area fills can bleed across multiple combo charts because gradient IDs are only index-based and not chart-scoped. Consider generating per-chart-unique gradient IDs before wiring `id`/`fill` so each chart’s SVG defs remain isolated.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

xAxisKey: attrs.x_axis_key,
xAxisType: attrs.x_axis_type || null,
series,
yAxes: attrs.y_axes ? (tryParseJsonObject(attrs.y_axes) as ParsedYAxesConfig | undefined) : undefined,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Malformed y_axes story attributes can now reach chart rendering as valid axis config because the parser only checks that the JSON is an object before casting. Consider validating left/right.domain as either 'auto' or numeric { min, max } before returning yAxes, so edited stories fail safely instead of passing undefined bounds into Recharts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/shared/src/story-segments.ts, line 82:

<comment>Malformed `y_axes` story attributes can now reach chart rendering as valid axis config because the parser only checks that the JSON is an object before casting. Consider validating `left/right.domain` as either `'auto'` or numeric `{ min, max }` before returning `yAxes`, so edited stories fail safely instead of passing undefined bounds into Recharts.</comment>

<file context>
@@ -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 || '',
 	};
</file context>

Comment on lines +37 to +40
z.object({
min: z.number().describe('Minimum value of the axis scale.'),
max: z.number().describe('Maximum value of the axis scale.'),
}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Manual axis ranges can validate even when min is greater than max, which can produce inverted or broken Y-axis scaling at render time. Adding a schema refinement for min <= max would reject invalid domain configs early.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/shared/src/tools/display-chart.ts, line 37:

<comment>Manual axis ranges can validate even when `min` is greater than `max`, which can produce inverted or broken Y-axis scaling at render time. Adding a schema refinement for `min <= max` would reject invalid domain configs early.</comment>

<file context>
@@ -10,16 +10,49 @@ export const ChartTypeEnum = z.enum([
+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.'),
</file context>
Suggested change
z.object({
min: z.number().describe('Minimum value of the axis scale.'),
max: z.number().describe('Maximum value of the axis scale.'),
}),
z.object({
min: z.number().describe('Minimum value of the axis scale.'),
max: z.number().describe('Maximum value of the axis scale.'),
}).refine((domain) => domain.min <= domain.max, {
message: 'Axis domain requires min <= max.',
}),

'kpi_card',
'scatter',
'radar',
'combo',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Story validation doesn't check combo-specific series fields (series_type, y_axis). When chart_type="combo", the validator could reject invalid series_type values (must be "bar"|"line"|"area") and y_axis values (must be "left"|"right"). Without this, story authors get no editor feedback for typos or invalid combo config — the chart would silently apply defaults or potentially misrender.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/shared/src/story-validation.ts, line 23:

<comment>Story validation doesn't check combo-specific series fields (`series_type`, `y_axis`). When `chart_type="combo"`, the validator could reject invalid `series_type` values (must be "bar"|"line"|"area") and `y_axis` values (must be "left"|"right"). Without this, story authors get no editor feedback for typos or invalid combo config — the chart would silently apply defaults or potentially misrender.</comment>

<file context>
@@ -20,6 +20,7 @@ const VALID_CHART_TYPES = new Set([
 	'kpi_card',
 	'scatter',
 	'radar',
+	'combo',
 ]);
 
</file context>

<defs>
{series.map((s, i) =>
s.series_type === 'area' ? (
<linearGradient key={s.data_key} id={`grad-combo-${i}`} x1='0' y1='0' x2='0' y2='1'>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Area fills can bleed across multiple combo charts because gradient IDs are only index-based and not chart-scoped. Consider generating per-chart-unique gradient IDs before wiring id/fill so each chart’s SVG defs remain isolated.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/shared/src/chart-builder.tsx, line 319:

<comment>Area fills can bleed across multiple combo charts because gradient IDs are only index-based and not chart-scoped. Consider generating per-chart-unique gradient IDs before wiring `id`/`fill` so each chart’s SVG defs remain isolated.</comment>

<file context>
@@ -286,6 +292,139 @@ function buildAreaChart(props: ResolvedProps) {
+				<defs>
+					{series.map((s, i) =>
+						s.series_type === 'area' ? (
+							<linearGradient key={s.data_key} id={`grad-combo-${i}`} x1='0' y1='0' x2='0' y2='1'>
+								<stop offset='0%' stopColor={colorFor(s.data_key, i)} stopOpacity={0.25} />
+								<stop offset='100%' stopColor={colorFor(s.data_key, i)} stopOpacity={0} />
</file context>

@@ -1,9 +1,28 @@
export interface ParsedChartSeries {
data_key: string;
color: string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: Parsed combo story series can omit color, but ParsedChartSeries.color is typed as required. This makes downstream code believe a parsed value is always a string even though generated combo blocks and the display-chart schema allow it to be absent; color?: string would match runtime behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/shared/src/story-segments.ts, line 3:

<comment>Parsed combo story series can omit `color`, but `ParsedChartSeries.color` is typed as required. This makes downstream code believe a parsed value is always a string even though generated combo blocks and the display-chart schema allow it to be absent; `color?: string` would match runtime behavior.</comment>

<file context>
@@ -1,9 +1,28 @@
+export interface ParsedChartSeries {
+	data_key: string;
+	color: string;
+	label?: string;
+	series_type?: 'bar' | 'line' | 'area';
</file context>

@socallmebertille socallmebertille left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this PR adds real value and it LGTM for the most part :

  • I just left a tiny comment below
  • and could you also take a look at the Cubic comments that seem valid to me

Comment on lines +328 to +337
<YAxis
yAxisId='left'
tick={AXIS_TICK}
tickLine={false}
axisLine={false}
minTickGap={12}
tickFormatter={formatYAxisTick}
domain={resolveAxisDomain(yAxes?.left?.domain)}
label={axisLabel(yAxes?.left?.label, 'left')}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The left YAxis is always rendered, so if all series are assigned to the right axis we still draw an empty left axis. So, you should make the left axis conditional too, symmetric to the right one OR add a guard in the edit dialog preventing the user from moving the last remaining left-axis series to the right (keeps at least one series on the left).

@Bl3f

Bl3f commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

cf. #1241

@Bl3f Bl3f closed this Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants