@opendata-ai/openchart-engine 6.12.0 → 6.15.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/dist/index.js +1022 -648
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +12 -30
- package/src/__tests__/compile-chart.test.ts +4 -4
- package/src/__tests__/dimensions.test.ts +2 -2
- package/src/__tests__/encoding-sugar.test.ts +390 -0
- package/src/annotations/collisions.ts +268 -0
- package/src/annotations/compute.ts +9 -912
- package/src/annotations/constants.ts +32 -0
- package/src/annotations/geometry.ts +167 -0
- package/src/annotations/position.ts +95 -0
- package/src/annotations/resolve-range.ts +98 -0
- package/src/annotations/resolve-refline.ts +148 -0
- package/src/annotations/resolve-text.ts +134 -0
- package/src/charts/__tests__/post-process.test.ts +258 -0
- package/src/charts/bar/__tests__/labels.test.ts +31 -0
- package/src/charts/bar/compute.ts +27 -6
- package/src/charts/bar/index.ts +3 -0
- package/src/charts/bar/labels.ts +38 -14
- package/src/charts/column/__tests__/compute.test.ts +99 -0
- package/src/charts/column/compute.ts +27 -6
- package/src/charts/column/index.ts +3 -0
- package/src/charts/column/labels.ts +35 -13
- package/src/charts/dot/index.ts +10 -1
- package/src/charts/dot/labels.ts +37 -6
- package/src/charts/line/area.ts +31 -6
- package/src/charts/line/compute.ts +7 -2
- package/src/charts/line/index.ts +33 -2
- package/src/charts/post-process.ts +215 -0
- package/src/compile.ts +91 -158
- package/src/compiler/normalize.ts +2 -2
- package/src/layout/axes.ts +12 -15
- package/src/layout/dimensions.ts +3 -3
- package/src/layout/scales.ts +116 -36
- package/src/legend/compute.ts +2 -4
- package/src/tooltips/__tests__/compute.test.ts +188 -0
- package/src/tooltips/compute.ts +54 -12
- package/src/transforms/__tests__/aggregate.test.ts +159 -0
- package/src/transforms/__tests__/fold.test.ts +79 -0
- package/src/transforms/aggregate.ts +130 -0
- package/src/transforms/fold.ts +49 -0
- package/src/transforms/index.ts +8 -0
|
@@ -18,12 +18,24 @@ import type {
|
|
|
18
18
|
ResolvedLabel,
|
|
19
19
|
} from '@opendata-ai/openchart-core';
|
|
20
20
|
import {
|
|
21
|
+
abbreviateNumber,
|
|
21
22
|
buildD3Formatter,
|
|
22
23
|
estimateTextWidth,
|
|
24
|
+
formatNumber,
|
|
23
25
|
getRepresentativeColor,
|
|
24
26
|
resolveCollisions,
|
|
25
27
|
} from '@opendata-ai/openchart-core';
|
|
26
28
|
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Helpers
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** Format a column value for display (abbreviate large numbers). */
|
|
34
|
+
function formatColumnValue(value: number): string {
|
|
35
|
+
if (Math.abs(value) >= 1000) return abbreviateNumber(value);
|
|
36
|
+
return formatNumber(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
// ---------------------------------------------------------------------------
|
|
28
40
|
// Constants
|
|
29
41
|
// ---------------------------------------------------------------------------
|
|
@@ -47,6 +59,7 @@ export function computeColumnLabels(
|
|
|
47
59
|
density: LabelDensity = 'auto',
|
|
48
60
|
labelFormat?: string,
|
|
49
61
|
labelPrefix?: string,
|
|
62
|
+
valueField?: string,
|
|
50
63
|
): ResolvedLabel[] {
|
|
51
64
|
// 'none': no labels at all
|
|
52
65
|
if (density === 'none') return [];
|
|
@@ -60,19 +73,28 @@ export function computeColumnLabels(
|
|
|
60
73
|
const candidates: LabelCandidate[] = [];
|
|
61
74
|
|
|
62
75
|
for (const mark of targetMarks) {
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
// Get the original numeric value from the data row when possible,
|
|
77
|
+
// falling back to parsing the aria label (which may lose precision
|
|
78
|
+
// due to abbreviation rounding, e.g. 1955 → "2K" → 2000).
|
|
79
|
+
let valuePart: string;
|
|
80
|
+
const rawNum = valueField != null ? Number(mark.data[valueField]) : NaN;
|
|
81
|
+
|
|
82
|
+
if (formatter && Number.isFinite(rawNum)) {
|
|
83
|
+
valuePart = formatter(rawNum);
|
|
84
|
+
} else if (Number.isFinite(rawNum)) {
|
|
85
|
+
valuePart = formatColumnValue(rawNum);
|
|
86
|
+
} else {
|
|
87
|
+
// Fallback: extract from aria label
|
|
88
|
+
const ariaLabel = mark.aria.label;
|
|
89
|
+
const lastColon = ariaLabel.lastIndexOf(':');
|
|
90
|
+
const rawValue = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
|
|
91
|
+
if (!rawValue) continue;
|
|
92
|
+
if (formatter) {
|
|
93
|
+
const num = Number(rawValue.replace(/[^0-9.-]/g, ''));
|
|
94
|
+
valuePart = !Number.isNaN(num) ? formatter(num) : rawValue;
|
|
95
|
+
} else {
|
|
96
|
+
valuePart = rawValue;
|
|
97
|
+
}
|
|
76
98
|
}
|
|
77
99
|
if (labelPrefix) valuePart = labelPrefix + valuePart;
|
|
78
100
|
|
package/src/charts/dot/index.ts
CHANGED
|
@@ -26,7 +26,16 @@ export const dotRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _t
|
|
|
26
26
|
const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
|
|
27
27
|
|
|
28
28
|
// Compute and attach labels to point marks (respects spec.labels.density)
|
|
29
|
-
const
|
|
29
|
+
const valueField =
|
|
30
|
+
spec.encoding?.x && 'field' in spec.encoding.x ? spec.encoding.x.field : undefined;
|
|
31
|
+
const labels = computeDotLabels(
|
|
32
|
+
pointMarks,
|
|
33
|
+
chartArea,
|
|
34
|
+
spec.labels.density,
|
|
35
|
+
spec.labels.prefix,
|
|
36
|
+
spec.labels.format,
|
|
37
|
+
valueField,
|
|
38
|
+
);
|
|
30
39
|
let labelIdx = 0;
|
|
31
40
|
for (const mark of marks) {
|
|
32
41
|
if (mark.type === 'point' && labelIdx < labels.length) {
|
package/src/charts/dot/labels.ts
CHANGED
|
@@ -18,11 +18,24 @@ import type {
|
|
|
18
18
|
ResolvedLabel,
|
|
19
19
|
} from '@opendata-ai/openchart-core';
|
|
20
20
|
import {
|
|
21
|
+
abbreviateNumber,
|
|
22
|
+
buildD3Formatter,
|
|
21
23
|
estimateTextWidth,
|
|
24
|
+
formatNumber,
|
|
22
25
|
getRepresentativeColor,
|
|
23
26
|
resolveCollisions,
|
|
24
27
|
} from '@opendata-ai/openchart-core';
|
|
25
28
|
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Helpers
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** Format a dot value for display (abbreviate large numbers). */
|
|
34
|
+
function formatDotValue(value: number): string {
|
|
35
|
+
if (Math.abs(value) >= 1000) return abbreviateNumber(value);
|
|
36
|
+
return formatNumber(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
// ---------------------------------------------------------------------------
|
|
27
40
|
// Constants
|
|
28
41
|
// ---------------------------------------------------------------------------
|
|
@@ -45,6 +58,8 @@ export function computeDotLabels(
|
|
|
45
58
|
_chartArea: Rect,
|
|
46
59
|
density: LabelDensity = 'auto',
|
|
47
60
|
labelPrefix?: string,
|
|
61
|
+
labelFormat?: string,
|
|
62
|
+
valueField?: string,
|
|
48
63
|
): ResolvedLabel[] {
|
|
49
64
|
// 'none': no labels at all
|
|
50
65
|
if (density === 'none') return [];
|
|
@@ -53,15 +68,31 @@ export function computeDotLabels(
|
|
|
53
68
|
const targetMarks =
|
|
54
69
|
density === 'endpoints' && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
|
|
55
70
|
|
|
71
|
+
const formatter = buildD3Formatter(labelFormat);
|
|
56
72
|
const candidates: LabelCandidate[] = [];
|
|
57
73
|
|
|
58
74
|
for (const mark of targetMarks) {
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
75
|
+
// Get the original numeric value from the data row when possible,
|
|
76
|
+
// falling back to parsing the aria label (which may lose precision
|
|
77
|
+
// due to abbreviation rounding, e.g. 1955 → "2K" → 2000).
|
|
78
|
+
let valuePart: string;
|
|
79
|
+
const rawNum = valueField != null ? Number(mark.data[valueField]) : NaN;
|
|
80
|
+
|
|
81
|
+
if (formatter && Number.isFinite(rawNum)) {
|
|
82
|
+
valuePart = formatter(rawNum);
|
|
83
|
+
} else if (Number.isFinite(rawNum)) {
|
|
84
|
+
valuePart = formatDotValue(rawNum);
|
|
85
|
+
} else {
|
|
86
|
+
// Fallback: extract from aria label
|
|
87
|
+
const ariaLabel = mark.aria.label;
|
|
88
|
+
const lastColon = ariaLabel.lastIndexOf(':');
|
|
89
|
+
valuePart = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
|
|
90
|
+
if (!valuePart) continue;
|
|
91
|
+
if (formatter) {
|
|
92
|
+
const num = Number(valuePart.replace(/[^0-9.-]/g, ''));
|
|
93
|
+
if (!Number.isNaN(num)) valuePart = formatter(num);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
65
96
|
if (labelPrefix) valuePart = labelPrefix + valuePart;
|
|
66
97
|
|
|
67
98
|
const textWidth = estimateTextWidth(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
|
package/src/charts/line/area.ts
CHANGED
|
@@ -9,7 +9,15 @@
|
|
|
9
9
|
import type { AreaMark, DataRow, Encoding, MarkAria, Rect } from '@opendata-ai/openchart-core';
|
|
10
10
|
import { getRepresentativeColor } from '@opendata-ai/openchart-core';
|
|
11
11
|
import type { ScaleLinear } from 'd3-scale';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
area,
|
|
14
|
+
line,
|
|
15
|
+
stack,
|
|
16
|
+
stackOffsetExpand,
|
|
17
|
+
stackOffsetNone,
|
|
18
|
+
stackOffsetSilhouette,
|
|
19
|
+
stackOrderNone,
|
|
20
|
+
} from 'd3-shape';
|
|
13
21
|
|
|
14
22
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
15
23
|
import type { ResolvedScales } from '../../layout/scales';
|
|
@@ -66,8 +74,12 @@ function computeSingleArea(
|
|
|
66
74
|
for (const [seriesKey, rows] of groups) {
|
|
67
75
|
const color = getColor(scales, seriesKey);
|
|
68
76
|
|
|
69
|
-
// Sort rows by x-axis field so areas draw left-to-right
|
|
70
|
-
|
|
77
|
+
// Sort rows by x-axis field so areas draw left-to-right.
|
|
78
|
+
// For nominal/ordinal axes, preserve data order.
|
|
79
|
+
const sortedRows =
|
|
80
|
+
xChannel.type === 'nominal' || xChannel.type === 'ordinal'
|
|
81
|
+
? rows
|
|
82
|
+
: sortByField(rows, xChannel.field);
|
|
71
83
|
|
|
72
84
|
// Compute points, filtering out null values
|
|
73
85
|
const validPoints: { x: number; yTop: number; yBottom: number; row: DataRow }[] = [];
|
|
@@ -154,8 +166,12 @@ function computeStackedArea(
|
|
|
154
166
|
return computeSingleArea(spec, scales, chartArea);
|
|
155
167
|
}
|
|
156
168
|
|
|
157
|
-
// Sort data by x field so stacked areas render left-to-right
|
|
158
|
-
|
|
169
|
+
// Sort data by x field so stacked areas render left-to-right.
|
|
170
|
+
// For nominal/ordinal axes, preserve data order.
|
|
171
|
+
const sortedData =
|
|
172
|
+
xChannel.type === 'nominal' || xChannel.type === 'ordinal'
|
|
173
|
+
? spec.data
|
|
174
|
+
: sortByField(spec.data, xChannel.field);
|
|
159
175
|
|
|
160
176
|
// Collect unique series keys and x values, and build a lookup from
|
|
161
177
|
// (x-value, series-key) -> original data row so stacked area marks
|
|
@@ -200,11 +216,20 @@ function computeStackedArea(
|
|
|
200
216
|
return pivot;
|
|
201
217
|
});
|
|
202
218
|
|
|
219
|
+
// Resolve stack offset from the y channel's stack property
|
|
220
|
+
const stackProp = yChannel.stack;
|
|
221
|
+
const offsetFn =
|
|
222
|
+
stackProp === 'normalize'
|
|
223
|
+
? stackOffsetExpand
|
|
224
|
+
: stackProp === 'center'
|
|
225
|
+
? stackOffsetSilhouette
|
|
226
|
+
: stackOffsetNone;
|
|
227
|
+
|
|
203
228
|
// Use d3 stack to compute the stacked layout
|
|
204
229
|
const stackGenerator = stack<Record<string, unknown>>()
|
|
205
230
|
.keys(keys)
|
|
206
231
|
.order(stackOrderNone)
|
|
207
|
-
.offset(
|
|
232
|
+
.offset(offsetFn);
|
|
208
233
|
|
|
209
234
|
const stackedData = stackGenerator(pivotData);
|
|
210
235
|
const yScale = scales.y.scale as ScaleLinear<number, number>;
|
|
@@ -75,8 +75,13 @@ export function computeLineMarks(
|
|
|
75
75
|
: getColor(scales, seriesKey);
|
|
76
76
|
const strokeColor = getRepresentativeColor(color);
|
|
77
77
|
|
|
78
|
-
// Sort rows by x-axis field so lines draw left-to-right
|
|
79
|
-
|
|
78
|
+
// Sort rows by x-axis field so lines draw left-to-right.
|
|
79
|
+
// For nominal/ordinal axes, preserve data order since there's no
|
|
80
|
+
// natural sort and the scale domain already reflects intended order.
|
|
81
|
+
const sortedRows =
|
|
82
|
+
xChannel.type === 'nominal' || xChannel.type === 'ordinal'
|
|
83
|
+
? rows
|
|
84
|
+
: sortByField(rows, xChannel.field);
|
|
80
85
|
|
|
81
86
|
// Compute pixel positions for each data point, preserving nulls
|
|
82
87
|
// for line break handling
|
package/src/charts/line/index.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Exports line and area chart renderers and computation functions.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { LineMark, Mark } from '@opendata-ai/openchart-core';
|
|
7
|
+
import type { AreaMark, LineMark, Mark } from '@opendata-ai/openchart-core';
|
|
8
|
+
import { getRepresentativeColor } from '@opendata-ai/openchart-core';
|
|
8
9
|
import type { ChartRenderer } from '../registry';
|
|
9
10
|
import { computeAreaMarks } from './area';
|
|
10
11
|
import { computeLineMarks } from './compute';
|
|
@@ -53,12 +54,42 @@ export const lineRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _
|
|
|
53
54
|
*/
|
|
54
55
|
export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
|
|
55
56
|
const areas = computeAreaMarks(spec, scales, chartArea);
|
|
56
|
-
|
|
57
|
+
|
|
58
|
+
const encoding = spec.encoding;
|
|
59
|
+
const hasColor = !!(encoding.color && 'field' in encoding.color);
|
|
60
|
+
|
|
61
|
+
// For stacked areas, derive line marks from the area top paths so lines
|
|
62
|
+
// align with stacked positions. For non-stacked, compute lines normally.
|
|
63
|
+
const lines = hasColor
|
|
64
|
+
? linesFromAreas(areas)
|
|
65
|
+
: computeLineMarks(spec, scales, chartArea, strategy);
|
|
57
66
|
|
|
58
67
|
// Areas go first (rendered behind lines), then lines on top
|
|
59
68
|
return [...areas, ...lines] as Mark[];
|
|
60
69
|
};
|
|
61
70
|
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Helpers
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Derive LineMark[] from stacked AreaMark[] using each area's top boundary.
|
|
77
|
+
* This ensures lines sit on top of their corresponding stacked area bands.
|
|
78
|
+
*/
|
|
79
|
+
function linesFromAreas(areas: AreaMark[]): LineMark[] {
|
|
80
|
+
return areas.map((a) => ({
|
|
81
|
+
type: 'line' as const,
|
|
82
|
+
points: a.topPoints,
|
|
83
|
+
path: a.topPath,
|
|
84
|
+
stroke: getRepresentativeColor(a.fill),
|
|
85
|
+
strokeWidth: a.strokeWidth ?? 1,
|
|
86
|
+
seriesKey: a.seriesKey,
|
|
87
|
+
data: a.data,
|
|
88
|
+
dataPoints: a.dataPoints,
|
|
89
|
+
aria: { label: `${a.seriesKey ?? 'Series'}: line with ${a.topPoints.length} data points` },
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
|
|
62
93
|
// ---------------------------------------------------------------------------
|
|
63
94
|
// Public exports
|
|
64
95
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mark post-processing: obstacle computation, renderer dispatch, and
|
|
3
|
+
* animation index assignment. These are mark-type-aware operations that
|
|
4
|
+
* run after the chart renderer has produced marks.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
Encoding,
|
|
9
|
+
Mark,
|
|
10
|
+
MarkDef,
|
|
11
|
+
PointMark,
|
|
12
|
+
Rect,
|
|
13
|
+
RectMark,
|
|
14
|
+
ResolvedAnimation,
|
|
15
|
+
} from '@opendata-ai/openchart-core';
|
|
16
|
+
import type { ResolvedScales } from '../layout/scales';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Mark obstacles for annotation collision avoidance
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compute bounding rects from marks to use as obstacles for annotation nudging.
|
|
24
|
+
*
|
|
25
|
+
* For band-scale charts (bar, dot): groups marks by band row and returns
|
|
26
|
+
* a single obstacle per row spanning the full band height and x-range.
|
|
27
|
+
*
|
|
28
|
+
* For other charts (column, scatter): returns individual mark bounds so
|
|
29
|
+
* annotations avoid overlapping any visible data mark.
|
|
30
|
+
*/
|
|
31
|
+
export function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
|
|
32
|
+
// Band-scale y-axis: group marks by row for efficient obstacle computation
|
|
33
|
+
if (scales.y?.type === 'band') {
|
|
34
|
+
return computeBandRowObstacles(marks, scales);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// All other charts: use individual rect/point mark bounds as obstacles
|
|
38
|
+
const obstacles: Rect[] = [];
|
|
39
|
+
for (const mark of marks) {
|
|
40
|
+
if (mark.type === 'rect') {
|
|
41
|
+
const rm = mark as RectMark;
|
|
42
|
+
obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
|
|
43
|
+
} else if (mark.type === 'point') {
|
|
44
|
+
const pm = mark as PointMark;
|
|
45
|
+
obstacles.push({
|
|
46
|
+
x: pm.cx - pm.r,
|
|
47
|
+
y: pm.cy - pm.r,
|
|
48
|
+
width: pm.r * 2,
|
|
49
|
+
height: pm.r * 2,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return obstacles;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Group band-scale marks by row, returning one obstacle per band. */
|
|
57
|
+
function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
|
|
58
|
+
const rows = new Map<number, { minX: number; maxX: number; bandY: number }>();
|
|
59
|
+
|
|
60
|
+
for (const mark of marks) {
|
|
61
|
+
let cy: number;
|
|
62
|
+
let left: number;
|
|
63
|
+
let right: number;
|
|
64
|
+
|
|
65
|
+
if (mark.type === 'point') {
|
|
66
|
+
const pm = mark as PointMark;
|
|
67
|
+
cy = pm.cy;
|
|
68
|
+
left = pm.cx - pm.r;
|
|
69
|
+
right = pm.cx + pm.r;
|
|
70
|
+
} else if (mark.type === 'rect') {
|
|
71
|
+
const rm = mark as RectMark;
|
|
72
|
+
cy = rm.y + rm.height / 2;
|
|
73
|
+
left = rm.x;
|
|
74
|
+
right = rm.x + rm.width;
|
|
75
|
+
} else {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Round cy to group marks on the same band
|
|
80
|
+
const key = Math.round(cy);
|
|
81
|
+
const existing = rows.get(key);
|
|
82
|
+
if (existing) {
|
|
83
|
+
existing.minX = Math.min(existing.minX, left);
|
|
84
|
+
existing.maxX = Math.max(existing.maxX, right);
|
|
85
|
+
} else {
|
|
86
|
+
rows.set(key, { minX: left, maxX: right, bandY: cy });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Get bandwidth from the band scale
|
|
91
|
+
const bandScale = scales.y!.scale as { bandwidth?: () => number };
|
|
92
|
+
const bandwidth = bandScale.bandwidth?.() ?? 0;
|
|
93
|
+
if (bandwidth === 0) return [];
|
|
94
|
+
|
|
95
|
+
const obstacles: Rect[] = [];
|
|
96
|
+
for (const { minX, maxX, bandY } of rows.values()) {
|
|
97
|
+
obstacles.push({
|
|
98
|
+
x: minX,
|
|
99
|
+
y: bandY - bandwidth / 2,
|
|
100
|
+
width: maxX - minX,
|
|
101
|
+
height: bandwidth,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return obstacles;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Renderer key dispatch
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve the renderer key from mark type, encoding, and mark definition.
|
|
114
|
+
*
|
|
115
|
+
* - 'bar' -> 'bar' (horizontal) or 'bar:vertical' based on encoding axis types
|
|
116
|
+
* - 'arc' -> 'arc' (pie) or 'arc:donut' based on innerRadius
|
|
117
|
+
* - All other mark types pass through unchanged
|
|
118
|
+
*/
|
|
119
|
+
export function resolveRendererKey(
|
|
120
|
+
markType: string,
|
|
121
|
+
encoding: Partial<Encoding>,
|
|
122
|
+
markDef: Partial<MarkDef>,
|
|
123
|
+
): string {
|
|
124
|
+
if (markType === 'bar') {
|
|
125
|
+
const xType = encoding.x?.type;
|
|
126
|
+
const yType = encoding.y?.type;
|
|
127
|
+
const isVertical =
|
|
128
|
+
(xType === 'nominal' || xType === 'ordinal' || xType === 'temporal') &&
|
|
129
|
+
yType === 'quantitative';
|
|
130
|
+
if (isVertical) {
|
|
131
|
+
return 'bar:vertical';
|
|
132
|
+
}
|
|
133
|
+
} else if (markType === 'arc') {
|
|
134
|
+
const innerRadius = markDef.innerRadius;
|
|
135
|
+
if (innerRadius && innerRadius > 0) {
|
|
136
|
+
return 'arc:donut';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return markType;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Animation index assignment
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/** Extract the primary quantitative value from a mark for value-based stagger ordering. */
|
|
147
|
+
function getMarkPrimaryValue(mark: Mark): number {
|
|
148
|
+
switch (mark.type) {
|
|
149
|
+
case 'rect':
|
|
150
|
+
return mark.height; // bar height is the primary value encoding
|
|
151
|
+
case 'point':
|
|
152
|
+
return mark.cy; // y position for scatter
|
|
153
|
+
case 'arc':
|
|
154
|
+
return mark.endAngle - mark.startAngle; // arc angle extent
|
|
155
|
+
case 'line':
|
|
156
|
+
case 'area':
|
|
157
|
+
return 0; // series marks don't have individual values
|
|
158
|
+
default:
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Assign animation indices to marks for stagger ordering.
|
|
165
|
+
*
|
|
166
|
+
* Two phases:
|
|
167
|
+
* 1. Value-based stagger: sorts marks by primary value, assigns sequential indices.
|
|
168
|
+
* Skips stacked rects since they get group-based indices in phase 2.
|
|
169
|
+
* 2. Stack-based stagger: groups marks by stackGroup, assigns the same index to
|
|
170
|
+
* all segments in a group, and computes stackPos (segment position: 0, 1, 2...).
|
|
171
|
+
* This intentionally overwrites any value-based indices for stacked marks.
|
|
172
|
+
*/
|
|
173
|
+
export function assignAnimationIndices(
|
|
174
|
+
marks: Mark[],
|
|
175
|
+
animation: ResolvedAnimation | undefined,
|
|
176
|
+
): void {
|
|
177
|
+
if (!animation?.enabled) return;
|
|
178
|
+
|
|
179
|
+
// Phase 1: Value-based stagger ordering. Skip stacked rects
|
|
180
|
+
// since they get group-based indices below (avoids wasted work that gets overwritten).
|
|
181
|
+
if (animation.staggerOrder === 'value') {
|
|
182
|
+
const indexed = marks.map((m, i) => ({ mark: m, idx: i }));
|
|
183
|
+
indexed.sort((a, b) => {
|
|
184
|
+
const av = getMarkPrimaryValue(a.mark);
|
|
185
|
+
const bv = getMarkPrimaryValue(b.mark);
|
|
186
|
+
return av - bv;
|
|
187
|
+
});
|
|
188
|
+
for (let i = 0; i < indexed.length; i++) {
|
|
189
|
+
const m = indexed[i].mark;
|
|
190
|
+
if (m.type === 'rect' && (m as RectMark).stackGroup) continue;
|
|
191
|
+
m.animationIndex = i;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Phase 2: For stacked bars/columns, assign the same animationIndex to all segments
|
|
196
|
+
// sharing a stackGroup so they animate as one contiguous bar per category.
|
|
197
|
+
// Also compute stackPos (segment position within each group: 0, 1, 2...)
|
|
198
|
+
// so the renderer can chain segment animations sequentially.
|
|
199
|
+
const groupIndexMap = new Map<string, number>();
|
|
200
|
+
const groupStackPos = new Map<string, number>();
|
|
201
|
+
let nextGroupIndex = 0;
|
|
202
|
+
for (const mark of marks) {
|
|
203
|
+
if (mark.type === 'rect' && (mark as RectMark).stackGroup) {
|
|
204
|
+
const rect = mark as RectMark;
|
|
205
|
+
const group = rect.stackGroup!;
|
|
206
|
+
if (!groupIndexMap.has(group)) {
|
|
207
|
+
groupIndexMap.set(group, nextGroupIndex++);
|
|
208
|
+
}
|
|
209
|
+
rect.animationIndex = groupIndexMap.get(group)!;
|
|
210
|
+
const pos = groupStackPos.get(group) ?? 0;
|
|
211
|
+
rect.stackPos = pos;
|
|
212
|
+
groupStackPos.set(group, pos + 1);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|