@cfasim-ui/charts 0.7.8 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BarChart/BarChart.d.ts +9 -26
- package/dist/ChartMenu/ChartMenu.d.ts +3 -2
- package/dist/ChartTooltip/ChartTooltip.d.ts +9 -12
- package/dist/ChoroplethMap/ChoroplethMap.d.ts +42 -190
- package/dist/ChoroplethMap/ChoroplethTooltip.d.ts +20 -27
- package/dist/ChoroplethMap/canvasLayer.d.ts +23 -6
- package/dist/ChoroplethMap/cityLayout.d.ts +3 -2
- package/dist/ChoroplethMap/mixedGeo.d.ts +74 -0
- package/dist/ChoroplethMap/mixedGeo.test.d.ts +1 -0
- package/dist/DataTable/DataTable.d.ts +3 -2
- package/dist/LineChart/LineChart.d.ts +9 -26
- package/dist/_shared/ChartAnnotations.d.ts +3 -2
- package/dist/_shared/ChartZoomControls.d.ts +3 -2
- package/dist/_shared/index.d.ts +2 -1
- package/dist/_shared/mapTheme.d.ts +159 -0
- package/dist/_shared/mapTheme.test.d.ts +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1821 -1462
- package/docs/BarChart.md +776 -0
- package/docs/ChoroplethMap.md +1394 -0
- package/docs/DataTable.md +386 -0
- package/docs/LineChart.md +1267 -0
- package/docs/index.json +56 -0
- package/package.json +25 -21
- package/src/BarChart/BarChart.md +743 -0
- package/src/BarChart/BarChart.vue +1901 -0
- package/src/ChartMenu/ChartMenu.vue +220 -0
- package/src/ChartMenu/download.ts +120 -0
- package/src/ChartTooltip/ChartTooltip.vue +97 -0
- package/src/ChoroplethMap/ChoroplethMap.md +1354 -0
- package/src/ChoroplethMap/ChoroplethMap.vue +3778 -0
- package/src/ChoroplethMap/ChoroplethTooltip.vue +103 -0
- package/src/ChoroplethMap/canvasLayer.ts +373 -0
- package/src/ChoroplethMap/cityLayout.ts +262 -0
- package/src/ChoroplethMap/hsaMapping.ts +4116 -0
- package/src/ChoroplethMap/mixedGeo.ts +201 -0
- package/src/DataTable/DataTable.md +372 -0
- package/src/DataTable/DataTable.vue +406 -0
- package/src/LineChart/LineChart.md +1225 -0
- package/src/LineChart/LineChart.vue +1555 -0
- package/src/_shared/ChartAnnotations.vue +420 -0
- package/src/_shared/ChartZoomControls.vue +138 -0
- package/src/_shared/annotations.ts +106 -0
- package/src/_shared/axes.ts +69 -0
- package/src/_shared/chartProps.ts +201 -0
- package/src/_shared/computeTicks.ts +42 -0
- package/src/_shared/contrast.ts +100 -0
- package/src/_shared/dateAxis.ts +501 -0
- package/src/_shared/index.ts +78 -0
- package/src/_shared/mapTheme.ts +551 -0
- package/src/_shared/scale.ts +86 -0
- package/src/_shared/seriesCsv.ts +68 -0
- package/src/_shared/touch.ts +8 -0
- package/src/_shared/useChartFoundation.ts +175 -0
- package/src/_shared/useChartFullscreen.ts +254 -0
- package/src/_shared/useChartMenu.ts +111 -0
- package/src/_shared/useChartPadding.ts +235 -0
- package/src/_shared/useChartSize.ts +58 -0
- package/src/_shared/useChartTooltip.ts +205 -0
- package/src/env.d.ts +4 -0
- package/src/hsa-mapping.ts +1 -0
- package/src/index.ts +41 -0
- package/src/tooltip-position.ts +55 -0
- package/src/us-cities/data.ts +1371 -0
- package/src/us-cities/index.ts +122 -0
- package/src/us-cities.ts +7 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the current device has a touch screen. A function (not a
|
|
3
|
+
* module-level constant) so unit tests can mock it per-case and so SSR
|
|
4
|
+
* evaluation is deferred until an event actually needs the answer.
|
|
5
|
+
*/
|
|
6
|
+
export function isTouchDevice(): boolean {
|
|
7
|
+
return typeof window !== "undefined" && "ontouchstart" in window;
|
|
8
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { computed } from "vue";
|
|
2
|
+
import { formatNumber, type NumberFormat } from "@cfasim-ui/shared";
|
|
3
|
+
import { formatTick } from "./axes.js";
|
|
4
|
+
import { useChartSize } from "./useChartSize.js";
|
|
5
|
+
import { useChartPadding, type ChartPadding } from "./useChartPadding.js";
|
|
6
|
+
import { useChartTooltip } from "./useChartTooltip.js";
|
|
7
|
+
import type { TooltipClamp } from "../tooltip-position.js";
|
|
8
|
+
import { useChartMenu } from "./useChartMenu.js";
|
|
9
|
+
import type { TitleStyle } from "./chartProps.js";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_WIDTH_FALLBACK = 400;
|
|
12
|
+
const DEFAULT_HEIGHT = 200;
|
|
13
|
+
|
|
14
|
+
export interface ChartFoundationOptions {
|
|
15
|
+
// Reactive getters for the shared chart props.
|
|
16
|
+
width: () => number | undefined;
|
|
17
|
+
height: () => number | undefined;
|
|
18
|
+
title: () => string | undefined;
|
|
19
|
+
titleStyle: () => TitleStyle | undefined;
|
|
20
|
+
xLabel: () => string | undefined;
|
|
21
|
+
yLabel: () => string | undefined;
|
|
22
|
+
debounce: () => number | undefined;
|
|
23
|
+
menu: () => boolean | string | undefined;
|
|
24
|
+
tooltipTrigger: () => "hover" | "click" | undefined;
|
|
25
|
+
tooltipClamp: () => TooltipClamp | undefined;
|
|
26
|
+
filename: () => string | undefined;
|
|
27
|
+
downloadLink: () => boolean | string | undefined;
|
|
28
|
+
downloadButton: () => boolean | string | undefined;
|
|
29
|
+
fullscreenTarget: () => string | HTMLElement | undefined;
|
|
30
|
+
chartPadding: () => ChartPadding | undefined;
|
|
31
|
+
// Chart-specific hooks that the composable can't infer.
|
|
32
|
+
/**
|
|
33
|
+
* Labels for the inline legend in render order. Empty array = no
|
|
34
|
+
* legend strip. Drives wrapping into multiple rows and the resulting
|
|
35
|
+
* top-padding reservation.
|
|
36
|
+
*/
|
|
37
|
+
inlineLegendLabels: () => readonly string[];
|
|
38
|
+
hasTooltipSlot: () => boolean;
|
|
39
|
+
getCsv: () => string;
|
|
40
|
+
pointerToIndex: (clientX: number, clientY: number) => number | null;
|
|
41
|
+
onHover: (payload: { index: number } | null) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Axis a finger drags along to scrub the tooltip on touch; the
|
|
44
|
+
* orthogonal direction is left to the browser for page scrolling.
|
|
45
|
+
* Defaults to `"x"`.
|
|
46
|
+
*/
|
|
47
|
+
scrubAxis?: () => "x" | "y";
|
|
48
|
+
/**
|
|
49
|
+
* Extra height (in px) the chart adds *below* the SVG plot area
|
|
50
|
+
* (e.g. LineChart's area-section labels). Used to keep the SVG total
|
|
51
|
+
* height matched to the container when fullscreen.
|
|
52
|
+
*/
|
|
53
|
+
extraBelowHeight?: () => number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Wires up the shared chart plumbing — size measurement, padding, tooltip
|
|
58
|
+
* interaction, and the menu/download wiring — that every cartesian chart
|
|
59
|
+
* needs. Returns the reactive values and refs each chart's template
|
|
60
|
+
* consumes.
|
|
61
|
+
*/
|
|
62
|
+
export function useChartFoundation(opts: ChartFoundationOptions) {
|
|
63
|
+
const { containerRef, measuredWidth, measuredHeight } = useChartSize({
|
|
64
|
+
debounce: opts.debounce,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const {
|
|
68
|
+
svgRef,
|
|
69
|
+
items: menuItems,
|
|
70
|
+
downloadLinkText,
|
|
71
|
+
csvHref,
|
|
72
|
+
downloadButtonText,
|
|
73
|
+
triggerCsvDownload,
|
|
74
|
+
resolvedFilename: menuFilename,
|
|
75
|
+
isFullscreen,
|
|
76
|
+
fullscreenStyle,
|
|
77
|
+
teleportTarget,
|
|
78
|
+
exitFullscreen,
|
|
79
|
+
} = useChartMenu({
|
|
80
|
+
filename: opts.filename,
|
|
81
|
+
legacyMenuLabel: opts.menu,
|
|
82
|
+
getCsv: opts.getCsv,
|
|
83
|
+
downloadLink: opts.downloadLink,
|
|
84
|
+
downloadButton: opts.downloadButton,
|
|
85
|
+
fullscreen: true,
|
|
86
|
+
fullscreenTarget: opts.fullscreenTarget,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const width = computed(() => {
|
|
90
|
+
if (isFullscreen.value && measuredWidth.value > 0) {
|
|
91
|
+
return measuredWidth.value;
|
|
92
|
+
}
|
|
93
|
+
return opts.width() ?? (measuredWidth.value || DEFAULT_WIDTH_FALLBACK);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const height = computed(() => {
|
|
97
|
+
if (isFullscreen.value && measuredHeight.value > 0) {
|
|
98
|
+
const extra = opts.extraBelowHeight?.() ?? 0;
|
|
99
|
+
return measuredHeight.value - extra;
|
|
100
|
+
}
|
|
101
|
+
return opts.height() ?? DEFAULT_HEIGHT;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const { padding, legendY, inlineLegendLayout, innerW, innerH, bounds } =
|
|
105
|
+
useChartPadding({
|
|
106
|
+
title: opts.title,
|
|
107
|
+
titleStyle: opts.titleStyle,
|
|
108
|
+
xLabel: opts.xLabel,
|
|
109
|
+
yLabel: opts.yLabel,
|
|
110
|
+
inlineLegendLabels: opts.inlineLegendLabels,
|
|
111
|
+
width: () => width.value,
|
|
112
|
+
height: () => height.value,
|
|
113
|
+
extraPadding: opts.chartPadding,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const {
|
|
117
|
+
hoverIndex,
|
|
118
|
+
tooltipRef,
|
|
119
|
+
tooltipPos,
|
|
120
|
+
handlers: tooltipHandlers,
|
|
121
|
+
} = useChartTooltip({
|
|
122
|
+
enabled: opts.hasTooltipSlot,
|
|
123
|
+
trigger: opts.tooltipTrigger,
|
|
124
|
+
clamp: () => opts.tooltipClamp() ?? "window",
|
|
125
|
+
pointerToIndex: opts.pointerToIndex,
|
|
126
|
+
containerRef,
|
|
127
|
+
scrubAxis: opts.scrubAxis,
|
|
128
|
+
onHover: opts.onHover,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
containerRef,
|
|
133
|
+
svgRef,
|
|
134
|
+
width,
|
|
135
|
+
height,
|
|
136
|
+
padding,
|
|
137
|
+
legendY,
|
|
138
|
+
inlineLegendLayout,
|
|
139
|
+
innerW,
|
|
140
|
+
innerH,
|
|
141
|
+
bounds,
|
|
142
|
+
hoverIndex,
|
|
143
|
+
tooltipRef,
|
|
144
|
+
tooltipPos,
|
|
145
|
+
tooltipHandlers,
|
|
146
|
+
menuItems,
|
|
147
|
+
downloadLinkText,
|
|
148
|
+
csvHref,
|
|
149
|
+
downloadButtonText,
|
|
150
|
+
triggerCsvDownload,
|
|
151
|
+
menuFilename,
|
|
152
|
+
isFullscreen,
|
|
153
|
+
fullscreenStyle,
|
|
154
|
+
teleportTarget,
|
|
155
|
+
exitFullscreen,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build a tooltip value formatter that prefers `tooltipValueFormat`,
|
|
161
|
+
* falls back to the chart's axis tick formatter, and finally to
|
|
162
|
+
* `formatTick`. Both chart components use the same precedence order.
|
|
163
|
+
*/
|
|
164
|
+
export function makeTooltipValueFormatter(
|
|
165
|
+
tooltipFormat: () => NumberFormat | undefined,
|
|
166
|
+
axisFormat: () => NumberFormat | undefined,
|
|
167
|
+
): (v: number) => string {
|
|
168
|
+
return (v: number) => {
|
|
169
|
+
const tf = tooltipFormat();
|
|
170
|
+
if (tf !== undefined) return formatNumber(v, tf);
|
|
171
|
+
const af = axisFormat();
|
|
172
|
+
if (af !== undefined) return formatNumber(v, af);
|
|
173
|
+
return formatTick(v);
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { computed, onMounted, onUnmounted, ref } from "vue";
|
|
2
|
+
import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
|
|
3
|
+
import { isTouchDevice } from "./touch.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Module-level refcount so multiple expanded charts on the same page
|
|
7
|
+
* share one body-scroll lock — the last one to collapse restores the
|
|
8
|
+
* original `overflow` value.
|
|
9
|
+
*/
|
|
10
|
+
let bodyLockCount = 0;
|
|
11
|
+
let savedBodyOverflow = "";
|
|
12
|
+
|
|
13
|
+
function lockBodyScroll() {
|
|
14
|
+
if (typeof document === "undefined") return;
|
|
15
|
+
if (bodyLockCount === 0) {
|
|
16
|
+
savedBodyOverflow = document.body.style.overflow;
|
|
17
|
+
document.body.style.overflow = "hidden";
|
|
18
|
+
}
|
|
19
|
+
bodyLockCount++;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function unlockBodyScroll() {
|
|
23
|
+
if (typeof document === "undefined") return;
|
|
24
|
+
if (bodyLockCount === 0) return;
|
|
25
|
+
bodyLockCount--;
|
|
26
|
+
if (bodyLockCount === 0) {
|
|
27
|
+
document.body.style.overflow = savedBodyOverflow;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Pinch-zoom lock (touch devices): a `position: fixed` overlay covers the
|
|
33
|
+
* *layout* viewport, so if the user has pinch-zoomed the page, the
|
|
34
|
+
* expanded chart's corners (controls, ✕) sit outside the visible area.
|
|
35
|
+
* Tightening the viewport meta to `maximum-scale=1` snaps the page zoom
|
|
36
|
+
* back to 1× and holds it while expanded — a pinch should zoom the chart,
|
|
37
|
+
* not the page. The last chart to collapse restores the original content
|
|
38
|
+
* so page zoom (an accessibility feature) comes back. No-op without a
|
|
39
|
+
* viewport meta or on mouse-only devices.
|
|
40
|
+
*/
|
|
41
|
+
let viewportLockCount = 0;
|
|
42
|
+
let savedViewportContent: string | null = null;
|
|
43
|
+
|
|
44
|
+
function viewportMeta(): HTMLMetaElement | null {
|
|
45
|
+
return document.querySelector<HTMLMetaElement>('meta[name="viewport"]');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function lockViewportZoom() {
|
|
49
|
+
if (typeof document === "undefined" || !isTouchDevice()) return;
|
|
50
|
+
const meta = viewportMeta();
|
|
51
|
+
if (!meta) return;
|
|
52
|
+
if (viewportLockCount === 0) {
|
|
53
|
+
savedViewportContent = meta.getAttribute("content");
|
|
54
|
+
// Append rather than replace so app directives (e.g. viewport-fit for
|
|
55
|
+
// safe-area insets) survive while expanded; a later duplicate key wins.
|
|
56
|
+
const base = savedViewportContent || "width=device-width, initial-scale=1";
|
|
57
|
+
meta.setAttribute("content", `${base}, maximum-scale=1`);
|
|
58
|
+
}
|
|
59
|
+
viewportLockCount++;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function unlockViewportZoom() {
|
|
63
|
+
if (typeof document === "undefined") return;
|
|
64
|
+
if (viewportLockCount === 0) return;
|
|
65
|
+
viewportLockCount--;
|
|
66
|
+
if (viewportLockCount === 0) {
|
|
67
|
+
const meta = viewportMeta();
|
|
68
|
+
if (meta && savedViewportContent != null) {
|
|
69
|
+
meta.setAttribute("content", savedViewportContent);
|
|
70
|
+
}
|
|
71
|
+
savedViewportContent = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ChartFullscreenOptions {
|
|
76
|
+
/**
|
|
77
|
+
* Reactive getter for where to teleport the expanded chart. A CSS
|
|
78
|
+
* selector or element; resolves to `"body"` when unset. Teleporting
|
|
79
|
+
* out to the document root is what makes `position: fixed` reliable:
|
|
80
|
+
* a `transform`/`filter`/`contain`/`perspective` on any ancestor would
|
|
81
|
+
* otherwise become the containing block and trap the "fullscreen" box
|
|
82
|
+
* inside it.
|
|
83
|
+
*/
|
|
84
|
+
target?: () => string | HTMLElement | undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Tracks whether the chart is in "expanded" mode (fills the window). The
|
|
89
|
+
* browser Fullscreen API isn't used — avoiding its restrictions
|
|
90
|
+
* (user-gesture requirement, permission policy, iframe sandboxing) and
|
|
91
|
+
* keeping the chart inside the document so the rest of the page stays
|
|
92
|
+
* keyboard-navigable.
|
|
93
|
+
*
|
|
94
|
+
* Resilience is the whole point here, so the layout is driven two ways
|
|
95
|
+
* that don't depend on the CSS cascade:
|
|
96
|
+
* - `fullscreenStyle` returns the critical layout as an *inline* style
|
|
97
|
+
* object. Inline styles outrank any class rule regardless of stylesheet
|
|
98
|
+
* source order, so they beat each chart's scoped `position: relative`
|
|
99
|
+
* base rule (equal specificity, and the scoped rule often loads last)
|
|
100
|
+
* and still work even if the consumer never imported the package CSS.
|
|
101
|
+
* - `teleportTarget` moves the node to the document root so the fixed
|
|
102
|
+
* positioning resolves against the viewport, not a transformed ancestor.
|
|
103
|
+
* The `.is-fullscreen` class remains on the wrapper only as a hook for the
|
|
104
|
+
* one rule inline styles can't express (ChoroplethMap's SVG stretch).
|
|
105
|
+
*/
|
|
106
|
+
export function useChartFullscreen(opts: ChartFullscreenOptions = {}) {
|
|
107
|
+
const isFullscreen = ref(false);
|
|
108
|
+
let locked = false;
|
|
109
|
+
|
|
110
|
+
// Visual-viewport pinning. A fixed overlay covers the *layout* viewport,
|
|
111
|
+
// but on a pinch-zoomed page only part of that is visible — and iOS
|
|
112
|
+
// refuses to programmatically reset page zoom (the viewport-meta clamp
|
|
113
|
+
// above is best-effort; it works on Android and older iOS). While
|
|
114
|
+
// expanded, track the visual viewport and pin the overlay to exactly the
|
|
115
|
+
// visible box instead of `inset: 0`, so the chart and its controls are
|
|
116
|
+
// always fully on screen whatever the page zoom.
|
|
117
|
+
const visualBox = ref<Record<string, string> | null>(null);
|
|
118
|
+
|
|
119
|
+
function updateVisualBox() {
|
|
120
|
+
const vv = typeof window !== "undefined" ? window.visualViewport : null;
|
|
121
|
+
if (!vv) return;
|
|
122
|
+
// Always box off the visual viewport while it's observable — at 1×
|
|
123
|
+
// this equals inset: 0, and it additionally tracks mobile URL-bar
|
|
124
|
+
// collapse. `inset: 0` remains only as the no-API fallback.
|
|
125
|
+
visualBox.value = {
|
|
126
|
+
top: `${vv.offsetTop}px`,
|
|
127
|
+
left: `${vv.offsetLeft}px`,
|
|
128
|
+
width: `${vv.width}px`,
|
|
129
|
+
height: `${vv.height}px`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function watchVisualViewport(on: boolean) {
|
|
134
|
+
const vv = typeof window !== "undefined" ? window.visualViewport : null;
|
|
135
|
+
if (!vv) return;
|
|
136
|
+
if (on) {
|
|
137
|
+
vv.addEventListener("resize", updateVisualBox);
|
|
138
|
+
vv.addEventListener("scroll", updateVisualBox);
|
|
139
|
+
updateVisualBox();
|
|
140
|
+
} else {
|
|
141
|
+
vv.removeEventListener("resize", updateVisualBox);
|
|
142
|
+
vv.removeEventListener("scroll", updateVisualBox);
|
|
143
|
+
visualBox.value = null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function setExpanded(value: boolean) {
|
|
148
|
+
if (value === isFullscreen.value) return;
|
|
149
|
+
isFullscreen.value = value;
|
|
150
|
+
if (value && !locked) {
|
|
151
|
+
lockBodyScroll();
|
|
152
|
+
lockViewportZoom();
|
|
153
|
+
watchVisualViewport(true);
|
|
154
|
+
locked = true;
|
|
155
|
+
} else if (!value && locked) {
|
|
156
|
+
unlockBodyScroll();
|
|
157
|
+
unlockViewportZoom();
|
|
158
|
+
watchVisualViewport(false);
|
|
159
|
+
locked = false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function onKey(e: KeyboardEvent) {
|
|
164
|
+
if (e.key !== "Escape" || !isFullscreen.value) return;
|
|
165
|
+
// Skip when Esc is closing a menu/dialog layered on top of us so we
|
|
166
|
+
// don't collapse the chart at the same time. Reka's dropdown menu
|
|
167
|
+
// uses a document-level Esc listener and doesn't call preventDefault,
|
|
168
|
+
// so checking the event target is the most reliable signal.
|
|
169
|
+
const t = e.target;
|
|
170
|
+
if (t instanceof Element && t.closest('[role="menu"], [role="dialog"]')) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
setExpanded(false);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function enter() {
|
|
177
|
+
setExpanded(true);
|
|
178
|
+
}
|
|
179
|
+
function exit() {
|
|
180
|
+
setExpanded(false);
|
|
181
|
+
}
|
|
182
|
+
function toggle() {
|
|
183
|
+
setExpanded(!isFullscreen.value);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
onMounted(() => {
|
|
187
|
+
if (typeof document === "undefined") return;
|
|
188
|
+
document.addEventListener("keydown", onKey);
|
|
189
|
+
});
|
|
190
|
+
onUnmounted(() => {
|
|
191
|
+
if (typeof document === "undefined") return;
|
|
192
|
+
document.removeEventListener("keydown", onKey);
|
|
193
|
+
// Component torn down while expanded — release the locks so the body
|
|
194
|
+
// doesn't stay frozen and page zoom comes back.
|
|
195
|
+
if (locked) {
|
|
196
|
+
unlockBodyScroll();
|
|
197
|
+
unlockViewportZoom();
|
|
198
|
+
watchVisualViewport(false);
|
|
199
|
+
locked = false;
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Returns a reactive ChartMenuItem ref that flips between Expand and
|
|
205
|
+
* Collapse — shared by useChartMenu and any chart (e.g. ChoroplethMap)
|
|
206
|
+
* that builds its menu list directly.
|
|
207
|
+
*/
|
|
208
|
+
const menuItem = computed<ChartMenuItem>(() => ({
|
|
209
|
+
label: isFullscreen.value ? "Collapse" : "Fullscreen",
|
|
210
|
+
action: toggle,
|
|
211
|
+
ariaPressed: isFullscreen.value,
|
|
212
|
+
}));
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Critical fullscreen layout as inline styles (only while expanded).
|
|
216
|
+
* Bound via `:style` on the chart wrapper so it always wins over the
|
|
217
|
+
* scoped base rule and survives a missing stylesheet import.
|
|
218
|
+
*/
|
|
219
|
+
const fullscreenStyle = computed<Record<string, string> | undefined>(() =>
|
|
220
|
+
isFullscreen.value
|
|
221
|
+
? {
|
|
222
|
+
position: "fixed",
|
|
223
|
+
// Pinned to the visual viewport when the page is pinch-zoomed;
|
|
224
|
+
// identical to inset: 0 otherwise.
|
|
225
|
+
...(visualBox.value ?? { inset: "0" }),
|
|
226
|
+
"z-index": "var(--cfasim-z-fullscreen, 1000)",
|
|
227
|
+
background: "var(--color-bg-0, #fff)",
|
|
228
|
+
color: "var(--color-text, inherit)",
|
|
229
|
+
// Edge-to-edge on touch — padding just frames slivers of the
|
|
230
|
+
// page underneath on small screens; desktop keeps modal-style
|
|
231
|
+
// breathing room.
|
|
232
|
+
padding: isTouchDevice() ? "0" : "2em",
|
|
233
|
+
"box-sizing": "border-box",
|
|
234
|
+
display: "flex",
|
|
235
|
+
"flex-direction": "column",
|
|
236
|
+
"justify-content": "center",
|
|
237
|
+
}
|
|
238
|
+
: undefined,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const teleportTarget = computed<string | HTMLElement>(
|
|
242
|
+
() => opts.target?.() || "body",
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
isFullscreen,
|
|
247
|
+
toggle,
|
|
248
|
+
enter,
|
|
249
|
+
exit,
|
|
250
|
+
menuItem,
|
|
251
|
+
fullscreenStyle,
|
|
252
|
+
teleportTarget,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { computed, ref, type Ref } from "vue";
|
|
2
|
+
import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
|
|
3
|
+
import { saveSvg, savePng, downloadCsv } from "../ChartMenu/download.js";
|
|
4
|
+
import { useChartFullscreen } from "./useChartFullscreen.js";
|
|
5
|
+
|
|
6
|
+
export interface ChartMenuOptions {
|
|
7
|
+
filename: () => string | undefined;
|
|
8
|
+
/** Used as the menu's filename fallback when `filename` is unset. */
|
|
9
|
+
legacyMenuLabel: () => boolean | string | undefined;
|
|
10
|
+
/** Builds the CSV content for downloads. */
|
|
11
|
+
getCsv: () => string;
|
|
12
|
+
/** Whether a separate download link is rendered (and the CSV menu item should be hidden). */
|
|
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;
|
|
16
|
+
/**
|
|
17
|
+
* When true, prepends a Fullscreen menu item that toggles the chart
|
|
18
|
+
* into a full-window view. The consumer binds the returned
|
|
19
|
+
* `isFullscreen` class plus `fullscreenStyle` to its wrapper and wraps
|
|
20
|
+
* it in a `<Teleport :to="teleportTarget" :disabled="!isFullscreen">`.
|
|
21
|
+
*/
|
|
22
|
+
fullscreen?: boolean;
|
|
23
|
+
/** Forwarded to `useChartFullscreen` — see its `target` option. */
|
|
24
|
+
fullscreenTarget?: () => string | HTMLElement | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Computes the standard chart menu items (Fullscreen / SVG / PNG / CSV) plus
|
|
29
|
+
* the CSV-download-link state shared by every chart.
|
|
30
|
+
*/
|
|
31
|
+
export function useChartMenu(opts: ChartMenuOptions) {
|
|
32
|
+
const svgRef = ref<SVGSVGElement | null>(null);
|
|
33
|
+
|
|
34
|
+
function resolvedFilename(): string {
|
|
35
|
+
const f = opts.filename();
|
|
36
|
+
if (f) return f;
|
|
37
|
+
const menu = opts.legacyMenuLabel();
|
|
38
|
+
return typeof menu === "string" ? menu : "chart";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const fullscreen = opts.fullscreen
|
|
42
|
+
? useChartFullscreen({ target: opts.fullscreenTarget })
|
|
43
|
+
: null;
|
|
44
|
+
|
|
45
|
+
const items = computed<ChartMenuItem[]>(() => {
|
|
46
|
+
const fname = resolvedFilename();
|
|
47
|
+
const out: ChartMenuItem[] = [];
|
|
48
|
+
if (fullscreen) {
|
|
49
|
+
out.push(fullscreen.menuItem.value);
|
|
50
|
+
}
|
|
51
|
+
out.push(
|
|
52
|
+
{
|
|
53
|
+
label: "Save as SVG",
|
|
54
|
+
action: () => {
|
|
55
|
+
if (svgRef.value) saveSvg(svgRef.value, fname);
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
label: "Save as PNG",
|
|
60
|
+
action: () => {
|
|
61
|
+
if (svgRef.value) savePng(svgRef.value, fname);
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
const buttonOn = opts.downloadButton?.();
|
|
66
|
+
if (!opts.downloadLink() && !buttonOn) {
|
|
67
|
+
out.push({
|
|
68
|
+
label: "Download CSV",
|
|
69
|
+
action: () => downloadCsv(opts.getCsv(), fname),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const downloadLinkText = computed<string | null>(() => {
|
|
76
|
+
if (opts.downloadButton?.()) return null;
|
|
77
|
+
const v = opts.downloadLink();
|
|
78
|
+
if (!v) return null;
|
|
79
|
+
return typeof v === "string" ? v : "Download data (CSV)";
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const csvHref = computed<string | null>(() => {
|
|
83
|
+
if (opts.downloadButton?.()) return null;
|
|
84
|
+
if (!opts.downloadLink()) return null;
|
|
85
|
+
return `data:text/csv;charset=utf-8,${encodeURIComponent(opts.getCsv())}`;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const downloadButtonText = computed<string | null>(() => {
|
|
89
|
+
const v = opts.downloadButton?.();
|
|
90
|
+
if (!v) return null;
|
|
91
|
+
return typeof v === "string" ? v : "Download data (CSV)";
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
function triggerCsvDownload() {
|
|
95
|
+
downloadCsv(opts.getCsv(), resolvedFilename());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
svgRef: svgRef as Ref<SVGSVGElement | null>,
|
|
100
|
+
items,
|
|
101
|
+
downloadLinkText,
|
|
102
|
+
csvHref,
|
|
103
|
+
downloadButtonText,
|
|
104
|
+
triggerCsvDownload,
|
|
105
|
+
resolvedFilename,
|
|
106
|
+
isFullscreen: fullscreen?.isFullscreen ?? ref(false),
|
|
107
|
+
fullscreenStyle: fullscreen?.fullscreenStyle ?? computed(() => undefined),
|
|
108
|
+
teleportTarget: fullscreen?.teleportTarget ?? computed(() => "body"),
|
|
109
|
+
exitFullscreen: fullscreen?.exit ?? (() => {}),
|
|
110
|
+
};
|
|
111
|
+
}
|