@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.
Files changed (104) hide show
  1. package/dist/assets/LpNumberFlow-rZkwQwlU.css +116 -0
  2. package/dist/assets/LpRollingText-CR1mb0Vt.css +74 -0
  3. package/dist/assets/LpSparkline-0mKTq7pb.css +51 -0
  4. package/dist/assets/LpTooltip-B61SYd4K.css +17 -0
  5. package/dist/assets/LpUptimeBar-B98o9krI.css +18 -0
  6. package/dist/chunks/{LpAppShell-CaQYQKZV.js → LpAppShell-B5k0J3Cf.js} +1 -1
  7. package/dist/chunks/LpChart-tNXs1ttr.js +671 -0
  8. package/dist/chunks/{LpCodeBlock-CSiOSPs8.js → LpCodeBlock-rS_6mO3D.js} +1 -1
  9. package/dist/chunks/{LpConfirmDialog-CObRah9c.js → LpConfirmDialog-CjOGcj1e.js} +32 -4
  10. package/dist/chunks/{LpDisclosure-DP6yfgfM.js → LpDisclosure-BnwP4Q-b.js} +1 -1
  11. package/dist/chunks/{LpFileTree-nU1wRRxZ.js → LpFileTree-BaV7f0O7.js} +1 -1
  12. package/dist/chunks/{LpFileTreeNode-D7WgUPcJ.js → LpFileTreeNode-BzpBa7R7.js} +6 -8
  13. package/dist/chunks/{LpLogViewer-B1aLUVdz.js → LpLogViewer-CcQ3s3Fp.js} +2 -1
  14. package/dist/chunks/{LpMapPicker-DFKrd1Ne.js → LpMapPicker-CXvoTrAe.js} +2 -4
  15. package/dist/chunks/{LpNotificationBell-X1M30e0X.js → LpNotificationBell-Dvu-b6Vn.js} +56 -27
  16. package/dist/chunks/LpNumberFlow-amWYaeuA.js +221 -0
  17. package/dist/chunks/LpPie-BkjWlfSw.js +525 -0
  18. package/dist/chunks/LpProgress-DoIP4jQR.js +198 -0
  19. package/dist/chunks/LpRollingText-CM5gkSeF.js +50 -0
  20. package/dist/chunks/{LpSidebar-D3GGLSE7.js → LpSidebar-7Vpy2IJc.js} +1 -1
  21. package/dist/chunks/{LpSidebarNav-X14xMdPa.js → LpSidebarNav-CFX5NxGs.js} +1 -1
  22. package/dist/chunks/LpSparkline-C6xQ2dwv.js +175 -0
  23. package/dist/chunks/{LpStat-CdbEFOvn.js → LpStat-CDanpzU0.js} +43 -5
  24. package/dist/chunks/{LpThemeSwitcher-BgpJgrnP.js → LpThemeSwitcher-7mAK6bI7.js} +2 -4
  25. package/dist/chunks/LpTooltip-hCLPAqMG.js +131 -0
  26. package/dist/chunks/LpUptimeBar-DcQ8HikG.js +152 -0
  27. package/dist/component-names.d.ts +1 -1
  28. package/dist/components/LpAppShell.vue.js +1 -1
  29. package/dist/components/LpChart.vue.d.ts +88 -0
  30. package/dist/components/LpChart.vue.js +4 -0
  31. package/dist/components/LpCodeBlock.vue.js +1 -1
  32. package/dist/components/LpConfirmDialog.vue.d.ts +18 -2
  33. package/dist/components/LpConfirmDialog.vue.js +1 -1
  34. package/dist/components/LpDisclosure.vue.js +1 -1
  35. package/dist/components/LpFileTree.vue.d.ts +1 -1
  36. package/dist/components/LpFileTree.vue.js +1 -1
  37. package/dist/components/LpFileTreeNode.vue.js +1 -1
  38. package/dist/components/LpLightbox.vue.d.ts +1 -1
  39. package/dist/components/LpLogViewer.vue.d.ts +1 -1
  40. package/dist/components/LpLogViewer.vue.js +1 -1
  41. package/dist/components/LpMapPicker.vue.js +1 -1
  42. package/dist/components/LpNotificationBell.vue.d.ts +12 -0
  43. package/dist/components/LpNotificationBell.vue.js +1 -1
  44. package/dist/components/LpNumberFlow.vue.d.ts +35 -0
  45. package/dist/components/LpNumberFlow.vue.js +4 -0
  46. package/dist/components/LpPie.vue.d.ts +134 -0
  47. package/dist/components/LpPie.vue.js +4 -0
  48. package/dist/components/LpProgress.vue.d.ts +56 -2
  49. package/dist/components/LpProgress.vue.js +1 -1
  50. package/dist/components/LpRollingText.vue.d.ts +33 -0
  51. package/dist/components/LpRollingText.vue.js +4 -0
  52. package/dist/components/LpSidebar.vue.js +1 -1
  53. package/dist/components/LpSidebarNav.vue.js +1 -1
  54. package/dist/components/LpSparkline.vue.d.ts +48 -0
  55. package/dist/components/LpSparkline.vue.js +4 -0
  56. package/dist/components/LpStat.vue.d.ts +25 -2
  57. package/dist/components/LpStat.vue.js +1 -1
  58. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  59. package/dist/components/LpTooltip.vue.d.ts +24 -3
  60. package/dist/components/LpTooltip.vue.js +1 -1
  61. package/dist/components/LpUptimeBar.vue.js +1 -1
  62. package/dist/components/chart.d.ts +180 -0
  63. package/dist/components/chart.js +353 -0
  64. package/dist/composables/easing.d.ts +46 -0
  65. package/dist/composables/easing.js +23 -0
  66. package/dist/composables/usePillTransition.d.ts +13 -1
  67. package/dist/composables/usePillTransition.js +22 -2
  68. package/dist/composables/useReveal.d.ts +26 -0
  69. package/dist/composables/useReveal.js +45 -0
  70. package/dist/composables/useSettled.js +2 -2
  71. package/dist/composables/useShift.js +2 -4
  72. package/dist/index.d.ts +14 -0
  73. package/dist/index.js +126 -103
  74. package/package.json +2 -1
  75. package/src/component-names.ts +5 -0
  76. package/src/components/LpChart.vue +846 -0
  77. package/src/components/LpCodeBlock.vue +1 -1
  78. package/src/components/LpConfirmDialog.vue +49 -0
  79. package/src/components/LpDisclosure.vue +1 -1
  80. package/src/components/LpFileTreeNode.vue +9 -10
  81. package/src/components/LpLogViewer.vue +2 -3
  82. package/src/components/LpMapPicker.vue +2 -5
  83. package/src/components/LpNotificationBell.vue +76 -15
  84. package/src/components/LpNumberFlow.vue +524 -0
  85. package/src/components/LpPie.vue +778 -0
  86. package/src/components/LpProgress.vue +283 -6
  87. package/src/components/LpRollingText.vue +157 -0
  88. package/src/components/LpSidebarNav.vue +1 -1
  89. package/src/components/LpSparkline.vue +312 -0
  90. package/src/components/LpStat.vue +52 -4
  91. package/src/components/LpTooltip.vue +185 -3
  92. package/src/components/LpUptimeBar.vue +150 -53
  93. package/src/components/chart.ts +686 -0
  94. package/src/composables/easing.ts +88 -0
  95. package/src/composables/usePillTransition.ts +71 -6
  96. package/src/composables/useReveal.ts +113 -0
  97. package/src/composables/useSettled.ts +2 -4
  98. package/src/composables/useShift.ts +2 -8
  99. package/src/index.ts +19 -0
  100. package/src/theme/useTheme.ts +2 -8
  101. package/src/tokens/tokens.css +36 -0
  102. package/dist/chunks/LpProgress-BhI0IbjC.js +0 -37
  103. package/dist/chunks/LpTooltip-D1hbjQWG.js +0 -63
  104. 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>