@opendata-ai/openchart-engine 7.1.3 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "7.1.3",
3
+ "version": "7.2.0",
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": "7.1.3",
51
+ "@opendata-ai/openchart-core": "7.2.0",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -227,7 +227,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
227
227
  "connector": undefined,
228
228
  "style": {
229
229
  "dominantBaseline": "central",
230
- "fill": "#111111",
230
+ "fill": "#ffffff",
231
231
  "fontFamily": "system-ui, -apple-system, sans-serif",
232
232
  "fontSize": 11,
233
233
  "fontWeight": 600,
@@ -260,7 +260,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
260
260
  "connector": undefined,
261
261
  "style": {
262
262
  "dominantBaseline": "central",
263
- "fill": "#111111",
263
+ "fill": "#ffffff",
264
264
  "fontFamily": "system-ui, -apple-system, sans-serif",
265
265
  "fontSize": 11,
266
266
  "fontWeight": 600,
@@ -293,7 +293,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
293
293
  "connector": undefined,
294
294
  "style": {
295
295
  "dominantBaseline": "central",
296
- "fill": "#111111",
296
+ "fill": "#ffffff",
297
297
  "fontFamily": "system-ui, -apple-system, sans-serif",
298
298
  "fontSize": 11,
299
299
  "fontWeight": 600,
@@ -360,14 +360,14 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
360
360
  "background": "transparent",
361
361
  "categorical": [
362
362
  "#06b6d4",
363
- "#eb7289",
364
- "#3bb974",
365
- "#ad87ed",
366
- "#e69c3a",
367
- "#4ba3f7",
368
- "#eb8656",
369
- "#8494fa",
370
- "#00b9c3",
363
+ "#ee4a73",
364
+ "#00b054",
365
+ "#a46bf5",
366
+ "#e07d00",
367
+ "#0091ff",
368
+ "#f36000",
369
+ "#6f7dff",
370
+ "#00afbf",
371
371
  ],
372
372
  "diverging": {
373
373
  "brownTeal": [
@@ -461,6 +461,8 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
461
461
  "chromeGap": 4,
462
462
  "chromeToChart": 8,
463
463
  "padding": 20,
464
+ "xAxisHeight": 26,
465
+ "xAxisLabelPadding": 14,
464
466
  },
465
467
  },
466
468
  "tooltipDescriptors": [
@@ -790,7 +792,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
790
792
  "value": "40",
791
793
  },
792
794
  {
793
- "color": "#eb7289",
795
+ "color": "#ee4a73",
794
796
  "dataY": 143.375,
795
797
  "labelLines": [
796
798
  "UK",
@@ -800,7 +802,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
800
802
  "dataX": 657.899,
801
803
  "fill": "transparent",
802
804
  "radius": 4,
803
- "stroke": "#eb7289",
805
+ "stroke": "#ee4a73",
804
806
  "strokeWidth": 2,
805
807
  "x": 661.899,
806
808
  "y": 143.375,
@@ -810,7 +812,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
810
812
  "value": "35",
811
813
  },
812
814
  {
813
- "color": "#3bb974",
815
+ "color": "#00b054",
814
816
  "dataY": 242.62999999999997,
815
817
  "labelLines": [
816
818
  "FR",
@@ -820,7 +822,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
820
822
  "dataX": 657.899,
821
823
  "fill": "transparent",
822
824
  "radius": 4,
823
- "stroke": "#3bb974",
825
+ "stroke": "#00b054",
824
826
  "strokeWidth": 2,
825
827
  "x": 661.899,
826
828
  "y": 242.62999999999997,
@@ -830,7 +832,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
830
832
  "value": "22",
831
833
  },
832
834
  {
833
- "color": "#ad87ed",
835
+ "color": "#a46bf5",
834
836
  "dataY": 196.82,
835
837
  "labelLines": [
836
838
  "DE",
@@ -840,7 +842,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
840
842
  "dataX": 657.899,
841
843
  "fill": "transparent",
842
844
  "radius": 4,
843
- "stroke": "#ad87ed",
845
+ "stroke": "#a46bf5",
844
846
  "strokeWidth": 2,
845
847
  "x": 661.899,
846
848
  "y": 196.82,
@@ -885,19 +887,19 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
885
887
  },
886
888
  {
887
889
  "active": true,
888
- "color": "#eb7289",
890
+ "color": "#ee4a73",
889
891
  "label": "UK",
890
892
  "shape": "line",
891
893
  },
892
894
  {
893
895
  "active": true,
894
- "color": "#3bb974",
896
+ "color": "#00b054",
895
897
  "label": "FR",
896
898
  "shape": "line",
897
899
  },
898
900
  {
899
901
  "active": true,
900
- "color": "#ad87ed",
902
+ "color": "#a46bf5",
901
903
  "label": "DE",
902
904
  "shape": "line",
903
905
  },
@@ -1035,7 +1037,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1035
1037
  "tooltip": {
1036
1038
  "fields": [
1037
1039
  {
1038
- "color": "#eb7289",
1040
+ "color": "#ee4a73",
1039
1041
  "label": "country",
1040
1042
  "value": "UK",
1041
1043
  },
@@ -1063,7 +1065,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1063
1065
  "tooltip": {
1064
1066
  "fields": [
1065
1067
  {
1066
- "color": "#eb7289",
1068
+ "color": "#ee4a73",
1067
1069
  "label": "country",
1068
1070
  "value": "UK",
1069
1071
  },
@@ -1096,7 +1098,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1096
1098
  },
1097
1099
  ],
1098
1100
  "seriesKey": "UK",
1099
- "stroke": "#eb7289",
1101
+ "stroke": "#ee4a73",
1100
1102
  "strokeDasharray": undefined,
1101
1103
  "strokeWidth": 1.5,
1102
1104
  "type": "line",
@@ -1127,7 +1129,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1127
1129
  "tooltip": {
1128
1130
  "fields": [
1129
1131
  {
1130
- "color": "#3bb974",
1132
+ "color": "#00b054",
1131
1133
  "label": "country",
1132
1134
  "value": "FR",
1133
1135
  },
@@ -1155,7 +1157,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1155
1157
  "tooltip": {
1156
1158
  "fields": [
1157
1159
  {
1158
- "color": "#3bb974",
1160
+ "color": "#00b054",
1159
1161
  "label": "country",
1160
1162
  "value": "FR",
1161
1163
  },
@@ -1188,7 +1190,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1188
1190
  },
1189
1191
  ],
1190
1192
  "seriesKey": "FR",
1191
- "stroke": "#3bb974",
1193
+ "stroke": "#00b054",
1192
1194
  "strokeDasharray": undefined,
1193
1195
  "strokeWidth": 1.5,
1194
1196
  "type": "line",
@@ -1219,7 +1221,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1219
1221
  "tooltip": {
1220
1222
  "fields": [
1221
1223
  {
1222
- "color": "#ad87ed",
1224
+ "color": "#a46bf5",
1223
1225
  "label": "country",
1224
1226
  "value": "DE",
1225
1227
  },
@@ -1247,7 +1249,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1247
1249
  "tooltip": {
1248
1250
  "fields": [
1249
1251
  {
1250
- "color": "#ad87ed",
1252
+ "color": "#a46bf5",
1251
1253
  "label": "country",
1252
1254
  "value": "DE",
1253
1255
  },
@@ -1280,7 +1282,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1280
1282
  },
1281
1283
  ],
1282
1284
  "seriesKey": "DE",
1283
- "stroke": "#ad87ed",
1285
+ "stroke": "#a46bf5",
1284
1286
  "strokeDasharray": undefined,
1285
1287
  "strokeWidth": 1.5,
1286
1288
  "type": "line",
@@ -1334,14 +1336,14 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1334
1336
  "background": "transparent",
1335
1337
  "categorical": [
1336
1338
  "#06b6d4",
1337
- "#eb7289",
1338
- "#3bb974",
1339
- "#ad87ed",
1340
- "#e69c3a",
1341
- "#4ba3f7",
1342
- "#eb8656",
1343
- "#8494fa",
1344
- "#00b9c3",
1339
+ "#ee4a73",
1340
+ "#00b054",
1341
+ "#a46bf5",
1342
+ "#e07d00",
1343
+ "#0091ff",
1344
+ "#f36000",
1345
+ "#6f7dff",
1346
+ "#00afbf",
1345
1347
  ],
1346
1348
  "diverging": {
1347
1349
  "brownTeal": [
@@ -1435,6 +1437,8 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1435
1437
  "chromeGap": 4,
1436
1438
  "chromeToChart": 8,
1437
1439
  "padding": 20,
1440
+ "xAxisHeight": 26,
1441
+ "xAxisLabelPadding": 14,
1438
1442
  },
1439
1443
  },
1440
1444
  "tooltipDescriptors": [],
@@ -1923,14 +1927,14 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1923
1927
  "background": "transparent",
1924
1928
  "categorical": [
1925
1929
  "#06b6d4",
1926
- "#eb7289",
1927
- "#3bb974",
1928
- "#ad87ed",
1929
- "#e69c3a",
1930
- "#4ba3f7",
1931
- "#eb8656",
1932
- "#8494fa",
1933
- "#00b9c3",
1930
+ "#ee4a73",
1931
+ "#00b054",
1932
+ "#a46bf5",
1933
+ "#e07d00",
1934
+ "#0091ff",
1935
+ "#f36000",
1936
+ "#6f7dff",
1937
+ "#00afbf",
1934
1938
  ],
1935
1939
  "diverging": {
1936
1940
  "brownTeal": [
@@ -2024,6 +2028,8 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
2024
2028
  "chromeGap": 4,
2025
2029
  "chromeToChart": 8,
2026
2030
  "padding": 20,
2031
+ "xAxisHeight": 26,
2032
+ "xAxisLabelPadding": 14,
2027
2033
  },
2028
2034
  },
2029
2035
  "tooltipDescriptors": [
@@ -126,6 +126,84 @@ describe('computeDimensions', () => {
126
126
  expect(withLegend.chartArea.height).toBeLessThan(withoutLegend.chartArea.height);
127
127
  });
128
128
 
129
+ it('reserves enough left margin for y-axis title to clear tick labels', () => {
130
+ const specWithYTitle: NormalizedChartSpec = {
131
+ ...baseSpec,
132
+ encoding: {
133
+ x: { field: 'date', type: 'temporal' },
134
+ y: { field: 'value', type: 'quantitative', axis: { title: 'Share of districts' } },
135
+ },
136
+ };
137
+ const specWithoutYTitle: NormalizedChartSpec = {
138
+ ...baseSpec,
139
+ encoding: {
140
+ x: { field: 'date', type: 'temporal' },
141
+ y: { field: 'value', type: 'quantitative' },
142
+ },
143
+ };
144
+
145
+ const dimsWithTitle = computeDimensions(
146
+ specWithYTitle,
147
+ { width: 600, height: 400 },
148
+ emptyLegend,
149
+ lightTheme,
150
+ );
151
+ const dimsWithoutTitle = computeDimensions(
152
+ specWithoutYTitle,
153
+ { width: 600, height: 400 },
154
+ emptyLegend,
155
+ lightTheme,
156
+ );
157
+
158
+ // A chart with a y-axis title needs more left margin than one without
159
+ expect(dimsWithTitle.margins.left).toBeGreaterThan(dimsWithoutTitle.margins.left);
160
+ // The difference should be at least enough for the rotated title glyph
161
+ // plus breathing room (halfGlyph ~7 + trailing pad 4 = 11px minimum)
162
+ expect(dimsWithTitle.margins.left - dimsWithoutTitle.margins.left).toBeGreaterThanOrEqual(11);
163
+ });
164
+
165
+ it('y-axis title margin scales with tick label width', () => {
166
+ const smallValues: NormalizedChartSpec = {
167
+ ...baseSpec,
168
+ data: [
169
+ { date: '2020-01-01', value: 5 },
170
+ { date: '2021-01-01', value: 9 },
171
+ ],
172
+ encoding: {
173
+ x: { field: 'date', type: 'temporal' },
174
+ y: { field: 'value', type: 'quantitative', axis: { title: 'Count' } },
175
+ },
176
+ };
177
+ const largeValues: NormalizedChartSpec = {
178
+ ...baseSpec,
179
+ data: [
180
+ { date: '2020-01-01', value: 1_500_000 },
181
+ { date: '2021-01-01', value: 2_000_000 },
182
+ ],
183
+ encoding: {
184
+ x: { field: 'date', type: 'temporal' },
185
+ y: { field: 'value', type: 'quantitative', axis: { title: 'Revenue ($)' } },
186
+ },
187
+ };
188
+
189
+ const dimsSmall = computeDimensions(
190
+ smallValues,
191
+ { width: 600, height: 400 },
192
+ emptyLegend,
193
+ lightTheme,
194
+ );
195
+ const dimsLarge = computeDimensions(
196
+ largeValues,
197
+ { width: 600, height: 400 },
198
+ emptyLegend,
199
+ lightTheme,
200
+ );
201
+
202
+ // Larger numeric values produce wider tick labels (e.g. "1.5M" vs "0.0"),
203
+ // so the y-axis title margin should grow to keep clearance
204
+ expect(dimsLarge.margins.left).toBeGreaterThan(dimsSmall.margins.left);
205
+ });
206
+
129
207
  it('applies dark mode theme adaptation', () => {
130
208
  const lightDims = computeDimensions(
131
209
  baseSpec,
@@ -382,6 +460,36 @@ describe('computeDimensions', () => {
382
460
  });
383
461
  });
384
462
 
463
+ it('avoids doubling axisMargin and legendGap when top legend is present', () => {
464
+ const dimsWithTopLegend = computeDimensions(
465
+ baseSpec,
466
+ { width: 600, height: 400 },
467
+ topLegend,
468
+ lightTheme,
469
+ );
470
+ const dimsNoLegend = computeDimensions(
471
+ baseSpec,
472
+ { width: 600, height: 400 },
473
+ emptyLegend,
474
+ lightTheme,
475
+ );
476
+
477
+ // Without a top legend, the full topAxisGap (axisMargin + inlineTickOverhang)
478
+ // separates chrome from chart area. With a top legend, legendGap already
479
+ // provides separation, so only inlineTickOverhang is added (not the full
480
+ // topAxisGap). This means the chart area gains back ~axisMargin (6px)
481
+ // that would otherwise be redundant spacing.
482
+ //
483
+ // The top margin with legend includes: legendHeight(28) + legendGap(8)
484
+ // + inlineTickOverhang(17) instead of the no-legend topAxisGap(23).
485
+ // Net: margin delta = 28 + 8 + 17 - 23 = 30px.
486
+ // If axisMargin were doubling up: 28 + 8 + 23 - 23 = 36px.
487
+ const topMarginDelta = dimsWithTopLegend.margins.top - dimsNoLegend.margins.top;
488
+ expect(topMarginDelta).toBeLessThan(topLegend.bounds.height + legendGap(600) + 1);
489
+ // With a legend present, the chart area should still be shorter
490
+ expect(dimsWithTopLegend.chartArea.height).toBeLessThan(dimsNoLegend.chartArea.height);
491
+ });
492
+
385
493
  it('tightens legend gap on narrow viewports', () => {
386
494
  const wideDims = computeDimensions(
387
495
  baseSpec,
@@ -217,3 +217,75 @@ describe('computeBarLabels with Unicode minus (U+2212) in aria values', () => {
217
217
  expect(labels[1].text).toBe('\u22125%'); // −5%
218
218
  });
219
219
  });
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Dark-mode inside-label color
223
+ // ---------------------------------------------------------------------------
224
+
225
+ describe('computeBarLabels inside-label color by mode', () => {
226
+ // Wide bar (>= 40px) so the label is placed inside and uses pickLabelColor.
227
+ function makeFilledMark(fill: string): RectMark {
228
+ return {
229
+ type: 'rect',
230
+ x: 0,
231
+ y: 0,
232
+ width: 200,
233
+ height: 25,
234
+ fill,
235
+ data: { category: 'A', value: 100 },
236
+ aria: { label: 'A: 100' },
237
+ };
238
+ }
239
+
240
+ it('mid-tone fill gets white inside-label in light mode, dark in dark mode', () => {
241
+ const slate = [makeFilledMark('#94a3b8')];
242
+ const light = computeBarLabels(
243
+ slate,
244
+ chartArea,
245
+ 'all',
246
+ undefined,
247
+ undefined,
248
+ undefined,
249
+ undefined,
250
+ false,
251
+ );
252
+ const dark = computeBarLabels(
253
+ slate,
254
+ chartArea,
255
+ 'all',
256
+ undefined,
257
+ undefined,
258
+ undefined,
259
+ undefined,
260
+ true,
261
+ );
262
+ expect(light[0].style.fill).toBe('#ffffff');
263
+ expect(dark[0].style.fill).toBe('#111111');
264
+ });
265
+
266
+ it('saturated fill keeps white inside-label in both modes', () => {
267
+ const red = [makeFilledMark('#c0392b')];
268
+ const light = computeBarLabels(
269
+ red,
270
+ chartArea,
271
+ 'all',
272
+ undefined,
273
+ undefined,
274
+ undefined,
275
+ undefined,
276
+ false,
277
+ );
278
+ const dark = computeBarLabels(
279
+ red,
280
+ chartArea,
281
+ 'all',
282
+ undefined,
283
+ undefined,
284
+ undefined,
285
+ undefined,
286
+ true,
287
+ );
288
+ expect(light[0].style.fill).toBe('#ffffff');
289
+ expect(dark[0].style.fill).toBe('#ffffff');
290
+ });
291
+ });
@@ -13,7 +13,7 @@ import { computeBarLabels } from './labels';
13
13
  // Bar chart renderer
14
14
  // ---------------------------------------------------------------------------
15
15
 
16
- export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
16
+ export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, theme) => {
17
17
  const marks = computeBarMarks(spec, scales, chartArea, strategy);
18
18
 
19
19
  // Compute and attach value labels (respects spec.labels.density)
@@ -27,6 +27,9 @@ export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _t
27
27
  spec.labels.prefix,
28
28
  valueField,
29
29
  spec.labels.color,
30
+ theme.isDark,
31
+ spec.labels.fontSize,
32
+ spec.labels.suffix,
30
33
  );
31
34
  for (let i = 0; i < marks.length && i < labels.length; i++) {
32
35
  marks[i].label = labels[i];
@@ -95,7 +95,11 @@ export function computeBarLabels(
95
95
  labelPrefix?: string,
96
96
  valueField?: string,
97
97
  labelColor?: string,
98
+ darkMode = false,
99
+ fontSize?: number,
100
+ labelSuffix?: string,
98
101
  ): ResolvedLabel[] {
102
+ const FONT_SIZE = fontSize ?? LABEL_FONT_SIZE;
99
103
  const targetMarks = filterByDensity(marks, density);
100
104
 
101
105
  const candidates: LabelCandidate[] = [];
@@ -131,9 +135,10 @@ export function computeBarLabels(
131
135
  }
132
136
  }
133
137
  if (labelPrefix) valuePart = labelPrefix + valuePart;
138
+ if (labelSuffix) valuePart = valuePart + labelSuffix;
134
139
 
135
- const textWidth = estimateTextWidth(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
136
- const textHeight = LABEL_FONT_SIZE * 1.2;
140
+ const textWidth = estimateTextWidth(valuePart, FONT_SIZE, LABEL_FONT_WEIGHT);
141
+ const textHeight = FONT_SIZE * 1.2;
137
142
 
138
143
  // Detect stacked bars: cornerRadius 0 indicates stacked segment
139
144
  const isStacked = mark.stackGroup !== undefined;
@@ -150,18 +155,18 @@ export function computeBarLabels(
150
155
  if (isStacked && isInside) {
151
156
  // Stacked: centered within segment
152
157
  anchorX = mark.x + mark.width / 2;
153
- fill = pickLabelColor(bgColor);
158
+ fill = pickLabelColor(bgColor, darkMode);
154
159
  textAnchor = 'middle';
155
160
  } else if (isInside) {
156
161
  if (isNegative) {
157
162
  // Negative bar: left-aligned within bar (bar extends leftward)
158
163
  anchorX = mark.x + LABEL_PADDING;
159
- fill = pickLabelColor(bgColor);
164
+ fill = pickLabelColor(bgColor, darkMode);
160
165
  textAnchor = 'start';
161
166
  } else {
162
167
  // Positive bar: right-aligned within bar
163
168
  anchorX = mark.x + mark.width - LABEL_PADDING;
164
- fill = pickLabelColor(bgColor);
169
+ fill = pickLabelColor(bgColor, darkMode);
165
170
  textAnchor = 'end';
166
171
  }
167
172
  } else {
@@ -195,7 +200,7 @@ export function computeBarLabels(
195
200
  priority: 'data',
196
201
  style: {
197
202
  fontFamily: 'system-ui, -apple-system, sans-serif',
198
- fontSize: LABEL_FONT_SIZE,
203
+ fontSize: FONT_SIZE,
199
204
  fontWeight: LABEL_FONT_WEIGHT,
200
205
  fill,
201
206
  lineHeight: 1.2,
@@ -27,6 +27,8 @@ export const columnRenderer: ChartRenderer = (spec, scales, chartArea, strategy,
27
27
  spec.labels.prefix,
28
28
  valueField,
29
29
  spec.labels.color,
30
+ spec.labels.fontSize,
31
+ spec.labels.suffix,
30
32
  );
31
33
  for (let i = 0; i < marks.length && i < labels.length; i++) {
32
34
  marks[i].label = labels[i];
@@ -51,7 +51,10 @@ export function computeColumnLabels(
51
51
  labelPrefix?: string,
52
52
  valueField?: string,
53
53
  labelColor?: string,
54
+ fontSize?: number,
55
+ labelSuffix?: string,
54
56
  ): ResolvedLabel[] {
57
+ const FONT_SIZE = fontSize ?? LABEL_FONT_SIZE;
55
58
  const targetMarks = filterByDensity(marks, density);
56
59
 
57
60
  const formatter = buildD3Formatter(labelFormat);
@@ -83,13 +86,14 @@ export function computeColumnLabels(
83
86
  valuePart = rawValue;
84
87
  }
85
88
  }
86
- if (labelPrefix) valuePart = labelPrefix + valuePart;
87
-
88
89
  const numericValue = parseFloat(valuePart);
89
90
  const isNegative = Number.isFinite(numericValue) && numericValue < 0;
90
91
 
91
- const textWidth = estimateTextWidth(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
92
- const textHeight = LABEL_FONT_SIZE * 1.2;
92
+ if (labelPrefix) valuePart = labelPrefix + valuePart;
93
+ if (labelSuffix) valuePart = valuePart + labelSuffix;
94
+
95
+ const textWidth = estimateTextWidth(valuePart, FONT_SIZE, LABEL_FONT_WEIGHT);
96
+ const textHeight = FONT_SIZE * 1.2;
93
97
 
94
98
  // anchorY is the TOP of the label bounding box so the collision system's
95
99
  // AABB check (rect = { y: anchorY, height: textHeight }) is geometrically
@@ -110,7 +114,7 @@ export function computeColumnLabels(
110
114
  priority: 'data',
111
115
  style: {
112
116
  fontFamily: 'system-ui, -apple-system, sans-serif',
113
- fontSize: LABEL_FONT_SIZE,
117
+ fontSize: FONT_SIZE,
114
118
  fontWeight: LABEL_FONT_WEIGHT,
115
119
  fill: labelColor ?? getRepresentativeColor(mark.fill),
116
120
  lineHeight: 1.2,
@@ -210,8 +210,10 @@ function normalizeLabels(labels?: LabelSpec): NormalizedChartSpec['labels'] {
210
210
  density: labels.density ?? 'auto',
211
211
  format: labels.format ?? '',
212
212
  prefix: labels.prefix ?? '',
213
+ suffix: labels.suffix,
213
214
  offsets: labels.offsets,
214
215
  color: labels.color,
216
+ fontSize: labels.fontSize,
215
217
  };
216
218
  }
217
219
 
@@ -110,7 +110,7 @@ export interface NormalizedChartSpec {
110
110
  annotations: Annotation[];
111
111
  /** Normalized label configuration with defaults applied. density, format, and prefix are always set; offsets and color stay optional. */
112
112
  labels: Required<Pick<LabelConfig, 'density' | 'format' | 'prefix'>> &
113
- Pick<LabelConfig, 'offsets' | 'color'>;
113
+ Pick<LabelConfig, 'offsets' | 'color' | 'fontSize' | 'suffix'>;
114
114
  /** Legend configuration (position override). */
115
115
  legend?: LegendConfig;
116
116
  /** Right-side endpoint labels column config (multi-series line/area only). */
@@ -126,26 +126,27 @@ const TEMPORAL_SCALE_TYPES = new Set(['time', 'utc']);
126
126
  function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
127
127
  const axisConfig = resolvedScale.channel.axis || undefined;
128
128
  const formatStr = axisConfig?.format;
129
+ const suffix = axisConfig?.labelSuffix ?? '';
129
130
 
130
131
  if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
131
132
  const temporalFmt = buildTemporalFormatter(formatStr);
132
- if (temporalFmt) return temporalFmt(value as Date);
133
+ if (temporalFmt) return temporalFmt(value as Date) + suffix;
133
134
  const useUtc = resolvedScale.type === 'utc';
134
- return formatDate(value as Date, undefined, undefined, useUtc);
135
+ return formatDate(value as Date, undefined, undefined, useUtc) + suffix;
135
136
  }
136
137
 
137
138
  if (NUMERIC_SCALE_TYPES.has(resolvedScale.type)) {
138
139
  const num = value as number;
139
140
  if (formatStr) {
140
141
  const fmt = buildD3Formatter(formatStr);
141
- if (fmt) return fmt(num);
142
+ if (fmt) return fmt(num) + suffix;
142
143
  }
143
144
  // Abbreviate large numbers for axis labels
144
- if (Math.abs(num) >= 1000) return abbreviateNumber(num);
145
- return formatNumber(num);
145
+ if (Math.abs(num) >= 1000) return abbreviateNumber(num) + suffix;
146
+ return formatNumber(num) + suffix;
146
147
  }
147
148
 
148
- return String(value);
149
+ return String(value) + suffix;
149
150
  }
150
151
 
151
152
  /**