@cfasim-ui/docs 0.4.14 → 0.4.16

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.
@@ -1,8 +1,8 @@
1
1
  # BarChart
2
2
 
3
- A responsive SVG bar chart supporting single, grouped, and stacked series in
4
- either vertical (column) or horizontal orientation. Shares axis, tooltip,
5
- menu, and CSV export wiring with [`LineChart`](./line-chart).
3
+ A responsive SVG bar chart supporting single, grouped, stacked, and overlay
4
+ series in either vertical (column) or horizontal orientation. Shares axis,
5
+ tooltip, menu, and CSV export wiring with [`LineChart`](./line-chart).
6
6
 
7
7
  ## Examples
8
8
 
@@ -100,6 +100,101 @@ menu, and CSV export wiring with [`LineChart`](./line-chart).
100
100
  </template>
101
101
  </ComponentDemo>
102
102
 
103
+ ### Overlay series
104
+
105
+ `layout="overlay"` draws each series at full group width from the shared
106
+ baseline, painting later series on top of earlier ones. Use it to compare
107
+ a backdrop value (e.g. target, capacity, prior period) against a shorter
108
+ foreground value sharing the same axis.
109
+
110
+ Series render in the order they appear in `series`, so put the taller
111
+ backdrop first and the shorter foreground after. Use `opacity`, a
112
+ distinct color, or a per-series `blendMode` (see below) if bars might
113
+ overlap fully.
114
+
115
+ <ComponentDemo>
116
+ <BarChart
117
+ :series="[
118
+ { data: [40, 40, 40, 40], legend: 'Target', color: '#d4d4d8' },
119
+ { data: [22, 35, 28, 38], legend: 'Actual', color: '#0057b7' },
120
+ ]"
121
+ :categories="['Q1', 'Q2', 'Q3', 'Q4']"
122
+ layout="overlay"
123
+ :height="220"
124
+ y-label="Doses (thousands)"
125
+ tooltip-trigger="hover"
126
+ />
127
+
128
+ <template #code>
129
+
130
+ ```vue
131
+ <BarChart
132
+ :series="[
133
+ { data: [40, 40, 40, 40], legend: 'Target', color: '#d4d4d8' },
134
+ { data: [22, 35, 28, 38], legend: 'Actual', color: '#0057b7' },
135
+ ]"
136
+ :categories="['Q1', 'Q2', 'Q3', 'Q4']"
137
+ layout="overlay"
138
+ :height="220"
139
+ y-label="Doses (thousands)"
140
+ tooltip-trigger="hover"
141
+ />
142
+ ```
143
+
144
+ </template>
145
+ </ComponentDemo>
146
+
147
+ ### Blend mode
148
+
149
+ Set `blendMode` on a series to apply a CSS `mix-blend-mode` to every
150
+ bar in that series. Useful with `layout="overlay"` (or just two
151
+ overlapping grouped bars) to combine colors instead of one obscuring
152
+ the other. Common picks: `"multiply"` (darkens light fills where bars
153
+ overlap), `"screen"` (lightens dark fills), `"difference"` (high
154
+ contrast).
155
+
156
+ <ComponentDemo>
157
+ <BarChart
158
+ :series="[
159
+ { data: [22, 35, 28, 38], legend: 'Treatment A', color: '#ef4444', blendMode: 'multiply' },
160
+ { data: [30, 24, 36, 32], legend: 'Treatment B', color: '#3b82f6', blendMode: 'multiply' },
161
+ ]"
162
+ :categories="['Q1', 'Q2', 'Q3', 'Q4']"
163
+ layout="overlay"
164
+ :height="220"
165
+ y-label="Cases avoided"
166
+ tooltip-trigger="hover"
167
+ />
168
+
169
+ <template #code>
170
+
171
+ ```vue
172
+ <BarChart
173
+ :series="[
174
+ {
175
+ data: [22, 35, 28, 38],
176
+ legend: 'Treatment A',
177
+ color: '#ef4444',
178
+ blendMode: 'multiply',
179
+ },
180
+ {
181
+ data: [30, 24, 36, 32],
182
+ legend: 'Treatment B',
183
+ color: '#3b82f6',
184
+ blendMode: 'multiply',
185
+ },
186
+ ]"
187
+ :categories="['Q1', 'Q2', 'Q3', 'Q4']"
188
+ layout="overlay"
189
+ :height="220"
190
+ y-label="Cases avoided"
191
+ tooltip-trigger="hover"
192
+ />
193
+ ```
194
+
195
+ </template>
196
+ </ComponentDemo>
197
+
103
198
  ### Horizontal orientation
104
199
 
105
200
  <ComponentDemo>
@@ -290,6 +385,80 @@ top still represents the sum.
290
385
  </template>
291
386
  </ComponentDemo>
292
387
 
388
+ ### Summary line overlay
389
+
390
+ Layer a curve (KDE, rolling mean, target) on top of the bars with
391
+ `summary-lines`. Each line scales to **its own value extent** —
392
+ independent of the bar axis — so a probability-density curve in
393
+ `[0, 0.02]` and a histogram in `[0, 500]` can share the same chart.
394
+
395
+ <ComponentDemo>
396
+ <BarChart
397
+ :data="[3, 5, 8, 12, 18, 24, 31, 27, 22, 16, 11, 7, 4, 2]"
398
+ :categories="['2.11', '2.33', '2.56', '2.78', '3.00', '3.22', '3.44', '3.67', '3.89', '4.11', '4.33', '4.56', '4.78', '5.00']"
399
+ :summary-lines="[
400
+ {
401
+ data: [0.05, 0.10, 0.20, 0.40, 0.75, 1.10, 1.32, 1.20, 0.92, 0.60, 0.32, 0.16, 0.07, 0.03],
402
+ color: '#ef4444',
403
+ strokeWidth: 2,
404
+ legend: 'KDE',
405
+ },
406
+ ]"
407
+ :width="380"
408
+ :height="240"
409
+ x-label="R₀"
410
+ y-label="Frequency"
411
+ />
412
+
413
+ <template #code>
414
+
415
+ ```vue
416
+ <BarChart
417
+ :data="[3, 5, 8, 12, 18, 24, 31, 27, 22, 16, 11, 7, 4, 2]"
418
+ :categories="[
419
+ '2.11',
420
+ '2.33',
421
+ '2.56',
422
+ '2.78',
423
+ '3.00',
424
+ '3.22',
425
+ '3.44',
426
+ '3.67',
427
+ '3.89',
428
+ '4.11',
429
+ '4.33',
430
+ '4.56',
431
+ '4.78',
432
+ '5.00',
433
+ ]"
434
+ :summary-lines="[
435
+ {
436
+ data: [
437
+ 0.05, 0.1, 0.2, 0.4, 0.75, 1.1, 1.32, 1.2, 0.92, 0.6, 0.32, 0.16, 0.07,
438
+ 0.03,
439
+ ],
440
+ color: '#ef4444',
441
+ strokeWidth: 2,
442
+ legend: 'KDE',
443
+ },
444
+ ]"
445
+ :height="240"
446
+ x-label="R₀"
447
+ y-label="Frequency"
448
+ />
449
+ ```
450
+
451
+ </template>
452
+ </ComponentDemo>
453
+
454
+ Each line accepts `color`, `strokeWidth`, `dashed`, `opacity`,
455
+ `blendMode`, `dots`, and `dotRadius` for styling — same vocabulary as
456
+ `LineChart` series (both extend `LineMarkStyle`). Override the line's
457
+ extent with `valueMin` / `valueMax`. Pass `x: number[]` (in
458
+ category-index space — `0` is the first category center, fractional
459
+ values land between) to plot at custom positions; useful when the
460
+ summary samples are denser or sparser than the histogram bins.
461
+
293
462
  ### Annotations
294
463
 
295
464
  Pin callouts to specific bars with `annotations`. `x` is the category
@@ -424,14 +593,16 @@ anchor. `lineColor`, `lineWidth`, and `lineDash` style the line.
424
593
  | `csv` | `string \| (() =&gt; string)` | No | — |
425
594
  | `filename` | `string` | No | — |
426
595
  | `downloadLink` | `boolean \| string` | No | — |
596
+ | `downloadButton` | `boolean \| string` | No | — |
427
597
  | `annotations` | `readonly ChartAnnotation[]` | No | — |
428
598
  | `chartPadding` | `ChartPadding` | No | — |
429
599
  | `data` | `BarChartData` | No | — |
430
600
  | `y` | `BarChartData` | No | — |
431
601
  | `series` | `BarSeries[]` | No | — |
602
+ | `summaryLines` | `BarSummaryLine[]` | No | — |
432
603
  | `categories` | `readonly (string \| Date)[]` | No | — |
433
604
  | `orientation` | `"vertical" \| "horizontal"` | No | `"vertical"` |
434
- | `layout` | `"grouped" \| "stacked"` | No | `"grouped"` |
605
+ | `layout` | `"grouped" \| "stacked" \| "overlay"` | No | `"grouped"` |
435
606
  | `valueMin` | `number` | No | `0` |
436
607
  | `valueScaleType` | `"linear" \| "log"` | No | `"linear"` |
437
608
  | `valueTicks` | `number \| number[]` | No | — |
@@ -32,6 +32,8 @@ import {
32
32
  type ChartHoverPayload,
33
33
  type ChartTooltipBaseProps,
34
34
  type ChartTooltipValue,
35
+ type BlendMode,
36
+ type LineMarkStyle,
35
37
  } from "../_shared/index.js";
36
38
 
37
39
  export type BarChartData = ChartData;
@@ -42,6 +44,12 @@ export interface BarSeries {
42
44
  data?: BarChartData;
43
45
  color?: string;
44
46
  opacity?: number;
47
+ /**
48
+ * CSS `mix-blend-mode` applied to each bar in this series. Lets
49
+ * overlapping bars (e.g. `layout="overlay"`) combine their colors
50
+ * instead of one obscuring the other.
51
+ */
52
+ blendMode?: BlendMode;
45
53
  /** Label shown in the inline legend. */
46
54
  legend?: string;
47
55
  /**
@@ -56,6 +64,38 @@ export interface BarSeries {
56
64
  showInTooltip?: boolean;
57
65
  }
58
66
 
67
+ /**
68
+ * A line overlay drawn on top of the bars — e.g. a KDE over a histogram,
69
+ * a rolling mean, or any other summary curve. Each line scales to its
70
+ * own value extent (independent of the bars), so a probability-density
71
+ * curve in `[0, 0.02]` and a count histogram in `[0, 500]` can coexist.
72
+ *
73
+ * Sample the curve densely on the input side — `summaryLines` connects
74
+ * points with straight segments (matching matplotlib / seaborn `kde=True`).
75
+ *
76
+ * Shares visual styling (`color`, `strokeWidth`, `dashed`, `opacity`,
77
+ * `blendMode`, `dots`, `dotRadius`, `legend`, `showInLegend`) with
78
+ * `LineChart`'s `Series` via `LineMarkStyle`.
79
+ */
80
+ export interface BarSummaryLine extends LineMarkStyle {
81
+ /** Y values along the line. */
82
+ data: BarChartData;
83
+ /**
84
+ * X positions in category-index space (0 = first category center,
85
+ * `categories.length - 1` = last). Fractional values land between
86
+ * category centers. When omitted, points plot at successive category
87
+ * centers — one point per `data` entry.
88
+ */
89
+ x?: BarChartData;
90
+ /**
91
+ * Override the line's value-axis floor. When unset, defaults to the
92
+ * line's own min (clamped to 0 when all data is non-negative).
93
+ */
94
+ valueMin?: number;
95
+ /** Override the line's value-axis ceiling. Defaults to the line's own max. */
96
+ valueMax?: number;
97
+ }
98
+
59
99
  interface BarChartProps extends ChartCommonProps {
60
100
  /** Single-series values. Equivalent to `y`. */
61
101
  data?: BarChartData;
@@ -63,6 +103,12 @@ interface BarChartProps extends ChartCommonProps {
63
103
  y?: BarChartData;
64
104
  /** Multi-series mode. Each series has its own values. */
65
105
  series?: BarSeries[];
106
+ /**
107
+ * Line overlays drawn on top of the bars (e.g. a KDE, a rolling mean,
108
+ * or any summary curve). Each line scales to its own value extent —
109
+ * unrelated to the bar value axis. See `BarSummaryLine`.
110
+ */
111
+ summaryLines?: BarSummaryLine[];
66
112
  /**
67
113
  * Category labels for the categorical axis. Length should match the
68
114
  * longest series. When omitted, indices (0, 1, 2, ...) are used.
@@ -73,8 +119,14 @@ interface BarChartProps extends ChartCommonProps {
73
119
  categories?: readonly (string | Date)[];
74
120
  /** "vertical" (default, aka column) draws upright bars; "horizontal" draws sideways. */
75
121
  orientation?: "vertical" | "horizontal";
76
- /** "grouped" (default) places series side-by-side; "stacked" stacks them. */
77
- layout?: "grouped" | "stacked";
122
+ /**
123
+ * "grouped" (default) places series side-by-side; "stacked" stacks
124
+ * them; "overlay" draws each series at full group width from the
125
+ * shared baseline, painting later series on top of earlier ones.
126
+ * In overlay mode set `opacity` per series, or order series so the
127
+ * tallest renders first, so bars behind remain visible.
128
+ */
129
+ layout?: "grouped" | "stacked" | "overlay";
78
130
  /** Force the value axis to start at this value or lower (default 0). */
79
131
  valueMin?: number;
80
132
  /**
@@ -148,6 +200,7 @@ type ResolvedSeries = {
148
200
  data: BarChartData;
149
201
  color?: string;
150
202
  opacity?: number;
203
+ blendMode?: BlendMode;
151
204
  legend?: string;
152
205
  showInLegend?: boolean;
153
206
  showInTooltip?: boolean;
@@ -158,6 +211,7 @@ function resolveSeries(s: BarSeries): ResolvedSeries {
158
211
  data: s.y ?? s.data ?? EMPTY_DATA,
159
212
  color: s.color,
160
213
  opacity: s.opacity,
214
+ blendMode: s.blendMode,
161
215
  legend: s.legend,
162
216
  showInLegend: s.showInLegend,
163
217
  showInTooltip: s.showInTooltip,
@@ -334,6 +388,7 @@ interface BarRect {
334
388
  h: number;
335
389
  color: string;
336
390
  opacity: number;
391
+ blendMode?: BlendMode;
337
392
  value: number;
338
393
  categoryIndex: number;
339
394
  seriesIndex: number;
@@ -351,6 +406,7 @@ function makeBar(
351
406
  span: number,
352
407
  color: string,
353
408
  opacity: number,
409
+ blendMode: BlendMode | undefined,
354
410
  value: number,
355
411
  categoryIndex: number,
356
412
  seriesIndex: number,
@@ -365,6 +421,7 @@ function makeBar(
365
421
  h: size,
366
422
  color,
367
423
  opacity,
424
+ blendMode,
368
425
  value,
369
426
  categoryIndex,
370
427
  seriesIndex,
@@ -377,6 +434,7 @@ function makeBar(
377
434
  h: span,
378
435
  color,
379
436
  opacity,
437
+ blendMode,
380
438
  value,
381
439
  categoryIndex,
382
440
  seriesIndex,
@@ -414,6 +472,7 @@ const bars = computed<BarRect[]>(() => {
414
472
  group,
415
473
  series.color ?? defaultColor(s),
416
474
  series.opacity ?? 1,
475
+ series.blendMode,
417
476
  raw,
418
477
  i,
419
478
  s,
@@ -422,6 +481,26 @@ const bars = computed<BarRect[]>(() => {
422
481
  if (raw >= 0) posCursor = top;
423
482
  else negCursor = top;
424
483
  }
484
+ } else if (props.layout === "overlay") {
485
+ for (let s = 0; s < k; s++) {
486
+ const series = seriesList[s];
487
+ const raw = Number(series.data[i] ?? NaN);
488
+ if (!isFinite(raw)) continue;
489
+ out.push(
490
+ makeBar(
491
+ baseline,
492
+ valuePixel(raw),
493
+ groupStart,
494
+ group,
495
+ series.color ?? defaultColor(s),
496
+ series.opacity ?? 1,
497
+ series.blendMode,
498
+ raw,
499
+ i,
500
+ s,
501
+ ),
502
+ );
503
+ }
425
504
  } else {
426
505
  for (let s = 0; s < k; s++) {
427
506
  const series = seriesList[s];
@@ -436,6 +515,7 @@ const bars = computed<BarRect[]>(() => {
436
515
  bw,
437
516
  series.color ?? defaultColor(s),
438
517
  series.opacity ?? 1,
518
+ series.blendMode,
439
519
  raw,
440
520
  i,
441
521
  s,
@@ -447,6 +527,138 @@ const bars = computed<BarRect[]>(() => {
447
527
  return out;
448
528
  });
449
529
 
530
+ /**
531
+ * Per-line styling, resolved with defaults but without pixel work. Kept
532
+ * separate from `ResolvedSummaryLine` so the inline legend can depend on
533
+ * it without pulling in the chart's pixel layout (which depends on the
534
+ * legend row count — a circular reference otherwise).
535
+ */
536
+ type StyledSummaryLine = {
537
+ data: BarChartData;
538
+ x?: BarChartData;
539
+ color: string;
540
+ strokeWidth: number;
541
+ dashed: boolean;
542
+ opacity: number;
543
+ blendMode?: BlendMode;
544
+ dots: boolean;
545
+ dotRadius: number;
546
+ extent: { min: number; max: number };
547
+ legend?: string;
548
+ showInLegend: boolean;
549
+ };
550
+
551
+ type ResolvedSummaryLine = StyledSummaryLine & {
552
+ pathD: string;
553
+ points: { x: number; y: number }[];
554
+ };
555
+
556
+ /** Compute the line's own value extent. */
557
+ function computeLineExtent(line: BarSummaryLine): { min: number; max: number } {
558
+ let min = Infinity;
559
+ let max = -Infinity;
560
+ for (const v of line.data) {
561
+ const n = Number(v);
562
+ if (!isFinite(n)) continue;
563
+ if (n < min) min = n;
564
+ if (n > max) max = n;
565
+ }
566
+ if (!isFinite(min)) {
567
+ min = 0;
568
+ max = 1;
569
+ }
570
+ if (line.valueMin !== undefined) {
571
+ min = line.valueMin;
572
+ } else if (min > 0) {
573
+ // Default to a 0 floor for all-positive data so the line shape
574
+ // reads as "rises from zero" rather than filling the full plot.
575
+ min = 0;
576
+ }
577
+ if (line.valueMax !== undefined) max = line.valueMax;
578
+ if (min === max) max = min + 1;
579
+ return { min, max };
580
+ }
581
+
582
+ /**
583
+ * Project a single line point (x in category-index space, y in the
584
+ * line's own value space) to pixels. Orientation flips which axis is
585
+ * categorical vs value.
586
+ */
587
+ function projectLinePoint(
588
+ x: number,
589
+ y: number,
590
+ extent: { min: number; max: number },
591
+ ): { x: number; y: number } {
592
+ const base = isVertical.value ? padding.value.left : padding.value.top;
593
+ const categoricalPx = base + (x + 0.5) * slotSize.value;
594
+ const span = extent.max - extent.min || 1;
595
+ const frac = (y - extent.min) / span;
596
+ if (isVertical.value) {
597
+ return {
598
+ x: categoricalPx,
599
+ y: padding.value.top + innerH.value - frac * innerH.value,
600
+ };
601
+ }
602
+ return {
603
+ x: padding.value.left + frac * innerW.value,
604
+ y: categoricalPx,
605
+ };
606
+ }
607
+
608
+ const summaryLinesStyled = computed<StyledSummaryLine[]>(() => {
609
+ const lines = props.summaryLines;
610
+ if (!lines || lines.length === 0) return [];
611
+ return lines.map((line, i): StyledSummaryLine => {
612
+ return {
613
+ data: line.data ?? EMPTY_DATA,
614
+ x: line.x,
615
+ color: line.color ?? defaultLineColor(i),
616
+ strokeWidth: line.strokeWidth ?? 2,
617
+ dashed: line.dashed ?? false,
618
+ opacity: line.opacity ?? 1,
619
+ blendMode: line.blendMode,
620
+ dots: line.dots ?? false,
621
+ dotRadius: line.dotRadius ?? (line.strokeWidth ?? 2) + 1,
622
+ extent: computeLineExtent(line),
623
+ legend: line.legend,
624
+ showInLegend: line.showInLegend !== false,
625
+ };
626
+ });
627
+ });
628
+
629
+ const summaryLinesResolved = computed<ResolvedSummaryLine[]>(() => {
630
+ if (slotSize.value === 0) return [];
631
+ return summaryLinesStyled.value.map((line): ResolvedSummaryLine => {
632
+ const points: { x: number; y: number }[] = [];
633
+ let d = "";
634
+ let inSeg = false;
635
+ for (let j = 0; j < line.data.length; j++) {
636
+ const yv = Number(line.data[j]);
637
+ const xv = line.x ? Number(line.x[j]) : j;
638
+ if (!isFinite(yv) || !isFinite(xv)) {
639
+ inSeg = false;
640
+ continue;
641
+ }
642
+ const p = projectLinePoint(xv, yv, line.extent);
643
+ points.push(p);
644
+ d += inSeg ? `L${p.x},${p.y}` : `M${p.x},${p.y}`;
645
+ inSeg = true;
646
+ }
647
+ return { ...line, pathD: d, points };
648
+ });
649
+ });
650
+
651
+ const DEFAULT_LINE_COLORS = [
652
+ "var(--color-fg-1, #111)",
653
+ "var(--color-danger, #ef4444)",
654
+ "var(--color-success, #10b981)",
655
+ "var(--color-info, #6366f1)",
656
+ ];
657
+
658
+ function defaultLineColor(i: number): string {
659
+ return DEFAULT_LINE_COLORS[i % DEFAULT_LINE_COLORS.length];
660
+ }
661
+
450
662
  const DEFAULT_COLORS = [
451
663
  "var(--color-primary, #3b82f6)",
452
664
  "var(--color-accent, #f59e0b)",
@@ -580,13 +792,28 @@ const categoryTickItems = computed<CategoryTickItem[]>(() => {
580
792
  interface InlineLegendItem {
581
793
  label: string;
582
794
  color: string;
795
+ kind: "bar" | "line";
796
+ dashed?: boolean;
583
797
  }
584
798
 
585
799
  const inlineLegendItems = computed<InlineLegendItem[]>(() => {
586
800
  const items: InlineLegendItem[] = [];
587
801
  allSeries.value.forEach((s, i) => {
588
802
  if (!s.legend || s.showInLegend === false) return;
589
- items.push({ label: s.legend, color: s.color ?? defaultColor(i) });
803
+ items.push({
804
+ label: s.legend,
805
+ color: s.color ?? defaultColor(i),
806
+ kind: "bar",
807
+ });
808
+ });
809
+ summaryLinesStyled.value.forEach((line) => {
810
+ if (!line.legend || !line.showInLegend) return;
811
+ items.push({
812
+ label: line.legend,
813
+ color: line.color,
814
+ kind: "line",
815
+ dashed: line.dashed,
816
+ });
590
817
  });
591
818
  return items;
592
819
  });
@@ -652,6 +879,8 @@ const {
652
879
  menuItems,
653
880
  downloadLinkText,
654
881
  csvHref,
882
+ downloadButtonText,
883
+ triggerCsvDownload,
655
884
  menuFilename,
656
885
  isFullscreen,
657
886
  } = useChartFoundation({
@@ -667,6 +896,7 @@ const {
667
896
  tooltipClamp: () => props.tooltipClamp,
668
897
  filename: () => props.filename,
669
898
  downloadLink: () => props.downloadLink,
899
+ downloadButton: () => props.downloadButton,
670
900
  chartPadding: () => props.chartPadding,
671
901
  inlineLegendLabels: () => inlineLegendLabels.value,
672
902
  hasTooltipSlot: () => hasTooltipSlot.value,
@@ -824,12 +1054,23 @@ const positionedLegendItems = computed(() => {
824
1054
  <g v-if="positionedLegendItems.length > 0">
825
1055
  <template v-for="(item, i) in positionedLegendItems" :key="'ileg' + i">
826
1056
  <rect
1057
+ v-if="item.kind === 'bar'"
827
1058
  :x="item.x"
828
1059
  :y="item.y - 5"
829
1060
  width="12"
830
1061
  height="10"
831
1062
  :fill="item.color"
832
1063
  />
1064
+ <line
1065
+ v-else
1066
+ :x1="item.x"
1067
+ :y1="item.y"
1068
+ :x2="item.x + 12"
1069
+ :y2="item.y"
1070
+ :stroke="item.color"
1071
+ stroke-width="2"
1072
+ :stroke-dasharray="item.dashed ? '4 2' : undefined"
1073
+ />
833
1074
  <text
834
1075
  :x="item.x + 18"
835
1076
  :y="item.y + 4"
@@ -988,7 +1229,38 @@ const positionedLegendItems = computed(() => {
988
1229
  :height="bar.h"
989
1230
  :fill="bar.color"
990
1231
  :fill-opacity="bar.opacity"
1232
+ :style="bar.blendMode ? { mixBlendMode: bar.blendMode } : undefined"
991
1233
  />
1234
+ <!-- summary lines (drawn above bars, below annotations) -->
1235
+ <template v-for="(line, i) in summaryLinesResolved" :key="'sl' + i">
1236
+ <path
1237
+ v-if="line.pathD"
1238
+ data-testid="summary-line"
1239
+ :d="line.pathD"
1240
+ fill="none"
1241
+ :stroke="line.color"
1242
+ :stroke-width="line.strokeWidth"
1243
+ :stroke-opacity="line.opacity"
1244
+ :stroke-dasharray="line.dashed ? '6 3' : undefined"
1245
+ stroke-linecap="round"
1246
+ stroke-linejoin="round"
1247
+ :style="line.blendMode ? { mixBlendMode: line.blendMode } : undefined"
1248
+ />
1249
+ <template v-if="line.dots">
1250
+ <circle
1251
+ v-for="(pt, j) in line.points"
1252
+ :key="'sld' + i + '-' + j"
1253
+ :cx="pt.x"
1254
+ :cy="pt.y"
1255
+ :r="line.dotRadius"
1256
+ :fill="line.color"
1257
+ :fill-opacity="line.opacity"
1258
+ :style="
1259
+ line.blendMode ? { mixBlendMode: line.blendMode } : undefined
1260
+ "
1261
+ />
1262
+ </template>
1263
+ </template>
992
1264
  <!-- Tooltip: interaction overlay -->
993
1265
  <rect
994
1266
  v-if="hasTooltipSlot"
@@ -1051,6 +1323,14 @@ const positionedLegendItems = computed(() => {
1051
1323
  >
1052
1324
  {{ downloadLinkText }}
1053
1325
  </a>
1326
+ <button
1327
+ v-if="downloadButtonText"
1328
+ type="button"
1329
+ class="bar-chart-download-button"
1330
+ @click="triggerCsvDownload"
1331
+ >
1332
+ {{ downloadButtonText }}
1333
+ </button>
1054
1334
  </div>
1055
1335
  </template>
1056
1336
 
@@ -1086,3 +1366,27 @@ const positionedLegendItems = computed(() => {
1086
1366
  flex-shrink: 0;
1087
1367
  }
1088
1368
  </style>
1369
+
1370
+ <style>
1371
+ .bar-chart-download-button {
1372
+ display: inline-flex;
1373
+ align-items: center;
1374
+ margin-top: 0.5em;
1375
+ padding: 0.5em 1em;
1376
+ border: 1px solid var(--color-border);
1377
+ border-radius: 0.25em;
1378
+ background: var(--color-bg-0, #fff);
1379
+ color: var(--color-text);
1380
+ font-size: var(--font-size-sm);
1381
+ cursor: pointer;
1382
+ }
1383
+
1384
+ .bar-chart-download-button:hover {
1385
+ background: var(--color-bg-1, rgba(0, 0, 0, 0.05));
1386
+ }
1387
+
1388
+ .bar-chart-download-button:focus-visible {
1389
+ outline: 2px solid var(--color-primary);
1390
+ outline-offset: 2px;
1391
+ }
1392
+ </style>
@@ -157,9 +157,8 @@ the available space equally.
157
157
 
158
158
  A `⋯` menu appears in the top-right corner of every table with a
159
159
  **Download** item that exports the data as CSV. Use `download-menu-link`
160
- to customize the menu item label, `filename` to control the downloaded
161
- filename, and `csv` to supply custom CSV content. Pass `:menu="false"`
162
- to hide the menu entirely.
160
+ to customize the menu item label and `filename` to control the
161
+ downloaded filename. Pass `:menu="false"` to hide the menu entirely.
163
162
 
164
163
  <ComponentDemo>
165
164
  <DataTable
@@ -184,6 +183,115 @@ to hide the menu entirely.
184
183
  </template>
185
184
  </ComponentDemo>
186
185
 
186
+ ### Custom CSV download
187
+
188
+ By default, the Download menu item exports the displayed table as CSV.
189
+ Use the `csv` prop to supply your own content — for example, to include
190
+ ISO dates, extra columns that aren't in the table, or values formatted
191
+ differently from the on-screen rendering. Accepts a raw string or a
192
+ function returning one (called lazily on click).
193
+
194
+ <ComponentDemo>
195
+ <DataTable
196
+ :data="{ day: [0, 1, 2, 3, 4], cases: [1, 21, 56, 101, 141] }"
197
+ filename="sir-cases"
198
+ :csv="'date,day,cases\n2024-01-01,0,1\n2024-01-02,1,21\n2024-01-03,2,56\n2024-01-04,3,101\n2024-01-05,4,141'"
199
+ />
200
+
201
+ <template #code>
202
+
203
+ ```vue
204
+ <DataTable
205
+ :data="{
206
+ day: [0, 1, 2, 3, 4],
207
+ cases: [1, 21, 56, 101, 141],
208
+ }"
209
+ filename="sir-cases"
210
+ :csv="`date,day,cases
211
+ 2024-01-01,0,1
212
+ 2024-01-02,1,21
213
+ 2024-01-03,2,56
214
+ 2024-01-04,3,101
215
+ 2024-01-05,4,141`"
216
+ />
217
+ ```
218
+
219
+ </template>
220
+ </ComponentDemo>
221
+
222
+ ### Download button
223
+
224
+ Pass `download-button` to render a visible, labeled button beneath the
225
+ table instead of exposing the download only via the top-right menu. The
226
+ button uses `download-menu-link` as its label, and the menu's Download
227
+ item is suppressed so the action isn't duplicated. The button has the
228
+ class `data-table-download-button` and its styles are unscoped, so it
229
+ can be targeted directly from custom CSS without specificity battles.
230
+
231
+ <ComponentDemo>
232
+ <DataTable
233
+ :data="{ day: [0, 1, 2, 3, 4], cases: [1, 21, 56, 101, 141] }"
234
+ filename="sir-cases"
235
+ download-menu-link="Download CSV"
236
+ download-button
237
+ :csv="'date,day,cases\n2024-01-01,0,1\n2024-01-02,1,21\n2024-01-03,2,56\n2024-01-04,3,101\n2024-01-05,4,141'"
238
+ />
239
+
240
+ <template #code>
241
+
242
+ ```vue
243
+ <DataTable
244
+ :data="{
245
+ day: [0, 1, 2, 3, 4],
246
+ cases: [1, 21, 56, 101, 141],
247
+ }"
248
+ filename="sir-cases"
249
+ download-menu-link="Download CSV"
250
+ download-button
251
+ :csv="`date,day,cases
252
+ 2024-01-01,0,1
253
+ 2024-01-02,1,21
254
+ 2024-01-03,2,56
255
+ 2024-01-04,3,101
256
+ 2024-01-05,4,141`"
257
+ />
258
+ ```
259
+
260
+ </template>
261
+ </ComponentDemo>
262
+
263
+ ### Download link
264
+
265
+ Pass `download-link` to render a plain text link beneath the table
266
+ instead of (or alongside) the menu. It's a real `<a href download>` —
267
+ right-click → Save As works. Pass `true` for the default "Download data
268
+ (CSV)" label, or a string to customize. The menu's Download item is
269
+ suppressed when this is set. If both `download-link` and
270
+ `download-button` are set, the button wins.
271
+
272
+ <ComponentDemo>
273
+ <DataTable
274
+ :data="{ day: [0, 1, 2, 3, 4], cases: [1, 21, 56, 101, 141] }"
275
+ filename="sir-cases"
276
+ download-link
277
+ />
278
+
279
+ <template #code>
280
+
281
+ ```vue
282
+ <DataTable
283
+ :data="{
284
+ day: [0, 1, 2, 3, 4],
285
+ cases: [1, 21, 56, 101, 141],
286
+ }"
287
+ filename="sir-cases"
288
+ download-link
289
+ />
290
+ ```
291
+
292
+ </template>
293
+ </ComponentDemo>
294
+
187
295
  ## Props
188
296
 
189
297
  | Prop | Type | Required | Default |
@@ -195,6 +303,8 @@ to hide the menu entirely.
195
303
  | `csv` | `string \| (() =&gt; string)` | No | — |
196
304
  | `filename` | `string` | No | — |
197
305
  | `downloadMenuLink` | `string` | No | `"Download"` |
306
+ | `downloadButton` | `boolean` | No | `false` |
307
+ | `downloadLink` | `boolean \| string` | No | — |
198
308
  | `fullWidth` | `boolean` | No | `false` |
199
309
 
200
310
 
@@ -54,14 +54,34 @@ const props = withDefaults(
54
54
  /** Filename (without extension) for downloaded CSV files. */
55
55
  filename?: string;
56
56
  /**
57
- * Label for the Download item in the table's top-right menu.
58
- * Defaults to "Download".
57
+ * Label for the Download item in the table's top-right menu, and for
58
+ * the button rendered when `downloadButton` is true. Defaults to
59
+ * "Download".
59
60
  */
60
61
  downloadMenuLink?: string;
62
+ /**
63
+ * Render a visible "Download" button beneath the table instead of
64
+ * exposing the action only via the top-right menu. When enabled, the
65
+ * menu's Download item is suppressed to avoid duplicate controls.
66
+ */
67
+ downloadButton?: boolean;
68
+ /**
69
+ * Render a plain text link beneath the table for downloading CSV.
70
+ * Pass `true` for the default "Download data (CSV)" label, or a
71
+ * string to customize. When set, the menu's Download item is
72
+ * suppressed. Mutually exclusive with `downloadButton`; if both are
73
+ * set, `downloadButton` wins.
74
+ */
75
+ downloadLink?: boolean | string;
61
76
  /** Stretch the table to fill its container's width. */
62
77
  fullWidth?: boolean;
63
78
  }>(),
64
- { menu: true, fullWidth: false, downloadMenuLink: "Download" },
79
+ {
80
+ menu: true,
81
+ fullWidth: false,
82
+ downloadMenuLink: "Download",
83
+ downloadButton: false,
84
+ },
65
85
  );
66
86
 
67
87
  function columnLabel(name: string): string {
@@ -165,14 +185,30 @@ function toCsv(): string {
165
185
  return lines.join("\n");
166
186
  }
167
187
 
168
- const menuItems = computed<ChartMenuItem[]>(() => [
169
- {
170
- label: props.downloadMenuLink,
171
- action: () => downloadCsv(toCsv(), menuFilename()),
172
- },
173
- ]);
188
+ function triggerDownload() {
189
+ downloadCsv(toCsv(), menuFilename());
190
+ }
191
+
192
+ const menuItems = computed<ChartMenuItem[]>(() => {
193
+ if (props.downloadButton || props.downloadLink) return [];
194
+ return [{ label: props.downloadMenuLink, action: triggerDownload }];
195
+ });
196
+
197
+ const downloadLinkText = computed<string | null>(() => {
198
+ if (props.downloadButton) return null;
199
+ const v = props.downloadLink;
200
+ if (!v) return null;
201
+ return typeof v === "string" ? v : "Download data (CSV)";
202
+ });
203
+
204
+ const csvHref = computed<string | null>(() => {
205
+ if (!downloadLinkText.value) return null;
206
+ return `data:text/csv;charset=utf-8,${encodeURIComponent(toCsv())}`;
207
+ });
174
208
 
175
- const showMenu = computed(() => Boolean(props.menu));
209
+ const showMenu = computed(
210
+ () => Boolean(props.menu) && menuItems.value.length > 0,
211
+ );
176
212
  </script>
177
213
 
178
214
  <template>
@@ -215,6 +251,22 @@ const showMenu = computed(() => Boolean(props.menu));
215
251
  </tbody>
216
252
  </table>
217
253
  </div>
254
+ <button
255
+ v-if="downloadButton"
256
+ type="button"
257
+ class="data-table-download-button"
258
+ @click="triggerDownload"
259
+ >
260
+ {{ downloadMenuLink }}
261
+ </button>
262
+ <a
263
+ v-else-if="downloadLinkText"
264
+ class="data-table-download-link"
265
+ :href="csvHref!"
266
+ :download="`${menuFilename()}.csv`"
267
+ >
268
+ {{ downloadLinkText }}
269
+ </a>
218
270
  </div>
219
271
  </template>
220
272
 
@@ -288,3 +340,34 @@ const showMenu = computed(() => Boolean(props.menu));
288
340
  padding-right: 2.5em;
289
341
  }
290
342
  </style>
343
+
344
+ <style>
345
+ .data-table-download-button {
346
+ display: inline-flex;
347
+ align-items: center;
348
+ margin-top: 0.75em;
349
+ padding: 0.5em 1em;
350
+ border: 1px solid var(--color-border);
351
+ border-radius: 0.25em;
352
+ background: var(--color-bg-0, #fff);
353
+ color: var(--color-text);
354
+ font-size: var(--font-size-sm);
355
+ cursor: pointer;
356
+ }
357
+
358
+ .data-table-download-button:hover {
359
+ background: var(--color-bg-1, rgba(0, 0, 0, 0.05));
360
+ }
361
+
362
+ .data-table-download-button:focus-visible {
363
+ outline: 2px solid var(--color-primary);
364
+ outline-offset: 2px;
365
+ }
366
+
367
+ .data-table-download-link {
368
+ display: block;
369
+ text-align: right;
370
+ font-size: var(--font-size-sm);
371
+ margin-top: 0.25em;
372
+ }
373
+ </style>
@@ -478,6 +478,66 @@ busy background — the outline keeps each series visually distinct.
478
478
  </template>
479
479
  </ComponentDemo>
480
480
 
481
+ ### Blend mode
482
+
483
+ Set `blendMode` on a `Series` to apply a CSS `mix-blend-mode` to that
484
+ series' line (and dots, if enabled). Useful when two thick or
485
+ high-contrast lines overlap — `"multiply"` darkens the crossing point
486
+ on light backgrounds, `"screen"` lightens on dark, `"difference"`
487
+ inverts. The white `outline`, if any, is unaffected.
488
+
489
+ <ComponentDemo>
490
+ <LineChart
491
+ :series="[
492
+ {
493
+ data: Array.from({ length: 30 }, (_, t) => 20 + 15 * Math.sin(t / 4)),
494
+ color: '#ef4444',
495
+ strokeWidth: 3,
496
+ blendMode: 'multiply',
497
+ legend: 'Strain A',
498
+ },
499
+ {
500
+ data: Array.from({ length: 30 }, (_, t) => 20 + 15 * Math.cos(t / 4)),
501
+ color: '#3b82f6',
502
+ strokeWidth: 3,
503
+ blendMode: 'multiply',
504
+ legend: 'Strain B',
505
+ },
506
+ ]"
507
+ :height="240"
508
+ x-label="Day"
509
+ y-label="Incidence"
510
+ />
511
+
512
+ <template #code>
513
+
514
+ ```vue
515
+ <LineChart
516
+ :series="[
517
+ {
518
+ data: strainA,
519
+ color: '#ef4444',
520
+ strokeWidth: 3,
521
+ blendMode: 'multiply',
522
+ legend: 'Strain A',
523
+ },
524
+ {
525
+ data: strainB,
526
+ color: '#3b82f6',
527
+ strokeWidth: 3,
528
+ blendMode: 'multiply',
529
+ legend: 'Strain B',
530
+ },
531
+ ]"
532
+ :height="240"
533
+ x-label="Day"
534
+ y-label="Incidence"
535
+ />
536
+ ```
537
+
538
+ </template>
539
+ </ComponentDemo>
540
+
481
541
  ### Grid lines
482
542
 
483
543
  <ComponentDemo>
@@ -881,7 +941,10 @@ dates, categorical labels, or extra columns that aren't plotted). Use
881
941
  `filename` to control the download filename (shared by SVG, PNG and CSV).
882
942
 
883
943
  Pass `download-link` to also render a plain text link below the chart — set
884
- it to `true` for the default label, or pass a string to customize it.
944
+ it to `true` for the default label, or pass a string to customize it. Use
945
+ `download-button` instead to render a styled `<button>` (with the class
946
+ `line-chart-download-button`, available for custom CSS) in place of the
947
+ link.
885
948
 
886
949
  <ComponentDemo>
887
950
  <LineChart
@@ -944,6 +1007,7 @@ until the user clicks Download:
944
1007
  | `csv` | `string \| (() =&gt; string)` | No | — |
945
1008
  | `filename` | `string` | No | — |
946
1009
  | `downloadLink` | `boolean \| string` | No | — |
1010
+ | `downloadButton` | `boolean \| string` | No | — |
947
1011
  | `annotations` | `readonly ChartAnnotation[]` | No | — |
948
1012
  | `chartPadding` | `ChartPadding` | No | — |
949
1013
  | `y` | `LineChartData` | No | — |
@@ -35,6 +35,8 @@ import {
35
35
  type ChartHoverPayload,
36
36
  type ChartTooltipBaseProps,
37
37
  type ChartTooltipValue,
38
+ type BlendMode,
39
+ type LineMarkStyle,
38
40
  } from "../_shared/index.js";
39
41
 
40
42
  /**
@@ -52,7 +54,13 @@ export type LineChartData = ChartData;
52
54
  */
53
55
  export type LineChartXInput = LineChartData | readonly (string | Date)[];
54
56
 
55
- export interface Series {
57
+ /**
58
+ * A single line/dot series. Inherits visual styling
59
+ * (`color`, `strokeWidth`, `dashed`, `opacity`, `blendMode`, `dots`,
60
+ * `dotRadius`, `legend`, `showInLegend`) from `LineMarkStyle`, shared
61
+ * with `BarChart`'s `BarSummaryLine`.
62
+ */
63
+ export interface Series extends LineMarkStyle {
56
64
  /**
57
65
  * Y-values. One of `y` or `data` must be supplied; `y` wins if both
58
66
  * are set.
@@ -67,12 +75,11 @@ export interface Series {
67
75
  * date strings or `Date` objects to enable date-axis mode.
68
76
  */
69
77
  x?: LineChartXInput;
70
- color?: string;
71
- dashed?: boolean;
72
- strokeWidth?: number;
73
- opacity?: number;
78
+ /** Overrides `opacity` for the line stroke only. */
74
79
  lineOpacity?: number;
80
+ /** Overrides `opacity` for the dots only. */
75
81
  dotOpacity?: number;
82
+ /** Draw the line. Default true. */
76
83
  line?: boolean;
77
84
  /**
78
85
  * Draw a page-colored stroke behind the line so it stays visually
@@ -80,17 +87,8 @@ export interface Series {
80
87
  * effect when `line` is `false`.
81
88
  */
82
89
  outline?: boolean;
83
- dots?: boolean;
84
- dotRadius?: number;
85
90
  dotFill?: string;
86
91
  dotStroke?: string;
87
- /** Label shown in the inline legend */
88
- legend?: string;
89
- /**
90
- * Whether this series appears in the inline legend. Defaults to true.
91
- * Has no effect when `legend` is unset (no legend entry to begin with).
92
- */
93
- showInLegend?: boolean;
94
92
  /**
95
93
  * Whether this series contributes a value to the tooltip and shows a
96
94
  * hover dot. Defaults to true. The series line/dots are still drawn.
@@ -931,6 +929,8 @@ const {
931
929
  menuItems,
932
930
  downloadLinkText,
933
931
  csvHref,
932
+ downloadButtonText,
933
+ triggerCsvDownload,
934
934
  menuFilename,
935
935
  isFullscreen,
936
936
  } = useChartFoundation({
@@ -946,6 +946,7 @@ const {
946
946
  tooltipClamp: () => props.tooltipClamp,
947
947
  filename: () => props.filename,
948
948
  downloadLink: () => props.downloadLink,
949
+ downloadButton: () => props.downloadButton,
949
950
  chartPadding: () => props.chartPadding,
950
951
  inlineLegendLabels: () => inlineLegendLabels.value,
951
952
  hasTooltipSlot: () => hasTooltipSlot.value,
@@ -1208,6 +1209,7 @@ const positionedLegendItems = computed(() => {
1208
1209
  :stroke-width="s.strokeWidth ?? 1.5"
1209
1210
  :stroke-opacity="s.lineOpacity ?? s.opacity ?? lineOpacity"
1210
1211
  :stroke-dasharray="s.dashed ? '6 3' : undefined"
1212
+ :style="s.blendMode ? { mixBlendMode: s.blendMode } : undefined"
1211
1213
  />
1212
1214
  <template v-if="s.dots">
1213
1215
  <circle
@@ -1219,6 +1221,7 @@ const positionedLegendItems = computed(() => {
1219
1221
  :fill="s.dotFill ?? s.color ?? 'currentColor'"
1220
1222
  :fill-opacity="s.dotOpacity ?? s.opacity ?? lineOpacity"
1221
1223
  :stroke="s.dotStroke ?? 'none'"
1224
+ :style="s.blendMode ? { mixBlendMode: s.blendMode } : undefined"
1222
1225
  />
1223
1226
  </template>
1224
1227
  </template>
@@ -1403,6 +1406,14 @@ const positionedLegendItems = computed(() => {
1403
1406
  >
1404
1407
  {{ downloadLinkText }}
1405
1408
  </a>
1409
+ <button
1410
+ v-if="downloadButtonText"
1411
+ type="button"
1412
+ class="line-chart-download-button"
1413
+ @click="triggerCsvDownload"
1414
+ >
1415
+ {{ downloadButtonText }}
1416
+ </button>
1406
1417
  </div>
1407
1418
  </template>
1408
1419
 
@@ -1438,3 +1449,27 @@ const positionedLegendItems = computed(() => {
1438
1449
  flex-shrink: 0;
1439
1450
  }
1440
1451
  </style>
1452
+
1453
+ <style>
1454
+ .line-chart-download-button {
1455
+ display: inline-flex;
1456
+ align-items: center;
1457
+ margin-top: 0.5em;
1458
+ padding: 0.5em 1em;
1459
+ border: 1px solid var(--color-border);
1460
+ border-radius: 0.25em;
1461
+ background: var(--color-bg-0, #fff);
1462
+ color: var(--color-text);
1463
+ font-size: var(--font-size-sm);
1464
+ cursor: pointer;
1465
+ }
1466
+
1467
+ .line-chart-download-button:hover {
1468
+ background: var(--color-bg-1, rgba(0, 0, 0, 0.05));
1469
+ }
1470
+
1471
+ .line-chart-download-button:focus-visible {
1472
+ outline: 2px solid var(--color-primary);
1473
+ outline-offset: 2px;
1474
+ }
1475
+ </style>
@@ -35,6 +35,59 @@ export interface LabelStyle {
35
35
  fontWeight?: number | string;
36
36
  }
37
37
 
38
+ /**
39
+ * CSS `mix-blend-mode` values usable on chart series. Drives how a
40
+ * series' pixels combine with what's painted behind them. Useful for
41
+ * overlapping series (e.g. `"multiply"` darkens light fills where bars
42
+ * overlap; `"screen"` lightens dark fills).
43
+ */
44
+ export type BlendMode =
45
+ | "normal"
46
+ | "multiply"
47
+ | "screen"
48
+ | "overlay"
49
+ | "darken"
50
+ | "lighten"
51
+ | "color-dodge"
52
+ | "color-burn"
53
+ | "hard-light"
54
+ | "soft-light"
55
+ | "difference"
56
+ | "exclusion"
57
+ | "hue"
58
+ | "saturation"
59
+ | "color"
60
+ | "luminosity";
61
+
62
+ /**
63
+ * Visual styling shared by anything drawn as "a stroked line with
64
+ * optional dots" — used by `LineChart`'s `Series` and `BarChart`'s
65
+ * `BarSummaryLine`. Chart-specific data (y/x arrays, scaling overrides,
66
+ * tooltip/outline behavior) lives on the extending type.
67
+ */
68
+ export interface LineMarkStyle {
69
+ color?: string;
70
+ strokeWidth?: number;
71
+ dashed?: boolean;
72
+ opacity?: number;
73
+ /**
74
+ * CSS `mix-blend-mode` applied to the line and dots. Lets overlapping
75
+ * marks combine their colors instead of one obscuring the other.
76
+ */
77
+ blendMode?: BlendMode;
78
+ /** Draw a dot at each sample point. Default false. */
79
+ dots?: boolean;
80
+ /** Radius of each dot in pixels. */
81
+ dotRadius?: number;
82
+ /** Label shown in the inline legend. */
83
+ legend?: string;
84
+ /**
85
+ * Whether this mark appears in the inline legend. Defaults to true.
86
+ * Has no effect when `legend` is unset.
87
+ */
88
+ showInLegend?: boolean;
89
+ }
90
+
38
91
  /**
39
92
  * Props common to every cartesian chart component. Anything specific to
40
93
  * the chart type (series shape, layout, value-axis details) lives on the
@@ -88,6 +141,13 @@ export interface ChartCommonProps {
88
141
  * for the default label or a string to customize.
89
142
  */
90
143
  downloadLink?: boolean | string;
144
+ /**
145
+ * Show a `<button>` below the chart to download CSV. Pass `true` for
146
+ * the default label or a string to customize. When set, the CSV menu
147
+ * item is suppressed. Mutually exclusive with `downloadLink`; if both
148
+ * are set, `downloadButton` wins.
149
+ */
150
+ downloadButton?: boolean | string;
91
151
  /** Annotations rendered as the top layer of the chart. */
92
152
  annotations?: readonly ChartAnnotation[];
93
153
  /**
@@ -51,6 +51,8 @@ export type {
51
51
  ChartTooltipBaseProps,
52
52
  TitleStyle,
53
53
  LabelStyle,
54
+ BlendMode,
55
+ LineMarkStyle,
54
56
  } from "./chartProps.js";
55
57
  export {
56
58
  parseDate,
@@ -25,6 +25,7 @@ export interface ChartFoundationOptions {
25
25
  tooltipClamp: () => TooltipClamp | undefined;
26
26
  filename: () => string | undefined;
27
27
  downloadLink: () => boolean | string | undefined;
28
+ downloadButton: () => boolean | string | undefined;
28
29
  chartPadding: () => ChartPadding | undefined;
29
30
  // Chart-specific hooks that the composable can't infer.
30
31
  /**
@@ -61,6 +62,8 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
61
62
  items: menuItems,
62
63
  downloadLinkText,
63
64
  csvHref,
65
+ downloadButtonText,
66
+ triggerCsvDownload,
64
67
  resolvedFilename: menuFilename,
65
68
  isFullscreen,
66
69
  } = useChartMenu({
@@ -68,6 +71,7 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
68
71
  legacyMenuLabel: opts.menu,
69
72
  getCsv: opts.getCsv,
70
73
  downloadLink: opts.downloadLink,
74
+ downloadButton: opts.downloadButton,
71
75
  fullscreen: true,
72
76
  });
73
77
 
@@ -130,6 +134,8 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
130
134
  menuItems,
131
135
  downloadLinkText,
132
136
  csvHref,
137
+ downloadButtonText,
138
+ triggerCsvDownload,
133
139
  menuFilename,
134
140
  isFullscreen,
135
141
  measuredHeight,
@@ -11,6 +11,8 @@ export interface ChartMenuOptions {
11
11
  getCsv: () => string;
12
12
  /** Whether a separate download link is rendered (and the CSV menu item should be hidden). */
13
13
  downloadLink: () => boolean | string | undefined;
14
+ /** Whether a separate download button is rendered (and the CSV menu item should be hidden). */
15
+ downloadButton?: () => boolean | string | undefined;
14
16
  /**
15
17
  * When true, prepends an Expand/Collapse menu item that toggles the
16
18
  * chart into a full-window view. The consumer is responsible for
@@ -56,7 +58,8 @@ export function useChartMenu(opts: ChartMenuOptions) {
56
58
  },
57
59
  },
58
60
  );
59
- if (!opts.downloadLink()) {
61
+ const buttonOn = opts.downloadButton?.();
62
+ if (!opts.downloadLink() && !buttonOn) {
60
63
  out.push({
61
64
  label: "Download CSV",
62
65
  action: () => downloadCsv(opts.getCsv(), fname),
@@ -66,21 +69,35 @@ export function useChartMenu(opts: ChartMenuOptions) {
66
69
  });
67
70
 
68
71
  const downloadLinkText = computed<string | null>(() => {
72
+ if (opts.downloadButton?.()) return null;
69
73
  const v = opts.downloadLink();
70
74
  if (!v) return null;
71
75
  return typeof v === "string" ? v : "Download data (CSV)";
72
76
  });
73
77
 
74
78
  const csvHref = computed<string | null>(() => {
79
+ if (opts.downloadButton?.()) return null;
75
80
  if (!opts.downloadLink()) return null;
76
81
  return `data:text/csv;charset=utf-8,${encodeURIComponent(opts.getCsv())}`;
77
82
  });
78
83
 
84
+ const downloadButtonText = computed<string | null>(() => {
85
+ const v = opts.downloadButton?.();
86
+ if (!v) return null;
87
+ return typeof v === "string" ? v : "Download data (CSV)";
88
+ });
89
+
90
+ function triggerCsvDownload() {
91
+ downloadCsv(opts.getCsv(), resolvedFilename());
92
+ }
93
+
79
94
  return {
80
95
  svgRef: svgRef as Ref<SVGSVGElement | null>,
81
96
  items,
82
97
  downloadLinkText,
83
98
  csvHref,
99
+ downloadButtonText,
100
+ triggerCsvDownload,
84
101
  resolvedFilename,
85
102
  isFullscreen: fullscreen?.isFullscreen ?? ref(false),
86
103
  };
package/charts/index.ts CHANGED
@@ -11,6 +11,7 @@ export {
11
11
  default as BarChart,
12
12
  type BarChartData,
13
13
  type BarSeries,
14
+ type BarSummaryLine,
14
15
  } from "./BarChart/BarChart.vue";
15
16
  export {
16
17
  default as ChoroplethMap,
@@ -33,3 +34,4 @@ export {
33
34
  type ColumnWidth,
34
35
  } from "./DataTable/DataTable.vue";
35
36
  export type { ChartAnnotation } from "./_shared/annotations.js";
37
+ export type { BlendMode, LineMarkStyle } from "./_shared/chartProps.js";
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.14",
2
+ "version": "0.4.16",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
@@ -149,6 +149,7 @@
149
149
  "categorical",
150
150
  "grouped",
151
151
  "stacked",
152
+ "overlay",
152
153
  "vertical",
153
154
  "horizontal",
154
155
  "svg"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {