@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.10
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/dist/index.d.ts +124 -14
- package/dist/index.js +16254 -9417
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +232 -56
- package/src/__tests__/annotation-behavior.test.ts +852 -0
- package/src/__tests__/axes.test.ts +50 -3
- package/src/__tests__/compile-chart.test.ts +30 -0
- package/src/__tests__/compile-footnotes.test.ts +277 -0
- package/src/__tests__/compile-layer.test.ts +120 -1
- package/src/__tests__/facet.test.ts +144 -0
- package/src/__tests__/helpers/invariants.ts +54 -20
- package/src/__tests__/known-bugs.test.ts +455 -2
- package/src/__tests__/legend.test.ts +69 -4
- package/src/__tests__/ranking-recipes.test.ts +260 -0
- package/src/__tests__/scales.test.ts +9 -3
- package/src/__tests__/series-search.test.ts +92 -0
- package/src/__tests__/spec-sugar.test.ts +801 -0
- package/src/__tests__/you-draw-it.test.ts +160 -0
- package/src/annotations/__tests__/compute.test.ts +207 -92
- package/src/annotations/__tests__/geometry.test.ts +66 -0
- package/src/annotations/__tests__/resolve-text.test.ts +527 -27
- package/src/annotations/__tests__/rich-text.test.ts +91 -0
- package/src/annotations/__tests__/thinning.test.ts +148 -0
- package/src/annotations/collisions.ts +130 -84
- package/src/annotations/compute.ts +83 -85
- package/src/annotations/constants.ts +116 -6
- package/src/annotations/geometry.ts +228 -77
- package/src/annotations/move.ts +135 -0
- package/src/annotations/placement.ts +62 -14
- package/src/annotations/resolve-range.ts +2 -0
- package/src/annotations/resolve-refline.ts +2 -0
- package/src/annotations/resolve-text.ts +318 -111
- package/src/annotations/rich-text.ts +65 -0
- package/src/annotations/thinning.ts +84 -10
- package/src/barlist/compile-barlist.ts +16 -6
- package/src/charts/_shared/format-label-value.ts +9 -8
- package/src/charts/bar/__tests__/compute.test.ts +57 -10
- package/src/charts/bar/__tests__/labels.test.ts +5 -4
- package/src/charts/bar/compute.ts +2 -6
- package/src/charts/bar/index.ts +8 -1
- package/src/charts/bar/labels.ts +3 -3
- package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
- package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
- package/src/charts/beeswarm/compute.ts +203 -0
- package/src/charts/beeswarm/dodge.ts +155 -0
- package/src/charts/beeswarm/index.ts +32 -0
- package/src/charts/builtin.ts +12 -1
- package/src/charts/calendar/__tests__/compute.test.ts +417 -0
- package/src/charts/calendar/compute.ts +319 -0
- package/src/charts/calendar/index.ts +32 -0
- package/src/charts/column/__tests__/compute.test.ts +21 -7
- package/src/charts/column/__tests__/labels.test.ts +76 -1
- package/src/charts/column/compute.ts +2 -6
- package/src/charts/column/index.ts +10 -2
- package/src/charts/column/labels.ts +95 -21
- package/src/charts/dot/index.ts +8 -1
- package/src/charts/dot/labels.ts +3 -3
- package/src/charts/line/__tests__/compute.test.ts +15 -19
- package/src/charts/line/area.ts +8 -23
- package/src/charts/line/compute.ts +32 -2
- package/src/charts/parliament/__tests__/compute.test.ts +411 -0
- package/src/charts/parliament/compute.ts +313 -0
- package/src/charts/parliament/index.ts +32 -0
- package/src/charts/pie/__tests__/compute.test.ts +103 -0
- package/src/charts/pie/compute.ts +63 -8
- package/src/charts/pie/labels.ts +1 -0
- package/src/charts/range/__tests__/compute.test.ts +486 -0
- package/src/charts/range/compute.ts +273 -0
- package/src/charts/range/index.ts +82 -0
- package/src/charts/range/labels.ts +169 -0
- package/src/charts/rect/__tests__/compute.test.ts +120 -0
- package/src/charts/rect/index.ts +127 -0
- package/src/charts/scatter/compute.ts +7 -21
- package/src/charts/text/__tests__/compute.test.ts +307 -0
- package/src/charts/text/index.ts +44 -7
- package/src/charts/tick/__tests__/compute.test.ts +75 -0
- package/src/charts/tick/index.ts +7 -4
- package/src/charts/utils.ts +5 -3
- package/src/charts/waffle/__tests__/compute.test.ts +305 -0
- package/src/charts/waffle/compute.ts +197 -0
- package/src/charts/waffle/index.ts +31 -0
- package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
- package/src/compile/__tests__/data-clip.test.ts +68 -0
- package/src/compile/__tests__/fill-patterns.test.ts +154 -0
- package/src/compile/__tests__/size-scale.test.ts +108 -0
- package/src/compile/color-scale-range.ts +54 -33
- package/src/compile/data-clip.ts +21 -6
- package/src/compile/fill-patterns.ts +74 -0
- package/src/compile/layer.ts +121 -21
- package/src/compile/size-scale.ts +141 -0
- package/src/compile/spec-sugar.ts +767 -0
- package/src/compile/you-draw-it.ts +123 -0
- package/src/compile.ts +415 -110
- package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
- package/src/compiler/__tests__/keys.test.ts +3 -9
- package/src/compiler/__tests__/normalize.test.ts +65 -1
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +170 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +251 -0
- package/src/compiler/__tests__/validate.test.ts +258 -0
- package/src/compiler/__tests__/vl-parity.test.ts +68 -0
- package/src/compiler/a11y-warnings.ts +110 -0
- package/src/compiler/keys.ts +5 -4
- package/src/compiler/normalize.ts +151 -7
- package/src/compiler/types.ts +11 -0
- package/src/compiler/validate.ts +961 -32
- package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
- package/src/endpoint-labels/compute.ts +40 -12
- package/src/endpoint-labels/format.ts +45 -15
- package/src/endpoint-labels/predict.ts +50 -36
- package/src/format/__tests__/field-format.test.ts +164 -0
- package/src/format/field-format.ts +27 -0
- package/src/geo/__tests__/compile-map.test.ts +1128 -0
- package/src/geo/compile-map.ts +1376 -0
- package/src/geo/join.ts +115 -0
- package/src/geo/projections.ts +65 -0
- package/src/geo/types.ts +26 -0
- package/src/graphs/__tests__/compile-graph.test.ts +25 -0
- package/src/graphs/compile-graph.ts +27 -18
- package/src/index.ts +20 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +35 -6
- package/src/layout/dimensions.ts +139 -83
- package/src/layout/facet.ts +14 -3
- package/src/layout/plan.ts +83 -33
- package/src/layout/scales.ts +132 -15
- package/src/legend/__tests__/continuous.test.ts +318 -0
- package/src/legend/__tests__/size.test.ts +348 -0
- package/src/legend/__tests__/suppression.test.ts +17 -1
- package/src/legend/compute.ts +123 -16
- package/src/legend/continuous.ts +327 -0
- package/src/legend/size.ts +407 -0
- package/src/legend/suppression.ts +8 -7
- package/src/sankey/compile-sankey.ts +31 -19
- package/src/tables/format-cells.ts +16 -7
- package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
- package/src/tilemap/compile-tilemap.ts +24 -7
- package/src/tooltips/__tests__/compute.test.ts +2 -2
- package/src/tooltips/compute.ts +422 -41
- package/src/transforms/__tests__/conditional.test.ts +18 -0
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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')
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
}
|
package/src/charts/dot/index.ts
CHANGED
|
@@ -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
|
-
|
|
43
|
+
labelFormatter,
|
|
37
44
|
valueField,
|
|
38
45
|
);
|
|
39
46
|
let labelIdx = 0;
|
package/src/charts/dot/labels.ts
CHANGED
|
@@ -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
|
-
|
|
51
|
+
labelFormatter?: NumberFormatter | null,
|
|
52
52
|
valueField?: string,
|
|
53
53
|
): ResolvedLabel[] {
|
|
54
54
|
const targetMarks = filterByDensity(marks, density);
|
|
55
55
|
|
|
56
|
-
const formatter =
|
|
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('
|
|
521
|
-
//
|
|
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('
|
|
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 (
|
|
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
|
-
...
|
|
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' },
|
package/src/charts/line/area.ts
CHANGED
|
@@ -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 (
|
|
8
|
-
* - Default for multi-series with `color` is **
|
|
9
|
-
*
|
|
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 |
|
|
44
|
-
* -
|
|
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
|
-
|
|
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 |
|
|
457
|
-
* - `
|
|
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
|
-
|
|
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.
|