@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.
Files changed (67) hide show
  1. package/dist/BarChart/BarChart.d.ts +9 -26
  2. package/dist/ChartMenu/ChartMenu.d.ts +3 -2
  3. package/dist/ChartTooltip/ChartTooltip.d.ts +9 -12
  4. package/dist/ChoroplethMap/ChoroplethMap.d.ts +42 -190
  5. package/dist/ChoroplethMap/ChoroplethTooltip.d.ts +20 -27
  6. package/dist/ChoroplethMap/canvasLayer.d.ts +23 -6
  7. package/dist/ChoroplethMap/cityLayout.d.ts +3 -2
  8. package/dist/ChoroplethMap/mixedGeo.d.ts +74 -0
  9. package/dist/ChoroplethMap/mixedGeo.test.d.ts +1 -0
  10. package/dist/DataTable/DataTable.d.ts +3 -2
  11. package/dist/LineChart/LineChart.d.ts +9 -26
  12. package/dist/_shared/ChartAnnotations.d.ts +3 -2
  13. package/dist/_shared/ChartZoomControls.d.ts +3 -2
  14. package/dist/_shared/index.d.ts +2 -1
  15. package/dist/_shared/mapTheme.d.ts +159 -0
  16. package/dist/_shared/mapTheme.test.d.ts +1 -0
  17. package/dist/index.css +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1821 -1462
  20. package/docs/BarChart.md +776 -0
  21. package/docs/ChoroplethMap.md +1394 -0
  22. package/docs/DataTable.md +386 -0
  23. package/docs/LineChart.md +1267 -0
  24. package/docs/index.json +56 -0
  25. package/package.json +25 -21
  26. package/src/BarChart/BarChart.md +743 -0
  27. package/src/BarChart/BarChart.vue +1901 -0
  28. package/src/ChartMenu/ChartMenu.vue +220 -0
  29. package/src/ChartMenu/download.ts +120 -0
  30. package/src/ChartTooltip/ChartTooltip.vue +97 -0
  31. package/src/ChoroplethMap/ChoroplethMap.md +1354 -0
  32. package/src/ChoroplethMap/ChoroplethMap.vue +3778 -0
  33. package/src/ChoroplethMap/ChoroplethTooltip.vue +103 -0
  34. package/src/ChoroplethMap/canvasLayer.ts +373 -0
  35. package/src/ChoroplethMap/cityLayout.ts +262 -0
  36. package/src/ChoroplethMap/hsaMapping.ts +4116 -0
  37. package/src/ChoroplethMap/mixedGeo.ts +201 -0
  38. package/src/DataTable/DataTable.md +372 -0
  39. package/src/DataTable/DataTable.vue +406 -0
  40. package/src/LineChart/LineChart.md +1225 -0
  41. package/src/LineChart/LineChart.vue +1555 -0
  42. package/src/_shared/ChartAnnotations.vue +420 -0
  43. package/src/_shared/ChartZoomControls.vue +138 -0
  44. package/src/_shared/annotations.ts +106 -0
  45. package/src/_shared/axes.ts +69 -0
  46. package/src/_shared/chartProps.ts +201 -0
  47. package/src/_shared/computeTicks.ts +42 -0
  48. package/src/_shared/contrast.ts +100 -0
  49. package/src/_shared/dateAxis.ts +501 -0
  50. package/src/_shared/index.ts +78 -0
  51. package/src/_shared/mapTheme.ts +551 -0
  52. package/src/_shared/scale.ts +86 -0
  53. package/src/_shared/seriesCsv.ts +68 -0
  54. package/src/_shared/touch.ts +8 -0
  55. package/src/_shared/useChartFoundation.ts +175 -0
  56. package/src/_shared/useChartFullscreen.ts +254 -0
  57. package/src/_shared/useChartMenu.ts +111 -0
  58. package/src/_shared/useChartPadding.ts +235 -0
  59. package/src/_shared/useChartSize.ts +58 -0
  60. package/src/_shared/useChartTooltip.ts +205 -0
  61. package/src/env.d.ts +4 -0
  62. package/src/hsa-mapping.ts +1 -0
  63. package/src/index.ts +41 -0
  64. package/src/tooltip-position.ts +55 -0
  65. package/src/us-cities/data.ts +1371 -0
  66. package/src/us-cities/index.ts +122 -0
  67. 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
+ }