@opendata-ai/openchart-engine 6.10.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.10.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.10.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",
@@ -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';
@@ -339,11 +340,14 @@ function computeSimpleBars(
339
340
  const bandY = yScale(category);
340
341
  if (bandY === undefined) continue;
341
342
 
342
- let color: string;
343
+ let color: string | GradientDef;
343
344
  if (conditionalColor) {
344
- color = String(
345
- resolveConditionalValue(row, conditionalColor) ?? getColor(scales, '__default__'),
346
- );
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
+ }
347
351
  } else if (sequentialColor) {
348
352
  color = getSequentialColor(scales, value);
349
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
 
@@ -14,12 +14,13 @@ import type {
14
14
  ConditionalValueDef,
15
15
  DataRow,
16
16
  Encoding,
17
+ GradientDef,
17
18
  LayoutStrategy,
18
19
  MarkAria,
19
20
  Rect,
20
21
  RectMark,
21
22
  } from '@opendata-ai/openchart-core';
22
- import { abbreviateNumber, formatNumber } from '@opendata-ai/openchart-core';
23
+ import { abbreviateNumber, formatNumber, isGradientDef } from '@opendata-ai/openchart-core';
23
24
  import type { ScaleBand, ScaleLinear } from 'd3-scale';
24
25
  import type { NormalizedChartSpec } from '../../compiler/types';
25
26
  import type { ResolvedScales } from '../../layout/scales';
@@ -169,11 +170,14 @@ function computeSimpleColumns(
169
170
  const bandX = xScale(category);
170
171
  if (bandX === undefined) continue;
171
172
 
172
- let color: string;
173
+ let color: string | GradientDef;
173
174
  if (conditionalColor) {
174
- color = String(
175
- resolveConditionalValue(row, conditionalColor) ?? getColor(scales, '__default__'),
176
- );
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
+ }
177
181
  } else if (sequentialColor) {
178
182
  color = getSequentialColor(scales, value);
179
183
  } 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
 
@@ -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)
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { Encoding, Mark, MarkAria, TextMarkLayout } 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';
@@ -52,9 +53,11 @@ export function computeTextMarks(
52
53
  const text = String(row[textChannel.field] ?? '');
53
54
  if (!text) continue;
54
55
 
55
- const color = colorField
56
- ? getColor(scales, String(row[colorField] ?? '__default__'))
57
- : getColor(scales, '__default__');
56
+ const color = getRepresentativeColor(
57
+ colorField
58
+ ? getColor(scales, String(row[colorField] ?? '__default__'))
59
+ : getColor(scales, '__default__'),
60
+ );
58
61
 
59
62
  const fontSize = sizeEncoding
60
63
  ? Math.max(8, Math.min(48, Number(row[sizeEncoding.field]) || 12))
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { Encoding, Mark, MarkAria, Rect, TickMarkLayout } 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';
@@ -48,9 +49,11 @@ export function computeTickMarks(
48
49
  const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
49
50
  if (xVal == null || yVal == null) continue;
50
51
 
51
- const color = colorField
52
- ? getColor(scales, String(row[colorField] ?? '__default__'))
53
- : getColor(scales, '__default__');
52
+ const color = getRepresentativeColor(
53
+ colorField
54
+ ? getColor(scales, String(row[colorField] ?? '__default__'))
55
+ : getColor(scales, '__default__'),
56
+ );
54
57
 
55
58
  const aria: MarkAria = {
56
59
  label: `${row[xChannel.field]}, ${row[yChannel.field]}`,
@@ -5,7 +5,7 @@
5
5
  * data grouping, color lookup, and shared constants.
6
6
  */
7
7
 
8
- import type { DataRow } from '@opendata-ai/openchart-core';
8
+ import type { DataRow, GradientDef } from '@opendata-ai/openchart-core';
9
9
  import type { ScaleBand, ScaleLinear, ScalePoint, ScaleTime } from 'd3-scale';
10
10
  import type { D3Scale, ResolvedScales } from '../layout/scales';
11
11
 
@@ -143,7 +143,7 @@ export function getColor(
143
143
  key: string,
144
144
  _index?: number,
145
145
  fallback: string = DEFAULT_COLOR,
146
- ): string {
146
+ ): string | GradientDef {
147
147
  if (scales.color && key !== '__default__') {
148
148
  const colorScale = scales.color.scale as (v: string) => string;
149
149
  return colorScale(key);
@@ -159,7 +159,7 @@ export function getSequentialColor(
159
159
  scales: ResolvedScales,
160
160
  value: number,
161
161
  fallback: string = DEFAULT_COLOR,
162
- ): string {
162
+ ): string | GradientDef {
163
163
  if (scales.color?.type === 'sequential') {
164
164
  const colorScale = scales.color.scale as unknown as (v: number) => string;
165
165
  return colorScale(value);
package/src/compile.ts CHANGED
@@ -386,8 +386,9 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
386
386
  }
387
387
  }
388
388
 
389
- // Set default color for single-series charts (no color encoding)
390
- scales.defaultColor = theme.colors.categorical[0];
389
+ // Set default color for single-series charts. If the user set a fill on the mark def
390
+ // (string or gradient), that takes priority over the theme's first categorical color.
391
+ scales.defaultColor = chartSpec.markDef.fill ?? theme.colors.categorical[0];
391
392
 
392
393
  // Arc charts (pie/donut) don't use axes or gridlines
393
394
  const isRadial = chartSpec.markType === 'arc';
@@ -99,8 +99,8 @@ export interface ResolvedScales {
99
99
  y?: ResolvedScale;
100
100
  color?: ResolvedScale;
101
101
  size?: ResolvedScale;
102
- /** Default color for single-series charts (first categorical palette color). */
103
- defaultColor?: string;
102
+ /** Default color for single-series charts (first categorical palette color or markDef.fill gradient). */
103
+ defaultColor?: string | import('@opendata-ai/openchart-core').GradientDef;
104
104
  }
105
105
 
106
106
  // ---------------------------------------------------------------------------
@@ -19,7 +19,12 @@ import type {
19
19
  TooltipContent,
20
20
  TooltipField,
21
21
  } from '@opendata-ai/openchart-core';
22
- import { buildTemporalFormatter, formatDate, formatNumber } from '@opendata-ai/openchart-core';
22
+ import {
23
+ buildTemporalFormatter,
24
+ formatDate,
25
+ formatNumber,
26
+ getRepresentativeColor,
27
+ } from '@opendata-ai/openchart-core';
23
28
  import { format as d3Format } from 'd3-format';
24
29
 
25
30
  import type { NormalizedChartSpec } from '../compiler/types';
@@ -155,7 +160,7 @@ function tooltipsForPoint(
155
160
  markIndex: number,
156
161
  ): Array<[string, TooltipContent]> {
157
162
  const title = getTooltipTitle(mark.data, encoding);
158
- const fields = buildFields(mark.data, encoding, mark.fill);
163
+ const fields = buildFields(mark.data, encoding, getRepresentativeColor(mark.fill));
159
164
 
160
165
  return [[`point-${markIndex}`, { title, fields }]];
161
166
  }
@@ -166,7 +171,7 @@ function tooltipsForRect(
166
171
  markIndex: number,
167
172
  ): Array<[string, TooltipContent]> {
168
173
  const title = getTooltipTitle(mark.data, encoding);
169
- const fields = buildFields(mark.data, encoding, mark.fill);
174
+ const fields = buildFields(mark.data, encoding, getRepresentativeColor(mark.fill));
170
175
 
171
176
  return [[`rect-${markIndex}`, { title, fields }]];
172
177
  }
@@ -187,14 +192,14 @@ function tooltipsForArc(
187
192
  fields.push({
188
193
  label: categoryName,
189
194
  value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y.axis?.format),
190
- color: mark.fill,
195
+ color: getRepresentativeColor(mark.fill),
191
196
  });
192
197
  }
193
198
  } else if (encoding.y) {
194
199
  fields.push({
195
200
  label: encoding.y.field,
196
201
  value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y.axis?.format),
197
- color: mark.fill,
202
+ color: getRepresentativeColor(mark.fill),
198
203
  });
199
204
  }
200
205
 
@@ -214,7 +219,7 @@ function tooltipsForArea(
214
219
  for (const dp of mark.dataPoints) {
215
220
  dp.tooltip = {
216
221
  title: getTooltipTitle(dp.datum, encoding),
217
- fields: buildFields(dp.datum, encoding, mark.fill),
222
+ fields: buildFields(dp.datum, encoding, getRepresentativeColor(mark.fill)),
218
223
  };
219
224
  }
220
225
  }