@opendata-ai/openchart-engine 6.9.0 → 6.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.9.0",
3
+ "version": "6.11.0",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@opendata-ai/openchart-core": "6.9.0",
48
+ "@opendata-ai/openchart-core": "6.11.0",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -203,6 +203,98 @@ describe('computeBarMarks', () => {
203
203
  });
204
204
  });
205
205
 
206
+ describe('grouped bars (stack: null)', () => {
207
+ function makeDodgedBarSpec(): NormalizedChartSpec {
208
+ const spec = makeGroupedBarSpec();
209
+ (spec.encoding.x as { stack?: boolean | null }).stack = null;
210
+ return spec;
211
+ }
212
+
213
+ it('produces marks for all data rows', () => {
214
+ const spec = makeDodgedBarSpec();
215
+ const scales = computeScales(spec, chartArea, spec.data);
216
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
217
+
218
+ expect(marks).toHaveLength(6);
219
+ });
220
+
221
+ it('grouped bars within a category have different y positions', () => {
222
+ const spec = makeDodgedBarSpec();
223
+ const scales = computeScales(spec, chartArea, spec.data);
224
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
225
+
226
+ const q1East = marks.find(
227
+ (m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
228
+ )!;
229
+ const q1West = marks.find(
230
+ (m) => m.aria.label.includes('Q1') && m.aria.label.includes('West'),
231
+ )!;
232
+
233
+ expect(q1East.y).not.toBe(q1West.y);
234
+ });
235
+
236
+ it('grouped bars all start from baseline (not cumulative)', () => {
237
+ const spec = makeDodgedBarSpec();
238
+ const scales = computeScales(spec, chartArea, spec.data);
239
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
240
+
241
+ const q1East = marks.find(
242
+ (m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
243
+ )!;
244
+ const q1West = marks.find(
245
+ (m) => m.aria.label.includes('Q1') && m.aria.label.includes('West'),
246
+ )!;
247
+
248
+ // Both bars start at the same x position (baseline)
249
+ expect(q1East.x).toBe(q1West.x);
250
+ });
251
+
252
+ it('grouped bars have cornerRadius 2', () => {
253
+ const spec = makeDodgedBarSpec();
254
+ const scales = computeScales(spec, chartArea, spec.data);
255
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
256
+
257
+ for (const mark of marks) {
258
+ expect(mark.cornerRadius).toBe(2);
259
+ }
260
+ });
261
+
262
+ it('grouped bars do not set stackGroup', () => {
263
+ const spec = makeDodgedBarSpec();
264
+ const scales = computeScales(spec, chartArea, spec.data);
265
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
266
+
267
+ for (const mark of marks) {
268
+ expect(mark.stackGroup).toBeUndefined();
269
+ }
270
+ });
271
+
272
+ it('sub-band heights are smaller than full bandwidth', () => {
273
+ const spec = makeDodgedBarSpec();
274
+ const scales = computeScales(spec, chartArea, spec.data);
275
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
276
+
277
+ // With 2 groups, each sub-bar should be less than the full bandwidth
278
+ const stackedSpec = makeGroupedBarSpec();
279
+ const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
280
+ const stackedMarks = computeBarMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
281
+
282
+ expect(marks[0].height).toBeLessThan(stackedMarks[0].height);
283
+ expect(marks[0].height).toBeGreaterThan(0);
284
+ });
285
+
286
+ it('scale domain covers max individual value, not stacked sum', () => {
287
+ const spec = makeDodgedBarSpec();
288
+ const scales = computeScales(spec, chartArea, spec.data);
289
+
290
+ // Max individual value is 70 (Q3 West), not 115 (Q3 stacked sum)
291
+ const xScale = scales.x!.scale;
292
+ const domain = xScale.domain() as number[];
293
+ // Domain should not extend to the stacked sum (115)
294
+ expect(domain[1]).toBeLessThanOrEqual(80); // some nice rounding above 70
295
+ });
296
+ });
297
+
206
298
  describe('colored (non-stacked) bars', () => {
207
299
  it('renders colored bars when each category has one row with color encoding', () => {
208
300
  const spec: NormalizedChartSpec = {
@@ -10,12 +10,13 @@ import type {
10
10
  ConditionalValueDef,
11
11
  DataRow,
12
12
  Encoding,
13
+ GradientDef,
13
14
  LayoutStrategy,
14
15
  MarkAria,
15
16
  Rect,
16
17
  RectMark,
17
18
  } from '@opendata-ai/openchart-core';
18
- import { abbreviateNumber, formatNumber } from '@opendata-ai/openchart-core';
19
+ import { abbreviateNumber, formatNumber, isGradientDef } from '@opendata-ai/openchart-core';
19
20
  import type { ScaleBand, ScaleLinear } from 'd3-scale';
20
21
 
21
22
  import type { NormalizedChartSpec } from '../../compiler/types';
@@ -99,6 +100,22 @@ export function computeBarMarks(
99
100
  const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
100
101
 
101
102
  if (needsStacking) {
103
+ const stackDisabled = xChannel.stack === null || xChannel.stack === false;
104
+
105
+ if (stackDisabled) {
106
+ return computeGroupedBars(
107
+ spec.data,
108
+ xChannel.field,
109
+ yChannel.field,
110
+ colorField,
111
+ xScale,
112
+ yScale,
113
+ bandwidth,
114
+ baseline,
115
+ scales,
116
+ );
117
+ }
118
+
102
119
  return computeStackedBars(
103
120
  spec.data,
104
121
  xChannel.field,
@@ -184,6 +201,73 @@ function computeStackedBars(
184
201
  return marks;
185
202
  }
186
203
 
204
+ /** Compute grouped (dodged) horizontal bars -- side-by-side within each category band. */
205
+ function computeGroupedBars(
206
+ data: DataRow[],
207
+ valueField: string,
208
+ categoryField: string,
209
+ colorField: string,
210
+ xScale: ScaleLinear<number, number>,
211
+ yScale: ScaleBand<string>,
212
+ bandwidth: number,
213
+ baseline: number,
214
+ scales: ResolvedScales,
215
+ ): RectMark[] {
216
+ const marks: RectMark[] = [];
217
+ const categoryGroups = groupByField(data, categoryField);
218
+
219
+ // Build a stable group order from first appearance in data (Map for O(1) lookup)
220
+ const groupIndexMap = new Map<string, number>();
221
+ for (const row of data) {
222
+ const key = String(row[colorField] ?? '');
223
+ if (!groupIndexMap.has(key)) {
224
+ groupIndexMap.set(key, groupIndexMap.size);
225
+ }
226
+ }
227
+ const groupCount = groupIndexMap.size;
228
+ if (groupCount === 0) return marks;
229
+
230
+ // Subdivide the band height by group count with a small gap
231
+ const gap = Math.min(1, bandwidth * 0.05);
232
+ const subBandHeight = Math.max((bandwidth - gap * (groupCount - 1)) / groupCount, MIN_BAR_WIDTH);
233
+
234
+ for (const [category, rows] of categoryGroups) {
235
+ const bandY = yScale(category);
236
+ if (bandY === undefined) continue;
237
+
238
+ for (const row of rows) {
239
+ const groupKey = String(row[colorField] ?? '');
240
+ const value = Number(row[valueField] ?? 0);
241
+ if (!Number.isFinite(value)) continue;
242
+
243
+ const groupIndex = groupIndexMap.get(groupKey) ?? 0;
244
+ const color = getColor(scales, groupKey);
245
+ const xPos = value >= 0 ? baseline : xScale(value);
246
+ const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
247
+ const subY = bandY + groupIndex * (subBandHeight + gap);
248
+
249
+ const aria: MarkAria = {
250
+ label: `${category}, ${groupKey}: ${formatBarValue(value)}`,
251
+ };
252
+
253
+ marks.push({
254
+ type: 'rect',
255
+ x: xPos,
256
+ y: subY,
257
+ width: barWidth,
258
+ height: subBandHeight,
259
+ fill: color,
260
+ cornerRadius: 2,
261
+ data: row as Record<string, unknown>,
262
+ aria,
263
+ orient: 'horizontal',
264
+ });
265
+ }
266
+ }
267
+
268
+ return marks;
269
+ }
270
+
187
271
  /** Compute colored (non-stacked) horizontal bars. Used when color encoding
188
272
  * is present but each category has only one row (e.g., diverging charts). */
189
273
  function computeColoredBars(
@@ -256,11 +340,14 @@ function computeSimpleBars(
256
340
  const bandY = yScale(category);
257
341
  if (bandY === undefined) continue;
258
342
 
259
- let color: string;
343
+ let color: string | GradientDef;
260
344
  if (conditionalColor) {
261
- color = String(
262
- resolveConditionalValue(row, conditionalColor) ?? getColor(scales, '__default__'),
263
- );
345
+ const resolved = resolveConditionalValue(row, conditionalColor);
346
+ if (resolved != null) {
347
+ color = isGradientDef(resolved) ? resolved : String(resolved);
348
+ } else {
349
+ color = getColor(scales, '__default__');
350
+ }
264
351
  } else if (sequentialColor) {
265
352
  color = getSequentialColor(scales, value);
266
353
  } else {
@@ -20,6 +20,7 @@ import type {
20
20
  import {
21
21
  buildD3Formatter,
22
22
  estimateTextWidth,
23
+ getRepresentativeColor,
23
24
  resolveCollisions,
24
25
  } from '@opendata-ai/openchart-core';
25
26
 
@@ -141,7 +142,7 @@ export function computeBarLabels(
141
142
  } else {
142
143
  // Outside: just past the bar's right edge
143
144
  anchorX = mark.x + mark.width + LABEL_PADDING;
144
- fill = mark.fill;
145
+ fill = getRepresentativeColor(mark.fill);
145
146
  textAnchor = 'start';
146
147
  }
147
148
 
@@ -187,6 +187,72 @@ describe('computeColumnMarks', () => {
187
187
  });
188
188
  });
189
189
 
190
+ describe('grouped columns (stack: null)', () => {
191
+ function makeDodgedColumnSpec(): NormalizedChartSpec {
192
+ const spec = makeGroupedColumnSpec();
193
+ (spec.encoding.y as { stack?: boolean | null }).stack = null;
194
+ return spec;
195
+ }
196
+
197
+ it('produces marks for all data rows', () => {
198
+ const spec = makeDodgedColumnSpec();
199
+ const scales = computeScales(spec, chartArea, spec.data);
200
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
201
+
202
+ expect(marks).toHaveLength(6);
203
+ });
204
+
205
+ it('grouped columns within a category have different x positions', () => {
206
+ const spec = makeDodgedColumnSpec();
207
+ const scales = computeScales(spec, chartArea, spec.data);
208
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
209
+
210
+ const janNorth = marks.find(
211
+ (m) => m.aria.label.includes('Jan') && m.aria.label.includes('North'),
212
+ )!;
213
+ const janSouth = marks.find(
214
+ (m) => m.aria.label.includes('Jan') && m.aria.label.includes('South'),
215
+ )!;
216
+
217
+ expect(janNorth.x).not.toBe(janSouth.x);
218
+ });
219
+
220
+ it('grouped columns have subdivided widths', () => {
221
+ const spec = makeDodgedColumnSpec();
222
+ const scales = computeScales(spec, chartArea, spec.data);
223
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
224
+
225
+ // With 2 groups, each sub-column should be narrower than full bandwidth
226
+ const stackedSpec = makeGroupedColumnSpec();
227
+ const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
228
+ const stackedMarks = computeColumnMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
229
+
230
+ expect(marks[0].width).toBeLessThan(stackedMarks[0].width);
231
+ expect(marks[0].width).toBeGreaterThan(0);
232
+ });
233
+
234
+ it('grouped columns have cornerRadius 2 and no stackGroup', () => {
235
+ const spec = makeDodgedColumnSpec();
236
+ const scales = computeScales(spec, chartArea, spec.data);
237
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
238
+
239
+ for (const mark of marks) {
240
+ expect(mark.cornerRadius).toBe(2);
241
+ expect(mark.stackGroup).toBeUndefined();
242
+ }
243
+ });
244
+
245
+ it('scale domain covers max individual value, not stacked sum', () => {
246
+ const spec = makeDodgedColumnSpec();
247
+ const scales = computeScales(spec, chartArea, spec.data);
248
+
249
+ // Max individual value is 150 (Mar North), not 280 (Mar stacked sum)
250
+ const yScale = scales.y!.scale;
251
+ const domain = yScale.domain() as number[];
252
+ expect(domain[1]).toBeLessThanOrEqual(170); // some nice rounding above 150
253
+ });
254
+ });
255
+
190
256
  describe('negative values', () => {
191
257
  it('negative columns extend downward from baseline', () => {
192
258
  const spec = makeNegativeColumnSpec();
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Takes a normalized chart spec with resolved scales and produces
5
5
  * RectMark[] for rendering vertical columns. When a color encoding
6
- * is present, columns are stacked (cumulative heights per category).
6
+ * is present, columns are either stacked (cumulative heights) or grouped
7
+ * (side-by-side) based on the `stack` property of the quantitative channel.
7
8
  *
8
9
  * Shares conceptual logic with bar chart but axes are swapped:
9
10
  * x-axis is categorical (band scale), y-axis is quantitative.
@@ -13,12 +14,13 @@ import type {
13
14
  ConditionalValueDef,
14
15
  DataRow,
15
16
  Encoding,
17
+ GradientDef,
16
18
  LayoutStrategy,
17
19
  MarkAria,
18
20
  Rect,
19
21
  RectMark,
20
22
  } from '@opendata-ai/openchart-core';
21
- import { abbreviateNumber, formatNumber } from '@opendata-ai/openchart-core';
23
+ import { abbreviateNumber, formatNumber, isGradientDef } from '@opendata-ai/openchart-core';
22
24
  import type { ScaleBand, ScaleLinear } from 'd3-scale';
23
25
  import type { NormalizedChartSpec } from '../../compiler/types';
24
26
  import type { ResolvedScales } from '../../layout/scales';
@@ -88,6 +90,22 @@ export function computeColumnMarks(
88
90
  const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
89
91
 
90
92
  if (needsStacking) {
93
+ const stackDisabled = yChannel.stack === null || yChannel.stack === false;
94
+
95
+ if (stackDisabled) {
96
+ return computeGroupedColumns(
97
+ spec.data,
98
+ xChannel.field,
99
+ yChannel.field,
100
+ colorField,
101
+ xScale,
102
+ yScale,
103
+ bandwidth,
104
+ baseline,
105
+ scales,
106
+ );
107
+ }
108
+
91
109
  return computeStackedColumns(
92
110
  spec.data,
93
111
  xChannel.field,
@@ -152,11 +170,14 @@ function computeSimpleColumns(
152
170
  const bandX = xScale(category);
153
171
  if (bandX === undefined) continue;
154
172
 
155
- let color: string;
173
+ let color: string | GradientDef;
156
174
  if (conditionalColor) {
157
- color = String(
158
- resolveConditionalValue(row, conditionalColor) ?? getColor(scales, '__default__'),
159
- );
175
+ const resolved = resolveConditionalValue(row, conditionalColor);
176
+ if (resolved != null) {
177
+ color = isGradientDef(resolved) ? resolved : String(resolved);
178
+ } else {
179
+ color = getColor(scales, '__default__');
180
+ }
160
181
  } else if (sequentialColor) {
161
182
  color = getSequentialColor(scales, value);
162
183
  } else {
@@ -241,6 +262,77 @@ function computeColoredColumns(
241
262
  return marks;
242
263
  }
243
264
 
265
+ /** Compute grouped (dodged) vertical columns -- side-by-side within each category band. */
266
+ function computeGroupedColumns(
267
+ data: DataRow[],
268
+ categoryField: string,
269
+ valueField: string,
270
+ colorField: string,
271
+ xScale: ScaleBand<string>,
272
+ yScale: ScaleLinear<number, number>,
273
+ bandwidth: number,
274
+ baseline: number,
275
+ scales: ResolvedScales,
276
+ ): RectMark[] {
277
+ const marks: RectMark[] = [];
278
+ const categoryGroups = groupByField(data, categoryField);
279
+
280
+ // Build a stable group order from first appearance in data (Map for O(1) lookup)
281
+ const groupIndexMap = new Map<string, number>();
282
+ for (const row of data) {
283
+ const key = String(row[colorField] ?? '');
284
+ if (!groupIndexMap.has(key)) {
285
+ groupIndexMap.set(key, groupIndexMap.size);
286
+ }
287
+ }
288
+ const groupCount = groupIndexMap.size;
289
+ if (groupCount === 0) return marks;
290
+
291
+ // Subdivide the band width by group count with a small gap
292
+ const gap = Math.min(1, bandwidth * 0.05);
293
+ const subBandWidth = Math.max(
294
+ (bandwidth - gap * (groupCount - 1)) / groupCount,
295
+ MIN_COLUMN_HEIGHT,
296
+ );
297
+
298
+ for (const [category, rows] of categoryGroups) {
299
+ const bandX = xScale(category);
300
+ if (bandX === undefined) continue;
301
+
302
+ for (const row of rows) {
303
+ const groupKey = String(row[colorField] ?? '');
304
+ const value = Number(row[valueField] ?? 0);
305
+ if (!Number.isFinite(value)) continue;
306
+
307
+ const groupIndex = groupIndexMap.get(groupKey) ?? 0;
308
+ const color = getColor(scales, groupKey);
309
+ const yPos = yScale(value);
310
+ const columnHeight = Math.max(Math.abs(baseline - yPos), MIN_COLUMN_HEIGHT);
311
+ const y = value >= 0 ? yPos : baseline;
312
+ const subX = bandX + groupIndex * (subBandWidth + gap);
313
+
314
+ const aria: MarkAria = {
315
+ label: `${category}, ${groupKey}: ${formatColumnValue(value)}`,
316
+ };
317
+
318
+ marks.push({
319
+ type: 'rect',
320
+ x: subX,
321
+ y,
322
+ width: subBandWidth,
323
+ height: columnHeight,
324
+ fill: color,
325
+ cornerRadius: 2,
326
+ data: row as Record<string, unknown>,
327
+ aria,
328
+ orient: 'vertical',
329
+ });
330
+ }
331
+ }
332
+
333
+ return marks;
334
+ }
335
+
244
336
  /** Compute stacked vertical columns. */
245
337
  function computeStackedColumns(
246
338
  data: DataRow[],
@@ -20,6 +20,7 @@ import type {
20
20
  import {
21
21
  buildD3Formatter,
22
22
  estimateTextWidth,
23
+ getRepresentativeColor,
23
24
  resolveCollisions,
24
25
  } from '@opendata-ai/openchart-core';
25
26
 
@@ -99,7 +100,7 @@ export function computeColumnLabels(
99
100
  fontFamily: 'system-ui, -apple-system, sans-serif',
100
101
  fontSize: LABEL_FONT_SIZE,
101
102
  fontWeight: LABEL_FONT_WEIGHT,
102
- fill: mark.fill,
103
+ fill: getRepresentativeColor(mark.fill),
103
104
  lineHeight: 1.2,
104
105
  textAnchor: 'middle',
105
106
  dominantBaseline: isNegative ? 'hanging' : 'auto',
@@ -17,7 +17,11 @@ import type {
17
17
  Rect,
18
18
  ResolvedLabel,
19
19
  } from '@opendata-ai/openchart-core';
20
- import { estimateTextWidth, resolveCollisions } from '@opendata-ai/openchart-core';
20
+ import {
21
+ estimateTextWidth,
22
+ getRepresentativeColor,
23
+ resolveCollisions,
24
+ } from '@opendata-ai/openchart-core';
21
25
 
22
26
  // ---------------------------------------------------------------------------
23
27
  // Constants
@@ -74,7 +78,7 @@ export function computeDotLabels(
74
78
  fontFamily: 'system-ui, -apple-system, sans-serif',
75
79
  fontSize: LABEL_FONT_SIZE,
76
80
  fontWeight: LABEL_FONT_WEIGHT,
77
- fill: mark.fill,
81
+ fill: getRepresentativeColor(mark.fill),
78
82
  lineHeight: 1.2,
79
83
  textAnchor: 'start',
80
84
  dominantBaseline: 'central',
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { AreaMark, DataRow, Encoding, MarkAria, Rect } from '@opendata-ai/openchart-core';
10
+ import { getRepresentativeColor } from '@opendata-ai/openchart-core';
10
11
  import type { ScaleLinear } from 'd3-scale';
11
12
  import { area, line, stack, stackOffsetNone, stackOrderNone } from 'd3-shape';
12
13
 
@@ -122,7 +123,7 @@ function computeSingleArea(
122
123
  topPath: topPathStr,
123
124
  fill: color,
124
125
  fillOpacity: DEFAULT_FILL_OPACITY,
125
- stroke: color,
126
+ stroke: getRepresentativeColor(color),
126
127
  strokeWidth: 2,
127
128
  seriesKey: seriesKey === '__default__' ? undefined : seriesKey,
128
129
  data: validPoints.map((p) => p.row),
@@ -258,7 +259,7 @@ function computeStackedArea(
258
259
  topPath: topPathStr,
259
260
  fill: color,
260
261
  fillOpacity: 0.7, // Higher opacity for stacked so layers are visible
261
- stroke: color,
262
+ stroke: getRepresentativeColor(color),
262
263
  strokeWidth: 1,
263
264
  seriesKey,
264
265
  data: layer.map((d) => {
@@ -10,12 +10,14 @@
10
10
  import type {
11
11
  DataRow,
12
12
  Encoding,
13
+ GradientDef,
13
14
  LayoutStrategy,
14
15
  LineMark,
15
16
  MarkAria,
16
17
  PointMark,
17
18
  Rect,
18
19
  } from '@opendata-ai/openchart-core';
20
+ import { getRepresentativeColor } from '@opendata-ai/openchart-core';
19
21
  import { line } from 'd3-shape';
20
22
 
21
23
  import type { NormalizedChartSpec } from '../../compiler/types';
@@ -68,9 +70,10 @@ export function computeLineMarks(
68
70
 
69
71
  for (const [seriesKey, rows] of groups) {
70
72
  // For sequential color, use a mid-range color for the line stroke
71
- const color = isSequentialColor
73
+ const color: string | GradientDef = isSequentialColor
72
74
  ? getSequentialColor(scales, _getMidValue(rows, sequentialColorField!))
73
75
  : getColor(scales, seriesKey);
76
+ const strokeColor = getRepresentativeColor(color);
74
77
 
75
78
  // Sort rows by x-axis field so lines draw left-to-right
76
79
  const sortedRows = sortByField(rows, xChannel.field);
@@ -165,7 +168,7 @@ export function computeLineMarks(
165
168
  type: 'line',
166
169
  points: allPoints,
167
170
  path: combinedPath,
168
- stroke: color,
171
+ stroke: strokeColor,
169
172
  strokeWidth: styleOverride?.strokeWidth ?? DEFAULT_STROKE_WIDTH,
170
173
  strokeDasharray,
171
174
  opacity: styleOverride?.opacity,
@@ -8,17 +8,21 @@
8
8
 
9
9
  import type {
10
10
  ArcMark,
11
+ ConditionalValueDef,
11
12
  DataRow,
12
13
  Encoding,
14
+ GradientDef,
13
15
  LayoutStrategy,
14
16
  MarkAria,
15
17
  Rect,
16
18
  } from '@opendata-ai/openchart-core';
19
+ import { isConditionalDef, isGradientDef } from '@opendata-ai/openchart-core';
17
20
  import type { PieArcDatum } from 'd3-shape';
18
21
  import { arc as d3Arc, pie as d3Pie } from 'd3-shape';
19
22
 
20
23
  import type { NormalizedChartSpec } from '../../compiler/types';
21
24
  import type { ResolvedScales } from '../../layout/scales';
25
+ import { resolveConditionalValue } from '../../transforms/conditional';
22
26
 
23
27
  // ---------------------------------------------------------------------------
24
28
  // Constants
@@ -109,6 +113,10 @@ export function computePieMarks(
109
113
  const valueChannel = encoding.y ?? encoding.x;
110
114
  const categoryField =
111
115
  encoding.color && 'field' in encoding.color ? encoding.color.field : undefined;
116
+ const conditionalColor =
117
+ encoding.color && isConditionalDef(encoding.color)
118
+ ? (encoding.color as ConditionalValueDef)
119
+ : undefined;
112
120
 
113
121
  if (!valueChannel) return [];
114
122
 
@@ -190,9 +198,22 @@ export function computePieMarks(
190
198
  const arcDatum = arcs[i];
191
199
  const slice = arcDatum.data;
192
200
 
193
- // Get color from scale or default palette
194
- let color: string;
195
- if (scales.color && categoryField) {
201
+ // Get color: conditional (supports gradients) > scale > default palette
202
+ let color: string | GradientDef;
203
+ if (conditionalColor) {
204
+ const resolved = resolveConditionalValue(
205
+ slice.originalRow as Record<string, unknown>,
206
+ conditionalColor,
207
+ );
208
+ if (resolved != null) {
209
+ color = isGradientDef(resolved) ? resolved : String(resolved);
210
+ } else if (scales.color && categoryField) {
211
+ const colorScale = scales.color.scale as (v: string) => string;
212
+ color = colorScale(slice.label);
213
+ } else {
214
+ color = DEFAULT_PALETTE[i % DEFAULT_PALETTE.length];
215
+ }
216
+ } else if (scales.color && categoryField) {
196
217
  const colorScale = scales.color.scale as (v: string) => string;
197
218
  color = colorScale(slice.label);
198
219
  } else {
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { Encoding, Mark, MarkAria, Rect, RuleMarkLayout } from '@opendata-ai/openchart-core';
10
+ import { getRepresentativeColor } from '@opendata-ai/openchart-core';
10
11
 
11
12
  import type { NormalizedChartSpec } from '../../compiler/types';
12
13
  import type { ResolvedScales } from '../../layout/scales';
@@ -84,9 +85,11 @@ export function computeRuleMarks(
84
85
  if (y2Val != null) y2 = y2Val;
85
86
  }
86
87
 
87
- const color = colorField
88
- ? getColor(scales, String(row[colorField] ?? '__default__'))
89
- : getColor(scales, '__default__');
88
+ const color = getRepresentativeColor(
89
+ colorField
90
+ ? getColor(scales, String(row[colorField] ?? '__default__'))
91
+ : getColor(scales, '__default__'),
92
+ );
90
93
 
91
94
  const strokeDashEncoding =
92
95
  encoding.strokeDash && 'field' in encoding.strokeDash ? encoding.strokeDash : undefined;
@@ -10,6 +10,7 @@
10
10
  import type {
11
11
  Encoding,
12
12
  FieldType,
13
+ GradientDef,
13
14
  LayoutStrategy,
14
15
  MarkAria,
15
16
  PointMark,
@@ -139,7 +140,7 @@ export function computeScatterMarks(
139
140
  if (cx === undefined || cy === undefined) continue;
140
141
 
141
142
  const category = colorField && !isSequentialColor ? String(row[colorField] ?? '') : undefined;
142
- let color: string;
143
+ let color: string | GradientDef;
143
144
  if (isSequentialColor && colorField) {
144
145
  const val = Number(row[colorField]);
145
146
  color = Number.isFinite(val)