@opendata-ai/openchart-engine 6.15.1 → 6.16.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.15.1",
3
+ "version": "6.16.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.15.1",
48
+ "@opendata-ai/openchart-core": "6.16.0",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -243,6 +243,38 @@ describe('computeLegend', () => {
243
243
  expect(maxRowsVisible).toBeGreaterThan(defaultVisible);
244
244
  });
245
245
 
246
+ it('uses explicit domain+range colors in legend entries', () => {
247
+ const specExplicit: NormalizedChartSpec = {
248
+ ...specWithColor,
249
+ data: [
250
+ { date: '2020', value: 10, country: 'UK' },
251
+ { date: '2021', value: 20, country: 'US' },
252
+ { date: '2022', value: 30, country: 'Germany' },
253
+ ],
254
+ encoding: {
255
+ x: { field: 'date', type: 'temporal' },
256
+ y: { field: 'value', type: 'quantitative' },
257
+ color: {
258
+ field: 'country',
259
+ type: 'nominal',
260
+ scale: {
261
+ domain: ['US', 'UK', 'Germany'],
262
+ range: ['#ff0000', '#0000ff', '#00ff00'],
263
+ },
264
+ },
265
+ },
266
+ };
267
+ const legend = computeLegend(specExplicit, compactStrategy, theme, chartArea);
268
+ // Data order is UK, US, Germany but domain order is US, UK, Germany
269
+ // Legend should match colors to domain indices, not data order
270
+ const ukEntry = legend.entries.find((e) => e.label === 'UK')!;
271
+ const usEntry = legend.entries.find((e) => e.label === 'US')!;
272
+ const deEntry = legend.entries.find((e) => e.label === 'Germany')!;
273
+ expect(usEntry.color).toBe('#ff0000');
274
+ expect(ukEntry.color).toBe('#0000ff');
275
+ expect(deEntry.color).toBe('#00ff00');
276
+ });
277
+
246
278
  it('uses correct swatch shape for chart type', () => {
247
279
  const lineLegend = computeLegend(specWithColor, fullStrategy, theme, chartArea);
248
280
  expect(lineLegend.entries[0].shape).toBe('line');
@@ -50,6 +50,7 @@ export function resolveTextAnnotation(
50
50
  if (px === null || py === null) return null;
51
51
 
52
52
  const defaultTextFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
53
+
53
54
  const labelStyle = makeAnnotationLabelStyle(
54
55
  annotation.fontSize,
55
56
  annotation.fontWeight,
package/src/compile.ts CHANGED
@@ -375,19 +375,28 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
375
375
  // Compute scales
376
376
  const scales = computeScales(renderSpec, chartArea, renderSpec.data);
377
377
 
378
- // Update color scale to use theme palette
378
+ // Update color scale to use theme palette (only when user hasn't provided an explicit range)
379
379
  if (scales.color) {
380
+ const hasExplicitRange = !!(
381
+ renderSpec.encoding.color &&
382
+ 'field' in renderSpec.encoding.color &&
383
+ (renderSpec.encoding.color.scale?.range as string[] | undefined)?.length
384
+ );
380
385
  if (scales.color.type === 'sequential') {
381
386
  // Sequential: use first sequential palette (or fall back to categorical endpoints)
382
- const seqStops = Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
383
- (scales.color.scale as unknown as import('d3-scale').ScaleLinear<string, string>).range([
384
- seqStops[0],
385
- seqStops[seqStops.length - 1],
386
- ]);
387
+ if (!hasExplicitRange) {
388
+ const seqStops = Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
389
+ (scales.color.scale as unknown as import('d3-scale').ScaleLinear<string, string>).range([
390
+ seqStops[0],
391
+ seqStops[seqStops.length - 1],
392
+ ]);
393
+ }
387
394
  } else {
388
- (scales.color.scale as import('d3-scale').ScaleOrdinal<string, string>).range(
389
- theme.colors.categorical,
390
- );
395
+ if (!hasExplicitRange) {
396
+ (scales.color.scale as import('d3-scale').ScaleOrdinal<string, string>).range(
397
+ theme.colors.categorical,
398
+ );
399
+ }
391
400
  }
392
401
  }
393
402
 
@@ -185,7 +185,7 @@ export function computeDimensions(
185
185
  }
186
186
  }
187
187
  if (maxLabelWidth > 0) {
188
- margins.right = Math.max(margins.right, padding + maxLabelWidth + 16);
188
+ margins.right = Math.max(margins.right, padding + maxLabelWidth + 8);
189
189
  }
190
190
  }
191
191
  }
@@ -512,7 +512,11 @@ function buildOrdinalColorScale(
512
512
  ? explicitDomain.map(String)
513
513
  : applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
514
514
 
515
- const scale = scaleOrdinal<string>().domain(values).range(palette);
515
+ // Use explicit range if provided, otherwise fall back to theme palette
516
+ const explicitRange = channel.scale?.range as string[] | undefined;
517
+ const colors = explicitRange ?? palette;
518
+
519
+ const scale = scaleOrdinal<string>().domain(values).range(colors);
516
520
 
517
521
  return { scale, type: 'ordinal', channel };
518
522
  }
@@ -526,9 +530,13 @@ function buildSequentialColorScale(
526
530
  const domainMin = min(values) ?? 0;
527
531
  const domainMax = max(values) ?? 1;
528
532
 
533
+ // Use explicit range if provided, otherwise fall back to theme palette endpoints
534
+ const explicitRange = channel.scale?.range as string[] | undefined;
535
+ const colors = explicitRange ?? palette;
536
+
529
537
  const scale = scaleLinear<string>()
530
538
  .domain([domainMin, domainMax])
531
- .range([palette[0], palette[palette.length - 1]])
539
+ .range([colors[0], colors[colors.length - 1]])
532
540
  .clamp(true);
533
541
 
534
542
  // Cast: sequential color scale (number -> string) is structurally incompatible
@@ -70,15 +70,26 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
70
70
  if (colorEnc.type === 'quantitative') return [];
71
71
 
72
72
  const uniqueValues = [...new Set(spec.data.map((d) => String(d[colorEnc.field])))];
73
- const palette = theme.colors.categorical;
73
+ const explicitDomain = colorEnc.scale?.domain as string[] | undefined;
74
+ const explicitRange = colorEnc.scale?.range as string[] | undefined;
75
+ const palette = explicitRange ?? theme.colors.categorical;
74
76
  const shape = swatchShapeForType(spec.markType);
75
77
 
76
- return uniqueValues.map((value, i) => ({
77
- label: value,
78
- color: palette[i % palette.length],
79
- shape,
80
- active: true,
81
- }));
78
+ return uniqueValues.map((value, i) => {
79
+ // When explicit domain+range are provided, look up the color by domain index
80
+ // so legend colors match the mark colors exactly.
81
+ let colorIndex = i;
82
+ if (explicitDomain && explicitRange) {
83
+ const domainIdx = explicitDomain.indexOf(value);
84
+ if (domainIdx >= 0) colorIndex = domainIdx;
85
+ }
86
+ return {
87
+ label: value,
88
+ color: palette[colorIndex % palette.length],
89
+ shape,
90
+ active: true,
91
+ };
92
+ });
82
93
  }
83
94
 
84
95
  /**
@@ -89,7 +89,7 @@ describe('computeCategoryColors', () => {
89
89
  }
90
90
  });
91
91
 
92
- it('dark mode adapts colors', () => {
92
+ it('dark mode preserves explicit user-provided colors', () => {
93
93
  const col: ColumnConfig = {
94
94
  key: 'status',
95
95
  categoryColors: {
@@ -97,18 +97,32 @@ describe('computeCategoryColors', () => {
97
97
  inactive: '#ff0000',
98
98
  },
99
99
  };
100
- const lightTheme = getTheme(false);
101
100
  const darkTheme = getTheme(true);
101
+ const colors = computeCategoryColors(data, col, darkTheme, true);
102
102
 
103
- const lightColors = computeCategoryColors(data, col, lightTheme, false);
103
+ // Explicit colors should NOT be adapted for dark mode
104
+ expect(colors.get(0)!.backgroundColor).toBe('#00ff00');
105
+ expect(colors.get(1)!.backgroundColor).toBe('#ff0000');
106
+ });
107
+
108
+ it('dark mode adapts auto-assigned palette colors but not explicit ones', () => {
109
+ // Use a bright yellow that will definitely get adapted in dark mode
110
+ const col: ColumnConfig = {
111
+ key: 'status',
112
+ categoryColors: {
113
+ active: '#ffff00',
114
+ },
115
+ };
116
+ const darkTheme = getTheme(true);
104
117
  const darkColors = computeCategoryColors(data, col, darkTheme, true);
105
118
 
106
- expect(lightColors.size).toBe(darkColors.size);
119
+ // Explicit color should be preserved as-is (not adapted)
120
+ expect(darkColors.get(0)!.backgroundColor).toBe('#ffff00');
107
121
 
108
- // Dark mode colors should be adapted (different from light mode)
109
- const lightBg = lightColors.get(0)!.backgroundColor;
110
- const darkBg = darkColors.get(0)!.backgroundColor;
111
- expect(darkBg).not.toBe(lightBg);
122
+ // Auto-assigned palette colors should still be present (adaptation may or
123
+ // may not visually change them, but the code path runs adaptColorForDarkMode)
124
+ expect(darkColors.has(1)).toBe(true); // inactive
125
+ expect(darkColors.has(3)).toBe(true); // pending
112
126
  });
113
127
 
114
128
  it('dark mode text contrast still meets AA', () => {
@@ -137,6 +151,34 @@ describe('computeCategoryColors', () => {
137
151
  expect(colors.size).toBe(0);
138
152
  });
139
153
 
154
+ it('skips transparent and none category colors', () => {
155
+ const dataWithSpecial = [
156
+ { status: 'active' },
157
+ { status: 'inactive' },
158
+ { status: 'pending' },
159
+ { status: 'disabled' },
160
+ ];
161
+ const col: ColumnConfig = {
162
+ key: 'status',
163
+ categoryColors: {
164
+ active: '#00ff00',
165
+ inactive: 'transparent',
166
+ pending: 'none',
167
+ disabled: '#cccccc',
168
+ },
169
+ };
170
+ const theme = getTheme();
171
+ const colors = computeCategoryColors(dataWithSpecial, col, theme, false);
172
+
173
+ // transparent and none rows should be skipped
174
+ expect(colors.has(1)).toBe(false); // inactive = transparent
175
+ expect(colors.has(2)).toBe(false); // pending = none
176
+ // explicit colors should still be present
177
+ expect(colors.get(0)!.backgroundColor).toBe('#00ff00');
178
+ expect(colors.get(3)!.backgroundColor).toBe('#cccccc');
179
+ expect(colors.size).toBe(2);
180
+ });
181
+
140
182
  it('skips null values', () => {
141
183
  const dataWithNull = [{ status: 'active' }, { status: null }, { status: 'inactive' }];
142
184
  const col: ColumnConfig = {
@@ -39,9 +39,15 @@ export function computeCategoryColors(
39
39
 
40
40
  const key = String(raw);
41
41
  let bg: string;
42
+ let isExplicit = false;
42
43
 
43
- if (explicitMap[key]) {
44
+ if (explicitMap[key] != null) {
45
+ if (explicitMap[key] === 'transparent' || explicitMap[key] === 'none') {
46
+ // Skip transparent/none — let the cell inherit default table styling
47
+ continue;
48
+ }
44
49
  bg = explicitMap[key];
50
+ isExplicit = true;
45
51
  } else if (autoAssigned.has(key)) {
46
52
  bg = autoAssigned.get(key)!;
47
53
  } else {
@@ -51,8 +57,8 @@ export function computeCategoryColors(
51
57
  autoAssigned.set(key, bg);
52
58
  }
53
59
 
54
- // Dark mode adaptation
55
- if (darkMode) {
60
+ // Dark mode adaptation (skip for explicit user-provided colors)
61
+ if (darkMode && !isExplicit) {
56
62
  bg = adaptColorForDarkMode(bg, lightBg, darkBg);
57
63
  }
58
64
 
@@ -14,6 +14,15 @@ describe('evaluatePredicate', () => {
14
14
  it('matches equal numeric value', () => {
15
15
  expect(evaluatePredicate({ age: 30 }, { field: 'age', equal: 30 })).toBe(true);
16
16
  });
17
+
18
+ it('matches string value against numeric predicate via loose equality', () => {
19
+ // CSV/JSON parsing often produces string values for numeric fields
20
+ expect(evaluatePredicate({ id: '181' }, { field: 'id', equal: 181 })).toBe(true);
21
+ });
22
+
23
+ it('matches numeric value against string predicate via loose equality', () => {
24
+ expect(evaluatePredicate({ id: 181 }, { field: 'id', equal: '181' })).toBe(true);
25
+ });
17
26
  });
18
27
 
19
28
  describe('FieldPredicate: lt/lte/gt/gte', () => {
@@ -62,6 +71,14 @@ describe('evaluatePredicate', () => {
62
71
  it('rejects values not in the set', () => {
63
72
  expect(evaluatePredicate({ c: 'green' }, { field: 'c', oneOf: ['red', 'blue'] })).toBe(false);
64
73
  });
74
+
75
+ it('matches string value against numeric oneOf via loose equality', () => {
76
+ expect(evaluatePredicate({ id: '181' }, { field: 'id', oneOf: [181, 200] })).toBe(true);
77
+ });
78
+
79
+ it('rejects value not in numeric oneOf set', () => {
80
+ expect(evaluatePredicate({ id: '999' }, { field: 'id', oneOf: [181, 200] })).toBe(false);
81
+ });
65
82
  });
66
83
 
67
84
  describe('FieldPredicate: valid', () => {
@@ -26,9 +26,11 @@ function evaluateFieldPredicate(datum: DataRow, pred: FieldPredicate): boolean {
26
26
  return pred.valid ? isValid : !isValid;
27
27
  }
28
28
 
29
- // equal
29
+ // equal: use loose equality so "181" == 181 matches across type boundaries
30
+ // (data values may arrive as strings from CSV/JSON parsing)
30
31
  if (pred.equal !== undefined) {
31
- return value === pred.equal;
32
+ // biome-ignore lint/suspicious/noDoubleEquals: intentional loose equality for CSV/JSON type coercion
33
+ return value == pred.equal;
32
34
  }
33
35
 
34
36
  // Numeric comparisons
@@ -53,9 +55,10 @@ function evaluateFieldPredicate(datum: DataRow, pred: FieldPredicate): boolean {
53
55
  return numValue >= min && numValue <= max;
54
56
  }
55
57
 
56
- // oneOf: value is in the set
58
+ // oneOf: use loose equality (same rationale as equal above)
57
59
  if (pred.oneOf !== undefined) {
58
- return pred.oneOf.includes(value);
60
+ // biome-ignore lint/suspicious/noDoubleEquals: intentional loose equality for CSV/JSON type coercion
61
+ return pred.oneOf.some((v) => v == value);
59
62
  }
60
63
 
61
64
  // No condition specified, default to true