@opendata-ai/openchart-engine 6.0.0 → 6.1.1

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.
Files changed (65) hide show
  1. package/dist/index.d.ts +155 -19
  2. package/dist/index.js +1513 -164
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +6 -3
  6. package/src/__tests__/axes.test.ts +168 -4
  7. package/src/__tests__/compile-chart.test.ts +23 -12
  8. package/src/__tests__/compile-layer.test.ts +386 -0
  9. package/src/__tests__/dimensions.test.ts +6 -3
  10. package/src/__tests__/legend.test.ts +6 -3
  11. package/src/__tests__/scales.test.ts +176 -2
  12. package/src/annotations/__tests__/compute.test.ts +8 -4
  13. package/src/charts/bar/__tests__/compute.test.ts +12 -6
  14. package/src/charts/bar/compute.ts +21 -5
  15. package/src/charts/column/__tests__/compute.test.ts +14 -7
  16. package/src/charts/column/compute.ts +21 -6
  17. package/src/charts/dot/__tests__/compute.test.ts +10 -5
  18. package/src/charts/dot/compute.ts +10 -4
  19. package/src/charts/line/__tests__/compute.test.ts +102 -11
  20. package/src/charts/line/__tests__/curves.test.ts +51 -0
  21. package/src/charts/line/__tests__/labels.test.ts +2 -1
  22. package/src/charts/line/__tests__/mark-options.test.ts +175 -0
  23. package/src/charts/line/area.ts +19 -8
  24. package/src/charts/line/compute.ts +64 -25
  25. package/src/charts/line/curves.ts +40 -0
  26. package/src/charts/pie/__tests__/compute.test.ts +10 -5
  27. package/src/charts/pie/compute.ts +2 -1
  28. package/src/charts/rule/index.ts +127 -0
  29. package/src/charts/scatter/__tests__/compute.test.ts +10 -5
  30. package/src/charts/scatter/compute.ts +15 -5
  31. package/src/charts/text/index.ts +92 -0
  32. package/src/charts/tick/index.ts +84 -0
  33. package/src/charts/utils.ts +1 -1
  34. package/src/compile.ts +175 -23
  35. package/src/compiler/__tests__/compile.test.ts +4 -4
  36. package/src/compiler/__tests__/normalize.test.ts +4 -4
  37. package/src/compiler/__tests__/validate.test.ts +25 -26
  38. package/src/compiler/index.ts +1 -1
  39. package/src/compiler/normalize.ts +77 -4
  40. package/src/compiler/types.ts +6 -2
  41. package/src/compiler/validate.ts +167 -35
  42. package/src/graphs/__tests__/compile-graph.test.ts +2 -2
  43. package/src/graphs/compile-graph.ts +2 -2
  44. package/src/index.ts +17 -1
  45. package/src/layout/axes.ts +122 -20
  46. package/src/layout/dimensions.ts +15 -9
  47. package/src/layout/scales.ts +320 -31
  48. package/src/legend/compute.ts +9 -6
  49. package/src/tables/__tests__/compile-table.test.ts +1 -1
  50. package/src/tooltips/__tests__/compute.test.ts +10 -5
  51. package/src/tooltips/compute.ts +32 -14
  52. package/src/transforms/__tests__/bin.test.ts +88 -0
  53. package/src/transforms/__tests__/calculate.test.ts +146 -0
  54. package/src/transforms/__tests__/conditional.test.ts +109 -0
  55. package/src/transforms/__tests__/filter.test.ts +59 -0
  56. package/src/transforms/__tests__/index.test.ts +93 -0
  57. package/src/transforms/__tests__/predicates.test.ts +176 -0
  58. package/src/transforms/__tests__/timeunit.test.ts +129 -0
  59. package/src/transforms/bin.ts +87 -0
  60. package/src/transforms/calculate.ts +60 -0
  61. package/src/transforms/conditional.ts +46 -0
  62. package/src/transforms/filter.ts +17 -0
  63. package/src/transforms/index.ts +48 -0
  64. package/src/transforms/predicates.ts +90 -0
  65. package/src/transforms/timeunit.ts +88 -0
@@ -20,7 +20,8 @@ const fullStrategy: LayoutStrategy = {
20
20
 
21
21
  function makeSimpleBarSpec(): NormalizedChartSpec {
22
22
  return {
23
- type: 'bar',
23
+ markType: 'bar',
24
+ markDef: { type: 'bar' },
24
25
  data: [
25
26
  { category: 'Apple', value: 50 },
26
27
  { category: 'Banana', value: 30 },
@@ -41,7 +42,8 @@ function makeSimpleBarSpec(): NormalizedChartSpec {
41
42
 
42
43
  function makeGroupedBarSpec(): NormalizedChartSpec {
43
44
  return {
44
- type: 'bar',
45
+ markType: 'bar',
46
+ markDef: { type: 'bar' },
45
47
  data: [
46
48
  { category: 'Q1', value: 50, region: 'East' },
47
49
  { category: 'Q1', value: 40, region: 'West' },
@@ -66,7 +68,8 @@ function makeGroupedBarSpec(): NormalizedChartSpec {
66
68
 
67
69
  function makeNegativeBarSpec(): NormalizedChartSpec {
68
70
  return {
69
- type: 'bar',
71
+ markType: 'bar',
72
+ markDef: { type: 'bar' },
70
73
  data: [
71
74
  { category: 'Growth', value: 15 },
72
75
  { category: 'Decline', value: -10 },
@@ -227,7 +230,8 @@ describe('computeBarMarks', () => {
227
230
  describe('edge cases', () => {
228
231
  it('returns empty array when no x encoding', () => {
229
232
  const spec: NormalizedChartSpec = {
230
- type: 'bar',
233
+ markType: 'bar',
234
+ markDef: { type: 'bar' },
231
235
  data: [{ category: 'A', value: 10 }],
232
236
  encoding: {
233
237
  y: { field: 'category', type: 'nominal' },
@@ -246,7 +250,8 @@ describe('computeBarMarks', () => {
246
250
 
247
251
  it('returns empty array for empty data', () => {
248
252
  const spec: NormalizedChartSpec = {
249
- type: 'bar',
253
+ markType: 'bar',
254
+ markDef: { type: 'bar' },
250
255
  data: [],
251
256
  encoding: {
252
257
  x: { field: 'value', type: 'quantitative' },
@@ -306,7 +311,8 @@ describe('computeBarLabels', () => {
306
311
 
307
312
  it('applies format with literal alpha suffix (e.g. "T")', () => {
308
313
  const spec: NormalizedChartSpec = {
309
- type: 'bar',
314
+ markType: 'bar',
315
+ markDef: { type: 'bar' },
310
316
  data: [
311
317
  { company: 'Apple', cap: 3.75 },
312
318
  { company: 'Meta', cap: 1.63 },
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type {
10
+ ConditionalValueDef,
10
11
  DataRow,
11
12
  Encoding,
12
13
  LayoutStrategy,
@@ -19,6 +20,7 @@ import type { ScaleBand, ScaleLinear } from 'd3-scale';
19
20
 
20
21
  import type { NormalizedChartSpec } from '../../compiler/types';
21
22
  import type { ResolvedScales } from '../../layout/scales';
23
+ import { isConditionalValueDef, resolveConditionalValue } from '../../transforms/conditional';
22
24
  import { getColor, getSequentialColor, groupByField } from '../utils';
23
25
 
24
26
  // ---------------------------------------------------------------------------
@@ -68,8 +70,13 @@ export function computeBarMarks(
68
70
 
69
71
  const bandwidth = yScale.bandwidth();
70
72
  const baseline = xScale(0);
71
- const colorField = encoding.color?.field;
72
- const isSequentialColor = encoding.color?.type === 'quantitative';
73
+ const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
74
+ const conditionalColor =
75
+ encoding.color && isConditionalValueDef(encoding.color)
76
+ ? (encoding.color as ConditionalValueDef)
77
+ : undefined;
78
+ const colorField = colorEnc?.field;
79
+ const isSequentialColor = colorEnc?.type === 'quantitative';
73
80
 
74
81
  // If no color encoding, or sequential color (value-based gradient), render simple bars
75
82
  if (!colorField || isSequentialColor) {
@@ -83,6 +90,7 @@ export function computeBarMarks(
83
90
  baseline,
84
91
  scales,
85
92
  isSequentialColor,
93
+ conditionalColor,
86
94
  );
87
95
  }
88
96
 
@@ -167,6 +175,7 @@ function computeSimpleBars(
167
175
  baseline: number,
168
176
  scales: ResolvedScales,
169
177
  sequentialColor = false,
178
+ conditionalColor?: ConditionalValueDef,
170
179
  ): RectMark[] {
171
180
  const marks: RectMark[] = [];
172
181
 
@@ -178,9 +187,16 @@ function computeSimpleBars(
178
187
  const bandY = yScale(category);
179
188
  if (bandY === undefined) continue;
180
189
 
181
- const color = sequentialColor
182
- ? getSequentialColor(scales, value)
183
- : getColor(scales, '__default__');
190
+ let color: string;
191
+ if (conditionalColor) {
192
+ color = String(
193
+ resolveConditionalValue(row, conditionalColor) ?? getColor(scales, '__default__'),
194
+ );
195
+ } else if (sequentialColor) {
196
+ color = getSequentialColor(scales, value);
197
+ } else {
198
+ color = getColor(scales, '__default__');
199
+ }
184
200
  const xPos = value >= 0 ? baseline : xScale(value);
185
201
  const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
186
202
 
@@ -20,7 +20,8 @@ const fullStrategy: LayoutStrategy = {
20
20
 
21
21
  function makeSimpleColumnSpec(): NormalizedChartSpec {
22
22
  return {
23
- type: 'column',
23
+ markType: 'bar',
24
+ markDef: { type: 'bar', orient: 'vertical' },
24
25
  data: [
25
26
  { month: 'Jan', sales: 120 },
26
27
  { month: 'Feb', sales: 80 },
@@ -42,7 +43,8 @@ function makeSimpleColumnSpec(): NormalizedChartSpec {
42
43
 
43
44
  function makeGroupedColumnSpec(): NormalizedChartSpec {
44
45
  return {
45
- type: 'column',
46
+ markType: 'bar',
47
+ markDef: { type: 'bar', orient: 'vertical' },
46
48
  data: [
47
49
  { month: 'Jan', sales: 120, region: 'North' },
48
50
  { month: 'Jan', sales: 80, region: 'South' },
@@ -67,7 +69,8 @@ function makeGroupedColumnSpec(): NormalizedChartSpec {
67
69
 
68
70
  function makeNegativeColumnSpec(): NormalizedChartSpec {
69
71
  return {
70
- type: 'column',
72
+ markType: 'bar',
73
+ markDef: { type: 'bar', orient: 'vertical' },
71
74
  data: [
72
75
  { quarter: 'Q1', growth: 5 },
73
76
  { quarter: 'Q2', growth: -3 },
@@ -211,7 +214,8 @@ describe('computeColumnMarks', () => {
211
214
  describe('edge cases', () => {
212
215
  it('returns empty array when no y encoding', () => {
213
216
  const spec: NormalizedChartSpec = {
214
- type: 'column',
217
+ markType: 'bar',
218
+ markDef: { type: 'bar', orient: 'vertical' },
215
219
  data: [{ month: 'Jan', sales: 100 }],
216
220
  encoding: {
217
221
  x: { field: 'month', type: 'nominal' },
@@ -230,7 +234,8 @@ describe('computeColumnMarks', () => {
230
234
 
231
235
  it('returns empty array for empty data', () => {
232
236
  const spec: NormalizedChartSpec = {
233
- type: 'column',
237
+ markType: 'bar',
238
+ markDef: { type: 'bar', orient: 'vertical' },
234
239
  data: [],
235
240
  encoding: {
236
241
  x: { field: 'month', type: 'nominal' },
@@ -288,7 +293,8 @@ describe('computeColumnLabels', () => {
288
293
 
289
294
  it('applies format with trailing zero trim (~)', () => {
290
295
  const spec: NormalizedChartSpec = {
291
- type: 'column',
296
+ markType: 'bar',
297
+ markDef: { type: 'bar', orient: 'vertical' },
292
298
  data: [
293
299
  { company: 'A', cap: 3.1 },
294
300
  { company: 'B', cap: 2.85 },
@@ -315,7 +321,8 @@ describe('computeColumnLabels', () => {
315
321
 
316
322
  it('applies format with literal alpha suffix (e.g. "T")', () => {
317
323
  const spec: NormalizedChartSpec = {
318
- type: 'column',
324
+ markType: 'bar',
325
+ markDef: { type: 'bar', orient: 'vertical' },
319
326
  data: [
320
327
  { company: 'Apple', cap: 3.75 },
321
328
  { company: 'Meta', cap: 1.63 },
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import type {
13
+ ConditionalValueDef,
13
14
  DataRow,
14
15
  Encoding,
15
16
  LayoutStrategy,
@@ -19,9 +20,9 @@ import type {
19
20
  } from '@opendata-ai/openchart-core';
20
21
  import { abbreviateNumber, formatNumber } from '@opendata-ai/openchart-core';
21
22
  import type { ScaleBand, ScaleLinear } from 'd3-scale';
22
-
23
23
  import type { NormalizedChartSpec } from '../../compiler/types';
24
24
  import type { ResolvedScales } from '../../layout/scales';
25
+ import { isConditionalValueDef, resolveConditionalValue } from '../../transforms/conditional';
25
26
  import { getColor, getSequentialColor, groupByField } from '../utils';
26
27
 
27
28
  // ---------------------------------------------------------------------------
@@ -71,9 +72,14 @@ export function computeColumnMarks(
71
72
 
72
73
  const bandwidth = xScale.bandwidth();
73
74
  const baseline = yScale(0);
74
- const colorField = encoding.color?.field;
75
+ const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
76
+ const conditionalColor =
77
+ encoding.color && isConditionalValueDef(encoding.color)
78
+ ? (encoding.color as ConditionalValueDef)
79
+ : undefined;
80
+ const colorField = colorEnc?.field;
75
81
 
76
- const isSequentialColor = encoding.color?.type === 'quantitative';
82
+ const isSequentialColor = colorEnc?.type === 'quantitative';
77
83
 
78
84
  // Color encoding present: decide between colored simple columns vs stacked
79
85
  if (colorField && !isSequentialColor) {
@@ -119,6 +125,7 @@ export function computeColumnMarks(
119
125
  baseline,
120
126
  scales,
121
127
  isSequentialColor,
128
+ conditionalColor,
122
129
  );
123
130
  }
124
131
 
@@ -133,6 +140,7 @@ function computeSimpleColumns(
133
140
  baseline: number,
134
141
  scales: ResolvedScales,
135
142
  sequentialColor = false,
143
+ conditionalColor?: ConditionalValueDef,
136
144
  ): RectMark[] {
137
145
  const marks: RectMark[] = [];
138
146
 
@@ -144,9 +152,16 @@ function computeSimpleColumns(
144
152
  const bandX = xScale(category);
145
153
  if (bandX === undefined) continue;
146
154
 
147
- const color = sequentialColor
148
- ? getSequentialColor(scales, value)
149
- : getColor(scales, '__default__');
155
+ let color: string;
156
+ if (conditionalColor) {
157
+ color = String(
158
+ resolveConditionalValue(row, conditionalColor) ?? getColor(scales, '__default__'),
159
+ );
160
+ } else if (sequentialColor) {
161
+ color = getSequentialColor(scales, value);
162
+ } else {
163
+ color = getColor(scales, '__default__');
164
+ }
150
165
  const yPos = yScale(value);
151
166
  const columnHeight = Math.max(Math.abs(baseline - yPos), MIN_COLUMN_HEIGHT);
152
167
 
@@ -20,7 +20,8 @@ const fullStrategy: LayoutStrategy = {
20
20
 
21
21
  function makeSimpleDotSpec(): NormalizedChartSpec {
22
22
  return {
23
- type: 'dot',
23
+ markType: 'circle',
24
+ markDef: { type: 'circle' },
24
25
  data: [
25
26
  { country: 'USA', score: 85 },
26
27
  { country: 'UK', score: 72 },
@@ -42,7 +43,8 @@ function makeSimpleDotSpec(): NormalizedChartSpec {
42
43
 
43
44
  function makeColoredDotSpec(): NormalizedChartSpec {
44
45
  return {
45
- type: 'dot',
46
+ markType: 'circle',
47
+ markDef: { type: 'circle' },
46
48
  data: [
47
49
  { item: 'Revenue', value: 120, status: 'good' },
48
50
  { item: 'Costs', value: 80, status: 'neutral' },
@@ -142,7 +144,8 @@ describe('computeDotMarks', () => {
142
144
  describe('dumbbell (multi-series)', () => {
143
145
  function makeDumbbellSpec(): NormalizedChartSpec {
144
146
  return {
145
- type: 'dot',
147
+ markType: 'circle',
148
+ markDef: { type: 'circle' },
146
149
  data: [
147
150
  { country: 'USA', rate: 78, gender: 'Male' },
148
151
  { country: 'USA', rate: 82, gender: 'Female' },
@@ -264,7 +267,8 @@ describe('computeDotMarks', () => {
264
267
  describe('edge cases', () => {
265
268
  it('returns empty array when no x encoding', () => {
266
269
  const spec: NormalizedChartSpec = {
267
- type: 'dot',
270
+ markType: 'circle',
271
+ markDef: { type: 'circle' },
268
272
  data: [{ country: 'USA', score: 85 }],
269
273
  encoding: {
270
274
  y: { field: 'country', type: 'nominal' },
@@ -283,7 +287,8 @@ describe('computeDotMarks', () => {
283
287
 
284
288
  it('returns empty array for empty data', () => {
285
289
  const spec: NormalizedChartSpec = {
286
- type: 'dot',
290
+ markType: 'circle',
291
+ markDef: { type: 'circle' },
287
292
  data: [],
288
293
  encoding: {
289
294
  x: { field: 'score', type: 'quantitative' },
@@ -22,7 +22,7 @@ import type { ScaleBand, ScaleLinear } from 'd3-scale';
22
22
 
23
23
  import type { NormalizedChartSpec } from '../../compiler/types';
24
24
  import type { ResolvedScales } from '../../layout/scales';
25
- import { getColor, groupByField } from '../utils';
25
+ import { getColor, getSequentialColor, groupByField } from '../utils';
26
26
 
27
27
  // ---------------------------------------------------------------------------
28
28
  // Constants
@@ -68,9 +68,11 @@ export function computeDotMarks(
68
68
 
69
69
  const bandwidth = yScale.bandwidth();
70
70
  const baseline = xScale(0);
71
- const colorField = encoding.color?.field;
71
+ const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
72
+ const isSequentialColor = colorEnc?.type === 'quantitative';
73
+ const colorField = isSequentialColor ? undefined : colorEnc?.field;
72
74
 
73
- // Multi-series: dumbbell chart with connecting bars
75
+ // Multi-series (categorical): dumbbell chart with connecting bars
74
76
  if (colorField) {
75
77
  return computeDumbbellMarks(
76
78
  spec.data,
@@ -94,6 +96,7 @@ export function computeDotMarks(
94
96
  bandwidth,
95
97
  baseline,
96
98
  scales,
99
+ isSequentialColor,
97
100
  );
98
101
  }
99
102
 
@@ -198,6 +201,7 @@ function computeLollipopMarks(
198
201
  bandwidth: number,
199
202
  baseline: number,
200
203
  scales: ResolvedScales,
204
+ isSequentialColor = false,
201
205
  ): (PointMark | RectMark)[] {
202
206
  const marks: (PointMark | RectMark)[] = [];
203
207
 
@@ -212,7 +216,9 @@ function computeLollipopMarks(
212
216
  const cx = xScale(value);
213
217
  const cy = bandY + bandwidth / 2;
214
218
 
215
- const color = getColor(scales, '__default__');
219
+ const color = isSequentialColor
220
+ ? getSequentialColor(scales, value)
221
+ : getColor(scales, '__default__');
216
222
 
217
223
  // Stem: thin rectangle from baseline to dot center
218
224
  const stemX = Math.min(baseline, cx);
@@ -28,7 +28,8 @@ const compactStrategy: LayoutStrategy = {
28
28
 
29
29
  function makeSingleSeriesSpec(): NormalizedChartSpec {
30
30
  return {
31
- type: 'line',
31
+ markType: 'line',
32
+ markDef: { type: 'line', point: true },
32
33
  data: [
33
34
  { date: '2020-01-01', value: 10 },
34
35
  { date: '2021-01-01', value: 40 },
@@ -49,7 +50,8 @@ function makeSingleSeriesSpec(): NormalizedChartSpec {
49
50
 
50
51
  function makeMultiSeriesSpec(): NormalizedChartSpec {
51
52
  return {
52
- type: 'line',
53
+ markType: 'line',
54
+ markDef: { type: 'line', point: true },
53
55
  data: [
54
56
  { date: '2020-01-01', value: 10, country: 'US' },
55
57
  { date: '2021-01-01', value: 40, country: 'US' },
@@ -74,7 +76,8 @@ function makeMultiSeriesSpec(): NormalizedChartSpec {
74
76
 
75
77
  function makeMissingDataSpec(): NormalizedChartSpec {
76
78
  return {
77
- type: 'line',
79
+ markType: 'line',
80
+ markDef: { type: 'line', point: true },
78
81
  data: [
79
82
  { date: '2020-01-01', value: 10 },
80
83
  { date: '2021-01-01', value: null },
@@ -144,14 +147,14 @@ describe('computeLineMarks', () => {
144
147
  expect(lineMark.seriesKey).toBeUndefined();
145
148
  });
146
149
 
147
- it('point marks have invisible fill (for hover only)', () => {
150
+ it('visible point marks have filled opacity when point: true', () => {
148
151
  const spec = makeSingleSeriesSpec();
149
152
  const scales = computeScales(spec, chartArea, spec.data);
150
153
  const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
151
154
 
152
155
  const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
153
156
  for (const pm of pointMarks) {
154
- expect(pm.fillOpacity).toBe(0);
157
+ expect(pm.fillOpacity).toBe(1);
155
158
  }
156
159
  });
157
160
  });
@@ -397,7 +400,8 @@ describe('computeLineMarks', () => {
397
400
  describe('edge cases', () => {
398
401
  it('returns empty array when no x encoding', () => {
399
402
  const spec: NormalizedChartSpec = {
400
- type: 'line',
403
+ markType: 'line',
404
+ markDef: { type: 'line', point: true },
401
405
  data: [{ value: 10 }],
402
406
  encoding: {
403
407
  y: { field: 'value', type: 'quantitative' },
@@ -416,7 +420,8 @@ describe('computeLineMarks', () => {
416
420
 
417
421
  it('returns empty array for empty data', () => {
418
422
  const spec: NormalizedChartSpec = {
419
- type: 'line',
423
+ markType: 'line',
424
+ markDef: { type: 'line', point: true },
420
425
  data: [],
421
426
  encoding: {
422
427
  x: { field: 'date', type: 'temporal' },
@@ -542,7 +547,8 @@ describe('computeAreaMarks', () => {
542
547
 
543
548
  it('sorts stacked area with 3+ series and shuffled dates', () => {
544
549
  const spec: NormalizedChartSpec = {
545
- type: 'line',
550
+ markType: 'line',
551
+ markDef: { type: 'line', point: true },
546
552
  data: [
547
553
  { date: '2022-01-01', value: 30, region: 'A' },
548
554
  { date: '2020-01-01', value: 10, region: 'A' },
@@ -618,7 +624,8 @@ describe('computeAreaMarks', () => {
618
624
  // is 300, so the y-scale domain must go up to at least 300. Without the
619
625
  // stacked domain fix, the domain only reaches 100 and the top layers clip.
620
626
  const spec: NormalizedChartSpec = {
621
- type: 'area',
627
+ markType: 'area',
628
+ markDef: { type: 'area' },
622
629
  data: [
623
630
  { date: '2020-01-01', value: 100, group: 'A' },
624
631
  { date: '2021-01-01', value: 100, group: 'A' },
@@ -659,7 +666,8 @@ describe('computeAreaMarks', () => {
659
666
  // should handle this gracefully (empty marks) rather than crashing or
660
667
  // producing NaN-filled paths.
661
668
  const spec: NormalizedChartSpec = {
662
- type: 'area',
669
+ markType: 'area',
670
+ markDef: { type: 'area' },
663
671
  data: [
664
672
  { quarter: '2022-Q1', revenue: 45, segment: 'Services' },
665
673
  { quarter: '2022-Q2', revenue: 52, segment: 'Services' },
@@ -750,7 +758,8 @@ describe('computeLineLabels', () => {
750
758
  it('collision detection resolves overlapping labels', () => {
751
759
  // Create a spec where series end at the same y position
752
760
  const spec: NormalizedChartSpec = {
753
- type: 'line',
761
+ markType: 'line',
762
+ markDef: { type: 'line', point: true },
754
763
  data: [
755
764
  { date: '2020-01-01', value: 10, country: 'A' },
756
765
  { date: '2021-01-01', value: 30, country: 'A' },
@@ -908,3 +917,85 @@ describe('seriesStyles', () => {
908
917
  }
909
918
  });
910
919
  });
920
+
921
+ // ---------------------------------------------------------------------------
922
+ // Sequential (quantitative) color
923
+ // ---------------------------------------------------------------------------
924
+
925
+ describe('sequential color encoding', () => {
926
+ function makeSequentialColorSpec(): NormalizedChartSpec {
927
+ return {
928
+ markType: 'line',
929
+ markDef: { type: 'line' },
930
+ data: [
931
+ { date: '2020-01-01', value: 10 },
932
+ { date: '2021-01-01', value: 40 },
933
+ { date: '2022-01-01', value: 30 },
934
+ ],
935
+ encoding: {
936
+ x: { field: 'date', type: 'temporal' },
937
+ y: { field: 'value', type: 'quantitative' },
938
+ color: { field: 'value', type: 'quantitative' },
939
+ },
940
+ chrome: {},
941
+ annotations: [],
942
+ responsive: true,
943
+ theme: {},
944
+ darkMode: 'off',
945
+ labels: { density: 'auto', format: '' },
946
+ };
947
+ }
948
+
949
+ it('produces a single line mark (no grouping) with sequential color', () => {
950
+ const spec = makeSequentialColorSpec();
951
+ const scales = computeScales(spec, chartArea, spec.data);
952
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
953
+
954
+ const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
955
+ expect(lineMarks).toHaveLength(1);
956
+ // Should not group into multiple series
957
+ expect(lineMarks[0].seriesKey).toBeUndefined();
958
+ });
959
+
960
+ it('auto-shows point marks for sequential color', () => {
961
+ const spec = makeSequentialColorSpec();
962
+ // markDef.point is NOT set, but points should appear anyway for sequential color
963
+ const scales = computeScales(spec, chartArea, spec.data);
964
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
965
+
966
+ const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
967
+ expect(pointMarks).toHaveLength(3);
968
+ // Points should be visible (r > 0)
969
+ expect(pointMarks.every((p) => p.r > 0)).toBe(true);
970
+ });
971
+
972
+ it('assigns different colors to points based on data value', () => {
973
+ const spec = makeSequentialColorSpec();
974
+ const scales = computeScales(spec, chartArea, spec.data);
975
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
976
+
977
+ const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
978
+ // The three points have values 10, 40, 30 so should get distinct colors
979
+ const colors = pointMarks.map((p) => p.fill);
980
+ // Min (10) and max (40) should definitely differ
981
+ expect(colors[0]).not.toBe(colors[1]);
982
+ });
983
+
984
+ it('handles NaN values gracefully in sequential color', () => {
985
+ const spec = makeSequentialColorSpec();
986
+ spec.data = [
987
+ { date: '2020-01-01', value: 10 },
988
+ { date: '2021-01-01', value: 'not a number' },
989
+ { date: '2022-01-01', value: 30 },
990
+ ];
991
+ const scales = computeScales(spec, chartArea, spec.data);
992
+ // Should not throw
993
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
994
+ const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
995
+ // All points should have a valid fill color (string)
996
+ for (const p of pointMarks) {
997
+ expect(typeof p.fill).toBe('string');
998
+ expect(p.fill.length).toBeGreaterThan(0);
999
+ }
1000
+ });
1001
+ });
@@ -0,0 +1,51 @@
1
+ import {
2
+ curveBasis,
3
+ curveCardinal,
4
+ curveLinear,
5
+ curveMonotoneX,
6
+ curveNatural,
7
+ curveStep,
8
+ curveStepAfter,
9
+ curveStepBefore,
10
+ } from 'd3-shape';
11
+ import { describe, expect, it } from 'vitest';
12
+ import { resolveCurve } from '../curves';
13
+
14
+ describe('resolveCurve', () => {
15
+ it('defaults to curveMonotoneX when no interpolation is specified', () => {
16
+ expect(resolveCurve()).toBe(curveMonotoneX);
17
+ expect(resolveCurve(undefined)).toBe(curveMonotoneX);
18
+ });
19
+
20
+ it('maps "linear" to curveLinear', () => {
21
+ expect(resolveCurve('linear')).toBe(curveLinear);
22
+ });
23
+
24
+ it('maps "monotone" to curveMonotoneX', () => {
25
+ expect(resolveCurve('monotone')).toBe(curveMonotoneX);
26
+ });
27
+
28
+ it('maps "step" to curveStep', () => {
29
+ expect(resolveCurve('step')).toBe(curveStep);
30
+ });
31
+
32
+ it('maps "step-before" to curveStepBefore', () => {
33
+ expect(resolveCurve('step-before')).toBe(curveStepBefore);
34
+ });
35
+
36
+ it('maps "step-after" to curveStepAfter', () => {
37
+ expect(resolveCurve('step-after')).toBe(curveStepAfter);
38
+ });
39
+
40
+ it('maps "basis" to curveBasis', () => {
41
+ expect(resolveCurve('basis')).toBe(curveBasis);
42
+ });
43
+
44
+ it('maps "cardinal" to curveCardinal', () => {
45
+ expect(resolveCurve('cardinal')).toBe(curveCardinal);
46
+ });
47
+
48
+ it('maps "natural" to curveNatural', () => {
49
+ expect(resolveCurve('natural')).toBe(curveNatural);
50
+ });
51
+ });
@@ -22,7 +22,8 @@ const compactStrategy: LayoutStrategy = {
22
22
 
23
23
  function makeLine(series: string, color: string, yOffset: number): LineMark {
24
24
  return {
25
- type: 'line',
25
+ markType: 'line',
26
+ markDef: { type: 'line' },
26
27
  points: [
27
28
  { x: 50, y: 100 + yOffset },
28
29
  { x: 150, y: 80 + yOffset },