Skip to content

Commit f1c04b6

Browse files
os-zhuangclaude
andauthored
fix(charts): a spec series[].type draws, and a spec-shape series plots at all (#2945) (#3004)
the spec ALREADY models a combo chart, per-series, and its own field comment says so — `ChartSeries.type`, "Series type override (combo charts)" — exactly as it models stacking with `ChartSeries.stack` rather than a `stacked-bar` family. So `combo` is not a name an author should reach for; it is what "the series disagree about their family" looks like from the renderer's side. `effectiveChartFamily` derives it. Chasing that turned up two live bugs, both silent, on the path a spec author takes. 1. The per-series override was parsed, carried, and then DROPPED. Only the `chartType === 'combo'` branch read `series[].chartType`, so `{ type: 'bar', series: [{ name: 'revenue' }, { name: 'margin', type: 'line' }] }` drew `margin` as a bar. A unit test even asserted the value was carried. 2. A spec-shape `series` rendered NOTHING AT ALL. `series` is the one binding both shapes spell alike, so ChartRenderer's blanket "internal props win" rule let the author's `[{ name }]` shadow the normalized `[{ dataKey }]` and reach a renderer that reads `dataKey`. Every other spec binding has a distinct name (`xAxis` vs `xAxisKey`), which is why only this one broke — and why the isolated normalization tests all passed over a dead path. (2) is fixed here rather than filed, because (1) alone would have been a fix no spec author could reach. `ChartRendererProps.series` now accepts both shapes too: it was typed internal-only, so writing the protocol correctly was a type error on code that works. Also found in passing: the combo branch had an `area` arm inside a `BarChart` container, and Recharts draws an `<Area>` child of `BarChart` as nothing — so an authored combo with an `area` series rendered a blank series. The container is now `ComposedChart`, which is what Recharts provides for mixed marks. Widening only. One family stays one family, an explicit `combo` is untouched, and a family with no per-series meaning (`pie`, `horizontal-bar`) is never widened. A derived combo binds to the left axis unless a series asks for `yAxis: 'right'` — the spec's own default — so widening changes the mark and not the scale; the bar→left/line→right guess is kept for an authored `combo`, where it was the only way to reach a second axis. Guards: the two helpers are unit-tested over the family matrix; the DOM tests assert the MARKS rather than the derived family, since the carry was already covered and the drawing was what broke; and `spec-derived-unions.test.ts` asserts `combo` is absent from the spec's `ChartTypeSchema`, so the day it is adopted upstream the derivation is named as the thing to retire. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent d10f526 commit f1c04b6

9 files changed

Lines changed: 627 additions & 25 deletions
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@object-ui/plugin-charts": minor
3+
---
4+
5+
fix(charts): a spec `series[].type` override actually draws, and a spec-shape `series` plots at all (#2945)
6+
7+
#2945 listed `combo` (`plugin-charts`) as renderer-local dialect to "promote or
8+
delete". Neither: the spec **already models a combo chart**, per-series, and its
9+
own field comment says so —
10+
11+
```ts
12+
// spec/src/ui/chart.zod.ts — ChartSeriesSchema
13+
/** Series type override (combo charts) */
14+
type: ChartTypeSchema.optional().describe('Override chart type for this series'),
15+
```
16+
17+
— exactly as it models stacking with `ChartSeries.stack` rather than a
18+
`stacked-bar` family. So `combo` is not a name an author should reach for; it is
19+
what "the series disagree about their family" looks like from the renderer's
20+
side. `effectiveChartFamily` now derives it, and `combo` stays a documented
21+
renderer-local marker (internal callers pass it directly today).
22+
23+
Chasing that turned up two live bugs, both silent, on the path a spec author
24+
takes.
25+
26+
**1. The per-series override was parsed, carried, and then dropped.** Only the
27+
renderer's `chartType === 'combo'` branch read `series[].chartType`, so
28+
29+
```ts
30+
{ type: 'bar', series: [{ name: 'revenue' }, { name: 'margin', type: 'line' }] }
31+
```
32+
33+
drew `margin` as a bar. Nothing was wrong at any layer but the last — a unit test
34+
even asserted the value was carried.
35+
36+
**2. A spec-shape `series` rendered nothing at all.** `series` is the one binding
37+
both shapes spell with the same key, so `ChartRenderer`'s blanket "internal props
38+
win" rule let the author's `[{ name }]` shadow the normalized `[{ dataKey }]` and
39+
reach a renderer that reads `dataKey`. Blank chart. Every other spec binding has a
40+
distinct name (`xAxis` vs `xAxisKey`), which is why only this one broke — and why
41+
the isolated normalization tests all passed over a dead path. The raw array is now
42+
preferred only when it already speaks the internal shape, so internal callers are
43+
byte-for-byte unchanged and a mixed array works too.
44+
45+
**Also fixed in passing:** the combo branch had an `area` arm under a `BarChart`
46+
container, and Recharts renders an `<Area>` child of `BarChart` as nothing — so an
47+
authored combo with an `area` series drew a blank series. The container is now
48+
`ComposedChart`, which is what Recharts provides for mixed marks.
49+
50+
Widening only. A chart whose series all resolve to one family keeps its own
51+
family, an explicit `combo` is untouched, and a family with no per-series meaning
52+
(`pie`, `horizontal-bar`, …) is never widened. A derived combo binds series to the
53+
left axis unless one asks for `yAxis: 'right'` — the spec's own default — so
54+
widening changes the series' mark and not its scale; the legacy bar→left/line→right
55+
guess is kept for an authored `combo`, where it was historically the only way to
56+
reach a second axis.
57+
58+
Guards: `effectiveChartFamily` / `comboBaseFamily` are unit-tested over the whole
59+
family matrix; DOM-level tests assert the **marks** rather than the derived family,
60+
since the carry was already covered and the drawing was what broke; and
61+
`spec-derived-unions.test.ts` asserts `combo` is absent from the spec's
62+
`ChartTypeSchema`, so the day it is adopted upstream the derivation is named as the
63+
thing to retire.

docs/audits/2901-spec-enum-renderer-coverage.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ value corruption rather than disclosure.
166166
| Name | Location | Call |
167167
|---|---|---|
168168
| `navigation` action type | [ActionRunner.ts](../../packages/core/src/actions/ActionRunner.ts) | ~~Promote~~**resolved as a declared alias instead.** See the correction below. |
169-
| `combo` chart type | [normalizeChartSchema.ts:63](../../packages/plugin-charts/src/normalizeChartSchema.ts) | Drawn at `AdvancedChartImpl.tsx:819`. Promote or delete. |
169+
| `combo` chart type | [normalizeChartSchema.ts](../../packages/plugin-charts/src/normalizeChartSchema.ts) | ~~Promote or delete~~**derived from the series instead.** See Correction 4; the spec models it as `ChartSeries.type`. |
170170
| `system` theme mode | ThemeProvider ×2 | Delete — the spec name is `auto`, and `react/ThemeContext.tsx` already gets it right. |
171171
| `scale-fade` preset | [useAnimation.ts:71](../../packages/react/src/hooks/useAnimation.ts) | Delete with the hyphen/underscore fix. |
172172
| `modal` notification type | [NotificationContext.tsx:23](../../packages/react/src/context/NotificationContext.tsx) | Inert — nothing reads `displayType`. |
@@ -358,7 +358,7 @@ On a list whose only narrowing is that filter, the user sees rows they had asked
358358
to exclude. It is not a permission bypass — row-scoping `$and`-composes as a
359359
separate arm and survives — but it ranks with Tier 1, not Tier 2.
360360

361-
## Correction 4 — "promote `navigation` upstream" was the wrong call
361+
## Correction 4 — "promote it upstream" was the wrong call twice (`navigation`, `combo`)
362362

363363
Direction B told the reader to promote the `navigation` action type into the spec,
364364
on the grounds that it is "a real capability the spec doesn't name". The capability
@@ -385,6 +385,28 @@ and delete — **alias to the spec name it duplicates** — and picking between
385385
three starts with asking whether the spec really lacks the concept or just spells
386386
it differently.
387387

388+
**`combo` is the same mistake, and the check is one grep.** The row above told the
389+
reader to promote or delete it. `ChartSeriesSchema.type` already exists, and its
390+
field comment reads *"Series type override (combo charts)"* — the spec models a
391+
combo chart per-series, exactly as it models stacking with `ChartSeries.stack`
392+
rather than a `stacked-bar` family. `combo` is therefore derived from the series,
393+
not authored (`effectiveChartFamily`).
394+
395+
Reading the row as written would also have missed what was actually broken. Two
396+
live silent failures sat on the path a spec author takes:
397+
398+
1. the per-series override was parsed and carried and then **dropped** — only the
399+
`chartType === 'combo'` branch read `series[].chartType`, so `type: 'bar'` with
400+
a `type: 'line'` series drew a bar; and
401+
2. a spec-shape `series` **rendered nothing at all**`series` is the one binding
402+
both shapes spell alike, so `ChartRenderer`'s "internal props win" rule let the
403+
author's `[{ name }]` shadow the normalized `[{ dataKey }]`.
404+
405+
Neither is visible from an enum-coverage angle, which is worth noting about this
406+
audit's method: **a dialect row can be the least interesting thing wrong at that
407+
line.** The `combo` row asked whether a name belonged in the spec; the answer was
408+
that the spec's own name for it had never worked.
409+
388410
## Status of the items above
389411

390412
| Item | Status |
@@ -394,7 +416,8 @@ it differently.
394416
| `DensityModeSchema` inert | Resolved upstream (removed in spec 17) |
395417
| Packages lacking the `@objectstack/spec` devDep | **6**`plugin-list` and `data-objectstack` gained it in #2974; `plugin-charts`, `plugin-dashboard`, `plugin-report`, `components`, `mobile` and `fields` remain. (The body above says 6 because it counted a 13-package "needs a guard" list that omitted `fields`; adding `fields` and removing the two now fixed leaves 6 either way.) |
396418
| The five shadowing forks | Fixed — #2985 (closes #2944) |
397-
| `navigation` action type | Resolved as a declared alias, not promoted — see Correction 4 |
419+
| `navigation` action type | Resolved as a declared alias, not promoted — #2994, see Correction 4 |
420+
| `combo` chart type | Derived from `ChartSeries.type`, not promoted — see Correction 4. Uncovered two silent render bugs on the spec-shape path. |
398421
| Everything else | Open — #2941 (Tier 1), #2942 (Tier 2), #2943 (Tier 3), #2945 (vocabularies) |
399422

400423
One method note for anyone repeating this: the original run read a checkout that
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* #2945 — a series that overrides its family actually draws that family.
6+
*
7+
* `ChartSeries.type` is the spec's own per-series family override; its field
8+
* comment reads "Series type override (combo charts)". It was parsed by
9+
* `normalizeChartSchema` and carried on `series[].chartType` — a unit test even
10+
* asserted the carry — and then dropped, because only the renderer's
11+
* `chartType === 'combo'` branch read it. So an author writing the protocol got
12+
* their line series drawn as a bar, with nothing wrong at any layer but the last.
13+
*
14+
* These assert the MARKS in the DOM rather than the derived family, because the
15+
* carry was already covered and the drawing is what was broken.
16+
*/
17+
18+
import React from 'react';
19+
import { describe, it, expect, vi, afterEach } from 'vitest';
20+
import { render, cleanup } from '@testing-library/react';
21+
22+
// Recharts' ResponsiveContainer measures via ResizeObserver, which reports 0×0
23+
// under the headless DOM, so nothing paints. Fix its size.
24+
vi.mock('recharts', async () => {
25+
const actual = await vi.importActual<any>('recharts');
26+
return {
27+
...actual,
28+
ResponsiveContainer: ({ children }: any) =>
29+
React.cloneElement(children, { width: 480, height: 320 }),
30+
};
31+
});
32+
33+
import AdvancedChartImpl from './AdvancedChartImpl';
34+
35+
afterEach(cleanup);
36+
37+
const DATA = [
38+
{ month: 'Jan', revenue: 120, margin: 0.4 },
39+
{ month: 'Feb', revenue: 80, margin: 0.9 },
40+
];
41+
42+
const base = {
43+
data: DATA,
44+
xAxisKey: 'month',
45+
isAnimationActive: false,
46+
};
47+
48+
const lines = (c: HTMLElement) => c.querySelectorAll('.recharts-line').length;
49+
const bars = (c: HTMLElement) => c.querySelectorAll('.recharts-bar').length;
50+
const areas = (c: HTMLElement) => c.querySelectorAll('.recharts-area').length;
51+
52+
describe('AdvancedChartImpl — a combo derived from the series', () => {
53+
it('draws a `type: line` series as a line on an otherwise-bar chart', () => {
54+
// The regression: `margin` used to render as a second bar.
55+
const { container } = render(
56+
<AdvancedChartImpl
57+
{...base}
58+
chartType="bar"
59+
series={[{ dataKey: 'revenue' }, { dataKey: 'margin', chartType: 'line' }]}
60+
/>,
61+
);
62+
expect(lines(container)).toBe(1);
63+
expect(bars(container)).toBe(1);
64+
});
65+
66+
it('draws a `type: bar` series as a bar on an otherwise-area chart', () => {
67+
const { container } = render(
68+
<AdvancedChartImpl
69+
{...base}
70+
chartType="area"
71+
series={[{ dataKey: 'revenue' }, { dataKey: 'margin', chartType: 'bar' }]}
72+
/>,
73+
);
74+
expect(bars(container)).toBe(1);
75+
// The un-annotated series takes the chart's own family, not `bar` — which
76+
// is why the base has to come from the chart rather than a hardcoded guess.
77+
expect(areas(container)).toBe(1);
78+
});
79+
80+
it('leaves a chart whose series all agree as a plain bar chart', () => {
81+
const { container } = render(
82+
<AdvancedChartImpl
83+
{...base}
84+
chartType="bar"
85+
series={[{ dataKey: 'revenue' }, { dataKey: 'margin' }]}
86+
/>,
87+
);
88+
expect(bars(container)).toBe(2);
89+
expect(lines(container)).toBe(0);
90+
});
91+
92+
it('binds a derived combo to one axis unless the series asks for two', () => {
93+
// `ChartSeries.yAxis` defaults to 'left' in the spec, so widening a bar
94+
// chart into a combo changes the series' MARK and nothing else. Two y-axes
95+
// are always rendered by the combo branch; what matters is that the line
96+
// is not silently moved onto the right one, which would rescale it.
97+
const { container } = render(
98+
<AdvancedChartImpl
99+
{...base}
100+
chartType="bar"
101+
series={[{ dataKey: 'revenue' }, { dataKey: 'margin', chartType: 'line' }]}
102+
/>,
103+
);
104+
const line = container.querySelector('.recharts-line');
105+
expect(line).toBeTruthy();
106+
// Recharts places right-axis marks in the second y-axis' scale; the
107+
// rendered path is the observable proxy, so assert the explicit opt-in
108+
// changes it and the default does not.
109+
const defaultPath = container.querySelector('.recharts-line-curve')?.getAttribute('d');
110+
111+
cleanup();
112+
const { container: right } = render(
113+
<AdvancedChartImpl
114+
{...base}
115+
chartType="bar"
116+
series={[{ dataKey: 'revenue' }, { dataKey: 'margin', chartType: 'line', yAxis: 'right' }]}
117+
/>,
118+
);
119+
const rightPath = right.querySelector('.recharts-line-curve')?.getAttribute('d');
120+
expect(defaultPath).toBeTruthy();
121+
expect(rightPath).toBeTruthy();
122+
expect(rightPath).not.toBe(defaultPath);
123+
});
124+
125+
it('draws an `area` series in a combo at all (it used to draw nothing)', () => {
126+
// Found while widening this: the combo branch had an `area` arm, but its
127+
// container was `BarChart`, under which a Recharts `<Area>` child renders
128+
// nothing. The arm was unreachable and the series came out blank — silently.
129+
// `ComposedChart` is the container built for mixed marks.
130+
const { container } = render(
131+
<AdvancedChartImpl
132+
{...base}
133+
chartType="combo"
134+
series={[{ dataKey: 'revenue', chartType: 'bar' }, { dataKey: 'margin', chartType: 'area' }]}
135+
/>,
136+
);
137+
expect(areas(container)).toBe(1);
138+
expect(bars(container)).toBe(1);
139+
});
140+
141+
it('still honours an explicitly authored `combo`', () => {
142+
// `DatasetPreview` passes this directly for mixed-scale datasets, with
143+
// series that declare nothing. The legacy index/family default applies:
144+
// first series bar, the rest lines.
145+
const { container } = render(
146+
<AdvancedChartImpl
147+
{...base}
148+
chartType="combo"
149+
series={[{ dataKey: 'revenue' }, { dataKey: 'margin' }]}
150+
/>,
151+
);
152+
expect(bars(container)).toBe(1);
153+
expect(lines(container)).toBe(1);
154+
});
155+
});

packages/plugin-charts/src/AdvancedChartImpl.tsx

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
LineChart,
1515
Area,
1616
AreaChart,
17+
ComposedChart,
1718
Pie,
1819
PieChart,
1920
Radar,
@@ -47,7 +48,7 @@ import {
4748
ChartConfig
4849
} from './ChartContainerImpl';
4950
import { mapScatterClick, mapTreemapClick, mapSankeyClick } from './chartDrillEvents';
50-
import { formatterFor, domainFor, ticksFor, RENDERABLE, SINGLE_VALUE_CHART_TYPES, TABULAR_CHART_TYPES, type NormalizedAxis, type NormalizedSeries } from './normalizeChartSchema';
51+
import { formatterFor, domainFor, ticksFor, RENDERABLE, SINGLE_VALUE_CHART_TYPES, TABULAR_CHART_TYPES, effectiveChartFamily, comboBaseFamily, type NormalizedAxis, type NormalizedSeries } from './normalizeChartSchema';
5152
import { buildCategoryRank } from '@object-ui/core';
5253

5354
// Default color fallback for chart series
@@ -96,6 +97,11 @@ const comparisonStyle = (s: any, kind: 'line' | 'area' | 'bar' | 'scatter') => {
9697
};
9798

9899
export interface AdvancedChartImplProps {
100+
/**
101+
* Chart family. `combo` is renderer-local and rarely needs to be passed:
102+
* series declaring different families derive it (`effectiveChartFamily`),
103+
* which is how `@objectstack/spec` expresses a combo chart.
104+
*/
99105
chartType?: 'bar' | 'column' | 'horizontal-bar' | 'line' | 'area' | 'pie' | 'donut' | 'radar' | 'scatter' | 'funnel' | 'combo' | 'treemap' | 'sankey';
100106
data?: Array<Record<string, any>>;
101107
config?: ChartConfig;
@@ -266,7 +272,14 @@ function AdvancedChartImplInner({
266272
}: AdvancedChartImplProps) {
267273
// Normalize 'column' → 'bar' (Recharts BarChart is already vertical).
268274
// 'column' is the spec-level alias for vertical bars; 'horizontal-bar' stays as-is.
269-
const chartType = rawChartType === 'column' ? 'bar' : rawChartType;
275+
const baseChartType = rawChartType === 'column' ? 'bar' : rawChartType;
276+
// A chart whose series declare more than one family IS a combo, whatever its
277+
// own family says — `ChartSeries.type` is how the spec expresses that, and
278+
// reading it only under an explicit `combo` drew the overridden series as the
279+
// base family instead (#2945). `comboSeriesBase` is what an un-annotated
280+
// series draws, and `undefined` marks an explicitly-authored combo.
281+
const chartType = effectiveChartFamily(baseChartType, series);
282+
const comboSeriesBase = comboBaseFamily(baseChartType);
270283
const data = Array.isArray(rawData) ? rawData : [];
271284

272285
// Only emit the prop when explicitly disabled, so the default (animated)
@@ -364,9 +377,9 @@ function AdvancedChartImplInner({
364377
radar: RadarChart,
365378
scatter: ScatterChart,
366379
funnel: FunnelChart as any,
367-
combo: BarChart,
368-
// treemap/sankey return from their own branches above; mapped here only so
369-
// the index type stays exhaustive.
380+
// combo/treemap/sankey return from their own branches above; mapped here
381+
// only so the index type stays exhaustive.
382+
combo: ComposedChart,
370383
treemap: BarChart,
371384
sankey: BarChart,
372385
}[chartType] || BarChart;
@@ -877,12 +890,18 @@ function AdvancedChartImplInner({
877890
);
878891
}
879892

880-
// Combo chart (mixed bar + line on same chart)
893+
// Combo chart (mixed families on one plot). Reached either by an explicit
894+
// `chartType: 'combo'` or by series that declare different families — see
895+
// `effectiveChartFamily`.
881896
if (chartType === 'combo') {
882897
return (
883898
<ChartFrame title={title} subtitle={subtitle}>
884899
<ChartContainer config={config} className={className} {...containerProps}>
885-
<BarChart data={data}>
900+
{/* `ComposedChart`, not `BarChart`, is the Recharts container built to
901+
host mixed marks. Under `BarChart` an `<Area>` child renders nothing
902+
at all, so the `seriesType === 'area'` arm below was unreachable —
903+
an authored combo with an `area` series drew a blank series. */}
904+
<ComposedChart data={data}>
886905
<CartesianGrid {...gridProps} />
887906
<XAxis dataKey={xAxisKey} {...xAxisCommonProps} />
888907
<YAxis yAxisId="left" tickLine={false} axisLine={false} tickFormatter={yTickFormatter} width={48} {...yAxisSpecProps(primaryY)} />
@@ -898,12 +917,21 @@ function AdvancedChartImplInner({
898917
{brushEl}
899918
{series.map((s: any, index: number) => {
900919
const color = resolveColor(config[s.dataKey]?.color || DEFAULT_CHART_COLOR);
901-
const seriesType = s.chartType || (index === 0 ? 'bar' : 'line');
902-
// An explicit spec `series[].yAxis` wins over the family-derived
903-
// default (bar→left / line→right), which is only a guess.
920+
// A derived combo knows what an un-annotated series is: the chart's
921+
// own family. Only an explicitly-authored `combo` has no base, and
922+
// there the index heuristic stands so those charts are unchanged.
923+
const seriesType = s.chartType || comboSeriesBase || (index === 0 ? 'bar' : 'line');
924+
// An explicit spec `series[].yAxis` wins over any default. Where
925+
// there is no `yAxis`, a DERIVED combo follows the spec, which
926+
// defaults `ChartSeries.yAxis` to 'left' — so widening a bar chart
927+
// into a combo changes the series' mark and nothing else. The
928+
// bar→left / line→right guess is kept only for an authored `combo`,
929+
// where historically it was the sole way to reach a second axis.
904930
const yAxisId = s.yAxis === 'right' || s.yAxis === 'left'
905931
? s.yAxis
906-
: (seriesType === 'bar' ? 'left' : 'right');
932+
: comboSeriesBase
933+
? 'left'
934+
: (seriesType === 'bar' ? 'left' : 'right');
907935
const cmp = comparisonStyle(s, seriesType as any);
908936
const stackProps = s.stack ? { stackId: String(s.stack) } : {};
909937
const valueFormatter = formatterFor((yAxisId === 'right' ? secondaryY : primaryY)?.format);
@@ -928,7 +956,7 @@ function AdvancedChartImplInner({
928956
</Bar>
929957
);
930958
})}
931-
</BarChart>
959+
</ComposedChart>
932960
</ChartContainer>
933961
</ChartFrame>
934962
);

0 commit comments

Comments
 (0)