@opendata-ai/openchart-engine 7.10.0 → 8.0.0-rc.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 (159) hide show
  1. package/dist/index.d.ts +142 -7
  2. package/dist/index.js +16182 -10032
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
  6. package/src/__tests__/annotation-behavior.test.ts +721 -0
  7. package/src/__tests__/axes.test.ts +50 -3
  8. package/src/__tests__/compile-chart.test.ts +30 -0
  9. package/src/__tests__/compile-footnotes.test.ts +277 -0
  10. package/src/__tests__/compile-layer.test.ts +120 -1
  11. package/src/__tests__/dimensions.test.ts +49 -4
  12. package/src/__tests__/helpers/invariants.ts +54 -20
  13. package/src/__tests__/known-bugs.test.ts +455 -2
  14. package/src/__tests__/legend.test.ts +120 -4
  15. package/src/__tests__/ranking-recipes.test.ts +260 -0
  16. package/src/__tests__/scales.test.ts +9 -3
  17. package/src/__tests__/series-search.test.ts +92 -0
  18. package/src/__tests__/spec-sugar.test.ts +801 -0
  19. package/src/__tests__/you-draw-it.test.ts +160 -0
  20. package/src/annotations/__tests__/compute.test.ts +207 -92
  21. package/src/annotations/__tests__/geometry.test.ts +66 -0
  22. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  23. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  24. package/src/annotations/__tests__/thinning.test.ts +148 -0
  25. package/src/annotations/collisions.ts +130 -84
  26. package/src/annotations/compute.ts +83 -85
  27. package/src/annotations/constants.ts +116 -6
  28. package/src/annotations/geometry.ts +222 -77
  29. package/src/annotations/move.ts +135 -0
  30. package/src/annotations/placement.ts +62 -14
  31. package/src/annotations/resolve-range.ts +2 -0
  32. package/src/annotations/resolve-refline.ts +2 -0
  33. package/src/annotations/resolve-text.ts +312 -111
  34. package/src/annotations/rich-text.ts +65 -0
  35. package/src/annotations/thinning.ts +84 -10
  36. package/src/barlist/compile-barlist.ts +16 -6
  37. package/src/charts/_shared/format-label-value.ts +9 -8
  38. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  39. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  40. package/src/charts/bar/compute.ts +2 -6
  41. package/src/charts/bar/index.ts +8 -1
  42. package/src/charts/bar/labels.ts +3 -3
  43. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  44. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  45. package/src/charts/beeswarm/compute.ts +203 -0
  46. package/src/charts/beeswarm/dodge.ts +155 -0
  47. package/src/charts/beeswarm/index.ts +32 -0
  48. package/src/charts/builtin.ts +12 -1
  49. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  50. package/src/charts/calendar/compute.ts +319 -0
  51. package/src/charts/calendar/index.ts +32 -0
  52. package/src/charts/column/__tests__/compute.test.ts +21 -7
  53. package/src/charts/column/__tests__/labels.test.ts +76 -1
  54. package/src/charts/column/compute.ts +2 -6
  55. package/src/charts/column/index.ts +10 -2
  56. package/src/charts/column/labels.ts +95 -21
  57. package/src/charts/dot/index.ts +8 -1
  58. package/src/charts/dot/labels.ts +3 -3
  59. package/src/charts/line/__tests__/compute.test.ts +15 -19
  60. package/src/charts/line/area.ts +8 -23
  61. package/src/charts/line/compute.ts +32 -2
  62. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  63. package/src/charts/parliament/compute.ts +313 -0
  64. package/src/charts/parliament/index.ts +32 -0
  65. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  66. package/src/charts/pie/compute.ts +63 -8
  67. package/src/charts/pie/labels.ts +1 -0
  68. package/src/charts/range/__tests__/compute.test.ts +486 -0
  69. package/src/charts/range/compute.ts +273 -0
  70. package/src/charts/range/index.ts +82 -0
  71. package/src/charts/range/labels.ts +169 -0
  72. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  73. package/src/charts/rect/index.ts +127 -0
  74. package/src/charts/scatter/compute.ts +7 -21
  75. package/src/charts/text/__tests__/compute.test.ts +307 -0
  76. package/src/charts/text/index.ts +44 -7
  77. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  78. package/src/charts/tick/index.ts +7 -4
  79. package/src/charts/utils.ts +5 -3
  80. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  81. package/src/charts/waffle/compute.ts +197 -0
  82. package/src/charts/waffle/index.ts +31 -0
  83. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  84. package/src/compile/__tests__/data-clip.test.ts +68 -0
  85. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  86. package/src/compile/__tests__/size-scale.test.ts +108 -0
  87. package/src/compile/color-scale-range.ts +54 -33
  88. package/src/compile/data-clip.ts +21 -6
  89. package/src/compile/fill-patterns.ts +74 -0
  90. package/src/compile/layer.ts +117 -21
  91. package/src/compile/size-scale.ts +139 -0
  92. package/src/compile/spec-sugar.ts +758 -0
  93. package/src/compile/you-draw-it.ts +123 -0
  94. package/src/compile.ts +315 -103
  95. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  114. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  115. package/src/compiler/__tests__/normalize.test.ts +65 -1
  116. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  117. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  118. package/src/compiler/__tests__/validate.test.ts +156 -0
  119. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  120. package/src/compiler/a11y-warnings.ts +110 -0
  121. package/src/compiler/normalize.ts +150 -7
  122. package/src/compiler/types.ts +11 -0
  123. package/src/compiler/validate.ts +664 -18
  124. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  125. package/src/endpoint-labels/compute.ts +40 -12
  126. package/src/endpoint-labels/format.ts +45 -15
  127. package/src/endpoint-labels/predict.ts +50 -36
  128. package/src/format/__tests__/field-format.test.ts +164 -0
  129. package/src/format/field-format.ts +27 -0
  130. package/src/geo/__tests__/compile-map.test.ts +621 -0
  131. package/src/geo/compile-map.ts +856 -0
  132. package/src/geo/join.ts +115 -0
  133. package/src/geo/projections.ts +45 -0
  134. package/src/geo/types.ts +24 -0
  135. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  136. package/src/graphs/compile-graph.ts +27 -18
  137. package/src/index.ts +28 -2
  138. package/src/layout/axes/ticks.ts +59 -10
  139. package/src/layout/axes.ts +30 -2
  140. package/src/layout/dimensions.ts +178 -95
  141. package/src/layout/facet.ts +14 -3
  142. package/src/layout/plan.ts +86 -35
  143. package/src/layout/scales.ts +132 -15
  144. package/src/legend/__tests__/continuous.test.ts +318 -0
  145. package/src/legend/__tests__/size.test.ts +348 -0
  146. package/src/legend/__tests__/suppression.test.ts +17 -1
  147. package/src/legend/compute.ts +126 -14
  148. package/src/legend/continuous.ts +327 -0
  149. package/src/legend/size.ts +407 -0
  150. package/src/legend/suppression.ts +8 -7
  151. package/src/legend/wrap.ts +8 -0
  152. package/src/sankey/compile-sankey.ts +31 -19
  153. package/src/tables/format-cells.ts +16 -7
  154. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  155. package/src/tilemap/compile-tilemap.ts +24 -7
  156. package/src/tooltips/__tests__/compute.test.ts +2 -2
  157. package/src/tooltips/compute.ts +422 -41
  158. package/src/transforms/__tests__/conditional.test.ts +18 -0
  159. package/src/transforms/aggregate.ts +2 -1
@@ -2,7 +2,10 @@
2
2
  * Column chart label computation.
3
3
  *
4
4
  * Produces value labels positioned above each column (for positive values)
5
- * or below (for negative values).
5
+ * or below (for negative values). Stacked segments are the exception: the
6
+ * space above a segment's top edge belongs to the next segment up, not to the
7
+ * background, so a stacked label is centered *inside* its own segment and
8
+ * takes a contrasting fill (mirrors computeBarLabels).
6
9
  *
7
10
  * Respects the spec's label density setting:
8
11
  * - 'all': consider every column's label (no density pre-filter), but still
@@ -16,13 +19,14 @@
16
19
  import type {
17
20
  LabelCandidate,
18
21
  LabelDensity,
22
+ NumberFormatter,
19
23
  RectMark,
20
24
  ResolvedLabel,
21
25
  } from '@opendata-ai/openchart-core';
22
26
  import {
23
- buildD3Formatter,
24
27
  estimateTextWidth,
25
28
  getRepresentativeColor,
29
+ pickLabelColor,
26
30
  resolveCollisions,
27
31
  textAscent,
28
32
  } from '@opendata-ai/openchart-core';
@@ -36,6 +40,8 @@ import { formatLabelValue } from '../_shared/format-label-value';
36
40
  const LABEL_FONT_SIZE = 10;
37
41
  const LABEL_FONT_WEIGHT = 600;
38
42
  const LABEL_OFFSET_Y = 8;
43
+ /** Vertical padding inside a stacked segment, above and below the label. */
44
+ const LABEL_PADDING_Y = 2;
39
45
 
40
46
  // ---------------------------------------------------------------------------
41
47
  // Public API
@@ -50,19 +56,23 @@ export function computeColumnLabels(
50
56
  marks: RectMark[],
51
57
  _chartArea: { x: number; y: number; width: number; height: number },
52
58
  density: LabelDensity = 'auto',
53
- labelFormat?: string,
59
+ labelFormatter?: NumberFormatter | null,
54
60
  labelPrefix?: string,
55
61
  valueField?: string,
56
62
  labelColor?: string,
63
+ darkMode = false,
57
64
  fontSize?: number,
58
65
  labelSuffix?: string,
59
66
  ): ResolvedLabel[] {
60
67
  const FONT_SIZE = fontSize ?? LABEL_FONT_SIZE;
61
68
  const targetMarks = filterByDensity(marks, density);
62
69
 
63
- const formatter = buildD3Formatter(labelFormat);
70
+ const formatter = labelFormatter ?? null;
64
71
 
65
72
  const candidates: LabelCandidate[] = [];
73
+ // Track whether each candidate fits inside its stacked segment. Non-stacked
74
+ // columns label into open space above the bar, so they always fit.
75
+ const fitsInSegment: boolean[] = [];
66
76
 
67
77
  for (const mark of targetMarks) {
68
78
  // Get the original numeric value from the data row when possible,
@@ -98,16 +108,38 @@ export function computeColumnLabels(
98
108
  const textWidth = estimateTextWidth(valuePart, FONT_SIZE, LABEL_FONT_WEIGHT);
99
109
  const textHeight = FONT_SIZE * 1.2;
100
110
 
111
+ // A stacked segment has another segment sitting directly on top of it, so
112
+ // the "float above the top edge" placement used for plain columns would
113
+ // drop the label into its neighbour — and tint it with this segment's
114
+ // color, guaranteeing colored-text-on-colored-fill. Center it inside its
115
+ // own segment instead and pick a fill that contrasts with that segment.
116
+ const isStacked = mark.stackGroup !== undefined;
117
+
101
118
  // anchorY is the TOP of the label bounding box so the collision system's
102
119
  // AABB check (rect = { y: anchorY, height: textHeight }) is geometrically
103
120
  // correct. Collision math stays in top-coordinate space; the ascent shift
104
121
  // to the alphabetic baseline is applied only at emission (see below).
122
+ // Stacked: top = segment center - textHeight / 2, text sits within
105
123
  // Positive bar: top = barTop - LABEL_OFFSET_Y - textHeight, text floats above
106
124
  // Negative bar: top = barBottom + LABEL_OFFSET_Y, text hangs below
107
125
  const anchorX = mark.x + mark.width / 2;
108
- const anchorY = isNegative
109
- ? mark.y + mark.height + LABEL_OFFSET_Y
110
- : mark.y - LABEL_OFFSET_Y - textHeight;
126
+ let anchorY: number;
127
+ let fill: string;
128
+
129
+ if (isStacked) {
130
+ anchorY = mark.y + mark.height / 2 - textHeight / 2;
131
+ fill = labelColor ?? pickLabelColor(getRepresentativeColor(mark.fill), darkMode);
132
+ } else {
133
+ anchorY = isNegative
134
+ ? mark.y + mark.height + LABEL_OFFSET_Y
135
+ : mark.y - LABEL_OFFSET_Y - textHeight;
136
+ fill = labelColor ?? getRepresentativeColor(mark.fill);
137
+ }
138
+
139
+ // A label only belongs inside a stacked segment if the segment is tall
140
+ // enough to hold it; short segments (Nuclear at ~4%) would otherwise
141
+ // overflow into their neighbours.
142
+ fitsInSegment.push(!(isStacked && textHeight > mark.height - 2 * LABEL_PADDING_Y));
111
143
 
112
144
  candidates.push({
113
145
  text: valuePart,
@@ -120,7 +152,7 @@ export function computeColumnLabels(
120
152
  fontFamily: 'system-ui, -apple-system, sans-serif',
121
153
  fontSize: FONT_SIZE,
122
154
  fontWeight: LABEL_FONT_WEIGHT,
123
- fill: labelColor ?? getRepresentativeColor(mark.fill),
155
+ fill,
124
156
  lineHeight: 1.2,
125
157
  textAnchor: 'middle',
126
158
  fontVariant: 'tabular-nums',
@@ -136,20 +168,62 @@ export function computeColumnLabels(
136
168
  // hanging from different font metrics than Blink, clipping labels on iOS).
137
169
  const ascent = textAscent(FONT_SIZE);
138
170
 
139
- // Every density that reaches here ('all' and 'auto') runs collision
171
+ // Every density that reaches here ('all' and 'auto') pre-hides candidates
172
+ // whose stacked segment is too short to hold them, then runs collision
140
173
  // resolution. 'all' just skips the density pre-filter above so every mark is
141
174
  // a candidate; colliding losers are still hidden by resolveCollisions rather
142
175
  // than stacked on top of each other.
143
- return resolveCollisions(candidates).map((label) => ({
144
- ...label,
145
- y: label.y + ascent,
146
- // The connector originates at the label; shift its start to the baseline
147
- // too so the leader stays glued to the shifted text.
148
- connector: label.connector
149
- ? {
150
- ...label.connector,
151
- from: { ...label.connector.from, y: label.connector.from.y + ascent },
152
- }
153
- : undefined,
154
- }));
176
+ const fittingCandidates: LabelCandidate[] = [];
177
+ const unfittingIndices = new Set<number>();
178
+ for (let i = 0; i < candidates.length; i++) {
179
+ if (fitsInSegment[i] === false) {
180
+ unfittingIndices.add(i);
181
+ } else {
182
+ // Carry the original candidate index so we can zip the resolved output
183
+ // back to it regardless of any reordering resolveCollisions performs.
184
+ fittingCandidates.push({ ...candidates[i], index: i });
185
+ }
186
+ }
187
+
188
+ // resolveCollisions may reorder its output (it sorts by priority), so key the
189
+ // result by the source index we carried rather than by position.
190
+ const resolvedByOrig = new Map<number, ResolvedLabel>();
191
+ for (const r of resolveCollisions(fittingCandidates)) {
192
+ if (r.index !== undefined) resolvedByOrig.set(r.index, r);
193
+ }
194
+
195
+ // Re-emit hidden labels rather than dropping them: the renderer zips
196
+ // marks[i].label = labels[i], so the output must stay index-aligned.
197
+ const results: ResolvedLabel[] = [];
198
+ for (let i = 0; i < candidates.length; i++) {
199
+ if (unfittingIndices.has(i)) {
200
+ results.push({
201
+ text: candidates[i].text,
202
+ x: candidates[i].anchorX,
203
+ y: candidates[i].anchorY + ascent,
204
+ style: candidates[i].style,
205
+ visible: false,
206
+ });
207
+ continue;
208
+ }
209
+
210
+ const r = resolvedByOrig.get(i);
211
+ if (!r) continue;
212
+ // Drop the internal zip index so it doesn't leak into the label output.
213
+ const { index: _index, ...rest } = r;
214
+ results.push({
215
+ ...rest,
216
+ y: r.y + ascent,
217
+ // The connector originates at the label; shift its start to the baseline
218
+ // too so the leader stays glued to the shifted text.
219
+ connector: r.connector
220
+ ? {
221
+ ...r.connector,
222
+ from: { ...r.connector.from, y: r.connector.from.y + ascent },
223
+ }
224
+ : undefined,
225
+ });
226
+ }
227
+
228
+ return results;
155
229
  }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Mark, PointMark } from '@opendata-ai/openchart-core';
8
+ import { resolveFieldFormatter } from '../../format/field-format';
8
9
  import type { ChartRenderer } from '../registry';
9
10
  import { computeDotMarks } from './compute';
10
11
  import { computeDotLabels } from './labels';
@@ -28,12 +29,18 @@ export const dotRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _t
28
29
  // Compute and attach labels to point marks (respects spec.labels.density)
29
30
  const valueField =
30
31
  spec.encoding?.x && 'field' in spec.encoding.x ? spec.encoding.x.field : undefined;
32
+ const labelFormatter = resolveFieldFormatter({
33
+ surfaceFormat: spec.labels.format,
34
+ channelFormat:
35
+ spec.encoding?.x && 'format' in spec.encoding.x ? spec.encoding.x.format : undefined,
36
+ values: valueField ? spec.data.map((r) => r[valueField]) : [],
37
+ });
31
38
  const labels = computeDotLabels(
32
39
  pointMarks,
33
40
  chartArea,
34
41
  spec.labels.density,
35
42
  spec.labels.prefix,
36
- spec.labels.format,
43
+ labelFormatter,
37
44
  valueField,
38
45
  );
39
46
  let labelIdx = 0;
@@ -13,12 +13,12 @@
13
13
  import type {
14
14
  LabelCandidate,
15
15
  LabelDensity,
16
+ NumberFormatter,
16
17
  PointMark,
17
18
  Rect,
18
19
  ResolvedLabel,
19
20
  } from '@opendata-ai/openchart-core';
20
21
  import {
21
- buildD3Formatter,
22
22
  estimateTextWidth,
23
23
  getRepresentativeColor,
24
24
  resolveCollisions,
@@ -48,12 +48,12 @@ export function computeDotLabels(
48
48
  _chartArea: Rect,
49
49
  density: LabelDensity = 'auto',
50
50
  labelPrefix?: string,
51
- labelFormat?: string,
51
+ labelFormatter?: NumberFormatter | null,
52
52
  valueField?: string,
53
53
  ): ResolvedLabel[] {
54
54
  const targetMarks = filterByDensity(marks, density);
55
55
 
56
- const formatter = buildD3Formatter(labelFormat);
56
+ const formatter = labelFormatter ?? null;
57
57
  const candidates: LabelCandidate[] = [];
58
58
 
59
59
  for (const mark of targetMarks) {
@@ -517,8 +517,8 @@ describe('computeAreaMarks', () => {
517
517
  expect(seriesKeys).toContain('UK');
518
518
  });
519
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.
520
+ it('stacked (default): produces multiple AreaMarks for multi-series without an explicit stack', () => {
521
+ // v8 VL-aligned default: multi-series with color stacks unless `stack: null | false`.
522
522
  const spec = makeMultiSeriesSpec();
523
523
  const scales = computeScales(spec, chartArea, spec.data);
524
524
  const marks = computeAreaMarks(spec, scales, chartArea);
@@ -527,10 +527,15 @@ describe('computeAreaMarks', () => {
527
527
  const seriesKeys = marks.map((m) => m.seriesKey).filter(Boolean);
528
528
  expect(seriesKeys).toContain('US');
529
529
  expect(seriesKeys).toContain('UK');
530
+ // Stacked layers should have different baselines (one stacks on top of the other)
531
+ const firstBottom = marks[0].bottomPoints[0]?.y;
532
+ const secondBottom = marks[1].bottomPoints[0]?.y;
533
+ expect(firstBottom).not.toBe(secondBottom);
530
534
  });
531
535
 
532
- it('overlap: every series shares the same baseline (no stacking offset)', () => {
536
+ it('overlap (stack: null): every series shares the same baseline (no stacking offset)', () => {
533
537
  const spec = makeMultiSeriesSpec();
538
+ spec.encoding.y!.stack = null;
534
539
  const scales = computeScales(spec, chartArea, spec.data);
535
540
  const marks = computeAreaMarks(spec, scales, chartArea);
536
541
 
@@ -544,8 +549,9 @@ describe('computeAreaMarks', () => {
544
549
  expect(baselines.size).toBe(1);
545
550
  });
546
551
 
547
- it('overlap: each series uses a translucent gradient fill', () => {
552
+ it('overlap (stack: null): each series uses a translucent gradient fill', () => {
548
553
  const spec = makeMultiSeriesSpec();
554
+ spec.encoding.y!.stack = null;
549
555
  const scales = computeScales(spec, chartArea, spec.data);
550
556
  const marks = computeAreaMarks(spec, scales, chartArea);
551
557
 
@@ -560,19 +566,7 @@ describe('computeAreaMarks', () => {
560
566
  }
561
567
  });
562
568
 
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)', () => {
569
+ it('stack: false opts into overlap rendering, same as stack: null', () => {
576
570
  const spec = makeMultiSeriesSpec();
577
571
  spec.encoding.y!.stack = false;
578
572
  const scales = computeScales(spec, chartArea, spec.data);
@@ -702,9 +696,11 @@ describe('computeAreaMarks', () => {
702
696
  }
703
697
  });
704
698
 
705
- it('sorts unsorted temporal data for overlap (multi-series default)', () => {
699
+ it('sorts unsorted temporal data for overlap (stack: null)', () => {
700
+ const base = makeMultiSeriesSpec();
701
+ base.encoding.y!.stack = null;
706
702
  const spec: NormalizedChartSpec = {
707
- ...makeMultiSeriesSpec(),
703
+ ...base,
708
704
  data: [
709
705
  { date: '2022-01-01', value: 30, country: 'US' },
710
706
  { date: '2020-01-01', value: 10, country: 'US' },
@@ -4,10 +4,9 @@
4
4
  * Uses D3 area() generator to produce AreaMark[] with top/bottom
5
5
  * boundary points and SVG path strings.
6
6
  *
7
- * Multi-series behavior (v6 redesign):
8
- * - Default for multi-series with `color` is **overlap** (one translucent
9
- * gradient band per series, layered with low opacity).
10
- * - Stacked rendering is opt-in via `encoding.y.stack: 'zero' | true | 'normalize' | 'center'`.
7
+ * Multi-series behavior (v8: VL-aligned defaults):
8
+ * - Default for multi-series with `color` is **stacked** from a zero baseline.
9
+ * - Overlap rendering is opt-out via `encoding.y.stack: null | false`.
11
10
  * - Single series always renders one gradient-filled area.
12
11
  */
13
12
 
@@ -40,22 +39,11 @@ const DEFAULT_FILL_OPACITY = 0.15;
40
39
  * Resolve `encoding.y.stack` to a boolean: should we stack this area chart?
41
40
  *
42
41
  * Vega-Lite-aligned semantics:
43
- * - undefined | null | false -> overlap (NEW DEFAULT for area)
44
- * - true | 'zero' | 'normalize' | 'center' -> stacked
45
- *
46
- * This is a v6 breaking change. Previously, multi-series with `color`
47
- * implicitly stacked. Now overlap is the default; stacking is opt-in.
42
+ * - undefined | true | 'zero' | 'normalize' | 'center' -> stacked (default)
43
+ * - null | false -> overlap (opt-out)
48
44
  */
49
45
  function isStacked(stackProp: unknown): boolean {
50
- if (stackProp === undefined || stackProp === null || stackProp === false) {
51
- return false;
52
- }
53
- return (
54
- stackProp === true ||
55
- stackProp === 'zero' ||
56
- stackProp === 'normalize' ||
57
- stackProp === 'center'
58
- );
46
+ return stackProp !== null && stackProp !== false;
59
47
  }
60
48
 
61
49
  // Gradient stops calibrated by series count. Solo areas can carry richer fills
@@ -453,14 +441,11 @@ function computeStackedArea(
453
441
  * Compute area marks from a normalized chart spec.
454
442
  *
455
443
  * Behavior depends on `encoding.y.stack` (Vega-Lite aligned):
456
- * - `undefined | null | false` -> overlap (default for multi-series)
457
- * - `true | 'zero' | 'normalize' | 'center'` -> stacked
444
+ * - `undefined | true | 'zero' | 'normalize' | 'center'` -> stacked (default for multi-series)
445
+ * - `null | false` -> overlap
458
446
  *
459
447
  * Single-series specs always render one gradient-filled area; the `stack`
460
448
  * branch only matters when a `color` encoding is present.
461
- *
462
- * BREAKING CHANGE (v6): multi-series no longer auto-stacks. Pass
463
- * `encoding.y.stack: 'zero'` (or `true`) to opt back into the old behavior.
464
449
  */
465
450
  export function computeAreaMarks(
466
451
  spec: NormalizedChartSpec,
@@ -37,6 +37,11 @@ const DEFAULT_STROKE_WIDTH = 1.5;
37
37
  /** Default radius for point marks (hover targets). */
38
38
  const DEFAULT_POINT_RADIUS = 3;
39
39
 
40
+ /** Dash patterns cycled through by the strokeDash encoding, assigned in the
41
+ * ordinal (first-seen data) order of the field values. The first value
42
+ * renders solid so single-value charts keep the default look. */
43
+ const STROKE_DASH_PATTERNS = ['', '6 4', '2 3', '8 4 2 4', '4 4', '1 3'];
44
+
40
45
  // ---------------------------------------------------------------------------
41
46
  // Public API
42
47
  // ---------------------------------------------------------------------------
@@ -67,7 +72,23 @@ export function computeLineMarks(
67
72
  // Sequential color: single series, per-point coloring. Categorical: group by color field.
68
73
  const colorField = isSequentialColor ? undefined : colorEnc?.field;
69
74
  const sequentialColorField = isSequentialColor ? colorEnc.field : undefined;
70
- const groups = groupByField(spec.data, colorField);
75
+ // strokeDash encoding: differentiate series by dash pattern (VL aligned).
76
+ // Without a color channel, the strokeDash field becomes the series grouping
77
+ // so each dash value renders as its own line. With a color channel, each
78
+ // series takes the dash of its first row (typically the same field).
79
+ const dashEnc =
80
+ encoding.strokeDash && 'field' in encoding.strokeDash ? encoding.strokeDash : undefined;
81
+ const dashField = dashEnc?.field;
82
+ const dashIndexByValue = new Map<string, number>();
83
+ if (dashField) {
84
+ for (const row of spec.data) {
85
+ const value = row[dashField];
86
+ if (value == null) continue;
87
+ const key = String(value);
88
+ if (!dashIndexByValue.has(key)) dashIndexByValue.set(key, dashIndexByValue.size);
89
+ }
90
+ }
91
+ const groups = groupByField(spec.data, colorField ?? dashField);
71
92
  const mutedMarks: (LineMark | PointMark)[] = [];
72
93
  const highlightedMarks: (LineMark | PointMark)[] = [];
73
94
  const highlight = spec.highlight ?? [];
@@ -176,10 +197,19 @@ export function computeLineMarks(
176
197
  resolvedStrokeWidth = Math.max(1, resolvedStrokeWidth * 0.75);
177
198
  }
178
199
 
179
- // Map lineStyle to SVG strokeDasharray
200
+ // Map lineStyle to SVG strokeDasharray. Explicit seriesStyles win over
201
+ // the strokeDash encoding.
180
202
  let strokeDasharray: string | undefined;
181
203
  if (styleOverride?.lineStyle === 'dashed') strokeDasharray = '6 4';
182
204
  else if (styleOverride?.lineStyle === 'dotted') strokeDasharray = '2 3';
205
+ else if (dashField) {
206
+ const dashValue = rows[0]?.[dashField];
207
+ if (dashValue != null) {
208
+ const idx = dashIndexByValue.get(String(dashValue)) ?? 0;
209
+ const pattern = STROKE_DASH_PATTERNS[idx % STROKE_DASH_PATTERNS.length];
210
+ if (pattern) strokeDasharray = pattern;
211
+ }
212
+ }
183
213
 
184
214
  // Create the LineMark with the combined path points.
185
215
  // The points array includes all valid points across all segments.