@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.
- package/LICENSE +201 -0
- package/charts/ChartMenu/ChartMenu.vue +140 -0
- package/charts/ChartMenu/download.ts +44 -0
- package/charts/ChartTooltip/ChartTooltip.vue +97 -0
- package/charts/ChoroplethMap/ChoroplethMap.md +398 -0
- package/charts/ChoroplethMap/ChoroplethMap.vue +777 -0
- package/charts/ChoroplethMap/hsaMapping.ts +4116 -0
- package/charts/DataTable/DataTable.md +143 -0
- package/charts/DataTable/DataTable.vue +277 -0
- package/charts/LineChart/LineChart.md +472 -0
- package/charts/LineChart/LineChart.vue +1216 -0
- package/charts/index.ts +23 -0
- package/charts/tooltip-position.ts +49 -0
- package/components/Box/Box.md +49 -0
- package/components/Box/Box.vue +52 -0
- package/components/Button/Button.md +67 -0
- package/components/Button/Button.vue +81 -0
- package/components/Expander/Expander.md +34 -0
- package/components/Expander/Expander.vue +95 -0
- package/components/Hint/Hint.md +29 -0
- package/components/Hint/Hint.vue +83 -0
- package/components/Icon/Icon.md +67 -0
- package/components/Icon/Icon.vue +112 -0
- package/components/LightDarkToggle/LightDarkToggle.vue +49 -0
- package/components/NumberInput/NumberInput.md +305 -0
- package/components/NumberInput/NumberInput.vue +531 -0
- package/components/SelectBox/SelectBox.md +110 -0
- package/components/SelectBox/SelectBox.vue +195 -0
- package/components/SidebarLayout/SidebarLayout.md +104 -0
- package/components/SidebarLayout/SidebarLayout.vue +466 -0
- package/components/Spinner/Spinner.md +51 -0
- package/components/Spinner/Spinner.vue +55 -0
- package/components/TextInput/TextInput.md +82 -0
- package/components/TextInput/TextInput.vue +94 -0
- package/components/Toggle/Toggle.md +81 -0
- package/components/Toggle/Toggle.vue +81 -0
- package/components/index.ts +15 -0
- package/index.json +121 -0
- package/package.json +24 -0
- package/pyodide/index.ts +7 -0
- package/pyodide/pyodide.worker.ts +233 -0
- package/pyodide/pyodideWorkerApi.ts +102 -0
- package/pyodide/useModel.ts +86 -0
- package/pyodide/vitePlugin.js +51 -0
- package/shared/ModelOutput.ts +88 -0
- package/shared/csv.ts +22 -0
- package/shared/index.ts +24 -0
- package/shared/transferUtils.ts +126 -0
- package/shared/useUrlParams.ts +296 -0
- package/theme/all.js +5 -0
- package/theme/base.css +176 -0
- package/theme/cfasim.css +3 -0
- package/theme/theme.css +113 -0
- package/theme/themes/cdc.css +22 -0
- package/theme/utilities.css +518 -0
- package/wasm/index.ts +2 -0
- package/wasm/useModel.ts +53 -0
- package/wasm/vitePlugin.js +35 -0
- package/wasm/wasm.worker.ts +74 -0
- 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>
|