Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/7698-series-opacity-dasharray-unconditional.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
'@object-ui/plugin-charts': minor
'@object-ui/types': patch
---

Chart `series[].opacity` and `series[].dashArray` are honoured on every series,
not only on a `variant: 'comparison'` one (objectui#7698).

`@objectstack/spec` declares `ChartSeries.opacity` ("Override series opacity")
and `ChartSeries.dashArray` ("Override stroke dash pattern") as unconditional
per-series overrides, and `normalizeSeries` read both off every series. The
renderer then honoured them on a comparison overlay only, so an author who
wrote `{ name: 'cost', opacity: 0.6 }` or `{ name: 'cost', dashArray: '4 4' }`
on a primary series got a mark drawn exactly as if the key were absent. Fixed
in the renderer rather than by narrowing the published declaration to match:
the spec is the contract of record, and a renderer's partial implementation
does not get to dictate it (AGENTS.md #0.1).

**Two gaps, not one.** The `variant` guard was the visible half — `comparisonStyle`
returned `null` for any other variant. The second half only showed on
`dashArray`: that helper already returned an AUTHORED dash for every family
(the `??` takes the left side whatever the kind), and the **Bar and Scatter
marks** then passed `fillOpacity` only, dropping `strokeDasharray` and
`strokeOpacity` on the floor — so an authored dash was lost on those two
families even on a comparison series. A fix aimed at the guard alone would have
left that untouched. `comparisonStyle` is now `seriesStyle`, and the Bar and
Scatter marks pass all three channels.

**Comparison series are unaffected.** The authored branch already won over the
muted defaults, and those defaults stay gated on `variant: 'comparison'`: a
comparison series carrying neither key keeps its lower opacity and its `'4 4'`
line/area dash exactly as before. The two stroke defaults no mark ever consumed
(bar and scatter — neither is stroked by this renderer) are now spelled
`undefined`, so opening `strokeOpacity` on those marks does not hand them a
default they never had.

Only a stroked mark can show a dash, so on a `bar` or `scatter` mark an
authored `dashArray` reaches the mark and paints nothing — the mark's geometry,
not a condition on the key. The `ChartDataSeries` mirror docs and the
plugin-charts reference, which stated the comparison-only condition as an
interim measure, are corrected in the same change.
6 changes: 3 additions & 3 deletions content/docs/plugins/plugin-charts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -190,12 +190,12 @@ Each `series` entry names the column it plots (`dataKey`, or the spec spelling `
| `stack` | `string` | Stack group id — series sharing one id stack together. |
| `yAxis` | `'left' \| 'right'` | Which y-axis the series binds to, on a chart that declares a second y-axis. |
| `variant` | `'primary' \| 'comparison'` | `comparison` draws the muted period-over-period overlay; `primary` (the default) is the normal treatment. |
| `dashArray` | `string` | SVG `stroke-dasharray`, e.g. `"4 4"` for a dashed line. Today the renderer applies it only on a `variant: 'comparison'` series **and** only on a mark that has a stroke to dash — a `line` or `area` mark (the chart's `chartType`, or a per-series `type` override), where it overrides the overlay's default `"4 4"`; a comparison `bar` or `scatter` mark takes `opacity` only and drops the dash, and on a primary series of any family it is read and unused. |
| `opacity` | `number` | Stroke and fill opacity override — any finite number (the spec bounds it to 0–1). Today the renderer applies it only on a `variant: 'comparison'` series, where it overrides the overlay's per-family default on every mark family — the fill of a `bar` or `scatter` mark, the stroke of a `line` mark, both on an `area` mark; on a primary series it is read and unused. |
| `dashArray` | `string` | SVG `stroke-dasharray`, e.g. `"4 4"` for a dashed line. Applies on **any** series, whatever its `variant`, and overrides the comparison overlay's default `"4 4"`. Only a mark with a stroke can show a dash, and this renderer strokes `line` and `area` marks (the chart's `chartType`, or a per-series `type` override) — on a `bar` or `scatter` mark the value reaches the mark and paints nothing, which is the mark's geometry rather than a condition on the key. |
| `opacity` | `number` | Stroke and fill opacity override — any finite number (the spec bounds it to 0–1). Applies on **any** series, whatever its `variant`, on every mark family — the fill of a `bar` or `scatter` mark, the stroke of a `line` mark, both on an `area` mark — and overrides the comparison overlay's per-family default. |

`chartType` is **not** a series key. It is the renderer's internal spelling of `type` — `@objectstack/spec`'s `ChartSeries` lists it as an alias — and the validator refuses it by name (`Unrecognized key(s) on this chart series: chartType. Did you mean chartType → type?`) instead of dropping it silently, which is what it used to do. Write `type`.

An `area` chart keeps every key in this example live: `stack` stacks the two primary areas, and the comparison overlay takes both the dash and the opacity (on a `bar` chart the overlay would take `opacity` only).
An `area` chart keeps every key in this example live: `stack` stacks the two primary areas, and the comparison overlay takes both the dash and the opacity (on a `bar` chart the dash would still reach the mark, but a bar has no stroke to dash). Neither key needs `variant: 'comparison'` — a primary series may carry them too, and then it is the overlay's defaults, not the keys, that stay comparison-only.

```tsx
const schema = {
Expand Down
64 changes: 43 additions & 21 deletions packages/plugin-charts/src/AdvancedChartImpl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,36 @@ const TW_COLORS: Record<string, string> = {
const resolveColor = (color: string) => TW_COLORS[color] || color;

/**
* Default visual treatment for a `variant: 'comparison'` series. Returns
* overrides per chart family so the comparison overlay reads as muted
* (dashed line, lower fill opacity) while still being color-matched to
* the primary series. Series-level `opacity` / `dashArray` win over defaults.
* Per-series presentation overrides, in two halves that are deliberately NOT
* gated the same way (objectui#7698).
*
* - The AUTHORED keys — `@objectstack/spec`'s `ChartSeries.opacity` ("Override
* series opacity") and `ChartSeries.dashArray` ("Override stroke dash
* pattern") — are declared with NO condition, so they apply on every series
* whatever its `variant`.
* - The muted comparison treatment (lower opacity, a `'4 4'` dash) is a
* DEFAULT and stays gated on `variant: 'comparison'`, so the overlay still
* reads as muted while a primary series carrying neither key is untouched.
*
* This function used to return `null` for anything but a comparison series, so
* an authored `opacity` / `dashArray` on a primary series was read by
* `normalizeSeries` and then discarded here — the renderer honouring a
* spec-declared unconditional override on one variant only. Narrowing the
* published declaration to match would have been the renderer's tolerance
* dictating the contract, which is the direction AGENTS.md #0.1 forbids.
*
* Comparison series keep every value they had. The authored branch already won
* over the defaults, and the two STROKE defaults no mark ever consumed — bar
* and scatter, neither of which this renderer strokes — are now spelled
* `undefined` rather than reaching the Bar and Scatter marks that gained
* `strokeOpacity` / `strokeDasharray` in the same change.
*/
const comparisonStyle = (s: any, kind: 'line' | 'area' | 'bar' | 'scatter') => {
if (s?.variant !== 'comparison') return null;
const strokeOpacity = typeof s.opacity === 'number' ? s.opacity : (kind === 'line' || kind === 'scatter' ? 0.5 : 0.6);
const fillOpacity = typeof s.opacity === 'number' ? s.opacity : (kind === 'bar' ? 0.4 : kind === 'area' ? 0.2 : 0.5);
const strokeDasharray = s.dashArray ?? (kind === 'line' || kind === 'area' ? '4 4' : undefined);
const seriesStyle = (s: any, kind: 'line' | 'area' | 'bar' | 'scatter') => {
const muted = s?.variant === 'comparison';
const authoredOpacity = typeof s?.opacity === 'number' ? s.opacity : undefined;
const strokeOpacity = authoredOpacity ?? (muted ? (kind === 'line' ? 0.5 : kind === 'area' ? 0.6 : undefined) : undefined);
const fillOpacity = authoredOpacity ?? (muted ? (kind === 'bar' ? 0.4 : kind === 'area' ? 0.2 : 0.5) : undefined);
const strokeDasharray = s?.dashArray ?? (muted && (kind === 'line' || kind === 'area') ? '4 4' : undefined);
return { strokeOpacity, fillOpacity, strokeDasharray };
};

Expand Down Expand Up @@ -1822,14 +1842,16 @@ function AdvancedChartImplInner({
{series.map((s: any, index: number) => {
const palette = getPalette();
const color = resolveColor(config[s.dataKey]?.color || palette[index % palette.length]);
const cmp = comparisonStyle(s, 'scatter');
const pres = seriesStyle(s, 'scatter');
return (
<Scatter
key={s.dataKey}
name={config[s.dataKey]?.label || s.dataKey}
data={data}
fill={color}
fillOpacity={cmp?.fillOpacity}
fillOpacity={pres.fillOpacity}
strokeOpacity={pres.strokeOpacity}
strokeDasharray={pres.strokeDasharray}
{...animProps}
{...scatterClickProps}
/>
Expand Down Expand Up @@ -1889,26 +1911,26 @@ function AdvancedChartImplInner({
: comboSeriesBase
? 'left'
: (seriesType === 'bar' ? 'left' : 'right');
const cmp = comparisonStyle(s, seriesType as any);
const pres = seriesStyle(s, seriesType as any);
const stackProps = s.stack ? { stackId: String(s.stack) } : {};
const valueFormatter = formatterFor((yAxisId === 'right' ? secondaryY : primaryY)?.format);

if (seriesType === 'line') {
return (
<Line key={s.dataKey} yAxisId={yAxisId} type="monotone" dataKey={s.dataKey} stroke={color} strokeWidth={2} dot={false} strokeOpacity={cmp?.strokeOpacity} strokeDasharray={cmp?.strokeDasharray} {...animProps} {...comboMarkClickProps(s)}>
<Line key={s.dataKey} yAxisId={yAxisId} type="monotone" dataKey={s.dataKey} stroke={color} strokeWidth={2} dot={false} strokeOpacity={pres.strokeOpacity} strokeDasharray={pres.strokeDasharray} {...animProps} {...comboMarkClickProps(s)}>
{dataLabel(valueFormatter)}
</Line>
);
}
if (seriesType === 'area') {
return (
<Area key={s.dataKey} yAxisId={yAxisId} type="monotone" dataKey={s.dataKey} fill={color} stroke={color} fillOpacity={cmp?.fillOpacity ?? 0.4} strokeOpacity={cmp?.strokeOpacity} strokeDasharray={cmp?.strokeDasharray} {...stackProps} {...animProps} {...comboMarkClickProps(s)}>
<Area key={s.dataKey} yAxisId={yAxisId} type="monotone" dataKey={s.dataKey} fill={color} stroke={color} fillOpacity={pres.fillOpacity ?? 0.4} strokeOpacity={pres.strokeOpacity} strokeDasharray={pres.strokeDasharray} {...stackProps} {...animProps} {...comboMarkClickProps(s)}>
{dataLabel(valueFormatter)}
</Area>
);
}
return (
<Bar key={s.dataKey} yAxisId={yAxisId} dataKey={s.dataKey} fill={color} radius={4} fillOpacity={cmp?.fillOpacity} {...stackProps} {...animProps} {...comboMarkClickProps(s)}>
<Bar key={s.dataKey} yAxisId={yAxisId} dataKey={s.dataKey} fill={color} radius={4} fillOpacity={pres.fillOpacity} strokeOpacity={pres.strokeOpacity} strokeDasharray={pres.strokeDasharray} {...stackProps} {...animProps} {...comboMarkClickProps(s)}>
{dataLabel(valueFormatter)}
</Bar>
);
Expand Down Expand Up @@ -2032,9 +2054,9 @@ function AdvancedChartImplInner({
// per series for visual consistency.
const primaryCount = series.filter((p: any) => p.variant !== 'comparison').length;
const colorPerCategory = primaryCount === 1 && !isComparison && series.length === 1 && data.length > 1;
const cmp = comparisonStyle(s, 'bar');
const pres = seriesStyle(s, 'bar');
return (
<Bar key={s.dataKey} dataKey={s.dataKey} fill={`url(#bg-${gslug(seriesColor)})`} radius={4} fillOpacity={cmp?.fillOpacity} {...stackProps} {...axisProps} {...animProps} {...markClickProps(s)}>
<Bar key={s.dataKey} dataKey={s.dataKey} fill={`url(#bg-${gslug(seriesColor)})`} radius={4} fillOpacity={pres.fillOpacity} strokeOpacity={pres.strokeOpacity} strokeDasharray={pres.strokeDasharray} {...stackProps} {...axisProps} {...animProps} {...markClickProps(s)}>
{colorPerCategory && data.map((entry, idx) => (
<Cell key={`cell-${idx}`} fill={`url(#bg-${gslug(resolveColor(colorForCategory(entry?.[xAxisKey], idx, palette)))})`} />
))}
Expand All @@ -2043,17 +2065,17 @@ function AdvancedChartImplInner({
);
}
if (chartType === 'line') {
const cmp = comparisonStyle(s, 'line');
const pres = seriesStyle(s, 'line');
return (
<Line key={s.dataKey} type="monotone" dataKey={s.dataKey} stroke={seriesColor} strokeWidth={2} dot={false} strokeOpacity={cmp?.strokeOpacity} strokeDasharray={cmp?.strokeDasharray} {...axisProps} {...animProps} {...markClickProps(s)}>
<Line key={s.dataKey} type="monotone" dataKey={s.dataKey} stroke={seriesColor} strokeWidth={2} dot={false} strokeOpacity={pres.strokeOpacity} strokeDasharray={pres.strokeDasharray} {...axisProps} {...animProps} {...markClickProps(s)}>
{dataLabel(valueFormatter)}
</Line>
);
}
if (chartType === 'area') {
const cmp = comparisonStyle(s, 'area');
const pres = seriesStyle(s, 'area');
return (
<Area key={s.dataKey} type="monotone" dataKey={s.dataKey} fill={`url(#ag-${gslug(seriesColor)})`} stroke={seriesColor} strokeWidth={2} fillOpacity={cmp?.fillOpacity ?? 1} strokeOpacity={cmp?.strokeOpacity} strokeDasharray={cmp?.strokeDasharray} {...stackProps} {...axisProps} {...animProps} {...markClickProps(s)}>
<Area key={s.dataKey} type="monotone" dataKey={s.dataKey} fill={`url(#ag-${gslug(seriesColor)})`} stroke={seriesColor} strokeWidth={2} fillOpacity={pres.fillOpacity ?? 1} strokeOpacity={pres.strokeOpacity} strokeDasharray={pres.strokeDasharray} {...stackProps} {...axisProps} {...animProps} {...markClickProps(s)}>
{dataLabel(valueFormatter)}
</Area>
);
Expand Down
Loading
Loading