@opendata-ai/openchart-engine 6.12.0 → 6.13.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.
Files changed (35) hide show
  1. package/dist/index.js +878 -606
  2. package/dist/index.js.map +1 -1
  3. package/package.json +2 -2
  4. package/src/__tests__/axes.test.ts +12 -30
  5. package/src/__tests__/compile-chart.test.ts +4 -4
  6. package/src/__tests__/dimensions.test.ts +2 -2
  7. package/src/__tests__/encoding-sugar.test.ts +389 -0
  8. package/src/annotations/collisions.ts +268 -0
  9. package/src/annotations/compute.ts +9 -912
  10. package/src/annotations/constants.ts +32 -0
  11. package/src/annotations/geometry.ts +167 -0
  12. package/src/annotations/position.ts +95 -0
  13. package/src/annotations/resolve-range.ts +98 -0
  14. package/src/annotations/resolve-refline.ts +148 -0
  15. package/src/annotations/resolve-text.ts +134 -0
  16. package/src/charts/__tests__/post-process.test.ts +258 -0
  17. package/src/charts/bar/__tests__/labels.test.ts +31 -0
  18. package/src/charts/bar/compute.ts +27 -6
  19. package/src/charts/bar/labels.ts +7 -1
  20. package/src/charts/column/__tests__/compute.test.ts +99 -0
  21. package/src/charts/column/compute.ts +27 -6
  22. package/src/charts/line/area.ts +19 -2
  23. package/src/charts/post-process.ts +215 -0
  24. package/src/compile.ts +90 -158
  25. package/src/compiler/normalize.ts +2 -2
  26. package/src/layout/axes.ts +10 -13
  27. package/src/layout/dimensions.ts +3 -3
  28. package/src/layout/scales.ts +106 -29
  29. package/src/tooltips/__tests__/compute.test.ts +188 -0
  30. package/src/tooltips/compute.ts +25 -11
  31. package/src/transforms/__tests__/aggregate.test.ts +159 -0
  32. package/src/transforms/__tests__/fold.test.ts +79 -0
  33. package/src/transforms/aggregate.ts +130 -0
  34. package/src/transforms/fold.ts +49 -0
  35. 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
- let cumulativeValue = 0;
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 value = Number(row[valueField] ?? 0);
186
+ const rawValue = Number(row[valueField] ?? 0);
170
187
  // Only stack positive values (same approach as stacked columns)
171
- if (!Number.isFinite(value) || value <= 0) continue;
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(value)}`,
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,
@@ -42,7 +42,8 @@ const SUFFIX_MULTIPLIERS: Record<string, number> = {
42
42
  * Returns NaN when the string cannot be parsed.
43
43
  */
44
44
  function parseDisplayNumber(raw: string): number {
45
- const trimmed = raw.trim();
45
+ // Normalize Unicode minus (U+2212, produced by d3-format) to ASCII hyphen-minus
46
+ const trimmed = raw.trim().replace(/\u2212/g, '-');
46
47
  if (!trimmed) return NaN;
47
48
 
48
49
  // Check for trailing abbreviation suffix (case-insensitive)
@@ -54,6 +55,11 @@ function parseDisplayNumber(raw: string): number {
54
55
  return Number.isNaN(n) ? NaN : n * multiplier;
55
56
  }
56
57
 
58
+ // Strip literal % suffix (e.g., from "+.0f%" d3-format strings)
59
+ if (last === '%') {
60
+ return Number(trimmed.slice(0, -1).replace(/,/g, ''));
61
+ }
62
+
57
63
  // No suffix — strip commas and parse
58
64
  return Number(trimmed.replace(/,/g, ''));
59
65
  }
@@ -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
- let cumulativeValue = 0;
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 value = Number(row[valueField] ?? 0);
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(value) || value <= 0) continue;
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(value)}`,
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,
@@ -9,7 +9,15 @@
9
9
  import type { AreaMark, DataRow, Encoding, MarkAria, Rect } from '@opendata-ai/openchart-core';
10
10
  import { getRepresentativeColor } from '@opendata-ai/openchart-core';
11
11
  import type { ScaleLinear } from 'd3-scale';
12
- import { area, line, stack, stackOffsetNone, stackOrderNone } from 'd3-shape';
12
+ import {
13
+ area,
14
+ line,
15
+ stack,
16
+ stackOffsetExpand,
17
+ stackOffsetNone,
18
+ stackOffsetSilhouette,
19
+ stackOrderNone,
20
+ } from 'd3-shape';
13
21
 
14
22
  import type { NormalizedChartSpec } from '../../compiler/types';
15
23
  import type { ResolvedScales } from '../../layout/scales';
@@ -200,11 +208,20 @@ function computeStackedArea(
200
208
  return pivot;
201
209
  });
202
210
 
211
+ // Resolve stack offset from the y channel's stack property
212
+ const stackProp = yChannel.stack;
213
+ const offsetFn =
214
+ stackProp === 'normalize'
215
+ ? stackOffsetExpand
216
+ : stackProp === 'center'
217
+ ? stackOffsetSilhouette
218
+ : stackOffsetNone;
219
+
203
220
  // Use d3 stack to compute the stacked layout
204
221
  const stackGenerator = stack<Record<string, unknown>>()
205
222
  .keys(keys)
206
223
  .order(stackOrderNone)
207
- .offset(stackOffsetNone);
224
+ .offset(offsetFn);
208
225
 
209
226
  const stackedData = stackGenerator(pivotData);
210
227
  const yScale = scales.y.scale as ScaleLinear<number, number>;