@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.9.0",
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.9.0",
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 per category).
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[],
@@ -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
- if (spec.markType === 'bar' && encoding.color && encoding.x.type === 'quantitative') {
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;