@opendata-ai/openchart-engine 6.8.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.8.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.8.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[],
@@ -246,6 +246,7 @@ function normalizeSankeySpec(spec: SankeySpec, _warnings: string[]): NormalizedS
246
246
  nodeAlign: spec.nodeAlign ?? 'justify',
247
247
  iterations: spec.iterations ?? 6,
248
248
  linkStyle: spec.linkStyle ?? 'gradient',
249
+ nodeLabelAlign: spec.nodeLabelAlign ?? 'auto',
249
250
  chrome: normalizeChrome(spec.chrome),
250
251
  legend: spec.legend,
251
252
  theme: spec.theme ?? {},
@@ -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;
@@ -131,18 +131,28 @@ function getLinkColors(
131
131
 
132
132
  /**
133
133
  * Determine label position for a node based on its column depth.
134
- * Leftmost column: label to the right.
135
- * Rightmost column: label to the left.
136
- * Middle columns: label to the right (default).
134
+ * Default ('auto'): leftmost/middle columns label right, rightmost column labels left.
135
+ * 'right': all labels to the right. 'left': all labels to the left.
137
136
  */
138
137
  function computeNodeLabel(
139
138
  node: ComputedNode,
140
139
  maxDepth: number,
141
140
  theme: ResolvedTheme,
142
141
  nodeWidth: number,
142
+ nodeLabelAlign: 'auto' | 'left' | 'right' = 'auto',
143
143
  ): SankeyNodeMark['label'] {
144
144
  const depth = node.depth ?? 0;
145
- const isRightmost = depth === maxDepth;
145
+
146
+ // Determine which side to place the label
147
+ let placeLeft: boolean;
148
+ if (nodeLabelAlign === 'left') {
149
+ placeLeft = true;
150
+ } else if (nodeLabelAlign === 'right') {
151
+ placeLeft = false;
152
+ } else {
153
+ // 'auto': rightmost column goes left, everything else goes right
154
+ placeLeft = depth === maxDepth;
155
+ }
146
156
 
147
157
  const style: TextStyle = {
148
158
  fontFamily: theme.fonts.family,
@@ -158,8 +168,7 @@ function computeNodeLabel(
158
168
  const y1 = node.y1 ?? 0;
159
169
  const midY = (y0 + y1) / 2;
160
170
 
161
- if (isRightmost) {
162
- // Label to the left of the node
171
+ if (placeLeft) {
163
172
  return {
164
173
  text: node.label ?? node.id,
165
174
  x: x0 - LABEL_GAP,
@@ -169,7 +178,6 @@ function computeNodeLabel(
169
178
  };
170
179
  }
171
180
 
172
- // Label to the right of the node (leftmost and middle columns)
173
181
  return {
174
182
  text: node.label ?? node.id,
175
183
  x: x1 + LABEL_GAP,
@@ -311,15 +319,17 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
311
319
  sankeySpec.iterations,
312
320
  );
313
321
 
314
- // 6b. Check if any non-rightmost node labels overflow the right edge.
315
- // Non-rightmost nodes get labels to the right (textAnchor: start),
316
- // which can extend past the drawing area boundary.
322
+ // 6b. Check if any right-side node labels overflow the right edge.
323
+ const nodeLabelAlign = sankeySpec.nodeLabelAlign ?? 'auto';
317
324
  const maxDepthFirst = nodes.reduce((max, n) => Math.max(max, n.depth ?? 0), 0);
318
325
  const rightEdge = area.x + area.width;
319
326
  let maxOverflow = 0;
320
327
  for (const node of nodes) {
321
328
  const depth = node.depth ?? 0;
322
- if (depth === maxDepthFirst) continue; // rightmost labels go left, no overflow
329
+ // Skip nodes whose labels go left (they can't overflow the right edge)
330
+ const labelsLeft =
331
+ nodeLabelAlign === 'left' || (nodeLabelAlign === 'auto' && depth === maxDepthFirst);
332
+ if (labelsLeft) continue;
323
333
  const labelX = (node.x1 ?? nodeWidth) + LABEL_GAP;
324
334
  const labelText = node.label ?? node.id;
325
335
  const labelWidth = estimateTextWidth(labelText, labelFontSize, labelFontWeight);
@@ -375,7 +385,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
375
385
  height: (node.y1 ?? 0) - (node.y0 ?? 0),
376
386
  fill,
377
387
  cornerRadius: NODE_CORNER_RADIUS,
378
- label: computeNodeLabel(node, maxDepth, theme, sankeySpec.nodeWidth),
388
+ label: computeNodeLabel(node, maxDepth, theme, sankeySpec.nodeWidth, nodeLabelAlign),
379
389
  nodeId: node.id,
380
390
  value: node.value ?? 0,
381
391
  depth,
@@ -28,6 +28,7 @@ export interface NormalizedSankeySpec {
28
28
  nodeAlign: SankeyNodeAlign;
29
29
  iterations: number;
30
30
  linkStyle: SankeyLinkColor;
31
+ nodeLabelAlign: 'auto' | 'left' | 'right';
31
32
  chrome: NormalizedChrome;
32
33
  legend?: LegendConfig;
33
34
  theme: ThemeConfig;