@opendata-ai/openchart-engine 6.9.0 → 6.10.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 +126 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/charts/bar/__tests__/compute.test.ts +92 -0
- package/src/charts/bar/compute.ts +83 -0
- package/src/charts/column/__tests__/compute.test.ts +66 -0
- package/src/charts/column/compute.ts +89 -1
- package/src/layout/scales.ts +10 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.10.0",
|
|
4
4
|
"description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Riley Hilliard",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"typecheck": "tsc --noEmit"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@opendata-ai/openchart-core": "6.
|
|
48
|
+
"@opendata-ai/openchart-core": "6.10.0",
|
|
49
49
|
"d3-array": "^3.2.0",
|
|
50
50
|
"d3-format": "^3.1.2",
|
|
51
51
|
"d3-interpolate": "^3.0.0",
|
|
@@ -203,6 +203,98 @@ describe('computeBarMarks', () => {
|
|
|
203
203
|
});
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
+
describe('grouped bars (stack: null)', () => {
|
|
207
|
+
function makeDodgedBarSpec(): NormalizedChartSpec {
|
|
208
|
+
const spec = makeGroupedBarSpec();
|
|
209
|
+
(spec.encoding.x as { stack?: boolean | null }).stack = null;
|
|
210
|
+
return spec;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
it('produces marks for all data rows', () => {
|
|
214
|
+
const spec = makeDodgedBarSpec();
|
|
215
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
216
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
217
|
+
|
|
218
|
+
expect(marks).toHaveLength(6);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('grouped bars within a category have different y positions', () => {
|
|
222
|
+
const spec = makeDodgedBarSpec();
|
|
223
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
224
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
225
|
+
|
|
226
|
+
const q1East = marks.find(
|
|
227
|
+
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
|
|
228
|
+
)!;
|
|
229
|
+
const q1West = marks.find(
|
|
230
|
+
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('West'),
|
|
231
|
+
)!;
|
|
232
|
+
|
|
233
|
+
expect(q1East.y).not.toBe(q1West.y);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('grouped bars all start from baseline (not cumulative)', () => {
|
|
237
|
+
const spec = makeDodgedBarSpec();
|
|
238
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
239
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
240
|
+
|
|
241
|
+
const q1East = marks.find(
|
|
242
|
+
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
|
|
243
|
+
)!;
|
|
244
|
+
const q1West = marks.find(
|
|
245
|
+
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('West'),
|
|
246
|
+
)!;
|
|
247
|
+
|
|
248
|
+
// Both bars start at the same x position (baseline)
|
|
249
|
+
expect(q1East.x).toBe(q1West.x);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('grouped bars have cornerRadius 2', () => {
|
|
253
|
+
const spec = makeDodgedBarSpec();
|
|
254
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
255
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
256
|
+
|
|
257
|
+
for (const mark of marks) {
|
|
258
|
+
expect(mark.cornerRadius).toBe(2);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('grouped bars do not set stackGroup', () => {
|
|
263
|
+
const spec = makeDodgedBarSpec();
|
|
264
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
265
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
266
|
+
|
|
267
|
+
for (const mark of marks) {
|
|
268
|
+
expect(mark.stackGroup).toBeUndefined();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('sub-band heights are smaller than full bandwidth', () => {
|
|
273
|
+
const spec = makeDodgedBarSpec();
|
|
274
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
275
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
276
|
+
|
|
277
|
+
// With 2 groups, each sub-bar should be less than the full bandwidth
|
|
278
|
+
const stackedSpec = makeGroupedBarSpec();
|
|
279
|
+
const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
|
|
280
|
+
const stackedMarks = computeBarMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
|
|
281
|
+
|
|
282
|
+
expect(marks[0].height).toBeLessThan(stackedMarks[0].height);
|
|
283
|
+
expect(marks[0].height).toBeGreaterThan(0);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('scale domain covers max individual value, not stacked sum', () => {
|
|
287
|
+
const spec = makeDodgedBarSpec();
|
|
288
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
289
|
+
|
|
290
|
+
// Max individual value is 70 (Q3 West), not 115 (Q3 stacked sum)
|
|
291
|
+
const xScale = scales.x!.scale;
|
|
292
|
+
const domain = xScale.domain() as number[];
|
|
293
|
+
// Domain should not extend to the stacked sum (115)
|
|
294
|
+
expect(domain[1]).toBeLessThanOrEqual(80); // some nice rounding above 70
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
206
298
|
describe('colored (non-stacked) bars', () => {
|
|
207
299
|
it('renders colored bars when each category has one row with color encoding', () => {
|
|
208
300
|
const spec: NormalizedChartSpec = {
|
|
@@ -99,6 +99,22 @@ export function computeBarMarks(
|
|
|
99
99
|
const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
|
|
100
100
|
|
|
101
101
|
if (needsStacking) {
|
|
102
|
+
const stackDisabled = xChannel.stack === null || xChannel.stack === false;
|
|
103
|
+
|
|
104
|
+
if (stackDisabled) {
|
|
105
|
+
return computeGroupedBars(
|
|
106
|
+
spec.data,
|
|
107
|
+
xChannel.field,
|
|
108
|
+
yChannel.field,
|
|
109
|
+
colorField,
|
|
110
|
+
xScale,
|
|
111
|
+
yScale,
|
|
112
|
+
bandwidth,
|
|
113
|
+
baseline,
|
|
114
|
+
scales,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
102
118
|
return computeStackedBars(
|
|
103
119
|
spec.data,
|
|
104
120
|
xChannel.field,
|
|
@@ -184,6 +200,73 @@ function computeStackedBars(
|
|
|
184
200
|
return marks;
|
|
185
201
|
}
|
|
186
202
|
|
|
203
|
+
/** Compute grouped (dodged) horizontal bars -- side-by-side within each category band. */
|
|
204
|
+
function computeGroupedBars(
|
|
205
|
+
data: DataRow[],
|
|
206
|
+
valueField: string,
|
|
207
|
+
categoryField: string,
|
|
208
|
+
colorField: string,
|
|
209
|
+
xScale: ScaleLinear<number, number>,
|
|
210
|
+
yScale: ScaleBand<string>,
|
|
211
|
+
bandwidth: number,
|
|
212
|
+
baseline: number,
|
|
213
|
+
scales: ResolvedScales,
|
|
214
|
+
): RectMark[] {
|
|
215
|
+
const marks: RectMark[] = [];
|
|
216
|
+
const categoryGroups = groupByField(data, categoryField);
|
|
217
|
+
|
|
218
|
+
// Build a stable group order from first appearance in data (Map for O(1) lookup)
|
|
219
|
+
const groupIndexMap = new Map<string, number>();
|
|
220
|
+
for (const row of data) {
|
|
221
|
+
const key = String(row[colorField] ?? '');
|
|
222
|
+
if (!groupIndexMap.has(key)) {
|
|
223
|
+
groupIndexMap.set(key, groupIndexMap.size);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const groupCount = groupIndexMap.size;
|
|
227
|
+
if (groupCount === 0) return marks;
|
|
228
|
+
|
|
229
|
+
// Subdivide the band height by group count with a small gap
|
|
230
|
+
const gap = Math.min(1, bandwidth * 0.05);
|
|
231
|
+
const subBandHeight = Math.max((bandwidth - gap * (groupCount - 1)) / groupCount, MIN_BAR_WIDTH);
|
|
232
|
+
|
|
233
|
+
for (const [category, rows] of categoryGroups) {
|
|
234
|
+
const bandY = yScale(category);
|
|
235
|
+
if (bandY === undefined) continue;
|
|
236
|
+
|
|
237
|
+
for (const row of rows) {
|
|
238
|
+
const groupKey = String(row[colorField] ?? '');
|
|
239
|
+
const value = Number(row[valueField] ?? 0);
|
|
240
|
+
if (!Number.isFinite(value)) continue;
|
|
241
|
+
|
|
242
|
+
const groupIndex = groupIndexMap.get(groupKey) ?? 0;
|
|
243
|
+
const color = getColor(scales, groupKey);
|
|
244
|
+
const xPos = value >= 0 ? baseline : xScale(value);
|
|
245
|
+
const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
|
|
246
|
+
const subY = bandY + groupIndex * (subBandHeight + gap);
|
|
247
|
+
|
|
248
|
+
const aria: MarkAria = {
|
|
249
|
+
label: `${category}, ${groupKey}: ${formatBarValue(value)}`,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
marks.push({
|
|
253
|
+
type: 'rect',
|
|
254
|
+
x: xPos,
|
|
255
|
+
y: subY,
|
|
256
|
+
width: barWidth,
|
|
257
|
+
height: subBandHeight,
|
|
258
|
+
fill: color,
|
|
259
|
+
cornerRadius: 2,
|
|
260
|
+
data: row as Record<string, unknown>,
|
|
261
|
+
aria,
|
|
262
|
+
orient: 'horizontal',
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return marks;
|
|
268
|
+
}
|
|
269
|
+
|
|
187
270
|
/** Compute colored (non-stacked) horizontal bars. Used when color encoding
|
|
188
271
|
* is present but each category has only one row (e.g., diverging charts). */
|
|
189
272
|
function computeColoredBars(
|
|
@@ -187,6 +187,72 @@ describe('computeColumnMarks', () => {
|
|
|
187
187
|
});
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
+
describe('grouped columns (stack: null)', () => {
|
|
191
|
+
function makeDodgedColumnSpec(): NormalizedChartSpec {
|
|
192
|
+
const spec = makeGroupedColumnSpec();
|
|
193
|
+
(spec.encoding.y as { stack?: boolean | null }).stack = null;
|
|
194
|
+
return spec;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
it('produces marks for all data rows', () => {
|
|
198
|
+
const spec = makeDodgedColumnSpec();
|
|
199
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
200
|
+
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
201
|
+
|
|
202
|
+
expect(marks).toHaveLength(6);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('grouped columns within a category have different x positions', () => {
|
|
206
|
+
const spec = makeDodgedColumnSpec();
|
|
207
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
208
|
+
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
209
|
+
|
|
210
|
+
const janNorth = marks.find(
|
|
211
|
+
(m) => m.aria.label.includes('Jan') && m.aria.label.includes('North'),
|
|
212
|
+
)!;
|
|
213
|
+
const janSouth = marks.find(
|
|
214
|
+
(m) => m.aria.label.includes('Jan') && m.aria.label.includes('South'),
|
|
215
|
+
)!;
|
|
216
|
+
|
|
217
|
+
expect(janNorth.x).not.toBe(janSouth.x);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('grouped columns have subdivided widths', () => {
|
|
221
|
+
const spec = makeDodgedColumnSpec();
|
|
222
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
223
|
+
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
224
|
+
|
|
225
|
+
// With 2 groups, each sub-column should be narrower than full bandwidth
|
|
226
|
+
const stackedSpec = makeGroupedColumnSpec();
|
|
227
|
+
const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
|
|
228
|
+
const stackedMarks = computeColumnMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
|
|
229
|
+
|
|
230
|
+
expect(marks[0].width).toBeLessThan(stackedMarks[0].width);
|
|
231
|
+
expect(marks[0].width).toBeGreaterThan(0);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('grouped columns have cornerRadius 2 and no stackGroup', () => {
|
|
235
|
+
const spec = makeDodgedColumnSpec();
|
|
236
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
237
|
+
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
238
|
+
|
|
239
|
+
for (const mark of marks) {
|
|
240
|
+
expect(mark.cornerRadius).toBe(2);
|
|
241
|
+
expect(mark.stackGroup).toBeUndefined();
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('scale domain covers max individual value, not stacked sum', () => {
|
|
246
|
+
const spec = makeDodgedColumnSpec();
|
|
247
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
248
|
+
|
|
249
|
+
// Max individual value is 150 (Mar North), not 280 (Mar stacked sum)
|
|
250
|
+
const yScale = scales.y!.scale;
|
|
251
|
+
const domain = yScale.domain() as number[];
|
|
252
|
+
expect(domain[1]).toBeLessThanOrEqual(170); // some nice rounding above 150
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
190
256
|
describe('negative values', () => {
|
|
191
257
|
it('negative columns extend downward from baseline', () => {
|
|
192
258
|
const spec = makeNegativeColumnSpec();
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Takes a normalized chart spec with resolved scales and produces
|
|
5
5
|
* RectMark[] for rendering vertical columns. When a color encoding
|
|
6
|
-
* is present, columns are stacked (cumulative heights
|
|
6
|
+
* is present, columns are either stacked (cumulative heights) or grouped
|
|
7
|
+
* (side-by-side) based on the `stack` property of the quantitative channel.
|
|
7
8
|
*
|
|
8
9
|
* Shares conceptual logic with bar chart but axes are swapped:
|
|
9
10
|
* x-axis is categorical (band scale), y-axis is quantitative.
|
|
@@ -88,6 +89,22 @@ export function computeColumnMarks(
|
|
|
88
89
|
const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
|
|
89
90
|
|
|
90
91
|
if (needsStacking) {
|
|
92
|
+
const stackDisabled = yChannel.stack === null || yChannel.stack === false;
|
|
93
|
+
|
|
94
|
+
if (stackDisabled) {
|
|
95
|
+
return computeGroupedColumns(
|
|
96
|
+
spec.data,
|
|
97
|
+
xChannel.field,
|
|
98
|
+
yChannel.field,
|
|
99
|
+
colorField,
|
|
100
|
+
xScale,
|
|
101
|
+
yScale,
|
|
102
|
+
bandwidth,
|
|
103
|
+
baseline,
|
|
104
|
+
scales,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
91
108
|
return computeStackedColumns(
|
|
92
109
|
spec.data,
|
|
93
110
|
xChannel.field,
|
|
@@ -241,6 +258,77 @@ function computeColoredColumns(
|
|
|
241
258
|
return marks;
|
|
242
259
|
}
|
|
243
260
|
|
|
261
|
+
/** Compute grouped (dodged) vertical columns -- side-by-side within each category band. */
|
|
262
|
+
function computeGroupedColumns(
|
|
263
|
+
data: DataRow[],
|
|
264
|
+
categoryField: string,
|
|
265
|
+
valueField: string,
|
|
266
|
+
colorField: string,
|
|
267
|
+
xScale: ScaleBand<string>,
|
|
268
|
+
yScale: ScaleLinear<number, number>,
|
|
269
|
+
bandwidth: number,
|
|
270
|
+
baseline: number,
|
|
271
|
+
scales: ResolvedScales,
|
|
272
|
+
): RectMark[] {
|
|
273
|
+
const marks: RectMark[] = [];
|
|
274
|
+
const categoryGroups = groupByField(data, categoryField);
|
|
275
|
+
|
|
276
|
+
// Build a stable group order from first appearance in data (Map for O(1) lookup)
|
|
277
|
+
const groupIndexMap = new Map<string, number>();
|
|
278
|
+
for (const row of data) {
|
|
279
|
+
const key = String(row[colorField] ?? '');
|
|
280
|
+
if (!groupIndexMap.has(key)) {
|
|
281
|
+
groupIndexMap.set(key, groupIndexMap.size);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const groupCount = groupIndexMap.size;
|
|
285
|
+
if (groupCount === 0) return marks;
|
|
286
|
+
|
|
287
|
+
// Subdivide the band width by group count with a small gap
|
|
288
|
+
const gap = Math.min(1, bandwidth * 0.05);
|
|
289
|
+
const subBandWidth = Math.max(
|
|
290
|
+
(bandwidth - gap * (groupCount - 1)) / groupCount,
|
|
291
|
+
MIN_COLUMN_HEIGHT,
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
for (const [category, rows] of categoryGroups) {
|
|
295
|
+
const bandX = xScale(category);
|
|
296
|
+
if (bandX === undefined) continue;
|
|
297
|
+
|
|
298
|
+
for (const row of rows) {
|
|
299
|
+
const groupKey = String(row[colorField] ?? '');
|
|
300
|
+
const value = Number(row[valueField] ?? 0);
|
|
301
|
+
if (!Number.isFinite(value)) continue;
|
|
302
|
+
|
|
303
|
+
const groupIndex = groupIndexMap.get(groupKey) ?? 0;
|
|
304
|
+
const color = getColor(scales, groupKey);
|
|
305
|
+
const yPos = yScale(value);
|
|
306
|
+
const columnHeight = Math.max(Math.abs(baseline - yPos), MIN_COLUMN_HEIGHT);
|
|
307
|
+
const y = value >= 0 ? yPos : baseline;
|
|
308
|
+
const subX = bandX + groupIndex * (subBandWidth + gap);
|
|
309
|
+
|
|
310
|
+
const aria: MarkAria = {
|
|
311
|
+
label: `${category}, ${groupKey}: ${formatColumnValue(value)}`,
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
marks.push({
|
|
315
|
+
type: 'rect',
|
|
316
|
+
x: subX,
|
|
317
|
+
y,
|
|
318
|
+
width: subBandWidth,
|
|
319
|
+
height: columnHeight,
|
|
320
|
+
fill: color,
|
|
321
|
+
cornerRadius: 2,
|
|
322
|
+
data: row as Record<string, unknown>,
|
|
323
|
+
aria,
|
|
324
|
+
orient: 'vertical',
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return marks;
|
|
330
|
+
}
|
|
331
|
+
|
|
244
332
|
/** Compute stacked vertical columns. */
|
|
245
333
|
function computeStackedColumns(
|
|
246
334
|
data: DataRow[],
|
package/src/layout/scales.ts
CHANGED
|
@@ -622,7 +622,13 @@ export function computeScales(
|
|
|
622
622
|
// For stacked bars, the x-domain needs the max category sum, not max individual value.
|
|
623
623
|
// Without this, stacked bars would clip past the chart area.
|
|
624
624
|
let xData = data;
|
|
625
|
-
|
|
625
|
+
const xStackDisabled = encoding.x.stack === null || encoding.x.stack === false;
|
|
626
|
+
if (
|
|
627
|
+
spec.markType === 'bar' &&
|
|
628
|
+
encoding.color &&
|
|
629
|
+
encoding.x.type === 'quantitative' &&
|
|
630
|
+
!xStackDisabled
|
|
631
|
+
) {
|
|
626
632
|
const yField = encoding.y?.field;
|
|
627
633
|
const xField = encoding.x.field;
|
|
628
634
|
if (yField) {
|
|
@@ -660,10 +666,12 @@ export function computeScales(
|
|
|
660
666
|
spec.markType === 'bar' &&
|
|
661
667
|
(encoding.x?.type === 'nominal' || encoding.x?.type === 'ordinal') &&
|
|
662
668
|
encoding.y.type === 'quantitative';
|
|
669
|
+
const yStackDisabled = encoding.y.stack === null || encoding.y.stack === false;
|
|
663
670
|
if (
|
|
664
671
|
(isVerticalBar || spec.markType === 'area') &&
|
|
665
672
|
encoding.color &&
|
|
666
|
-
encoding.y.type === 'quantitative'
|
|
673
|
+
encoding.y.type === 'quantitative' &&
|
|
674
|
+
!yStackDisabled
|
|
667
675
|
) {
|
|
668
676
|
const xField = encoding.x?.field;
|
|
669
677
|
const yField = encoding.y.field;
|