@opendata-ai/openchart-engine 6.28.6 → 7.0.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.
Files changed (46) hide show
  1. package/README.md +1 -0
  2. package/dist/index.d.ts +8 -11
  3. package/dist/index.js +12296 -11337
  4. package/dist/index.js.map +1 -1
  5. package/package.json +2 -2
  6. package/src/__test-fixtures__/specs.ts +3 -0
  7. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +452 -377
  8. package/src/__tests__/axes.test.ts +75 -0
  9. package/src/__tests__/compile-chart.test.ts +304 -0
  10. package/src/__tests__/dimensions.test.ts +224 -0
  11. package/src/__tests__/legend.test.ts +44 -3
  12. package/src/annotations/__tests__/compute.test.ts +111 -0
  13. package/src/annotations/__tests__/resolve-text.test.ts +288 -0
  14. package/src/annotations/constants.ts +20 -0
  15. package/src/annotations/resolve-text.ts +161 -7
  16. package/src/charts/bar/compute.ts +24 -0
  17. package/src/charts/bar/labels.ts +1 -0
  18. package/src/charts/column/compute.ts +33 -1
  19. package/src/charts/column/labels.ts +1 -0
  20. package/src/charts/dot/labels.ts +1 -0
  21. package/src/charts/line/__tests__/compute.test.ts +153 -3
  22. package/src/charts/line/area.ts +111 -23
  23. package/src/charts/line/compute.ts +40 -10
  24. package/src/charts/line/index.ts +34 -7
  25. package/src/charts/line/labels.ts +29 -0
  26. package/src/charts/pie/labels.ts +1 -0
  27. package/src/compile/layer.ts +497 -0
  28. package/src/compile.ts +211 -586
  29. package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
  30. package/src/compiler/normalize.ts +6 -1
  31. package/src/compiler/sparkline-defaults.ts +138 -0
  32. package/src/compiler/types.ts +8 -0
  33. package/src/endpoint-labels/__tests__/compute.test.ts +438 -0
  34. package/src/endpoint-labels/compute.ts +417 -0
  35. package/src/endpoint-labels/constants.ts +54 -0
  36. package/src/endpoint-labels/format.ts +30 -0
  37. package/src/endpoint-labels/predict.ts +108 -0
  38. package/src/graphs/compile-graph.ts +1 -0
  39. package/src/layout/axes.ts +27 -2
  40. package/src/layout/dimensions.ts +270 -33
  41. package/src/layout/metrics.ts +118 -0
  42. package/src/layout/scales.ts +41 -4
  43. package/src/legend/__tests__/suppression.test.ts +294 -0
  44. package/src/legend/compute.ts +50 -40
  45. package/src/legend/suppression.ts +204 -0
  46. package/src/sankey/compile-sankey.ts +2 -0
@@ -3,24 +3,40 @@
3
3
  * optional callout connector to the data point.
4
4
  */
5
5
 
6
- import type {
7
- Rect,
8
- ResolvedAnnotation,
9
- ResolvedLabel,
10
- TextAnnotation,
11
- TextStyle,
6
+ import {
7
+ estimateTextWidth,
8
+ type Rect,
9
+ type ResolvedAnnotation,
10
+ type ResolvedLabel,
11
+ type TextAnnotation,
12
+ type TextStyle,
12
13
  } from '@opendata-ai/openchart-core';
13
14
  import type { ResolvedScales } from '../layout/scales';
14
15
  import {
16
+ DARK_DOT_FILL,
17
+ DARK_MUTED_TEXT_FILL,
15
18
  DARK_TEXT_FILL,
16
19
  DEFAULT_ANNOTATION_FONT_SIZE,
17
20
  DEFAULT_ANNOTATION_FONT_WEIGHT,
21
+ DEFAULT_DOT_RADIUS,
22
+ DEFAULT_DOT_STROKE_WIDTH,
18
23
  DEFAULT_LINE_HEIGHT,
24
+ LIGHT_DOT_FILL,
25
+ LIGHT_MUTED_TEXT_FILL,
19
26
  LIGHT_TEXT_FILL,
27
+ SUBTITLE_FONT_SIZE_RATIO,
28
+ SUBTITLE_GAP,
20
29
  } from './constants';
21
30
  import { applyOffset, computeAnchorOffset, computeConnectorOrigin } from './geometry';
22
31
  import { resolvePosition } from './position';
23
32
 
33
+ /** Horizontal gap between the drop-line and the label text. */
34
+ const DROP_LINE_LABEL_GAP = 8;
35
+ /** Vertical gap between the top of the drop-line and the top of the label box. */
36
+ const DROP_LINE_TOP_GAP = 4;
37
+ /** Vertical gap between the bottom of the drop-line and the data point. */
38
+ const DROP_LINE_BOTTOM_GAP = 4;
39
+
24
40
  export function makeAnnotationLabelStyle(
25
41
  fontSize?: number,
26
42
  fontWeight?: number,
@@ -58,6 +74,13 @@ export function resolveTextAnnotation(
58
74
  isDark,
59
75
  );
60
76
 
77
+ // Drop-line connector: vertical line through the data point's x with the
78
+ // label sitting flush beside it. Auto-flips to the opposite side if the
79
+ // chosen side would overflow the chart area.
80
+ if (annotation.connector === 'drop-line') {
81
+ return resolveDropLineAnnotation(annotation, px, py, chartArea, labelStyle, defaultTextFill);
82
+ }
83
+
61
84
  // Compute position from anchor direction + user offset
62
85
  const anchorDelta = computeAnchorOffset(annotation.anchor, px, py, chartArea);
63
86
  const finalDelta = applyOffset(anchorDelta, annotation.offset);
@@ -67,7 +90,8 @@ export function resolveTextAnnotation(
67
90
 
68
91
  // Connector: draw unless explicitly disabled
69
92
  const showConnector = annotation.connector !== false;
70
- const connectorStyle = annotation.connector === 'curve' ? 'curve' : 'straight';
93
+ const connectorStyle: 'straight' | 'curve' =
94
+ annotation.connector === 'curve' ? 'curve' : 'straight';
71
95
 
72
96
  // Compute connector origin: pick the edge midpoint closest to the data point
73
97
  const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
@@ -116,6 +140,7 @@ export function resolveTextAnnotation(
116
140
  ? {
117
141
  from: adjustedFrom,
118
142
  to: adjustedTo,
143
+ endpoint: { x: px, y: py },
119
144
  stroke: annotation.stroke ?? '#999999',
120
145
  style: connectorStyle,
121
146
  }
@@ -124,6 +149,135 @@ export function resolveTextAnnotation(
124
149
  halo: annotation.halo,
125
150
  };
126
151
 
152
+ // Resolve dot marker. Uses the connector's "to" endpoint coordinates
153
+ // (post user-supplied connectorOffset.to) so it sits exactly where the
154
+ // connector terminates at the data point.
155
+ let dot: ResolvedAnnotation['dot'] | undefined;
156
+ if (annotation.dot) {
157
+ const dotConfig = typeof annotation.dot === 'object' ? annotation.dot : {};
158
+ const defaultDotFill = isDark ? DARK_DOT_FILL : LIGHT_DOT_FILL;
159
+ const defaultDotStroke = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
160
+ dot = {
161
+ x: adjustedTo.x,
162
+ y: adjustedTo.y,
163
+ radius: dotConfig.radius ?? DEFAULT_DOT_RADIUS,
164
+ fill: dotConfig.fill ?? defaultDotFill,
165
+ stroke: dotConfig.stroke ?? defaultDotStroke,
166
+ strokeWidth: dotConfig.strokeWidth ?? DEFAULT_DOT_STROKE_WIDTH,
167
+ };
168
+ }
169
+
170
+ // Resolve subtitle. Positioned below the primary text block by
171
+ // (lineHeight * primaryLineCount * fontSize) + gap.
172
+ let subtitle: ResolvedAnnotation['subtitle'] | undefined;
173
+ if (annotation.subtitle) {
174
+ const primaryLineCount = annotation.text.split('\n').length;
175
+ const subtitleFontSize = Math.round(fontSize * SUBTITLE_FONT_SIZE_RATIO);
176
+ const mutedFill = isDark ? DARK_MUTED_TEXT_FILL : LIGHT_MUTED_TEXT_FILL;
177
+ const subtitleStyle: TextStyle = {
178
+ ...labelStyle,
179
+ fontSize: subtitleFontSize,
180
+ fontWeight: DEFAULT_ANNOTATION_FONT_WEIGHT,
181
+ fill: mutedFill,
182
+ };
183
+ const subtitleY = labelY + fontSize * DEFAULT_LINE_HEIGHT * primaryLineCount + SUBTITLE_GAP;
184
+ subtitle = {
185
+ text: annotation.subtitle,
186
+ x: labelX,
187
+ y: subtitleY,
188
+ style: subtitleStyle,
189
+ };
190
+ }
191
+
192
+ return {
193
+ type: 'text',
194
+ id: annotation.id,
195
+ label,
196
+ stroke: annotation.stroke,
197
+ fill: annotation.fill,
198
+ opacity: annotation.opacity,
199
+ zIndex: annotation.zIndex,
200
+ dot,
201
+ subtitle,
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Resolve a drop-line text annotation. The connector is a vertical line through
207
+ * the data point's x. The label sits beside the line, anchored toward the chosen
208
+ * side (left or right), and auto-flips if the chosen side would overflow.
209
+ */
210
+ function resolveDropLineAnnotation(
211
+ annotation: TextAnnotation,
212
+ px: number,
213
+ py: number,
214
+ chartArea: Rect,
215
+ labelStyle: TextStyle,
216
+ defaultTextFill: string,
217
+ ): ResolvedAnnotation {
218
+ const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
219
+ const fontWeight = annotation.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
220
+ const lines = annotation.text.split('\n');
221
+ const estimatedWidth = Math.max(
222
+ 0,
223
+ ...lines.map((line) => estimateTextWidth(line, fontSize, fontWeight)),
224
+ );
225
+
226
+ // Pick initial side from anchor; default to 'left' (label sits to the left
227
+ // of the drop-line) when not specified.
228
+ let side: 'left' | 'right' = annotation.anchor === 'right' ? 'right' : 'left';
229
+
230
+ // Auto-flip: if the chosen side would push the label past the chart-area edge,
231
+ // flip to the other side. Compare the estimated label width against the
232
+ // available space on each side. When neither side fits cleanly, fall back to
233
+ // whichever side has more room — graceful degradation beats silent overflow.
234
+ const spaceLeft = px - chartArea.x - DROP_LINE_LABEL_GAP;
235
+ const spaceRight = chartArea.x + chartArea.width - px - DROP_LINE_LABEL_GAP;
236
+ const fitsLeft = estimatedWidth <= spaceLeft;
237
+ const fitsRight = estimatedWidth <= spaceRight;
238
+ if (side === 'left' && !fitsLeft) {
239
+ side = fitsRight || spaceRight > spaceLeft ? 'right' : 'left';
240
+ } else if (side === 'right' && !fitsRight) {
241
+ side = fitsLeft || spaceLeft > spaceRight ? 'left' : 'right';
242
+ }
243
+
244
+ const labelX = side === 'left' ? px - DROP_LINE_LABEL_GAP : px + DROP_LINE_LABEL_GAP;
245
+ const textAnchor: 'start' | 'end' = side === 'left' ? 'end' : 'start';
246
+
247
+ // Drop the label box top a bit above the data point so the label and line
248
+ // share a baseline that reads as "callout above the point".
249
+ const lineHeight = fontSize * DEFAULT_LINE_HEIGHT;
250
+ const totalHeight = lineHeight * lines.length;
251
+ // Position the first line so the bottom of the label sits ~12px above py.
252
+ // Clamp to the chart-area top so multi-line labels near peaks don't escape
253
+ // upward into chrome / metric-bar territory.
254
+ const desiredLabelTopY = py - totalHeight - 12;
255
+ const minLabelTopY = chartArea.y + 4;
256
+ const labelTopY = Math.max(desiredLabelTopY, minLabelTopY);
257
+ const labelBaselineY = labelTopY + fontSize;
258
+
259
+ const resolvedStyle: TextStyle = { ...labelStyle, textAnchor };
260
+
261
+ const from = { x: px, y: labelTopY - DROP_LINE_TOP_GAP };
262
+ const to = { x: px, y: py - DROP_LINE_BOTTOM_GAP };
263
+
264
+ const label: ResolvedLabel = {
265
+ text: annotation.text,
266
+ x: labelX,
267
+ y: labelBaselineY,
268
+ style: resolvedStyle,
269
+ visible: true,
270
+ connector: {
271
+ from,
272
+ to,
273
+ endpoint: { x: px, y: py },
274
+ stroke: annotation.stroke ?? defaultTextFill,
275
+ style: 'drop-line',
276
+ },
277
+ background: annotation.background,
278
+ halo: annotation.halo,
279
+ };
280
+
127
281
  return {
128
282
  type: 'text',
129
283
  id: annotation.id,
@@ -372,6 +372,19 @@ function applyMarkDefOverrides(
372
372
 
373
373
  if (fixedSize == null && crSpec == null) return marks;
374
374
 
375
+ // Identify the rightmost segment per stackGroup (largest `x + width`).
376
+ // Only that segment receives the corner rounding so the seams between
377
+ // stacked segments stay square and flush.
378
+ const rightPerStack = new Map<string, RectMark>();
379
+ for (const mark of marks) {
380
+ if (mark.stackGroup === undefined) continue;
381
+ const current = rightPerStack.get(mark.stackGroup);
382
+ const markRight = mark.x + mark.width;
383
+ if (!current || markRight > current.x + current.width) {
384
+ rightPerStack.set(mark.stackGroup, mark);
385
+ }
386
+ }
387
+
375
388
  for (const mark of marks) {
376
389
  if (fixedSize != null && mark.stackGroup === undefined) {
377
390
  const barHeight = Math.min(fixedSize, bandwidth);
@@ -380,10 +393,21 @@ function applyMarkDefOverrides(
380
393
  mark.height = barHeight;
381
394
  }
382
395
  const effectiveHeight = mark.height;
396
+ const isStacked = mark.stackGroup !== undefined;
397
+ const isStackRight = isStacked && rightPerStack.get(mark.stackGroup!) === mark;
398
+
399
+ if (isStacked && !isStackRight) continue;
400
+
383
401
  if (crSpec === 'pill') {
384
402
  mark.cornerRadius = effectiveHeight / 2;
385
403
  } else if (typeof crSpec === 'number') {
386
404
  mark.cornerRadius = crSpec;
405
+ } else {
406
+ continue;
407
+ }
408
+
409
+ if (isStackRight) {
410
+ mark.cornerRadiusSides = { tl: false, tr: true, br: true, bl: false };
387
411
  }
388
412
  }
389
413
  return marks;
@@ -119,6 +119,7 @@ export function computeBarLabels(
119
119
  } else {
120
120
  // Fallback: extract from aria label
121
121
  const ariaLabel = mark.aria.label;
122
+ if (!ariaLabel) continue;
122
123
  const lastColon = ariaLabel.lastIndexOf(':');
123
124
  const rawValue = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
124
125
  if (!rawValue) continue;
@@ -68,7 +68,14 @@ export function computeColumnMarks(
68
68
  }
69
69
 
70
70
  const bandwidth = xScale.bandwidth();
71
- const baseline = yScale(0);
71
+ // Baseline = pixel y where the column's bottom edge anchors. When the
72
+ // y-domain includes zero (the common case), this is yScale(0). Sparkline
73
+ // mode tightens the domain to [min, max] (zero: false) so yScale(0) lands
74
+ // outside the chart area; in that case we anchor to the bottom of the
75
+ // y-range instead, otherwise every bar would render with the same height.
76
+ const yDomain = yScale.domain() as [number, number];
77
+ const yIncludesZero = yDomain[0] <= 0 && yDomain[1] >= 0;
78
+ const baseline = yIncludesZero ? yScale(0) : yScale(yDomain[0]);
72
79
  const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
73
80
  const conditionalColor =
74
81
  encoding.color && isConditionalValueDef(encoding.color)
@@ -423,6 +430,18 @@ function applyMarkDefOverrides(
423
430
 
424
431
  if (fixedSize == null && crSpec == null) return marks;
425
432
 
433
+ // Identify the topmost segment per stackGroup (smallest `y` since SVG
434
+ // grows downward). Only that segment receives the corner rounding so
435
+ // the seams between stacked segments stay square and flush.
436
+ const topPerStack = new Map<string, RectMark>();
437
+ for (const mark of marks) {
438
+ if (mark.stackGroup === undefined) continue;
439
+ const current = topPerStack.get(mark.stackGroup);
440
+ if (!current || mark.y < current.y) {
441
+ topPerStack.set(mark.stackGroup, mark);
442
+ }
443
+ }
444
+
426
445
  for (const mark of marks) {
427
446
  if (fixedSize != null && mark.stackGroup === undefined) {
428
447
  const barWidth = Math.min(fixedSize, bandwidth);
@@ -431,10 +450,23 @@ function applyMarkDefOverrides(
431
450
  mark.width = barWidth;
432
451
  }
433
452
  const effectiveWidth = mark.width;
453
+ const isStacked = mark.stackGroup !== undefined;
454
+ const isStackTop = isStacked && topPerStack.get(mark.stackGroup!) === mark;
455
+
456
+ // Stacked segments below the top stay square. Stack top rounds only its
457
+ // top corners; non-stacked bars round all four.
458
+ if (isStacked && !isStackTop) continue;
459
+
434
460
  if (crSpec === 'pill') {
435
461
  mark.cornerRadius = effectiveWidth / 2;
436
462
  } else if (typeof crSpec === 'number') {
437
463
  mark.cornerRadius = crSpec;
464
+ } else {
465
+ continue;
466
+ }
467
+
468
+ if (isStackTop) {
469
+ mark.cornerRadiusSides = { tl: true, tr: true, br: false, bl: false };
438
470
  }
439
471
  }
440
472
 
@@ -72,6 +72,7 @@ export function computeColumnLabels(
72
72
  } else {
73
73
  // Fallback: extract from aria label
74
74
  const ariaLabel = mark.aria.label;
75
+ if (!ariaLabel) continue;
75
76
  const lastColon = ariaLabel.lastIndexOf(':');
76
77
  const rawValue = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
77
78
  if (!rawValue) continue;
@@ -70,6 +70,7 @@ export function computeDotLabels(
70
70
  } else {
71
71
  // Fallback: extract from aria label
72
72
  const ariaLabel = mark.aria.label;
73
+ if (!ariaLabel) continue;
73
74
  const lastColon = ariaLabel.lastIndexOf(':');
74
75
  valuePart = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
75
76
  if (!valuePart) continue;
@@ -505,8 +505,9 @@ describe('computeAreaMarks', () => {
505
505
  }
506
506
  });
507
507
 
508
- it('stacked areas: produces multiple AreaMarks for multi-series', () => {
508
+ it('stacked areas: produces multiple AreaMarks for multi-series with stack: "zero"', () => {
509
509
  const spec = makeMultiSeriesSpec();
510
+ spec.encoding.y!.stack = 'zero';
510
511
  const scales = computeScales(spec, chartArea, spec.data);
511
512
  const marks = computeAreaMarks(spec, scales, chartArea);
512
513
 
@@ -516,6 +517,129 @@ describe('computeAreaMarks', () => {
516
517
  expect(seriesKeys).toContain('UK');
517
518
  });
518
519
 
520
+ it('overlap (default): produces multiple AreaMarks for multi-series', () => {
521
+ // v6 default: multi-series with color but no `stack` overlaps instead of stacking.
522
+ const spec = makeMultiSeriesSpec();
523
+ const scales = computeScales(spec, chartArea, spec.data);
524
+ const marks = computeAreaMarks(spec, scales, chartArea);
525
+
526
+ expect(marks).toHaveLength(2);
527
+ const seriesKeys = marks.map((m) => m.seriesKey).filter(Boolean);
528
+ expect(seriesKeys).toContain('US');
529
+ expect(seriesKeys).toContain('UK');
530
+ });
531
+
532
+ it('overlap: every series shares the same baseline (no stacking offset)', () => {
533
+ const spec = makeMultiSeriesSpec();
534
+ const scales = computeScales(spec, chartArea, spec.data);
535
+ const marks = computeAreaMarks(spec, scales, chartArea);
536
+
537
+ // All bottom points across all series should be at the same baseline.
538
+ const baselines = new Set<number>();
539
+ for (const mark of marks) {
540
+ for (const p of mark.bottomPoints) {
541
+ baselines.add(p.y);
542
+ }
543
+ }
544
+ expect(baselines.size).toBe(1);
545
+ });
546
+
547
+ it('overlap: each series uses a translucent gradient fill', () => {
548
+ const spec = makeMultiSeriesSpec();
549
+ const scales = computeScales(spec, chartArea, spec.data);
550
+ const marks = computeAreaMarks(spec, scales, chartArea);
551
+
552
+ expect(marks.length).toBeGreaterThan(0);
553
+ for (const mark of marks) {
554
+ expect(typeof mark.fill).toBe('object');
555
+ const fill = mark.fill as { gradient: string; stops: { opacity?: number }[] };
556
+ expect(fill.gradient).toBe('linear');
557
+ // Overlap stops are calibrated lower than solo stops so layered bands stay legible.
558
+ expect(fill.stops[0].opacity).toBe(0.22);
559
+ expect(fill.stops[fill.stops.length - 1].opacity).toBe(0);
560
+ }
561
+ });
562
+
563
+ it('overlap: stack: null is treated the same as undefined (overlap)', () => {
564
+ const spec = makeMultiSeriesSpec();
565
+ spec.encoding.y!.stack = null;
566
+ const scales = computeScales(spec, chartArea, spec.data);
567
+ const marks = computeAreaMarks(spec, scales, chartArea);
568
+
569
+ expect(marks).toHaveLength(2);
570
+ // Same baseline -> overlap, not stacked
571
+ const baselines = new Set(marks.flatMap((m) => m.bottomPoints.map((p) => p.y)));
572
+ expect(baselines.size).toBe(1);
573
+ });
574
+
575
+ it('overlap: stack: false is treated the same as undefined (overlap)', () => {
576
+ const spec = makeMultiSeriesSpec();
577
+ spec.encoding.y!.stack = false;
578
+ const scales = computeScales(spec, chartArea, spec.data);
579
+ const marks = computeAreaMarks(spec, scales, chartArea);
580
+
581
+ expect(marks).toHaveLength(2);
582
+ const baselines = new Set(marks.flatMap((m) => m.bottomPoints.map((p) => p.y)));
583
+ expect(baselines.size).toBe(1);
584
+ });
585
+
586
+ it('stack: true opts into stacked rendering', () => {
587
+ const spec = makeMultiSeriesSpec();
588
+ spec.encoding.y!.stack = true;
589
+ const scales = computeScales(spec, chartArea, spec.data);
590
+ const marks = computeAreaMarks(spec, scales, chartArea);
591
+
592
+ expect(marks).toHaveLength(2);
593
+ // Stacked layers should have different baselines (one stacks on top of the other)
594
+ const firstBottom = marks[0].bottomPoints[0]?.y;
595
+ const secondBottom = marks[1].bottomPoints[0]?.y;
596
+ expect(firstBottom).not.toBe(secondBottom);
597
+ });
598
+
599
+ it('solo (single series) uses the richer solo gradient', () => {
600
+ const spec = makeSingleSeriesSpec();
601
+ const scales = computeScales(spec, chartArea, spec.data);
602
+ const marks = computeAreaMarks(spec, scales, chartArea);
603
+
604
+ expect(marks).toHaveLength(1);
605
+ const fill = marks[0].fill as { gradient: string; stops: { opacity?: number }[] };
606
+ expect(fill.gradient).toBe('linear');
607
+ // Solo stops are heavier than overlap stops since there's no overlap.
608
+ expect(fill.stops[0].opacity).toBe(0.42);
609
+ });
610
+
611
+ it('stacked areas use a top-to-bottom gradient fill (not flat opacity)', () => {
612
+ const spec = makeMultiSeriesSpec();
613
+ spec.encoding.y!.stack = 'zero';
614
+ const scales = computeScales(spec, chartArea, spec.data);
615
+ const marks = computeAreaMarks(spec, scales, chartArea);
616
+
617
+ expect(marks.length).toBeGreaterThan(0);
618
+ for (const mark of marks) {
619
+ const fill = mark.fill as { gradient: string; stops: { opacity?: number }[] };
620
+ expect(fill.gradient).toBe('linear');
621
+ expect(fill.stops).toHaveLength(2);
622
+ expect(fill.stops[0].opacity).toBe(0.65);
623
+ expect(fill.stops[1].opacity).toBe(0.35);
624
+ // fillOpacity should be 1 so gradient stop-opacity controls the fade
625
+ expect(mark.fillOpacity).toBe(1);
626
+ }
627
+ });
628
+
629
+ it('stacked: markDef.fill string still overrides per-layer gradient', () => {
630
+ const spec = makeMultiSeriesSpec();
631
+ spec.encoding.y!.stack = 'zero';
632
+ spec.markDef = { type: 'area', fill: '#ff00ff' };
633
+ const scales = computeScales(spec, chartArea, spec.data);
634
+ const marks = computeAreaMarks(spec, scales, chartArea);
635
+
636
+ for (const mark of marks) {
637
+ expect(mark.fill).toBe('#ff00ff');
638
+ // Falls back to the historical 0.7 fillOpacity when user supplies flat color
639
+ expect(mark.fillOpacity).toBe(0.7);
640
+ }
641
+ });
642
+
519
643
  describe('x-axis sorting', () => {
520
644
  it('sorts unsorted temporal data for single area', () => {
521
645
  const spec: NormalizedChartSpec = {
@@ -536,6 +660,30 @@ describe('computeAreaMarks', () => {
536
660
  });
537
661
 
538
662
  it('sorts unsorted temporal data for stacked area', () => {
663
+ const base = makeMultiSeriesSpec();
664
+ base.encoding.y!.stack = 'zero';
665
+ const spec: NormalizedChartSpec = {
666
+ ...base,
667
+ data: [
668
+ { date: '2022-01-01', value: 30, country: 'US' },
669
+ { date: '2020-01-01', value: 10, country: 'US' },
670
+ { date: '2021-01-01', value: 40, country: 'US' },
671
+ { date: '2022-01-01', value: 45, country: 'UK' },
672
+ { date: '2020-01-01', value: 15, country: 'UK' },
673
+ { date: '2021-01-01', value: 35, country: 'UK' },
674
+ ],
675
+ };
676
+ const scales = computeScales(spec, chartArea, spec.data);
677
+ const marks = computeAreaMarks(spec, scales, chartArea);
678
+
679
+ for (const mark of marks) {
680
+ for (let i = 1; i < mark.topPoints.length; i++) {
681
+ expect(mark.topPoints[i].x).toBeGreaterThan(mark.topPoints[i - 1].x);
682
+ }
683
+ }
684
+ });
685
+
686
+ it('sorts unsorted temporal data for overlap (multi-series default)', () => {
539
687
  const spec: NormalizedChartSpec = {
540
688
  ...makeMultiSeriesSpec(),
541
689
  data: [
@@ -550,6 +698,7 @@ describe('computeAreaMarks', () => {
550
698
  const scales = computeScales(spec, chartArea, spec.data);
551
699
  const marks = computeAreaMarks(spec, scales, chartArea);
552
700
 
701
+ expect(marks).toHaveLength(2);
553
702
  for (const mark of marks) {
554
703
  for (let i = 1; i < mark.topPoints.length; i++) {
555
704
  expect(mark.topPoints[i].x).toBeGreaterThan(mark.topPoints[i - 1].x);
@@ -590,7 +739,7 @@ describe('computeAreaMarks', () => {
590
739
  ],
591
740
  encoding: {
592
741
  x: { field: 'date', type: 'temporal' },
593
- y: { field: 'value', type: 'quantitative' },
742
+ y: { field: 'value', type: 'quantitative', stack: 'zero' },
594
743
  color: { field: 'region', type: 'nominal' },
595
744
  },
596
745
  chrome: {},
@@ -635,6 +784,7 @@ describe('computeAreaMarks', () => {
635
784
 
636
785
  it('stacked areas: each layer has different baselines', () => {
637
786
  const spec = makeMultiSeriesSpec();
787
+ spec.encoding.y!.stack = 'zero';
638
788
  const scales = computeScales(spec, chartArea, spec.data);
639
789
  const marks = computeAreaMarks(spec, scales, chartArea);
640
790
 
@@ -664,7 +814,7 @@ describe('computeAreaMarks', () => {
664
814
  ],
665
815
  encoding: {
666
816
  x: { field: 'date', type: 'temporal' },
667
- y: { field: 'value', type: 'quantitative' },
817
+ y: { field: 'value', type: 'quantitative', stack: 'zero' },
668
818
  color: { field: 'group', type: 'nominal' },
669
819
  },
670
820
  chrome: {},