@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.
- package/charts/BarChart/BarChart.md +175 -4
- package/charts/BarChart/BarChart.vue +307 -3
- package/charts/DataTable/DataTable.md +113 -3
- package/charts/DataTable/DataTable.vue +93 -10
- package/charts/LineChart/LineChart.md +65 -1
- package/charts/LineChart/LineChart.vue +49 -14
- package/charts/_shared/chartProps.ts +60 -0
- package/charts/_shared/index.ts +2 -0
- package/charts/_shared/useChartFoundation.ts +6 -0
- package/charts/_shared/useChartMenu.ts +18 -1
- package/charts/index.ts +2 -0
- package/index.json +2 -1
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# BarChart
|
|
2
2
|
|
|
3
|
-
A responsive SVG bar chart supporting single, grouped,
|
|
4
|
-
either vertical (column) or horizontal orientation. Shares axis,
|
|
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 \| (() => 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
|
-
/**
|
|
77
|
-
|
|
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({
|
|
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
|
|
161
|
-
filename
|
|
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 \| (() => 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
|
|
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
|
-
{
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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(
|
|
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 \| (() => 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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/charts/_shared/index.ts
CHANGED
|
@@ -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
|
-
|
|
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