@opendata-ai/openchart-engine 6.27.0 → 6.28.2

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 (36) hide show
  1. package/dist/index.d.ts +38 -6
  2. package/dist/index.js +1040 -521
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +31 -4
  6. package/src/__tests__/axes.test.ts +101 -3
  7. package/src/__tests__/legend.test.ts +2 -2
  8. package/src/annotations/__tests__/compute.test.ts +175 -0
  9. package/src/annotations/position.ts +37 -1
  10. package/src/annotations/resolve-range.ts +5 -5
  11. package/src/barlist/__tests__/compile-barlist.test.ts +200 -0
  12. package/src/barlist/compile-barlist.ts +380 -0
  13. package/src/barlist/types.ts +28 -0
  14. package/src/charts/bar/__tests__/compute.test.ts +222 -0
  15. package/src/charts/bar/compute.ts +77 -44
  16. package/src/charts/bar/index.ts +1 -0
  17. package/src/charts/bar/labels.ts +3 -2
  18. package/src/charts/column/compute.ts +60 -27
  19. package/src/charts/column/index.ts +1 -0
  20. package/src/charts/column/labels.ts +2 -1
  21. package/src/charts/line/__tests__/compute.test.ts +2 -2
  22. package/src/charts/line/area.ts +25 -4
  23. package/src/charts/line/compute.ts +15 -5
  24. package/src/compile.ts +26 -1
  25. package/src/compiler/normalize.ts +25 -1
  26. package/src/compiler/types.ts +5 -3
  27. package/src/compiler/validate.ts +120 -5
  28. package/src/index.ts +5 -0
  29. package/src/layout/axes/ticks.ts +37 -8
  30. package/src/layout/axes.ts +11 -4
  31. package/src/layout/dimensions.ts +10 -4
  32. package/src/layout/scales.ts +10 -0
  33. package/src/legend/wrap.ts +1 -1
  34. package/src/tilemap/__tests__/compile-tilemap.test.ts +5 -2
  35. package/src/tilemap/compile-tilemap.ts +41 -29
  36. package/src/tooltips/compute.ts +4 -2
@@ -0,0 +1,200 @@
1
+ import type { BarListSpec, CompileOptions } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { compileBarList } from '../compile-barlist';
5
+
6
+ const BASE_OPTIONS: CompileOptions = {
7
+ width: 600,
8
+ height: 400,
9
+ };
10
+
11
+ function makeSpec(overrides?: Partial<BarListSpec>): BarListSpec {
12
+ return {
13
+ type: 'barlist',
14
+ data: [
15
+ { label: 'Alpha', count: 100 },
16
+ { label: 'Beta', count: 75 },
17
+ { label: 'Gamma', count: 50 },
18
+ { label: 'Delta', count: 25 },
19
+ ],
20
+ encoding: {
21
+ label: { field: 'label', type: 'nominal' },
22
+ value: { field: 'count', type: 'quantitative' },
23
+ },
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ describe('compileBarList', () => {
29
+ it('compiles a basic barlist spec with correct row count', () => {
30
+ const layout = compileBarList(makeSpec(), BASE_OPTIONS);
31
+ expect(layout.rows).toHaveLength(4);
32
+ });
33
+
34
+ it('sorts rows by value descending', () => {
35
+ const spec = makeSpec({
36
+ data: [
37
+ { label: 'Low', count: 10 },
38
+ { label: 'High', count: 100 },
39
+ { label: 'Mid', count: 50 },
40
+ ],
41
+ });
42
+ const layout = compileBarList(spec, BASE_OPTIONS);
43
+ expect(layout.rows[0].label.text).toBe('High');
44
+ expect(layout.rows[1].label.text).toBe('Mid');
45
+ expect(layout.rows[2].label.text).toBe('Low');
46
+ });
47
+
48
+ it('limits rows to maxItems', () => {
49
+ const spec = makeSpec({ maxItems: 2 });
50
+ const layout = compileBarList(spec, BASE_OPTIONS);
51
+ expect(layout.rows).toHaveLength(2);
52
+ expect(layout.rows[0].label.text).toBe('Alpha');
53
+ expect(layout.rows[1].label.text).toBe('Beta');
54
+ });
55
+
56
+ it('filters out null values', () => {
57
+ const spec = makeSpec({
58
+ data: [
59
+ { label: 'Valid', count: 50 },
60
+ { label: 'Null', count: null },
61
+ { label: 'Also valid', count: 25 },
62
+ ],
63
+ });
64
+ const layout = compileBarList(spec, BASE_OPTIONS);
65
+ expect(layout.rows).toHaveLength(2);
66
+ });
67
+
68
+ it('assigns cycling colors to rows', () => {
69
+ const layout = compileBarList(makeSpec(), BASE_OPTIONS);
70
+ const colors = layout.rows.map((r) => r.bar.fill);
71
+ expect(colors[0]).not.toBe(colors[1]);
72
+ expect(colors[1]).not.toBe(colors[2]);
73
+ });
74
+
75
+ it('bar width is proportional to value', () => {
76
+ const layout = compileBarList(makeSpec(), BASE_OPTIONS);
77
+ const maxWidth = layout.rows[0].bar.width;
78
+ const halfWidth = layout.rows[2].bar.width;
79
+ expect(halfWidth).toBeCloseTo(maxWidth * 0.5, 0);
80
+ });
81
+
82
+ it('first row gets full-width bar', () => {
83
+ const layout = compileBarList(makeSpec(), BASE_OPTIONS);
84
+ const row0 = layout.rows[0];
85
+ expect(row0.bar.width).toBe(row0.track.width);
86
+ });
87
+
88
+ it('pill cornerRadius is half the bar height', () => {
89
+ const spec = makeSpec({ barHeight: 8, cornerRadius: 'pill' });
90
+ const layout = compileBarList(spec, BASE_OPTIONS);
91
+ expect(layout.rows[0].bar.cornerRadius).toBe(4);
92
+ expect(layout.rows[0].track.cornerRadius).toBe(4);
93
+ });
94
+
95
+ it('numeric cornerRadius is used directly', () => {
96
+ const spec = makeSpec({ cornerRadius: 3 });
97
+ const layout = compileBarList(spec, BASE_OPTIONS);
98
+ expect(layout.rows[0].bar.cornerRadius).toBe(3);
99
+ });
100
+
101
+ it('default barHeight is 6', () => {
102
+ const layout = compileBarList(makeSpec(), BASE_OPTIONS);
103
+ expect(layout.rows[0].bar.height).toBe(6);
104
+ });
105
+
106
+ it('custom barHeight applies', () => {
107
+ const spec = makeSpec({ barHeight: 10 });
108
+ const layout = compileBarList(spec, BASE_OPTIONS);
109
+ expect(layout.rows[0].bar.height).toBe(10);
110
+ });
111
+
112
+ it('builds tooltip descriptors keyed by row index', () => {
113
+ const layout = compileBarList(makeSpec(), BASE_OPTIONS);
114
+ expect(layout.tooltipDescriptors.size).toBe(4);
115
+ const tooltip = layout.tooltipDescriptors.get('0');
116
+ expect(tooltip?.title).toBe('Alpha');
117
+ expect(tooltip?.fields[0].value).toBeDefined();
118
+ });
119
+
120
+ it('builds a11y metadata', () => {
121
+ const layout = compileBarList(makeSpec(), BASE_OPTIONS);
122
+ expect(layout.a11y.altText).toContain('4 items');
123
+ expect(layout.a11y.dataTableFallback).toHaveLength(4);
124
+ expect(layout.a11y.role).toBe('list');
125
+ });
126
+
127
+ it('returns animation indices matching row order', () => {
128
+ const layout = compileBarList(makeSpec(), BASE_OPTIONS);
129
+ expect(layout.rows[0].animationIndex).toBe(0);
130
+ expect(layout.rows[1].animationIndex).toBe(1);
131
+ expect(layout.rows[2].animationIndex).toBe(2);
132
+ });
133
+
134
+ it('throws when data is empty', () => {
135
+ const spec = makeSpec({ data: [] });
136
+ expect(() => compileBarList(spec, BASE_OPTIONS)).toThrow(/empty/i);
137
+ });
138
+
139
+ it('formats values with valueFormat', () => {
140
+ const spec = makeSpec({ valueFormat: '$,.0f' });
141
+ const layout = compileBarList(spec, BASE_OPTIONS);
142
+ expect(layout.rows[0].formattedValue).toBe('$100');
143
+ });
144
+
145
+ it('formats values with SI suffix format', () => {
146
+ const spec = makeSpec({
147
+ data: [
148
+ { label: 'Big', count: 1500 },
149
+ { label: 'Small', count: 200 },
150
+ ],
151
+ valueFormat: '~s',
152
+ });
153
+ const layout = compileBarList(spec, BASE_OPTIONS);
154
+ expect(layout.rows[0].formattedValue).toBe('1.5k');
155
+ });
156
+
157
+ it('dark mode changes label colors', () => {
158
+ const lightLayout = compileBarList(makeSpec(), { ...BASE_OPTIONS, darkMode: false });
159
+ const darkLayout = compileBarList(makeSpec(), { ...BASE_OPTIONS, darkMode: true });
160
+ expect(lightLayout.rows[0].valueLabel.style.fill).not.toBe(
161
+ darkLayout.rows[0].valueLabel.style.fill,
162
+ );
163
+ });
164
+
165
+ it('rejects non-barlist specs', () => {
166
+ const invalidSpec = {
167
+ type: 'tilemap',
168
+ data: { CA: 100 },
169
+ };
170
+ expect(() => compileBarList(invalidSpec, BASE_OPTIONS)).toThrow('non-barlist');
171
+ });
172
+
173
+ it('uses color encoding for consistent category colors', () => {
174
+ const spec = makeSpec({
175
+ data: [
176
+ { label: 'A', count: 100, cat: 'x' },
177
+ { label: 'B', count: 50, cat: 'x' },
178
+ { label: 'C', count: 25, cat: 'y' },
179
+ ],
180
+ encoding: {
181
+ label: { field: 'label', type: 'nominal' },
182
+ value: { field: 'count', type: 'quantitative' },
183
+ color: { field: 'cat', type: 'nominal' },
184
+ },
185
+ });
186
+ const layout = compileBarList(spec, BASE_OPTIONS);
187
+ expect(layout.rows[0].bar.fill).toBe(layout.rows[1].bar.fill);
188
+ expect(layout.rows[0].bar.fill).not.toBe(layout.rows[2].bar.fill);
189
+ });
190
+
191
+ it('preserves original data in row marks', () => {
192
+ const layout = compileBarList(makeSpec(), BASE_OPTIONS);
193
+ expect(layout.rows[0].data).toEqual({ label: 'Alpha', count: 100 });
194
+ });
195
+
196
+ it('rows have correct aria labels', () => {
197
+ const layout = compileBarList(makeSpec(), BASE_OPTIONS);
198
+ expect(layout.rows[0].aria.label).toContain('Alpha');
199
+ });
200
+ });
@@ -0,0 +1,380 @@
1
+ /**
2
+ * BarList compilation pipeline.
3
+ *
4
+ * Takes a raw barlist spec, validates, normalizes, resolves theme, computes
5
+ * chrome, lays out rows with proportional bars, builds tooltips and a11y,
6
+ * and returns a BarListLayout.
7
+ *
8
+ * Pipeline:
9
+ * validate -> normalize -> resolve theme -> dark mode adapt ->
10
+ * compute chrome -> extract data -> sort/limit rows ->
11
+ * compute row layout -> build row marks -> tooltips -> a11y ->
12
+ * animation -> return BarListLayout
13
+ */
14
+
15
+ import type {
16
+ BarListLayout,
17
+ BarListRowMark,
18
+ CompileOptions,
19
+ ResolvedAnimation,
20
+ ResolvedTheme,
21
+ TextStyle,
22
+ TooltipContent,
23
+ TooltipField,
24
+ } from '@opendata-ai/openchart-core';
25
+ import {
26
+ adaptTheme,
27
+ buildD3Formatter,
28
+ computeChrome,
29
+ estimateTextWidth,
30
+ formatNumber,
31
+ resolveTheme,
32
+ } from '@opendata-ai/openchart-core';
33
+
34
+ import { resolveAnimation } from '../compiler/animation';
35
+ import { compile as compileSpec } from '../compiler/index';
36
+ import type { NormalizedBarListSpec } from './types';
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Constants
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const DEFAULT_ROW_GAP = 8;
43
+ const LABEL_BAR_GAP = 12;
44
+ const BAR_VALUE_GAP = 12;
45
+ const VALUE_WIDTH = 56;
46
+ const LABEL_FONT_SIZE = 13;
47
+ const LABEL_FONT_WEIGHT = 500;
48
+ const SUBTITLE_FONT_SIZE = 12;
49
+ const SUBTITLE_FONT_WEIGHT = 400;
50
+ const VALUE_FONT_SIZE = 12;
51
+ const VALUE_FONT_WEIGHT = 400;
52
+
53
+ const BARLIST_COLORS = ['#06b6d4', '#34d399', '#fbbf24', '#f472b6', '#a78bfa'];
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Public API
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export function compileBarList(spec: unknown, options: CompileOptions): BarListLayout {
60
+ const { spec: normalized } = compileSpec(spec);
61
+
62
+ if (!('type' in normalized) || normalized.type !== 'barlist') {
63
+ throw new Error(
64
+ 'compileBarList received a non-barlist spec. Use compileChart, compileTable, compileGraph, compileSankey, or compileTileMap instead.',
65
+ );
66
+ }
67
+
68
+ const barlistSpec = normalized as NormalizedBarListSpec;
69
+
70
+ const rawWatermark = (spec as Record<string, unknown>).watermark;
71
+ const watermark =
72
+ rawWatermark !== undefined ? barlistSpec.watermark : (options.watermark ?? true);
73
+
74
+ // Resolve theme
75
+ const mergedThemeConfig = options.theme
76
+ ? { ...barlistSpec.theme, ...options.theme }
77
+ : barlistSpec.theme;
78
+ const lightTheme: ResolvedTheme = resolveTheme(mergedThemeConfig);
79
+ let theme: ResolvedTheme = lightTheme;
80
+ if (options.darkMode) {
81
+ theme = adaptTheme(theme);
82
+ }
83
+
84
+ // Compute chrome
85
+ const chrome = computeChrome(
86
+ {
87
+ title: barlistSpec.chrome.title,
88
+ subtitle: barlistSpec.chrome.subtitle,
89
+ source: barlistSpec.chrome.source,
90
+ byline: barlistSpec.chrome.byline,
91
+ footer: barlistSpec.chrome.footer,
92
+ },
93
+ theme,
94
+ options.width,
95
+ options.measureText,
96
+ 'full',
97
+ undefined,
98
+ watermark,
99
+ );
100
+
101
+ // Compute drawing area
102
+ const padding = theme.spacing.padding;
103
+ const fullArea = {
104
+ x: padding,
105
+ y: padding + chrome.topHeight,
106
+ width: options.width - padding * 2,
107
+ height: options.height - chrome.topHeight - chrome.bottomHeight - padding * 2,
108
+ };
109
+
110
+ if (fullArea.width <= 0 || fullArea.height <= 0) {
111
+ return emptyLayout(chrome, theme, options, watermark);
112
+ }
113
+
114
+ // Extract data
115
+ const labelField = barlistSpec.encoding.label.field;
116
+ const valueField = barlistSpec.encoding.value.field;
117
+ const subtitleField = barlistSpec.encoding.subtitle?.field;
118
+ const colorField = barlistSpec.encoding.color?.field;
119
+
120
+ // Compute row dimensions up front so we can auto-cap maxItems to fit available height
121
+ const barHeight = barlistSpec.barHeight;
122
+ const cornerRadius =
123
+ barlistSpec.cornerRadius === 'pill' ? barHeight / 2 : barlistSpec.cornerRadius;
124
+ const rowContentHeight = Math.max(barHeight, LABEL_FONT_SIZE * 1.4);
125
+ const rowHeight = rowContentHeight + DEFAULT_ROW_GAP;
126
+ const maxFittingRows = Math.max(1, Math.floor(fullArea.height / rowHeight));
127
+
128
+ // Filter valid rows, sort descending, cap to the lesser of maxItems and available height
129
+ const validRows = barlistSpec.data
130
+ .filter((row) => {
131
+ const val = row[valueField];
132
+ return val !== null && val !== undefined && !Number.isNaN(Number(val));
133
+ })
134
+ .sort((a, b) => Number(b[valueField]) - Number(a[valueField]))
135
+ .slice(0, Math.min(barlistSpec.maxItems, maxFittingRows));
136
+
137
+ if (validRows.length === 0) {
138
+ return emptyLayout(chrome, theme, options, watermark);
139
+ }
140
+
141
+ // Use absolute max so negative-only datasets produce valid proportions (e.g. [-100,-50] -> maxAbs=100)
142
+ const maxValue = Math.max(...validRows.map((r) => Math.abs(Number(r[valueField]))));
143
+
144
+ // Color assignment: cycle through barlist-specific palette
145
+ const colorMap = new Map<string, string>();
146
+ let colorIndex = 0;
147
+ const palette = BARLIST_COLORS;
148
+
149
+ function getColor(row: Record<string, unknown>, idx: number): string {
150
+ if (colorField) {
151
+ const key = String(row[colorField] ?? '');
152
+ if (!colorMap.has(key)) {
153
+ colorMap.set(key, palette[colorIndex % palette.length]);
154
+ colorIndex++;
155
+ }
156
+ return colorMap.get(key)!;
157
+ }
158
+ return palette[idx % palette.length];
159
+ }
160
+
161
+ // Value formatter
162
+ const formatter = buildD3Formatter(barlistSpec.valueFormat) ?? formatNumber;
163
+
164
+ // Compute label width: measure all labels and use a consistent width
165
+ const measureText =
166
+ options.measureText ??
167
+ ((text: string, fontSize: number) => ({
168
+ width: estimateTextWidth(text, fontSize),
169
+ height: fontSize,
170
+ }));
171
+
172
+ // When subtitles are present the column must fit: labelText + 6px gap + subtitleText
173
+ const perRowLabelWidths = new Map<number, number>();
174
+ let maxCombinedWidth = 0;
175
+ for (let i = 0; i < validRows.length; i++) {
176
+ const row = validRows[i];
177
+ const label = String(row[labelField] ?? '');
178
+ const labelW = measureText(label, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT).width;
179
+ perRowLabelWidths.set(i, labelW);
180
+ let combined = labelW + 4;
181
+ if (subtitleField && row[subtitleField] != null) {
182
+ const subtitle = String(row[subtitleField]);
183
+ combined =
184
+ labelW + 6 + measureText(subtitle, SUBTITLE_FONT_SIZE, SUBTITLE_FONT_WEIGHT).width + 4;
185
+ }
186
+ maxCombinedWidth = Math.max(maxCombinedWidth, combined);
187
+ }
188
+ const isNarrow = fullArea.width < 400;
189
+ const labelBarGap = isNarrow ? 8 : LABEL_BAR_GAP;
190
+ const barValueGap = isNarrow ? 6 : BAR_VALUE_GAP;
191
+ const valueWidth = isNarrow ? 44 : VALUE_WIDTH;
192
+ const maxLabelPct = isNarrow ? 0.35 : 0.4;
193
+ const labelWidth = Math.max(50, Math.min(maxCombinedWidth, fullArea.width * maxLabelPct));
194
+
195
+ const barAreaWidth = fullArea.width - labelWidth - labelBarGap - barValueGap - valueWidth;
196
+
197
+ const labelColor = theme.colors.text;
198
+ const subtitleColor = options.darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.45)';
199
+ const valueColor = options.darkMode ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.55)';
200
+
201
+ // Build row marks
202
+ const rows: BarListRowMark[] = [];
203
+
204
+ for (let i = 0; i < validRows.length; i++) {
205
+ const row = validRows[i];
206
+ const value = Number(row[valueField]);
207
+ const labelText = String(row[labelField] ?? '');
208
+ const formattedValue = formatter(value);
209
+ const barColor = getColor(row, i);
210
+ const pct = maxValue > 0 ? Math.abs(value) / maxValue : 0;
211
+
212
+ const rowY = fullArea.y + i * rowHeight;
213
+ const centerY = rowY + rowContentHeight / 2;
214
+
215
+ // Label (left-aligned)
216
+ const labelX = fullArea.x;
217
+ const labelStyle: TextStyle = {
218
+ fontFamily: theme.fonts.family,
219
+ fontSize: LABEL_FONT_SIZE,
220
+ fontWeight: LABEL_FONT_WEIGHT,
221
+ fill: labelColor,
222
+ lineHeight: 1.4,
223
+ };
224
+
225
+ // Subtitle (left-aligned, positioned after this row's measured label width + gap)
226
+ let subtitle: BarListRowMark['subtitle'];
227
+ if (subtitleField && row[subtitleField] != null) {
228
+ const subtitleText = String(row[subtitleField]);
229
+ const subtitleX = labelX + (perRowLabelWidths.get(i) ?? 0) + 6;
230
+ subtitle = {
231
+ text: subtitleText,
232
+ x: subtitleX,
233
+ y: centerY,
234
+ style: {
235
+ fontFamily: theme.fonts.family,
236
+ fontSize: SUBTITLE_FONT_SIZE,
237
+ fontWeight: SUBTITLE_FONT_WEIGHT,
238
+ fill: subtitleColor,
239
+ lineHeight: 1.4,
240
+ },
241
+ visible: true,
242
+ };
243
+ }
244
+
245
+ // Track (muted background bar)
246
+ const trackX = fullArea.x + labelWidth + labelBarGap;
247
+ const trackY = centerY - barHeight / 2;
248
+ const trackWidth = Math.max(barAreaWidth, 0);
249
+
250
+ // Fill bar (proportional width)
251
+ const barWidth = Math.max(pct * trackWidth, 0);
252
+
253
+ // Value label (right-aligned)
254
+ const valueLabelX = trackX + trackWidth + barValueGap + valueWidth;
255
+ const valueLabelStyle: TextStyle = {
256
+ fontFamily: `${theme.fonts.family}, ui-monospace, monospace`,
257
+ fontSize: VALUE_FONT_SIZE,
258
+ fontWeight: VALUE_FONT_WEIGHT,
259
+ fill: valueColor,
260
+ lineHeight: 1.4,
261
+ };
262
+
263
+ const rowMark: BarListRowMark = {
264
+ type: 'barlist-row',
265
+ index: i,
266
+ y: rowY,
267
+ height: rowHeight,
268
+ label: {
269
+ text: labelText,
270
+ x: labelX,
271
+ y: centerY,
272
+ style: labelStyle,
273
+ visible: true,
274
+ },
275
+ subtitle,
276
+ track: {
277
+ x: trackX,
278
+ y: trackY,
279
+ width: trackWidth,
280
+ height: barHeight,
281
+ cornerRadius,
282
+ },
283
+ bar: {
284
+ x: trackX,
285
+ y: trackY,
286
+ width: barWidth,
287
+ height: barHeight,
288
+ cornerRadius,
289
+ fill: barColor,
290
+ },
291
+ valueLabel: {
292
+ text: formattedValue,
293
+ x: valueLabelX,
294
+ y: centerY,
295
+ style: valueLabelStyle,
296
+ visible: true,
297
+ },
298
+ value,
299
+ formattedValue,
300
+ aria: {
301
+ role: 'listitem',
302
+ label: `${labelText}: ${formattedValue}`,
303
+ },
304
+ animationIndex: i,
305
+ data: row,
306
+ };
307
+
308
+ rows.push(rowMark);
309
+ }
310
+
311
+ // Build tooltip descriptors.
312
+ // TODO: honour encoding.tooltip channel to let callers add extra fields beyond the default value field.
313
+ const tooltipDescriptors = new Map<string, TooltipContent>();
314
+ for (const row of rows) {
315
+ const fields: TooltipField[] = [
316
+ { label: barlistSpec.encoding.value.title ?? valueField, value: row.formattedValue },
317
+ ];
318
+ tooltipDescriptors.set(String(row.index), {
319
+ title: row.label.text,
320
+ fields,
321
+ });
322
+ }
323
+
324
+ // Build a11y metadata
325
+ const a11y = {
326
+ altText: `Bar list showing ${rows.length} items ranked by ${valueField}`,
327
+ dataTableFallback: rows.map((r) => [r.label.text, r.formattedValue]),
328
+ role: 'list' as const,
329
+ keyboardNavigable: rows.length > 0,
330
+ };
331
+
332
+ // Resolve animation
333
+ const resolvedAnimation: ResolvedAnimation | undefined = resolveAnimation(barlistSpec.animation);
334
+
335
+ return {
336
+ area: fullArea,
337
+ chrome,
338
+ rows,
339
+ tooltipDescriptors,
340
+ a11y,
341
+ theme,
342
+ width: options.width,
343
+ height: options.height,
344
+ animation: resolvedAnimation,
345
+ watermark,
346
+ measureText,
347
+ };
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Empty layout fallback
352
+ // ---------------------------------------------------------------------------
353
+
354
+ function emptyLayout(
355
+ chrome: ReturnType<typeof computeChrome>,
356
+ theme: ResolvedTheme,
357
+ options: CompileOptions,
358
+ watermark: boolean,
359
+ ): BarListLayout {
360
+ return {
361
+ area: { x: 0, y: 0, width: 0, height: 0 },
362
+ chrome,
363
+ rows: [],
364
+ tooltipDescriptors: new Map(),
365
+ a11y: {
366
+ altText: 'Empty bar list',
367
+ dataTableFallback: [],
368
+ role: 'list',
369
+ keyboardNavigable: false,
370
+ },
371
+ theme,
372
+ width: options.width,
373
+ height: options.height,
374
+ watermark,
375
+ animation: undefined,
376
+ measureText:
377
+ options.measureText ??
378
+ ((text, fontSize) => ({ width: estimateTextWidth(text, fontSize), height: fontSize })),
379
+ };
380
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Internal normalized barlist spec type used by the compilation pipeline.
3
+ */
4
+
5
+ import type {
6
+ AnimationSpec,
7
+ BarListEncoding,
8
+ DarkMode,
9
+ DataRow,
10
+ ThemeConfig,
11
+ } from '@opendata-ai/openchart-core';
12
+
13
+ import type { NormalizedChrome } from '../compiler/types';
14
+
15
+ export interface NormalizedBarListSpec {
16
+ type: 'barlist';
17
+ data: DataRow[];
18
+ encoding: BarListEncoding;
19
+ barHeight: number;
20
+ cornerRadius: number | 'pill';
21
+ maxItems: number;
22
+ chrome: NormalizedChrome;
23
+ theme: ThemeConfig;
24
+ darkMode: DarkMode;
25
+ watermark: boolean;
26
+ animation?: AnimationSpec;
27
+ valueFormat?: string;
28
+ }