@cfasim-ui/docs 0.3.18 → 0.4.1

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.
@@ -0,0 +1,844 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import ChartMenu from "../ChartMenu/ChartMenu.vue";
4
+ import {
5
+ snap,
6
+ formatTick,
7
+ computeTickValues,
8
+ categoricalToCsv,
9
+ useChartSize,
10
+ useChartTooltip,
11
+ useChartMenu,
12
+ useChartPadding,
13
+ INLINE_LEGEND_HEIGHT,
14
+ type ChartData,
15
+ } from "../_shared/index.js";
16
+
17
+ export type BarChartData = ChartData;
18
+
19
+ export interface BarSeries {
20
+ /** Bar values; one entry per category. `y` is accepted as an alias. */
21
+ y?: BarChartData;
22
+ data?: BarChartData;
23
+ color?: string;
24
+ opacity?: number;
25
+ /** Label shown in the inline legend. */
26
+ legend?: string;
27
+ }
28
+
29
+ const props = withDefaults(
30
+ defineProps<{
31
+ /** Single-series values. Equivalent to `y`. */
32
+ data?: BarChartData;
33
+ /** Single-series values (alias for `data`). */
34
+ y?: BarChartData;
35
+ /** Multi-series mode. Each series has its own values. */
36
+ series?: BarSeries[];
37
+ /**
38
+ * Category labels for the categorical axis. Length should match the
39
+ * longest series. When omitted, indices (0, 1, 2, ...) are used.
40
+ */
41
+ categories?: readonly string[];
42
+ /** "vertical" (default, aka column) draws upright bars; "horizontal" draws sideways. */
43
+ orientation?: "vertical" | "horizontal";
44
+ /** "grouped" (default) places series side-by-side; "stacked" stacks them. */
45
+ layout?: "grouped" | "stacked";
46
+ width?: number;
47
+ height?: number;
48
+ title?: string;
49
+ xLabel?: string;
50
+ yLabel?: string;
51
+ /** Force the value axis to start at this value or lower (default 0). */
52
+ valueMin?: number;
53
+ /**
54
+ * Tick placement on the value axis (numeric). Number = interval,
55
+ * array = explicit values. When omitted, ticks are chosen automatically.
56
+ */
57
+ valueTicks?: number | number[];
58
+ /** Formatter for value-axis tick labels. */
59
+ valueTickFormat?: (value: number) => string;
60
+ /**
61
+ * Formatter for numeric values shown in the default tooltip. Receives
62
+ * the raw value. Defaults to the same tick formatter used for axes.
63
+ */
64
+ tooltipValueFormat?: (value: number) => string;
65
+ /** Formatter for category-axis labels. Receives the resolved category string. */
66
+ categoryFormat?: (label: string, index: number) => string;
67
+ /**
68
+ * Fraction of each category slot reserved as gap between groups (0..1).
69
+ * Default 0.2 — i.e. bars/groups fill 80% of their slot.
70
+ */
71
+ barPadding?: number;
72
+ /**
73
+ * Pixel gap between bars within a single category group in `grouped` layout.
74
+ * Default 1.
75
+ */
76
+ groupGap?: number;
77
+ debounce?: number;
78
+ menu?: boolean | string;
79
+ valueGrid?: boolean;
80
+ /**
81
+ * Custom per-index data passed to the tooltip slot. Accepts a plain
82
+ * array or any `ArrayLike` (e.g. a typed array column from a
83
+ * `ModelOutput`).
84
+ */
85
+ tooltipData?: ArrayLike<unknown>;
86
+ /** Tooltip activation mode. */
87
+ tooltipTrigger?: "hover" | "click";
88
+ /** Boundary for tooltip flip/clamp. Default "chart". */
89
+ tooltipClamp?: "none" | "chart" | "window";
90
+ /** Custom CSV content (string or function). When omitted, generated from the bars. */
91
+ csv?: string | (() => string);
92
+ /** Filename (without extension) for downloaded SVG, PNG, CSV. */
93
+ filename?: string;
94
+ /** Show a plain text link below the chart to download the CSV. */
95
+ downloadLink?: boolean | string;
96
+ }>(),
97
+ {
98
+ orientation: "vertical",
99
+ layout: "grouped",
100
+ valueMin: 0,
101
+ barPadding: 0.2,
102
+ groupGap: 1,
103
+ menu: true,
104
+ tooltipClamp: "chart",
105
+ valueGrid: true,
106
+ },
107
+ );
108
+
109
+ const emit = defineEmits<{
110
+ (e: "hover", payload: { index: number } | null): void;
111
+ }>();
112
+
113
+ defineSlots<{
114
+ tooltip?(props: {
115
+ index: number;
116
+ category: string;
117
+ values: { value: number; color: string; seriesIndex: number }[];
118
+ data: unknown;
119
+ }): unknown;
120
+ }>();
121
+
122
+ const { containerRef, measuredWidth } = useChartSize({
123
+ debounce: () => props.debounce,
124
+ });
125
+
126
+ const width = computed(() => props.width ?? (measuredWidth.value || 400));
127
+ const height = computed(() => props.height ?? 200);
128
+
129
+ const hasInlineLegend = computed(() => allSeries.value.some((s) => s.legend));
130
+
131
+ const { padding, innerW, innerH } = useChartPadding({
132
+ title: () => props.title,
133
+ xLabel: () => props.xLabel,
134
+ yLabel: () => props.yLabel,
135
+ hasInlineLegend: () => hasInlineLegend.value,
136
+ width: () => width.value,
137
+ height: () => height.value,
138
+ });
139
+
140
+ const EMPTY_DATA: readonly number[] = [];
141
+
142
+ type ResolvedSeries = {
143
+ data: BarChartData;
144
+ color?: string;
145
+ opacity?: number;
146
+ legend?: string;
147
+ };
148
+
149
+ function resolveSeries(s: BarSeries): ResolvedSeries {
150
+ return {
151
+ data: s.y ?? s.data ?? EMPTY_DATA,
152
+ color: s.color,
153
+ opacity: s.opacity,
154
+ legend: s.legend,
155
+ };
156
+ }
157
+
158
+ const allSeries = computed<ResolvedSeries[]>(() => {
159
+ if (props.series && props.series.length > 0)
160
+ return props.series.map(resolveSeries);
161
+ const topY = props.y ?? props.data;
162
+ if (topY) return [{ data: topY }];
163
+ return [];
164
+ });
165
+
166
+ const categoryCount = computed(() => {
167
+ let n = props.categories?.length ?? 0;
168
+ for (const s of allSeries.value) {
169
+ if (s.data.length > n) n = s.data.length;
170
+ }
171
+ return n;
172
+ });
173
+
174
+ const categoryLabels = computed<string[]>(() => {
175
+ const n = categoryCount.value;
176
+ const labels = new Array<string>(n);
177
+ for (let i = 0; i < n; i++) {
178
+ labels[i] = props.categories?.[i] ?? String(i);
179
+ }
180
+ return labels;
181
+ });
182
+
183
+ const isVertical = computed(() => props.orientation === "vertical");
184
+
185
+ /** Extent of the value axis (across all series, accounting for stacking). */
186
+ const valueExtent = computed(() => {
187
+ let min = Infinity;
188
+ let max = -Infinity;
189
+ if (props.layout === "stacked") {
190
+ const n = categoryCount.value;
191
+ for (let i = 0; i < n; i++) {
192
+ let pos = 0;
193
+ let neg = 0;
194
+ for (const s of allSeries.value) {
195
+ if (i >= s.data.length) continue;
196
+ const v = Number(s.data[i]);
197
+ if (!isFinite(v)) continue;
198
+ if (v >= 0) pos += v;
199
+ else neg += v;
200
+ }
201
+ if (pos > max) max = pos;
202
+ if (neg < min) min = neg;
203
+ }
204
+ } else {
205
+ for (const s of allSeries.value) {
206
+ for (const v of s.data) {
207
+ const n = Number(v);
208
+ if (!isFinite(n)) continue;
209
+ if (n < min) min = n;
210
+ if (n > max) max = n;
211
+ }
212
+ }
213
+ }
214
+ if (!isFinite(min)) min = 0;
215
+ if (!isFinite(max)) max = 0;
216
+ // Extend the value axis down to valueMin (default 0) when data sits
217
+ // above it, so bars share a common baseline.
218
+ const floor = props.valueMin ?? 0;
219
+ if (floor < min) min = floor;
220
+ const range = max - min || 1;
221
+ return { min, max, range };
222
+ });
223
+
224
+ /** Size (in pixels) of the categorical axis. */
225
+ const categoricalSize = computed(() =>
226
+ isVertical.value ? innerW.value : innerH.value,
227
+ );
228
+ /** Size (in pixels) of the value axis. */
229
+ const valueSize = computed(() =>
230
+ isVertical.value ? innerH.value : innerW.value,
231
+ );
232
+
233
+ const slotSize = computed(() => {
234
+ const n = categoryCount.value;
235
+ return n > 0 ? categoricalSize.value / n : 0;
236
+ });
237
+
238
+ /** Total width allocated to bars within one category slot. */
239
+ const groupWidth = computed(() => slotSize.value * (1 - props.barPadding));
240
+
241
+ /** Width of an individual bar (always; for stacked it's the full group). */
242
+ const barWidth = computed(() => {
243
+ const k = allSeries.value.length;
244
+ if (k === 0) return 0;
245
+ if (props.layout === "stacked" || k === 1) return groupWidth.value;
246
+ const totalGap = props.groupGap * (k - 1);
247
+ return Math.max(1, (groupWidth.value - totalGap) / k);
248
+ });
249
+
250
+ /** Pixel position of the start of category slot `i` along the categorical axis. */
251
+ function slotStart(i: number): number {
252
+ const base = isVertical.value ? padding.value.left : padding.value.top;
253
+ return base + i * slotSize.value;
254
+ }
255
+
256
+ /**
257
+ * Pixel position of the resting baseline for grouped bars — `valueMin`
258
+ * (default 0) clamped to the visible value extent. This pins positive
259
+ * and negative bars to a common zero line when data is mixed-sign, and
260
+ * to the requested floor when `valueMin` is set inside the data range.
261
+ */
262
+ const groupedBaselinePixel = computed(() => {
263
+ const { min, max } = valueExtent.value;
264
+ const target = props.valueMin ?? 0;
265
+ return valuePixel(Math.max(min, Math.min(max, target)));
266
+ });
267
+
268
+ /** Convert a data value to its pixel position along the value axis. */
269
+ function valuePixel(v: number): number {
270
+ const { min, range } = valueExtent.value;
271
+ const scale = valueSize.value / range;
272
+ if (isVertical.value) {
273
+ return padding.value.top + innerH.value - (v - min) * scale;
274
+ }
275
+ return padding.value.left + (v - min) * scale;
276
+ }
277
+
278
+ interface BarRect {
279
+ x: number;
280
+ y: number;
281
+ w: number;
282
+ h: number;
283
+ color: string;
284
+ opacity: number;
285
+ value: number;
286
+ categoryIndex: number;
287
+ seriesIndex: number;
288
+ }
289
+
290
+ /**
291
+ * Build one bar rectangle spanning [aPx..bPx] along the value axis and
292
+ * [start..start+span] along the categorical axis. Orientation flips
293
+ * which pair maps to (x, w) vs (y, h).
294
+ */
295
+ function makeBar(
296
+ aPx: number,
297
+ bPx: number,
298
+ start: number,
299
+ span: number,
300
+ color: string,
301
+ opacity: number,
302
+ value: number,
303
+ categoryIndex: number,
304
+ seriesIndex: number,
305
+ ): BarRect {
306
+ const lo = Math.min(aPx, bPx);
307
+ const size = Math.abs(aPx - bPx);
308
+ if (isVertical.value) {
309
+ return {
310
+ x: start,
311
+ y: lo,
312
+ w: span,
313
+ h: size,
314
+ color,
315
+ opacity,
316
+ value,
317
+ categoryIndex,
318
+ seriesIndex,
319
+ };
320
+ }
321
+ return {
322
+ x: lo,
323
+ y: start,
324
+ w: size,
325
+ h: span,
326
+ color,
327
+ opacity,
328
+ value,
329
+ categoryIndex,
330
+ seriesIndex,
331
+ };
332
+ }
333
+
334
+ const bars = computed<BarRect[]>(() => {
335
+ const out: BarRect[] = [];
336
+ const seriesList = allSeries.value;
337
+ const k = seriesList.length;
338
+ if (k === 0) return out;
339
+ const n = categoryCount.value;
340
+ const slot = slotSize.value;
341
+ const group = groupWidth.value;
342
+ const bw = barWidth.value;
343
+ const innerOffset = (slot - group) / 2;
344
+ const baseline = groupedBaselinePixel.value;
345
+
346
+ for (let i = 0; i < n; i++) {
347
+ const groupStart = slotStart(i) + innerOffset;
348
+ if (props.layout === "stacked") {
349
+ let posCursor = 0;
350
+ let negCursor = 0;
351
+ for (let s = 0; s < k; s++) {
352
+ const series = seriesList[s];
353
+ const raw = Number(series.data[i] ?? NaN);
354
+ if (!isFinite(raw)) continue;
355
+ const bottom = raw >= 0 ? posCursor : negCursor;
356
+ const top = bottom + raw;
357
+ out.push(
358
+ makeBar(
359
+ valuePixel(bottom),
360
+ valuePixel(top),
361
+ groupStart,
362
+ group,
363
+ series.color ?? defaultColor(s),
364
+ series.opacity ?? 1,
365
+ raw,
366
+ i,
367
+ s,
368
+ ),
369
+ );
370
+ if (raw >= 0) posCursor = top;
371
+ else negCursor = top;
372
+ }
373
+ } else {
374
+ for (let s = 0; s < k; s++) {
375
+ const series = seriesList[s];
376
+ const raw = Number(series.data[i] ?? NaN);
377
+ if (!isFinite(raw)) continue;
378
+ const barStart = groupStart + (k === 1 ? 0 : s * (bw + props.groupGap));
379
+ out.push(
380
+ makeBar(
381
+ baseline,
382
+ valuePixel(raw),
383
+ barStart,
384
+ bw,
385
+ series.color ?? defaultColor(s),
386
+ series.opacity ?? 1,
387
+ raw,
388
+ i,
389
+ s,
390
+ ),
391
+ );
392
+ }
393
+ }
394
+ }
395
+ return out;
396
+ });
397
+
398
+ const DEFAULT_COLORS = [
399
+ "var(--color-primary, #3b82f6)",
400
+ "var(--color-accent, #f59e0b)",
401
+ "var(--color-success, #10b981)",
402
+ "var(--color-danger, #ef4444)",
403
+ "var(--color-info, #6366f1)",
404
+ "var(--color-warning, #d97706)",
405
+ ];
406
+
407
+ function defaultColor(i: number): string {
408
+ return DEFAULT_COLORS[i % DEFAULT_COLORS.length];
409
+ }
410
+
411
+ function formatTooltipValue(v: number): string {
412
+ if (props.tooltipValueFormat) return props.tooltipValueFormat(v);
413
+ if (props.valueTickFormat) return props.valueTickFormat(v);
414
+ return formatTick(v);
415
+ }
416
+
417
+ const valueTickItems = computed(() => {
418
+ const { min, max } = valueExtent.value;
419
+ const fmt = (v: number) =>
420
+ props.valueTickFormat ? props.valueTickFormat(v) : formatTick(v);
421
+ if (min === max) {
422
+ return [
423
+ {
424
+ value: fmt(min),
425
+ pos: snap(valuePixel(min)),
426
+ },
427
+ ];
428
+ }
429
+ const targetTickPixels = isVertical.value ? 50 : 80;
430
+ const values = computeTickValues({
431
+ min,
432
+ max,
433
+ ticks: props.valueTicks,
434
+ targetTickCount: valueSize.value / targetTickPixels,
435
+ });
436
+ return values.map((v) => ({
437
+ value: fmt(v),
438
+ pos: snap(valuePixel(v)),
439
+ }));
440
+ });
441
+
442
+ interface CategoryTickItem {
443
+ label: string;
444
+ pos: number;
445
+ anchor: "start" | "middle" | "end";
446
+ }
447
+
448
+ const categoryTickItems = computed<CategoryTickItem[]>(() => {
449
+ const out: CategoryTickItem[] = [];
450
+ const n = categoryCount.value;
451
+ const fmt = (label: string, i: number) =>
452
+ props.categoryFormat ? props.categoryFormat(label, i) : label;
453
+ for (let i = 0; i < n; i++) {
454
+ const center = slotStart(i) + slotSize.value / 2;
455
+ out.push({
456
+ label: fmt(categoryLabels.value[i], i),
457
+ pos: center,
458
+ anchor: "middle",
459
+ });
460
+ }
461
+ return out;
462
+ });
463
+
464
+ interface InlineLegendItem {
465
+ label: string;
466
+ color: string;
467
+ }
468
+
469
+ const inlineLegendItems = computed<InlineLegendItem[]>(() => {
470
+ const items: InlineLegendItem[] = [];
471
+ allSeries.value.forEach((s, i) => {
472
+ if (!s.legend) return;
473
+ items.push({ label: s.legend, color: s.color ?? defaultColor(i) });
474
+ });
475
+ return items;
476
+ });
477
+
478
+ function toCsv(): string {
479
+ if (typeof props.csv === "function") return props.csv();
480
+ if (typeof props.csv === "string") return props.csv;
481
+ const namedSeries = allSeries.value.map((s) => ({
482
+ label: s.legend,
483
+ data: s.data,
484
+ }));
485
+ return categoricalToCsv(categoryLabels.value, namedSeries);
486
+ }
487
+
488
+ const hasTooltipSlot = computed(
489
+ () => !!props.tooltipData || !!props.tooltipTrigger,
490
+ );
491
+
492
+ function pointerToIndex(clientX: number, clientY: number): number | null {
493
+ const rect = containerRef.value?.getBoundingClientRect();
494
+ if (!rect) return null;
495
+ const n = categoryCount.value;
496
+ if (n === 0 || slotSize.value === 0) return null;
497
+ const local = isVertical.value
498
+ ? clientX - rect.left - padding.value.left
499
+ : clientY - rect.top - padding.value.top;
500
+ return Math.max(0, Math.min(n - 1, Math.floor(local / slotSize.value)));
501
+ }
502
+
503
+ const {
504
+ hoverIndex,
505
+ tooltipRef,
506
+ tooltipPos,
507
+ handlers: tooltipHandlers,
508
+ } = useChartTooltip({
509
+ enabled: () => hasTooltipSlot.value,
510
+ trigger: () => props.tooltipTrigger,
511
+ clamp: () => props.tooltipClamp,
512
+ pointerToIndex,
513
+ containerRef,
514
+ onHover: (payload) => emit("hover", payload),
515
+ });
516
+
517
+ const {
518
+ svgRef,
519
+ items: menuItems,
520
+ downloadLinkText,
521
+ csvHref,
522
+ resolvedFilename: menuFilename,
523
+ } = useChartMenu({
524
+ filename: () => props.filename,
525
+ legacyMenuLabel: () => props.menu,
526
+ getCsv: toCsv,
527
+ downloadLink: () => props.downloadLink,
528
+ });
529
+
530
+ const hoveredCategoryLabel = computed(() => {
531
+ const i = hoverIndex.value;
532
+ if (i === null) return undefined;
533
+ return categoryLabels.value[i];
534
+ });
535
+
536
+ const hoverSlotProps = computed(() => {
537
+ const idx = hoverIndex.value;
538
+ if (idx === null) return null;
539
+ return {
540
+ index: idx,
541
+ category: categoryLabels.value[idx] ?? String(idx),
542
+ values: allSeries.value.map((s, i) => ({
543
+ value: Number(s.data[idx] ?? NaN),
544
+ color: s.color ?? defaultColor(i),
545
+ seriesIndex: i,
546
+ })),
547
+ data: props.tooltipData?.[idx] ?? null,
548
+ };
549
+ });
550
+
551
+ /** Pixel rectangle of the hovered category slot (for the highlight band). */
552
+ const hoverBand = computed(() => {
553
+ const i = hoverIndex.value;
554
+ if (i === null) return null;
555
+ const start = slotStart(i);
556
+ if (isVertical.value) {
557
+ return {
558
+ x: start,
559
+ y: padding.value.top,
560
+ w: slotSize.value,
561
+ h: innerH.value,
562
+ };
563
+ }
564
+ return {
565
+ x: padding.value.left,
566
+ y: start,
567
+ w: innerW.value,
568
+ h: slotSize.value,
569
+ };
570
+ });
571
+ </script>
572
+
573
+ <template>
574
+ <div ref="containerRef" class="bar-chart-wrapper">
575
+ <ChartMenu v-if="menu" :items="menuItems" />
576
+ <svg ref="svgRef" :width="width" :height="height">
577
+ <!-- title -->
578
+ <text
579
+ v-if="title"
580
+ :x="width / 2"
581
+ :y="18"
582
+ text-anchor="middle"
583
+ font-size="14"
584
+ font-weight="600"
585
+ fill="currentColor"
586
+ >
587
+ {{ title }}
588
+ </text>
589
+ <!-- inline legend -->
590
+ <g v-if="inlineLegendItems.length > 0">
591
+ <template v-for="(item, i) in inlineLegendItems" :key="'ileg' + i">
592
+ <rect
593
+ :x="padding.left + i * 120"
594
+ :y="padding.top - INLINE_LEGEND_HEIGHT / 2 - 5"
595
+ width="12"
596
+ height="10"
597
+ :fill="item.color"
598
+ />
599
+ <text
600
+ :x="padding.left + i * 120 + 18"
601
+ :y="padding.top - INLINE_LEGEND_HEIGHT / 2 + 4"
602
+ font-size="11"
603
+ fill="currentColor"
604
+ >
605
+ {{ item.label }}
606
+ </text>
607
+ </template>
608
+ </g>
609
+ <!-- axes -->
610
+ <line
611
+ :x1="snap(padding.left)"
612
+ :y1="snap(padding.top)"
613
+ :x2="snap(padding.left)"
614
+ :y2="snap(padding.top + innerH)"
615
+ stroke="currentColor"
616
+ stroke-opacity="0.3"
617
+ />
618
+ <line
619
+ :x1="snap(padding.left)"
620
+ :y1="snap(padding.top + innerH)"
621
+ :x2="snap(padding.left + innerW)"
622
+ :y2="snap(padding.top + innerH)"
623
+ stroke="currentColor"
624
+ stroke-opacity="0.3"
625
+ />
626
+ <!-- value grid lines -->
627
+ <template v-if="valueGrid">
628
+ <line
629
+ v-for="(tick, i) in valueTickItems"
630
+ :key="'vg' + i"
631
+ :x1="isVertical ? padding.left : tick.pos"
632
+ :y1="isVertical ? tick.pos : padding.top"
633
+ :x2="isVertical ? padding.left + innerW : tick.pos"
634
+ :y2="isVertical ? tick.pos : padding.top + innerH"
635
+ stroke="currentColor"
636
+ stroke-opacity="0.1"
637
+ />
638
+ </template>
639
+ <!-- hover highlight band (rendered behind bars) -->
640
+ <rect
641
+ v-if="hoverBand && hasTooltipSlot"
642
+ :x="hoverBand.x"
643
+ :y="hoverBand.y"
644
+ :width="hoverBand.w"
645
+ :height="hoverBand.h"
646
+ fill="currentColor"
647
+ fill-opacity="0.06"
648
+ pointer-events="none"
649
+ />
650
+ <!-- value tick labels -->
651
+ <template v-if="isVertical">
652
+ <text
653
+ v-for="(tick, i) in valueTickItems"
654
+ :key="'vt' + i"
655
+ data-testid="value-tick"
656
+ :x="padding.left - 6"
657
+ :y="tick.pos"
658
+ text-anchor="end"
659
+ dominant-baseline="middle"
660
+ font-size="10"
661
+ fill="currentColor"
662
+ fill-opacity="0.6"
663
+ >
664
+ {{ tick.value }}
665
+ </text>
666
+ </template>
667
+ <template v-else>
668
+ <text
669
+ v-for="(tick, i) in valueTickItems"
670
+ :key="'vt' + i"
671
+ data-testid="value-tick"
672
+ :x="tick.pos"
673
+ :y="padding.top + innerH + 16"
674
+ text-anchor="middle"
675
+ font-size="10"
676
+ fill="currentColor"
677
+ fill-opacity="0.6"
678
+ >
679
+ {{ tick.value }}
680
+ </text>
681
+ </template>
682
+ <!-- y axis label -->
683
+ <text
684
+ v-if="yLabel"
685
+ :x="0"
686
+ :y="0"
687
+ :transform="`translate(14, ${padding.top + innerH / 2}) rotate(-90)`"
688
+ text-anchor="middle"
689
+ font-size="13"
690
+ fill="currentColor"
691
+ >
692
+ {{ yLabel }}
693
+ </text>
694
+ <!-- category tick labels -->
695
+ <template v-if="isVertical">
696
+ <text
697
+ v-for="(tick, i) in categoryTickItems"
698
+ :key="'ct' + i"
699
+ data-testid="category-tick"
700
+ :x="tick.pos"
701
+ :y="padding.top + innerH + 16"
702
+ :text-anchor="tick.anchor"
703
+ font-size="10"
704
+ fill="currentColor"
705
+ fill-opacity="0.6"
706
+ >
707
+ {{ tick.label }}
708
+ </text>
709
+ </template>
710
+ <template v-else>
711
+ <text
712
+ v-for="(tick, i) in categoryTickItems"
713
+ :key="'ct' + i"
714
+ data-testid="category-tick"
715
+ :x="padding.left - 6"
716
+ :y="tick.pos"
717
+ text-anchor="end"
718
+ dominant-baseline="middle"
719
+ font-size="10"
720
+ fill="currentColor"
721
+ fill-opacity="0.6"
722
+ >
723
+ {{ tick.label }}
724
+ </text>
725
+ </template>
726
+ <!-- x axis label -->
727
+ <text
728
+ v-if="xLabel"
729
+ :x="padding.left + innerW / 2"
730
+ :y="height - 4"
731
+ text-anchor="middle"
732
+ font-size="13"
733
+ fill="currentColor"
734
+ >
735
+ {{ xLabel }}
736
+ </text>
737
+ <!-- bars -->
738
+ <rect
739
+ v-for="(bar, i) in bars"
740
+ :key="'bar' + i"
741
+ data-testid="bar"
742
+ :data-category="bar.categoryIndex"
743
+ :data-series="bar.seriesIndex"
744
+ :x="bar.x"
745
+ :y="bar.y"
746
+ :width="bar.w"
747
+ :height="bar.h"
748
+ :fill="bar.color"
749
+ :fill-opacity="bar.opacity"
750
+ />
751
+ <!-- Tooltip: interaction overlay -->
752
+ <rect
753
+ v-if="hasTooltipSlot"
754
+ :x="padding.left"
755
+ :y="padding.top"
756
+ :width="innerW"
757
+ :height="innerH"
758
+ fill="transparent"
759
+ style="cursor: crosshair; touch-action: none"
760
+ v-on="tooltipHandlers"
761
+ />
762
+ </svg>
763
+ <!-- Tooltip floating content -->
764
+ <div
765
+ v-if="hasTooltipSlot && hoverIndex !== null && hoverSlotProps"
766
+ ref="tooltipRef"
767
+ class="chart-tooltip-content"
768
+ :style="{
769
+ position: 'absolute',
770
+ top: '0',
771
+ left: '0',
772
+ willChange: 'transform',
773
+ transform: tooltipPos
774
+ ? `translate3d(${tooltipPos.left}px, ${tooltipPos.top}px, 0) translateY(-50%)`
775
+ : 'translateY(-50%)',
776
+ visibility: tooltipPos ? 'visible' : 'hidden',
777
+ }"
778
+ >
779
+ <slot name="tooltip" v-bind="hoverSlotProps">
780
+ <div class="bar-chart-tooltip">
781
+ <div v-if="hoveredCategoryLabel" class="bar-chart-tooltip-label">
782
+ {{ hoveredCategoryLabel }}
783
+ </div>
784
+ <div
785
+ v-for="v in hoverSlotProps.values"
786
+ :key="v.seriesIndex"
787
+ class="bar-chart-tooltip-row"
788
+ >
789
+ <span
790
+ class="bar-chart-tooltip-swatch"
791
+ :style="{ background: v.color }"
792
+ />
793
+ {{ isFinite(v.value) ? formatTooltipValue(v.value) : "—" }}
794
+ </div>
795
+ </div>
796
+ </slot>
797
+ </div>
798
+ <a
799
+ v-if="downloadLinkText"
800
+ class="bar-chart-download-link"
801
+ :href="csvHref!"
802
+ :download="`${menuFilename()}.csv`"
803
+ >
804
+ {{ downloadLinkText }}
805
+ </a>
806
+ </div>
807
+ </template>
808
+
809
+ <style scoped>
810
+ .bar-chart-wrapper {
811
+ position: relative;
812
+ width: 100%;
813
+ }
814
+
815
+ .bar-chart-wrapper:hover :deep(.chart-menu-button) {
816
+ opacity: 1;
817
+ }
818
+
819
+ .bar-chart-tooltip-label {
820
+ font-weight: 600;
821
+ margin-bottom: 0.25em;
822
+ }
823
+
824
+ .bar-chart-tooltip-row {
825
+ display: flex;
826
+ align-items: center;
827
+ gap: 0.375em;
828
+ }
829
+
830
+ .bar-chart-download-link {
831
+ display: block;
832
+ text-align: right;
833
+ font-size: var(--font-size-sm);
834
+ margin-top: 0.25em;
835
+ }
836
+
837
+ .bar-chart-tooltip-swatch {
838
+ display: inline-block;
839
+ width: 0.625em;
840
+ height: 0.625em;
841
+ border-radius: 50%;
842
+ flex-shrink: 0;
843
+ }
844
+ </style>