@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/dist/index.d.ts +38 -6
- package/dist/index.js +1009 -520
- 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__/legend.test.ts +2 -2
- 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 +120 -0
- package/src/charts/bar/compute.ts +77 -45
- 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 +6 -4
- package/src/layout/axes.ts +2 -2
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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
|
|
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(
|
|
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
|
+
}
|