@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
@@ -6,7 +6,13 @@
6
6
  * nominal/ordinal -> scaleBand() or scaleOrdinal(), depending on context.
7
7
  */
8
8
 
9
- import type { DataRow, Encoding, EncodingChannel, Rect } from '@opendata-ai/openchart-core';
9
+ import type {
10
+ DataRow,
11
+ Encoding,
12
+ EncodingChannel,
13
+ Rect,
14
+ ScaleType,
15
+ } from '@opendata-ai/openchart-core';
10
16
  import { extent, max, min } from 'd3-array';
11
17
  import type {
12
18
  ScaleBand,
@@ -14,9 +20,28 @@ import type {
14
20
  ScaleLogarithmic,
15
21
  ScaleOrdinal,
16
22
  ScalePoint,
23
+ ScalePower,
24
+ ScaleQuantile,
25
+ ScaleQuantize,
26
+ ScaleSymLog,
27
+ ScaleThreshold,
17
28
  ScaleTime,
18
29
  } from 'd3-scale';
19
- import { scaleBand, scaleLinear, scaleLog, scaleOrdinal, scalePoint, scaleTime } from 'd3-scale';
30
+ import {
31
+ scaleBand,
32
+ scaleLinear,
33
+ scaleLog,
34
+ scaleOrdinal,
35
+ scalePoint,
36
+ scalePow,
37
+ scaleQuantile,
38
+ scaleQuantize,
39
+ scaleSqrt,
40
+ scaleSymlog,
41
+ scaleThreshold,
42
+ scaleTime,
43
+ scaleUtc,
44
+ } from 'd3-scale';
20
45
 
21
46
  import type { NormalizedChartSpec } from '../compiler/types';
22
47
 
@@ -24,11 +49,19 @@ import type { NormalizedChartSpec } from '../compiler/types';
24
49
  // Types
25
50
  // ---------------------------------------------------------------------------
26
51
 
27
- /** Continuous D3 scales (linear, time, log) that support .ticks() and .nice(). */
52
+ /** Continuous D3 scales (linear, time, log, pow, sqrt, symlog) that support .ticks() and .nice(). */
28
53
  export type D3ContinuousScale =
29
54
  | ScaleLinear<number, number>
30
55
  | ScaleTime<number, number>
31
- | ScaleLogarithmic<number, number>;
56
+ | ScaleLogarithmic<number, number>
57
+ | ScalePower<number, number>
58
+ | ScaleSymLog<number, number>;
59
+
60
+ /** Discretizing D3 scales (quantile, quantize, threshold). */
61
+ export type D3DiscretizingScale =
62
+ | ScaleQuantile<number>
63
+ | ScaleQuantize<number>
64
+ | ScaleThreshold<number, number>;
32
65
 
33
66
  /** Categorical D3 scales (band, point, ordinal) that support .domain() as string[]. */
34
67
  export type D3CategoricalScale =
@@ -37,11 +70,14 @@ export type D3CategoricalScale =
37
70
  | ScaleOrdinal<string, string>;
38
71
 
39
72
  /** Union of all D3 scale types used by the engine. */
40
- export type D3Scale = D3ContinuousScale | D3CategoricalScale;
73
+ export type D3Scale = D3ContinuousScale | D3CategoricalScale | D3DiscretizingScale;
41
74
 
42
75
  /** A sequential color scale mapping numbers to color strings. */
43
76
  export type D3SequentialColorScale = ScaleLinear<string, string>;
44
77
 
78
+ /** All resolved scale type identifiers. */
79
+ export type ResolvedScaleType = ScaleType | 'sequential';
80
+
45
81
  /**
46
82
  * A resolved scale wrapping a d3 scale with type metadata.
47
83
  * We need to carry the scale type around so axes and marks know
@@ -52,7 +88,7 @@ export interface ResolvedScale {
52
88
  /** The d3 scale function. Maps domain value -> pixel position or color. */
53
89
  scale: D3Scale;
54
90
  /** The scale type for downstream use. */
55
- type: 'linear' | 'time' | 'band' | 'ordinal' | 'point' | 'log' | 'sequential';
91
+ type: ResolvedScaleType;
56
92
  /** The encoding channel this scale was derived from. */
57
93
  channel: EncodingChannel;
58
94
  }
@@ -104,6 +140,24 @@ function uniqueStrings(values: unknown[]): string[] {
104
140
  return result;
105
141
  }
106
142
 
143
+ // ---------------------------------------------------------------------------
144
+ // Helpers: apply common scale config
145
+ // ---------------------------------------------------------------------------
146
+
147
+ /** Apply clamp and reverse config to a continuous scale. */
148
+ function applyContinuousConfig(
149
+ scale: { clamp(v: boolean): unknown; range(): number[]; range(r: number[]): unknown },
150
+ channel: EncodingChannel,
151
+ ): void {
152
+ if (channel.scale?.clamp) {
153
+ scale.clamp(true);
154
+ }
155
+ if (channel.scale?.reverse) {
156
+ const [r0, r1] = scale.range() as number[];
157
+ scale.range([r1, r0]);
158
+ }
159
+ }
160
+
107
161
  // ---------------------------------------------------------------------------
108
162
  // Scale builders
109
163
  // ---------------------------------------------------------------------------
@@ -124,10 +178,32 @@ function buildTimeScale(
124
178
  if (channel.scale?.nice !== false) {
125
179
  scale.nice();
126
180
  }
181
+ applyContinuousConfig(scale, channel);
127
182
 
128
183
  return { scale, type: 'time', channel };
129
184
  }
130
185
 
186
+ function buildUtcScale(
187
+ channel: EncodingChannel,
188
+ data: DataRow[],
189
+ rangeStart: number,
190
+ rangeEnd: number,
191
+ ): ResolvedScale {
192
+ const values = parseDates(fieldValues(data, channel.field));
193
+ const domain = channel.scale?.domain
194
+ ? [new Date(channel.scale.domain[0] as string), new Date(channel.scale.domain[1] as string)]
195
+ : (extent(values) as [Date, Date]);
196
+
197
+ const scale = scaleUtc().domain(domain).range([rangeStart, rangeEnd]);
198
+
199
+ if (channel.scale?.nice !== false) {
200
+ scale.nice();
201
+ }
202
+ applyContinuousConfig(scale, channel);
203
+
204
+ return { scale, type: 'utc', channel };
205
+ }
206
+
131
207
  function buildLinearScale(
132
208
  channel: EncodingChannel,
133
209
  data: DataRow[],
@@ -159,6 +235,7 @@ function buildLinearScale(
159
235
  if (channel.scale?.nice !== false) {
160
236
  scale.nice();
161
237
  }
238
+ applyContinuousConfig(scale, channel);
162
239
 
163
240
  return { scale, type: 'linear', channel };
164
241
  }
@@ -170,14 +247,190 @@ function buildLogScale(
170
247
  rangeEnd: number,
171
248
  ): ResolvedScale {
172
249
  const values = parseNumbers(fieldValues(data, channel.field)).filter((v) => v > 0);
173
- const domainMin = min(values) ?? 1;
174
- const domainMax = max(values) ?? 10;
250
+ const domainMin = channel.scale?.domain
251
+ ? (channel.scale.domain as [number, number])[0]
252
+ : (min(values) ?? 1);
253
+ const domainMax = channel.scale?.domain
254
+ ? (channel.scale.domain as [number, number])[1]
255
+ : (max(values) ?? 10);
175
256
 
176
- const scale = scaleLog().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]).nice();
257
+ const scale = scaleLog().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
258
+
259
+ if (channel.scale?.base !== undefined) {
260
+ scale.base(channel.scale.base);
261
+ }
262
+ if (channel.scale?.nice !== false) {
263
+ scale.nice();
264
+ }
265
+ applyContinuousConfig(scale, channel);
177
266
 
178
267
  return { scale, type: 'log', channel };
179
268
  }
180
269
 
270
+ function buildPowScale(
271
+ channel: EncodingChannel,
272
+ data: DataRow[],
273
+ rangeStart: number,
274
+ rangeEnd: number,
275
+ ): ResolvedScale {
276
+ const values = parseNumbers(fieldValues(data, channel.field));
277
+
278
+ let domainMin: number;
279
+ let domainMax: number;
280
+
281
+ if (channel.scale?.domain) {
282
+ [domainMin, domainMax] = channel.scale.domain as [number, number];
283
+ } else {
284
+ domainMin = min(values) ?? 0;
285
+ domainMax = max(values) ?? 1;
286
+ if (channel.scale?.zero !== false) {
287
+ domainMin = Math.min(0, domainMin);
288
+ domainMax = Math.max(0, domainMax);
289
+ }
290
+ }
291
+
292
+ const scale = scalePow().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
293
+
294
+ if (channel.scale?.exponent !== undefined) {
295
+ scale.exponent(channel.scale.exponent);
296
+ }
297
+ if (channel.scale?.nice !== false) {
298
+ scale.nice();
299
+ }
300
+ applyContinuousConfig(scale, channel);
301
+
302
+ return { scale, type: 'pow', channel };
303
+ }
304
+
305
+ function buildSqrtScale(
306
+ channel: EncodingChannel,
307
+ data: DataRow[],
308
+ rangeStart: number,
309
+ rangeEnd: number,
310
+ ): ResolvedScale {
311
+ const values = parseNumbers(fieldValues(data, channel.field));
312
+
313
+ let domainMin: number;
314
+ let domainMax: number;
315
+
316
+ if (channel.scale?.domain) {
317
+ [domainMin, domainMax] = channel.scale.domain as [number, number];
318
+ } else {
319
+ domainMin = min(values) ?? 0;
320
+ domainMax = max(values) ?? 1;
321
+ if (channel.scale?.zero !== false) {
322
+ domainMin = Math.min(0, domainMin);
323
+ domainMax = Math.max(0, domainMax);
324
+ }
325
+ }
326
+
327
+ const scale = scaleSqrt().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
328
+
329
+ if (channel.scale?.nice !== false) {
330
+ scale.nice();
331
+ }
332
+ applyContinuousConfig(scale, channel);
333
+
334
+ return { scale, type: 'sqrt', channel };
335
+ }
336
+
337
+ function buildSymlogScale(
338
+ channel: EncodingChannel,
339
+ data: DataRow[],
340
+ rangeStart: number,
341
+ rangeEnd: number,
342
+ ): ResolvedScale {
343
+ const values = parseNumbers(fieldValues(data, channel.field));
344
+
345
+ let domainMin: number;
346
+ let domainMax: number;
347
+
348
+ if (channel.scale?.domain) {
349
+ [domainMin, domainMax] = channel.scale.domain as [number, number];
350
+ } else {
351
+ domainMin = min(values) ?? 0;
352
+ domainMax = max(values) ?? 1;
353
+ if (channel.scale?.zero !== false) {
354
+ domainMin = Math.min(0, domainMin);
355
+ domainMax = Math.max(0, domainMax);
356
+ }
357
+ }
358
+
359
+ const scale = scaleSymlog().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
360
+
361
+ if (channel.scale?.constant !== undefined) {
362
+ scale.constant(channel.scale.constant);
363
+ }
364
+ if (channel.scale?.nice !== false) {
365
+ scale.nice();
366
+ }
367
+ applyContinuousConfig(scale, channel);
368
+
369
+ return { scale, type: 'symlog', channel };
370
+ }
371
+
372
+ function buildQuantileScale(
373
+ channel: EncodingChannel,
374
+ data: DataRow[],
375
+ rangeStart: number,
376
+ rangeEnd: number,
377
+ ): ResolvedScale {
378
+ const values = parseNumbers(fieldValues(data, channel.field));
379
+ const range = channel.scale?.range
380
+ ? (channel.scale.range as number[])
381
+ : evenRange(rangeStart, rangeEnd, 4);
382
+
383
+ const scale = scaleQuantile<number>().domain(values).range(range);
384
+
385
+ return { scale: scale as unknown as D3Scale, type: 'quantile', channel };
386
+ }
387
+
388
+ function buildQuantizeScale(
389
+ channel: EncodingChannel,
390
+ data: DataRow[],
391
+ rangeStart: number,
392
+ rangeEnd: number,
393
+ ): ResolvedScale {
394
+ const values = parseNumbers(fieldValues(data, channel.field));
395
+ const domainMin = channel.scale?.domain
396
+ ? (channel.scale.domain as [number, number])[0]
397
+ : (min(values) ?? 0);
398
+ const domainMax = channel.scale?.domain
399
+ ? (channel.scale.domain as [number, number])[1]
400
+ : (max(values) ?? 1);
401
+ const range = channel.scale?.range
402
+ ? (channel.scale.range as number[])
403
+ : evenRange(rangeStart, rangeEnd, 4);
404
+
405
+ const scale = scaleQuantize<number>().domain([domainMin, domainMax]).range(range);
406
+
407
+ return { scale: scale as unknown as D3Scale, type: 'quantize', channel };
408
+ }
409
+
410
+ function buildThresholdScale(
411
+ channel: EncodingChannel,
412
+ _data: DataRow[],
413
+ rangeStart: number,
414
+ rangeEnd: number,
415
+ ): ResolvedScale {
416
+ // Threshold scales require explicit domain breakpoints
417
+ const domainBreaks = channel.scale?.domain ? (channel.scale.domain as number[]) : [0.5];
418
+ const range = channel.scale?.range
419
+ ? (channel.scale.range as number[])
420
+ : evenRange(rangeStart, rangeEnd, domainBreaks.length + 1);
421
+
422
+ const scale = scaleThreshold<number, number>().domain(domainBreaks).range(range);
423
+
424
+ return { scale: scale as unknown as D3Scale, type: 'threshold', channel };
425
+ }
426
+
427
+ /** Generate an evenly-spaced range of `count` values between start and end. */
428
+ function evenRange(start: number, end: number, count: number): number[] {
429
+ if (count <= 1) return [start];
430
+ const step = (end - start) / (count - 1);
431
+ return Array.from({ length: count }, (_, i) => start + step * i);
432
+ }
433
+
181
434
  function buildBandScale(
182
435
  channel: EncodingChannel,
183
436
  data: DataRow[],
@@ -188,7 +441,19 @@ function buildBandScale(
188
441
  ? (channel.scale.domain as string[])
189
442
  : uniqueStrings(fieldValues(data, channel.field));
190
443
 
191
- const scale = scaleBand().domain(values).range([rangeStart, rangeEnd]).padding(0.35);
444
+ const padding = channel.scale?.padding ?? 0.35;
445
+ const scale = scaleBand().domain(values).range([rangeStart, rangeEnd]).padding(padding);
446
+
447
+ if (channel.scale?.paddingInner !== undefined) {
448
+ scale.paddingInner(channel.scale.paddingInner);
449
+ }
450
+ if (channel.scale?.paddingOuter !== undefined) {
451
+ scale.paddingOuter(channel.scale.paddingOuter);
452
+ }
453
+ if (channel.scale?.reverse) {
454
+ const [r0, r1] = scale.range();
455
+ scale.range([r1, r0]);
456
+ }
192
457
 
193
458
  return { scale, type: 'band', channel };
194
459
  }
@@ -203,7 +468,13 @@ function buildPointScale(
203
468
  ? (channel.scale.domain as string[])
204
469
  : uniqueStrings(fieldValues(data, channel.field));
205
470
 
206
- const scale = scalePoint().domain(values).range([rangeStart, rangeEnd]).padding(0.5);
471
+ const padding = channel.scale?.padding ?? 0.5;
472
+ const scale = scalePoint().domain(values).range([rangeStart, rangeEnd]).padding(padding);
473
+
474
+ if (channel.scale?.reverse) {
475
+ const [r0, r1] = scale.range();
476
+ scale.range([r1, r0]);
477
+ }
207
478
 
208
479
  return { scale, type: 'point', channel };
209
480
  }
@@ -261,10 +532,24 @@ function buildPositionalScale(
261
532
  switch (channel.scale.type) {
262
533
  case 'time':
263
534
  return buildTimeScale(channel, data, rangeStart, rangeEnd);
535
+ case 'utc':
536
+ return buildUtcScale(channel, data, rangeStart, rangeEnd);
264
537
  case 'linear':
265
538
  return buildLinearScale(channel, data, rangeStart, rangeEnd);
266
539
  case 'log':
267
540
  return buildLogScale(channel, data, rangeStart, rangeEnd);
541
+ case 'pow':
542
+ return buildPowScale(channel, data, rangeStart, rangeEnd);
543
+ case 'sqrt':
544
+ return buildSqrtScale(channel, data, rangeStart, rangeEnd);
545
+ case 'symlog':
546
+ return buildSymlogScale(channel, data, rangeStart, rangeEnd);
547
+ case 'quantile':
548
+ return buildQuantileScale(channel, data, rangeStart, rangeEnd);
549
+ case 'quantize':
550
+ return buildQuantizeScale(channel, data, rangeStart, rangeEnd);
551
+ case 'threshold':
552
+ return buildThresholdScale(channel, data, rangeStart, rangeEnd);
268
553
  case 'band':
269
554
  return buildBandScale(channel, data, rangeStart, rangeEnd);
270
555
  case 'point':
@@ -282,12 +567,8 @@ function buildPositionalScale(
282
567
  return buildLinearScale(channel, data, rangeStart, rangeEnd);
283
568
  case 'nominal':
284
569
  case 'ordinal':
285
- // Bar/column charts use band scales for their categorical axis
286
- if (
287
- (chartType === 'bar' && axis === 'y') ||
288
- (chartType === 'column' && axis === 'x') ||
289
- (chartType === 'dot' && axis === 'y')
290
- ) {
570
+ // Bar charts use band scales for their categorical axis (both orientations)
571
+ if (chartType === 'bar' || (chartType === 'circle' && axis === 'y')) {
291
572
  return buildBandScale(channel, data, rangeStart, rangeEnd);
292
573
  }
293
574
  return buildPointScale(channel, data, rangeStart, rangeEnd);
@@ -317,7 +598,7 @@ export function computeScales(
317
598
  const encoding = spec.encoding as Encoding;
318
599
 
319
600
  // Scatter/bubble charts should NOT include zero by default (tight domain fits data range)
320
- if (spec.type === 'scatter') {
601
+ if (spec.markType === 'point') {
321
602
  if (encoding.x?.type === 'quantitative' && encoding.x.scale?.zero === undefined) {
322
603
  if (!encoding.x.scale) {
323
604
  (encoding.x as { scale?: Record<string, unknown> }).scale = { zero: false };
@@ -338,7 +619,7 @@ export function computeScales(
338
619
  // For stacked bars, the x-domain needs the max category sum, not max individual value.
339
620
  // Without this, stacked bars would clip past the chart area.
340
621
  let xData = data;
341
- if (spec.type === 'bar' && encoding.color && encoding.x.type === 'quantitative') {
622
+ if (spec.markType === 'bar' && encoding.color && encoding.x.type === 'quantitative') {
342
623
  const yField = encoding.y?.field;
343
624
  const xField = encoding.x.field;
344
625
  if (yField) {
@@ -361,18 +642,23 @@ export function computeScales(
361
642
  xData,
362
643
  chartArea.x,
363
644
  chartArea.x + chartArea.width,
364
- spec.type,
645
+ spec.markType,
365
646
  'x',
366
647
  );
367
648
  }
368
649
 
369
650
  if (encoding.y) {
370
- // For stacked columns and stacked areas, the y-domain needs the max category
371
- // sum, not the max individual value. Without this, stacked marks would clip
372
- // above the chart area.
651
+ // For stacked vertical bars and stacked areas, the y-domain needs the max
652
+ // category sum, not the max individual value. Without this, stacked marks
653
+ // would clip above the chart area.
654
+ // Vertical bar = x is categorical and y is quantitative (old 'column' chart type).
373
655
  let yData = data;
656
+ const isVerticalBar =
657
+ spec.markType === 'bar' &&
658
+ (encoding.x?.type === 'nominal' || encoding.x?.type === 'ordinal') &&
659
+ encoding.y.type === 'quantitative';
374
660
  if (
375
- (spec.type === 'column' || spec.type === 'area') &&
661
+ (isVerticalBar || spec.markType === 'area') &&
376
662
  encoding.color &&
377
663
  encoding.y.type === 'quantitative'
378
664
  ) {
@@ -399,7 +685,7 @@ export function computeScales(
399
685
  yData,
400
686
  chartArea.y + chartArea.height,
401
687
  chartArea.y,
402
- spec.type,
688
+ spec.markType,
403
689
  'y',
404
690
  );
405
691
  }
@@ -418,12 +704,15 @@ export function computeScales(
418
704
  '#858078',
419
705
  ];
420
706
 
421
- if (encoding.color.type === 'quantitative') {
422
- // Sequential color scale for value-based coloring
423
- result.color = buildSequentialColorScale(encoding.color, data, defaultPalette);
424
- } else {
425
- // Categorical color scale for nominal/ordinal grouping
426
- result.color = buildOrdinalColorScale(encoding.color, data, defaultPalette);
707
+ // Only build color scales for field-based encodings, not conditional value defs
708
+ if ('field' in encoding.color) {
709
+ if (encoding.color.type === 'quantitative') {
710
+ // Sequential color scale for value-based coloring
711
+ result.color = buildSequentialColorScale(encoding.color, data, defaultPalette);
712
+ } else {
713
+ // Categorical color scale for nominal/ordinal grouping
714
+ result.color = buildOrdinalColorScale(encoding.color, data, defaultPalette);
715
+ }
427
716
  }
428
717
  }
429
718
 
@@ -44,13 +44,13 @@ const TOP_LEGEND_MAX_ROWS = 2;
44
44
  // Helpers
45
45
  // ---------------------------------------------------------------------------
46
46
 
47
- /** Determine the swatch shape based on chart type. */
48
- function swatchShapeForType(chartType: string): LegendEntry['shape'] {
49
- switch (chartType) {
47
+ /** Determine the swatch shape based on mark type. */
48
+ function swatchShapeForType(markType: string): LegendEntry['shape'] {
49
+ switch (markType) {
50
50
  case 'line':
51
51
  return 'line';
52
- case 'scatter':
53
- case 'dot':
52
+ case 'point':
53
+ case 'circle':
54
54
  return 'circle';
55
55
  default:
56
56
  return 'square';
@@ -62,12 +62,15 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
62
62
  const colorEnc = spec.encoding.color;
63
63
  if (!colorEnc) return [];
64
64
 
65
+ // Conditional color definitions don't produce legend entries
66
+ if ('condition' in colorEnc) return [];
67
+
65
68
  // Sequential (quantitative) color doesn't produce discrete legend entries
66
69
  if (colorEnc.type === 'quantitative') return [];
67
70
 
68
71
  const uniqueValues = [...new Set(spec.data.map((d) => String(d[colorEnc.field])))];
69
72
  const palette = theme.colors.categorical;
70
- const shape = swatchShapeForType(spec.type);
73
+ const shape = swatchShapeForType(spec.markType);
71
74
 
72
75
  return uniqueValues.map((value, i) => ({
73
76
  label: value,
@@ -154,7 +154,7 @@ describe('compileTable', () => {
154
154
  expect(() =>
155
155
  compileTable(
156
156
  {
157
- type: 'line',
157
+ mark: 'line',
158
158
  data: [
159
159
  { date: '2020-01-01', y: 2 },
160
160
  { date: '2021-01-01', y: 4 },
@@ -11,7 +11,8 @@ const _chartArea: Rect = { x: 50, y: 20, width: 500, height: 300 };
11
11
 
12
12
  function makeLineSpec(): NormalizedChartSpec {
13
13
  return {
14
- type: 'line',
14
+ markType: 'line',
15
+ markDef: { type: 'line' },
15
16
  data: [
16
17
  { date: '2020-01-01', value: 10, country: 'US' },
17
18
  { date: '2021-01-01', value: 40, country: 'US' },
@@ -34,7 +35,8 @@ function makeLineSpec(): NormalizedChartSpec {
34
35
 
35
36
  function makeBarSpec(): NormalizedChartSpec {
36
37
  return {
37
- type: 'bar',
38
+ markType: 'bar',
39
+ markDef: { type: 'bar' },
38
40
  data: [
39
41
  { category: 'A', value: 100 },
40
42
  { category: 'B', value: 200 },
@@ -55,7 +57,8 @@ function makeBarSpec(): NormalizedChartSpec {
55
57
 
56
58
  function makePieSpec(): NormalizedChartSpec {
57
59
  return {
58
- type: 'pie',
60
+ markType: 'arc',
61
+ markDef: { type: 'arc' },
59
62
  data: [
60
63
  { segment: 'Alpha', amount: 30 },
61
64
  { segment: 'Beta', amount: 50 },
@@ -282,7 +285,8 @@ describe('computeTooltipDescriptors', () => {
282
285
  const spec = makeLineSpec();
283
286
  const marks: Mark[] = [
284
287
  {
285
- type: 'line',
288
+ markType: 'line',
289
+ markDef: { type: 'line' },
286
290
  points: [
287
291
  { x: 100, y: 200 },
288
292
  { x: 300, y: 100 },
@@ -302,7 +306,8 @@ describe('computeTooltipDescriptors', () => {
302
306
  const spec = makeLineSpec();
303
307
  const marks: Mark[] = [
304
308
  {
305
- type: 'area',
309
+ markType: 'area',
310
+ markDef: { type: 'area' },
306
311
  topPoints: [{ x: 100, y: 200 }],
307
312
  bottomPoints: [{ x: 100, y: 300 }],
308
313
  path: 'M0,0',
@@ -70,8 +70,8 @@ function buildFields(row: DataRow, encoding: Encoding, color?: string): TooltipF
70
70
  });
71
71
  }
72
72
 
73
- // Size (for scatter/bubble)
74
- if (encoding.size) {
73
+ // Size (for scatter/bubble) - skip conditional size definitions
74
+ if (encoding.size && 'field' in encoding.size) {
75
75
  fields.push({
76
76
  label: encoding.size.axis?.label ?? encoding.size.field,
77
77
  value: formatValue(row[encoding.size.field], encoding.size.type, encoding.size.axis?.format),
@@ -103,8 +103,8 @@ function getTooltipTitle(row: DataRow, encoding: Encoding): string | undefined {
103
103
  return String(row[encoding.y.field] ?? '');
104
104
  }
105
105
 
106
- // For color-encoded series, use the series name
107
- if (encoding.color) {
106
+ // For color-encoded series, use the series name (skip conditional defs)
107
+ if (encoding.color && 'field' in encoding.color) {
108
108
  return String(row[encoding.color.field] ?? '');
109
109
  }
110
110
 
@@ -116,12 +116,20 @@ function getTooltipTitle(row: DataRow, encoding: Encoding): string | undefined {
116
116
  // ---------------------------------------------------------------------------
117
117
 
118
118
  function tooltipsForLine(
119
- _mark: LineMark,
120
- _encoding: Encoding,
119
+ mark: LineMark,
120
+ encoding: Encoding,
121
121
  _markIndex: number,
122
122
  ): Array<[string, TooltipContent]> {
123
- // Line marks themselves don't get individual tooltips.
124
- // The point marks at each data point handle that.
123
+ // Populate tooltip content on each dataPoint for voronoi overlay lookup.
124
+ // Line marks don't emit per-mark tooltip descriptors (the overlay handles it).
125
+ if (mark.dataPoints) {
126
+ for (const dp of mark.dataPoints) {
127
+ dp.tooltip = {
128
+ title: getTooltipTitle(dp.datum, encoding),
129
+ fields: buildFields(dp.datum, encoding, mark.stroke),
130
+ };
131
+ }
132
+ }
125
133
  return [];
126
134
  }
127
135
 
@@ -156,8 +164,9 @@ function tooltipsForArc(
156
164
  const fields: TooltipField[] = [];
157
165
 
158
166
  // For pie/donut, show the category and its value
159
- if (encoding.color) {
160
- const categoryName = String(row[encoding.color.field] ?? '');
167
+ const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
168
+ if (colorEnc) {
169
+ const categoryName = String(row[colorEnc.field] ?? '');
161
170
  if (encoding.y) {
162
171
  fields.push({
163
172
  label: categoryName,
@@ -173,17 +182,26 @@ function tooltipsForArc(
173
182
  });
174
183
  }
175
184
 
176
- const title = encoding.color ? String(row[encoding.color.field] ?? '') : undefined;
185
+ const title = colorEnc ? String(row[colorEnc.field] ?? '') : undefined;
177
186
 
178
187
  return [[`arc-${markIndex}`, { title, fields }]];
179
188
  }
180
189
 
181
190
  function tooltipsForArea(
182
- _mark: AreaMark,
183
- _encoding: Encoding,
191
+ mark: AreaMark,
192
+ encoding: Encoding,
184
193
  _markIndex: number,
185
194
  ): Array<[string, TooltipContent]> {
186
- // Area marks are background fills; point marks on top handle tooltips.
195
+ // Populate tooltip content on each dataPoint for voronoi overlay lookup.
196
+ // Area marks don't emit per-mark tooltip descriptors (the overlay handles it).
197
+ if (mark.dataPoints) {
198
+ for (const dp of mark.dataPoints) {
199
+ dp.tooltip = {
200
+ title: getTooltipTitle(dp.datum, encoding),
201
+ fields: buildFields(dp.datum, encoding, mark.fill),
202
+ };
203
+ }
204
+ }
187
205
  return [];
188
206
  }
189
207