@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.
- package/README.md +1 -0
- package/dist/index.d.ts +8 -11
- package/dist/index.js +12296 -11337
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +3 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +452 -377
- package/src/__tests__/axes.test.ts +75 -0
- package/src/__tests__/compile-chart.test.ts +304 -0
- package/src/__tests__/dimensions.test.ts +224 -0
- package/src/__tests__/legend.test.ts +44 -3
- package/src/annotations/__tests__/compute.test.ts +111 -0
- package/src/annotations/__tests__/resolve-text.test.ts +288 -0
- package/src/annotations/constants.ts +20 -0
- package/src/annotations/resolve-text.ts +161 -7
- package/src/charts/bar/compute.ts +24 -0
- package/src/charts/bar/labels.ts +1 -0
- package/src/charts/column/compute.ts +33 -1
- package/src/charts/column/labels.ts +1 -0
- package/src/charts/dot/labels.ts +1 -0
- package/src/charts/line/__tests__/compute.test.ts +153 -3
- package/src/charts/line/area.ts +111 -23
- package/src/charts/line/compute.ts +40 -10
- package/src/charts/line/index.ts +34 -7
- package/src/charts/line/labels.ts +29 -0
- package/src/charts/pie/labels.ts +1 -0
- package/src/compile/layer.ts +497 -0
- package/src/compile.ts +211 -586
- package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
- package/src/compiler/normalize.ts +6 -1
- package/src/compiler/sparkline-defaults.ts +138 -0
- package/src/compiler/types.ts +8 -0
- package/src/endpoint-labels/__tests__/compute.test.ts +438 -0
- package/src/endpoint-labels/compute.ts +417 -0
- package/src/endpoint-labels/constants.ts +54 -0
- package/src/endpoint-labels/format.ts +30 -0
- package/src/endpoint-labels/predict.ts +108 -0
- package/src/graphs/compile-graph.ts +1 -0
- package/src/layout/axes.ts +27 -2
- package/src/layout/dimensions.ts +270 -33
- package/src/layout/metrics.ts +118 -0
- package/src/layout/scales.ts +41 -4
- package/src/legend/__tests__/suppression.test.ts +294 -0
- package/src/legend/compute.ts +50 -40
- package/src/legend/suppression.ts +204 -0
- 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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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;
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/charts/dot/labels.ts
CHANGED
|
@@ -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: {},
|