@cfasim-ui/docs 0.4.10 → 0.4.11
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.vue +68 -20
- package/charts/ChartMenu/ChartMenu.vue +3 -0
- package/charts/ChoroplethMap/ChoroplethMap.vue +14 -5
- package/charts/LineChart/LineChart.md +88 -0
- package/charts/LineChart/LineChart.vue +87 -34
- package/charts/_shared/fullscreen.css +51 -0
- package/charts/_shared/index.ts +3 -1
- package/charts/_shared/useChartFoundation.ts +54 -27
- package/charts/_shared/useChartFullscreen.ts +104 -0
- package/charts/_shared/useChartMenu.ts +19 -4
- package/charts/_shared/useChartPadding.ts +101 -15
- package/charts/_shared/useChartSize.ts +14 -5
- package/charts/index.ts +2 -0
- package/index.json +1 -1
- package/package.json +1 -1
|
@@ -13,10 +13,12 @@ import {
|
|
|
13
13
|
useChartFoundation,
|
|
14
14
|
makeTooltipValueFormatter,
|
|
15
15
|
ChartAnnotations,
|
|
16
|
+
INLINE_LEGEND_ROW_HEIGHT,
|
|
16
17
|
type ChartData,
|
|
17
18
|
type ChartCommonProps,
|
|
18
19
|
type ChartHoverPayload,
|
|
19
20
|
type ChartTooltipBaseProps,
|
|
21
|
+
type ChartTooltipValue,
|
|
20
22
|
} from "../_shared/index.js";
|
|
21
23
|
|
|
22
24
|
export type BarChartData = ChartData;
|
|
@@ -29,6 +31,16 @@ export interface BarSeries {
|
|
|
29
31
|
opacity?: number;
|
|
30
32
|
/** Label shown in the inline legend. */
|
|
31
33
|
legend?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Whether this series appears in the inline legend. Defaults to true.
|
|
36
|
+
* Has no effect when `legend` is unset (no legend entry to begin with).
|
|
37
|
+
*/
|
|
38
|
+
showInLegend?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Whether this series contributes a value to the tooltip. Defaults to
|
|
41
|
+
* true. The bars are still drawn.
|
|
42
|
+
*/
|
|
43
|
+
showInTooltip?: boolean;
|
|
32
44
|
}
|
|
33
45
|
|
|
34
46
|
interface BarChartProps extends ChartCommonProps {
|
|
@@ -104,8 +116,6 @@ defineSlots<{
|
|
|
104
116
|
tooltip?(props: ChartTooltipBaseProps & { category: string }): unknown;
|
|
105
117
|
}>();
|
|
106
118
|
|
|
107
|
-
const hasInlineLegend = computed(() => allSeries.value.some((s) => s.legend));
|
|
108
|
-
|
|
109
119
|
const EMPTY_DATA: readonly number[] = [];
|
|
110
120
|
|
|
111
121
|
type ResolvedSeries = {
|
|
@@ -113,6 +123,8 @@ type ResolvedSeries = {
|
|
|
113
123
|
color?: string;
|
|
114
124
|
opacity?: number;
|
|
115
125
|
legend?: string;
|
|
126
|
+
showInLegend?: boolean;
|
|
127
|
+
showInTooltip?: boolean;
|
|
116
128
|
};
|
|
117
129
|
|
|
118
130
|
function resolveSeries(s: BarSeries): ResolvedSeries {
|
|
@@ -121,6 +133,8 @@ function resolveSeries(s: BarSeries): ResolvedSeries {
|
|
|
121
133
|
color: s.color,
|
|
122
134
|
opacity: s.opacity,
|
|
123
135
|
legend: s.legend,
|
|
136
|
+
showInLegend: s.showInLegend,
|
|
137
|
+
showInTooltip: s.showInTooltip,
|
|
124
138
|
};
|
|
125
139
|
}
|
|
126
140
|
|
|
@@ -462,12 +476,16 @@ interface InlineLegendItem {
|
|
|
462
476
|
const inlineLegendItems = computed<InlineLegendItem[]>(() => {
|
|
463
477
|
const items: InlineLegendItem[] = [];
|
|
464
478
|
allSeries.value.forEach((s, i) => {
|
|
465
|
-
if (!s.legend) return;
|
|
479
|
+
if (!s.legend || s.showInLegend === false) return;
|
|
466
480
|
items.push({ label: s.legend, color: s.color ?? defaultColor(i) });
|
|
467
481
|
});
|
|
468
482
|
return items;
|
|
469
483
|
});
|
|
470
484
|
|
|
485
|
+
const inlineLegendLabels = computed(() =>
|
|
486
|
+
inlineLegendItems.value.map((item) => item.label),
|
|
487
|
+
);
|
|
488
|
+
|
|
471
489
|
function toCsv(): string {
|
|
472
490
|
if (typeof props.csv === "function") return props.csv();
|
|
473
491
|
if (typeof props.csv === "string") return props.csv;
|
|
@@ -514,6 +532,7 @@ const {
|
|
|
514
532
|
height,
|
|
515
533
|
padding,
|
|
516
534
|
legendY,
|
|
535
|
+
inlineLegendLayout,
|
|
517
536
|
innerW,
|
|
518
537
|
innerH,
|
|
519
538
|
bounds,
|
|
@@ -525,6 +544,7 @@ const {
|
|
|
525
544
|
downloadLinkText,
|
|
526
545
|
csvHref,
|
|
527
546
|
menuFilename,
|
|
547
|
+
isFullscreen,
|
|
528
548
|
} = useChartFoundation({
|
|
529
549
|
width: () => props.width,
|
|
530
550
|
height: () => props.height,
|
|
@@ -538,7 +558,7 @@ const {
|
|
|
538
558
|
filename: () => props.filename,
|
|
539
559
|
downloadLink: () => props.downloadLink,
|
|
540
560
|
chartPadding: () => props.chartPadding,
|
|
541
|
-
|
|
561
|
+
inlineLegendLabels: () => inlineLegendLabels.value,
|
|
542
562
|
hasTooltipSlot: () => hasTooltipSlot.value,
|
|
543
563
|
getCsv: toCsv,
|
|
544
564
|
pointerToIndex,
|
|
@@ -554,14 +574,21 @@ const hoveredCategoryLabel = computed(() => {
|
|
|
554
574
|
const hoverSlotProps = computed(() => {
|
|
555
575
|
const idx = hoverIndex.value;
|
|
556
576
|
if (idx === null) return null;
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
577
|
+
const series = allSeries.value;
|
|
578
|
+
const values: ChartTooltipValue[] = [];
|
|
579
|
+
for (let i = 0; i < series.length; i++) {
|
|
580
|
+
const s = series[i];
|
|
581
|
+
if (s.showInTooltip === false) continue;
|
|
582
|
+
values.push({
|
|
561
583
|
value: Number(s.data[idx] ?? NaN),
|
|
562
584
|
color: s.color ?? defaultColor(i),
|
|
563
585
|
seriesIndex: i,
|
|
564
|
-
})
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
return {
|
|
589
|
+
index: idx,
|
|
590
|
+
category: categoryLabels.value[idx] ?? String(idx),
|
|
591
|
+
values,
|
|
565
592
|
data: props.tooltipData?.[idx] ?? null,
|
|
566
593
|
};
|
|
567
594
|
});
|
|
@@ -586,11 +613,36 @@ const hoverBand = computed(() => {
|
|
|
586
613
|
h: slotSize.value,
|
|
587
614
|
};
|
|
588
615
|
});
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Legend items joined with their wrapped pixel positions. `x` is the
|
|
619
|
+
* left edge of the indicator; `y` is the center of the row.
|
|
620
|
+
*/
|
|
621
|
+
const positionedLegendItems = computed(() => {
|
|
622
|
+
const positions = inlineLegendLayout.value.positions;
|
|
623
|
+
const pad = padding.value.left;
|
|
624
|
+
const baseY = legendY.value;
|
|
625
|
+
return inlineLegendItems.value.map((item, i) => {
|
|
626
|
+
const pos = positions[i];
|
|
627
|
+
return {
|
|
628
|
+
...item,
|
|
629
|
+
x: pad + pos.x,
|
|
630
|
+
y: baseY + pos.row * INLINE_LEGEND_ROW_HEIGHT,
|
|
631
|
+
};
|
|
632
|
+
});
|
|
633
|
+
});
|
|
589
634
|
</script>
|
|
590
635
|
|
|
591
636
|
<template>
|
|
592
|
-
<div
|
|
637
|
+
<div
|
|
638
|
+
ref="containerRef"
|
|
639
|
+
class="bar-chart-wrapper"
|
|
640
|
+
:class="{ 'is-fullscreen': isFullscreen }"
|
|
641
|
+
>
|
|
593
642
|
<ChartMenu v-if="menu" :items="menuItems" />
|
|
643
|
+
<div class="chart-sr-only" aria-live="polite">
|
|
644
|
+
{{ isFullscreen ? "Chart expanded to fill window" : "" }}
|
|
645
|
+
</div>
|
|
594
646
|
<svg ref="svgRef" :width="width" :height="height">
|
|
595
647
|
<!-- title -->
|
|
596
648
|
<text
|
|
@@ -605,18 +657,18 @@ const hoverBand = computed(() => {
|
|
|
605
657
|
{{ title }}
|
|
606
658
|
</text>
|
|
607
659
|
<!-- inline legend -->
|
|
608
|
-
<g v-if="
|
|
609
|
-
<template v-for="(item, i) in
|
|
660
|
+
<g v-if="positionedLegendItems.length > 0">
|
|
661
|
+
<template v-for="(item, i) in positionedLegendItems" :key="'ileg' + i">
|
|
610
662
|
<rect
|
|
611
|
-
:x="
|
|
612
|
-
:y="
|
|
663
|
+
:x="item.x"
|
|
664
|
+
:y="item.y - 5"
|
|
613
665
|
width="12"
|
|
614
666
|
height="10"
|
|
615
667
|
:fill="item.color"
|
|
616
668
|
/>
|
|
617
669
|
<text
|
|
618
|
-
:x="
|
|
619
|
-
:y="
|
|
670
|
+
:x="item.x + 18"
|
|
671
|
+
:y="item.y + 4"
|
|
620
672
|
font-size="11"
|
|
621
673
|
fill="currentColor"
|
|
622
674
|
>
|
|
@@ -837,10 +889,6 @@ const hoverBand = computed(() => {
|
|
|
837
889
|
width: 100%;
|
|
838
890
|
}
|
|
839
891
|
|
|
840
|
-
.bar-chart-wrapper:hover :deep(.chart-menu-button) {
|
|
841
|
-
opacity: 1;
|
|
842
|
-
}
|
|
843
|
-
|
|
844
892
|
.bar-chart-tooltip-label {
|
|
845
893
|
font-weight: 600;
|
|
846
894
|
margin-bottom: 0.25em;
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
export interface ChartMenuItem {
|
|
11
11
|
label: string;
|
|
12
12
|
action: () => void;
|
|
13
|
+
/** Sets aria-pressed on the menu item — use for toggle-style items. */
|
|
14
|
+
ariaPressed?: boolean;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
const props = withDefaults(
|
|
@@ -72,6 +74,7 @@ const useDropdown = () => props.forceDropdown || props.items.length > 1;
|
|
|
72
74
|
v-for="item in items"
|
|
73
75
|
:key="item.label"
|
|
74
76
|
class="chart-menu-item"
|
|
77
|
+
:aria-pressed="item.ariaPressed"
|
|
75
78
|
@select="item.action"
|
|
76
79
|
>
|
|
77
80
|
{{ item.label }}
|
|
@@ -21,6 +21,7 @@ import { fipsToHsa, hsaNames } from "./hsaMapping.js";
|
|
|
21
21
|
import ChartMenu from "../ChartMenu/ChartMenu.vue";
|
|
22
22
|
import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
|
|
23
23
|
import { saveSvg, savePng } from "../ChartMenu/download.js";
|
|
24
|
+
import { useChartFullscreen } from "../_shared/index.js";
|
|
24
25
|
import { placeTooltip } from "../tooltip-position.js";
|
|
25
26
|
import ChoroplethTooltip from "./ChoroplethTooltip.vue";
|
|
26
27
|
|
|
@@ -1298,9 +1299,12 @@ const gradientCss = computed(() => {
|
|
|
1298
1299
|
return `linear-gradient(to right, ${stops})`;
|
|
1299
1300
|
});
|
|
1300
1301
|
|
|
1302
|
+
const fullscreen = useChartFullscreen();
|
|
1303
|
+
|
|
1301
1304
|
const menuItems = computed<ChartMenuItem[]>(() => {
|
|
1302
1305
|
const fname = menuFilename();
|
|
1303
1306
|
return [
|
|
1307
|
+
fullscreen.menuItem.value,
|
|
1304
1308
|
{
|
|
1305
1309
|
label: "Save as SVG",
|
|
1306
1310
|
action: () => {
|
|
@@ -1360,8 +1364,17 @@ watch(
|
|
|
1360
1364
|
</script>
|
|
1361
1365
|
|
|
1362
1366
|
<template>
|
|
1363
|
-
<div
|
|
1367
|
+
<div
|
|
1368
|
+
ref="containerRef"
|
|
1369
|
+
:class="[
|
|
1370
|
+
'choropleth-wrapper',
|
|
1371
|
+
{ pannable: pan, 'is-fullscreen': fullscreen.isFullscreen.value },
|
|
1372
|
+
]"
|
|
1373
|
+
>
|
|
1364
1374
|
<ChartMenu v-if="menu" :items="menuItems" />
|
|
1375
|
+
<div class="chart-sr-only" aria-live="polite">
|
|
1376
|
+
{{ fullscreen.isFullscreen.value ? "Map expanded to fill window" : "" }}
|
|
1377
|
+
</div>
|
|
1365
1378
|
<!--
|
|
1366
1379
|
Title + legend live as an HTML overlay on top of the SVG so they keep
|
|
1367
1380
|
their intrinsic px sizes regardless of how the browser scales the
|
|
@@ -1476,10 +1489,6 @@ watch(
|
|
|
1476
1489
|
cursor: grabbing;
|
|
1477
1490
|
}
|
|
1478
1491
|
|
|
1479
|
-
.choropleth-wrapper:hover :deep(.chart-menu-button) {
|
|
1480
|
-
opacity: 1;
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
1492
|
.state-path {
|
|
1484
1493
|
cursor: pointer;
|
|
1485
1494
|
}
|
|
@@ -98,6 +98,46 @@ When `x` is omitted, `y`/`data` values are plotted at indices 0, 1, 2, etc.
|
|
|
98
98
|
</template>
|
|
99
99
|
</ComponentDemo>
|
|
100
100
|
|
|
101
|
+
### Legend
|
|
102
|
+
|
|
103
|
+
Set `legend` on a series to add an entry to the inline legend strip above the plot. When the items don't all fit on one row at the chart's current width, the legend wraps to additional rows and the plot area shrinks to keep the legend clear of the data. Use `showInLegend: false` to keep a series off the legend (e.g. when its `legend` string is reused as a CSV column header) and `showInTooltip: false` to omit it from the hover tooltip.
|
|
104
|
+
|
|
105
|
+
<ComponentDemo>
|
|
106
|
+
<LineChart
|
|
107
|
+
:series="[
|
|
108
|
+
{ data: [0, 12, 28, 45, 60, 55, 40, 25, 12], color: '#0057b7', legend: 'No interventions' },
|
|
109
|
+
{ data: [0, 10, 22, 38, 50, 45, 32, 18, 8], color: '#fb7e38', legend: 'School closures' },
|
|
110
|
+
{ data: [0, 8, 18, 30, 40, 35, 25, 14, 6], color: '#ef4444', legend: 'Masking + distancing' },
|
|
111
|
+
{ data: [0, 6, 14, 24, 32, 28, 20, 10, 4], color: '#10b981', legend: 'Vaccination only' },
|
|
112
|
+
{ data: [0, 4, 10, 16, 22, 19, 13, 7, 3], color: '#6366f1', legend: 'Vaccination + masking' },
|
|
113
|
+
{ data: [0, 3, 7, 11, 15, 13, 9, 5, 2], color: '#a855f7', legend: 'All interventions combined' },
|
|
114
|
+
]"
|
|
115
|
+
:height="240"
|
|
116
|
+
x-label="Weeks"
|
|
117
|
+
y-label="Incidence"
|
|
118
|
+
/>
|
|
119
|
+
|
|
120
|
+
<template #code>
|
|
121
|
+
|
|
122
|
+
```vue
|
|
123
|
+
<LineChart
|
|
124
|
+
:series="[
|
|
125
|
+
{ data: scenarioA, color: '#0057b7', legend: 'No interventions' },
|
|
126
|
+
{ data: scenarioB, color: '#fb7e38', legend: 'School closures' },
|
|
127
|
+
{ data: scenarioC, color: '#ef4444', legend: 'Masking + distancing' },
|
|
128
|
+
{ data: scenarioD, color: '#10b981', legend: 'Vaccination only' },
|
|
129
|
+
{ data: scenarioE, color: '#6366f1', legend: 'Vaccination + masking' },
|
|
130
|
+
{ data: scenarioF, color: '#a855f7', legend: 'All interventions combined' },
|
|
131
|
+
]"
|
|
132
|
+
:height="240"
|
|
133
|
+
x-label="Weeks"
|
|
134
|
+
y-label="Incidence"
|
|
135
|
+
/>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
</template>
|
|
139
|
+
</ComponentDemo>
|
|
140
|
+
|
|
101
141
|
### Tooltip
|
|
102
142
|
|
|
103
143
|
Hover over the chart to see a tooltip with values at each data point. Set `tooltip-trigger="hover"` to enable the built-in tooltip with crosshair and highlight dots. Use the `#tooltip` slot for custom content. Pass `tooltip-value-format` to control how numeric values render (e.g. percentages, currency); it falls back to `y-tick-format` when omitted.
|
|
@@ -248,6 +288,46 @@ floor, and `yMin <= 0` is ignored.
|
|
|
248
288
|
</template>
|
|
249
289
|
</ComponentDemo>
|
|
250
290
|
|
|
291
|
+
### Outline
|
|
292
|
+
|
|
293
|
+
Set `outline: true` on a series to draw a page-colored stroke behind the
|
|
294
|
+
line. This is useful when lines overlap, cross dense areas, or sit on a
|
|
295
|
+
busy background — the outline keeps each series visually distinct.
|
|
296
|
+
|
|
297
|
+
<ComponentDemo>
|
|
298
|
+
<LineChart
|
|
299
|
+
:series="[
|
|
300
|
+
{ data: [0, 10, 25, 45, 60, 55, 40, 20, 8], color: '#fb7e38', strokeWidth: 3, outline: true },
|
|
301
|
+
{ data: [0, 12, 22, 40, 58, 58, 42, 22, 10], color: '#0057b7', strokeWidth: 3, outline: true },
|
|
302
|
+
]"
|
|
303
|
+
:height="200"
|
|
304
|
+
/>
|
|
305
|
+
|
|
306
|
+
<template #code>
|
|
307
|
+
|
|
308
|
+
```vue
|
|
309
|
+
<LineChart
|
|
310
|
+
:series="[
|
|
311
|
+
{
|
|
312
|
+
data: [0, 10, 25, 45, 60, 55, 40, 20, 8],
|
|
313
|
+
color: '#fb7e38',
|
|
314
|
+
strokeWidth: 3,
|
|
315
|
+
outline: true,
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
data: [0, 12, 22, 40, 58, 58, 42, 22, 10],
|
|
319
|
+
color: '#0057b7',
|
|
320
|
+
strokeWidth: 3,
|
|
321
|
+
outline: true,
|
|
322
|
+
},
|
|
323
|
+
]"
|
|
324
|
+
:height="200"
|
|
325
|
+
/>
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
</template>
|
|
329
|
+
</ComponentDemo>
|
|
330
|
+
|
|
251
331
|
### Many trajectories with low opacity
|
|
252
332
|
|
|
253
333
|
<ComponentDemo>
|
|
@@ -664,6 +744,13 @@ positioned from the anchor as usual via `offset`.
|
|
|
664
744
|
</template>
|
|
665
745
|
</ComponentDemo>
|
|
666
746
|
|
|
747
|
+
### Chart menu
|
|
748
|
+
|
|
749
|
+
The chart menu (top-right) leads with Expand / Collapse and then Save as SVG,
|
|
750
|
+
Save as PNG, and Download CSV. Expand grows the chart to fill the browser
|
|
751
|
+
window so the plot has more room; press <kbd>Esc</kbd> or pick "Collapse" to
|
|
752
|
+
return to the inline size. Set `menu="false"` to hide the trigger entirely.
|
|
753
|
+
|
|
667
754
|
### Custom CSV download
|
|
668
755
|
|
|
669
756
|
By default, the Download CSV menu item exports the chart series as CSV. Use
|
|
@@ -789,6 +876,7 @@ interface Series {
|
|
|
789
876
|
strokeWidth?: number;
|
|
790
877
|
opacity?: number;
|
|
791
878
|
line?: boolean;
|
|
879
|
+
outline?: boolean; // page-colored stroke drawn behind the line
|
|
792
880
|
dots?: boolean;
|
|
793
881
|
dotRadius?: number;
|
|
794
882
|
dotFill?: string;
|
|
@@ -13,10 +13,12 @@ import {
|
|
|
13
13
|
useChartFoundation,
|
|
14
14
|
makeTooltipValueFormatter,
|
|
15
15
|
ChartAnnotations,
|
|
16
|
+
INLINE_LEGEND_ROW_HEIGHT,
|
|
16
17
|
type ChartData,
|
|
17
18
|
type ChartCommonProps,
|
|
18
19
|
type ChartHoverPayload,
|
|
19
20
|
type ChartTooltipBaseProps,
|
|
21
|
+
type ChartTooltipValue,
|
|
20
22
|
} from "../_shared/index.js";
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -48,12 +50,28 @@ export interface Series {
|
|
|
48
50
|
lineOpacity?: number;
|
|
49
51
|
dotOpacity?: number;
|
|
50
52
|
line?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Draw a page-colored stroke behind the line so it stays visually
|
|
55
|
+
* separated from overlapping series and busy backgrounds. Has no
|
|
56
|
+
* effect when `line` is `false`.
|
|
57
|
+
*/
|
|
58
|
+
outline?: boolean;
|
|
51
59
|
dots?: boolean;
|
|
52
60
|
dotRadius?: number;
|
|
53
61
|
dotFill?: string;
|
|
54
62
|
dotStroke?: string;
|
|
55
63
|
/** Label shown in the inline legend */
|
|
56
64
|
legend?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Whether this series appears in the inline legend. Defaults to true.
|
|
67
|
+
* Has no effect when `legend` is unset (no legend entry to begin with).
|
|
68
|
+
*/
|
|
69
|
+
showInLegend?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Whether this series contributes a value to the tooltip and shows a
|
|
72
|
+
* hover dot. Defaults to true. The series line/dots are still drawn.
|
|
73
|
+
*/
|
|
74
|
+
showInTooltip?: boolean;
|
|
57
75
|
}
|
|
58
76
|
|
|
59
77
|
export interface Area {
|
|
@@ -167,15 +185,6 @@ defineSlots<{
|
|
|
167
185
|
tooltip?(props: ChartTooltipBaseProps & { xLabel?: string }): unknown;
|
|
168
186
|
}>();
|
|
169
187
|
|
|
170
|
-
const hasInlineLegend = computed(
|
|
171
|
-
() =>
|
|
172
|
-
allSeries.value.some((s) => s.legend) ||
|
|
173
|
-
props.areaSections?.some(
|
|
174
|
-
(s) => s.legend === "inline" && (s.label || s.description),
|
|
175
|
-
) ||
|
|
176
|
-
false,
|
|
177
|
-
);
|
|
178
|
-
|
|
179
188
|
/**
|
|
180
189
|
* Internal series shape where `data` (y-values) is always resolved.
|
|
181
190
|
* `Series.y` takes precedence over `Series.data` when both are set.
|
|
@@ -503,7 +512,7 @@ interface InlineLegendItem {
|
|
|
503
512
|
const inlineLegendItems = computed<InlineLegendItem[]>(() => {
|
|
504
513
|
const items: InlineLegendItem[] = [];
|
|
505
514
|
for (const s of allSeries.value) {
|
|
506
|
-
if (!s.legend) continue;
|
|
515
|
+
if (!s.legend || s.showInLegend === false) continue;
|
|
507
516
|
items.push({
|
|
508
517
|
label: s.legend,
|
|
509
518
|
color: s.color ?? "currentColor",
|
|
@@ -533,6 +542,10 @@ const inlineLegendItems = computed<InlineLegendItem[]>(() => {
|
|
|
533
542
|
return items;
|
|
534
543
|
});
|
|
535
544
|
|
|
545
|
+
const inlineLegendLabels = computed(() =>
|
|
546
|
+
inlineLegendItems.value.map((item) => item.label),
|
|
547
|
+
);
|
|
548
|
+
|
|
536
549
|
const totalHeight = computed(
|
|
537
550
|
() => height.value + sectionLabels.value.extraHeight,
|
|
538
551
|
);
|
|
@@ -675,6 +688,7 @@ const hoverDots = computed(() => {
|
|
|
675
688
|
if (targetX === null) return [];
|
|
676
689
|
const dots: { x: number; y: number; color: string }[] = [];
|
|
677
690
|
for (const s of allSeries.value) {
|
|
691
|
+
if (s.showInTooltip === false) continue;
|
|
678
692
|
const nIdx = nearestIndex(s, targetX);
|
|
679
693
|
if (nIdx === null) continue;
|
|
680
694
|
const yv = s.data[nIdx];
|
|
@@ -704,17 +718,22 @@ const hoverSlotProps = computed(() => {
|
|
|
704
718
|
} else {
|
|
705
719
|
xLabel = formatTick(displayX);
|
|
706
720
|
}
|
|
721
|
+
const series = allSeries.value;
|
|
722
|
+
const values: ChartTooltipValue[] = [];
|
|
723
|
+
for (let i = 0; i < series.length; i++) {
|
|
724
|
+
const s = series[i];
|
|
725
|
+
if (s.showInTooltip === false) continue;
|
|
726
|
+
const nIdx = nearestIndex(s, targetX);
|
|
727
|
+
values.push({
|
|
728
|
+
value: nIdx !== null ? Number(s.data[nIdx]) : NaN,
|
|
729
|
+
color: s.color ?? "currentColor",
|
|
730
|
+
seriesIndex: i,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
707
733
|
return {
|
|
708
734
|
index: idx,
|
|
709
735
|
xLabel,
|
|
710
|
-
values
|
|
711
|
-
const nIdx = nearestIndex(s, targetX);
|
|
712
|
-
return {
|
|
713
|
-
value: nIdx !== null ? Number(s.data[nIdx]) : NaN,
|
|
714
|
-
color: s.color ?? "currentColor",
|
|
715
|
-
seriesIndex: i,
|
|
716
|
-
};
|
|
717
|
-
}),
|
|
736
|
+
values,
|
|
718
737
|
data: props.tooltipData?.[idx] ?? null,
|
|
719
738
|
};
|
|
720
739
|
});
|
|
@@ -747,6 +766,7 @@ const {
|
|
|
747
766
|
height,
|
|
748
767
|
padding,
|
|
749
768
|
legendY,
|
|
769
|
+
inlineLegendLayout,
|
|
750
770
|
innerW,
|
|
751
771
|
innerH,
|
|
752
772
|
bounds,
|
|
@@ -758,6 +778,7 @@ const {
|
|
|
758
778
|
downloadLinkText,
|
|
759
779
|
csvHref,
|
|
760
780
|
menuFilename,
|
|
781
|
+
isFullscreen,
|
|
761
782
|
} = useChartFoundation({
|
|
762
783
|
width: () => props.width,
|
|
763
784
|
height: () => props.height,
|
|
@@ -771,17 +792,43 @@ const {
|
|
|
771
792
|
filename: () => props.filename,
|
|
772
793
|
downloadLink: () => props.downloadLink,
|
|
773
794
|
chartPadding: () => props.chartPadding,
|
|
774
|
-
|
|
795
|
+
inlineLegendLabels: () => inlineLegendLabels.value,
|
|
775
796
|
hasTooltipSlot: () => hasTooltipSlot.value,
|
|
776
797
|
getCsv: toCsv,
|
|
777
798
|
pointerToIndex: indexFromPointer,
|
|
778
799
|
onHover: (payload) => emit("hover", payload),
|
|
800
|
+
extraBelowHeight: () => sectionLabels.value.extraHeight,
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Legend items joined with their wrapped pixel positions. `x` is the
|
|
805
|
+
* left edge of the indicator; `y` is the center of the row.
|
|
806
|
+
*/
|
|
807
|
+
const positionedLegendItems = computed(() => {
|
|
808
|
+
const positions = inlineLegendLayout.value.positions;
|
|
809
|
+
const pad = padding.value.left;
|
|
810
|
+
const baseY = legendY.value;
|
|
811
|
+
return inlineLegendItems.value.map((item, i) => {
|
|
812
|
+
const pos = positions[i];
|
|
813
|
+
return {
|
|
814
|
+
...item,
|
|
815
|
+
x: pad + pos.x,
|
|
816
|
+
y: baseY + pos.row * INLINE_LEGEND_ROW_HEIGHT,
|
|
817
|
+
};
|
|
818
|
+
});
|
|
779
819
|
});
|
|
780
820
|
</script>
|
|
781
821
|
|
|
782
822
|
<template>
|
|
783
|
-
<div
|
|
823
|
+
<div
|
|
824
|
+
ref="containerRef"
|
|
825
|
+
class="line-chart-wrapper"
|
|
826
|
+
:class="{ 'is-fullscreen': isFullscreen }"
|
|
827
|
+
>
|
|
784
828
|
<ChartMenu v-if="menu" :items="menuItems" />
|
|
829
|
+
<div class="chart-sr-only" aria-live="polite">
|
|
830
|
+
{{ isFullscreen ? "Chart expanded to fill window" : "" }}
|
|
831
|
+
</div>
|
|
785
832
|
<svg ref="svgRef" :width="width" :height="totalHeight">
|
|
786
833
|
<!-- title -->
|
|
787
834
|
<text
|
|
@@ -796,15 +843,15 @@ const {
|
|
|
796
843
|
{{ title }}
|
|
797
844
|
</text>
|
|
798
845
|
<!-- inline legend -->
|
|
799
|
-
<g v-if="
|
|
800
|
-
<template v-for="(item, i) in
|
|
846
|
+
<g v-if="positionedLegendItems.length > 0">
|
|
847
|
+
<template v-for="(item, i) in positionedLegendItems" :key="'ileg' + i">
|
|
801
848
|
<!-- series indicator: line -->
|
|
802
849
|
<line
|
|
803
850
|
v-if="item.type === 'series'"
|
|
804
|
-
:x1="
|
|
805
|
-
:y1="
|
|
806
|
-
:x2="
|
|
807
|
-
:y2="
|
|
851
|
+
:x1="item.x"
|
|
852
|
+
:y1="item.y"
|
|
853
|
+
:x2="item.x + 12"
|
|
854
|
+
:y2="item.y"
|
|
808
855
|
:stroke="item.color"
|
|
809
856
|
stroke-width="2"
|
|
810
857
|
:stroke-dasharray="item.dashed ? '4 2' : undefined"
|
|
@@ -812,8 +859,8 @@ const {
|
|
|
812
859
|
<!-- section indicator: filled circle -->
|
|
813
860
|
<circle
|
|
814
861
|
v-else
|
|
815
|
-
:cx="
|
|
816
|
-
:cy="
|
|
862
|
+
:cx="item.x + 4"
|
|
863
|
+
:cy="item.y"
|
|
817
864
|
r="4"
|
|
818
865
|
:fill="item.color"
|
|
819
866
|
:fill-opacity="item.fillOpacity"
|
|
@@ -821,8 +868,8 @@ const {
|
|
|
821
868
|
stroke-width="1.5"
|
|
822
869
|
/>
|
|
823
870
|
<text
|
|
824
|
-
:x="
|
|
825
|
-
:y="
|
|
871
|
+
:x="item.x + 18"
|
|
872
|
+
:y="item.y + 4"
|
|
826
873
|
font-size="11"
|
|
827
874
|
fill="currentColor"
|
|
828
875
|
>
|
|
@@ -936,6 +983,16 @@ const {
|
|
|
936
983
|
/>
|
|
937
984
|
<!-- data lines and dots -->
|
|
938
985
|
<template v-for="(s, i) in allSeries" :key="i">
|
|
986
|
+
<path
|
|
987
|
+
v-if="s.line !== false && s.outline"
|
|
988
|
+
:d="toPath(s)"
|
|
989
|
+
fill="none"
|
|
990
|
+
stroke="var(--color-bg-0, #fff)"
|
|
991
|
+
:stroke-width="(s.strokeWidth ?? 1.5) + 4"
|
|
992
|
+
stroke-linecap="round"
|
|
993
|
+
stroke-linejoin="round"
|
|
994
|
+
data-testid="line-outline"
|
|
995
|
+
/>
|
|
939
996
|
<path
|
|
940
997
|
v-if="s.line !== false"
|
|
941
998
|
:d="toPath(s)"
|
|
@@ -1147,10 +1204,6 @@ const {
|
|
|
1147
1204
|
width: 100%;
|
|
1148
1205
|
}
|
|
1149
1206
|
|
|
1150
|
-
.line-chart-wrapper:hover :deep(.chart-menu-button) {
|
|
1151
|
-
opacity: 1;
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
1207
|
.line-chart-tooltip-label {
|
|
1155
1208
|
font-weight: 600;
|
|
1156
1209
|
margin-bottom: 0.25em;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/* Shared chart styles for the Expand / fullscreen menu item.
|
|
2
|
+
Bundled into @cfasim-ui/charts/style.css via index.ts so it ships
|
|
3
|
+
once for the whole package instead of repeating in every chart SFC. */
|
|
4
|
+
|
|
5
|
+
.line-chart-wrapper:hover .chart-menu-button,
|
|
6
|
+
.bar-chart-wrapper:hover .chart-menu-button,
|
|
7
|
+
.choropleth-wrapper:hover .chart-menu-button,
|
|
8
|
+
.line-chart-wrapper:focus-within .chart-menu-button,
|
|
9
|
+
.bar-chart-wrapper:focus-within .chart-menu-button,
|
|
10
|
+
.choropleth-wrapper:focus-within .chart-menu-button,
|
|
11
|
+
.line-chart-wrapper.is-fullscreen .chart-menu-button,
|
|
12
|
+
.bar-chart-wrapper.is-fullscreen .chart-menu-button,
|
|
13
|
+
.choropleth-wrapper.is-fullscreen .chart-menu-button {
|
|
14
|
+
opacity: 1;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.line-chart-wrapper.is-fullscreen,
|
|
18
|
+
.bar-chart-wrapper.is-fullscreen,
|
|
19
|
+
.choropleth-wrapper.is-fullscreen {
|
|
20
|
+
position: fixed;
|
|
21
|
+
inset: 0;
|
|
22
|
+
z-index: var(--cfasim-z-fullscreen, 1000);
|
|
23
|
+
background: var(--color-bg-0, #fff);
|
|
24
|
+
color: var(--color-text, inherit);
|
|
25
|
+
padding: 2em;
|
|
26
|
+
box-sizing: border-box;
|
|
27
|
+
display: flex;
|
|
28
|
+
flex-direction: column;
|
|
29
|
+
justify-content: center;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* ChoroplethMap doesn't go through useChartFoundation, so its SVG keeps
|
|
33
|
+
its prop-driven width/height. Stretch it to fill the expanded box. */
|
|
34
|
+
.choropleth-wrapper.is-fullscreen svg {
|
|
35
|
+
flex: 1 1 auto;
|
|
36
|
+
min-height: 0;
|
|
37
|
+
height: 100%;
|
|
38
|
+
width: 100%;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.chart-sr-only {
|
|
42
|
+
position: absolute;
|
|
43
|
+
width: 1px;
|
|
44
|
+
height: 1px;
|
|
45
|
+
padding: 0;
|
|
46
|
+
margin: -1px;
|
|
47
|
+
overflow: hidden;
|
|
48
|
+
clip: rect(0, 0, 0, 0);
|
|
49
|
+
white-space: nowrap;
|
|
50
|
+
border: 0;
|
|
51
|
+
}
|
package/charts/_shared/index.ts
CHANGED
|
@@ -16,16 +16,18 @@ export {
|
|
|
16
16
|
export { useChartSize, type ChartSizeOptions } from "./useChartSize.js";
|
|
17
17
|
export {
|
|
18
18
|
useChartPadding,
|
|
19
|
-
|
|
19
|
+
INLINE_LEGEND_ROW_HEIGHT,
|
|
20
20
|
type ChartPaddingOptions,
|
|
21
21
|
type ChartPadding,
|
|
22
22
|
type ChartBounds,
|
|
23
|
+
type PositionedLegendItem,
|
|
23
24
|
} from "./useChartPadding.js";
|
|
24
25
|
export {
|
|
25
26
|
useChartTooltip,
|
|
26
27
|
type ChartTooltipOptions,
|
|
27
28
|
} from "./useChartTooltip.js";
|
|
28
29
|
export { useChartMenu, type ChartMenuOptions } from "./useChartMenu.js";
|
|
30
|
+
export { useChartFullscreen } from "./useChartFullscreen.js";
|
|
29
31
|
export { seriesToCsv, categoricalToCsv, type CsvSeries } from "./seriesCsv.js";
|
|
30
32
|
export { default as ChartAnnotations } from "./ChartAnnotations.vue";
|
|
31
33
|
export type { ChartAnnotation } from "./annotations.js";
|
|
@@ -25,11 +25,22 @@ export interface ChartFoundationOptions {
|
|
|
25
25
|
downloadLink: () => boolean | string | undefined;
|
|
26
26
|
chartPadding: () => ChartPadding | undefined;
|
|
27
27
|
// Chart-specific hooks that the composable can't infer.
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Labels for the inline legend in render order. Empty array = no
|
|
30
|
+
* legend strip. Drives wrapping into multiple rows and the resulting
|
|
31
|
+
* top-padding reservation.
|
|
32
|
+
*/
|
|
33
|
+
inlineLegendLabels: () => readonly string[];
|
|
29
34
|
hasTooltipSlot: () => boolean;
|
|
30
35
|
getCsv: () => string;
|
|
31
36
|
pointerToIndex: (clientX: number, clientY: number) => number | null;
|
|
32
37
|
onHover: (payload: { index: number } | null) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Extra height (in px) the chart adds *below* the SVG plot area
|
|
40
|
+
* (e.g. LineChart's area-section labels). Used to keep the SVG total
|
|
41
|
+
* height matched to the container when fullscreen.
|
|
42
|
+
*/
|
|
43
|
+
extraBelowHeight?: () => number;
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
/**
|
|
@@ -39,25 +50,51 @@ export interface ChartFoundationOptions {
|
|
|
39
50
|
* consumes.
|
|
40
51
|
*/
|
|
41
52
|
export function useChartFoundation(opts: ChartFoundationOptions) {
|
|
42
|
-
const { containerRef, measuredWidth } = useChartSize({
|
|
53
|
+
const { containerRef, measuredWidth, measuredHeight } = useChartSize({
|
|
43
54
|
debounce: opts.debounce,
|
|
44
55
|
});
|
|
45
56
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
const {
|
|
58
|
+
svgRef,
|
|
59
|
+
items: menuItems,
|
|
60
|
+
downloadLinkText,
|
|
61
|
+
csvHref,
|
|
62
|
+
resolvedFilename: menuFilename,
|
|
63
|
+
isFullscreen,
|
|
64
|
+
} = useChartMenu({
|
|
65
|
+
filename: opts.filename,
|
|
66
|
+
legacyMenuLabel: opts.menu,
|
|
67
|
+
getCsv: opts.getCsv,
|
|
68
|
+
downloadLink: opts.downloadLink,
|
|
69
|
+
fullscreen: true,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const width = computed(() => {
|
|
73
|
+
if (isFullscreen.value && measuredWidth.value > 0) {
|
|
74
|
+
return measuredWidth.value;
|
|
75
|
+
}
|
|
76
|
+
return opts.width() ?? (measuredWidth.value || DEFAULT_WIDTH_FALLBACK);
|
|
77
|
+
});
|
|
50
78
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
height: () => height.value,
|
|
58
|
-
extraPadding: opts.chartPadding,
|
|
79
|
+
const height = computed(() => {
|
|
80
|
+
if (isFullscreen.value && measuredHeight.value > 0) {
|
|
81
|
+
const extra = opts.extraBelowHeight?.() ?? 0;
|
|
82
|
+
return measuredHeight.value - extra;
|
|
83
|
+
}
|
|
84
|
+
return opts.height() ?? DEFAULT_HEIGHT;
|
|
59
85
|
});
|
|
60
86
|
|
|
87
|
+
const { padding, legendY, inlineLegendLayout, innerW, innerH, bounds } =
|
|
88
|
+
useChartPadding({
|
|
89
|
+
title: opts.title,
|
|
90
|
+
xLabel: opts.xLabel,
|
|
91
|
+
yLabel: opts.yLabel,
|
|
92
|
+
inlineLegendLabels: opts.inlineLegendLabels,
|
|
93
|
+
width: () => width.value,
|
|
94
|
+
height: () => height.value,
|
|
95
|
+
extraPadding: opts.chartPadding,
|
|
96
|
+
});
|
|
97
|
+
|
|
61
98
|
const {
|
|
62
99
|
hoverIndex,
|
|
63
100
|
tooltipRef,
|
|
@@ -72,19 +109,6 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
|
|
|
72
109
|
onHover: opts.onHover,
|
|
73
110
|
});
|
|
74
111
|
|
|
75
|
-
const {
|
|
76
|
-
svgRef,
|
|
77
|
-
items: menuItems,
|
|
78
|
-
downloadLinkText,
|
|
79
|
-
csvHref,
|
|
80
|
-
resolvedFilename: menuFilename,
|
|
81
|
-
} = useChartMenu({
|
|
82
|
-
filename: opts.filename,
|
|
83
|
-
legacyMenuLabel: opts.menu,
|
|
84
|
-
getCsv: opts.getCsv,
|
|
85
|
-
downloadLink: opts.downloadLink,
|
|
86
|
-
});
|
|
87
|
-
|
|
88
112
|
return {
|
|
89
113
|
containerRef,
|
|
90
114
|
svgRef,
|
|
@@ -92,6 +116,7 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
|
|
|
92
116
|
height,
|
|
93
117
|
padding,
|
|
94
118
|
legendY,
|
|
119
|
+
inlineLegendLayout,
|
|
95
120
|
innerW,
|
|
96
121
|
innerH,
|
|
97
122
|
bounds,
|
|
@@ -103,6 +128,8 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
|
|
|
103
128
|
downloadLinkText,
|
|
104
129
|
csvHref,
|
|
105
130
|
menuFilename,
|
|
131
|
+
isFullscreen,
|
|
132
|
+
measuredHeight,
|
|
106
133
|
};
|
|
107
134
|
}
|
|
108
135
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { computed, onMounted, onUnmounted, ref } from "vue";
|
|
2
|
+
import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Module-level refcount so multiple expanded charts on the same page
|
|
6
|
+
* share one body-scroll lock — the last one to collapse restores the
|
|
7
|
+
* original `overflow` value.
|
|
8
|
+
*/
|
|
9
|
+
let bodyLockCount = 0;
|
|
10
|
+
let savedBodyOverflow = "";
|
|
11
|
+
|
|
12
|
+
function lockBodyScroll() {
|
|
13
|
+
if (typeof document === "undefined") return;
|
|
14
|
+
if (bodyLockCount === 0) {
|
|
15
|
+
savedBodyOverflow = document.body.style.overflow;
|
|
16
|
+
document.body.style.overflow = "hidden";
|
|
17
|
+
}
|
|
18
|
+
bodyLockCount++;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function unlockBodyScroll() {
|
|
22
|
+
if (typeof document === "undefined") return;
|
|
23
|
+
if (bodyLockCount === 0) return;
|
|
24
|
+
bodyLockCount--;
|
|
25
|
+
if (bodyLockCount === 0) {
|
|
26
|
+
document.body.style.overflow = savedBodyOverflow;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Tracks whether the chart is in "expanded" mode (fills the window via
|
|
32
|
+
* CSS). The browser Fullscreen API isn't used — the consumer wires a
|
|
33
|
+
* `.is-fullscreen` class to its wrapper and the CSS handles positioning,
|
|
34
|
+
* which avoids Fullscreen API restrictions (user-gesture requirement,
|
|
35
|
+
* permission policy, iframe sandboxing) and keeps the chart inside the
|
|
36
|
+
* document so the rest of the page remains keyboard-navigable.
|
|
37
|
+
*/
|
|
38
|
+
export function useChartFullscreen() {
|
|
39
|
+
const isFullscreen = ref(false);
|
|
40
|
+
let locked = false;
|
|
41
|
+
|
|
42
|
+
function setExpanded(value: boolean) {
|
|
43
|
+
if (value === isFullscreen.value) return;
|
|
44
|
+
isFullscreen.value = value;
|
|
45
|
+
if (value && !locked) {
|
|
46
|
+
lockBodyScroll();
|
|
47
|
+
locked = true;
|
|
48
|
+
} else if (!value && locked) {
|
|
49
|
+
unlockBodyScroll();
|
|
50
|
+
locked = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function onKey(e: KeyboardEvent) {
|
|
55
|
+
if (e.key !== "Escape" || !isFullscreen.value) return;
|
|
56
|
+
// Skip when Esc is closing a menu/dialog layered on top of us so we
|
|
57
|
+
// don't collapse the chart at the same time. Reka's dropdown menu
|
|
58
|
+
// uses a document-level Esc listener and doesn't call preventDefault,
|
|
59
|
+
// so checking the event target is the most reliable signal.
|
|
60
|
+
const t = e.target;
|
|
61
|
+
if (t instanceof Element && t.closest('[role="menu"], [role="dialog"]')) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
setExpanded(false);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function enter() {
|
|
68
|
+
setExpanded(true);
|
|
69
|
+
}
|
|
70
|
+
function exit() {
|
|
71
|
+
setExpanded(false);
|
|
72
|
+
}
|
|
73
|
+
function toggle() {
|
|
74
|
+
setExpanded(!isFullscreen.value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
onMounted(() => {
|
|
78
|
+
if (typeof document === "undefined") return;
|
|
79
|
+
document.addEventListener("keydown", onKey);
|
|
80
|
+
});
|
|
81
|
+
onUnmounted(() => {
|
|
82
|
+
if (typeof document === "undefined") return;
|
|
83
|
+
document.removeEventListener("keydown", onKey);
|
|
84
|
+
// Component torn down while expanded — release the lock so the body
|
|
85
|
+
// doesn't stay frozen.
|
|
86
|
+
if (locked) {
|
|
87
|
+
unlockBodyScroll();
|
|
88
|
+
locked = false;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Returns a reactive ChartMenuItem ref that flips between Expand and
|
|
94
|
+
* Collapse — shared by useChartMenu and any chart (e.g. ChoroplethMap)
|
|
95
|
+
* that builds its menu list directly.
|
|
96
|
+
*/
|
|
97
|
+
const menuItem = computed<ChartMenuItem>(() => ({
|
|
98
|
+
label: isFullscreen.value ? "Collapse" : "Expand",
|
|
99
|
+
action: toggle,
|
|
100
|
+
ariaPressed: isFullscreen.value,
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
return { isFullscreen, toggle, enter, exit, menuItem };
|
|
104
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { computed, ref, type Ref } from "vue";
|
|
2
2
|
import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
|
|
3
3
|
import { saveSvg, savePng, downloadCsv } from "../ChartMenu/download.js";
|
|
4
|
+
import { useChartFullscreen } from "./useChartFullscreen.js";
|
|
4
5
|
|
|
5
6
|
export interface ChartMenuOptions {
|
|
6
7
|
filename: () => string | undefined;
|
|
@@ -10,11 +11,18 @@ export interface ChartMenuOptions {
|
|
|
10
11
|
getCsv: () => string;
|
|
11
12
|
/** Whether a separate download link is rendered (and the CSV menu item should be hidden). */
|
|
12
13
|
downloadLink: () => boolean | string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* When true, prepends an Expand/Collapse menu item that toggles the
|
|
16
|
+
* chart into a full-window view. The consumer is responsible for
|
|
17
|
+
* binding the returned `isFullscreen` ref to a CSS class on its
|
|
18
|
+
* wrapper.
|
|
19
|
+
*/
|
|
20
|
+
fullscreen?: boolean;
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
/**
|
|
16
|
-
* Computes the standard chart menu items (SVG / PNG / CSV) plus
|
|
17
|
-
* CSV-download-link state shared by every chart.
|
|
24
|
+
* Computes the standard chart menu items (Expand / SVG / PNG / CSV) plus
|
|
25
|
+
* the CSV-download-link state shared by every chart.
|
|
18
26
|
*/
|
|
19
27
|
export function useChartMenu(opts: ChartMenuOptions) {
|
|
20
28
|
const svgRef = ref<SVGSVGElement | null>(null);
|
|
@@ -26,9 +34,15 @@ export function useChartMenu(opts: ChartMenuOptions) {
|
|
|
26
34
|
return typeof menu === "string" ? menu : "chart";
|
|
27
35
|
}
|
|
28
36
|
|
|
37
|
+
const fullscreen = opts.fullscreen ? useChartFullscreen() : null;
|
|
38
|
+
|
|
29
39
|
const items = computed<ChartMenuItem[]>(() => {
|
|
30
40
|
const fname = resolvedFilename();
|
|
31
|
-
const out: ChartMenuItem[] = [
|
|
41
|
+
const out: ChartMenuItem[] = [];
|
|
42
|
+
if (fullscreen) {
|
|
43
|
+
out.push(fullscreen.menuItem.value);
|
|
44
|
+
}
|
|
45
|
+
out.push(
|
|
32
46
|
{
|
|
33
47
|
label: "Save as SVG",
|
|
34
48
|
action: () => {
|
|
@@ -41,7 +55,7 @@ export function useChartMenu(opts: ChartMenuOptions) {
|
|
|
41
55
|
if (svgRef.value) savePng(svgRef.value, fname);
|
|
42
56
|
},
|
|
43
57
|
},
|
|
44
|
-
|
|
58
|
+
);
|
|
45
59
|
if (!opts.downloadLink()) {
|
|
46
60
|
out.push({
|
|
47
61
|
label: "Download CSV",
|
|
@@ -68,5 +82,6 @@ export function useChartMenu(opts: ChartMenuOptions) {
|
|
|
68
82
|
downloadLinkText,
|
|
69
83
|
csvHref,
|
|
70
84
|
resolvedFilename,
|
|
85
|
+
isFullscreen: fullscreen?.isFullscreen ?? ref(false),
|
|
71
86
|
};
|
|
72
87
|
}
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { computed } from "vue";
|
|
2
2
|
|
|
3
|
-
/** Vertical space reserved
|
|
4
|
-
export const
|
|
3
|
+
/** Vertical space reserved per row of the inline legend strip. */
|
|
4
|
+
export const INLINE_LEGEND_ROW_HEIGHT = 20;
|
|
5
|
+
/**
|
|
6
|
+
* Estimated character width at font-size 11 used by inline-legend
|
|
7
|
+
* measurement. Matches the value used by LineChart's area-section labels
|
|
8
|
+
* so wrapping behaves consistently across the chart's text overlays.
|
|
9
|
+
*/
|
|
10
|
+
const LEGEND_CHAR_WIDTH = 7;
|
|
11
|
+
/** X offset of legend text from the start of its row slot. */
|
|
12
|
+
const LEGEND_TEXT_INDENT = 18;
|
|
13
|
+
/** Horizontal gap between adjacent legend items on the same row. */
|
|
14
|
+
const LEGEND_ITEM_GAP = 16;
|
|
5
15
|
|
|
6
16
|
/**
|
|
7
17
|
* Extra space added around the chart's standard layout. A number applies
|
|
@@ -22,13 +32,26 @@ export interface ChartPaddingOptions {
|
|
|
22
32
|
title: () => string | undefined;
|
|
23
33
|
xLabel: () => string | undefined;
|
|
24
34
|
yLabel: () => string | undefined;
|
|
25
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Labels for the inline legend items, in render order. Empty array
|
|
37
|
+
* (or omitted) means no legend strip. Drives both row-count for
|
|
38
|
+
* reserving top padding and per-item wrap positions.
|
|
39
|
+
*/
|
|
40
|
+
inlineLegendLabels: () => readonly string[];
|
|
26
41
|
width: () => number;
|
|
27
42
|
height: () => number;
|
|
28
43
|
/** Extra pixels added on top of the standard axis spacing. */
|
|
29
44
|
extraPadding?: () => ChartPadding | undefined;
|
|
30
45
|
}
|
|
31
46
|
|
|
47
|
+
/** Position of a single legend item within the legend strip. */
|
|
48
|
+
export interface PositionedLegendItem {
|
|
49
|
+
/** X offset relative to `padding.left`. */
|
|
50
|
+
x: number;
|
|
51
|
+
/** Row index, 0-based. */
|
|
52
|
+
row: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
32
55
|
function resolvePadding(p: ChartPadding | undefined) {
|
|
33
56
|
if (p == null) return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
34
57
|
if (typeof p === "number") return { top: p, right: p, bottom: p, left: p };
|
|
@@ -40,6 +63,14 @@ function resolvePadding(p: ChartPadding | undefined) {
|
|
|
40
63
|
};
|
|
41
64
|
}
|
|
42
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Estimated rendered width (in px) of a single legend item — indicator,
|
|
68
|
+
* text, and surrounding spacing. Used to flow items into rows.
|
|
69
|
+
*/
|
|
70
|
+
function estimateLegendItemWidth(label: string): number {
|
|
71
|
+
return LEGEND_TEXT_INDENT + label.length * LEGEND_CHAR_WIDTH;
|
|
72
|
+
}
|
|
73
|
+
|
|
43
74
|
/**
|
|
44
75
|
* Computes the standard chart padding (top/right/bottom/left) and the
|
|
45
76
|
* derived inner plotting region (innerW, innerH). Shared by LineChart
|
|
@@ -47,30 +78,78 @@ function resolvePadding(p: ChartPadding | undefined) {
|
|
|
47
78
|
* consistent. `extraPadding` adds extra space outside the plot — useful
|
|
48
79
|
* for annotations that need to extend past the data area without
|
|
49
80
|
* clipping.
|
|
81
|
+
*
|
|
82
|
+
* The inline legend wraps to multiple rows when items don't fit in the
|
|
83
|
+
* available width; `padding.top` grows by `INLINE_LEGEND_ROW_HEIGHT` per
|
|
84
|
+
* row so the plot stays clear of the legend strip.
|
|
50
85
|
*/
|
|
51
86
|
export function useChartPadding(opts: ChartPaddingOptions) {
|
|
87
|
+
// Horizontal padding is computed first because `innerW` depends only
|
|
88
|
+
// on left/right (not on legend row count). Splitting padding this way
|
|
89
|
+
// keeps the legend layout — which depends on `innerW` — out of the
|
|
90
|
+
// reactivity cycle for left/right.
|
|
91
|
+
const horizontalPadding = computed(() => {
|
|
92
|
+
const extra = resolvePadding(opts.extraPadding?.());
|
|
93
|
+
return {
|
|
94
|
+
left: (opts.yLabel() ? 56 : 50) + extra.left,
|
|
95
|
+
right: 10 + extra.right,
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const innerW = computed(
|
|
100
|
+
() =>
|
|
101
|
+
opts.width() -
|
|
102
|
+
horizontalPadding.value.left -
|
|
103
|
+
horizontalPadding.value.right,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const inlineLegendLayout = computed<{
|
|
107
|
+
positions: PositionedLegendItem[];
|
|
108
|
+
rowCount: number;
|
|
109
|
+
}>(() => {
|
|
110
|
+
const labels = opts.inlineLegendLabels();
|
|
111
|
+
if (labels.length === 0) return { positions: [], rowCount: 0 };
|
|
112
|
+
const available = Math.max(0, innerW.value);
|
|
113
|
+
const positions: PositionedLegendItem[] = [];
|
|
114
|
+
let row = 0;
|
|
115
|
+
let x = 0;
|
|
116
|
+
for (const label of labels) {
|
|
117
|
+
const w = estimateLegendItemWidth(label);
|
|
118
|
+
// Wrap to a new row when the item won't fit, except when we're
|
|
119
|
+
// already at row-start (`x === 0`): a single oversized label gets
|
|
120
|
+
// its own row instead of triggering an infinite-wrap loop.
|
|
121
|
+
if (x > 0 && x + w > available) {
|
|
122
|
+
row++;
|
|
123
|
+
x = 0;
|
|
124
|
+
}
|
|
125
|
+
positions.push({ x, row });
|
|
126
|
+
x += w + LEGEND_ITEM_GAP;
|
|
127
|
+
}
|
|
128
|
+
return { positions, rowCount: row + 1 };
|
|
129
|
+
});
|
|
130
|
+
|
|
52
131
|
const padding = computed(() => {
|
|
53
132
|
const extra = resolvePadding(opts.extraPadding?.());
|
|
133
|
+
const rowCount = inlineLegendLayout.value.rowCount;
|
|
54
134
|
return {
|
|
55
135
|
top:
|
|
56
136
|
(opts.title() ? 26 : 10) +
|
|
57
|
-
|
|
137
|
+
rowCount * INLINE_LEGEND_ROW_HEIGHT +
|
|
58
138
|
extra.top,
|
|
59
|
-
right: 10 + extra.right,
|
|
60
139
|
bottom: (opts.xLabel() ? 38 : 30) + extra.bottom,
|
|
61
|
-
left:
|
|
140
|
+
left: horizontalPadding.value.left,
|
|
141
|
+
right: horizontalPadding.value.right,
|
|
62
142
|
};
|
|
63
143
|
});
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
144
|
+
|
|
145
|
+
// Y-center of the first inline-legend row. Sits at the top of the
|
|
146
|
+
// padding band (just below any title), so any user-supplied
|
|
147
|
+
// `extraPadding.top` becomes empty room between the legend and the
|
|
148
|
+
// plot. Subsequent rows are offset by `INLINE_LEGEND_ROW_HEIGHT`.
|
|
68
149
|
const legendY = computed(
|
|
69
|
-
() => (opts.title() ? 26 : 10) +
|
|
70
|
-
);
|
|
71
|
-
const innerW = computed(
|
|
72
|
-
() => opts.width() - padding.value.left - padding.value.right,
|
|
150
|
+
() => (opts.title() ? 26 : 10) + INLINE_LEGEND_ROW_HEIGHT / 2,
|
|
73
151
|
);
|
|
152
|
+
|
|
74
153
|
const innerH = computed(
|
|
75
154
|
() => opts.height() - padding.value.top - padding.value.bottom,
|
|
76
155
|
);
|
|
@@ -88,7 +167,14 @@ export function useChartPadding(opts: ChartPaddingOptions) {
|
|
|
88
167
|
height: innerH.value,
|
|
89
168
|
};
|
|
90
169
|
});
|
|
91
|
-
return {
|
|
170
|
+
return {
|
|
171
|
+
padding,
|
|
172
|
+
legendY,
|
|
173
|
+
inlineLegendLayout,
|
|
174
|
+
innerW,
|
|
175
|
+
innerH,
|
|
176
|
+
bounds,
|
|
177
|
+
};
|
|
92
178
|
}
|
|
93
179
|
|
|
94
180
|
export type ChartBounds = {
|
|
@@ -15,23 +15,27 @@ export interface ChartSizeOptions {
|
|
|
15
15
|
export function useChartSize(opts: ChartSizeOptions = {}) {
|
|
16
16
|
const containerRef = ref<HTMLElement | null>(null);
|
|
17
17
|
const measuredWidth = ref(0);
|
|
18
|
+
const measuredHeight = ref(0);
|
|
18
19
|
let observer: ResizeObserver | null = null;
|
|
19
20
|
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
20
21
|
|
|
21
22
|
onMounted(() => {
|
|
22
23
|
if (!containerRef.value) return;
|
|
23
24
|
measuredWidth.value = containerRef.value.clientWidth;
|
|
25
|
+
measuredHeight.value = containerRef.value.clientHeight;
|
|
24
26
|
observer = new ResizeObserver((entries) => {
|
|
25
27
|
const entry = entries[0];
|
|
26
28
|
if (!entry) return;
|
|
29
|
+
const apply = () => {
|
|
30
|
+
measuredWidth.value = entry.contentRect.width;
|
|
31
|
+
measuredHeight.value = entry.contentRect.height;
|
|
32
|
+
};
|
|
27
33
|
const debounce = opts.debounce?.();
|
|
28
34
|
if (debounce) {
|
|
29
35
|
if (resizeTimeout) clearTimeout(resizeTimeout);
|
|
30
|
-
resizeTimeout = setTimeout(
|
|
31
|
-
measuredWidth.value = entry.contentRect.width;
|
|
32
|
-
}, debounce);
|
|
36
|
+
resizeTimeout = setTimeout(apply, debounce);
|
|
33
37
|
} else {
|
|
34
|
-
|
|
38
|
+
apply();
|
|
35
39
|
}
|
|
36
40
|
});
|
|
37
41
|
observer.observe(containerRef.value);
|
|
@@ -45,5 +49,10 @@ export function useChartSize(opts: ChartSizeOptions = {}) {
|
|
|
45
49
|
return {
|
|
46
50
|
containerRef,
|
|
47
51
|
measuredWidth,
|
|
48
|
-
|
|
52
|
+
measuredHeight,
|
|
53
|
+
} as {
|
|
54
|
+
containerRef: Ref<HTMLElement | null>;
|
|
55
|
+
measuredWidth: Ref<number>;
|
|
56
|
+
measuredHeight: Ref<number>;
|
|
57
|
+
};
|
|
49
58
|
}
|
package/charts/index.ts
CHANGED
package/index.json
CHANGED