@cfasim-ui/docs 0.4.15 → 0.5.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.
@@ -21,6 +21,7 @@ import {
21
21
  TICK_LABEL_FONT_SIZE,
22
22
  TICK_LABEL_OPACITY,
23
23
  LEGEND_FONT_SIZE,
24
+ pickContrastColor,
24
25
  resolveLabelStyle,
25
26
  parseDate,
26
27
  isAllDates,
@@ -34,6 +35,8 @@ import {
34
35
  type ChartTooltipValue,
35
36
  type BlendMode,
36
37
  type LineMarkStyle,
38
+ type LabelStyle,
39
+ type ChartPadding,
37
40
  } from "../_shared/index.js";
38
41
 
39
42
  export type BarChartData = ChartData;
@@ -96,6 +99,66 @@ export interface BarSummaryLine extends LineMarkStyle {
96
99
  valueMax?: number;
97
100
  }
98
101
 
102
+ /** Context passed to a `barLabels.format` function. */
103
+ export interface BarLabelContext {
104
+ value: number;
105
+ categoryIndex: number;
106
+ seriesIndex: number;
107
+ /** The resolved category label string. */
108
+ category: string;
109
+ }
110
+
111
+ /**
112
+ * Styling and behavior for value labels drawn directly on the bars (an
113
+ * alternative to reading values off the value axis — see `valueAxis`).
114
+ * Most useful with `layout="stacked"`, where each segment carries its own
115
+ * value. Extends `LabelStyle` (`fontSize` / `fontWeight`).
116
+ */
117
+ export interface BarLabelStyle extends LabelStyle {
118
+ /**
119
+ * How to turn a bar's numeric value into label text. A `NumberFormat`
120
+ * (preset/printf/function), or a function receiving `(value, context)`
121
+ * for fully custom strings (e.g. `">99%"`). Return `""` to omit a
122
+ * label. When unset, falls back to `valueTickFormat`, then a default.
123
+ */
124
+ format?: NumberFormat | ((value: number, ctx: BarLabelContext) => string);
125
+ /**
126
+ * Label text color. `"auto"` (default) picks white or near-black per
127
+ * segment by the fill's luminance for readable contrast; outside-placed
128
+ * labels use the chart's text color. Any other CSS color is used as-is
129
+ * for every label.
130
+ */
131
+ color?: string;
132
+ /**
133
+ * Where to draw each label. `"auto"` (default) centers it inside the
134
+ * segment when the text fits, otherwise just past the segment's
135
+ * high-value edge. `"inside"` / `"outside"` force one placement.
136
+ */
137
+ placement?: "auto" | "inside" | "outside";
138
+ /**
139
+ * Alignment of an *inside* label along the value axis. `"center"`
140
+ * (default) centers it in its segment; `"start"` pins it to the
141
+ * segment's low-value edge (left edge for horizontal bars); `"end"`
142
+ * pins it to the high-value edge. Outside/overflow labels ignore this
143
+ * and always sit just past the high-value edge.
144
+ */
145
+ align?: "start" | "center" | "end";
146
+ /**
147
+ * How to handle labels that would collide along the value axis (common
148
+ * with many small adjacent segments). `"shift"` (default) nudges them
149
+ * apart toward higher values; `"hide"` drops the colliding label. Either
150
+ * way, a label pushed past the chart edge is hidden.
151
+ */
152
+ overlap?: "shift" | "hide";
153
+ /**
154
+ * Segments smaller than this many pixels (along the value axis) never
155
+ * get an *inside* label — they fall back to outside placement (or hide,
156
+ * per `overlap`). Use to suppress labels crammed into thin slivers.
157
+ * Default 0 (size alone never forces a label out; text fit still does).
158
+ */
159
+ minSize?: number;
160
+ }
161
+
99
162
  interface BarChartProps extends ChartCommonProps {
100
163
  /** Single-series values. Equivalent to `y`. */
101
164
  data?: BarChartData;
@@ -151,6 +214,15 @@ interface BarChartProps extends ChartCommonProps {
151
214
  valueTickFormat?: NumberFormat;
152
215
  /** Formatter for category-axis labels. Receives the resolved category string. */
153
216
  categoryFormat?: (label: string, index: number) => string;
217
+ /**
218
+ * Alignment of the category labels in horizontal orientation. `"end"`
219
+ * (default) right-aligns them against the plot's left edge; `"start"`
220
+ * left-aligns them at the chart's left margin; `"center"` centers them
221
+ * in the margin. The `categoryHeader`, if set, follows the same
222
+ * alignment. Ignored in vertical orientation (labels stay centered
223
+ * under each column).
224
+ */
225
+ categoryAlign?: "start" | "center" | "end";
154
226
  /**
155
227
  * Date-tick formatter for date-mode category labels (auto-detected
156
228
  * when every entry of `categories` parses as a date). Ignored unless
@@ -172,6 +244,33 @@ interface BarChartProps extends ChartCommonProps {
172
244
  */
173
245
  groupGap?: number;
174
246
  valueGrid?: boolean;
247
+ /**
248
+ * Draw the value axis (its line and tick labels). Default true. Set
249
+ * false for a clean "value-on-bar" look where each segment is labeled
250
+ * via `barLabels` and there's no axis to read against.
251
+ */
252
+ valueAxis?: boolean;
253
+ /**
254
+ * Draw the numeric value directly on each bar instead of (or alongside)
255
+ * reading it off the value axis. `true` uses defaults; pass a
256
+ * `BarLabelStyle` to customize formatting, color, placement, and
257
+ * overlap handling. Most useful with `layout="stacked"`.
258
+ */
259
+ barLabels?: boolean | BarLabelStyle;
260
+ /**
261
+ * Header drawn above the category labels (e.g. "Scenario"). Aligned to
262
+ * match the category labels — right-edge-aligned in horizontal
263
+ * orientation, centered under each column in vertical. Reserves a row
264
+ * of space above the plot.
265
+ */
266
+ categoryHeader?: string;
267
+ /**
268
+ * Header drawn above the bars / value area (e.g. "Proportion of
269
+ * simulations"), left-aligned to where the bars start. Pairs with
270
+ * `categoryHeader` to label both columns of a value-on-bar chart.
271
+ * Reserves a row of space above the plot.
272
+ */
273
+ valueHeader?: string;
175
274
  }
176
275
 
177
276
  const props = withDefaults(defineProps<BarChartProps>(), {
@@ -184,6 +283,7 @@ const props = withDefaults(defineProps<BarChartProps>(), {
184
283
  tooltipClamp: "chart",
185
284
  valueGrid: true,
186
285
  valueScaleType: "linear",
286
+ valueAxis: true,
187
287
  });
188
288
 
189
289
  const emit = defineEmits<{
@@ -861,6 +961,27 @@ function pointerToIndex(clientX: number, clientY: number): number | null {
861
961
  return Math.max(0, Math.min(n - 1, Math.floor(local / slotSize.value)));
862
962
  }
863
963
 
964
+ /** Height reserved above the plot for the category/value column headers. */
965
+ const HEADER_ROW_HEIGHT = 22;
966
+
967
+ const hasHeaders = computed(
968
+ () => !!(props.categoryHeader || props.valueHeader),
969
+ );
970
+
971
+ /**
972
+ * `chartPadding` with extra top room reserved for the column headers when
973
+ * either is set, so they don't overlap the legend or the plot.
974
+ */
975
+ const effectiveChartPadding = computed<ChartPadding | undefined>(() => {
976
+ const extraTop = hasHeaders.value ? HEADER_ROW_HEIGHT : 0;
977
+ const base = props.chartPadding;
978
+ if (!extraTop) return base;
979
+ if (base == null) return { top: extraTop };
980
+ if (typeof base === "number")
981
+ return { top: base + extraTop, right: base, bottom: base, left: base };
982
+ return { ...base, top: (base.top ?? 0) + extraTop };
983
+ });
984
+
864
985
  const {
865
986
  containerRef,
866
987
  svgRef,
@@ -879,6 +1000,8 @@ const {
879
1000
  menuItems,
880
1001
  downloadLinkText,
881
1002
  csvHref,
1003
+ downloadButtonText,
1004
+ triggerCsvDownload,
882
1005
  menuFilename,
883
1006
  isFullscreen,
884
1007
  } = useChartFoundation({
@@ -894,7 +1017,8 @@ const {
894
1017
  tooltipClamp: () => props.tooltipClamp,
895
1018
  filename: () => props.filename,
896
1019
  downloadLink: () => props.downloadLink,
897
- chartPadding: () => props.chartPadding,
1020
+ downloadButton: () => props.downloadButton,
1021
+ chartPadding: () => effectiveChartPadding.value,
898
1022
  inlineLegendLabels: () => inlineLegendLabels.value,
899
1023
  hasTooltipSlot: () => hasTooltipSlot.value,
900
1024
  getCsv: toCsv,
@@ -918,6 +1042,21 @@ const legendResolved = computed(() =>
918
1042
  resolveLabelStyle(props.legendStyle, { fontSize: LEGEND_FONT_SIZE }),
919
1043
  );
920
1044
 
1045
+ /** Small inset from the chart's left edge for start-aligned category labels. */
1046
+ const CATEGORY_LABEL_INSET = 2;
1047
+
1048
+ /**
1049
+ * Left x-anchor for the title and inline legend. When the category labels
1050
+ * are start-aligned (horizontal), the title/legend align to that same left
1051
+ * edge so the whole header column lines up; otherwise they sit at the
1052
+ * plot's left edge.
1053
+ */
1054
+ const headerLeftX = computed(() =>
1055
+ props.categoryAlign === "start" && !isVertical.value
1056
+ ? CATEGORY_LABEL_INSET
1057
+ : padding.value.left,
1058
+ );
1059
+
921
1060
  /** Resolved title style with defaults applied. */
922
1061
  const titleResolved = computed(() => {
923
1062
  const s = props.titleStyle;
@@ -925,7 +1064,7 @@ const titleResolved = computed(() => {
925
1064
  const b = bounds.value;
926
1065
  const x =
927
1066
  align === "left"
928
- ? b.left
1067
+ ? headerLeftX.value
929
1068
  : align === "right"
930
1069
  ? b.right
931
1070
  : b.left + b.width / 2;
@@ -1004,7 +1143,7 @@ const hoverBand = computed(() => {
1004
1143
  */
1005
1144
  const positionedLegendItems = computed(() => {
1006
1145
  const positions = inlineLegendLayout.value.positions;
1007
- const pad = padding.value.left;
1146
+ const pad = headerLeftX.value;
1008
1147
  const baseY = legendY.value;
1009
1148
  return inlineLegendItems.value.map((item, i) => {
1010
1149
  const pos = positions[i];
@@ -1015,6 +1154,306 @@ const positionedLegendItems = computed(() => {
1015
1154
  };
1016
1155
  });
1017
1156
  });
1157
+
1158
+ const BAR_LABEL_FONT_SIZE = 11;
1159
+ /** Estimated glyph width as a fraction of font size (matches axis logic). */
1160
+ const BAR_LABEL_CHAR_WIDTH = 0.6;
1161
+ /** Inset of an inside label / offset of an outside label from the edge. */
1162
+ const BAR_LABEL_PAD = 4;
1163
+ /** Minimum gap between two labels when decluttering. */
1164
+ const BAR_LABEL_GAP = 4;
1165
+
1166
+ interface ResolvedBarLabels {
1167
+ format?: NumberFormat | ((value: number, ctx: BarLabelContext) => string);
1168
+ color: string;
1169
+ placement: "auto" | "inside" | "outside";
1170
+ align: "start" | "center" | "end";
1171
+ overlap: "shift" | "hide";
1172
+ minSize: number;
1173
+ fontSize: number;
1174
+ fontWeight: number | string | undefined;
1175
+ }
1176
+
1177
+ const barLabelOptions = computed<ResolvedBarLabels | null>(() => {
1178
+ const b = props.barLabels;
1179
+ if (!b) return null;
1180
+ const o = b === true ? {} : b;
1181
+ return {
1182
+ format: o.format,
1183
+ color: o.color ?? "auto",
1184
+ placement: o.placement ?? "auto",
1185
+ align: o.align ?? "center",
1186
+ overlap: o.overlap ?? "shift",
1187
+ minSize: o.minSize ?? 0,
1188
+ fontSize: o.fontSize ?? BAR_LABEL_FONT_SIZE,
1189
+ fontWeight: o.fontWeight,
1190
+ };
1191
+ });
1192
+
1193
+ function formatBarLabel(value: number, ctx: BarLabelContext): string {
1194
+ const f = barLabelOptions.value?.format;
1195
+ if (typeof f === "function") return f(value, ctx);
1196
+ if (f !== undefined) return formatNumber(value, f);
1197
+ if (props.valueTickFormat !== undefined)
1198
+ return formatNumber(value, props.valueTickFormat);
1199
+ return formatTick(value);
1200
+ }
1201
+
1202
+ /**
1203
+ * Fill color of the bar covering pixel (px, py) within category `cat`, or
1204
+ * null when no bar sits there (the chart background). Used to choose a
1205
+ * readable color for a label that overflows onto a neighboring segment.
1206
+ */
1207
+ function barColorAt(cat: number, px: number, py: number): string | null {
1208
+ // Iterate in reverse so the topmost (last-drawn) bar wins under overlap.
1209
+ for (let i = bars.value.length - 1; i >= 0; i--) {
1210
+ const b = bars.value[i];
1211
+ if (b.categoryIndex !== cat) continue;
1212
+ if (px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h)
1213
+ return b.color;
1214
+ }
1215
+ return null;
1216
+ }
1217
+
1218
+ interface BarLabelItem {
1219
+ key: string;
1220
+ x: number;
1221
+ y: number;
1222
+ text: string;
1223
+ anchor: "start" | "middle" | "end";
1224
+ fill: string;
1225
+ fontSize: number;
1226
+ fontWeight: number | string | undefined;
1227
+ }
1228
+
1229
+ const barLabelItems = computed<BarLabelItem[]>(() => {
1230
+ const opts = barLabelOptions.value;
1231
+ if (!opts) return [];
1232
+ const vertical = isVertical.value;
1233
+ const fontSize = opts.fontSize;
1234
+
1235
+ // Build a draft label per bar, computing its placement (inside vs
1236
+ // outside) and a 1D extent [lo, hi] along the value axis in a
1237
+ // "value-increasing" coordinate `u` (rightward when horizontal, upward
1238
+ // when vertical). Collision resolution then runs per category in `u`.
1239
+ interface Draft {
1240
+ bar: (typeof bars.value)[number];
1241
+ text: string;
1242
+ textLen: number;
1243
+ inside: boolean;
1244
+ cross: number; // fixed coordinate on the categorical axis
1245
+ lo: number; // low-value edge of the text in u
1246
+ hi: number; // high-value edge of the text in u
1247
+ // Horizontal text-anchor for this label (vertical bars always center).
1248
+ svgAnchor: "start" | "middle" | "end";
1249
+ hidden: boolean;
1250
+ }
1251
+
1252
+ const drafts: Draft[] = [];
1253
+ for (const bar of bars.value) {
1254
+ const text = formatBarLabel(bar.value, {
1255
+ value: bar.value,
1256
+ categoryIndex: bar.categoryIndex,
1257
+ seriesIndex: bar.seriesIndex,
1258
+ category:
1259
+ categoryLabels.value[bar.categoryIndex] ?? String(bar.categoryIndex),
1260
+ });
1261
+ if (!text) continue;
1262
+
1263
+ const segSize = vertical ? bar.h : bar.w;
1264
+ const textLen = text.length * fontSize * BAR_LABEL_CHAR_WIDTH;
1265
+ const fits =
1266
+ segSize >= textLen + 2 * BAR_LABEL_PAD && segSize >= opts.minSize;
1267
+ const inside =
1268
+ opts.placement === "inside"
1269
+ ? true
1270
+ : opts.placement === "outside"
1271
+ ? false
1272
+ : fits;
1273
+
1274
+ // `u` increases with value: rightward (x) for horizontal bars, upward
1275
+ // (-y) for vertical bars. The label occupies a span of `len` in u —
1276
+ // its text width when horizontal, its line height when vertical.
1277
+ let lo: number;
1278
+ let hi: number;
1279
+ let cross: number;
1280
+ let svgAnchor: "start" | "middle" | "end" = "middle";
1281
+ const segHiU = vertical ? -bar.y : bar.x + bar.w; // high-value edge
1282
+ const segLoU = vertical ? -(bar.y + bar.h) : bar.x; // low-value edge
1283
+ const len = vertical ? fontSize : textLen;
1284
+ cross = vertical ? bar.x + bar.w / 2 : bar.y + bar.h / 2;
1285
+ if (!inside) {
1286
+ // Just past the high-value edge, growing outward.
1287
+ lo = segHiU + BAR_LABEL_PAD;
1288
+ hi = lo + len;
1289
+ svgAnchor = "start";
1290
+ } else if (opts.align === "start") {
1291
+ lo = segLoU + BAR_LABEL_PAD;
1292
+ hi = lo + len;
1293
+ svgAnchor = "start";
1294
+ } else if (opts.align === "end") {
1295
+ hi = segHiU - BAR_LABEL_PAD;
1296
+ lo = hi - len;
1297
+ svgAnchor = "end";
1298
+ } else {
1299
+ const c = (segHiU + segLoU) / 2;
1300
+ lo = c - len / 2;
1301
+ hi = c + len / 2;
1302
+ svgAnchor = "middle";
1303
+ }
1304
+ drafts.push({
1305
+ bar,
1306
+ text,
1307
+ textLen,
1308
+ inside,
1309
+ cross,
1310
+ lo,
1311
+ hi,
1312
+ svgAnchor,
1313
+ hidden: false,
1314
+ });
1315
+ }
1316
+
1317
+ // Declutter per category along `u`: sweep low→high, pushing or hiding
1318
+ // labels that would overlap the previous one. Bounds keep a pushed label
1319
+ // from sliding off the chart.
1320
+ const uMax = vertical ? -padding.value.top : width.value - 2;
1321
+ const byCategory = new Map<number, Draft[]>();
1322
+ for (const d of drafts) {
1323
+ const arr = byCategory.get(d.bar.categoryIndex);
1324
+ if (arr) arr.push(d);
1325
+ else byCategory.set(d.bar.categoryIndex, [d]);
1326
+ }
1327
+ for (const group of byCategory.values()) {
1328
+ group.sort((a, b) => a.lo - b.lo);
1329
+ let cursor = -Infinity;
1330
+ for (const d of group) {
1331
+ if (d.lo < cursor + BAR_LABEL_GAP) {
1332
+ if (opts.overlap === "hide") {
1333
+ d.hidden = true;
1334
+ continue;
1335
+ }
1336
+ const shift = cursor + BAR_LABEL_GAP - d.lo;
1337
+ d.lo += shift;
1338
+ d.hi += shift;
1339
+ d.inside = false; // a shifted label no longer sits in its segment
1340
+ d.svgAnchor = "start";
1341
+ }
1342
+ if (d.hi > uMax) {
1343
+ d.hidden = true;
1344
+ continue;
1345
+ }
1346
+ cursor = d.hi;
1347
+ }
1348
+ }
1349
+
1350
+ const auto = opts.color === "auto";
1351
+ const items: BarLabelItem[] = [];
1352
+ for (const d of drafts) {
1353
+ if (d.hidden) continue;
1354
+ let x: number;
1355
+ let y: number;
1356
+ let anchor: "start" | "middle" | "end";
1357
+ if (vertical) {
1358
+ // Vertical bars center the text horizontally; align shifted the
1359
+ // [lo, hi] band along the value (y) axis, so render at its center.
1360
+ x = d.cross;
1361
+ y = -((d.lo + d.hi) / 2);
1362
+ anchor = "middle";
1363
+ } else {
1364
+ anchor = d.svgAnchor;
1365
+ x =
1366
+ anchor === "start" ? d.lo : anchor === "end" ? d.hi : (d.lo + d.hi) / 2;
1367
+ y = d.cross;
1368
+ }
1369
+ // Pick contrast against whatever the label actually sits on. Inside
1370
+ // labels are on their own segment; an outside/shifted label commonly
1371
+ // lands on a neighboring segment of the same stack — use that
1372
+ // segment's fill, falling back to the chart background (currentColor)
1373
+ // only when the label clears all bars.
1374
+ let fill: string;
1375
+ if (!auto) {
1376
+ fill = opts.color;
1377
+ } else if (d.inside) {
1378
+ fill = pickContrastColor(d.bar.color);
1379
+ } else {
1380
+ const cx = vertical ? d.cross : (d.lo + d.hi) / 2;
1381
+ const cy = vertical ? -((d.lo + d.hi) / 2) : d.cross;
1382
+ const under = barColorAt(d.bar.categoryIndex, cx, cy);
1383
+ fill = under ? pickContrastColor(under) : "currentColor";
1384
+ }
1385
+ items.push({
1386
+ key: `${d.bar.categoryIndex}-${d.bar.seriesIndex}`,
1387
+ x,
1388
+ y,
1389
+ text: d.text,
1390
+ anchor,
1391
+ fill,
1392
+ fontSize,
1393
+ fontWeight: opts.fontWeight,
1394
+ });
1395
+ }
1396
+ return items;
1397
+ });
1398
+
1399
+ /**
1400
+ * Anchor + x for the category labels (and matching `categoryHeader`) in
1401
+ * horizontal orientation, driven by `categoryAlign`. Vertical orientation
1402
+ * doesn't use this (labels stay centered under each column).
1403
+ */
1404
+ const categoryLabelLayout = computed<{
1405
+ anchor: "start" | "middle" | "end";
1406
+ x: number;
1407
+ }>(() => {
1408
+ const right = padding.value.left - 6;
1409
+ switch (props.categoryAlign ?? "end") {
1410
+ case "start":
1411
+ return { anchor: "start", x: CATEGORY_LABEL_INSET };
1412
+ case "center":
1413
+ return { anchor: "middle", x: (CATEGORY_LABEL_INSET + right) / 2 };
1414
+ default:
1415
+ return { anchor: "end", x: right };
1416
+ }
1417
+ });
1418
+
1419
+ interface ColumnHeader {
1420
+ key: string;
1421
+ text: string;
1422
+ x: number;
1423
+ y: number;
1424
+ anchor: "start" | "middle" | "end";
1425
+ }
1426
+
1427
+ /**
1428
+ * Positioned column headers (`categoryHeader` / `valueHeader`), drawn in
1429
+ * the reserved row just above the plot. The category header is aligned to
1430
+ * match the category labels; the value header starts where the bars do.
1431
+ */
1432
+ const columnHeaders = computed<ColumnHeader[]>(() => {
1433
+ const out: ColumnHeader[] = [];
1434
+ const y = padding.value.top - 7;
1435
+ if (props.valueHeader) {
1436
+ out.push({
1437
+ key: "vh",
1438
+ text: props.valueHeader,
1439
+ x: padding.value.left,
1440
+ y,
1441
+ anchor: "start",
1442
+ });
1443
+ }
1444
+ if (props.categoryHeader) {
1445
+ const horizontal = !isVertical.value;
1446
+ const cat = categoryLabelLayout.value;
1447
+ out.push({
1448
+ key: "ch",
1449
+ text: props.categoryHeader,
1450
+ x: horizontal ? cat.x : padding.value.left,
1451
+ y,
1452
+ anchor: horizontal ? cat.anchor : "start",
1453
+ });
1454
+ }
1455
+ return out;
1456
+ });
1018
1457
  </script>
1019
1458
 
1020
1459
  <template>
@@ -1047,6 +1486,20 @@ const positionedLegendItems = computed(() => {
1047
1486
  {{ line }}
1048
1487
  </tspan>
1049
1488
  </text>
1489
+ <!-- column headers (category / value), in the reserved row above the plot -->
1490
+ <text
1491
+ v-for="h in columnHeaders"
1492
+ :key="h.key"
1493
+ :data-testid="h.key === 'ch' ? 'category-header' : 'value-header'"
1494
+ :x="h.x"
1495
+ :y="h.y"
1496
+ :text-anchor="h.anchor"
1497
+ :font-size="axisLabelResolved.fontSize"
1498
+ :fill="axisLabelResolved.fill"
1499
+ font-weight="600"
1500
+ >
1501
+ {{ h.text }}
1502
+ </text>
1050
1503
  <!-- inline legend -->
1051
1504
  <g v-if="positionedLegendItems.length > 0">
1052
1505
  <template v-for="(item, i) in positionedLegendItems" :key="'ileg' + i">
@@ -1079,8 +1532,9 @@ const positionedLegendItems = computed(() => {
1079
1532
  </text>
1080
1533
  </template>
1081
1534
  </g>
1082
- <!-- axes -->
1535
+ <!-- axes (the value axis line is suppressed when valueAxis is false) -->
1083
1536
  <line
1537
+ v-if="!isVertical || valueAxis"
1084
1538
  :x1="snap(padding.left)"
1085
1539
  :y1="snap(padding.top)"
1086
1540
  :x2="snap(padding.left)"
@@ -1089,6 +1543,7 @@ const positionedLegendItems = computed(() => {
1089
1543
  stroke-opacity="0.3"
1090
1544
  />
1091
1545
  <line
1546
+ v-if="isVertical || valueAxis"
1092
1547
  :x1="snap(padding.left)"
1093
1548
  :y1="snap(padding.top + innerH)"
1094
1549
  :x2="snap(padding.left + innerW)"
@@ -1097,7 +1552,7 @@ const positionedLegendItems = computed(() => {
1097
1552
  stroke-opacity="0.3"
1098
1553
  />
1099
1554
  <!-- value grid lines -->
1100
- <template v-if="valueGrid">
1555
+ <template v-if="valueGrid && valueAxis">
1101
1556
  <line
1102
1557
  v-for="(tick, i) in valueTickItems"
1103
1558
  :key="'vg' + i"
@@ -1121,7 +1576,7 @@ const positionedLegendItems = computed(() => {
1121
1576
  pointer-events="none"
1122
1577
  />
1123
1578
  <!-- value tick labels -->
1124
- <template v-if="isVertical">
1579
+ <template v-if="valueAxis && isVertical">
1125
1580
  <text
1126
1581
  v-for="(tick, i) in valueTickItems"
1127
1582
  :key="'vt' + i"
@@ -1138,7 +1593,7 @@ const positionedLegendItems = computed(() => {
1138
1593
  {{ tick.value }}
1139
1594
  </text>
1140
1595
  </template>
1141
- <template v-else>
1596
+ <template v-else-if="valueAxis">
1142
1597
  <text
1143
1598
  v-for="(tick, i) in valueTickItems"
1144
1599
  :key="'vt' + i"
@@ -1189,9 +1644,9 @@ const positionedLegendItems = computed(() => {
1189
1644
  v-for="(tick, i) in categoryTickItems"
1190
1645
  :key="'ct' + i"
1191
1646
  data-testid="category-tick"
1192
- :x="padding.left - 6"
1647
+ :x="categoryLabelLayout.x"
1193
1648
  :y="tick.pos"
1194
- text-anchor="end"
1649
+ :text-anchor="categoryLabelLayout.anchor"
1195
1650
  dominant-baseline="middle"
1196
1651
  :font-size="tickLabelResolved.fontSize"
1197
1652
  :fill="tickLabelResolved.fill"
@@ -1228,6 +1683,22 @@ const positionedLegendItems = computed(() => {
1228
1683
  :fill-opacity="bar.opacity"
1229
1684
  :style="bar.blendMode ? { mixBlendMode: bar.blendMode } : undefined"
1230
1685
  />
1686
+ <!-- value-on-bar labels -->
1687
+ <text
1688
+ v-for="item in barLabelItems"
1689
+ :key="'blbl' + item.key"
1690
+ data-testid="bar-label"
1691
+ :x="item.x"
1692
+ :y="item.y"
1693
+ :text-anchor="item.anchor"
1694
+ dominant-baseline="middle"
1695
+ :font-size="item.fontSize"
1696
+ :font-weight="item.fontWeight"
1697
+ :fill="item.fill"
1698
+ pointer-events="none"
1699
+ >
1700
+ {{ item.text }}
1701
+ </text>
1231
1702
  <!-- summary lines (drawn above bars, below annotations) -->
1232
1703
  <template v-for="(line, i) in summaryLinesResolved" :key="'sl' + i">
1233
1704
  <path
@@ -1320,6 +1791,14 @@ const positionedLegendItems = computed(() => {
1320
1791
  >
1321
1792
  {{ downloadLinkText }}
1322
1793
  </a>
1794
+ <button
1795
+ v-if="downloadButtonText"
1796
+ type="button"
1797
+ class="bar-chart-download-button"
1798
+ @click="triggerCsvDownload"
1799
+ >
1800
+ {{ downloadButtonText }}
1801
+ </button>
1323
1802
  </div>
1324
1803
  </template>
1325
1804
 
@@ -1355,3 +1834,27 @@ const positionedLegendItems = computed(() => {
1355
1834
  flex-shrink: 0;
1356
1835
  }
1357
1836
  </style>
1837
+
1838
+ <style>
1839
+ .bar-chart-download-button {
1840
+ display: inline-flex;
1841
+ align-items: center;
1842
+ margin-top: 0.5em;
1843
+ padding: 0.5em 1em;
1844
+ border: 1px solid var(--color-border);
1845
+ border-radius: 0.25em;
1846
+ background: var(--color-bg-0, #fff);
1847
+ color: var(--color-text);
1848
+ font-size: var(--font-size-sm);
1849
+ cursor: pointer;
1850
+ }
1851
+
1852
+ .bar-chart-download-button:hover {
1853
+ background: var(--color-bg-1, rgba(0, 0, 0, 0.05));
1854
+ }
1855
+
1856
+ .bar-chart-download-button:focus-visible {
1857
+ outline: 2px solid var(--color-primary);
1858
+ outline-offset: 2px;
1859
+ }
1860
+ </style>