@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.
- package/dist/index.d.ts +38 -6
- package/dist/index.js +1040 -521
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +31 -4
- package/src/__tests__/axes.test.ts +101 -3
- package/src/__tests__/legend.test.ts +2 -2
- package/src/annotations/__tests__/compute.test.ts +175 -0
- package/src/annotations/position.ts +37 -1
- package/src/annotations/resolve-range.ts +5 -5
- package/src/barlist/__tests__/compile-barlist.test.ts +200 -0
- package/src/barlist/compile-barlist.ts +380 -0
- package/src/barlist/types.ts +28 -0
- package/src/charts/bar/__tests__/compute.test.ts +222 -0
- package/src/charts/bar/compute.ts +77 -44
- package/src/charts/bar/index.ts +1 -0
- package/src/charts/bar/labels.ts +3 -2
- package/src/charts/column/compute.ts +60 -27
- package/src/charts/column/index.ts +1 -0
- package/src/charts/column/labels.ts +2 -1
- package/src/charts/line/__tests__/compute.test.ts +2 -2
- package/src/charts/line/area.ts +25 -4
- package/src/charts/line/compute.ts +15 -5
- package/src/compile.ts +26 -1
- package/src/compiler/normalize.ts +25 -1
- package/src/compiler/types.ts +5 -3
- package/src/compiler/validate.ts +120 -5
- package/src/index.ts +5 -0
- package/src/layout/axes/ticks.ts +37 -8
- package/src/layout/axes.ts +11 -4
- package/src/layout/dimensions.ts +10 -4
- package/src/layout/scales.ts +10 -0
- package/src/legend/wrap.ts +1 -1
- package/src/tilemap/__tests__/compile-tilemap.test.ts +5 -2
- package/src/tilemap/compile-tilemap.ts +41 -29
- 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
|
+
}
|