@opendata-ai/openchart-engine 6.27.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.27.2",
3
+ "version": "6.28.2",
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",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "6.27.2",
51
+ "@opendata-ai/openchart-core": "6.28.2",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -413,6 +413,15 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
413
413
  "#756bb1",
414
414
  "#54278f",
415
415
  ],
416
+ "teal": [
417
+ "#06b6d4",
418
+ "#05a3be",
419
+ "#0490a8",
420
+ "#037d92",
421
+ "#026a7c",
422
+ "#015766",
423
+ "#004450",
424
+ ],
416
425
  },
417
426
  "text": "#1d1d1d",
418
427
  },
@@ -890,7 +899,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
890
899
  "seriesKey": "US",
891
900
  "stroke": "#1b7fa3",
892
901
  "strokeDasharray": undefined,
893
- "strokeWidth": 2.5,
902
+ "strokeWidth": 1.5,
894
903
  "type": "line",
895
904
  },
896
905
  {
@@ -998,7 +1007,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
998
1007
  "seriesKey": "UK",
999
1008
  "stroke": "#c44e52",
1000
1009
  "strokeDasharray": undefined,
1001
- "strokeWidth": 2.5,
1010
+ "strokeWidth": 1.5,
1002
1011
  "type": "line",
1003
1012
  },
1004
1013
  {
@@ -1106,7 +1115,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1106
1115
  "seriesKey": "FR",
1107
1116
  "stroke": "#6a9f58",
1108
1117
  "strokeDasharray": undefined,
1109
- "strokeWidth": 2.5,
1118
+ "strokeWidth": 1.5,
1110
1119
  "type": "line",
1111
1120
  },
1112
1121
  {
@@ -1214,7 +1223,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1214
1223
  "seriesKey": "DE",
1215
1224
  "stroke": "#d47215",
1216
1225
  "strokeDasharray": undefined,
1217
- "strokeWidth": 2.5,
1226
+ "strokeWidth": 1.5,
1218
1227
  "type": "line",
1219
1228
  },
1220
1229
  ],
@@ -1323,6 +1332,15 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1323
1332
  "#756bb1",
1324
1333
  "#54278f",
1325
1334
  ],
1335
+ "teal": [
1336
+ "#06b6d4",
1337
+ "#05a3be",
1338
+ "#0490a8",
1339
+ "#037d92",
1340
+ "#026a7c",
1341
+ "#015766",
1342
+ "#004450",
1343
+ ],
1326
1344
  },
1327
1345
  "text": "#1d1d1d",
1328
1346
  },
@@ -1891,6 +1909,15 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1891
1909
  "#756bb1",
1892
1910
  "#54278f",
1893
1911
  ],
1912
+ "teal": [
1913
+ "#06b6d4",
1914
+ "#05a3be",
1915
+ "#0490a8",
1916
+ "#037d92",
1917
+ "#026a7c",
1918
+ "#015766",
1919
+ "#004450",
1920
+ ],
1894
1921
  },
1895
1922
  "text": "#1d1d1d",
1896
1923
  },
@@ -447,7 +447,7 @@ describe('computeLegend', () => {
447
447
  legend: { position: 'top' as const },
448
448
  };
449
449
 
450
- it('places the legend exactly 4px above the chart area at standard width', () => {
450
+ it('places the legend exactly 8px above the chart area at standard width', () => {
451
451
  const layout = compileChart(topLegendSpec, { width: 600, height: 400 });
452
452
 
453
453
  expect(layout.legend.position).toBe('top');
@@ -456,7 +456,7 @@ describe('computeLegend', () => {
456
456
 
457
457
  const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
458
458
  const gap = layout.area.y - legendBottom;
459
- expect(gap).toBe(4);
459
+ expect(gap).toBe(8);
460
460
  });
461
461
 
462
462
  it('eliminates legend gap on narrow viewports (< 420px)', () => {
@@ -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
+ }