@cfasim-ui/docs 0.4.10 → 0.4.12
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 +54 -1
- package/charts/BarChart/BarChart.vue +248 -48
- package/charts/ChartMenu/ChartMenu.vue +3 -0
- package/charts/ChoroplethMap/ChoroplethMap.md +8 -2
- package/charts/ChoroplethMap/ChoroplethMap.vue +102 -11
- package/charts/LineChart/LineChart.md +226 -9
- package/charts/LineChart/LineChart.vue +381 -120
- package/charts/_shared/ChartAnnotations.vue +56 -17
- package/charts/_shared/annotations.ts +14 -9
- package/charts/_shared/chartProps.ts +39 -0
- package/charts/_shared/dateAxis.ts +507 -0
- package/charts/_shared/fullscreen.css +51 -0
- package/charts/_shared/index.ts +25 -1
- package/charts/_shared/useChartFoundation.ts +57 -27
- package/charts/_shared/useChartFullscreen.ts +104 -0
- package/charts/_shared/useChartMenu.ts +19 -4
- package/charts/_shared/useChartPadding.ts +152 -18
- package/charts/_shared/useChartSize.ts +14 -5
- package/charts/hsa-mapping.ts +1 -0
- package/charts/index.ts +2 -1
- package/index.json +1 -1
- package/package.json +1 -1
- package/shared/useUrlParams.ts +203 -40
|
@@ -6,6 +6,7 @@ import { useChartPadding, type ChartPadding } from "./useChartPadding.js";
|
|
|
6
6
|
import { useChartTooltip } from "./useChartTooltip.js";
|
|
7
7
|
import type { TooltipClamp } from "../tooltip-position.js";
|
|
8
8
|
import { useChartMenu } from "./useChartMenu.js";
|
|
9
|
+
import type { TitleStyle } from "./chartProps.js";
|
|
9
10
|
|
|
10
11
|
const DEFAULT_WIDTH_FALLBACK = 400;
|
|
11
12
|
const DEFAULT_HEIGHT = 200;
|
|
@@ -15,6 +16,7 @@ export interface ChartFoundationOptions {
|
|
|
15
16
|
width: () => number | undefined;
|
|
16
17
|
height: () => number | undefined;
|
|
17
18
|
title: () => string | undefined;
|
|
19
|
+
titleStyle: () => TitleStyle | undefined;
|
|
18
20
|
xLabel: () => string | undefined;
|
|
19
21
|
yLabel: () => string | undefined;
|
|
20
22
|
debounce: () => number | undefined;
|
|
@@ -25,11 +27,22 @@ export interface ChartFoundationOptions {
|
|
|
25
27
|
downloadLink: () => boolean | string | undefined;
|
|
26
28
|
chartPadding: () => ChartPadding | undefined;
|
|
27
29
|
// Chart-specific hooks that the composable can't infer.
|
|
28
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Labels for the inline legend in render order. Empty array = no
|
|
32
|
+
* legend strip. Drives wrapping into multiple rows and the resulting
|
|
33
|
+
* top-padding reservation.
|
|
34
|
+
*/
|
|
35
|
+
inlineLegendLabels: () => readonly string[];
|
|
29
36
|
hasTooltipSlot: () => boolean;
|
|
30
37
|
getCsv: () => string;
|
|
31
38
|
pointerToIndex: (clientX: number, clientY: number) => number | null;
|
|
32
39
|
onHover: (payload: { index: number } | null) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Extra height (in px) the chart adds *below* the SVG plot area
|
|
42
|
+
* (e.g. LineChart's area-section labels). Used to keep the SVG total
|
|
43
|
+
* height matched to the container when fullscreen.
|
|
44
|
+
*/
|
|
45
|
+
extraBelowHeight?: () => number;
|
|
33
46
|
}
|
|
34
47
|
|
|
35
48
|
/**
|
|
@@ -39,25 +52,52 @@ export interface ChartFoundationOptions {
|
|
|
39
52
|
* consumes.
|
|
40
53
|
*/
|
|
41
54
|
export function useChartFoundation(opts: ChartFoundationOptions) {
|
|
42
|
-
const { containerRef, measuredWidth } = useChartSize({
|
|
55
|
+
const { containerRef, measuredWidth, measuredHeight } = useChartSize({
|
|
43
56
|
debounce: opts.debounce,
|
|
44
57
|
});
|
|
45
58
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
const {
|
|
60
|
+
svgRef,
|
|
61
|
+
items: menuItems,
|
|
62
|
+
downloadLinkText,
|
|
63
|
+
csvHref,
|
|
64
|
+
resolvedFilename: menuFilename,
|
|
65
|
+
isFullscreen,
|
|
66
|
+
} = useChartMenu({
|
|
67
|
+
filename: opts.filename,
|
|
68
|
+
legacyMenuLabel: opts.menu,
|
|
69
|
+
getCsv: opts.getCsv,
|
|
70
|
+
downloadLink: opts.downloadLink,
|
|
71
|
+
fullscreen: true,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const width = computed(() => {
|
|
75
|
+
if (isFullscreen.value && measuredWidth.value > 0) {
|
|
76
|
+
return measuredWidth.value;
|
|
77
|
+
}
|
|
78
|
+
return opts.width() ?? (measuredWidth.value || DEFAULT_WIDTH_FALLBACK);
|
|
79
|
+
});
|
|
50
80
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
height: () => height.value,
|
|
58
|
-
extraPadding: opts.chartPadding,
|
|
81
|
+
const height = computed(() => {
|
|
82
|
+
if (isFullscreen.value && measuredHeight.value > 0) {
|
|
83
|
+
const extra = opts.extraBelowHeight?.() ?? 0;
|
|
84
|
+
return measuredHeight.value - extra;
|
|
85
|
+
}
|
|
86
|
+
return opts.height() ?? DEFAULT_HEIGHT;
|
|
59
87
|
});
|
|
60
88
|
|
|
89
|
+
const { padding, legendY, inlineLegendLayout, innerW, innerH, bounds } =
|
|
90
|
+
useChartPadding({
|
|
91
|
+
title: opts.title,
|
|
92
|
+
titleStyle: opts.titleStyle,
|
|
93
|
+
xLabel: opts.xLabel,
|
|
94
|
+
yLabel: opts.yLabel,
|
|
95
|
+
inlineLegendLabels: opts.inlineLegendLabels,
|
|
96
|
+
width: () => width.value,
|
|
97
|
+
height: () => height.value,
|
|
98
|
+
extraPadding: opts.chartPadding,
|
|
99
|
+
});
|
|
100
|
+
|
|
61
101
|
const {
|
|
62
102
|
hoverIndex,
|
|
63
103
|
tooltipRef,
|
|
@@ -72,19 +112,6 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
|
|
|
72
112
|
onHover: opts.onHover,
|
|
73
113
|
});
|
|
74
114
|
|
|
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
115
|
return {
|
|
89
116
|
containerRef,
|
|
90
117
|
svgRef,
|
|
@@ -92,6 +119,7 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
|
|
|
92
119
|
height,
|
|
93
120
|
padding,
|
|
94
121
|
legendY,
|
|
122
|
+
inlineLegendLayout,
|
|
95
123
|
innerW,
|
|
96
124
|
innerH,
|
|
97
125
|
bounds,
|
|
@@ -103,6 +131,8 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
|
|
|
103
131
|
downloadLinkText,
|
|
104
132
|
csvHref,
|
|
105
133
|
menuFilename,
|
|
134
|
+
isFullscreen,
|
|
135
|
+
measuredHeight,
|
|
106
136
|
};
|
|
107
137
|
}
|
|
108
138
|
|
|
@@ -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,56 @@
|
|
|
1
1
|
import { computed } from "vue";
|
|
2
|
+
import type { LabelStyle, TitleStyle } from "./chartProps.js";
|
|
2
3
|
|
|
3
|
-
/** Vertical space reserved
|
|
4
|
-
export const
|
|
4
|
+
/** Vertical space reserved per row of the inline legend strip. */
|
|
5
|
+
export const INLINE_LEGEND_ROW_HEIGHT = 20;
|
|
6
|
+
/** Default line height (px) for the chart title; overridable per chart. */
|
|
7
|
+
export const TITLE_LINE_HEIGHT = 18;
|
|
8
|
+
/** Default font size (px) for the chart title; overridable per chart. */
|
|
9
|
+
export const TITLE_FONT_SIZE = 14;
|
|
10
|
+
/** Default font weight for the chart title; overridable per chart. */
|
|
11
|
+
export const TITLE_FONT_WEIGHT: number | string = 600;
|
|
12
|
+
/** Default font size (px) for axis labels (`xLabel` / `yLabel`). */
|
|
13
|
+
export const AXIS_LABEL_FONT_SIZE = 13;
|
|
14
|
+
/** Default font size (px) for axis tick labels (the numbers on each axis). */
|
|
15
|
+
export const TICK_LABEL_FONT_SIZE = 10;
|
|
16
|
+
/** Default fill-opacity for tick labels when no custom color is set. */
|
|
17
|
+
export const TICK_LABEL_OPACITY = 0.6;
|
|
18
|
+
/** Default font size (px) for inline legend item labels. */
|
|
19
|
+
export const LEGEND_FONT_SIZE = 11;
|
|
20
|
+
/** Vertical space below the title's last baseline before the next element. */
|
|
21
|
+
const TITLE_BOTTOM_GAP = 8;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve a `LabelStyle` against per-element defaults. Returns the
|
|
25
|
+
* fields the chart's `<text>` element needs. When `style.color` is set,
|
|
26
|
+
* the default fill-opacity is dropped so the exact color renders.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveLabelStyle(
|
|
29
|
+
style: LabelStyle | undefined,
|
|
30
|
+
defaults: { fontSize: number; fillOpacity?: number },
|
|
31
|
+
): {
|
|
32
|
+
fontSize: number;
|
|
33
|
+
fill: string;
|
|
34
|
+
fontWeight: number | string | undefined;
|
|
35
|
+
fillOpacity: number | undefined;
|
|
36
|
+
} {
|
|
37
|
+
return {
|
|
38
|
+
fontSize: style?.fontSize ?? defaults.fontSize,
|
|
39
|
+
fill: style?.color ?? "currentColor",
|
|
40
|
+
fontWeight: style?.fontWeight,
|
|
41
|
+
fillOpacity: style?.color != null ? undefined : defaults.fillOpacity,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Estimated character width at font-size 11 used by inline-legend
|
|
46
|
+
* measurement. Matches the value used by LineChart's area-section labels
|
|
47
|
+
* so wrapping behaves consistently across the chart's text overlays.
|
|
48
|
+
*/
|
|
49
|
+
const LEGEND_CHAR_WIDTH = 7;
|
|
50
|
+
/** X offset of legend text from the start of its row slot. */
|
|
51
|
+
const LEGEND_TEXT_INDENT = 18;
|
|
52
|
+
/** Horizontal gap between adjacent legend items on the same row. */
|
|
53
|
+
const LEGEND_ITEM_GAP = 16;
|
|
5
54
|
|
|
6
55
|
/**
|
|
7
56
|
* Extra space added around the chart's standard layout. A number applies
|
|
@@ -20,15 +69,29 @@ export type ChartPadding =
|
|
|
20
69
|
|
|
21
70
|
export interface ChartPaddingOptions {
|
|
22
71
|
title: () => string | undefined;
|
|
72
|
+
titleStyle?: () => TitleStyle | undefined;
|
|
23
73
|
xLabel: () => string | undefined;
|
|
24
74
|
yLabel: () => string | undefined;
|
|
25
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Labels for the inline legend items, in render order. Empty array
|
|
77
|
+
* (or omitted) means no legend strip. Drives both row-count for
|
|
78
|
+
* reserving top padding and per-item wrap positions.
|
|
79
|
+
*/
|
|
80
|
+
inlineLegendLabels: () => readonly string[];
|
|
26
81
|
width: () => number;
|
|
27
82
|
height: () => number;
|
|
28
83
|
/** Extra pixels added on top of the standard axis spacing. */
|
|
29
84
|
extraPadding?: () => ChartPadding | undefined;
|
|
30
85
|
}
|
|
31
86
|
|
|
87
|
+
/** Position of a single legend item within the legend strip. */
|
|
88
|
+
export interface PositionedLegendItem {
|
|
89
|
+
/** X offset relative to `padding.left`. */
|
|
90
|
+
x: number;
|
|
91
|
+
/** Row index, 0-based. */
|
|
92
|
+
row: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
32
95
|
function resolvePadding(p: ChartPadding | undefined) {
|
|
33
96
|
if (p == null) return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
34
97
|
if (typeof p === "number") return { top: p, right: p, bottom: p, left: p };
|
|
@@ -40,6 +103,14 @@ function resolvePadding(p: ChartPadding | undefined) {
|
|
|
40
103
|
};
|
|
41
104
|
}
|
|
42
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Estimated rendered width (in px) of a single legend item — indicator,
|
|
108
|
+
* text, and surrounding spacing. Used to flow items into rows.
|
|
109
|
+
*/
|
|
110
|
+
function estimateLegendItemWidth(label: string): number {
|
|
111
|
+
return LEGEND_TEXT_INDENT + label.length * LEGEND_CHAR_WIDTH;
|
|
112
|
+
}
|
|
113
|
+
|
|
43
114
|
/**
|
|
44
115
|
* Computes the standard chart padding (top/right/bottom/left) and the
|
|
45
116
|
* derived inner plotting region (innerW, innerH). Shared by LineChart
|
|
@@ -47,30 +118,86 @@ function resolvePadding(p: ChartPadding | undefined) {
|
|
|
47
118
|
* consistent. `extraPadding` adds extra space outside the plot — useful
|
|
48
119
|
* for annotations that need to extend past the data area without
|
|
49
120
|
* clipping.
|
|
121
|
+
*
|
|
122
|
+
* The inline legend wraps to multiple rows when items don't fit in the
|
|
123
|
+
* available width; `padding.top` grows by `INLINE_LEGEND_ROW_HEIGHT` per
|
|
124
|
+
* row so the plot stays clear of the legend strip.
|
|
50
125
|
*/
|
|
51
126
|
export function useChartPadding(opts: ChartPaddingOptions) {
|
|
52
|
-
|
|
127
|
+
// Horizontal padding is computed first because `innerW` depends only
|
|
128
|
+
// on left/right (not on legend row count). Splitting padding this way
|
|
129
|
+
// keeps the legend layout — which depends on `innerW` — out of the
|
|
130
|
+
// reactivity cycle for left/right.
|
|
131
|
+
const horizontalPadding = computed(() => {
|
|
53
132
|
const extra = resolvePadding(opts.extraPadding?.());
|
|
54
133
|
return {
|
|
55
|
-
|
|
56
|
-
(opts.title() ? 26 : 10) +
|
|
57
|
-
(opts.hasInlineLegend() ? INLINE_LEGEND_HEIGHT : 0) +
|
|
58
|
-
extra.top,
|
|
134
|
+
left: (opts.yLabel() ? 56 : 50) + extra.left,
|
|
59
135
|
right: 10 + extra.right,
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const innerW = computed(
|
|
140
|
+
() =>
|
|
141
|
+
opts.width() -
|
|
142
|
+
horizontalPadding.value.left -
|
|
143
|
+
horizontalPadding.value.right,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const inlineLegendLayout = computed<{
|
|
147
|
+
positions: PositionedLegendItem[];
|
|
148
|
+
rowCount: number;
|
|
149
|
+
}>(() => {
|
|
150
|
+
const labels = opts.inlineLegendLabels();
|
|
151
|
+
if (labels.length === 0) return { positions: [], rowCount: 0 };
|
|
152
|
+
const available = Math.max(0, innerW.value);
|
|
153
|
+
const positions: PositionedLegendItem[] = [];
|
|
154
|
+
let row = 0;
|
|
155
|
+
let x = 0;
|
|
156
|
+
for (const label of labels) {
|
|
157
|
+
const w = estimateLegendItemWidth(label);
|
|
158
|
+
// Wrap to a new row when the item won't fit, except when we're
|
|
159
|
+
// already at row-start (`x === 0`): a single oversized label gets
|
|
160
|
+
// its own row instead of triggering an infinite-wrap loop.
|
|
161
|
+
if (x > 0 && x + w > available) {
|
|
162
|
+
row++;
|
|
163
|
+
x = 0;
|
|
164
|
+
}
|
|
165
|
+
positions.push({ x, row });
|
|
166
|
+
x += w + LEGEND_ITEM_GAP;
|
|
167
|
+
}
|
|
168
|
+
return { positions, rowCount: row + 1 };
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Title height in px. `\n` in the title creates additional lines, each
|
|
172
|
+
// adding `titleStyle.lineHeight` (default 18). When there is no title,
|
|
173
|
+
// reserve a small top margin so the plot doesn't hug the top edge.
|
|
174
|
+
const titleHeight = computed(() => {
|
|
175
|
+
const t = opts.title();
|
|
176
|
+
if (!t) return 10;
|
|
177
|
+
const lineHeight = opts.titleStyle?.()?.lineHeight ?? TITLE_LINE_HEIGHT;
|
|
178
|
+
const lineCount = t.split("\n").length;
|
|
179
|
+
return lineCount * lineHeight + TITLE_BOTTOM_GAP;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const padding = computed(() => {
|
|
183
|
+
const extra = resolvePadding(opts.extraPadding?.());
|
|
184
|
+
const rowCount = inlineLegendLayout.value.rowCount;
|
|
185
|
+
return {
|
|
186
|
+
top: titleHeight.value + rowCount * INLINE_LEGEND_ROW_HEIGHT + extra.top,
|
|
60
187
|
bottom: (opts.xLabel() ? 38 : 30) + extra.bottom,
|
|
61
|
-
left:
|
|
188
|
+
left: horizontalPadding.value.left,
|
|
189
|
+
right: horizontalPadding.value.right,
|
|
62
190
|
};
|
|
63
191
|
});
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
192
|
+
|
|
193
|
+
// Y-center of the first inline-legend row. Sits at the top of the
|
|
194
|
+
// padding band (just below any title), so any user-supplied
|
|
195
|
+
// `extraPadding.top` becomes empty room between the legend and the
|
|
196
|
+
// plot. Subsequent rows are offset by `INLINE_LEGEND_ROW_HEIGHT`.
|
|
68
197
|
const legendY = computed(
|
|
69
|
-
() =>
|
|
70
|
-
);
|
|
71
|
-
const innerW = computed(
|
|
72
|
-
() => opts.width() - padding.value.left - padding.value.right,
|
|
198
|
+
() => titleHeight.value + INLINE_LEGEND_ROW_HEIGHT / 2,
|
|
73
199
|
);
|
|
200
|
+
|
|
74
201
|
const innerH = computed(
|
|
75
202
|
() => opts.height() - padding.value.top - padding.value.bottom,
|
|
76
203
|
);
|
|
@@ -88,7 +215,14 @@ export function useChartPadding(opts: ChartPaddingOptions) {
|
|
|
88
215
|
height: innerH.value,
|
|
89
216
|
};
|
|
90
217
|
});
|
|
91
|
-
return {
|
|
218
|
+
return {
|
|
219
|
+
padding,
|
|
220
|
+
legendY,
|
|
221
|
+
inlineLegendLayout,
|
|
222
|
+
innerW,
|
|
223
|
+
innerH,
|
|
224
|
+
bounds,
|
|
225
|
+
};
|
|
92
226
|
}
|
|
93
227
|
|
|
94
228
|
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
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { fipsToHsa, hsaNames } from "./ChoroplethMap/hsaMapping.js";
|
package/charts/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import "./_shared/fullscreen.css";
|
|
2
|
+
|
|
1
3
|
export {
|
|
2
4
|
default as LineChart,
|
|
3
5
|
type LineChartData,
|
|
@@ -21,7 +23,6 @@ export {
|
|
|
21
23
|
type FocusValue,
|
|
22
24
|
type FocusStyle,
|
|
23
25
|
} from "./ChoroplethMap/ChoroplethMap.vue";
|
|
24
|
-
export { fipsToHsa, hsaNames } from "./ChoroplethMap/hsaMapping.js";
|
|
25
26
|
export { default as ChartTooltip } from "./ChartTooltip/ChartTooltip.vue";
|
|
26
27
|
export {
|
|
27
28
|
default as DataTable,
|
package/index.json
CHANGED