@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
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { Mark, PointMark, RectMark, ResolvedAnimation } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import type { ResolvedScales } from '../../layout/scales';
|
|
4
|
+
import { assignAnimationIndices, computeMarkObstacles, resolveRendererKey } from '../post-process';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// computeMarkObstacles
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
describe('computeMarkObstacles', () => {
|
|
11
|
+
it('returns individual rects for non-band rect marks', () => {
|
|
12
|
+
const marks: Mark[] = [
|
|
13
|
+
{ type: 'rect', x: 10, y: 20, width: 50, height: 30, fill: '#000' } as RectMark,
|
|
14
|
+
{ type: 'rect', x: 80, y: 20, width: 50, height: 30, fill: '#000' } as RectMark,
|
|
15
|
+
];
|
|
16
|
+
const scales = { y: { type: 'linear' } } as unknown as ResolvedScales;
|
|
17
|
+
const result = computeMarkObstacles(marks, scales);
|
|
18
|
+
expect(result).toHaveLength(2);
|
|
19
|
+
expect(result[0]).toEqual({ x: 10, y: 20, width: 50, height: 30 });
|
|
20
|
+
expect(result[1]).toEqual({ x: 80, y: 20, width: 50, height: 30 });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns point mark bounds as bounding box from cx/cy/r', () => {
|
|
24
|
+
const marks: Mark[] = [{ type: 'point', cx: 100, cy: 100, r: 10, fill: '#000' } as PointMark];
|
|
25
|
+
const scales = { y: { type: 'linear' } } as unknown as ResolvedScales;
|
|
26
|
+
const result = computeMarkObstacles(marks, scales);
|
|
27
|
+
expect(result).toHaveLength(1);
|
|
28
|
+
expect(result[0]).toEqual({ x: 90, y: 90, width: 20, height: 20 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns grouped row obstacles for band-scale charts', () => {
|
|
32
|
+
const marks: Mark[] = [
|
|
33
|
+
{ type: 'rect', x: 10, y: 50, width: 40, height: 20, fill: '#000' } as RectMark,
|
|
34
|
+
{ type: 'rect', x: 60, y: 50, width: 30, height: 20, fill: '#000' } as RectMark,
|
|
35
|
+
];
|
|
36
|
+
const bandScale = Object.assign((v: string) => (v === 'A' ? 40 : 100), {
|
|
37
|
+
bandwidth: () => 30,
|
|
38
|
+
domain: () => ['A', 'B'],
|
|
39
|
+
});
|
|
40
|
+
const scales = { y: { type: 'band', scale: bandScale } } as unknown as ResolvedScales;
|
|
41
|
+
const result = computeMarkObstacles(marks, scales);
|
|
42
|
+
// Both marks have cy ~60, so they group into one row obstacle
|
|
43
|
+
expect(result).toHaveLength(1);
|
|
44
|
+
expect(result[0].x).toBe(10);
|
|
45
|
+
expect(result[0].width).toBe(80); // 90 - 10
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns empty array for empty marks', () => {
|
|
49
|
+
const scales = { y: { type: 'linear' } } as unknown as ResolvedScales;
|
|
50
|
+
const result = computeMarkObstacles([], scales);
|
|
51
|
+
expect(result).toHaveLength(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// resolveRendererKey
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
describe('resolveRendererKey', () => {
|
|
60
|
+
it('keeps bar as bar when x=quantitative, y=nominal (horizontal)', () => {
|
|
61
|
+
const encoding = {
|
|
62
|
+
x: { field: 'val', type: 'quantitative' },
|
|
63
|
+
y: { field: 'cat', type: 'nominal' },
|
|
64
|
+
};
|
|
65
|
+
expect(resolveRendererKey('bar', encoding, {})).toBe('bar');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('resolves bar to bar:vertical when x=nominal, y=quantitative', () => {
|
|
69
|
+
const encoding = {
|
|
70
|
+
x: { field: 'cat', type: 'nominal' },
|
|
71
|
+
y: { field: 'val', type: 'quantitative' },
|
|
72
|
+
};
|
|
73
|
+
expect(resolveRendererKey('bar', encoding, {})).toBe('bar:vertical');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('resolves bar to bar:vertical when x=ordinal, y=quantitative', () => {
|
|
77
|
+
const encoding = {
|
|
78
|
+
x: { field: 'cat', type: 'ordinal' },
|
|
79
|
+
y: { field: 'val', type: 'quantitative' },
|
|
80
|
+
};
|
|
81
|
+
expect(resolveRendererKey('bar', encoding, {})).toBe('bar:vertical');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('resolves bar to bar:vertical when x=temporal, y=quantitative', () => {
|
|
85
|
+
const encoding = {
|
|
86
|
+
x: { field: 'date', type: 'temporal' },
|
|
87
|
+
y: { field: 'val', type: 'quantitative' },
|
|
88
|
+
};
|
|
89
|
+
expect(resolveRendererKey('bar', encoding, {})).toBe('bar:vertical');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('resolves arc to arc:donut when innerRadius > 0', () => {
|
|
93
|
+
expect(resolveRendererKey('arc', {}, { innerRadius: 50 })).toBe('arc:donut');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('keeps arc as arc when no innerRadius', () => {
|
|
97
|
+
expect(resolveRendererKey('arc', {}, {})).toBe('arc');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('keeps arc as arc when innerRadius is 0', () => {
|
|
101
|
+
expect(resolveRendererKey('arc', {}, { innerRadius: 0 })).toBe('arc');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('passes through other mark types unchanged', () => {
|
|
105
|
+
expect(resolveRendererKey('line', {}, {})).toBe('line');
|
|
106
|
+
expect(resolveRendererKey('area', {}, {})).toBe('area');
|
|
107
|
+
expect(resolveRendererKey('point', {}, {})).toBe('point');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// assignAnimationIndices
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
describe('assignAnimationIndices', () => {
|
|
116
|
+
it('assigns sequential indices sorted by primary value for value stagger', () => {
|
|
117
|
+
const marks: Mark[] = [
|
|
118
|
+
{ type: 'rect', x: 0, y: 0, width: 10, height: 30, fill: '#000' } as RectMark,
|
|
119
|
+
{ type: 'rect', x: 0, y: 0, width: 10, height: 10, fill: '#000' } as RectMark,
|
|
120
|
+
{ type: 'rect', x: 0, y: 0, width: 10, height: 50, fill: '#000' } as RectMark,
|
|
121
|
+
];
|
|
122
|
+
const animation: ResolvedAnimation = {
|
|
123
|
+
enabled: true,
|
|
124
|
+
duration: 500,
|
|
125
|
+
ease: 'smooth',
|
|
126
|
+
staggerDelay: 50,
|
|
127
|
+
staggerOrder: 'value',
|
|
128
|
+
annotationDelay: 0,
|
|
129
|
+
};
|
|
130
|
+
assignAnimationIndices(marks, animation);
|
|
131
|
+
// Sorted by height: 10, 30, 50 -> indices 0, 1, 2
|
|
132
|
+
expect(marks[0].animationIndex).toBe(1); // height 30
|
|
133
|
+
expect(marks[1].animationIndex).toBe(0); // height 10
|
|
134
|
+
expect(marks[2].animationIndex).toBe(2); // height 50
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('assigns group-based indices for stacked rects', () => {
|
|
138
|
+
const marks: Mark[] = [
|
|
139
|
+
{
|
|
140
|
+
type: 'rect',
|
|
141
|
+
x: 0,
|
|
142
|
+
y: 0,
|
|
143
|
+
width: 10,
|
|
144
|
+
height: 30,
|
|
145
|
+
fill: '#000',
|
|
146
|
+
stackGroup: 'A',
|
|
147
|
+
} as RectMark,
|
|
148
|
+
{
|
|
149
|
+
type: 'rect',
|
|
150
|
+
x: 0,
|
|
151
|
+
y: 0,
|
|
152
|
+
width: 10,
|
|
153
|
+
height: 20,
|
|
154
|
+
fill: '#000',
|
|
155
|
+
stackGroup: 'A',
|
|
156
|
+
} as RectMark,
|
|
157
|
+
{
|
|
158
|
+
type: 'rect',
|
|
159
|
+
x: 0,
|
|
160
|
+
y: 0,
|
|
161
|
+
width: 10,
|
|
162
|
+
height: 40,
|
|
163
|
+
fill: '#000',
|
|
164
|
+
stackGroup: 'B',
|
|
165
|
+
} as RectMark,
|
|
166
|
+
];
|
|
167
|
+
const animation: ResolvedAnimation = {
|
|
168
|
+
enabled: true,
|
|
169
|
+
duration: 500,
|
|
170
|
+
ease: 'smooth',
|
|
171
|
+
staggerDelay: 50,
|
|
172
|
+
staggerOrder: 'value',
|
|
173
|
+
annotationDelay: 0,
|
|
174
|
+
};
|
|
175
|
+
assignAnimationIndices(marks, animation);
|
|
176
|
+
// Stack group A gets index 0, B gets index 1
|
|
177
|
+
const rectMarks = marks as RectMark[];
|
|
178
|
+
expect(rectMarks[0].animationIndex).toBe(0);
|
|
179
|
+
expect(rectMarks[0].stackPos).toBe(0);
|
|
180
|
+
expect(rectMarks[1].animationIndex).toBe(0);
|
|
181
|
+
expect(rectMarks[1].stackPos).toBe(1);
|
|
182
|
+
expect(rectMarks[2].animationIndex).toBe(1);
|
|
183
|
+
expect(rectMarks[2].stackPos).toBe(0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('stack indices overwrite value-based indices', () => {
|
|
187
|
+
const marks: Mark[] = [
|
|
188
|
+
{
|
|
189
|
+
type: 'rect',
|
|
190
|
+
x: 0,
|
|
191
|
+
y: 0,
|
|
192
|
+
width: 10,
|
|
193
|
+
height: 30,
|
|
194
|
+
fill: '#000',
|
|
195
|
+
stackGroup: 'A',
|
|
196
|
+
} as RectMark,
|
|
197
|
+
{
|
|
198
|
+
type: 'rect',
|
|
199
|
+
x: 0,
|
|
200
|
+
y: 0,
|
|
201
|
+
width: 10,
|
|
202
|
+
height: 50,
|
|
203
|
+
fill: '#000',
|
|
204
|
+
stackGroup: 'A',
|
|
205
|
+
} as RectMark,
|
|
206
|
+
];
|
|
207
|
+
const animation: ResolvedAnimation = {
|
|
208
|
+
enabled: true,
|
|
209
|
+
duration: 500,
|
|
210
|
+
ease: 'smooth',
|
|
211
|
+
staggerDelay: 50,
|
|
212
|
+
staggerOrder: 'value',
|
|
213
|
+
annotationDelay: 0,
|
|
214
|
+
};
|
|
215
|
+
assignAnimationIndices(marks, animation);
|
|
216
|
+
// Both should have the same group index (0), not value-sorted indices
|
|
217
|
+
expect((marks[0] as RectMark).animationIndex).toBe(0);
|
|
218
|
+
expect((marks[1] as RectMark).animationIndex).toBe(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('is a no-op when animation is undefined', () => {
|
|
222
|
+
const marks: Mark[] = [
|
|
223
|
+
{ type: 'rect', x: 0, y: 0, width: 10, height: 30, fill: '#000' } as RectMark,
|
|
224
|
+
];
|
|
225
|
+
assignAnimationIndices(marks, undefined);
|
|
226
|
+
expect(marks[0].animationIndex).toBeUndefined();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('is a no-op when animation is disabled', () => {
|
|
230
|
+
const marks: Mark[] = [
|
|
231
|
+
{ type: 'rect', x: 0, y: 0, width: 10, height: 30, fill: '#000' } as RectMark,
|
|
232
|
+
];
|
|
233
|
+
const animation: ResolvedAnimation = {
|
|
234
|
+
enabled: false,
|
|
235
|
+
duration: 500,
|
|
236
|
+
ease: 'smooth',
|
|
237
|
+
staggerDelay: 50,
|
|
238
|
+
staggerOrder: 'value',
|
|
239
|
+
annotationDelay: 0,
|
|
240
|
+
};
|
|
241
|
+
assignAnimationIndices(marks, animation);
|
|
242
|
+
expect(marks[0].animationIndex).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('handles empty marks array', () => {
|
|
246
|
+
const animation: ResolvedAnimation = {
|
|
247
|
+
enabled: true,
|
|
248
|
+
duration: 500,
|
|
249
|
+
ease: 'smooth',
|
|
250
|
+
staggerDelay: 50,
|
|
251
|
+
staggerOrder: 'value',
|
|
252
|
+
annotationDelay: 0,
|
|
253
|
+
};
|
|
254
|
+
const marks: Mark[] = [];
|
|
255
|
+
assignAnimationIndices(marks, animation);
|
|
256
|
+
expect(marks).toHaveLength(0);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -185,3 +185,34 @@ describe('computeBarLabels with $~s format and abbreviated aria values', () => {
|
|
|
185
185
|
expect(labels[0].text).toBe('$500');
|
|
186
186
|
});
|
|
187
187
|
});
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Unicode minus sign handling
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
describe('computeBarLabels with Unicode minus (U+2212) in aria values', () => {
|
|
194
|
+
function makeUnicodeMinusMark(index: number, ariaValue: string): RectMark {
|
|
195
|
+
return {
|
|
196
|
+
type: 'rect',
|
|
197
|
+
x: 0,
|
|
198
|
+
y: index * 30,
|
|
199
|
+
width: 200,
|
|
200
|
+
height: 25,
|
|
201
|
+
fill: '#4e79a7',
|
|
202
|
+
data: { category: `Cat${index}` },
|
|
203
|
+
aria: { label: `Cat${index}: ${ariaValue}` },
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
it('parses Unicode minus and applies format with % suffix', () => {
|
|
208
|
+
const unicodeMarks: RectMark[] = [
|
|
209
|
+
makeUnicodeMinusMark(0, '\u221234'), // −34
|
|
210
|
+
makeUnicodeMinusMark(1, '\u22125'), // −5
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
const labels = computeBarLabels(unicodeMarks, chartArea, 'all', '+.0f%');
|
|
214
|
+
expect(labels).toHaveLength(2);
|
|
215
|
+
expect(labels[0].text).toBe('\u221234%'); // −34%
|
|
216
|
+
expect(labels[1].text).toBe('\u22125%'); // −5%
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -116,6 +116,13 @@ export function computeBarMarks(
|
|
|
116
116
|
);
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
const stackMode =
|
|
120
|
+
xChannel.stack === 'normalize'
|
|
121
|
+
? 'normalize'
|
|
122
|
+
: xChannel.stack === 'center'
|
|
123
|
+
? 'center'
|
|
124
|
+
: 'zero';
|
|
125
|
+
|
|
119
126
|
return computeStackedBars(
|
|
120
127
|
spec.data,
|
|
121
128
|
xChannel.field,
|
|
@@ -126,6 +133,7 @@ export function computeBarMarks(
|
|
|
126
133
|
bandwidth,
|
|
127
134
|
baseline,
|
|
128
135
|
scales,
|
|
136
|
+
stackMode,
|
|
129
137
|
);
|
|
130
138
|
}
|
|
131
139
|
|
|
@@ -143,7 +151,7 @@ export function computeBarMarks(
|
|
|
143
151
|
);
|
|
144
152
|
}
|
|
145
153
|
|
|
146
|
-
/** Compute stacked horizontal bars. */
|
|
154
|
+
/** Compute stacked horizontal bars with support for zero/normalize/center modes. */
|
|
147
155
|
function computeStackedBars(
|
|
148
156
|
data: DataRow[],
|
|
149
157
|
valueField: string,
|
|
@@ -154,6 +162,7 @@ function computeStackedBars(
|
|
|
154
162
|
bandwidth: number,
|
|
155
163
|
_baseline: number,
|
|
156
164
|
scales: ResolvedScales,
|
|
165
|
+
stackMode: 'zero' | 'normalize' | 'center' = 'zero',
|
|
157
166
|
): RectMark[] {
|
|
158
167
|
const marks: RectMark[] = [];
|
|
159
168
|
const categoryGroups = groupByField(data, categoryField);
|
|
@@ -162,13 +171,25 @@ function computeStackedBars(
|
|
|
162
171
|
const bandY = yScale(category);
|
|
163
172
|
if (bandY === undefined) continue;
|
|
164
173
|
|
|
165
|
-
|
|
174
|
+
// Compute category total for normalize/center modes
|
|
175
|
+
let categoryTotal = 0;
|
|
176
|
+
for (const row of rows) {
|
|
177
|
+
const v = Number(row[valueField] ?? 0);
|
|
178
|
+
if (Number.isFinite(v) && v > 0) categoryTotal += v;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// For center mode, offset so the stack is centered around zero
|
|
182
|
+
let cumulativeValue = stackMode === 'center' ? -categoryTotal / 2 : 0;
|
|
166
183
|
|
|
167
184
|
for (const row of rows) {
|
|
168
185
|
const groupKey = String(row[colorField] ?? '');
|
|
169
|
-
const
|
|
186
|
+
const rawValue = Number(row[valueField] ?? 0);
|
|
170
187
|
// Only stack positive values (same approach as stacked columns)
|
|
171
|
-
if (!Number.isFinite(
|
|
188
|
+
if (!Number.isFinite(rawValue) || rawValue <= 0) continue;
|
|
189
|
+
|
|
190
|
+
// For normalize mode, scale the value to a fraction of the total
|
|
191
|
+
const value =
|
|
192
|
+
stackMode === 'normalize' && categoryTotal > 0 ? rawValue / categoryTotal : rawValue;
|
|
172
193
|
|
|
173
194
|
const color = getColor(scales, groupKey);
|
|
174
195
|
|
|
@@ -177,12 +198,12 @@ function computeStackedBars(
|
|
|
177
198
|
const barWidth = Math.max(Math.abs(xRight - xLeft), MIN_BAR_WIDTH);
|
|
178
199
|
|
|
179
200
|
const aria: MarkAria = {
|
|
180
|
-
label: `${category}, ${groupKey}: ${formatBarValue(
|
|
201
|
+
label: `${category}, ${groupKey}: ${formatBarValue(rawValue)}`,
|
|
181
202
|
};
|
|
182
203
|
|
|
183
204
|
marks.push({
|
|
184
205
|
type: 'rect',
|
|
185
|
-
x: xLeft,
|
|
206
|
+
x: Math.min(xLeft, xRight),
|
|
186
207
|
y: bandY,
|
|
187
208
|
width: barWidth,
|
|
188
209
|
height: bandwidth,
|
package/src/charts/bar/index.ts
CHANGED
|
@@ -17,12 +17,15 @@ export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _t
|
|
|
17
17
|
const marks = computeBarMarks(spec, scales, chartArea, strategy);
|
|
18
18
|
|
|
19
19
|
// Compute and attach value labels (respects spec.labels.density)
|
|
20
|
+
const valueField =
|
|
21
|
+
spec.encoding?.x && 'field' in spec.encoding.x ? spec.encoding.x.field : undefined;
|
|
20
22
|
const labels = computeBarLabels(
|
|
21
23
|
marks,
|
|
22
24
|
chartArea,
|
|
23
25
|
spec.labels.density,
|
|
24
26
|
spec.labels.format,
|
|
25
27
|
spec.labels.prefix,
|
|
28
|
+
valueField,
|
|
26
29
|
);
|
|
27
30
|
for (let i = 0; i < marks.length && i < labels.length; i++) {
|
|
28
31
|
marks[i].label = labels[i];
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -18,8 +18,10 @@ 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';
|
|
@@ -28,6 +30,12 @@ import {
|
|
|
28
30
|
// Helpers
|
|
29
31
|
// ---------------------------------------------------------------------------
|
|
30
32
|
|
|
33
|
+
/** Format a bar value for display (abbreviate large numbers). */
|
|
34
|
+
function formatBarValue(value: number): string {
|
|
35
|
+
if (Math.abs(value) >= 1000) return abbreviateNumber(value);
|
|
36
|
+
return formatNumber(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
/** Suffix multipliers mirroring core's abbreviateNumber output (K/M/B/T). */
|
|
32
40
|
const SUFFIX_MULTIPLIERS: Record<string, number> = {
|
|
33
41
|
K: 1_000,
|
|
@@ -42,7 +50,8 @@ const SUFFIX_MULTIPLIERS: Record<string, number> = {
|
|
|
42
50
|
* Returns NaN when the string cannot be parsed.
|
|
43
51
|
*/
|
|
44
52
|
function parseDisplayNumber(raw: string): number {
|
|
45
|
-
|
|
53
|
+
// Normalize Unicode minus (U+2212, produced by d3-format) to ASCII hyphen-minus
|
|
54
|
+
const trimmed = raw.trim().replace(/\u2212/g, '-');
|
|
46
55
|
if (!trimmed) return NaN;
|
|
47
56
|
|
|
48
57
|
// Check for trailing abbreviation suffix (case-insensitive)
|
|
@@ -54,6 +63,11 @@ function parseDisplayNumber(raw: string): number {
|
|
|
54
63
|
return Number.isNaN(n) ? NaN : n * multiplier;
|
|
55
64
|
}
|
|
56
65
|
|
|
66
|
+
// Strip literal % suffix (e.g., from "+.0f%" d3-format strings)
|
|
67
|
+
if (last === '%') {
|
|
68
|
+
return Number(trimmed.slice(0, -1).replace(/,/g, ''));
|
|
69
|
+
}
|
|
70
|
+
|
|
57
71
|
// No suffix — strip commas and parse
|
|
58
72
|
return Number(trimmed.replace(/,/g, ''));
|
|
59
73
|
}
|
|
@@ -84,6 +98,7 @@ export function computeBarLabels(
|
|
|
84
98
|
density: LabelDensity = 'auto',
|
|
85
99
|
labelFormat?: string,
|
|
86
100
|
labelPrefix?: string,
|
|
101
|
+
valueField?: string,
|
|
87
102
|
): ResolvedLabel[] {
|
|
88
103
|
// 'none': no labels at all
|
|
89
104
|
if (density === 'none') return [];
|
|
@@ -100,19 +115,28 @@ export function computeBarLabels(
|
|
|
100
115
|
const formatter = buildD3Formatter(labelFormat);
|
|
101
116
|
|
|
102
117
|
for (const mark of targetMarks) {
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
118
|
+
// Get the original numeric value from the data row when possible,
|
|
119
|
+
// falling back to parsing the aria label (which may lose precision
|
|
120
|
+
// due to abbreviation rounding, e.g. 1955 → "2K" → 2000).
|
|
121
|
+
let valuePart: string;
|
|
122
|
+
const rawNum = valueField != null ? Number(mark.data[valueField]) : NaN;
|
|
123
|
+
|
|
124
|
+
if (formatter && Number.isFinite(rawNum)) {
|
|
125
|
+
valuePart = formatter(rawNum);
|
|
126
|
+
} else if (Number.isFinite(rawNum)) {
|
|
127
|
+
valuePart = formatBarValue(rawNum);
|
|
128
|
+
} else {
|
|
129
|
+
// Fallback: extract from aria label
|
|
130
|
+
const ariaLabel = mark.aria.label;
|
|
131
|
+
const lastColon = ariaLabel.lastIndexOf(':');
|
|
132
|
+
const rawValue = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
|
|
133
|
+
if (!rawValue) continue;
|
|
134
|
+
if (formatter) {
|
|
135
|
+
const num = parseDisplayNumber(rawValue);
|
|
136
|
+
valuePart = !Number.isNaN(num) ? formatter(num) : rawValue;
|
|
137
|
+
} else {
|
|
138
|
+
valuePart = rawValue;
|
|
139
|
+
}
|
|
116
140
|
}
|
|
117
141
|
if (labelPrefix) valuePart = labelPrefix + valuePart;
|
|
118
142
|
|
|
@@ -424,3 +424,102 @@ describe('computeColumnLabels', () => {
|
|
|
424
424
|
expect(texts).toContain('200%');
|
|
425
425
|
});
|
|
426
426
|
});
|
|
427
|
+
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// Stack mode tests
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
describe('stack modes', () => {
|
|
433
|
+
function makeStackedSpec(
|
|
434
|
+
stackMode: boolean | 'zero' | 'normalize' | 'center' | null,
|
|
435
|
+
): NormalizedChartSpec {
|
|
436
|
+
return {
|
|
437
|
+
markType: 'bar',
|
|
438
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
439
|
+
data: [
|
|
440
|
+
{ cat: 'A', val: 30, grp: 'X' },
|
|
441
|
+
{ cat: 'A', val: 70, grp: 'Y' },
|
|
442
|
+
{ cat: 'B', val: 40, grp: 'X' },
|
|
443
|
+
{ cat: 'B', val: 60, grp: 'Y' },
|
|
444
|
+
],
|
|
445
|
+
encoding: {
|
|
446
|
+
x: { field: 'cat', type: 'nominal' },
|
|
447
|
+
y: { field: 'val', type: 'quantitative', stack: stackMode },
|
|
448
|
+
color: { field: 'grp', type: 'nominal' },
|
|
449
|
+
},
|
|
450
|
+
chrome: {},
|
|
451
|
+
annotations: [],
|
|
452
|
+
responsive: true,
|
|
453
|
+
theme: {},
|
|
454
|
+
darkMode: 'off',
|
|
455
|
+
labels: { density: 'auto', format: '' },
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
it('normalize: produces marks whose stacked fractions sum to ~1 per category', () => {
|
|
460
|
+
const spec = makeStackedSpec('normalize');
|
|
461
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
462
|
+
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
463
|
+
|
|
464
|
+
expect(marks.length).toBe(4);
|
|
465
|
+
|
|
466
|
+
// Group by category (stackGroup) and verify normalized heights
|
|
467
|
+
const catA = marks.filter((m) => m.stackGroup === 'A');
|
|
468
|
+
const catB = marks.filter((m) => m.stackGroup === 'B');
|
|
469
|
+
expect(catA).toHaveLength(2);
|
|
470
|
+
expect(catB).toHaveLength(2);
|
|
471
|
+
|
|
472
|
+
// The y scale domain is [0, 1] for normalize. Verify marks don't overlap
|
|
473
|
+
// and each category's marks span the full [0, 1] range when mapped back.
|
|
474
|
+
// Category A: 30/(30+70)=0.3, 70/(30+70)=0.7
|
|
475
|
+
// Category B: 40/(40+60)=0.4, 60/(40+60)=0.6
|
|
476
|
+
// All marks should have non-zero height
|
|
477
|
+
for (const mark of marks) {
|
|
478
|
+
expect(mark.height).toBeGreaterThan(0);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('center: produces marks with symmetric offsets around zero', () => {
|
|
483
|
+
const spec = makeStackedSpec('center');
|
|
484
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
485
|
+
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
486
|
+
|
|
487
|
+
expect(marks.length).toBe(4);
|
|
488
|
+
|
|
489
|
+
// All marks should have non-zero height
|
|
490
|
+
for (const mark of marks) {
|
|
491
|
+
expect(mark.height).toBeGreaterThan(0);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('existing zero mode still works correctly', () => {
|
|
496
|
+
const spec = makeStackedSpec('zero');
|
|
497
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
498
|
+
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
499
|
+
|
|
500
|
+
expect(marks.length).toBe(4);
|
|
501
|
+
// All stacked marks should have stackGroup set
|
|
502
|
+
for (const mark of marks) {
|
|
503
|
+
expect(mark.stackGroup).toBeDefined();
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('null/false disables stacking (grouped mode)', () => {
|
|
508
|
+
const specNull = makeStackedSpec(null);
|
|
509
|
+
const scalesNull = computeScales(specNull, chartArea, specNull.data);
|
|
510
|
+
const marksNull = computeColumnMarks(specNull, scalesNull, chartArea, fullStrategy);
|
|
511
|
+
|
|
512
|
+
// Grouped mode: no stackGroup
|
|
513
|
+
for (const mark of marksNull) {
|
|
514
|
+
expect(mark.stackGroup).toBeUndefined();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const specFalse = makeStackedSpec(false);
|
|
518
|
+
const scalesFalse = computeScales(specFalse, chartArea, specFalse.data);
|
|
519
|
+
const marksFalse = computeColumnMarks(specFalse, scalesFalse, chartArea, fullStrategy);
|
|
520
|
+
|
|
521
|
+
for (const mark of marksFalse) {
|
|
522
|
+
expect(mark.stackGroup).toBeUndefined();
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
});
|
|
@@ -106,6 +106,13 @@ export function computeColumnMarks(
|
|
|
106
106
|
);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
const stackMode =
|
|
110
|
+
yChannel.stack === 'normalize'
|
|
111
|
+
? 'normalize'
|
|
112
|
+
: yChannel.stack === 'center'
|
|
113
|
+
? 'center'
|
|
114
|
+
: 'zero';
|
|
115
|
+
|
|
109
116
|
return computeStackedColumns(
|
|
110
117
|
spec.data,
|
|
111
118
|
xChannel.field,
|
|
@@ -116,6 +123,7 @@ export function computeColumnMarks(
|
|
|
116
123
|
bandwidth,
|
|
117
124
|
baseline,
|
|
118
125
|
scales,
|
|
126
|
+
stackMode,
|
|
119
127
|
);
|
|
120
128
|
}
|
|
121
129
|
|
|
@@ -333,7 +341,7 @@ function computeGroupedColumns(
|
|
|
333
341
|
return marks;
|
|
334
342
|
}
|
|
335
343
|
|
|
336
|
-
/** Compute stacked vertical columns. */
|
|
344
|
+
/** Compute stacked vertical columns with support for zero/normalize/center modes. */
|
|
337
345
|
function computeStackedColumns(
|
|
338
346
|
data: DataRow[],
|
|
339
347
|
categoryField: string,
|
|
@@ -344,6 +352,7 @@ function computeStackedColumns(
|
|
|
344
352
|
bandwidth: number,
|
|
345
353
|
_baseline: number,
|
|
346
354
|
scales: ResolvedScales,
|
|
355
|
+
stackMode: 'zero' | 'normalize' | 'center' = 'zero',
|
|
347
356
|
): RectMark[] {
|
|
348
357
|
const marks: RectMark[] = [];
|
|
349
358
|
const categoryGroups = groupByField(data, categoryField);
|
|
@@ -352,14 +361,26 @@ function computeStackedColumns(
|
|
|
352
361
|
const bandX = xScale(category);
|
|
353
362
|
if (bandX === undefined) continue;
|
|
354
363
|
|
|
355
|
-
|
|
364
|
+
// Compute category total for normalize/center modes
|
|
365
|
+
let categoryTotal = 0;
|
|
366
|
+
for (const row of rows) {
|
|
367
|
+
const v = Number(row[valueField] ?? 0);
|
|
368
|
+
if (Number.isFinite(v) && v > 0) categoryTotal += v;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// For center mode, offset so the stack is centered around zero
|
|
372
|
+
let cumulativeValue = stackMode === 'center' ? -categoryTotal / 2 : 0;
|
|
356
373
|
|
|
357
374
|
for (const row of rows) {
|
|
358
375
|
const groupKey = String(row[colorField] ?? '');
|
|
359
|
-
const
|
|
376
|
+
const rawValue = Number(row[valueField] ?? 0);
|
|
360
377
|
// Stacking only applies to positive values; negative/zero rows are skipped
|
|
361
378
|
// since cumulative stacking doesn't make visual sense for mixed signs.
|
|
362
|
-
if (!Number.isFinite(
|
|
379
|
+
if (!Number.isFinite(rawValue) || rawValue <= 0) continue;
|
|
380
|
+
|
|
381
|
+
// For normalize mode, scale the value to a fraction of the total
|
|
382
|
+
const value =
|
|
383
|
+
stackMode === 'normalize' && categoryTotal > 0 ? rawValue / categoryTotal : rawValue;
|
|
363
384
|
|
|
364
385
|
const color = getColor(scales, groupKey);
|
|
365
386
|
|
|
@@ -368,13 +389,13 @@ function computeStackedColumns(
|
|
|
368
389
|
const columnHeight = Math.max(Math.abs(yBottom - yTop), MIN_COLUMN_HEIGHT);
|
|
369
390
|
|
|
370
391
|
const aria: MarkAria = {
|
|
371
|
-
label: `${category}, ${groupKey}: ${formatColumnValue(
|
|
392
|
+
label: `${category}, ${groupKey}: ${formatColumnValue(rawValue)}`,
|
|
372
393
|
};
|
|
373
394
|
|
|
374
395
|
marks.push({
|
|
375
396
|
type: 'rect',
|
|
376
397
|
x: bandX,
|
|
377
|
-
y: yTop,
|
|
398
|
+
y: Math.min(yTop, yBottom),
|
|
378
399
|
width: bandwidth,
|
|
379
400
|
height: columnHeight,
|
|
380
401
|
fill: color,
|
|
@@ -17,12 +17,15 @@ export const columnRenderer: ChartRenderer = (spec, scales, chartArea, strategy,
|
|
|
17
17
|
const marks = computeColumnMarks(spec, scales, chartArea, strategy);
|
|
18
18
|
|
|
19
19
|
// Compute and attach value labels (respects spec.labels.density)
|
|
20
|
+
const valueField =
|
|
21
|
+
spec.encoding?.y && 'field' in spec.encoding.y ? spec.encoding.y.field : undefined;
|
|
20
22
|
const labels = computeColumnLabels(
|
|
21
23
|
marks,
|
|
22
24
|
chartArea,
|
|
23
25
|
spec.labels.density,
|
|
24
26
|
spec.labels.format,
|
|
25
27
|
spec.labels.prefix,
|
|
28
|
+
valueField,
|
|
26
29
|
);
|
|
27
30
|
for (let i = 0; i < marks.length && i < labels.length; i++) {
|
|
28
31
|
marks[i].label = labels[i];
|