@leavepulse/ui 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/LpNumberFlow-rZkwQwlU.css +116 -0
- package/dist/assets/LpRollingText-CR1mb0Vt.css +74 -0
- package/dist/assets/LpSparkline-0mKTq7pb.css +51 -0
- package/dist/assets/LpTooltip-B61SYd4K.css +17 -0
- package/dist/assets/LpUptimeBar-B98o9krI.css +18 -0
- package/dist/chunks/{LpAppShell-CaQYQKZV.js → LpAppShell-B5k0J3Cf.js} +1 -1
- package/dist/chunks/LpChart-tNXs1ttr.js +671 -0
- package/dist/chunks/{LpCodeBlock-CSiOSPs8.js → LpCodeBlock-rS_6mO3D.js} +1 -1
- package/dist/chunks/{LpConfirmDialog-CObRah9c.js → LpConfirmDialog-CjOGcj1e.js} +32 -4
- package/dist/chunks/{LpDisclosure-DP6yfgfM.js → LpDisclosure-BnwP4Q-b.js} +1 -1
- package/dist/chunks/{LpFileTree-nU1wRRxZ.js → LpFileTree-BaV7f0O7.js} +1 -1
- package/dist/chunks/{LpFileTreeNode-D7WgUPcJ.js → LpFileTreeNode-BzpBa7R7.js} +6 -8
- package/dist/chunks/{LpLogViewer-B1aLUVdz.js → LpLogViewer-CcQ3s3Fp.js} +2 -1
- package/dist/chunks/{LpMapPicker-DFKrd1Ne.js → LpMapPicker-CXvoTrAe.js} +2 -4
- package/dist/chunks/{LpNotificationBell-X1M30e0X.js → LpNotificationBell-Dvu-b6Vn.js} +56 -27
- package/dist/chunks/LpNumberFlow-amWYaeuA.js +221 -0
- package/dist/chunks/LpPie-BkjWlfSw.js +525 -0
- package/dist/chunks/LpProgress-DoIP4jQR.js +198 -0
- package/dist/chunks/LpRollingText-CM5gkSeF.js +50 -0
- package/dist/chunks/{LpSidebar-D3GGLSE7.js → LpSidebar-7Vpy2IJc.js} +1 -1
- package/dist/chunks/{LpSidebarNav-X14xMdPa.js → LpSidebarNav-CFX5NxGs.js} +1 -1
- package/dist/chunks/LpSparkline-C6xQ2dwv.js +175 -0
- package/dist/chunks/{LpStat-CdbEFOvn.js → LpStat-CDanpzU0.js} +43 -5
- package/dist/chunks/{LpThemeSwitcher-BgpJgrnP.js → LpThemeSwitcher-7mAK6bI7.js} +2 -4
- package/dist/chunks/LpTooltip-hCLPAqMG.js +131 -0
- package/dist/chunks/LpUptimeBar-DcQ8HikG.js +152 -0
- package/dist/component-names.d.ts +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpChart.vue.d.ts +88 -0
- package/dist/components/LpChart.vue.js +4 -0
- package/dist/components/LpCodeBlock.vue.js +1 -1
- package/dist/components/LpConfirmDialog.vue.d.ts +18 -2
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpDisclosure.vue.js +1 -1
- package/dist/components/LpFileTree.vue.d.ts +1 -1
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpFileTreeNode.vue.js +1 -1
- package/dist/components/LpLightbox.vue.d.ts +1 -1
- package/dist/components/LpLogViewer.vue.d.ts +1 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpMapPicker.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.d.ts +12 -0
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpNumberFlow.vue.d.ts +35 -0
- package/dist/components/LpNumberFlow.vue.js +4 -0
- package/dist/components/LpPie.vue.d.ts +134 -0
- package/dist/components/LpPie.vue.js +4 -0
- package/dist/components/LpProgress.vue.d.ts +56 -2
- package/dist/components/LpProgress.vue.js +1 -1
- package/dist/components/LpRollingText.vue.d.ts +33 -0
- package/dist/components/LpRollingText.vue.js +4 -0
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpSparkline.vue.d.ts +48 -0
- package/dist/components/LpSparkline.vue.js +4 -0
- package/dist/components/LpStat.vue.d.ts +25 -2
- package/dist/components/LpStat.vue.js +1 -1
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/LpTooltip.vue.d.ts +24 -3
- package/dist/components/LpTooltip.vue.js +1 -1
- package/dist/components/LpUptimeBar.vue.js +1 -1
- package/dist/components/chart.d.ts +180 -0
- package/dist/components/chart.js +353 -0
- package/dist/composables/easing.d.ts +46 -0
- package/dist/composables/easing.js +23 -0
- package/dist/composables/usePillTransition.d.ts +13 -1
- package/dist/composables/usePillTransition.js +22 -2
- package/dist/composables/useReveal.d.ts +26 -0
- package/dist/composables/useReveal.js +45 -0
- package/dist/composables/useSettled.js +2 -2
- package/dist/composables/useShift.js +2 -4
- package/dist/index.d.ts +14 -0
- package/dist/index.js +126 -103
- package/package.json +2 -1
- package/src/component-names.ts +5 -0
- package/src/components/LpChart.vue +846 -0
- package/src/components/LpCodeBlock.vue +1 -1
- package/src/components/LpConfirmDialog.vue +49 -0
- package/src/components/LpDisclosure.vue +1 -1
- package/src/components/LpFileTreeNode.vue +9 -10
- package/src/components/LpLogViewer.vue +2 -3
- package/src/components/LpMapPicker.vue +2 -5
- package/src/components/LpNotificationBell.vue +76 -15
- package/src/components/LpNumberFlow.vue +524 -0
- package/src/components/LpPie.vue +778 -0
- package/src/components/LpProgress.vue +283 -6
- package/src/components/LpRollingText.vue +157 -0
- package/src/components/LpSidebarNav.vue +1 -1
- package/src/components/LpSparkline.vue +312 -0
- package/src/components/LpStat.vue +52 -4
- package/src/components/LpTooltip.vue +185 -3
- package/src/components/LpUptimeBar.vue +150 -53
- package/src/components/chart.ts +686 -0
- package/src/composables/easing.ts +88 -0
- package/src/composables/usePillTransition.ts +71 -6
- package/src/composables/useReveal.ts +113 -0
- package/src/composables/useSettled.ts +2 -4
- package/src/composables/useShift.ts +2 -8
- package/src/index.ts +19 -0
- package/src/theme/useTheme.ts +2 -8
- package/src/tokens/tokens.css +36 -0
- package/dist/chunks/LpProgress-BhI0IbjC.js +0 -37
- package/dist/chunks/LpTooltip-D1hbjQWG.js +0 -63
- package/dist/chunks/LpUptimeBar-CB366k0O.js +0 -127
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Token-driven SVG chart: line, area and bar off one data shape. No charting
|
|
4
|
+
* dependency — the kit already draws its own SVG (LpUptimeBar, the canvas), and
|
|
5
|
+
* owning the marks is what lets every colour, radius and duration come from the
|
|
6
|
+
* theme rather than a library's own palette.
|
|
7
|
+
*
|
|
8
|
+
* Interaction is a single crosshair that snaps to the nearest point and reports
|
|
9
|
+
* every series at that index at once, so comparing series is one hover, not one
|
|
10
|
+
* hover per line.
|
|
11
|
+
*/
|
|
12
|
+
import { Motion } from "motion-v"
|
|
13
|
+
import { computed, ref, shallowRef, watch, onMounted, onBeforeUnmount } from "vue"
|
|
14
|
+
import { usePillTransition } from "../composables/usePillTransition"
|
|
15
|
+
import { useReveal } from "../composables/useReveal"
|
|
16
|
+
import {
|
|
17
|
+
areaPath,
|
|
18
|
+
axisTicks,
|
|
19
|
+
barPath,
|
|
20
|
+
extent,
|
|
21
|
+
formatTick,
|
|
22
|
+
linePath,
|
|
23
|
+
nearestIndex,
|
|
24
|
+
niceScale,
|
|
25
|
+
normalise,
|
|
26
|
+
plotHeight,
|
|
27
|
+
plotWidth,
|
|
28
|
+
pointCount,
|
|
29
|
+
resolveLabels,
|
|
30
|
+
xAt,
|
|
31
|
+
yAt,
|
|
32
|
+
type Box,
|
|
33
|
+
type ChartSeries,
|
|
34
|
+
} from "./chart"
|
|
35
|
+
import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
|
|
36
|
+
import LpIcon from "./LpIcon.vue"
|
|
37
|
+
import LpShift from "./LpShift.vue"
|
|
38
|
+
|
|
39
|
+
/** What was under the pointer when a point was clicked or right-clicked. */
|
|
40
|
+
export interface ChartPointEvent {
|
|
41
|
+
index: number
|
|
42
|
+
label: string
|
|
43
|
+
/** Every series' value at this index, in `series` order (hidden included). */
|
|
44
|
+
values: (number | null)[]
|
|
45
|
+
/** The original event, for callers that need modifiers or coordinates. */
|
|
46
|
+
originalEvent: MouseEvent
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const props = withDefaults(
|
|
50
|
+
defineProps<{
|
|
51
|
+
series: ChartSeries[]
|
|
52
|
+
/** Shared x-axis labels; a point's own `x` overrides its slot. */
|
|
53
|
+
labels?: string[]
|
|
54
|
+
type?: "line" | "area" | "bar"
|
|
55
|
+
/** Plot height in px. The width always fills the container. */
|
|
56
|
+
height?: number
|
|
57
|
+
/** Headline above the plot. */
|
|
58
|
+
title?: string
|
|
59
|
+
/** Sub-headline under the title. */
|
|
60
|
+
subtitle?: string
|
|
61
|
+
/** Curve tension for line/area. 0 = straight polyline, 1 = fully smooth. */
|
|
62
|
+
smooth?: number
|
|
63
|
+
/** Draw a dot at every point rather than only the hovered one. */
|
|
64
|
+
showPoints?: boolean
|
|
65
|
+
showGrid?: boolean
|
|
66
|
+
showXAxis?: boolean
|
|
67
|
+
showYAxis?: boolean
|
|
68
|
+
/** Legend also toggles series visibility on click. */
|
|
69
|
+
showLegend?: boolean
|
|
70
|
+
/** Roughly how many y ticks to aim for; bounds are rounded to fit. */
|
|
71
|
+
yTicks?: number
|
|
72
|
+
/** Force the y-axis to include 0 (bars always do). */
|
|
73
|
+
includeZero?: boolean
|
|
74
|
+
/** Appended to values in ticks and the tooltip, e.g. "%" or " ms". */
|
|
75
|
+
unit?: string
|
|
76
|
+
/** Unit for series on the right axis. Falls back to `unit`. */
|
|
77
|
+
rightUnit?: string
|
|
78
|
+
/** Custom value formatter; overrides the compact 1.2k default. */
|
|
79
|
+
formatValue?: (v: number) => string
|
|
80
|
+
/** Formatter for the right axis. Falls back to `formatValue`. */
|
|
81
|
+
formatRightValue?: (v: number) => string
|
|
82
|
+
/** Stack bars instead of grouping them side by side. */
|
|
83
|
+
stacked?: boolean
|
|
84
|
+
/** Grow the marks in on mount and on data change. */
|
|
85
|
+
animate?: boolean
|
|
86
|
+
/** Shown when every series is empty. */
|
|
87
|
+
emptyLabel?: string
|
|
88
|
+
/**
|
|
89
|
+
* Right-click menu for a point, built from whatever was hit. Return [] to
|
|
90
|
+
* leave the browser's own menu alone for that point. Which actions a point
|
|
91
|
+
* offers is an application question — "compare with last week", "open the
|
|
92
|
+
* incident", "copy value" — so the chart supplies the context and the
|
|
93
|
+
* caller supplies the items.
|
|
94
|
+
*/
|
|
95
|
+
menuItems?: (point: ChartPointEvent) => ContextMenuItemDef[]
|
|
96
|
+
}>(),
|
|
97
|
+
{
|
|
98
|
+
type: "line",
|
|
99
|
+
height: 240,
|
|
100
|
+
smooth: 0.9,
|
|
101
|
+
showGrid: true,
|
|
102
|
+
showXAxis: true,
|
|
103
|
+
showYAxis: true,
|
|
104
|
+
showLegend: true,
|
|
105
|
+
yTicks: 4,
|
|
106
|
+
animate: true,
|
|
107
|
+
emptyLabel: "No data",
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const emit = defineEmits<{
|
|
112
|
+
/** Click on a point/bar. */
|
|
113
|
+
(e: "pointClick", payload: ChartPointEvent): void
|
|
114
|
+
/** Right-click on a point/bar, fired before `menuItems` is consulted. */
|
|
115
|
+
(e: "pointContextMenu", payload: ChartPointEvent): void
|
|
116
|
+
}>()
|
|
117
|
+
|
|
118
|
+
// ── size ───────────────────────────────────────
|
|
119
|
+
// Width comes from the DOM, so it is 0 during SSR and the first tick; the marks
|
|
120
|
+
// simply have nothing to draw until the observer reports a real box.
|
|
121
|
+
const root = ref<HTMLElement | null>(null)
|
|
122
|
+
const width = ref(0)
|
|
123
|
+
let ro: ResizeObserver | null = null
|
|
124
|
+
|
|
125
|
+
/*
|
|
126
|
+
* The wipe waits for the chart to be scrolled to. A chart is tall, so it is the
|
|
127
|
+
* component most likely to mount below the fold — on a dashboard it is normal
|
|
128
|
+
* for every one but the first to have finished wiping before it is ever looked
|
|
129
|
+
* at. `revealed` gates the SMIL animation the same way `animate` does, so the
|
|
130
|
+
* two combine into one question: should this draw itself right now?
|
|
131
|
+
*/
|
|
132
|
+
const { el: revealAnchor, revealed } = useReveal({ immediate: () => !props.animate })
|
|
133
|
+
const drawing = computed(() => props.animate && revealed.value)
|
|
134
|
+
|
|
135
|
+
onMounted(() => {
|
|
136
|
+
// One element, two observers: hand the composable the same node rather than
|
|
137
|
+
// wrapping the chart in a spare div just to carry a second ref.
|
|
138
|
+
revealAnchor.value = root.value
|
|
139
|
+
if (!root.value || typeof ResizeObserver === "undefined") return
|
|
140
|
+
ro = new ResizeObserver(([entry]) => {
|
|
141
|
+
width.value = entry.contentRect.width
|
|
142
|
+
})
|
|
143
|
+
ro.observe(root.value)
|
|
144
|
+
width.value = root.value.clientWidth
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
onBeforeUnmount(() => {
|
|
148
|
+
ro?.disconnect()
|
|
149
|
+
ro = null
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// ── data ───────────────────────────────────────
|
|
153
|
+
const hidden = ref(new Set<string>())
|
|
154
|
+
|
|
155
|
+
const toggleSeries = (name: string) => {
|
|
156
|
+
const next = new Set(hidden.value)
|
|
157
|
+
if (next.has(name)) next.delete(name)
|
|
158
|
+
else next.add(name)
|
|
159
|
+
// Never hide the last visible series — an empty plot reads as broken.
|
|
160
|
+
if (next.size < props.series.length) hidden.value = next
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const allSeries = computed(() => normalise(props.series))
|
|
164
|
+
const visible = computed(() => allSeries.value.filter((s) => !hidden.value.has(s.name)))
|
|
165
|
+
|
|
166
|
+
const count = computed(() => pointCount(allSeries.value))
|
|
167
|
+
const labels = computed(() => resolveLabels(props.series, props.labels, count.value))
|
|
168
|
+
const banded = computed(() => props.type === "bar")
|
|
169
|
+
|
|
170
|
+
const hasData = computed(() =>
|
|
171
|
+
allSeries.value.some((s) => s.values.some((v) => v != null)),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
// Bars are read against a baseline, so their axis must contain zero.
|
|
175
|
+
const zeroBased = computed(() => props.includeZero || props.type === "bar")
|
|
176
|
+
|
|
177
|
+
/** Stacked bars are measured on their running totals, not on single values. */
|
|
178
|
+
const stackTotals = computed(() => {
|
|
179
|
+
if (!(props.type === "bar" && props.stacked)) return null
|
|
180
|
+
const pos: number[] = []
|
|
181
|
+
const neg: number[] = []
|
|
182
|
+
for (let i = 0; i < count.value; i++) {
|
|
183
|
+
let p = 0
|
|
184
|
+
let n = 0
|
|
185
|
+
for (const s of visible.value) {
|
|
186
|
+
const v = s.values[i]
|
|
187
|
+
if (v == null) continue
|
|
188
|
+
if (v >= 0) p += v
|
|
189
|
+
else n += v
|
|
190
|
+
}
|
|
191
|
+
pos.push(p)
|
|
192
|
+
neg.push(n)
|
|
193
|
+
}
|
|
194
|
+
return { pos, neg }
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const onRight = computed(() => visible.value.filter((s) => s.axis === "right"))
|
|
198
|
+
const onLeft = computed(() => {
|
|
199
|
+
// A chart whose every series asked for the right axis still needs the left
|
|
200
|
+
// one to carry the grid, so treat that as "no split at all".
|
|
201
|
+
const left = visible.value.filter((s) => s.axis === "left")
|
|
202
|
+
return left.length || !onRight.value.length ? left : visible.value
|
|
203
|
+
})
|
|
204
|
+
const hasRightAxis = computed(() => onRight.value.length > 0 && onLeft.value !== visible.value)
|
|
205
|
+
|
|
206
|
+
const scaleFor = (members: typeof visible.value) => {
|
|
207
|
+
const totals = stackTotals.value
|
|
208
|
+
const raw = totals
|
|
209
|
+
? { min: Math.min(0, ...totals.neg), max: Math.max(0, ...totals.pos) }
|
|
210
|
+
: extent(members, zeroBased.value)
|
|
211
|
+
return niceScale(raw.min, raw.max, props.yTicks)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const scale = computed(() => scaleFor(onLeft.value))
|
|
215
|
+
|
|
216
|
+
/*
|
|
217
|
+
* The right axis reuses the LEFT axis's tick COUNT so both sets of labels land
|
|
218
|
+
* on the same gridlines. Two independent tick runs would draw two interleaved
|
|
219
|
+
* grids across one plot, which reads as noise rather than as two scales.
|
|
220
|
+
*/
|
|
221
|
+
const rightScale = computed(() => {
|
|
222
|
+
if (!hasRightAxis.value) return null
|
|
223
|
+
const raw = extent(onRight.value, zeroBased.value)
|
|
224
|
+
const steps = Math.max(1, axisTicks(scale.value.min, scale.value.max, scale.value.step).length - 1)
|
|
225
|
+
const nice = niceScale(raw.min, raw.max, steps)
|
|
226
|
+
// Stretch the top so the run divides into exactly `steps` intervals.
|
|
227
|
+
return { min: nice.min, max: nice.min + nice.step * steps, step: nice.step }
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
const fmtWith = (v: number, unit?: string, f?: (n: number) => string): string =>
|
|
231
|
+
`${f ? f(v) : formatTick(v)}${unit ?? ""}`
|
|
232
|
+
|
|
233
|
+
const fmt = (v: number): string => fmtWith(v, props.unit, props.formatValue)
|
|
234
|
+
const fmtRight = (v: number): string =>
|
|
235
|
+
fmtWith(v, props.rightUnit ?? props.unit, props.formatRightValue ?? props.formatValue)
|
|
236
|
+
|
|
237
|
+
/** A series is read against its own axis — in the ticks and in the tooltip. */
|
|
238
|
+
const fmtSeries = (s: { axis: "left" | "right" }, v: number): string =>
|
|
239
|
+
s.axis === "right" && hasRightAxis.value ? fmtRight(v) : fmt(v)
|
|
240
|
+
|
|
241
|
+
const scaleOf = (s: { axis: "left" | "right" }) =>
|
|
242
|
+
s.axis === "right" && rightScale.value ? rightScale.value : scale.value
|
|
243
|
+
|
|
244
|
+
// ── layout ─────────────────────────────────────
|
|
245
|
+
// Side padding tracks the widest tick label so long numbers never clip.
|
|
246
|
+
const yLabels = computed(() =>
|
|
247
|
+
axisTicks(scale.value.min, scale.value.max, scale.value.step).map(fmt),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
const rightLabels = computed(() =>
|
|
251
|
+
rightScale.value
|
|
252
|
+
? axisTicks(rightScale.value.min, rightScale.value.max, rightScale.value.step).map(fmtRight)
|
|
253
|
+
: [],
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
const gutter = (labels: string[]) => 12 + Math.max(...labels.map((l) => l.length), 1) * 7
|
|
257
|
+
|
|
258
|
+
const box = computed<Box>(() => ({
|
|
259
|
+
width: width.value,
|
|
260
|
+
height: props.height,
|
|
261
|
+
left: props.showYAxis ? gutter(yLabels.value) : 8,
|
|
262
|
+
right: props.showYAxis && rightLabels.value.length ? gutter(rightLabels.value) : 12,
|
|
263
|
+
top: 10,
|
|
264
|
+
bottom: props.showXAxis ? 26 : 8,
|
|
265
|
+
}))
|
|
266
|
+
|
|
267
|
+
const geo = computed(() => ({
|
|
268
|
+
min: scale.value.min,
|
|
269
|
+
max: scale.value.max,
|
|
270
|
+
box: box.value,
|
|
271
|
+
count: count.value,
|
|
272
|
+
banded: banded.value,
|
|
273
|
+
}))
|
|
274
|
+
|
|
275
|
+
/** Geometry for one series — same box, but its own axis's min/max. */
|
|
276
|
+
const geoOf = (s: { axis: "left" | "right" }) => ({ ...geo.value, ...scaleOf(s) })
|
|
277
|
+
|
|
278
|
+
const gridLines = computed(() => {
|
|
279
|
+
const left = axisTicks(scale.value.min, scale.value.max, scale.value.step)
|
|
280
|
+
const right = rightScale.value
|
|
281
|
+
? axisTicks(rightScale.value.min, rightScale.value.max, rightScale.value.step)
|
|
282
|
+
: []
|
|
283
|
+
return left.map((v, i) => ({
|
|
284
|
+
v,
|
|
285
|
+
y: yAt(v, scale.value.min, scale.value.max, box.value),
|
|
286
|
+
label: fmt(v),
|
|
287
|
+
rightLabel: right[i] != null ? fmtRight(right[i]) : "",
|
|
288
|
+
}))
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
/** Thin out x labels so they never collide at narrow widths. */
|
|
292
|
+
const xLabelStride = computed(() => {
|
|
293
|
+
const w = plotWidth(box.value)
|
|
294
|
+
if (w <= 0 || count.value === 0) return 1
|
|
295
|
+
const longest = Math.max(...labels.value.map((l) => l.length), 1)
|
|
296
|
+
return Math.max(1, Math.ceil((count.value * (longest * 6.5 + 16)) / w))
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
const xLabels = computed(() =>
|
|
300
|
+
labels.value
|
|
301
|
+
.map((label, i) => ({ label, i, x: xAt(i, count.value, box.value, banded.value) }))
|
|
302
|
+
.filter(({ i }) => i % xLabelStride.value === 0 || i === count.value - 1),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
// ── marks ──────────────────────────────────────
|
|
306
|
+
const baseY = computed(() =>
|
|
307
|
+
yAt(
|
|
308
|
+
Math.max(scale.value.min, Math.min(scale.value.max, 0)),
|
|
309
|
+
scale.value.min,
|
|
310
|
+
scale.value.max,
|
|
311
|
+
box.value,
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
const lines = computed(() =>
|
|
316
|
+
visible.value.map((s) => {
|
|
317
|
+
const g = geoOf(s)
|
|
318
|
+
return {
|
|
319
|
+
...s,
|
|
320
|
+
d: linePath(s.values, g, props.smooth),
|
|
321
|
+
fill: props.type === "area" ? areaPath(s.values, g, props.smooth) : "",
|
|
322
|
+
/* A dashed series is a reference level (a budget, a target), not a
|
|
323
|
+
measurement — dotting it implies readings that aren't there. */
|
|
324
|
+
points: props.showPoints && !s.dashed,
|
|
325
|
+
scale: scaleOf(s),
|
|
326
|
+
}
|
|
327
|
+
}),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
/** Every bar as an absolute rect — grouped side by side, or stacked. */
|
|
331
|
+
const bars = computed(() => {
|
|
332
|
+
if (props.type !== "bar" || count.value === 0 || plotWidth(box.value) <= 0) return []
|
|
333
|
+
const band = plotWidth(box.value) / count.value
|
|
334
|
+
/* Bars read as a series of measurements, not as a filled block: keep real
|
|
335
|
+
ground visible between bands, and cap the width so a 4-point chart doesn't
|
|
336
|
+
turn into slabs. */
|
|
337
|
+
const inner = Math.min(band * 0.6, props.stacked ? 46 : 30 * (visible.value.length || 1))
|
|
338
|
+
const n = visible.value.length || 1
|
|
339
|
+
const slot = props.stacked ? inner : inner / n
|
|
340
|
+
const out: {
|
|
341
|
+
key: string
|
|
342
|
+
x: number
|
|
343
|
+
y: number
|
|
344
|
+
w: number
|
|
345
|
+
h: number
|
|
346
|
+
color: string
|
|
347
|
+
index: number
|
|
348
|
+
/** Which end of its stack this segment terminates; both when standalone. */
|
|
349
|
+
roundTop: boolean
|
|
350
|
+
roundBottom: boolean
|
|
351
|
+
d: string
|
|
352
|
+
}[] = []
|
|
353
|
+
|
|
354
|
+
const posTop = new Array(count.value).fill(0)
|
|
355
|
+
const negTop = new Array(count.value).fill(0)
|
|
356
|
+
|
|
357
|
+
visible.value.forEach((s, si) => {
|
|
358
|
+
for (let i = 0; i < count.value; i++) {
|
|
359
|
+
const v = s.values[i]
|
|
360
|
+
if (v == null) continue
|
|
361
|
+
const centre = xAt(i, count.value, box.value, true)
|
|
362
|
+
const x = props.stacked ? centre - inner / 2 : centre - inner / 2 + si * slot
|
|
363
|
+
let y: number
|
|
364
|
+
let h: number
|
|
365
|
+
if (props.stacked) {
|
|
366
|
+
// Each stacked bar starts where the previous one of its sign ended.
|
|
367
|
+
const prev = v >= 0 ? posTop[i] : negTop[i]
|
|
368
|
+
const from = yAt(prev, scale.value.min, scale.value.max, box.value)
|
|
369
|
+
const to = yAt(prev + v, scale.value.min, scale.value.max, box.value)
|
|
370
|
+
y = Math.min(from, to)
|
|
371
|
+
h = Math.abs(to - from)
|
|
372
|
+
if (v >= 0) posTop[i] += v
|
|
373
|
+
else negTop[i] += v
|
|
374
|
+
} else {
|
|
375
|
+
const sc = scaleOf(s)
|
|
376
|
+
const top = yAt(v, sc.min, sc.max, box.value)
|
|
377
|
+
const base = yAt(Math.max(sc.min, Math.min(sc.max, 0)), sc.min, sc.max, box.value)
|
|
378
|
+
y = Math.min(top, base)
|
|
379
|
+
h = Math.abs(top - base)
|
|
380
|
+
}
|
|
381
|
+
out.push({
|
|
382
|
+
key: `${s.name}-${i}`,
|
|
383
|
+
x,
|
|
384
|
+
y,
|
|
385
|
+
w: Math.max(1, slot - (props.stacked ? 0 : 2)),
|
|
386
|
+
h: Math.max(v === 0 ? 0 : 1, h),
|
|
387
|
+
color: s.color,
|
|
388
|
+
index: i,
|
|
389
|
+
/* A standalone bar rounds only the end it grows toward: a negative one
|
|
390
|
+
hangs off the baseline, so its square end is the top. */
|
|
391
|
+
roundTop: props.stacked || v >= 0,
|
|
392
|
+
roundBottom: props.stacked || v < 0,
|
|
393
|
+
d: "",
|
|
394
|
+
})
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
/*
|
|
399
|
+
* A stack is one bar cut into pieces, so only its outer ends round. Which
|
|
400
|
+
* piece is outermost isn't known while stacking (it depends on how many
|
|
401
|
+
* series had a value at that index), so the ends are resolved here, once the
|
|
402
|
+
* column is complete: topmost piece rounds its top, bottommost its bottom,
|
|
403
|
+
* everything between stays square and the joins read as one shape.
|
|
404
|
+
*/
|
|
405
|
+
if (props.stacked) {
|
|
406
|
+
const columns = new Map<number, typeof out>()
|
|
407
|
+
for (const b of out) {
|
|
408
|
+
const col = columns.get(b.index) ?? []
|
|
409
|
+
col.push(b)
|
|
410
|
+
columns.set(b.index, col)
|
|
411
|
+
}
|
|
412
|
+
for (const col of columns.values()) {
|
|
413
|
+
const top = Math.min(...col.map((b) => b.y))
|
|
414
|
+
const bottom = Math.max(...col.map((b) => b.y + b.h))
|
|
415
|
+
for (const b of col) {
|
|
416
|
+
b.roundTop = b.y === top
|
|
417
|
+
b.roundBottom = b.y + b.h === bottom
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
for (const b of out) {
|
|
423
|
+
b.d = barPath(b.x, b.y, b.w, b.h, 4, { top: b.roundTop, bottom: b.roundBottom })
|
|
424
|
+
}
|
|
425
|
+
return out
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
// ── hover ──────────────────────────────────────
|
|
429
|
+
const active = ref(-1)
|
|
430
|
+
const pointer = ref({ x: 0, y: 0 })
|
|
431
|
+
|
|
432
|
+
const onMove = (e: PointerEvent) => {
|
|
433
|
+
const el = e.currentTarget as SVGElement
|
|
434
|
+
const rect = el.getBoundingClientRect()
|
|
435
|
+
const x = e.clientX - rect.left
|
|
436
|
+
pointer.value = { x, y: e.clientY - rect.top }
|
|
437
|
+
active.value = nearestIndex(x, geo.value)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const onLeave = () => {
|
|
441
|
+
active.value = -1
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const pointEvent = (originalEvent: MouseEvent): ChartPointEvent => ({
|
|
445
|
+
index: active.value,
|
|
446
|
+
label: labels.value[active.value] ?? "",
|
|
447
|
+
values: allSeries.value.map((s) => s.values[active.value] ?? null),
|
|
448
|
+
originalEvent,
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
const onClick = (e: MouseEvent) => {
|
|
452
|
+
if (active.value < 0) return
|
|
453
|
+
emit("pointClick", pointEvent(e))
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/*
|
|
457
|
+
* The menu itself belongs to the caller — which actions a point offers is an
|
|
458
|
+
* application question. All the chart owes is WHICH point was hit, resolved
|
|
459
|
+
* before the menu opens so the items can be built for it.
|
|
460
|
+
*
|
|
461
|
+
* A right-click can land without a preceding pointermove (touch long-press, or
|
|
462
|
+
* a click straight after a scroll), so the index is resolved from this event
|
|
463
|
+
* rather than trusting whatever hover left behind.
|
|
464
|
+
*/
|
|
465
|
+
const menuPoint = ref<ChartPointEvent | null>(null)
|
|
466
|
+
|
|
467
|
+
/* Resolved on the right-click, so the menu describes the point actually hit. */
|
|
468
|
+
const menuEntries = computed(() =>
|
|
469
|
+
props.menuItems && menuPoint.value ? props.menuItems(menuPoint.value) : [],
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
const onContextMenu = (e: MouseEvent) => {
|
|
473
|
+
const rect = (e.currentTarget as SVGElement).getBoundingClientRect()
|
|
474
|
+
active.value = nearestIndex(e.clientX - rect.left, geo.value)
|
|
475
|
+
if (active.value < 0) return
|
|
476
|
+
menuPoint.value = pointEvent(e)
|
|
477
|
+
emit("pointContextMenu", menuPoint.value)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const activeX = computed(() =>
|
|
481
|
+
active.value < 0 ? 0 : xAt(active.value, count.value, box.value, banded.value),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
/** Dots on the crosshair — one per visible series that has a value here. */
|
|
485
|
+
const activePoints = computed(() => {
|
|
486
|
+
if (active.value < 0) return []
|
|
487
|
+
return visible.value
|
|
488
|
+
.map((s) => ({ s, v: s.values[active.value] }))
|
|
489
|
+
.filter((r): r is { s: (typeof visible.value)[number]; v: number } => r.v != null)
|
|
490
|
+
.map(({ s, v }) => {
|
|
491
|
+
const sc = scaleOf(s)
|
|
492
|
+
return {
|
|
493
|
+
color: s.color,
|
|
494
|
+
name: s.name,
|
|
495
|
+
value: v,
|
|
496
|
+
y: yAt(v, sc.min, sc.max, box.value),
|
|
497
|
+
}
|
|
498
|
+
})
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
const tooltipRows = computed(() =>
|
|
502
|
+
active.value < 0
|
|
503
|
+
? []
|
|
504
|
+
: visible.value.map((s) => ({
|
|
505
|
+
name: s.name,
|
|
506
|
+
color: s.color,
|
|
507
|
+
text:
|
|
508
|
+
s.values[active.value] == null
|
|
509
|
+
? "—"
|
|
510
|
+
: fmtSeries(s, s.values[active.value] as number),
|
|
511
|
+
})),
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
/*
|
|
515
|
+
* The tooltip trails the pointer on a soft spring rather than being pinned to
|
|
516
|
+
* it. Two reasons it is animated rather than positioned directly: the crosshair
|
|
517
|
+
* SNAPS to the nearest point, so a pinned card would jump a whole band at a
|
|
518
|
+
* time sideways while gliding vertically; and a card that lags slightly reads as
|
|
519
|
+
* following the cursor instead of being welded to it.
|
|
520
|
+
*
|
|
521
|
+
* x tracks the snapped crosshair (the card belongs to the point it describes),
|
|
522
|
+
* y tracks the raw pointer, clamped into the plot so it can't ride off the top
|
|
523
|
+
* or bottom edge. The spring is the kit's shared "float" feel.
|
|
524
|
+
*/
|
|
525
|
+
const CARD_W = 150
|
|
526
|
+
const CARD_GAP = 14
|
|
527
|
+
|
|
528
|
+
const tooltipTarget = computed(() => {
|
|
529
|
+
const flip = activeX.value > box.value.width - CARD_W
|
|
530
|
+
const y = Math.min(
|
|
531
|
+
Math.max(pointer.value.y, box.value.top + 8),
|
|
532
|
+
box.value.top + plotHeight(box.value) - 8,
|
|
533
|
+
)
|
|
534
|
+
return {
|
|
535
|
+
x: flip ? activeX.value - CARD_GAP : activeX.value + CARD_GAP,
|
|
536
|
+
y,
|
|
537
|
+
flip,
|
|
538
|
+
}
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
const floatTransition = usePillTransition("float")
|
|
542
|
+
|
|
543
|
+
// ── animation ──────────────────────────────────
|
|
544
|
+
// One clip rect wipes across the plot; re-keyed on data change so an update
|
|
545
|
+
// replays the reveal instead of snapping.
|
|
546
|
+
const revealKey = shallowRef(0)
|
|
547
|
+
watch(
|
|
548
|
+
() => [props.series, props.type] as const,
|
|
549
|
+
() => {
|
|
550
|
+
revealKey.value++
|
|
551
|
+
},
|
|
552
|
+
{ deep: true },
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
const clipId = `lp-chart-${Math.random().toString(36).slice(2, 9)}`
|
|
556
|
+
</script>
|
|
557
|
+
|
|
558
|
+
<template>
|
|
559
|
+
<div ref="root" class="flex w-full flex-col gap-3">
|
|
560
|
+
<div v-if="title || subtitle || $slots.actions" class="flex items-start justify-between gap-3">
|
|
561
|
+
<div class="flex flex-col gap-0.5">
|
|
562
|
+
<span v-if="title" class="text-sm font-medium text-ink">{{ title }}</span>
|
|
563
|
+
<span v-if="subtitle" class="text-xs text-muted">{{ subtitle }}</span>
|
|
564
|
+
</div>
|
|
565
|
+
<slot name="actions" />
|
|
566
|
+
</div>
|
|
567
|
+
|
|
568
|
+
<div
|
|
569
|
+
v-if="showLegend && allSeries.length > 1"
|
|
570
|
+
class="flex flex-wrap items-center gap-x-4 gap-y-1.5"
|
|
571
|
+
>
|
|
572
|
+
<button
|
|
573
|
+
v-for="s in allSeries"
|
|
574
|
+
:key="s.name"
|
|
575
|
+
type="button"
|
|
576
|
+
class="group flex cursor-pointer items-center gap-1.5 rounded-md text-xs outline-none transition-opacity duration-fast focus-visible:ring-2 focus-visible:ring-ring"
|
|
577
|
+
:class="hidden.has(s.name) ? 'opacity-40' : ''"
|
|
578
|
+
@click="toggleSeries(s.name)"
|
|
579
|
+
>
|
|
580
|
+
<span
|
|
581
|
+
class="size-2 shrink-0 rounded-full transition-transform duration-fast group-hover:scale-125"
|
|
582
|
+
:style="{ backgroundColor: s.color }"
|
|
583
|
+
/>
|
|
584
|
+
<span class="text-muted-strong group-hover:text-ink">{{ s.name }}</span>
|
|
585
|
+
<span
|
|
586
|
+
v-if="hasRightAxis && s.axis === 'right'"
|
|
587
|
+
class="text-[10px] text-muted"
|
|
588
|
+
title="Measured on the right axis"
|
|
589
|
+
>
|
|
590
|
+
↗
|
|
591
|
+
</span>
|
|
592
|
+
</button>
|
|
593
|
+
</div>
|
|
594
|
+
|
|
595
|
+
<!-- `always` keeps reka mounted while the items are still being resolved
|
|
596
|
+
from the click that opens the menu. -->
|
|
597
|
+
<LpContextMenu :items="menuEntries" :always="!!menuItems">
|
|
598
|
+
<!-- `group/plot` + data-state lets the tooltip hide itself while the
|
|
599
|
+
context menu is up: the two would otherwise stack on the same point. -->
|
|
600
|
+
<div class="group/plot relative" :style="{ height: `${height}px` }">
|
|
601
|
+
<div
|
|
602
|
+
v-if="!hasData"
|
|
603
|
+
class="flex size-full flex-col items-center justify-center gap-2 rounded-control border border-dashed border-line text-muted"
|
|
604
|
+
>
|
|
605
|
+
<LpIcon name="lucide:chart-no-axes-column" :size="18" />
|
|
606
|
+
<span class="text-xs">{{ emptyLabel }}</span>
|
|
607
|
+
</div>
|
|
608
|
+
|
|
609
|
+
<svg
|
|
610
|
+
v-else-if="width > 0"
|
|
611
|
+
:width="width"
|
|
612
|
+
:height="height"
|
|
613
|
+
class="overflow-visible touch-none select-none"
|
|
614
|
+
@pointermove="onMove"
|
|
615
|
+
@pointerleave="onLeave"
|
|
616
|
+
@click="onClick"
|
|
617
|
+
@contextmenu="onContextMenu"
|
|
618
|
+
>
|
|
619
|
+
<defs>
|
|
620
|
+
<linearGradient
|
|
621
|
+
v-for="s in lines"
|
|
622
|
+
:id="`${clipId}-g-${s.name}`"
|
|
623
|
+
:key="s.name"
|
|
624
|
+
x1="0"
|
|
625
|
+
y1="0"
|
|
626
|
+
x2="0"
|
|
627
|
+
y2="1"
|
|
628
|
+
>
|
|
629
|
+
<stop offset="0%" :stop-color="s.color" stop-opacity="0.28" />
|
|
630
|
+
<stop offset="100%" :stop-color="s.color" stop-opacity="0.02" />
|
|
631
|
+
</linearGradient>
|
|
632
|
+
<clipPath :id="`${clipId}-reveal`">
|
|
633
|
+
<!-- Closed both while the wipe is running (SMIL opens it) and
|
|
634
|
+
while the chart is still waiting to be scrolled to; only a
|
|
635
|
+
chart with no entrance at all starts open. -->
|
|
636
|
+
<rect
|
|
637
|
+
:key="revealKey"
|
|
638
|
+
:x="box.left"
|
|
639
|
+
:y="0"
|
|
640
|
+
:height="height"
|
|
641
|
+
:width="animate ? 0 : plotWidth(box)"
|
|
642
|
+
>
|
|
643
|
+
<!-- keySplines is --ease-travel written out: SMIL cannot read a
|
|
644
|
+
CSS custom property, so the curve is duplicated here. Change
|
|
645
|
+
one and change the other. -->
|
|
646
|
+
<animate
|
|
647
|
+
v-if="drawing"
|
|
648
|
+
attributeName="width"
|
|
649
|
+
:from="0"
|
|
650
|
+
:to="plotWidth(box)"
|
|
651
|
+
dur="0.95s"
|
|
652
|
+
fill="freeze"
|
|
653
|
+
calcMode="spline"
|
|
654
|
+
keySplines="0.2 0.25 0.5 0.85"
|
|
655
|
+
keyTimes="0;1"
|
|
656
|
+
/>
|
|
657
|
+
</rect>
|
|
658
|
+
</clipPath>
|
|
659
|
+
</defs>
|
|
660
|
+
|
|
661
|
+
<!-- grid + y ticks -->
|
|
662
|
+
<g v-if="showGrid || showYAxis">
|
|
663
|
+
<template v-for="g in gridLines" :key="g.v">
|
|
664
|
+
<line
|
|
665
|
+
v-if="showGrid"
|
|
666
|
+
:x1="box.left"
|
|
667
|
+
:x2="box.width - box.right"
|
|
668
|
+
:y1="g.y"
|
|
669
|
+
:y2="g.y"
|
|
670
|
+
stroke="var(--color-line)"
|
|
671
|
+
stroke-width="1"
|
|
672
|
+
/>
|
|
673
|
+
<text
|
|
674
|
+
v-if="showYAxis"
|
|
675
|
+
:x="box.left - 8"
|
|
676
|
+
:y="g.y"
|
|
677
|
+
text-anchor="end"
|
|
678
|
+
dominant-baseline="middle"
|
|
679
|
+
class="fill-[var(--color-muted)] text-[10px] tabular-nums"
|
|
680
|
+
>
|
|
681
|
+
{{ g.label }}
|
|
682
|
+
</text>
|
|
683
|
+
<text
|
|
684
|
+
v-if="showYAxis && g.rightLabel"
|
|
685
|
+
:x="box.width - box.right + 8"
|
|
686
|
+
:y="g.y"
|
|
687
|
+
text-anchor="start"
|
|
688
|
+
dominant-baseline="middle"
|
|
689
|
+
class="fill-[var(--color-muted)] text-[10px] tabular-nums"
|
|
690
|
+
>
|
|
691
|
+
{{ g.rightLabel }}
|
|
692
|
+
</text>
|
|
693
|
+
</template>
|
|
694
|
+
</g>
|
|
695
|
+
|
|
696
|
+
<!-- zero line, when the axis crosses it -->
|
|
697
|
+
<line
|
|
698
|
+
v-if="scale.min < 0 && scale.max > 0"
|
|
699
|
+
:x1="box.left"
|
|
700
|
+
:x2="box.width - box.right"
|
|
701
|
+
:y1="baseY"
|
|
702
|
+
:y2="baseY"
|
|
703
|
+
stroke="var(--color-line-strong)"
|
|
704
|
+
stroke-width="1"
|
|
705
|
+
/>
|
|
706
|
+
|
|
707
|
+
<!-- hovered band highlight -->
|
|
708
|
+
<rect
|
|
709
|
+
v-if="active >= 0 && banded"
|
|
710
|
+
:x="activeX - plotWidth(box) / count / 2"
|
|
711
|
+
:y="box.top"
|
|
712
|
+
:width="plotWidth(box) / count"
|
|
713
|
+
:height="plotHeight(box)"
|
|
714
|
+
fill="var(--color-surface-soft)"
|
|
715
|
+
opacity="0.5"
|
|
716
|
+
/>
|
|
717
|
+
|
|
718
|
+
<g :clip-path="`url(#${clipId}-reveal)`">
|
|
719
|
+
<!-- bars: paths, so a stack rounds as one shape (see barPath) -->
|
|
720
|
+
<path
|
|
721
|
+
v-for="b in bars"
|
|
722
|
+
:key="b.key"
|
|
723
|
+
:d="b.d"
|
|
724
|
+
:fill="b.color"
|
|
725
|
+
class="transition-opacity duration-fast"
|
|
726
|
+
:opacity="active < 0 || active === b.index ? 1 : 0.45"
|
|
727
|
+
/>
|
|
728
|
+
|
|
729
|
+
<!-- area fills -->
|
|
730
|
+
<path
|
|
731
|
+
v-for="s in lines"
|
|
732
|
+
v-show="type === 'area'"
|
|
733
|
+
:key="`f-${s.name}`"
|
|
734
|
+
:d="s.fill"
|
|
735
|
+
:fill="`url(#${clipId}-g-${s.name})`"
|
|
736
|
+
/>
|
|
737
|
+
|
|
738
|
+
<!-- lines -->
|
|
739
|
+
<path
|
|
740
|
+
v-for="s in lines"
|
|
741
|
+
v-show="type !== 'bar'"
|
|
742
|
+
:key="`l-${s.name}`"
|
|
743
|
+
:d="s.d"
|
|
744
|
+
fill="none"
|
|
745
|
+
:stroke="s.color"
|
|
746
|
+
stroke-width="2"
|
|
747
|
+
stroke-linecap="round"
|
|
748
|
+
stroke-linejoin="round"
|
|
749
|
+
:stroke-dasharray="s.dashed ? '5 4' : undefined"
|
|
750
|
+
/>
|
|
751
|
+
|
|
752
|
+
<!-- every-point dots -->
|
|
753
|
+
<template v-if="type !== 'bar'">
|
|
754
|
+
<template v-for="s in lines" :key="`p-${s.name}`">
|
|
755
|
+
<template v-if="s.points">
|
|
756
|
+
<circle
|
|
757
|
+
v-for="(v, i) in s.values"
|
|
758
|
+
v-show="v != null"
|
|
759
|
+
:key="i"
|
|
760
|
+
:cx="xAt(i, count, box, banded)"
|
|
761
|
+
:cy="v == null ? 0 : yAt(v, s.scale.min, s.scale.max, box)"
|
|
762
|
+
r="2.5"
|
|
763
|
+
:fill="s.color"
|
|
764
|
+
/>
|
|
765
|
+
</template>
|
|
766
|
+
</template>
|
|
767
|
+
</template>
|
|
768
|
+
</g>
|
|
769
|
+
|
|
770
|
+
<!-- crosshair -->
|
|
771
|
+
<g v-if="active >= 0 && type !== 'bar'">
|
|
772
|
+
<line
|
|
773
|
+
:x1="activeX"
|
|
774
|
+
:x2="activeX"
|
|
775
|
+
:y1="box.top"
|
|
776
|
+
:y2="box.top + plotHeight(box)"
|
|
777
|
+
stroke="var(--color-line-strong)"
|
|
778
|
+
stroke-width="1"
|
|
779
|
+
stroke-dasharray="3 3"
|
|
780
|
+
/>
|
|
781
|
+
<circle
|
|
782
|
+
v-for="p in activePoints"
|
|
783
|
+
:key="p.name"
|
|
784
|
+
:cx="activeX"
|
|
785
|
+
:cy="p.y"
|
|
786
|
+
r="4"
|
|
787
|
+
:fill="p.color"
|
|
788
|
+
stroke="var(--color-surface)"
|
|
789
|
+
stroke-width="2"
|
|
790
|
+
/>
|
|
791
|
+
</g>
|
|
792
|
+
|
|
793
|
+
<!-- x labels -->
|
|
794
|
+
<text
|
|
795
|
+
v-for="l in xLabels"
|
|
796
|
+
v-show="showXAxis"
|
|
797
|
+
:key="l.i"
|
|
798
|
+
:x="l.x"
|
|
799
|
+
:y="height - 8"
|
|
800
|
+
text-anchor="middle"
|
|
801
|
+
class="fill-[var(--color-muted)] text-[10px]"
|
|
802
|
+
:class="active === l.i ? 'fill-[var(--color-ink)]' : ''"
|
|
803
|
+
>
|
|
804
|
+
{{ l.label }}
|
|
805
|
+
</text>
|
|
806
|
+
</svg>
|
|
807
|
+
|
|
808
|
+
<!-- tooltip: springs after the pointer (see tooltipTarget) -->
|
|
809
|
+
<Motion
|
|
810
|
+
v-if="active >= 0 && hasData"
|
|
811
|
+
as="div"
|
|
812
|
+
class="pointer-events-none absolute left-0 top-0 z-(--z-tooltip) min-w-32 rounded-control border border-line bg-surface-overlay px-2.5 py-2 shadow-panel backdrop-blur group-data-[state=open]/plot:hidden"
|
|
813
|
+
:initial="{ opacity: 0, scale: 0.96 }"
|
|
814
|
+
:animate="{
|
|
815
|
+
opacity: 1,
|
|
816
|
+
scale: 1,
|
|
817
|
+
x: tooltipTarget.flip ? `calc(${tooltipTarget.x}px - 100%)` : `${tooltipTarget.x}px`,
|
|
818
|
+
y: `calc(${tooltipTarget.y}px - 50%)`,
|
|
819
|
+
}"
|
|
820
|
+
:transition="floatTransition"
|
|
821
|
+
>
|
|
822
|
+
<!-- A point where one series has no value drops a row; easing the box
|
|
823
|
+
keeps the card from snapping mid-glide. -->
|
|
824
|
+
<LpShift axis="both">
|
|
825
|
+
<div class="mb-1 text-[11px] font-medium text-muted-strong">
|
|
826
|
+
{{ labels[active] }}
|
|
827
|
+
</div>
|
|
828
|
+
<div class="flex flex-col gap-1">
|
|
829
|
+
<div
|
|
830
|
+
v-for="r in tooltipRows"
|
|
831
|
+
:key="r.name"
|
|
832
|
+
class="flex items-center justify-between gap-3 text-xs"
|
|
833
|
+
>
|
|
834
|
+
<span class="flex items-center gap-1.5 text-muted">
|
|
835
|
+
<span class="size-1.5 shrink-0 rounded-full" :style="{ backgroundColor: r.color }" />
|
|
836
|
+
{{ r.name }}
|
|
837
|
+
</span>
|
|
838
|
+
<span class="font-mono tabular-nums text-ink">{{ r.text }}</span>
|
|
839
|
+
</div>
|
|
840
|
+
</div>
|
|
841
|
+
</LpShift>
|
|
842
|
+
</Motion>
|
|
843
|
+
</div>
|
|
844
|
+
</LpContextMenu>
|
|
845
|
+
</div>
|
|
846
|
+
</template>
|