@opendata-ai/openchart-engine 1.2.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.d.ts +366 -0
- package/dist/index.js +4227 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/src/__test-fixtures__/specs.ts +124 -0
- package/src/__tests__/axes.test.ts +114 -0
- package/src/__tests__/compile-chart.test.ts +337 -0
- package/src/__tests__/dimensions.test.ts +151 -0
- package/src/__tests__/legend.test.ts +113 -0
- package/src/__tests__/scales.test.ts +109 -0
- package/src/annotations/__tests__/compute.test.ts +454 -0
- package/src/annotations/compute.ts +603 -0
- package/src/charts/__tests__/registry.test.ts +110 -0
- package/src/charts/bar/__tests__/compute.test.ts +294 -0
- package/src/charts/bar/__tests__/labels.test.ts +75 -0
- package/src/charts/bar/compute.ts +205 -0
- package/src/charts/bar/index.ts +33 -0
- package/src/charts/bar/labels.ts +132 -0
- package/src/charts/column/__tests__/compute.test.ts +277 -0
- package/src/charts/column/compute.ts +282 -0
- package/src/charts/column/index.ts +33 -0
- package/src/charts/column/labels.ts +108 -0
- package/src/charts/dot/__tests__/compute.test.ts +344 -0
- package/src/charts/dot/compute.ts +257 -0
- package/src/charts/dot/index.ts +46 -0
- package/src/charts/dot/labels.ts +97 -0
- package/src/charts/line/__tests__/compute.test.ts +437 -0
- package/src/charts/line/__tests__/labels.test.ts +93 -0
- package/src/charts/line/area.ts +288 -0
- package/src/charts/line/compute.ts +177 -0
- package/src/charts/line/index.ts +68 -0
- package/src/charts/line/labels.ts +144 -0
- package/src/charts/pie/__tests__/compute.test.ts +276 -0
- package/src/charts/pie/compute.ts +234 -0
- package/src/charts/pie/index.ts +49 -0
- package/src/charts/pie/labels.ts +142 -0
- package/src/charts/registry.ts +64 -0
- package/src/charts/scatter/__tests__/compute.test.ts +304 -0
- package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
- package/src/charts/scatter/compute.ts +124 -0
- package/src/charts/scatter/index.ts +41 -0
- package/src/charts/scatter/trendline.ts +100 -0
- package/src/charts/utils.ts +120 -0
- package/src/compile.ts +368 -0
- package/src/compiler/__tests__/compile.test.ts +87 -0
- package/src/compiler/__tests__/normalize.test.ts +210 -0
- package/src/compiler/__tests__/validate.test.ts +440 -0
- package/src/compiler/index.ts +47 -0
- package/src/compiler/normalize.ts +269 -0
- package/src/compiler/types.ts +148 -0
- package/src/compiler/validate.ts +581 -0
- package/src/graphs/__tests__/community.test.ts +228 -0
- package/src/graphs/__tests__/compile-graph.test.ts +315 -0
- package/src/graphs/__tests__/encoding.test.ts +314 -0
- package/src/graphs/community.ts +92 -0
- package/src/graphs/compile-graph.ts +291 -0
- package/src/graphs/encoding.ts +302 -0
- package/src/graphs/types.ts +98 -0
- package/src/index.ts +74 -0
- package/src/layout/axes.ts +194 -0
- package/src/layout/dimensions.ts +199 -0
- package/src/layout/gridlines.ts +84 -0
- package/src/layout/scales.ts +426 -0
- package/src/legend/compute.ts +186 -0
- package/src/tables/__tests__/bar-column.test.ts +147 -0
- package/src/tables/__tests__/category-colors.test.ts +153 -0
- package/src/tables/__tests__/compile-table.test.ts +208 -0
- package/src/tables/__tests__/format-cells.test.ts +126 -0
- package/src/tables/__tests__/heatmap.test.ts +124 -0
- package/src/tables/__tests__/pagination.test.ts +78 -0
- package/src/tables/__tests__/search.test.ts +94 -0
- package/src/tables/__tests__/sort.test.ts +107 -0
- package/src/tables/__tests__/sparkline.test.ts +122 -0
- package/src/tables/bar-column.ts +94 -0
- package/src/tables/category-colors.ts +67 -0
- package/src/tables/compile-table.ts +420 -0
- package/src/tables/format-cells.ts +110 -0
- package/src/tables/heatmap.ts +121 -0
- package/src/tables/pagination.ts +46 -0
- package/src/tables/search.ts +66 -0
- package/src/tables/sort.ts +69 -0
- package/src/tables/sparkline.ts +113 -0
- package/src/tables/utils.ts +16 -0
- package/src/tooltips/__tests__/compute.test.ts +328 -0
- package/src/tooltips/compute.ts +231 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import type { LayoutStrategy, Rect } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import type { NormalizedChartSpec } from '../../../compiler/types';
|
|
4
|
+
import { computeScales } from '../../../layout/scales';
|
|
5
|
+
import { computeBarMarks } from '../compute';
|
|
6
|
+
import { computeBarLabels } from '../labels';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Shared fixtures
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
const chartArea: Rect = { x: 80, y: 20, width: 500, height: 300 };
|
|
13
|
+
|
|
14
|
+
const fullStrategy: LayoutStrategy = {
|
|
15
|
+
labelMode: 'all',
|
|
16
|
+
legendPosition: 'right',
|
|
17
|
+
annotationPosition: 'inline',
|
|
18
|
+
axisLabelDensity: 'full',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function makeSimpleBarSpec(): NormalizedChartSpec {
|
|
22
|
+
return {
|
|
23
|
+
type: 'bar',
|
|
24
|
+
data: [
|
|
25
|
+
{ category: 'Apple', value: 50 },
|
|
26
|
+
{ category: 'Banana', value: 30 },
|
|
27
|
+
{ category: 'Cherry', value: 70 },
|
|
28
|
+
],
|
|
29
|
+
encoding: {
|
|
30
|
+
x: { field: 'value', type: 'quantitative' },
|
|
31
|
+
y: { field: 'category', type: 'nominal' },
|
|
32
|
+
},
|
|
33
|
+
chrome: {},
|
|
34
|
+
annotations: [],
|
|
35
|
+
responsive: true,
|
|
36
|
+
theme: {},
|
|
37
|
+
darkMode: 'off',
|
|
38
|
+
labels: { density: 'auto', format: '' },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeGroupedBarSpec(): NormalizedChartSpec {
|
|
43
|
+
return {
|
|
44
|
+
type: 'bar',
|
|
45
|
+
data: [
|
|
46
|
+
{ category: 'Q1', value: 50, region: 'East' },
|
|
47
|
+
{ category: 'Q1', value: 40, region: 'West' },
|
|
48
|
+
{ category: 'Q2', value: 60, region: 'East' },
|
|
49
|
+
{ category: 'Q2', value: 55, region: 'West' },
|
|
50
|
+
{ category: 'Q3', value: 45, region: 'East' },
|
|
51
|
+
{ category: 'Q3', value: 70, region: 'West' },
|
|
52
|
+
],
|
|
53
|
+
encoding: {
|
|
54
|
+
x: { field: 'value', type: 'quantitative' },
|
|
55
|
+
y: { field: 'category', type: 'nominal' },
|
|
56
|
+
color: { field: 'region', type: 'nominal' },
|
|
57
|
+
},
|
|
58
|
+
chrome: {},
|
|
59
|
+
annotations: [],
|
|
60
|
+
responsive: true,
|
|
61
|
+
theme: {},
|
|
62
|
+
darkMode: 'off',
|
|
63
|
+
labels: { density: 'auto', format: '' },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeNegativeBarSpec(): NormalizedChartSpec {
|
|
68
|
+
return {
|
|
69
|
+
type: 'bar',
|
|
70
|
+
data: [
|
|
71
|
+
{ category: 'Growth', value: 15 },
|
|
72
|
+
{ category: 'Decline', value: -10 },
|
|
73
|
+
{ category: 'Stable', value: 2 },
|
|
74
|
+
],
|
|
75
|
+
encoding: {
|
|
76
|
+
x: { field: 'value', type: 'quantitative' },
|
|
77
|
+
y: { field: 'category', type: 'nominal' },
|
|
78
|
+
},
|
|
79
|
+
chrome: {},
|
|
80
|
+
annotations: [],
|
|
81
|
+
responsive: true,
|
|
82
|
+
theme: {},
|
|
83
|
+
darkMode: 'off',
|
|
84
|
+
labels: { density: 'auto', format: '' },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// computeBarMarks tests
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
describe('computeBarMarks', () => {
|
|
93
|
+
describe('simple bars', () => {
|
|
94
|
+
it('produces one RectMark per data row', () => {
|
|
95
|
+
const spec = makeSimpleBarSpec();
|
|
96
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
97
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
98
|
+
|
|
99
|
+
expect(marks).toHaveLength(3);
|
|
100
|
+
expect(marks.every((m) => m.type === 'rect')).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('bars have positive width and height', () => {
|
|
104
|
+
const spec = makeSimpleBarSpec();
|
|
105
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
106
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
107
|
+
|
|
108
|
+
for (const mark of marks) {
|
|
109
|
+
expect(mark.width).toBeGreaterThan(0);
|
|
110
|
+
expect(mark.height).toBeGreaterThan(0);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('wider bars correspond to larger values', () => {
|
|
115
|
+
const spec = makeSimpleBarSpec();
|
|
116
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
117
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
118
|
+
|
|
119
|
+
// Cherry (70) should be wider than Banana (30)
|
|
120
|
+
const cherry = marks.find((m) => m.aria.label.includes('Cherry'))!;
|
|
121
|
+
const banana = marks.find((m) => m.aria.label.includes('Banana'))!;
|
|
122
|
+
expect(cherry.width).toBeGreaterThan(banana.width);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('bars have corner radius applied', () => {
|
|
126
|
+
const spec = makeSimpleBarSpec();
|
|
127
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
128
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
129
|
+
|
|
130
|
+
expect(marks[0].cornerRadius).toBe(2);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('each bar has an aria label with category and value', () => {
|
|
134
|
+
const spec = makeSimpleBarSpec();
|
|
135
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
136
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
137
|
+
|
|
138
|
+
expect(marks[0].aria.label).toContain('Apple');
|
|
139
|
+
expect(marks[0].aria.label).toContain('50');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('stacked bars', () => {
|
|
144
|
+
it('produces marks for all data rows', () => {
|
|
145
|
+
const spec = makeGroupedBarSpec();
|
|
146
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
147
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
148
|
+
|
|
149
|
+
// 3 categories * 2 groups = 6
|
|
150
|
+
expect(marks).toHaveLength(6);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('segments within a category have different colors', () => {
|
|
154
|
+
const spec = makeGroupedBarSpec();
|
|
155
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
156
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
157
|
+
|
|
158
|
+
// Find Q1 bars
|
|
159
|
+
const q1Marks = marks.filter((m) => m.aria.label.includes('Q1'));
|
|
160
|
+
expect(q1Marks).toHaveLength(2);
|
|
161
|
+
expect(q1Marks[0].fill).not.toBe(q1Marks[1].fill);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('stacked segments share the same y position within a category', () => {
|
|
165
|
+
const spec = makeGroupedBarSpec();
|
|
166
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
167
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
168
|
+
|
|
169
|
+
const q1East = marks.find(
|
|
170
|
+
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
|
|
171
|
+
)!;
|
|
172
|
+
const q1West = marks.find(
|
|
173
|
+
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('West'),
|
|
174
|
+
)!;
|
|
175
|
+
|
|
176
|
+
// Stacked bars share the same y position (full band height)
|
|
177
|
+
expect(q1East.y).toBe(q1West.y);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('stacked segments are placed end-to-end horizontally', () => {
|
|
181
|
+
const spec = makeGroupedBarSpec();
|
|
182
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
183
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
184
|
+
|
|
185
|
+
const q1Marks = marks.filter((m) => m.aria.label.includes('Q1'));
|
|
186
|
+
// Second segment should start where first ends
|
|
187
|
+
const first = q1Marks[0];
|
|
188
|
+
const second = q1Marks[1];
|
|
189
|
+
expect(second.x).toBeCloseTo(first.x + first.width, 0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('stacked bars have zero corner radius', () => {
|
|
193
|
+
const spec = makeGroupedBarSpec();
|
|
194
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
195
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
196
|
+
|
|
197
|
+
for (const mark of marks) {
|
|
198
|
+
expect(mark.cornerRadius).toBe(0);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('negative values', () => {
|
|
204
|
+
it('negative bars extend leftward from baseline', () => {
|
|
205
|
+
const spec = makeNegativeBarSpec();
|
|
206
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
207
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
208
|
+
|
|
209
|
+
const decline = marks.find((m) => m.aria.label.includes('Decline'))!;
|
|
210
|
+
const growth = marks.find((m) => m.aria.label.includes('Growth'))!;
|
|
211
|
+
|
|
212
|
+
// Negative bar should start to the left of where positive bar starts
|
|
213
|
+
expect(decline.x).toBeLessThan(growth.x);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('negative bars still have positive width', () => {
|
|
217
|
+
const spec = makeNegativeBarSpec();
|
|
218
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
219
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
220
|
+
|
|
221
|
+
for (const mark of marks) {
|
|
222
|
+
expect(mark.width).toBeGreaterThan(0);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('edge cases', () => {
|
|
228
|
+
it('returns empty array when no x encoding', () => {
|
|
229
|
+
const spec: NormalizedChartSpec = {
|
|
230
|
+
type: 'bar',
|
|
231
|
+
data: [{ category: 'A', value: 10 }],
|
|
232
|
+
encoding: {
|
|
233
|
+
y: { field: 'category', type: 'nominal' },
|
|
234
|
+
},
|
|
235
|
+
chrome: {},
|
|
236
|
+
annotations: [],
|
|
237
|
+
responsive: true,
|
|
238
|
+
theme: {},
|
|
239
|
+
darkMode: 'off',
|
|
240
|
+
labels: { density: 'auto', format: '' },
|
|
241
|
+
};
|
|
242
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
243
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
244
|
+
expect(marks).toHaveLength(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('returns empty array for empty data', () => {
|
|
248
|
+
const spec: NormalizedChartSpec = {
|
|
249
|
+
type: 'bar',
|
|
250
|
+
data: [],
|
|
251
|
+
encoding: {
|
|
252
|
+
x: { field: 'value', type: 'quantitative' },
|
|
253
|
+
y: { field: 'category', type: 'nominal' },
|
|
254
|
+
},
|
|
255
|
+
chrome: {},
|
|
256
|
+
annotations: [],
|
|
257
|
+
responsive: true,
|
|
258
|
+
theme: {},
|
|
259
|
+
darkMode: 'off',
|
|
260
|
+
labels: { density: 'auto', format: '' },
|
|
261
|
+
};
|
|
262
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
263
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
264
|
+
expect(marks).toHaveLength(0);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// computeBarLabels tests
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
describe('computeBarLabels', () => {
|
|
274
|
+
it('produces one label per bar mark', () => {
|
|
275
|
+
const spec = makeSimpleBarSpec();
|
|
276
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
277
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
278
|
+
const labels = computeBarLabels(marks, chartArea);
|
|
279
|
+
|
|
280
|
+
expect(labels).toHaveLength(marks.length);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('labels contain the value text', () => {
|
|
284
|
+
const spec = makeSimpleBarSpec();
|
|
285
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
286
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
287
|
+
const labels = computeBarLabels(marks, chartArea);
|
|
288
|
+
|
|
289
|
+
const texts = labels.map((l) => l.text);
|
|
290
|
+
expect(texts).toContain('50');
|
|
291
|
+
expect(texts).toContain('30');
|
|
292
|
+
expect(texts).toContain('70');
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { RectMark } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { computeBarLabels } from '../labels';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Fixtures
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const chartArea = { x: 0, y: 0, width: 400, height: 300 };
|
|
10
|
+
|
|
11
|
+
function makeMark(index: number, value: number): RectMark {
|
|
12
|
+
return {
|
|
13
|
+
type: 'rect',
|
|
14
|
+
x: 0,
|
|
15
|
+
y: index * 30,
|
|
16
|
+
width: Math.abs(value) * 5,
|
|
17
|
+
height: 25,
|
|
18
|
+
fill: '#4e79a7',
|
|
19
|
+
data: { category: `Cat${index}`, value },
|
|
20
|
+
aria: { label: `Cat${index}: ${value}` },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const marks: RectMark[] = [
|
|
25
|
+
makeMark(0, 10),
|
|
26
|
+
makeMark(1, 20),
|
|
27
|
+
makeMark(2, 30),
|
|
28
|
+
makeMark(3, 40),
|
|
29
|
+
makeMark(4, 50),
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Tests
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
describe('computeBarLabels density modes', () => {
|
|
37
|
+
it('density "auto" runs collision detection and produces labels', () => {
|
|
38
|
+
const labels = computeBarLabels(marks, chartArea, 'auto');
|
|
39
|
+
expect(labels.length).toBeGreaterThan(0);
|
|
40
|
+
// Some labels may be hidden by collision detection
|
|
41
|
+
expect(labels.every((l) => typeof l.visible === 'boolean')).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('density "all" shows every label as visible', () => {
|
|
45
|
+
const labels = computeBarLabels(marks, chartArea, 'all');
|
|
46
|
+
expect(labels).toHaveLength(marks.length);
|
|
47
|
+
expect(labels.every((l) => l.visible === true)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('density "none" returns empty array', () => {
|
|
51
|
+
const labels = computeBarLabels(marks, chartArea, 'none');
|
|
52
|
+
expect(labels).toHaveLength(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('density "endpoints" returns only first and last labels', () => {
|
|
56
|
+
const labels = computeBarLabels(marks, chartArea, 'endpoints');
|
|
57
|
+
expect(labels).toHaveLength(2);
|
|
58
|
+
// First label should contain the first mark's value
|
|
59
|
+
expect(labels[0].text).toBe('10');
|
|
60
|
+
// Last label should contain the last mark's value
|
|
61
|
+
expect(labels[1].text).toBe('50');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('density "endpoints" with single mark returns that mark', () => {
|
|
65
|
+
const labels = computeBarLabels([marks[0]], chartArea, 'endpoints');
|
|
66
|
+
expect(labels).toHaveLength(1);
|
|
67
|
+
expect(labels[0].text).toBe('10');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('default density is "auto"', () => {
|
|
71
|
+
const withAuto = computeBarLabels(marks, chartArea, 'auto');
|
|
72
|
+
const withDefault = computeBarLabels(marks, chartArea);
|
|
73
|
+
expect(withDefault.length).toBe(withAuto.length);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bar chart (horizontal) mark computation.
|
|
3
|
+
*
|
|
4
|
+
* Takes a normalized chart spec with resolved scales and produces
|
|
5
|
+
* RectMark[] for rendering horizontal bars. Supports grouped and
|
|
6
|
+
* stacked variants via the color encoding channel.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
DataRow,
|
|
11
|
+
Encoding,
|
|
12
|
+
LayoutStrategy,
|
|
13
|
+
MarkAria,
|
|
14
|
+
Rect,
|
|
15
|
+
RectMark,
|
|
16
|
+
} from '@opendata-ai/openchart-core';
|
|
17
|
+
import { abbreviateNumber, formatNumber } from '@opendata-ai/openchart-core';
|
|
18
|
+
import type { ScaleBand, ScaleLinear } from 'd3-scale';
|
|
19
|
+
|
|
20
|
+
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
21
|
+
import type { ResolvedScales } from '../../layout/scales';
|
|
22
|
+
import { getColor, getSequentialColor, groupByField } from '../utils';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Constants
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const MIN_BAR_WIDTH = 1;
|
|
29
|
+
|
|
30
|
+
/** Format a bar value for display (abbreviate large numbers). */
|
|
31
|
+
function formatBarValue(value: number): string {
|
|
32
|
+
if (Math.abs(value) >= 1000) return abbreviateNumber(value);
|
|
33
|
+
return formatNumber(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Public API
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compute horizontal bar marks from a normalized chart spec.
|
|
42
|
+
*
|
|
43
|
+
* Y axis uses a band scale for categories. X axis uses a linear scale
|
|
44
|
+
* for values. When a color encoding is present, bars within each category
|
|
45
|
+
* are grouped (subdivided bands) or stacked (cumulative widths).
|
|
46
|
+
*/
|
|
47
|
+
export function computeBarMarks(
|
|
48
|
+
spec: NormalizedChartSpec,
|
|
49
|
+
scales: ResolvedScales,
|
|
50
|
+
_chartArea: Rect,
|
|
51
|
+
_strategy: LayoutStrategy,
|
|
52
|
+
): RectMark[] {
|
|
53
|
+
const encoding = spec.encoding as Encoding;
|
|
54
|
+
const xChannel = encoding.x;
|
|
55
|
+
const yChannel = encoding.y;
|
|
56
|
+
|
|
57
|
+
if (!xChannel || !yChannel || !scales.x || !scales.y) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const yScale = scales.y.scale as ScaleBand<string>;
|
|
62
|
+
const xScale = scales.x.scale as ScaleLinear<number, number>;
|
|
63
|
+
|
|
64
|
+
// Band scale should provide bandwidth
|
|
65
|
+
if (typeof yScale.bandwidth !== 'function') {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const bandwidth = yScale.bandwidth();
|
|
70
|
+
const baseline = xScale(0);
|
|
71
|
+
const colorField = encoding.color?.field;
|
|
72
|
+
const isSequentialColor = encoding.color?.type === 'quantitative';
|
|
73
|
+
|
|
74
|
+
// If no color encoding, or sequential color (value-based gradient), render simple bars
|
|
75
|
+
if (!colorField || isSequentialColor) {
|
|
76
|
+
return computeSimpleBars(
|
|
77
|
+
spec.data,
|
|
78
|
+
xChannel.field,
|
|
79
|
+
yChannel.field,
|
|
80
|
+
xScale,
|
|
81
|
+
yScale,
|
|
82
|
+
bandwidth,
|
|
83
|
+
baseline,
|
|
84
|
+
scales,
|
|
85
|
+
isSequentialColor,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Stacked bars when color is present
|
|
90
|
+
return computeStackedBars(
|
|
91
|
+
spec.data,
|
|
92
|
+
xChannel.field,
|
|
93
|
+
yChannel.field,
|
|
94
|
+
colorField,
|
|
95
|
+
xScale,
|
|
96
|
+
yScale,
|
|
97
|
+
bandwidth,
|
|
98
|
+
baseline,
|
|
99
|
+
scales,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Compute stacked horizontal bars. */
|
|
104
|
+
function computeStackedBars(
|
|
105
|
+
data: DataRow[],
|
|
106
|
+
valueField: string,
|
|
107
|
+
categoryField: string,
|
|
108
|
+
colorField: string,
|
|
109
|
+
xScale: ScaleLinear<number, number>,
|
|
110
|
+
yScale: ScaleBand<string>,
|
|
111
|
+
bandwidth: number,
|
|
112
|
+
_baseline: number,
|
|
113
|
+
scales: ResolvedScales,
|
|
114
|
+
): RectMark[] {
|
|
115
|
+
const marks: RectMark[] = [];
|
|
116
|
+
const categoryGroups = groupByField(data, categoryField);
|
|
117
|
+
|
|
118
|
+
for (const [category, rows] of categoryGroups) {
|
|
119
|
+
const bandY = yScale(category);
|
|
120
|
+
if (bandY === undefined) continue;
|
|
121
|
+
|
|
122
|
+
let cumulativeValue = 0;
|
|
123
|
+
|
|
124
|
+
for (const row of rows) {
|
|
125
|
+
const groupKey = String(row[colorField] ?? '');
|
|
126
|
+
const value = Number(row[valueField] ?? 0);
|
|
127
|
+
// Only stack positive values (same approach as stacked columns)
|
|
128
|
+
if (!Number.isFinite(value) || value <= 0) continue;
|
|
129
|
+
|
|
130
|
+
const color = getColor(scales, groupKey);
|
|
131
|
+
|
|
132
|
+
const xLeft = xScale(cumulativeValue);
|
|
133
|
+
const xRight = xScale(cumulativeValue + value);
|
|
134
|
+
const barWidth = Math.max(Math.abs(xRight - xLeft), MIN_BAR_WIDTH);
|
|
135
|
+
|
|
136
|
+
const aria: MarkAria = {
|
|
137
|
+
label: `${category}, ${groupKey}: ${formatBarValue(value)}`,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
marks.push({
|
|
141
|
+
type: 'rect',
|
|
142
|
+
x: xLeft,
|
|
143
|
+
y: bandY,
|
|
144
|
+
width: barWidth,
|
|
145
|
+
height: bandwidth,
|
|
146
|
+
fill: color,
|
|
147
|
+
cornerRadius: 0,
|
|
148
|
+
data: row as Record<string, unknown>,
|
|
149
|
+
aria,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
cumulativeValue += value;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return marks;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Compute simple (non-grouped) horizontal bars. */
|
|
160
|
+
function computeSimpleBars(
|
|
161
|
+
data: DataRow[],
|
|
162
|
+
valueField: string,
|
|
163
|
+
categoryField: string,
|
|
164
|
+
xScale: ScaleLinear<number, number>,
|
|
165
|
+
yScale: ScaleBand<string>,
|
|
166
|
+
bandwidth: number,
|
|
167
|
+
baseline: number,
|
|
168
|
+
scales: ResolvedScales,
|
|
169
|
+
sequentialColor = false,
|
|
170
|
+
): RectMark[] {
|
|
171
|
+
const marks: RectMark[] = [];
|
|
172
|
+
|
|
173
|
+
for (const row of data) {
|
|
174
|
+
const category = String(row[categoryField] ?? '');
|
|
175
|
+
const value = Number(row[valueField] ?? 0);
|
|
176
|
+
if (!Number.isFinite(value)) continue;
|
|
177
|
+
|
|
178
|
+
const bandY = yScale(category);
|
|
179
|
+
if (bandY === undefined) continue;
|
|
180
|
+
|
|
181
|
+
const color = sequentialColor
|
|
182
|
+
? getSequentialColor(scales, value)
|
|
183
|
+
: getColor(scales, '__default__');
|
|
184
|
+
const xPos = value >= 0 ? baseline : xScale(value);
|
|
185
|
+
const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
|
|
186
|
+
|
|
187
|
+
const aria: MarkAria = {
|
|
188
|
+
label: `${category}: ${formatBarValue(value)}`,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
marks.push({
|
|
192
|
+
type: 'rect',
|
|
193
|
+
x: xPos,
|
|
194
|
+
y: bandY,
|
|
195
|
+
width: barWidth,
|
|
196
|
+
height: bandwidth,
|
|
197
|
+
fill: color,
|
|
198
|
+
cornerRadius: 2,
|
|
199
|
+
data: row as Record<string, unknown>,
|
|
200
|
+
aria,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return marks;
|
|
205
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bar chart module (horizontal bars).
|
|
3
|
+
*
|
|
4
|
+
* Exports the bar chart renderer and computation functions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Mark } from '@opendata-ai/openchart-core';
|
|
8
|
+
import type { ChartRenderer } from '../registry';
|
|
9
|
+
import { computeBarMarks } from './compute';
|
|
10
|
+
import { computeBarLabels } from './labels';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Bar chart renderer
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
|
|
17
|
+
const marks = computeBarMarks(spec, scales, chartArea, strategy);
|
|
18
|
+
|
|
19
|
+
// Compute and attach value labels (respects spec.labels.density)
|
|
20
|
+
const labels = computeBarLabels(marks, chartArea, spec.labels.density);
|
|
21
|
+
for (let i = 0; i < marks.length && i < labels.length; i++) {
|
|
22
|
+
marks[i].label = labels[i];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return marks as Mark[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Public exports
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export { computeBarMarks } from './compute';
|
|
33
|
+
export { computeBarLabels } from './labels';
|