@cfasim-ui/docs 0.3.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.
Files changed (60) hide show
  1. package/LICENSE +201 -0
  2. package/charts/ChartMenu/ChartMenu.vue +140 -0
  3. package/charts/ChartMenu/download.ts +44 -0
  4. package/charts/ChartTooltip/ChartTooltip.vue +97 -0
  5. package/charts/ChoroplethMap/ChoroplethMap.md +398 -0
  6. package/charts/ChoroplethMap/ChoroplethMap.vue +777 -0
  7. package/charts/ChoroplethMap/hsaMapping.ts +4116 -0
  8. package/charts/DataTable/DataTable.md +143 -0
  9. package/charts/DataTable/DataTable.vue +277 -0
  10. package/charts/LineChart/LineChart.md +472 -0
  11. package/charts/LineChart/LineChart.vue +1216 -0
  12. package/charts/index.ts +23 -0
  13. package/charts/tooltip-position.ts +49 -0
  14. package/components/Box/Box.md +49 -0
  15. package/components/Box/Box.vue +52 -0
  16. package/components/Button/Button.md +67 -0
  17. package/components/Button/Button.vue +81 -0
  18. package/components/Expander/Expander.md +34 -0
  19. package/components/Expander/Expander.vue +95 -0
  20. package/components/Hint/Hint.md +29 -0
  21. package/components/Hint/Hint.vue +83 -0
  22. package/components/Icon/Icon.md +67 -0
  23. package/components/Icon/Icon.vue +112 -0
  24. package/components/LightDarkToggle/LightDarkToggle.vue +49 -0
  25. package/components/NumberInput/NumberInput.md +305 -0
  26. package/components/NumberInput/NumberInput.vue +531 -0
  27. package/components/SelectBox/SelectBox.md +110 -0
  28. package/components/SelectBox/SelectBox.vue +195 -0
  29. package/components/SidebarLayout/SidebarLayout.md +104 -0
  30. package/components/SidebarLayout/SidebarLayout.vue +466 -0
  31. package/components/Spinner/Spinner.md +51 -0
  32. package/components/Spinner/Spinner.vue +55 -0
  33. package/components/TextInput/TextInput.md +82 -0
  34. package/components/TextInput/TextInput.vue +94 -0
  35. package/components/Toggle/Toggle.md +81 -0
  36. package/components/Toggle/Toggle.vue +81 -0
  37. package/components/index.ts +15 -0
  38. package/index.json +121 -0
  39. package/package.json +24 -0
  40. package/pyodide/index.ts +7 -0
  41. package/pyodide/pyodide.worker.ts +233 -0
  42. package/pyodide/pyodideWorkerApi.ts +102 -0
  43. package/pyodide/useModel.ts +86 -0
  44. package/pyodide/vitePlugin.js +51 -0
  45. package/shared/ModelOutput.ts +88 -0
  46. package/shared/csv.ts +22 -0
  47. package/shared/index.ts +24 -0
  48. package/shared/transferUtils.ts +126 -0
  49. package/shared/useUrlParams.ts +296 -0
  50. package/theme/all.js +5 -0
  51. package/theme/base.css +176 -0
  52. package/theme/cfasim.css +3 -0
  53. package/theme/theme.css +113 -0
  54. package/theme/themes/cdc.css +22 -0
  55. package/theme/utilities.css +518 -0
  56. package/wasm/index.ts +2 -0
  57. package/wasm/useModel.ts +53 -0
  58. package/wasm/vitePlugin.js +35 -0
  59. package/wasm/wasm.worker.ts +74 -0
  60. package/wasm/wasmWorkerApi.ts +38 -0
@@ -0,0 +1,1216 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch, onMounted, onUnmounted } from "vue";
3
+ import ChartMenu from "../ChartMenu/ChartMenu.vue";
4
+ import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
5
+ import { saveSvg, savePng, downloadCsv } from "../ChartMenu/download.js";
6
+ import { placeTooltip } from "../tooltip-position.js";
7
+
8
+ export interface Series {
9
+ data: number[];
10
+ color?: string;
11
+ dashed?: boolean;
12
+ strokeWidth?: number;
13
+ opacity?: number;
14
+ lineOpacity?: number;
15
+ dotOpacity?: number;
16
+ line?: boolean;
17
+ dots?: boolean;
18
+ dotRadius?: number;
19
+ dotFill?: string;
20
+ dotStroke?: string;
21
+ /** Label shown in the inline legend */
22
+ legend?: string;
23
+ }
24
+
25
+ export interface Area {
26
+ upper: number[];
27
+ lower: number[];
28
+ color?: string;
29
+ opacity?: number;
30
+ }
31
+
32
+ export interface AreaSection {
33
+ /** Index into the series array. When omitted, fills the full chart height with no line. */
34
+ seriesIndex?: number;
35
+ /** Start x-index (inclusive) */
36
+ startIndex: number;
37
+ /** End x-index (inclusive) */
38
+ endIndex: number;
39
+ /** Fill color; defaults to referenced series color */
40
+ color?: string;
41
+ /** Fill opacity; defaults to 0.15 */
42
+ opacity?: number;
43
+ /** Primary label text (e.g. "Day 36–63") */
44
+ label?: string;
45
+ /** Secondary description text (e.g. "40.0M vaccines administered") */
46
+ description?: string;
47
+ /** Stroke width for the highlighted line segment (default: 2) */
48
+ strokeWidth?: number;
49
+ /** Dashed stroke pattern */
50
+ dashed?: boolean;
51
+ /** Label placement: "below" (default) renders below chart, "inline" renders in legend row, false hides label */
52
+ legend?: "inline" | "below" | false;
53
+ }
54
+
55
+ const props = withDefaults(
56
+ defineProps<{
57
+ data?: number[];
58
+ series?: Series[];
59
+ areas?: Area[];
60
+ areaSections?: AreaSection[];
61
+ width?: number;
62
+ height?: number;
63
+ lineOpacity?: number;
64
+ title?: string;
65
+ xLabel?: string;
66
+ yLabel?: string;
67
+ yMin?: number;
68
+ xMin?: number;
69
+ /**
70
+ * Tick placement on the x-axis. Number = interval in data units
71
+ * (respecting `xMin`, e.g. `7` ticks every 7 days). Array = explicit tick
72
+ * values in data space; values outside the data range are dropped.
73
+ * When omitted, ticks are chosen automatically.
74
+ */
75
+ xTicks?: number | number[];
76
+ /**
77
+ * Tick placement on the y-axis. Number = interval in data units. Array =
78
+ * explicit tick values; values outside the data range are dropped. When
79
+ * omitted, ticks are chosen automatically.
80
+ */
81
+ yTicks?: number | number[];
82
+ /** Formatter for x-axis tick labels. Receives the raw numeric value. */
83
+ xTickFormat?: (value: number, index: number) => string;
84
+ /** Formatter for y-axis tick labels. Receives the raw numeric value. */
85
+ yTickFormat?: (value: number) => string;
86
+ /**
87
+ * @deprecated Use `xTickFormat` (e.g. `(_, i) => labels[i]`) together
88
+ * with `xTicks` for explicit control. Still honored for tooltip x-labels
89
+ * and as a default x-tick formatter when `xTickFormat` is not provided.
90
+ */
91
+ xLabels?: string[];
92
+ debounce?: number;
93
+ menu?: boolean | string;
94
+ xGrid?: boolean;
95
+ yGrid?: boolean;
96
+ /** Custom per-index data passed to the tooltip slot */
97
+ tooltipData?: unknown[];
98
+ /** Tooltip activation mode. Default: 'hover' */
99
+ tooltipTrigger?: "hover" | "click";
100
+ /**
101
+ * Boundary for tooltip flip/clamp. `"none"` always places to the right of
102
+ * the pointer with no clamping. `"chart"` (default) uses the chart
103
+ * container's bounding box. `"window"` uses the viewport.
104
+ */
105
+ tooltipClamp?: "none" | "chart" | "window";
106
+ /**
107
+ * Custom CSV content for the Download CSV menu item. Can be a raw CSV
108
+ * string or a function returning one. When omitted, CSV is generated
109
+ * from the chart series.
110
+ */
111
+ csv?: string | (() => string);
112
+ /** Filename (without extension) for downloaded SVG, PNG and CSV files. */
113
+ filename?: string;
114
+ /**
115
+ * Show a plain text link below the chart to download the CSV data.
116
+ * Pass `true` for the default label ("Download data (CSV)") or a string
117
+ * to customize the link text.
118
+ */
119
+ downloadLink?: boolean | string;
120
+ }>(),
121
+ { lineOpacity: 1, menu: true, tooltipClamp: "chart" },
122
+ );
123
+
124
+ const emit = defineEmits<{
125
+ (e: "hover", payload: { index: number } | null): void;
126
+ }>();
127
+
128
+ defineSlots<{
129
+ tooltip?(props: {
130
+ index: number;
131
+ xLabel?: string;
132
+ values: { value: number; color: string; seriesIndex: number }[];
133
+ data: unknown;
134
+ }): unknown;
135
+ }>();
136
+
137
+ const containerRef = ref<HTMLElement | null>(null);
138
+ const svgRef = ref<SVGSVGElement | null>(null);
139
+ const measuredWidth = ref(0);
140
+ let observer: ResizeObserver | null = null;
141
+ let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
142
+
143
+ onMounted(() => {
144
+ if (containerRef.value) {
145
+ measuredWidth.value = containerRef.value.clientWidth;
146
+ observer = new ResizeObserver((entries) => {
147
+ const entry = entries[0];
148
+ if (!entry) return;
149
+ if (props.debounce) {
150
+ if (resizeTimeout) clearTimeout(resizeTimeout);
151
+ resizeTimeout = setTimeout(() => {
152
+ measuredWidth.value = entry.contentRect.width;
153
+ }, props.debounce);
154
+ } else {
155
+ measuredWidth.value = entry.contentRect.width;
156
+ }
157
+ });
158
+ observer.observe(containerRef.value);
159
+ }
160
+ });
161
+
162
+ onUnmounted(() => {
163
+ observer?.disconnect();
164
+ if (resizeTimeout) clearTimeout(resizeTimeout);
165
+ });
166
+
167
+ const width = computed(() => props.width ?? (measuredWidth.value || 400));
168
+ const height = computed(() => props.height ?? 200);
169
+
170
+ const INLINE_LEGEND_HEIGHT = 20;
171
+
172
+ const hasInlineLegend = computed(
173
+ () =>
174
+ allSeries.value.some((s) => s.legend) ||
175
+ props.areaSections?.some(
176
+ (s) => s.legend === "inline" && (s.label || s.description),
177
+ ),
178
+ );
179
+
180
+ const padding = computed(() => ({
181
+ top:
182
+ (props.title ? 30 : 10) +
183
+ (hasInlineLegend.value ? INLINE_LEGEND_HEIGHT : 0),
184
+ right: 10,
185
+ bottom: props.xLabel ? 46 : 30,
186
+ left: props.yLabel ? 66 : 50,
187
+ }));
188
+
189
+ const innerW = computed(
190
+ () => width.value - padding.value.left - padding.value.right,
191
+ );
192
+ const innerH = computed(
193
+ () => height.value - padding.value.top - padding.value.bottom,
194
+ );
195
+
196
+ const allSeries = computed<Series[]>(() => {
197
+ if (props.series && props.series.length > 0) return props.series;
198
+ if (props.data) return [{ data: props.data }];
199
+ return [];
200
+ });
201
+
202
+ const allAreas = computed<Area[]>(() => props.areas ?? []);
203
+
204
+ const maxLen = computed(() => {
205
+ let m = 0;
206
+ for (const s of allSeries.value) {
207
+ if (s.data.length > m) m = s.data.length;
208
+ }
209
+ for (const a of allAreas.value) {
210
+ if (a.upper.length > m) m = a.upper.length;
211
+ if (a.lower.length > m) m = a.lower.length;
212
+ }
213
+ return m;
214
+ });
215
+
216
+ const extent = computed(() => {
217
+ let min = Infinity;
218
+ let max = -Infinity;
219
+ for (const s of allSeries.value) {
220
+ for (const v of s.data) {
221
+ if (!isFinite(v)) continue;
222
+ if (v < min) min = v;
223
+ if (v > max) max = v;
224
+ }
225
+ }
226
+ for (const a of allAreas.value) {
227
+ for (const v of a.upper) {
228
+ if (!isFinite(v)) continue;
229
+ if (v < min) min = v;
230
+ if (v > max) max = v;
231
+ }
232
+ for (const v of a.lower) {
233
+ if (!isFinite(v)) continue;
234
+ if (v < min) min = v;
235
+ if (v > max) max = v;
236
+ }
237
+ }
238
+ if (!isFinite(min)) return { min: 0, max: 0, range: 1 };
239
+ if (props.yMin != null && props.yMin < min) min = props.yMin;
240
+ return { min, max, range: max - min || 1 };
241
+ });
242
+
243
+ function toPath(data: number[]): string {
244
+ if (data.length === 0) return "";
245
+ const { min, range } = extent.value;
246
+ const len = maxLen.value;
247
+ const xScale = innerW.value / (len - 1 || 1);
248
+ const yScale = innerH.value / range;
249
+ const py = padding.value.top + innerH.value;
250
+ let d = "";
251
+ let inSegment = false;
252
+ for (let i = 0; i < data.length; i++) {
253
+ if (!isFinite(data[i])) {
254
+ inSegment = false;
255
+ continue;
256
+ }
257
+ const x = padding.value.left + i * xScale;
258
+ const y = py - (data[i] - min) * yScale;
259
+ d += inSegment ? `L${x},${y}` : `M${x},${y}`;
260
+ inSegment = true;
261
+ }
262
+ return d;
263
+ }
264
+
265
+ function toPoints(data: number[]): { x: number; y: number }[] {
266
+ const { min, range } = extent.value;
267
+ const len = maxLen.value;
268
+ const xScale = innerW.value / (len - 1 || 1);
269
+ const yScale = innerH.value / range;
270
+ const py = padding.value.top + innerH.value;
271
+ const pts: { x: number; y: number }[] = [];
272
+ for (let i = 0; i < data.length; i++) {
273
+ if (!isFinite(data[i])) continue;
274
+ pts.push({
275
+ x: padding.value.left + i * xScale,
276
+ y: py - (data[i] - min) * yScale,
277
+ });
278
+ }
279
+ return pts;
280
+ }
281
+
282
+ function toAreaPath(upper: number[], lower: number[]): string {
283
+ const len = Math.min(upper.length, lower.length);
284
+ if (len === 0) return "";
285
+ const { min, range } = extent.value;
286
+ const ml = maxLen.value;
287
+ const xScale = innerW.value / (ml - 1 || 1);
288
+ const yScale = innerH.value / range;
289
+ const py = padding.value.top + innerH.value;
290
+ const x = (i: number) => padding.value.left + i * xScale;
291
+ const y = (v: number) => py - (v - min) * yScale;
292
+ // Collect contiguous segments where both upper and lower are finite
293
+ const segments: number[][] = [];
294
+ let seg: number[] = [];
295
+ for (let i = 0; i < len; i++) {
296
+ if (isFinite(upper[i]) && isFinite(lower[i])) {
297
+ seg.push(i);
298
+ } else if (seg.length) {
299
+ segments.push(seg);
300
+ seg = [];
301
+ }
302
+ }
303
+ if (seg.length) segments.push(seg);
304
+ let d = "";
305
+ for (const s of segments) {
306
+ d += `M${x(s[0])},${y(upper[s[0]])}`;
307
+ for (let j = 1; j < s.length; j++) d += `L${x(s[j])},${y(upper[s[j]])}`;
308
+ for (let j = s.length - 1; j >= 0; j--)
309
+ d += `L${x(s[j])},${y(lower[s[j]])}`;
310
+ d += "Z";
311
+ }
312
+ return d;
313
+ }
314
+
315
+ function toSectionPath(section: AreaSection, closed = true): string {
316
+ const len = maxLen.value;
317
+ const xScale = innerW.value / (len - 1 || 1);
318
+ const py = padding.value.top + innerH.value;
319
+ const x = (i: number) => padding.value.left + i * xScale;
320
+
321
+ // No seriesIndex: full-height rectangle spanning the range
322
+ if (section.seriesIndex == null) {
323
+ const start = Math.max(0, section.startIndex);
324
+ const end = Math.min(len - 1, section.endIndex);
325
+ if (start > end) return "";
326
+ return `M${x(start)},${padding.value.top}L${x(end)},${padding.value.top}L${x(end)},${py}L${x(start)},${py}Z`;
327
+ }
328
+
329
+ const s = allSeries.value[section.seriesIndex];
330
+ if (!s) return "";
331
+ const { min, range } = extent.value;
332
+ const yScale = innerH.value / range;
333
+ const y = (v: number) => py - (v - min) * yScale;
334
+
335
+ const start = Math.max(0, section.startIndex);
336
+ const end = Math.min(s.data.length - 1, section.endIndex);
337
+ if (start > end) return "";
338
+
339
+ let d = `M${x(start)},${y(s.data[start])}`;
340
+ for (let i = start + 1; i <= end; i++) {
341
+ if (!isFinite(s.data[i])) continue;
342
+ d += `L${x(i)},${y(s.data[i])}`;
343
+ }
344
+ if (closed) {
345
+ d += `L${x(end)},${py}`;
346
+ d += `L${x(start)},${py}`;
347
+ d += "Z";
348
+ }
349
+ return d;
350
+ }
351
+
352
+ const SECTION_LABEL_ROW_HEIGHT = 36;
353
+ const SECTION_LABEL_TOP_MARGIN = 12;
354
+ const SECTION_LABEL_CHAR_WIDTH = 7;
355
+ const SECTION_LABEL_H_GAP = 16;
356
+
357
+ interface PositionedSectionLabel {
358
+ cx: number;
359
+ labelText: string;
360
+ descText: string;
361
+ textWidth: number;
362
+ row: number;
363
+ color: string;
364
+ fillOpacity: number;
365
+ }
366
+
367
+ const sectionLabels = computed<{
368
+ labels: PositionedSectionLabel[];
369
+ extraHeight: number;
370
+ }>(() => {
371
+ const sections = props.areaSections;
372
+ if (!sections?.length) return { labels: [], extraHeight: 0 };
373
+
374
+ const len = maxLen.value;
375
+ const xScale = innerW.value / (len - 1 || 1);
376
+
377
+ const items: PositionedSectionLabel[] = [];
378
+ for (const sec of sections) {
379
+ if (!sec.label && !sec.description) continue;
380
+ if (sec.legend === "inline" || sec.legend === false) continue;
381
+ const cx =
382
+ padding.value.left + ((sec.startIndex + sec.endIndex) / 2) * xScale;
383
+ const labelText = sec.label ?? "";
384
+ const descText = sec.description ?? "";
385
+ const maxChars = Math.max(labelText.length, descText.length);
386
+ const textWidth = maxChars * SECTION_LABEL_CHAR_WIDTH;
387
+ const color =
388
+ sec.color ??
389
+ (sec.seriesIndex != null
390
+ ? (allSeries.value[sec.seriesIndex]?.color ?? "currentColor")
391
+ : "#999");
392
+ items.push({
393
+ cx,
394
+ labelText,
395
+ descText,
396
+ textWidth,
397
+ row: 0,
398
+ color,
399
+ fillOpacity: sec.opacity ?? 0.15,
400
+ });
401
+ }
402
+
403
+ items.sort((a, b) => a.cx - b.cx);
404
+
405
+ // Greedy collision detection
406
+ const rowRightEdges: number[] = [];
407
+ for (const item of items) {
408
+ const left = item.cx - item.textWidth / 2;
409
+ let row = 0;
410
+ while (row < rowRightEdges.length) {
411
+ if (left >= rowRightEdges[row] + SECTION_LABEL_H_GAP) break;
412
+ row++;
413
+ }
414
+ item.row = row;
415
+ const right = item.cx + item.textWidth / 2;
416
+ rowRightEdges[row] = Math.max(rowRightEdges[row] ?? -Infinity, right);
417
+ }
418
+
419
+ if (items.length === 0) return { labels: [], extraHeight: 0 };
420
+ const maxRow = Math.max(...items.map((it) => it.row));
421
+ const extraHeight =
422
+ (maxRow + 1) * SECTION_LABEL_ROW_HEIGHT + SECTION_LABEL_TOP_MARGIN;
423
+ return { labels: items, extraHeight };
424
+ });
425
+
426
+ interface InlineLegendItem {
427
+ label: string;
428
+ color: string;
429
+ type: "series" | "section";
430
+ dashed?: boolean;
431
+ fillOpacity?: number;
432
+ }
433
+
434
+ const inlineLegendItems = computed<InlineLegendItem[]>(() => {
435
+ const items: InlineLegendItem[] = [];
436
+ for (const s of allSeries.value) {
437
+ if (!s.legend) continue;
438
+ items.push({
439
+ label: s.legend,
440
+ color: s.color ?? "currentColor",
441
+ type: "series",
442
+ dashed: s.dashed,
443
+ });
444
+ }
445
+ const sections = props.areaSections;
446
+ if (sections) {
447
+ for (const sec of sections) {
448
+ if (sec.legend !== "inline") continue;
449
+ if (!sec.label && !sec.description) continue;
450
+ const label = [sec.label, sec.description].filter(Boolean).join(" ");
451
+ const color =
452
+ sec.color ??
453
+ (sec.seriesIndex != null
454
+ ? (allSeries.value[sec.seriesIndex]?.color ?? "currentColor")
455
+ : "#999");
456
+ items.push({
457
+ label,
458
+ color,
459
+ type: "section",
460
+ fillOpacity: sec.opacity ?? 0.15,
461
+ });
462
+ }
463
+ }
464
+ return items;
465
+ });
466
+
467
+ const totalHeight = computed(
468
+ () => height.value + sectionLabels.value.extraHeight,
469
+ );
470
+
471
+ const sectionLabelBaseY = computed(
472
+ () =>
473
+ padding.value.top +
474
+ innerH.value +
475
+ padding.value.bottom +
476
+ SECTION_LABEL_TOP_MARGIN,
477
+ );
478
+
479
+ function niceStep(range: number, targetTicks: number): number {
480
+ const rough = range / targetTicks;
481
+ const mag = Math.pow(10, Math.floor(Math.log10(rough)));
482
+ const norm = rough / mag;
483
+ let step: number;
484
+ if (norm <= 1.5) step = 1;
485
+ else if (norm <= 3) step = 2;
486
+ else if (norm <= 7) step = 5;
487
+ else step = 10;
488
+ return step * mag;
489
+ }
490
+
491
+ /** Round to nearest half-pixel so 1px SVG strokes stay sharp. */
492
+ function snap(v: number): number {
493
+ return Math.round(v) + 0.5;
494
+ }
495
+
496
+ const numFmt = new Intl.NumberFormat();
497
+ function formatTick(v: number): string {
498
+ if (Math.abs(v) >= 1000) return numFmt.format(v);
499
+ if (Number.isInteger(v)) return v.toString();
500
+ return v.toFixed(1);
501
+ }
502
+
503
+ /** Generate interval-spaced values in [min, max], inclusive. */
504
+ function intervalValues(min: number, max: number, step: number): number[] {
505
+ if (!(step > 0) || !isFinite(step)) return [];
506
+ const out: number[] = [];
507
+ const start = Math.ceil(min / step) * step;
508
+ // Cap iteration to avoid runaway loops from pathological inputs.
509
+ const maxIterations = 1000;
510
+ for (
511
+ let i = 0, v = start;
512
+ v <= max + 1e-9 && i < maxIterations;
513
+ i++, v = start + i * step
514
+ ) {
515
+ out.push(v);
516
+ }
517
+ return out;
518
+ }
519
+
520
+ const yTickItems = computed(() => {
521
+ const { min, max } = extent.value;
522
+ const toY = (v: number) =>
523
+ snap(
524
+ padding.value.top +
525
+ innerH.value -
526
+ ((v - min) / extent.value.range) * innerH.value,
527
+ );
528
+ const fmt = (v: number) =>
529
+ props.yTickFormat ? props.yTickFormat(v) : formatTick(v);
530
+
531
+ if (min === max) {
532
+ return [{ value: fmt(min), y: snap(padding.value.top + innerH.value / 2) }];
533
+ }
534
+
535
+ let values: number[];
536
+ if (Array.isArray(props.yTicks)) {
537
+ values = props.yTicks.filter((v) => v >= min && v <= max);
538
+ } else if (typeof props.yTicks === "number") {
539
+ values = intervalValues(min, max, props.yTicks);
540
+ } else {
541
+ const targetTicks = Math.max(3, Math.floor(innerH.value / 50));
542
+ values = intervalValues(min, max, niceStep(max - min, targetTicks));
543
+ }
544
+ return values.map((v) => ({ value: fmt(v), y: toY(v) }));
545
+ });
546
+
547
+ const xTickItems = computed(() => {
548
+ const len = maxLen.value;
549
+ if (len <= 1) return [];
550
+ const offset = props.xMin ?? 0;
551
+ const xMin = offset;
552
+ const xMax = offset + (len - 1);
553
+
554
+ const toX = (v: number) =>
555
+ snap(padding.value.left + ((v - offset) / (len - 1)) * innerW.value);
556
+ const fmt = (v: number, i: number) => {
557
+ if (props.xTickFormat) return props.xTickFormat(v, i);
558
+ const labels = props.xLabels;
559
+ const idx = v - offset;
560
+ if (labels && Number.isInteger(idx) && idx >= 0 && idx < labels.length) {
561
+ return labels[idx];
562
+ }
563
+ return formatTick(v);
564
+ };
565
+
566
+ let values: number[];
567
+ if (Array.isArray(props.xTicks)) {
568
+ values = props.xTicks.filter((v) => v >= xMin && v <= xMax);
569
+ } else if (typeof props.xTicks === "number") {
570
+ values = intervalValues(xMin, xMax, props.xTicks);
571
+ } else if (props.xLabels && props.xLabels.length === len) {
572
+ const targetTicks = Math.max(3, Math.floor(innerW.value / 80));
573
+ const step = Math.max(1, Math.round((len - 1) / targetTicks));
574
+ values = [];
575
+ for (let i = 0; i < len; i += step) values.push(offset + i);
576
+ } else {
577
+ const targetTicks = Math.max(3, Math.floor(innerW.value / 80));
578
+ const step = niceStep(len - 1, targetTicks);
579
+ values = [];
580
+ for (let i = 0; i <= len - 1; i += step) {
581
+ values.push(Math.round(i) + offset);
582
+ }
583
+ }
584
+ const leftEdge = padding.value.left;
585
+ const rightEdge = padding.value.left + innerW.value;
586
+ const edgeSnapPx = 1;
587
+ return values.map((v, i) => {
588
+ const x = toX(v);
589
+ let anchor: "start" | "middle" | "end" = "middle";
590
+ if (x - leftEdge <= edgeSnapPx) anchor = "start";
591
+ else if (rightEdge - x <= edgeSnapPx) anchor = "end";
592
+ return { value: fmt(v, i), x, anchor };
593
+ });
594
+ });
595
+
596
+ function menuFilename() {
597
+ if (props.filename) return props.filename;
598
+ return typeof props.menu === "string" ? props.menu : "chart";
599
+ }
600
+
601
+ function getSvgEl(): SVGSVGElement | null {
602
+ return svgRef.value;
603
+ }
604
+
605
+ function toCsv(): string {
606
+ if (typeof props.csv === "function") return props.csv();
607
+ if (typeof props.csv === "string") return props.csv;
608
+ const series = allSeries.value;
609
+ if (series.length === 0) return "";
610
+ const len = maxLen.value;
611
+ const headers =
612
+ series.length === 1
613
+ ? ["index", "value"]
614
+ : ["index", ...series.map((_, i) => `series_${i}`)];
615
+ const rows = [headers.join(",")];
616
+ for (let r = 0; r < len; r++) {
617
+ const cells = [r.toString()];
618
+ for (const s of series) {
619
+ cells.push(r < s.data.length ? String(s.data[r]) : "");
620
+ }
621
+ rows.push(cells.join(","));
622
+ }
623
+ return rows.join("\n");
624
+ }
625
+
626
+ // Tooltip hover state
627
+ const TOUCH_Y_OFFSET = 50;
628
+ const hoverIndex = ref<number | null>(null);
629
+ const isTouching = ref(false);
630
+ const tooltipRef = ref<HTMLElement | null>(null);
631
+ const pointer = ref<{ clientX: number; clientY: number } | null>(null);
632
+ const tooltipPos = ref<{ left: number; top: number } | null>(null);
633
+ const hasTooltipSlot = computed(
634
+ () => !!props.tooltipData || !!props.tooltipTrigger,
635
+ );
636
+
637
+ const hoverX = computed(() => {
638
+ if (hoverIndex.value === null) return 0;
639
+ const len = maxLen.value;
640
+ const xScale = innerW.value / (len - 1 || 1);
641
+ return padding.value.left + hoverIndex.value * xScale;
642
+ });
643
+
644
+ const hoverDots = computed(() => {
645
+ const idx = hoverIndex.value;
646
+ if (idx === null) return [];
647
+ const { min, range } = extent.value;
648
+ const yScale = innerH.value / range;
649
+ const py = padding.value.top + innerH.value;
650
+ return allSeries.value
651
+ .filter((s) => idx < s.data.length && isFinite(s.data[idx]))
652
+ .map((s) => ({
653
+ x: hoverX.value,
654
+ y: py - (s.data[idx] - min) * yScale,
655
+ color: s.color ?? "currentColor",
656
+ }));
657
+ });
658
+
659
+ const hoverSlotProps = computed(() => {
660
+ const idx = hoverIndex.value;
661
+ if (idx === null) return null;
662
+ const offset = props.xMin ?? 0;
663
+ const xLabel = props.xTickFormat
664
+ ? props.xTickFormat(idx + offset, idx)
665
+ : props.xLabels?.[idx];
666
+ return {
667
+ index: idx,
668
+ xLabel,
669
+ values: allSeries.value.map((s, i) => ({
670
+ value: s.data[idx],
671
+ color: s.color ?? "currentColor",
672
+ seriesIndex: i,
673
+ })),
674
+ data: props.tooltipData?.[idx] ?? null,
675
+ };
676
+ });
677
+
678
+ function pointerFromEvent(
679
+ event: MouseEvent | TouchEvent,
680
+ ): { clientX: number; clientY: number } | null {
681
+ if ("touches" in event) {
682
+ return event.touches[0] ?? null;
683
+ }
684
+ return event;
685
+ }
686
+
687
+ function indexFromPointer(clientX: number): number | null {
688
+ const rect = containerRef.value?.getBoundingClientRect();
689
+ if (!rect) return null;
690
+ const len = maxLen.value;
691
+ if (len <= 1) return null;
692
+ const mouseX = clientX - rect.left;
693
+ const xScale = innerW.value / (len - 1 || 1);
694
+ const dataX = (mouseX - padding.value.left) / xScale;
695
+ return Math.round(Math.max(0, Math.min(len - 1, dataX)));
696
+ }
697
+
698
+ function updateHover(event: MouseEvent | TouchEvent) {
699
+ const pt = pointerFromEvent(event);
700
+ if (!pt) return;
701
+ const idx = indexFromPointer(pt.clientX);
702
+ if (idx === null) return;
703
+ hoverIndex.value = idx;
704
+ pointer.value = { clientX: pt.clientX, clientY: pt.clientY };
705
+ emit("hover", { index: idx });
706
+ }
707
+
708
+ watch(
709
+ [pointer, hoverIndex],
710
+ () => {
711
+ if (hoverIndex.value === null || !pointer.value) {
712
+ tooltipPos.value = null;
713
+ return;
714
+ }
715
+ const el = tooltipRef.value;
716
+ const container = containerRef.value;
717
+ if (!el || !container) return;
718
+ const rect = container.getBoundingClientRect();
719
+ const offset = isTouching.value ? TOUCH_Y_OFFSET : 0;
720
+ const { left, top } = placeTooltip(
721
+ pointer.value.clientX,
722
+ pointer.value.clientY - offset,
723
+ el.offsetWidth,
724
+ el.offsetHeight,
725
+ props.tooltipClamp,
726
+ rect,
727
+ );
728
+ tooltipPos.value = { left: left - rect.left, top: top - rect.top };
729
+ },
730
+ { flush: "post" },
731
+ );
732
+
733
+ function onChartMouseMove(event: MouseEvent) {
734
+ updateHover(event);
735
+ }
736
+
737
+ function onChartMouseLeave() {
738
+ if (props.tooltipTrigger !== "click") {
739
+ hoverIndex.value = null;
740
+ emit("hover", null);
741
+ }
742
+ }
743
+
744
+ function onChartClick(event: MouseEvent) {
745
+ if (props.tooltipTrigger !== "click") return;
746
+ const pt = pointerFromEvent(event);
747
+ if (!pt) return;
748
+ const idx = indexFromPointer(pt.clientX);
749
+ if (idx === null) return;
750
+ hoverIndex.value = hoverIndex.value === idx ? null : idx;
751
+ emit("hover", hoverIndex.value !== null ? { index: idx } : null);
752
+ }
753
+
754
+ function onTouchStart(event: TouchEvent) {
755
+ isTouching.value = true;
756
+ updateHover(event);
757
+ }
758
+
759
+ function onTouchMove(event: TouchEvent) {
760
+ updateHover(event);
761
+ }
762
+
763
+ function onTouchEnd() {
764
+ isTouching.value = false;
765
+ hoverIndex.value = null;
766
+ emit("hover", null);
767
+ }
768
+
769
+ const downloadLinkText = computed(() => {
770
+ if (!props.downloadLink) return null;
771
+ return typeof props.downloadLink === "string"
772
+ ? props.downloadLink
773
+ : "Download data (CSV)";
774
+ });
775
+
776
+ const csvHref = computed(() => {
777
+ if (!props.downloadLink) return null;
778
+ return `data:text/csv;charset=utf-8,${encodeURIComponent(toCsv())}`;
779
+ });
780
+
781
+ const menuItems = computed<ChartMenuItem[]>(() => {
782
+ const fname = menuFilename();
783
+ const items: ChartMenuItem[] = [
784
+ {
785
+ label: "Save as SVG",
786
+ action: () => {
787
+ const el = getSvgEl();
788
+ if (el) saveSvg(el, fname);
789
+ },
790
+ },
791
+ {
792
+ label: "Save as PNG",
793
+ action: () => {
794
+ const el = getSvgEl();
795
+ if (el) savePng(el, fname);
796
+ },
797
+ },
798
+ ];
799
+ if (!props.downloadLink) {
800
+ items.push({
801
+ label: "Download CSV",
802
+ action: () => downloadCsv(toCsv(), fname),
803
+ });
804
+ }
805
+ return items;
806
+ });
807
+ </script>
808
+
809
+ <template>
810
+ <div ref="containerRef" class="line-chart-wrapper">
811
+ <ChartMenu v-if="menu" :items="menuItems" />
812
+ <svg ref="svgRef" :width="width" :height="totalHeight">
813
+ <!-- title -->
814
+ <text
815
+ v-if="title"
816
+ :x="width / 2"
817
+ :y="18"
818
+ text-anchor="middle"
819
+ font-size="14"
820
+ font-weight="600"
821
+ fill="currentColor"
822
+ >
823
+ {{ title }}
824
+ </text>
825
+ <!-- inline legend -->
826
+ <g v-if="inlineLegendItems.length > 0">
827
+ <template v-for="(item, i) in inlineLegendItems" :key="'ileg' + i">
828
+ <!-- series indicator: line -->
829
+ <line
830
+ v-if="item.type === 'series'"
831
+ :x1="padding.left + i * 120"
832
+ :y1="padding.top - INLINE_LEGEND_HEIGHT / 2"
833
+ :x2="padding.left + i * 120 + 12"
834
+ :y2="padding.top - INLINE_LEGEND_HEIGHT / 2"
835
+ :stroke="item.color"
836
+ stroke-width="2"
837
+ :stroke-dasharray="item.dashed ? '4 2' : undefined"
838
+ />
839
+ <!-- section indicator: filled circle -->
840
+ <circle
841
+ v-else
842
+ :cx="padding.left + i * 120 + 4"
843
+ :cy="padding.top - INLINE_LEGEND_HEIGHT / 2"
844
+ r="4"
845
+ :fill="item.color"
846
+ :fill-opacity="item.fillOpacity"
847
+ :stroke="item.color"
848
+ stroke-width="1.5"
849
+ />
850
+ <text
851
+ :x="padding.left + i * 120 + 18"
852
+ :y="padding.top - INLINE_LEGEND_HEIGHT / 2 + 4"
853
+ font-size="11"
854
+ fill="currentColor"
855
+ >
856
+ {{ item.label }}
857
+ </text>
858
+ </template>
859
+ </g>
860
+ <!-- axes -->
861
+ <line
862
+ :x1="snap(padding.left)"
863
+ :y1="snap(padding.top)"
864
+ :x2="snap(padding.left)"
865
+ :y2="snap(padding.top + innerH)"
866
+ stroke="currentColor"
867
+ stroke-opacity="0.3"
868
+ />
869
+ <line
870
+ :x1="snap(padding.left)"
871
+ :y1="snap(padding.top + innerH)"
872
+ :x2="snap(padding.left + innerW)"
873
+ :y2="snap(padding.top + innerH)"
874
+ stroke="currentColor"
875
+ stroke-opacity="0.3"
876
+ />
877
+ <!-- y grid lines -->
878
+ <template v-if="yGrid">
879
+ <line
880
+ v-for="(tick, i) in yTickItems"
881
+ :key="'yg' + i"
882
+ :x1="padding.left"
883
+ :y1="tick.y"
884
+ :x2="padding.left + innerW"
885
+ :y2="tick.y"
886
+ stroke="currentColor"
887
+ stroke-opacity="0.1"
888
+ />
889
+ </template>
890
+ <!-- x grid lines -->
891
+ <template v-if="xGrid">
892
+ <line
893
+ v-for="(tick, i) in xTickItems"
894
+ :key="'xg' + i"
895
+ :x1="tick.x"
896
+ :y1="padding.top"
897
+ :x2="tick.x"
898
+ :y2="padding.top + innerH"
899
+ stroke="currentColor"
900
+ stroke-opacity="0.1"
901
+ />
902
+ </template>
903
+ <!-- y tick labels -->
904
+ <text
905
+ v-for="(tick, i) in yTickItems"
906
+ :key="'y' + i"
907
+ data-testid="y-tick"
908
+ :x="padding.left - 6"
909
+ :y="tick.y"
910
+ text-anchor="end"
911
+ dominant-baseline="middle"
912
+ font-size="10"
913
+ fill="currentColor"
914
+ fill-opacity="0.6"
915
+ >
916
+ {{ tick.value }}
917
+ </text>
918
+ <!-- y axis label -->
919
+ <text
920
+ v-if="yLabel"
921
+ :x="0"
922
+ :y="0"
923
+ :transform="`translate(14, ${padding.top + innerH / 2}) rotate(-90)`"
924
+ text-anchor="middle"
925
+ font-size="13"
926
+ fill="currentColor"
927
+ >
928
+ {{ yLabel }}
929
+ </text>
930
+ <!-- x tick labels -->
931
+ <text
932
+ v-for="(tick, i) in xTickItems"
933
+ :key="'x' + i"
934
+ data-testid="x-tick"
935
+ :x="tick.x"
936
+ :y="padding.top + innerH + 16"
937
+ :text-anchor="tick.anchor"
938
+ font-size="10"
939
+ fill="currentColor"
940
+ fill-opacity="0.6"
941
+ >
942
+ {{ tick.value }}
943
+ </text>
944
+ <!-- x axis label -->
945
+ <text
946
+ v-if="xLabel"
947
+ :x="padding.left + innerW / 2"
948
+ :y="height - 4"
949
+ text-anchor="middle"
950
+ font-size="13"
951
+ fill="currentColor"
952
+ >
953
+ {{ xLabel }}
954
+ </text>
955
+ <!-- areas -->
956
+ <path
957
+ v-for="(a, i) in allAreas"
958
+ :key="'area' + i"
959
+ :d="toAreaPath(a.upper, a.lower)"
960
+ :fill="a.color ?? 'currentColor'"
961
+ :fill-opacity="a.opacity ?? 0.2"
962
+ stroke="none"
963
+ />
964
+ <!-- data lines and dots -->
965
+ <template v-for="(s, i) in allSeries" :key="i">
966
+ <path
967
+ v-if="s.line !== false"
968
+ :d="toPath(s.data)"
969
+ fill="none"
970
+ :stroke="s.color ?? 'currentColor'"
971
+ :stroke-width="s.strokeWidth ?? 1.5"
972
+ :stroke-opacity="s.lineOpacity ?? s.opacity ?? lineOpacity"
973
+ :stroke-dasharray="s.dashed ? '6 3' : undefined"
974
+ />
975
+ <template v-if="s.dots">
976
+ <circle
977
+ v-for="(pt, j) in toPoints(s.data)"
978
+ :key="j"
979
+ :cx="pt.x"
980
+ :cy="pt.y"
981
+ :r="s.dotRadius ?? (s.strokeWidth ?? 1.5) + 1"
982
+ :fill="s.dotFill ?? s.color ?? 'currentColor'"
983
+ :fill-opacity="s.dotOpacity ?? s.opacity ?? lineOpacity"
984
+ :stroke="s.dotStroke ?? 'none'"
985
+ />
986
+ </template>
987
+ </template>
988
+ <!-- area sections (rendered above series) -->
989
+ <template v-for="(sec, i) in areaSections ?? []" :key="'areasec' + i">
990
+ <path
991
+ :d="toSectionPath(sec)"
992
+ :fill="
993
+ sec.color ??
994
+ (sec.seriesIndex != null
995
+ ? (allSeries[sec.seriesIndex]?.color ?? 'currentColor')
996
+ : '#999')
997
+ "
998
+ :fill-opacity="sec.opacity ?? 0.15"
999
+ stroke="none"
1000
+ />
1001
+ <path
1002
+ v-if="sec.seriesIndex != null"
1003
+ :d="toSectionPath(sec, false)"
1004
+ fill="none"
1005
+ :stroke="
1006
+ sec.color ?? allSeries[sec.seriesIndex]?.color ?? 'currentColor'
1007
+ "
1008
+ :stroke-width="sec.strokeWidth ?? 2"
1009
+ :stroke-dasharray="sec.dashed ? '6 3' : undefined"
1010
+ />
1011
+ <!-- vertical edge lines for full-height sections -->
1012
+ <template v-if="sec.seriesIndex == null">
1013
+ <line
1014
+ :x1="
1015
+ snap(padding.left + sec.startIndex * (innerW / (maxLen - 1 || 1)))
1016
+ "
1017
+ :y1="padding.top"
1018
+ :x2="
1019
+ snap(padding.left + sec.startIndex * (innerW / (maxLen - 1 || 1)))
1020
+ "
1021
+ :y2="padding.top + innerH"
1022
+ :stroke="sec.color ?? '#999'"
1023
+ :stroke-width="sec.strokeWidth ?? 2"
1024
+ :stroke-dasharray="sec.dashed ? '6 3' : undefined"
1025
+ />
1026
+ <line
1027
+ :x1="
1028
+ snap(padding.left + sec.endIndex * (innerW / (maxLen - 1 || 1)))
1029
+ "
1030
+ :y1="padding.top"
1031
+ :x2="
1032
+ snap(padding.left + sec.endIndex * (innerW / (maxLen - 1 || 1)))
1033
+ "
1034
+ :y2="padding.top + innerH"
1035
+ :stroke="sec.color ?? '#999'"
1036
+ :stroke-width="sec.strokeWidth ?? 2"
1037
+ :stroke-dasharray="sec.dashed ? '6 3' : undefined"
1038
+ />
1039
+ </template>
1040
+ <!-- tick marks at section boundaries -->
1041
+ <line
1042
+ :x1="
1043
+ snap(padding.left + sec.startIndex * (innerW / (maxLen - 1 || 1)))
1044
+ "
1045
+ :y1="padding.top + innerH - 4"
1046
+ :x2="
1047
+ snap(padding.left + sec.startIndex * (innerW / (maxLen - 1 || 1)))
1048
+ "
1049
+ :y2="padding.top + innerH + 4"
1050
+ stroke="currentColor"
1051
+ stroke-opacity="0.4"
1052
+ />
1053
+ <line
1054
+ :x1="snap(padding.left + sec.endIndex * (innerW / (maxLen - 1 || 1)))"
1055
+ :y1="padding.top + innerH - 4"
1056
+ :x2="snap(padding.left + sec.endIndex * (innerW / (maxLen - 1 || 1)))"
1057
+ :y2="padding.top + innerH + 4"
1058
+ stroke="currentColor"
1059
+ stroke-opacity="0.4"
1060
+ />
1061
+ </template>
1062
+ <!-- Tooltip: crosshair line -->
1063
+ <line
1064
+ v-if="hasTooltipSlot && hoverIndex !== null"
1065
+ :x1="snap(hoverX)"
1066
+ :y1="padding.top"
1067
+ :x2="snap(hoverX)"
1068
+ :y2="padding.top + innerH"
1069
+ stroke="currentColor"
1070
+ stroke-opacity="0.3"
1071
+ stroke-dasharray="4 2"
1072
+ pointer-events="none"
1073
+ />
1074
+ <!-- Tooltip: hover dots -->
1075
+ <circle
1076
+ v-for="(dot, i) in hoverDots"
1077
+ :key="'hd' + i"
1078
+ :cx="dot.x"
1079
+ :cy="dot.y"
1080
+ r="4"
1081
+ :fill="dot.color"
1082
+ stroke="var(--color-bg-0, #fff)"
1083
+ stroke-width="2"
1084
+ pointer-events="none"
1085
+ />
1086
+ <!-- Tooltip: interaction overlay -->
1087
+ <rect
1088
+ v-if="hasTooltipSlot"
1089
+ :x="padding.left"
1090
+ :y="padding.top"
1091
+ :width="innerW"
1092
+ :height="innerH"
1093
+ fill="transparent"
1094
+ style="cursor: crosshair; touch-action: none"
1095
+ @mousemove="onChartMouseMove"
1096
+ @mouseleave="onChartMouseLeave"
1097
+ @click="onChartClick"
1098
+ @touchstart.prevent="onTouchStart"
1099
+ @touchmove.prevent="onTouchMove"
1100
+ @touchend="onTouchEnd"
1101
+ />
1102
+ <!-- area section labels -->
1103
+ <g v-for="(item, i) in sectionLabels.labels" :key="'seclab' + i">
1104
+ <circle
1105
+ :cx="item.cx - item.textWidth / 2 - 2"
1106
+ :cy="sectionLabelBaseY + item.row * SECTION_LABEL_ROW_HEIGHT + 4"
1107
+ r="4"
1108
+ :fill="item.color"
1109
+ :fill-opacity="item.fillOpacity"
1110
+ :stroke="item.color"
1111
+ stroke-width="1.5"
1112
+ />
1113
+ <text
1114
+ v-if="item.labelText"
1115
+ :x="item.cx - item.textWidth / 2 + 8"
1116
+ :y="sectionLabelBaseY + item.row * SECTION_LABEL_ROW_HEIGHT + 8"
1117
+ font-size="11"
1118
+ font-weight="600"
1119
+ :fill="item.color"
1120
+ >
1121
+ {{ item.labelText }}
1122
+ </text>
1123
+ <text
1124
+ v-if="item.descText"
1125
+ :x="item.cx - item.textWidth / 2 + 8"
1126
+ :y="sectionLabelBaseY + item.row * SECTION_LABEL_ROW_HEIGHT + 22"
1127
+ font-size="11"
1128
+ fill="currentColor"
1129
+ fill-opacity="0.6"
1130
+ >
1131
+ {{ item.descText }}
1132
+ </text>
1133
+ </g>
1134
+ </svg>
1135
+ <!-- Tooltip floating content -->
1136
+ <div
1137
+ v-if="hasTooltipSlot && hoverIndex !== null && hoverSlotProps"
1138
+ ref="tooltipRef"
1139
+ class="chart-tooltip-content"
1140
+ :style="{
1141
+ position: 'absolute',
1142
+ top: '0',
1143
+ left: '0',
1144
+ willChange: 'transform',
1145
+ transform: tooltipPos
1146
+ ? `translate3d(${tooltipPos.left}px, ${tooltipPos.top}px, 0) translateY(-50%)`
1147
+ : 'translateY(-50%)',
1148
+ visibility: tooltipPos ? 'visible' : 'hidden',
1149
+ }"
1150
+ >
1151
+ <slot name="tooltip" v-bind="hoverSlotProps">
1152
+ <div class="line-chart-tooltip">
1153
+ <div v-if="hoverSlotProps.xLabel" class="line-chart-tooltip-label">
1154
+ {{ hoverSlotProps.xLabel }}
1155
+ </div>
1156
+ <div
1157
+ v-for="v in hoverSlotProps.values"
1158
+ :key="v.seriesIndex"
1159
+ class="line-chart-tooltip-row"
1160
+ >
1161
+ <span
1162
+ class="line-chart-tooltip-swatch"
1163
+ :style="{ background: v.color }"
1164
+ />
1165
+ {{ isFinite(v.value) ? formatTick(v.value) : "—" }}
1166
+ </div>
1167
+ </div>
1168
+ </slot>
1169
+ </div>
1170
+ <a
1171
+ v-if="downloadLinkText"
1172
+ class="line-chart-download-link"
1173
+ :href="csvHref!"
1174
+ :download="`${menuFilename()}.csv`"
1175
+ >
1176
+ {{ downloadLinkText }}
1177
+ </a>
1178
+ </div>
1179
+ </template>
1180
+
1181
+ <style scoped>
1182
+ .line-chart-wrapper {
1183
+ position: relative;
1184
+ width: 100%;
1185
+ }
1186
+
1187
+ .line-chart-wrapper:hover :deep(.chart-menu-button) {
1188
+ opacity: 1;
1189
+ }
1190
+
1191
+ .line-chart-tooltip-label {
1192
+ font-weight: 600;
1193
+ margin-bottom: 0.25em;
1194
+ }
1195
+
1196
+ .line-chart-tooltip-row {
1197
+ display: flex;
1198
+ align-items: center;
1199
+ gap: 0.375em;
1200
+ }
1201
+
1202
+ .line-chart-download-link {
1203
+ display: block;
1204
+ text-align: right;
1205
+ font-size: var(--font-size-sm);
1206
+ margin-top: 0.25em;
1207
+ }
1208
+
1209
+ .line-chart-tooltip-swatch {
1210
+ display: inline-block;
1211
+ width: 0.625em;
1212
+ height: 0.625em;
1213
+ border-radius: 50%;
1214
+ flex-shrink: 0;
1215
+ }
1216
+ </style>