@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.
Files changed (85) hide show
  1. package/dist/index.d.ts +366 -0
  2. package/dist/index.js +4227 -0
  3. package/dist/index.js.map +1 -0
  4. package/package.json +62 -0
  5. package/src/__test-fixtures__/specs.ts +124 -0
  6. package/src/__tests__/axes.test.ts +114 -0
  7. package/src/__tests__/compile-chart.test.ts +337 -0
  8. package/src/__tests__/dimensions.test.ts +151 -0
  9. package/src/__tests__/legend.test.ts +113 -0
  10. package/src/__tests__/scales.test.ts +109 -0
  11. package/src/annotations/__tests__/compute.test.ts +454 -0
  12. package/src/annotations/compute.ts +603 -0
  13. package/src/charts/__tests__/registry.test.ts +110 -0
  14. package/src/charts/bar/__tests__/compute.test.ts +294 -0
  15. package/src/charts/bar/__tests__/labels.test.ts +75 -0
  16. package/src/charts/bar/compute.ts +205 -0
  17. package/src/charts/bar/index.ts +33 -0
  18. package/src/charts/bar/labels.ts +132 -0
  19. package/src/charts/column/__tests__/compute.test.ts +277 -0
  20. package/src/charts/column/compute.ts +282 -0
  21. package/src/charts/column/index.ts +33 -0
  22. package/src/charts/column/labels.ts +108 -0
  23. package/src/charts/dot/__tests__/compute.test.ts +344 -0
  24. package/src/charts/dot/compute.ts +257 -0
  25. package/src/charts/dot/index.ts +46 -0
  26. package/src/charts/dot/labels.ts +97 -0
  27. package/src/charts/line/__tests__/compute.test.ts +437 -0
  28. package/src/charts/line/__tests__/labels.test.ts +93 -0
  29. package/src/charts/line/area.ts +288 -0
  30. package/src/charts/line/compute.ts +177 -0
  31. package/src/charts/line/index.ts +68 -0
  32. package/src/charts/line/labels.ts +144 -0
  33. package/src/charts/pie/__tests__/compute.test.ts +276 -0
  34. package/src/charts/pie/compute.ts +234 -0
  35. package/src/charts/pie/index.ts +49 -0
  36. package/src/charts/pie/labels.ts +142 -0
  37. package/src/charts/registry.ts +64 -0
  38. package/src/charts/scatter/__tests__/compute.test.ts +304 -0
  39. package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
  40. package/src/charts/scatter/compute.ts +124 -0
  41. package/src/charts/scatter/index.ts +41 -0
  42. package/src/charts/scatter/trendline.ts +100 -0
  43. package/src/charts/utils.ts +120 -0
  44. package/src/compile.ts +368 -0
  45. package/src/compiler/__tests__/compile.test.ts +87 -0
  46. package/src/compiler/__tests__/normalize.test.ts +210 -0
  47. package/src/compiler/__tests__/validate.test.ts +440 -0
  48. package/src/compiler/index.ts +47 -0
  49. package/src/compiler/normalize.ts +269 -0
  50. package/src/compiler/types.ts +148 -0
  51. package/src/compiler/validate.ts +581 -0
  52. package/src/graphs/__tests__/community.test.ts +228 -0
  53. package/src/graphs/__tests__/compile-graph.test.ts +315 -0
  54. package/src/graphs/__tests__/encoding.test.ts +314 -0
  55. package/src/graphs/community.ts +92 -0
  56. package/src/graphs/compile-graph.ts +291 -0
  57. package/src/graphs/encoding.ts +302 -0
  58. package/src/graphs/types.ts +98 -0
  59. package/src/index.ts +74 -0
  60. package/src/layout/axes.ts +194 -0
  61. package/src/layout/dimensions.ts +199 -0
  62. package/src/layout/gridlines.ts +84 -0
  63. package/src/layout/scales.ts +426 -0
  64. package/src/legend/compute.ts +186 -0
  65. package/src/tables/__tests__/bar-column.test.ts +147 -0
  66. package/src/tables/__tests__/category-colors.test.ts +153 -0
  67. package/src/tables/__tests__/compile-table.test.ts +208 -0
  68. package/src/tables/__tests__/format-cells.test.ts +126 -0
  69. package/src/tables/__tests__/heatmap.test.ts +124 -0
  70. package/src/tables/__tests__/pagination.test.ts +78 -0
  71. package/src/tables/__tests__/search.test.ts +94 -0
  72. package/src/tables/__tests__/sort.test.ts +107 -0
  73. package/src/tables/__tests__/sparkline.test.ts +122 -0
  74. package/src/tables/bar-column.ts +94 -0
  75. package/src/tables/category-colors.ts +67 -0
  76. package/src/tables/compile-table.ts +420 -0
  77. package/src/tables/format-cells.ts +110 -0
  78. package/src/tables/heatmap.ts +121 -0
  79. package/src/tables/pagination.ts +46 -0
  80. package/src/tables/search.ts +66 -0
  81. package/src/tables/sort.ts +69 -0
  82. package/src/tables/sparkline.ts +113 -0
  83. package/src/tables/utils.ts +16 -0
  84. package/src/tooltips/__tests__/compute.test.ts +328 -0
  85. package/src/tooltips/compute.ts +231 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Bar chart label computation.
3
+ *
4
+ * Produces value labels for horizontal bars, positioned inside the bar
5
+ * if the bar is wide enough, or outside (to the right) otherwise.
6
+ *
7
+ * Respects the spec's label density setting:
8
+ * - 'all': show every label, skip collision detection
9
+ * - 'auto': existing behavior (collision detection)
10
+ * - 'endpoints': first and last bars only
11
+ * - 'none': return empty array
12
+ */
13
+
14
+ import type {
15
+ LabelCandidate,
16
+ LabelDensity,
17
+ RectMark,
18
+ ResolvedLabel,
19
+ } from '@opendata-ai/openchart-core';
20
+ import { estimateTextWidth, resolveCollisions } from '@opendata-ai/openchart-core';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Constants
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const LABEL_FONT_SIZE = 11;
27
+ const LABEL_FONT_WEIGHT = 600;
28
+ const LABEL_PADDING = 6;
29
+ const MIN_WIDTH_FOR_INSIDE_LABEL = 40;
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Public API
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Compute value labels for bar marks.
37
+ *
38
+ * For each bar, the value from the data is formatted and placed either
39
+ * inside the bar (right-aligned) if the bar is wide enough, or just
40
+ * outside the bar's right edge.
41
+ */
42
+ export function computeBarLabels(
43
+ marks: RectMark[],
44
+ _chartArea: { x: number; y: number; width: number; height: number },
45
+ density: LabelDensity = 'auto',
46
+ ): ResolvedLabel[] {
47
+ // 'none': no labels at all
48
+ if (density === 'none') return [];
49
+
50
+ // Filter marks for 'endpoints' density
51
+ const targetMarks =
52
+ density === 'endpoints' && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
53
+
54
+ const candidates: LabelCandidate[] = [];
55
+
56
+ for (const mark of targetMarks) {
57
+ // Extract the display value from the aria label.
58
+ // Format is "category: value" or "category, group: value".
59
+ // Use the last colon to split, which handles colons in category names.
60
+ const ariaLabel = mark.aria.label;
61
+ const lastColon = ariaLabel.lastIndexOf(':');
62
+ const valuePart = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
63
+ if (!valuePart) continue;
64
+
65
+ const textWidth = estimateTextWidth(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
66
+ const textHeight = LABEL_FONT_SIZE * 1.2;
67
+
68
+ // Detect stacked bars: cornerRadius 0 indicates stacked segment
69
+ const isStacked = mark.cornerRadius === 0;
70
+
71
+ // Determine if label goes inside or outside the bar
72
+ const isInside = mark.width >= MIN_WIDTH_FOR_INSIDE_LABEL;
73
+
74
+ let anchorX: number;
75
+ let fill: string;
76
+ let textAnchor: 'start' | 'end' | 'middle';
77
+
78
+ if (isStacked && isInside) {
79
+ // Stacked: centered within segment
80
+ anchorX = mark.x + mark.width / 2;
81
+ fill = '#ffffff';
82
+ textAnchor = 'middle';
83
+ } else if (isInside) {
84
+ // Simple: right-aligned within bar
85
+ anchorX = mark.x + mark.width - LABEL_PADDING;
86
+ fill = '#ffffff';
87
+ textAnchor = 'end';
88
+ } else {
89
+ // Outside: just past the bar's right edge
90
+ anchorX = mark.x + mark.width + LABEL_PADDING;
91
+ fill = mark.fill;
92
+ textAnchor = 'start';
93
+ }
94
+
95
+ // anchorY = bar vertical center. With dominant-baseline: central,
96
+ // SVG places the text center at this y coordinate.
97
+ const anchorY = mark.y + mark.height / 2;
98
+
99
+ candidates.push({
100
+ text: valuePart,
101
+ anchorX,
102
+ anchorY,
103
+ width: textWidth,
104
+ height: textHeight,
105
+ priority: 'data',
106
+ style: {
107
+ fontFamily: 'system-ui, -apple-system, sans-serif',
108
+ fontSize: LABEL_FONT_SIZE,
109
+ fontWeight: LABEL_FONT_WEIGHT,
110
+ fill,
111
+ lineHeight: 1.2,
112
+ textAnchor,
113
+ dominantBaseline: 'central',
114
+ },
115
+ });
116
+ }
117
+
118
+ if (candidates.length === 0) return [];
119
+
120
+ // 'all': skip collision detection, mark everything visible
121
+ if (density === 'all') {
122
+ return candidates.map((c) => ({
123
+ text: c.text,
124
+ x: c.anchorX,
125
+ y: c.anchorY,
126
+ style: c.style,
127
+ visible: true,
128
+ }));
129
+ }
130
+
131
+ return resolveCollisions(candidates);
132
+ }
@@ -0,0 +1,277 @@
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 { computeColumnMarks } from '../compute';
6
+ import { computeColumnLabels } from '../labels';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Shared fixtures
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const chartArea: Rect = { x: 50, 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 makeSimpleColumnSpec(): NormalizedChartSpec {
22
+ return {
23
+ type: 'column',
24
+ data: [
25
+ { month: 'Jan', sales: 120 },
26
+ { month: 'Feb', sales: 80 },
27
+ { month: 'Mar', sales: 150 },
28
+ { month: 'Apr', sales: 200 },
29
+ ],
30
+ encoding: {
31
+ x: { field: 'month', type: 'nominal' },
32
+ y: { field: 'sales', type: 'quantitative' },
33
+ },
34
+ chrome: {},
35
+ annotations: [],
36
+ responsive: true,
37
+ theme: {},
38
+ darkMode: 'off',
39
+ labels: { density: 'auto', format: '' },
40
+ };
41
+ }
42
+
43
+ function makeGroupedColumnSpec(): NormalizedChartSpec {
44
+ return {
45
+ type: 'column',
46
+ data: [
47
+ { month: 'Jan', sales: 120, region: 'North' },
48
+ { month: 'Jan', sales: 80, region: 'South' },
49
+ { month: 'Feb', sales: 90, region: 'North' },
50
+ { month: 'Feb', sales: 110, region: 'South' },
51
+ { month: 'Mar', sales: 150, region: 'North' },
52
+ { month: 'Mar', sales: 130, region: 'South' },
53
+ ],
54
+ encoding: {
55
+ x: { field: 'month', type: 'nominal' },
56
+ y: { field: 'sales', type: 'quantitative' },
57
+ color: { field: 'region', type: 'nominal' },
58
+ },
59
+ chrome: {},
60
+ annotations: [],
61
+ responsive: true,
62
+ theme: {},
63
+ darkMode: 'off',
64
+ labels: { density: 'auto', format: '' },
65
+ };
66
+ }
67
+
68
+ function makeNegativeColumnSpec(): NormalizedChartSpec {
69
+ return {
70
+ type: 'column',
71
+ data: [
72
+ { quarter: 'Q1', growth: 5 },
73
+ { quarter: 'Q2', growth: -3 },
74
+ { quarter: 'Q3', growth: 8 },
75
+ { quarter: 'Q4', growth: -1 },
76
+ ],
77
+ encoding: {
78
+ x: { field: 'quarter', type: 'nominal' },
79
+ y: { field: 'growth', type: 'quantitative' },
80
+ },
81
+ chrome: {},
82
+ annotations: [],
83
+ responsive: true,
84
+ theme: {},
85
+ darkMode: 'off',
86
+ labels: { density: 'auto', format: '' },
87
+ };
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // computeColumnMarks tests
92
+ // ---------------------------------------------------------------------------
93
+
94
+ describe('computeColumnMarks', () => {
95
+ describe('simple columns', () => {
96
+ it('produces one RectMark per data row', () => {
97
+ const spec = makeSimpleColumnSpec();
98
+ const scales = computeScales(spec, chartArea, spec.data);
99
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
100
+
101
+ expect(marks).toHaveLength(4);
102
+ expect(marks.every((m) => m.type === 'rect')).toBe(true);
103
+ });
104
+
105
+ it('columns have positive width and height', () => {
106
+ const spec = makeSimpleColumnSpec();
107
+ const scales = computeScales(spec, chartArea, spec.data);
108
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
109
+
110
+ for (const mark of marks) {
111
+ expect(mark.width).toBeGreaterThan(0);
112
+ expect(mark.height).toBeGreaterThan(0);
113
+ }
114
+ });
115
+
116
+ it('taller columns correspond to larger values', () => {
117
+ const spec = makeSimpleColumnSpec();
118
+ const scales = computeScales(spec, chartArea, spec.data);
119
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
120
+
121
+ // Apr (200) should be taller than Feb (80)
122
+ const apr = marks.find((m) => m.aria.label.includes('Apr'))!;
123
+ const feb = marks.find((m) => m.aria.label.includes('Feb'))!;
124
+ expect(apr.height).toBeGreaterThan(feb.height);
125
+ });
126
+
127
+ it('higher values have lower y positions (SVG coordinates)', () => {
128
+ const spec = makeSimpleColumnSpec();
129
+ const scales = computeScales(spec, chartArea, spec.data);
130
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
131
+
132
+ const apr = marks.find((m) => m.aria.label.includes('Apr'))!;
133
+ const feb = marks.find((m) => m.aria.label.includes('Feb'))!;
134
+ // Apr (200) should start higher (lower y) than Feb (80)
135
+ expect(apr.y).toBeLessThan(feb.y);
136
+ });
137
+
138
+ it('each column has an aria label with category and value', () => {
139
+ const spec = makeSimpleColumnSpec();
140
+ const scales = computeScales(spec, chartArea, spec.data);
141
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
142
+
143
+ expect(marks[0].aria.label).toContain('Jan');
144
+ expect(marks[0].aria.label).toContain('120');
145
+ });
146
+ });
147
+
148
+ describe('stacked columns', () => {
149
+ it('produces marks for all data rows', () => {
150
+ const spec = makeGroupedColumnSpec();
151
+ const scales = computeScales(spec, chartArea, spec.data);
152
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
153
+
154
+ // 3 categories * 2 groups = 6
155
+ expect(marks).toHaveLength(6);
156
+ });
157
+
158
+ it('stacked segments within a category have different colors', () => {
159
+ const spec = makeGroupedColumnSpec();
160
+ const scales = computeScales(spec, chartArea, spec.data);
161
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
162
+
163
+ const janMarks = marks.filter((m) => m.aria.label.includes('Jan'));
164
+ expect(janMarks).toHaveLength(2);
165
+ expect(janMarks[0].fill).not.toBe(janMarks[1].fill);
166
+ });
167
+
168
+ it('stacked columns within a category share the same x position', () => {
169
+ const spec = makeGroupedColumnSpec();
170
+ const scales = computeScales(spec, chartArea, spec.data);
171
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
172
+
173
+ const janNorth = marks.find(
174
+ (m) => m.aria.label.includes('Jan') && m.aria.label.includes('North'),
175
+ )!;
176
+ const janSouth = marks.find(
177
+ (m) => m.aria.label.includes('Jan') && m.aria.label.includes('South'),
178
+ )!;
179
+
180
+ // Stacked columns share the same x position
181
+ expect(janNorth.x).toBe(janSouth.x);
182
+ // But have different y positions (stacked vertically)
183
+ expect(janNorth.y).not.toBe(janSouth.y);
184
+ });
185
+ });
186
+
187
+ describe('negative values', () => {
188
+ it('negative columns extend downward from baseline', () => {
189
+ const spec = makeNegativeColumnSpec();
190
+ const scales = computeScales(spec, chartArea, spec.data);
191
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
192
+
193
+ const q2 = marks.find((m) => m.aria.label.includes('Q2'))!;
194
+ const q1 = marks.find((m) => m.aria.label.includes('Q1'))!;
195
+
196
+ // Negative value column should start at a higher y (lower on page = baseline)
197
+ expect(q2.y).toBeGreaterThan(q1.y);
198
+ });
199
+
200
+ it('all columns have positive height', () => {
201
+ const spec = makeNegativeColumnSpec();
202
+ const scales = computeScales(spec, chartArea, spec.data);
203
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
204
+
205
+ for (const mark of marks) {
206
+ expect(mark.height).toBeGreaterThan(0);
207
+ }
208
+ });
209
+ });
210
+
211
+ describe('edge cases', () => {
212
+ it('returns empty array when no y encoding', () => {
213
+ const spec: NormalizedChartSpec = {
214
+ type: 'column',
215
+ data: [{ month: 'Jan', sales: 100 }],
216
+ encoding: {
217
+ x: { field: 'month', type: 'nominal' },
218
+ },
219
+ chrome: {},
220
+ annotations: [],
221
+ responsive: true,
222
+ theme: {},
223
+ darkMode: 'off',
224
+ labels: { density: 'auto', format: '' },
225
+ };
226
+ const scales = computeScales(spec, chartArea, spec.data);
227
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
228
+ expect(marks).toHaveLength(0);
229
+ });
230
+
231
+ it('returns empty array for empty data', () => {
232
+ const spec: NormalizedChartSpec = {
233
+ type: 'column',
234
+ data: [],
235
+ encoding: {
236
+ x: { field: 'month', type: 'nominal' },
237
+ y: { field: 'sales', type: 'quantitative' },
238
+ },
239
+ chrome: {},
240
+ annotations: [],
241
+ responsive: true,
242
+ theme: {},
243
+ darkMode: 'off',
244
+ labels: { density: 'auto', format: '' },
245
+ };
246
+ const scales = computeScales(spec, chartArea, spec.data);
247
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
248
+ expect(marks).toHaveLength(0);
249
+ });
250
+ });
251
+ });
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // computeColumnLabels tests
255
+ // ---------------------------------------------------------------------------
256
+
257
+ describe('computeColumnLabels', () => {
258
+ it('produces one label per column mark', () => {
259
+ const spec = makeSimpleColumnSpec();
260
+ const scales = computeScales(spec, chartArea, spec.data);
261
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
262
+ const labels = computeColumnLabels(marks, chartArea);
263
+
264
+ expect(labels).toHaveLength(marks.length);
265
+ });
266
+
267
+ it('labels contain the value text', () => {
268
+ const spec = makeSimpleColumnSpec();
269
+ const scales = computeScales(spec, chartArea, spec.data);
270
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
271
+ const labels = computeColumnLabels(marks, chartArea);
272
+
273
+ const texts = labels.map((l) => l.text);
274
+ expect(texts).toContain('120');
275
+ expect(texts).toContain('200');
276
+ });
277
+ });
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Column chart (vertical bars) mark computation.
3
+ *
4
+ * Takes a normalized chart spec with resolved scales and produces
5
+ * RectMark[] for rendering vertical columns. When a color encoding
6
+ * is present, columns are stacked (cumulative heights per category).
7
+ *
8
+ * Shares conceptual logic with bar chart but axes are swapped:
9
+ * x-axis is categorical (band scale), y-axis is quantitative.
10
+ */
11
+
12
+ import type {
13
+ DataRow,
14
+ Encoding,
15
+ LayoutStrategy,
16
+ MarkAria,
17
+ Rect,
18
+ RectMark,
19
+ } from '@opendata-ai/openchart-core';
20
+ import { abbreviateNumber, formatNumber } from '@opendata-ai/openchart-core';
21
+ import type { ScaleBand, ScaleLinear } from 'd3-scale';
22
+
23
+ import type { NormalizedChartSpec } from '../../compiler/types';
24
+ import type { ResolvedScales } from '../../layout/scales';
25
+ import { getColor, getSequentialColor, groupByField } from '../utils';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Constants
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const MIN_COLUMN_HEIGHT = 1;
32
+
33
+ /** Format a column value for display (abbreviate large numbers). */
34
+ function formatColumnValue(value: number): string {
35
+ if (Math.abs(value) >= 1000) return abbreviateNumber(value);
36
+ return formatNumber(value);
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Public API
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Compute vertical column marks from a normalized chart spec.
45
+ *
46
+ * X axis uses a band scale for categories. Y axis uses a linear scale
47
+ * for values. When a color encoding is present, columns within each
48
+ * category are stacked (cumulative heights).
49
+ */
50
+ export function computeColumnMarks(
51
+ spec: NormalizedChartSpec,
52
+ scales: ResolvedScales,
53
+ _chartArea: Rect,
54
+ _strategy: LayoutStrategy,
55
+ ): RectMark[] {
56
+ const encoding = spec.encoding as Encoding;
57
+ const xChannel = encoding.x;
58
+ const yChannel = encoding.y;
59
+
60
+ if (!xChannel || !yChannel || !scales.x || !scales.y) {
61
+ return [];
62
+ }
63
+
64
+ const xScale = scales.x.scale as ScaleBand<string>;
65
+ const yScale = scales.y.scale as ScaleLinear<number, number>;
66
+
67
+ // Band scale should provide bandwidth
68
+ if (typeof xScale.bandwidth !== 'function') {
69
+ return [];
70
+ }
71
+
72
+ const bandwidth = xScale.bandwidth();
73
+ const baseline = yScale(0);
74
+ const colorField = encoding.color?.field;
75
+
76
+ const isSequentialColor = encoding.color?.type === 'quantitative';
77
+
78
+ // Color encoding present: decide between colored simple columns vs stacked
79
+ if (colorField && !isSequentialColor) {
80
+ // Check if any category has multiple rows (actual stacking needed)
81
+ const categoryGroups = groupByField(spec.data, xChannel.field);
82
+ const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
83
+
84
+ if (needsStacking) {
85
+ return computeStackedColumns(
86
+ spec.data,
87
+ xChannel.field,
88
+ yChannel.field,
89
+ colorField,
90
+ xScale,
91
+ yScale,
92
+ bandwidth,
93
+ baseline,
94
+ scales,
95
+ );
96
+ }
97
+
98
+ // Single row per category: render like simple columns but with color from scale
99
+ return computeColoredColumns(
100
+ spec.data,
101
+ xChannel.field,
102
+ yChannel.field,
103
+ colorField,
104
+ xScale,
105
+ yScale,
106
+ bandwidth,
107
+ baseline,
108
+ scales,
109
+ );
110
+ }
111
+
112
+ return computeSimpleColumns(
113
+ spec.data,
114
+ xChannel.field,
115
+ yChannel.field,
116
+ xScale,
117
+ yScale,
118
+ bandwidth,
119
+ baseline,
120
+ scales,
121
+ isSequentialColor,
122
+ );
123
+ }
124
+
125
+ /** Compute simple (non-grouped) vertical columns. */
126
+ function computeSimpleColumns(
127
+ data: DataRow[],
128
+ categoryField: string,
129
+ valueField: string,
130
+ xScale: ScaleBand<string>,
131
+ yScale: ScaleLinear<number, number>,
132
+ bandwidth: number,
133
+ baseline: number,
134
+ scales: ResolvedScales,
135
+ sequentialColor = false,
136
+ ): RectMark[] {
137
+ const marks: RectMark[] = [];
138
+
139
+ for (const row of data) {
140
+ const category = String(row[categoryField] ?? '');
141
+ const value = Number(row[valueField] ?? 0);
142
+ if (!Number.isFinite(value)) continue;
143
+
144
+ const bandX = xScale(category);
145
+ if (bandX === undefined) continue;
146
+
147
+ const color = sequentialColor
148
+ ? getSequentialColor(scales, value)
149
+ : getColor(scales, '__default__');
150
+ const yPos = yScale(value);
151
+ const columnHeight = Math.max(Math.abs(baseline - yPos), MIN_COLUMN_HEIGHT);
152
+
153
+ // For positive values, column goes upward from baseline.
154
+ // For negative values, column goes downward from baseline.
155
+ const y = value >= 0 ? yPos : baseline;
156
+
157
+ const aria: MarkAria = {
158
+ label: `${category}: ${formatColumnValue(value)}`,
159
+ };
160
+
161
+ marks.push({
162
+ type: 'rect',
163
+ x: bandX,
164
+ y,
165
+ width: bandwidth,
166
+ height: columnHeight,
167
+ fill: color,
168
+ cornerRadius: 2,
169
+ data: row as Record<string, unknown>,
170
+ aria,
171
+ });
172
+ }
173
+
174
+ return marks;
175
+ }
176
+
177
+ /** Compute colored (non-stacked) vertical columns. Used when color encoding
178
+ * is present but each category has only one row (e.g., diverging charts). */
179
+ function computeColoredColumns(
180
+ data: DataRow[],
181
+ categoryField: string,
182
+ valueField: string,
183
+ colorField: string,
184
+ xScale: ScaleBand<string>,
185
+ yScale: ScaleLinear<number, number>,
186
+ bandwidth: number,
187
+ baseline: number,
188
+ scales: ResolvedScales,
189
+ ): RectMark[] {
190
+ const marks: RectMark[] = [];
191
+
192
+ for (const row of data) {
193
+ const category = String(row[categoryField] ?? '');
194
+ const value = Number(row[valueField] ?? 0);
195
+ if (!Number.isFinite(value)) continue;
196
+
197
+ const bandX = xScale(category);
198
+ if (bandX === undefined) continue;
199
+
200
+ const groupKey = String(row[colorField] ?? '');
201
+ const color = getColor(scales, groupKey);
202
+ const yPos = yScale(value);
203
+ const columnHeight = Math.max(Math.abs(baseline - yPos), MIN_COLUMN_HEIGHT);
204
+
205
+ const y = value >= 0 ? yPos : baseline;
206
+
207
+ const aria: MarkAria = {
208
+ label: `${category}, ${groupKey}: ${formatColumnValue(value)}`,
209
+ };
210
+
211
+ marks.push({
212
+ type: 'rect',
213
+ x: bandX,
214
+ y,
215
+ width: bandwidth,
216
+ height: columnHeight,
217
+ fill: color,
218
+ cornerRadius: 2,
219
+ data: row as Record<string, unknown>,
220
+ aria,
221
+ });
222
+ }
223
+
224
+ return marks;
225
+ }
226
+
227
+ /** Compute stacked vertical columns. */
228
+ function computeStackedColumns(
229
+ data: DataRow[],
230
+ categoryField: string,
231
+ valueField: string,
232
+ colorField: string,
233
+ xScale: ScaleBand<string>,
234
+ yScale: ScaleLinear<number, number>,
235
+ bandwidth: number,
236
+ _baseline: number,
237
+ scales: ResolvedScales,
238
+ ): RectMark[] {
239
+ const marks: RectMark[] = [];
240
+ const categoryGroups = groupByField(data, categoryField);
241
+
242
+ for (const [category, rows] of categoryGroups) {
243
+ const bandX = xScale(category);
244
+ if (bandX === undefined) continue;
245
+
246
+ let cumulativeValue = 0;
247
+
248
+ for (const row of rows) {
249
+ const groupKey = String(row[colorField] ?? '');
250
+ const value = Number(row[valueField] ?? 0);
251
+ // Stacking only applies to positive values; negative/zero rows are skipped
252
+ // since cumulative stacking doesn't make visual sense for mixed signs.
253
+ if (!Number.isFinite(value) || value <= 0) continue;
254
+
255
+ const color = getColor(scales, groupKey);
256
+
257
+ const yTop = yScale(cumulativeValue + value);
258
+ const yBottom = yScale(cumulativeValue);
259
+ const columnHeight = Math.max(Math.abs(yBottom - yTop), MIN_COLUMN_HEIGHT);
260
+
261
+ const aria: MarkAria = {
262
+ label: `${category}, ${groupKey}: ${formatColumnValue(value)}`,
263
+ };
264
+
265
+ marks.push({
266
+ type: 'rect',
267
+ x: bandX,
268
+ y: yTop,
269
+ width: bandwidth,
270
+ height: columnHeight,
271
+ fill: color,
272
+ cornerRadius: 0,
273
+ data: row as Record<string, unknown>,
274
+ aria,
275
+ });
276
+
277
+ cumulativeValue += value;
278
+ }
279
+ }
280
+
281
+ return marks;
282
+ }