@leavepulse/ui 0.24.1 → 0.26.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 (97) hide show
  1. package/dist/assets/LpNumberFlow-rZkwQwlU.css +116 -0
  2. package/dist/assets/LpRollingText-CR1mb0Vt.css +74 -0
  3. package/dist/assets/LpSkeleton-D7xU8IPV.css +29 -0
  4. package/dist/assets/LpSparkline-0mKTq7pb.css +51 -0
  5. package/dist/assets/LpTooltip-B61SYd4K.css +17 -0
  6. package/dist/assets/LpUptimeBar-B98o9krI.css +18 -0
  7. package/dist/chunks/LpChart-tNXs1ttr.js +671 -0
  8. package/dist/chunks/{LpConfirmDialog-CObRah9c.js → LpConfirmDialog-CjOGcj1e.js} +32 -4
  9. package/dist/chunks/{LpFileTree-H7Ho8uoq.js → LpFileTree-BaV7f0O7.js} +1 -1
  10. package/dist/chunks/{LpFileTreeNode-BEZIvxHi.js → LpFileTreeNode-BzpBa7R7.js} +5 -7
  11. package/dist/chunks/{LpLogViewer-B1aLUVdz.js → LpLogViewer-CcQ3s3Fp.js} +2 -1
  12. package/dist/chunks/{LpMapPicker-DFKrd1Ne.js → LpMapPicker-CXvoTrAe.js} +2 -4
  13. package/dist/chunks/{LpNotificationBell-X1M30e0X.js → LpNotificationBell-Dvu-b6Vn.js} +56 -27
  14. package/dist/chunks/LpNumberFlow-amWYaeuA.js +221 -0
  15. package/dist/chunks/LpPie-BkjWlfSw.js +525 -0
  16. package/dist/chunks/LpProgress-DoIP4jQR.js +198 -0
  17. package/dist/chunks/LpRollingText-CM5gkSeF.js +50 -0
  18. package/dist/chunks/LpSkeleton-t5mMYl3a.js +29 -0
  19. package/dist/chunks/LpSparkline-C6xQ2dwv.js +175 -0
  20. package/dist/chunks/{LpStat-CdbEFOvn.js → LpStat-CDanpzU0.js} +43 -5
  21. package/dist/chunks/{LpThemeSwitcher-BgpJgrnP.js → LpThemeSwitcher-7mAK6bI7.js} +2 -4
  22. package/dist/chunks/LpTooltip-hCLPAqMG.js +131 -0
  23. package/dist/chunks/LpUptimeBar-DcQ8HikG.js +152 -0
  24. package/dist/component-names.d.ts +1 -1
  25. package/dist/components/LpChart.vue.d.ts +88 -0
  26. package/dist/components/LpChart.vue.js +4 -0
  27. package/dist/components/LpConfirmDialog.vue.d.ts +18 -2
  28. package/dist/components/LpConfirmDialog.vue.js +1 -1
  29. package/dist/components/LpFileTree.vue.d.ts +1 -1
  30. package/dist/components/LpFileTree.vue.js +1 -1
  31. package/dist/components/LpFileTreeNode.vue.js +1 -1
  32. package/dist/components/LpLightbox.vue.d.ts +1 -1
  33. package/dist/components/LpLogViewer.vue.d.ts +1 -1
  34. package/dist/components/LpLogViewer.vue.js +1 -1
  35. package/dist/components/LpMapPicker.vue.js +1 -1
  36. package/dist/components/LpNotificationBell.vue.d.ts +12 -0
  37. package/dist/components/LpNotificationBell.vue.js +1 -1
  38. package/dist/components/LpNumberFlow.vue.d.ts +35 -0
  39. package/dist/components/LpNumberFlow.vue.js +4 -0
  40. package/dist/components/LpPie.vue.d.ts +134 -0
  41. package/dist/components/LpPie.vue.js +4 -0
  42. package/dist/components/LpProgress.vue.d.ts +56 -2
  43. package/dist/components/LpProgress.vue.js +1 -1
  44. package/dist/components/LpRollingText.vue.d.ts +33 -0
  45. package/dist/components/LpRollingText.vue.js +4 -0
  46. package/dist/components/LpSkeleton.vue.d.ts +11 -1
  47. package/dist/components/LpSkeleton.vue.js +1 -1
  48. package/dist/components/LpSparkline.vue.d.ts +48 -0
  49. package/dist/components/LpSparkline.vue.js +4 -0
  50. package/dist/components/LpStat.vue.d.ts +25 -2
  51. package/dist/components/LpStat.vue.js +1 -1
  52. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  53. package/dist/components/LpTooltip.vue.d.ts +24 -3
  54. package/dist/components/LpTooltip.vue.js +1 -1
  55. package/dist/components/LpUptimeBar.vue.js +1 -1
  56. package/dist/components/chart.d.ts +180 -0
  57. package/dist/components/chart.js +353 -0
  58. package/dist/composables/easing.d.ts +46 -0
  59. package/dist/composables/easing.js +23 -0
  60. package/dist/composables/usePillTransition.d.ts +13 -1
  61. package/dist/composables/usePillTransition.js +22 -2
  62. package/dist/composables/useReveal.d.ts +26 -0
  63. package/dist/composables/useReveal.js +45 -0
  64. package/dist/composables/useSettled.js +2 -2
  65. package/dist/composables/useShift.js +2 -4
  66. package/dist/index.d.ts +14 -0
  67. package/dist/index.js +125 -102
  68. package/package.json +2 -1
  69. package/src/component-names.ts +5 -0
  70. package/src/components/LpChart.vue +846 -0
  71. package/src/components/LpConfirmDialog.vue +49 -0
  72. package/src/components/LpFileTreeNode.vue +5 -9
  73. package/src/components/LpLogViewer.vue +2 -3
  74. package/src/components/LpMapPicker.vue +2 -5
  75. package/src/components/LpNotificationBell.vue +76 -15
  76. package/src/components/LpNumberFlow.vue +524 -0
  77. package/src/components/LpPie.vue +778 -0
  78. package/src/components/LpProgress.vue +283 -6
  79. package/src/components/LpRollingText.vue +157 -0
  80. package/src/components/LpSkeleton.vue +81 -8
  81. package/src/components/LpSparkline.vue +312 -0
  82. package/src/components/LpStat.vue +52 -4
  83. package/src/components/LpTooltip.vue +185 -3
  84. package/src/components/LpUptimeBar.vue +150 -53
  85. package/src/components/chart.ts +686 -0
  86. package/src/composables/easing.ts +88 -0
  87. package/src/composables/usePillTransition.ts +71 -6
  88. package/src/composables/useReveal.ts +113 -0
  89. package/src/composables/useSettled.ts +2 -4
  90. package/src/composables/useShift.ts +2 -8
  91. package/src/index.ts +19 -0
  92. package/src/theme/useTheme.ts +2 -8
  93. package/src/tokens/tokens.css +36 -0
  94. package/dist/chunks/LpProgress-BhI0IbjC.js +0 -37
  95. package/dist/chunks/LpSkeleton-eSFIo4kD.js +0 -22
  96. package/dist/chunks/LpTooltip-D1hbjQWG.js +0 -63
  97. package/dist/chunks/LpUptimeBar-CB366k0O.js +0 -127
@@ -0,0 +1,778 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * Shares of a whole, as a pie or a donut. Same SVG-and-tokens approach as
4
+ * LpChart; the two differ only by `innerRadius`, so they're one component.
5
+ *
6
+ * Takes ANY number of slices. A circle stops being readable past a handful —
7
+ * slivers can't be compared or clicked — but a shared kit can't dictate how
8
+ * many categories a caller has, so instead of refusing or truncating, the tail
9
+ * is folded into a single "Other" slice (see resolveSlices) that keeps its
10
+ * total, its share and a count of what it stands for. Raise `maxSlices` or pass
11
+ * `0` to draw every slice as given.
12
+ *
13
+ * A donut's hole is the natural place for the headline the chart is about — the
14
+ * total, or whatever the caller puts in the `center` slot.
15
+ */
16
+ import { computed, onBeforeUnmount, ref, watch } from "vue"
17
+ import { easeTravel, prefersReducedMotion } from "../composables/easing"
18
+ import { useReveal } from "../composables/useReveal"
19
+ import { arcPath, calloutLabels, resolveSlices, formatTick, type PieSlice } from "./chart"
20
+ import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
21
+ import LpIcon from "./LpIcon.vue"
22
+ import LpNumberFlow from "./LpNumberFlow.vue"
23
+ import LpRollingText from "./LpRollingText.vue"
24
+ import LpShift from "./LpShift.vue"
25
+ import LpTooltip from "./LpTooltip.vue"
26
+
27
+ /** A slice, as reported by every interaction event. */
28
+ export interface SliceEvent {
29
+ index: number
30
+ label: string
31
+ value: number
32
+ isOther: boolean
33
+ }
34
+
35
+ const props = withDefaults(
36
+ defineProps<{
37
+ slices: PieSlice[]
38
+ type?: "pie" | "donut"
39
+ /** Outer diameter in px. */
40
+ size?: number
41
+ /** Donut only: hole diameter as a fraction of the outer radius (0–1). */
42
+ innerRatio?: number
43
+ title?: string
44
+ subtitle?: string
45
+ /**
46
+ * Keep the largest N-1 slices and sum the rest into "Other". `0` disables
47
+ * the fold and draws every slice — for a caller that knows its data is
48
+ * already few, or that would rather show a crowded circle than a bucket.
49
+ */
50
+ maxSlices?: number
51
+ /** Label for the folded remainder. */
52
+ otherLabel?: string
53
+ /**
54
+ * Draw the slices largest-first instead of in the order given. Ranking
55
+ * always decides which slices survive `maxSlices`; this decides whether it
56
+ * also reorders them.
57
+ */
58
+ sortBySize?: boolean
59
+ /** Appended to values in the legend and tooltip, e.g. " GB". */
60
+ unit?: string
61
+ /** Custom value formatter; overrides the compact 1.2k default. */
62
+ formatValue?: (v: number) => string
63
+ /**
64
+ * Name the slices with leader lines around the circle instead of a legend
65
+ * beside it. Better when the labels are long and self-explanatory (versions,
66
+ * hostnames) and the reader wants share-per-name without cross-referencing a
67
+ * colour; the legend stays better for short, repeated categories.
68
+ */
69
+ labels?: boolean
70
+ /** Callout width reserved for text on each side, in px. */
71
+ labelWidth?: number
72
+ /**
73
+ * Defaults to true, except in `labels` mode — callouts already name every
74
+ * slice, and a legend beside them says the same thing twice. Set it
75
+ * explicitly to have both.
76
+ */
77
+ showLegend?: boolean
78
+ /** Print each slice's percentage next to its legend entry. */
79
+ showPercent?: boolean
80
+ /** Donut only: print the total in the hole. Ignored when #center is used. */
81
+ showTotal?: boolean
82
+ /** Gap between slices in degrees — reads as separate parts, not one disc. */
83
+ padAngle?: number
84
+ /** Corner radius of each slice in px. 0 for hard edges. */
85
+ cornerRadius?: number
86
+ /** How far the hovered slice lifts out of the circle, in px. */
87
+ hoverOffset?: number
88
+ /**
89
+ * Sweep the circle open, clockwise from twelve, when it first comes into
90
+ * view. Turn it off where several are drawn at once and the sweeps would
91
+ * compete rather than read as one arrival.
92
+ */
93
+ animate?: boolean
94
+ /** Sweep time in ms. */
95
+ duration?: number
96
+ /**
97
+ * Right-click menu, built from the slice that was hit and the whole current
98
+ * selection — so an action can apply to one slice or to all the picked ones.
99
+ */
100
+ menuItems?: (slice: SliceEvent, selection: SliceEvent[]) => ContextMenuItemDef[]
101
+ emptyLabel?: string
102
+ }>(),
103
+ {
104
+ type: "donut",
105
+ size: 180,
106
+ innerRatio: 0.62,
107
+ maxSlices: 6,
108
+ otherLabel: "Other",
109
+ showPercent: true,
110
+ showTotal: true,
111
+ labelWidth: 140,
112
+ padAngle: 1.5,
113
+ cornerRadius: 4,
114
+ hoverOffset: 8,
115
+ emptyLabel: "No data",
116
+ animate: true,
117
+ duration: 900,
118
+ },
119
+ )
120
+
121
+ const emit = defineEmits<{
122
+ (e: "sliceClick", payload: SliceEvent): void
123
+ /** The full selection after any change — click, Ctrl, Shift, drag or clear. */
124
+ (e: "selectionChange", payload: SliceEvent[]): void
125
+ /** Right-click, fired before `menuItems` is consulted. */
126
+ (e: "sliceContextMenu", payload: SliceEvent): void
127
+ }>()
128
+
129
+ const resolved = computed(() =>
130
+ resolveSlices(
131
+ props.slices,
132
+ props.maxSlices || undefined,
133
+ props.otherLabel,
134
+ !props.sortBySize,
135
+ ),
136
+ )
137
+
138
+ const hasData = computed(() => resolved.value.slices.length > 0)
139
+
140
+ const legendShown = computed(() => props.showLegend ?? !props.labels)
141
+
142
+ const fmt = (v: number): string =>
143
+ `${props.formatValue ? props.formatValue(v) : formatTick(v)}${props.unit ?? ""}`
144
+
145
+ const pct = (fraction: number): string => {
146
+ const p = fraction * 100
147
+ // Never round a present slice down to "0%" — it reads as absent.
148
+ if (p > 0 && p < 0.1) return "<0.1%"
149
+ return `${Number(p.toFixed(p < 10 ? 1 : 0))}%`
150
+ }
151
+
152
+ /* Angles first, separately: the label layout needs them before the paths can be
153
+ placed (the canvas height depends on how many labels there are). */
154
+ const arcAngles = computed(() => {
155
+ const list = resolved.value.slices
156
+ const pad = list.length > 1 ? (props.padAngle * Math.PI) / 180 : 0
157
+ let angle = -Math.PI / 2
158
+
159
+ return list.map((s, index) => {
160
+ const sweep = s.fraction * Math.PI * 2
161
+ const start = angle
162
+ angle += sweep
163
+ // A pad wider than the slice itself would invert it; clamp to a hairline.
164
+ const gap = Math.min(pad, sweep * 0.5)
165
+ return { index, slice: s, start: start + gap / 2, end: angle - gap / 2, mid: start + sweep / 2 }
166
+ })
167
+ })
168
+
169
+ /*
170
+ * Callouts need room on both sides for their text, so the canvas grows while
171
+ * the circle keeps its `size`. Everything below is measured from the canvas
172
+ * centre, which is why cx and cy are tracked separately.
173
+ */
174
+ /* The radial leg is long enough that leaders fan out clearly before they turn;
175
+ too short and a cluster of slivers bends almost on the rim. */
176
+ const ELBOW = 16
177
+ const RUN = 14
178
+ const LABEL_LINE = 17
179
+
180
+ const canvasWidth = computed(() =>
181
+ props.labels ? props.size + (props.labelWidth + ELBOW + RUN) * 2 : props.size,
182
+ )
183
+
184
+ /*
185
+ * Stacked labels can need more vertical room than the circle itself — sized by
186
+ * the FULLER SIDE, not the total. Splitting 20 labels across two columns needs
187
+ * room for ten, not twenty; sizing by the total left the columns loose while
188
+ * sizing by half exactly would overflow whenever the split is uneven (the
189
+ * balancing pass allows one side to hold the extra).
190
+ */
191
+ const canvasHeight = computed(() => {
192
+ if (!props.labels) return props.size
193
+ /*
194
+ * Tall enough for the column that actually forms. Labels are assigned by
195
+ * geometry, so the split follows the data rather than being even — a chart
196
+ * whose slices bunch on one side puts most of its labels there, and sizing for
197
+ * a 50/50 split would clip them. Counting each side's real occupancy sizes the
198
+ * box for the fuller one.
199
+ */
200
+ const right = arcAngles.value.filter((a) => Math.cos(a.mid) >= 0).length
201
+ const perSide = Math.max(right, arcAngles.value.length - right) + 2
202
+ return Math.max(props.size, perSide * LABEL_LINE + 16)
203
+ })
204
+
205
+ const cx = computed(() => canvasWidth.value / 2)
206
+ const cy = computed(() => canvasHeight.value / 2)
207
+
208
+ // The lift has to come out of the radius, not out of the box: a slice that
209
+ // grows past the viewBox would be clipped mid-hover.
210
+ const outer = computed(() => props.size / 2 - 2 - props.hoverOffset)
211
+ const inner = computed(() =>
212
+ props.type === "donut" ? outer.value * Math.min(0.95, Math.max(0, props.innerRatio)) : 0,
213
+ )
214
+
215
+ /*
216
+ * Angles run clockwise from twelve o'clock, which is where a circle is read
217
+ * from. The pad is taken off each slice's own sweep and only when there is more
218
+ * than one slice — a lone slice with a gap would be a circle with a nick in it.
219
+ */
220
+ const arcs = computed(() =>
221
+ arcAngles.value.map(({ slice, start, end, mid }) => ({
222
+ ...slice,
223
+ d: arcPath(cx.value, cy.value, outer.value, inner.value, start, end, props.cornerRadius),
224
+ /*
225
+ * Push along the slice's own bisector, so it moves straight out of the
226
+ * circle rather than sideways.
227
+ *
228
+ * As an SVG `transform` attribute, not a CSS `translate`: Vue binds the
229
+ * latter as an inline style, and on an SVG <path> that produced no style
230
+ * attribute at all — the wedge never moved, though the dimming beside it
231
+ * worked and made it look as if it had.
232
+ */
233
+ lift: `translate(${(Math.cos(mid) * props.hoverOffset).toFixed(2)} ${(
234
+ Math.sin(mid) * props.hoverOffset
235
+ ).toFixed(2)})`,
236
+ })),
237
+ )
238
+
239
+ /*
240
+ * The circle sweeps open clockwise from twelve, the way it is read.
241
+ *
242
+ * A wedge-shaped clip does the work, so nothing about the slices themselves
243
+ * changes — they are drawn once, at their final geometry, and simply revealed.
244
+ * Growing each slice's own angle instead would re-lay the callouts on every
245
+ * frame and shuffle the labels around the rim.
246
+ *
247
+ * The wedge is an `arcPath` from the same helper the slices use, at a radius
248
+ * comfortably past the rim so the lift on a hovered slice is never clipped.
249
+ */
250
+ const { el: revealAnchor, revealed } = useReveal({ immediate: () => !props.animate })
251
+
252
+ const sweep = ref(props.animate ? 0 : 1)
253
+ let sweepFrame = 0
254
+
255
+ const stopSweep = () => {
256
+ if (sweepFrame) cancelAnimationFrame(sweepFrame)
257
+ sweepFrame = 0
258
+ }
259
+
260
+ const runSweep = () => {
261
+ stopSweep()
262
+ if (prefersReducedMotion() || props.duration <= 0) {
263
+ sweep.value = 1
264
+ return
265
+ }
266
+ const started = performance.now()
267
+ const step = (now: number) => {
268
+ const t = Math.min(1, (now - started) / props.duration)
269
+ // The sweep is a journey around the circle, so it eases like one.
270
+ sweep.value = easeTravel(t)
271
+ sweepFrame = t < 1 ? requestAnimationFrame(step) : 0
272
+ }
273
+ sweepFrame = requestAnimationFrame(step)
274
+ }
275
+
276
+ watch(
277
+ revealed,
278
+ (visible) => {
279
+ if (visible && props.animate) runSweep()
280
+ },
281
+ { immediate: true },
282
+ )
283
+
284
+ onBeforeUnmount(stopSweep)
285
+
286
+ const sweepId = `lp-pie-${Math.random().toString(36).slice(2, 9)}`
287
+
288
+ /** Full circle once the sweep completes, so nothing stays clipped. */
289
+ const sweepPath = computed(() => {
290
+ const start = -Math.PI / 2
291
+ const radius = outer.value + props.hoverOffset + 4
292
+ return arcPath(cx.value, cy.value, radius, 0, start, start + Math.PI * 2 * sweep.value)
293
+ })
294
+
295
+ const callouts = computed(() =>
296
+ props.labels
297
+ ? calloutLabels(
298
+ arcAngles.value.map((a) => ({ index: a.index, mid: a.mid })),
299
+ cx.value,
300
+ cy.value,
301
+ outer.value,
302
+ ELBOW,
303
+ RUN,
304
+ LABEL_LINE,
305
+ canvasHeight.value,
306
+ )
307
+ : [],
308
+ )
309
+
310
+ /*
311
+ * Hover and selection are separate states, because they answer different
312
+ * questions. Hovering LIFTS the slice under the pointer — a local, positive
313
+ * response that leaves the rest alone. Selecting is a claim about the whole
314
+ * chart, and that is when everything unselected recedes; dimming on mere hover
315
+ * would make the circle flicker as the pointer crossed it.
316
+ *
317
+ * Selection is a SET, following the conventions of every list people already
318
+ * know: click replaces it, Ctrl/Cmd toggles one, Shift takes the run from the
319
+ * anchor, and dragging across the circle picks up whatever it passes over. A
320
+ * chart people compare slices in needs "these two, not that one", which a
321
+ * single-selection model can't express.
322
+ */
323
+ const active = ref(-1)
324
+ const selected = ref(new Set<number>())
325
+ /** Where a Shift-range starts: the last slice touched deliberately. */
326
+ const anchor = ref(-1)
327
+ const dragging = ref(false)
328
+
329
+ const isSelected = (i: number) => selected.value.has(i)
330
+
331
+ const payloadFor = (i: number) => {
332
+ const a = arcs.value[i]
333
+ return { index: i, label: a.label, value: a.value, isOther: a.isOther }
334
+ }
335
+
336
+ const emitSelection = () => {
337
+ const indices = [...selected.value].sort((x, y) => x - y)
338
+ emit("selectionChange", indices.map(payloadFor))
339
+ }
340
+
341
+ const rangeTo = (i: number) => {
342
+ const from = anchor.value < 0 ? i : anchor.value
343
+ const [lo, hi] = from <= i ? [from, i] : [i, from]
344
+ const next = new Set(selected.value)
345
+ for (let k = lo; k <= hi; k++) next.add(k)
346
+ return next
347
+ }
348
+
349
+ const onSlicePointerDown = (i: number, e: PointerEvent) => {
350
+ // Right-click opens a menu; it must not disturb a selection the menu acts on.
351
+ if (e.button === 2) return
352
+
353
+ if (e.shiftKey) {
354
+ selected.value = rangeTo(i)
355
+ } else if (e.ctrlKey || e.metaKey) {
356
+ const next = new Set(selected.value)
357
+ if (next.has(i)) next.delete(i)
358
+ else next.add(i)
359
+ selected.value = next
360
+ anchor.value = i
361
+ } else {
362
+ // A plain click on the only selected slice clears, so the circle can be
363
+ // reset without hunting for empty space.
364
+ const only = selected.value.size === 1 && selected.value.has(i)
365
+ selected.value = only ? new Set() : new Set([i])
366
+ anchor.value = only ? -1 : i
367
+ if (!only) beginDrag()
368
+ }
369
+ emitSelection()
370
+ emit("sliceClick", payloadFor(i))
371
+ }
372
+
373
+ /** Drag across slices to gather them — same gesture as a rubber-band in a list. */
374
+ const onSlicePointerEnter = (i: number) => {
375
+ clearTimeout(leaveTimer)
376
+ active.value = i
377
+ if (!dragging.value || selected.value.has(i)) return
378
+ const next = new Set(selected.value)
379
+ next.add(i)
380
+ selected.value = next
381
+ emitSelection()
382
+ }
383
+
384
+ /*
385
+ * A drag that ends outside the component never fires our own pointerup, and the
386
+ * circle would then keep gathering slices on the next hover. Bind to the window
387
+ * for the duration of the gesture only — the same shape LpScrollArea and
388
+ * LpDrawer use for their drags — rather than holding a global listener for the
389
+ * component's whole life.
390
+ */
391
+ /*
392
+ * Leaving a slice for the canvas — the donut's hole, the gaps between wedges —
393
+ * is not leaving the chart, so the svg's own pointerleave never fires and the
394
+ * hover would stay on the last slice touched. Clearing per slice means the
395
+ * readout and the tooltip both let go the moment the pointer is over nothing.
396
+ * Guarded by index so a move straight onto a neighbour (whose pointerenter has
397
+ * already run) doesn't immediately undo it.
398
+ */
399
+ let leaveTimer: ReturnType<typeof setTimeout> | undefined
400
+
401
+ const onSlicePointerLeave = (i: number) => {
402
+ if (active.value !== i) return
403
+ /*
404
+ * Deferred by a frame or two: crossing from one wedge to the next passes over
405
+ * the gap between them, and clearing immediately made the readout blink and
406
+ * the tooltip close and reopen on every crossing. A neighbour's pointerenter
407
+ * cancels this before it runs; only landing on nothing lets it through.
408
+ */
409
+ clearTimeout(leaveTimer)
410
+ leaveTimer = setTimeout(() => {
411
+ if (active.value === i) active.value = -1
412
+ }, 60)
413
+ }
414
+
415
+ /*
416
+ * Leaving the chart itself is unambiguous — drop the hover at once, without the
417
+ * grace period that covers the gaps between wedges.
418
+ *
419
+ * Except when the pointer "left" onto the tooltip. The tip is placed AT the
420
+ * cursor, and although it takes no pointer events the browser still resolves it
421
+ * as the element being entered, so crossing a wedge boundary underneath it
422
+ * reports a pointerleave from the whole svg with the tip as relatedTarget.
423
+ * Treated as a real exit, that closed the tip the instant it got in its own way.
424
+ */
425
+ const leaveChart = (e: PointerEvent) => {
426
+ // relatedTarget can be the panel itself or reka's positioning wrapper around
427
+ // it, so look both up and down from wherever the pointer was reported to go.
428
+ const to = e.relatedTarget
429
+ if (
430
+ to instanceof Element &&
431
+ (to.closest("[data-lp-follow]") || to.querySelector("[data-lp-follow]"))
432
+ )
433
+ return
434
+ clearTimeout(leaveTimer)
435
+ active.value = -1
436
+ endDrag()
437
+ }
438
+
439
+ const endDrag = () => {
440
+ dragging.value = false
441
+ window.removeEventListener("pointerup", endDrag)
442
+ window.removeEventListener("pointercancel", endDrag)
443
+ }
444
+
445
+ const beginDrag = () => {
446
+ dragging.value = true
447
+ window.addEventListener("pointerup", endDrag)
448
+ window.addEventListener("pointercancel", endDrag)
449
+ }
450
+
451
+ onBeforeUnmount(() => {
452
+ endDrag()
453
+ clearTimeout(leaveTimer)
454
+ window.removeEventListener("keydown", onKeydown)
455
+ })
456
+
457
+ const clearSelection = () => {
458
+ if (!selected.value.size) return
459
+ selected.value = new Set()
460
+ anchor.value = -1
461
+ emitSelection()
462
+ }
463
+
464
+ /*
465
+ * Escape drops the selection, the way it dismisses any transient state. Bound
466
+ * to the window rather than the chart because a selection made with the mouse
467
+ * leaves focus nowhere in particular — requiring the user to focus the chart
468
+ * first would make the key useless exactly when they reach for it. Only armed
469
+ * while something is selected, so the chart never swallows an Escape meant for
470
+ * a dialog or a menu above it.
471
+ */
472
+ const onKeydown = (e: KeyboardEvent) => {
473
+ if (e.key === "Escape") clearSelection()
474
+ }
475
+
476
+ watch(
477
+ () => selected.value.size > 0,
478
+ (hasSelection) => {
479
+ if (hasSelection) window.addEventListener("keydown", onKeydown)
480
+ else window.removeEventListener("keydown", onKeydown)
481
+ },
482
+ )
483
+
484
+ const menuPoint = ref<{ index: number; label: string; value: number; isOther: boolean } | null>(
485
+ null,
486
+ )
487
+
488
+ const menuEntries = computed(() =>
489
+ props.menuItems && menuPoint.value
490
+ ? props.menuItems(menuPoint.value, [...selected.value].sort((x, y) => x - y).map(payloadFor))
491
+ : [],
492
+ )
493
+
494
+ /*
495
+ * A right-click on an unselected slice moves the selection to it — the menu
496
+ * must act on what the user pointed at. Right-clicking INSIDE an existing
497
+ * selection keeps it, so a menu can operate on all of the picked slices.
498
+ */
499
+ const onSliceContextMenu = (i: number) => {
500
+ if (!selected.value.has(i)) {
501
+ selected.value = new Set([i])
502
+ anchor.value = i
503
+ emitSelection()
504
+ }
505
+ menuPoint.value = payloadFor(i)
506
+ emit("sliceContextMenu", menuPoint.value)
507
+ }
508
+
509
+ /** The slice the centre reads out: the hovered one, else a lone selection. */
510
+ const focused = computed(() => {
511
+ if (active.value >= 0) return active.value
512
+ return selected.value.size === 1 ? [...selected.value][0] : -1
513
+ })
514
+
515
+ /** Unselected slices only recede once something is actually selected. */
516
+ const dimmed = (i: number) => selected.value.size > 0 && !selected.value.has(i)
517
+
518
+ const total = computed(() => resolved.value.total)
519
+
520
+ const selectedArcs = computed(() =>
521
+ [...selected.value].sort((x, y) => x - y).map((i) => arcs.value[i]),
522
+ )
523
+
524
+ /** The slice under the pointer; null over the gaps, which disables the tip. */
525
+ const hovered = computed(() => (active.value >= 0 ? arcs.value[active.value] : null))
526
+
527
+ /* Sticky copy for the tooltip's own contents: `hovered` drops to null the
528
+ instant the pointer leaves a slice, and reading it directly would blank the
529
+ panel before its exit animation had run. */
530
+ const tipSlice = ref<(typeof arcs.value)[number] | null>(null)
531
+ watch(hovered, (v) => {
532
+ if (v) tipSlice.value = v
533
+ })
534
+
535
+ /* The raw number, so the centre can COUNT to it rather than cut. */
536
+ const centreValue = computed(() => {
537
+ if (focused.value >= 0) return arcs.value[focused.value].value
538
+ if (selected.value.size > 1)
539
+ return selectedArcs.value.reduce((sum, a) => sum + a.value, 0)
540
+ return total.value
541
+ })
542
+
543
+ const centreLabel = computed(() => {
544
+ if (focused.value >= 0) return arcs.value[focused.value].label
545
+ if (selected.value.size > 1) return `${selected.value.size} selected`
546
+ return "total"
547
+ })
548
+ </script>
549
+
550
+ <template>
551
+ <div ref="revealAnchor" class="flex flex-col gap-3">
552
+ <div v-if="title || subtitle" class="flex flex-col gap-0.5">
553
+ <span v-if="title" class="text-sm font-medium text-ink">{{ title }}</span>
554
+ <span v-if="subtitle" class="text-xs text-muted">{{ subtitle }}</span>
555
+ </div>
556
+
557
+ <div
558
+ v-if="!hasData"
559
+ class="flex flex-col items-center justify-center gap-2 rounded-control border border-dashed border-line text-muted"
560
+ :style="{ width: `${size}px`, height: `${size}px` }"
561
+ >
562
+ <LpIcon name="lucide:chart-pie" :size="18" />
563
+ <span class="text-xs">{{ emptyLabel }}</span>
564
+ </div>
565
+
566
+ <!-- items-start, not items-center: an unfolded legend can be taller than
567
+ the circle, and centring would push the circle down past it. -->
568
+ <LpContextMenu v-else :items="menuEntries" :always="!!menuItems">
569
+ <div class="flex flex-wrap items-start gap-x-6 gap-y-3">
570
+ <!--
571
+ One tooltip for the whole circle, following the cursor, rather than one
572
+ per slice. reka anchors a tip to an HTML box, and an SVG <path> gives
573
+ it nothing to measure — a per-slice tip simply never positioned itself.
574
+ Wrapping the container and reading the hovered slice gets the kit's
575
+ themed tip while keeping it beside the wedge under the pointer.
576
+ -->
577
+ <!-- Disabled while no slice is under the pointer: the trigger is the
578
+ whole canvas, so the gaps, the corners and a donut's hole would
579
+ otherwise open an empty panel. -->
580
+ <LpTooltip :delay="80" follow-cursor side="top" :disabled="!hovered">
581
+ <template #content>
582
+ <!--
583
+ Held from the last hovered slice so the panel keeps its text
584
+ through the closing animation instead of emptying first.
585
+
586
+ The tip stays put while the pointer crosses from one slice to the
587
+ next, so its text would otherwise hard-cut mid-glide: rolling the
588
+ label and counting the figure make it read as one readout being
589
+ updated rather than as a new tip that happens to be in the same
590
+ place.
591
+ -->
592
+ <!-- Eased: the label and the figure change width as the pointer
593
+ crosses slices, and an unmanaged panel snaps to each new size. -->
594
+ <LpShift v-if="tipSlice" axis="width">
595
+ <span class="flex items-center gap-1.5">
596
+ <span
597
+ class="size-1.5 shrink-0 rounded-full transition-colors duration-fast"
598
+ :style="{ backgroundColor: tipSlice.color }"
599
+ />
600
+ <LpRollingText
601
+ class="text-muted-strong"
602
+ :value="tipSlice.label"
603
+ :duration="200"
604
+ align="start"
605
+ />
606
+ <LpNumberFlow
607
+ class="font-mono text-ink"
608
+ :value="tipSlice.value"
609
+ :duration="240"
610
+ :format="fmt"
611
+ :animate-on-mount="false"
612
+ />
613
+ <LpRollingText
614
+ class="text-muted"
615
+ :value="pct(tipSlice.fraction)"
616
+ :duration="200"
617
+ align="end"
618
+ />
619
+ </span>
620
+ </LpShift>
621
+ </template>
622
+ <div
623
+ class="relative shrink-0"
624
+ :style="{ width: `${canvasWidth}px`, height: `${canvasHeight}px` }"
625
+ >
626
+ <!-- Clicking the empty middle (or the gaps) clears, so a selection can
627
+ be dropped without hunting for somewhere outside the chart. -->
628
+ <svg
629
+ :width="canvasWidth"
630
+ :height="canvasHeight"
631
+ class="touch-none select-none"
632
+ @pointerleave="leaveChart"
633
+ @pointerup="endDrag"
634
+ @click.self="clearSelection"
635
+ >
636
+ <!--
637
+ Drawing and hit-testing are SEPARATE paths, and only the drawn
638
+ one moves.
639
+
640
+ A wedge that lifts on hover slides out from under the very
641
+ pointer that lifted it: the cursor lands in the gap it left, the
642
+ hover drops, the wedge falls back, and it is under the pointer
643
+ again — so it oscillates for as long as the mouse rests near the
644
+ edge. Keeping the interactive copy fixed means the region being
645
+ hovered never changes shape, and the lift is purely cosmetic.
646
+ -->
647
+ <defs v-if="animate">
648
+ <clipPath :id="`${sweepId}-sweep`">
649
+ <path :d="sweepPath" />
650
+ </clipPath>
651
+ </defs>
652
+
653
+ <!-- Only the DRAWN slices are clipped. The hit areas below stay
654
+ whole, so a pointer resting where the sweep has not yet
655
+ reached still belongs to the slice it is over rather than to
656
+ nothing. -->
657
+ <g :clip-path="animate ? `url(#${sweepId}-sweep)` : undefined">
658
+ <path
659
+ v-for="(a, i) in arcs"
660
+ :key="a.label"
661
+ :d="a.d"
662
+ :fill="a.color"
663
+ class="pointer-events-none transition-[transform,opacity] duration-medium ease-[var(--ease-emphasized)] motion-reduce:transition-none"
664
+ :transform="active === i ? a.lift : undefined"
665
+ :opacity="dimmed(i) ? 0.28 : 1"
666
+ />
667
+ </g>
668
+
669
+ <!-- Invisible, unmoving, and on top: the actual hit area. -->
670
+ <path
671
+ v-for="(a, i) in arcs"
672
+ :key="`hit-${a.label}`"
673
+ :d="a.d"
674
+ fill="transparent"
675
+ class="cursor-pointer"
676
+ @pointerenter="onSlicePointerEnter(i)"
677
+ @pointerleave="onSlicePointerLeave(i)"
678
+ @pointerdown="onSlicePointerDown(i, $event)"
679
+ @contextmenu="onSliceContextMenu(i)"
680
+ />
681
+
682
+ <!-- Callouts. Drawn after the slices so a leader is never buried,
683
+ and hoverable as one unit with the slice they name. -->
684
+ <g v-if="labels">
685
+ <g
686
+ v-for="c in callouts"
687
+ :key="`c-${c.index}`"
688
+ class="cursor-pointer transition-opacity duration-fast"
689
+ :opacity="dimmed(c.index) ? 0.35 : 1"
690
+ @pointerenter="onSlicePointerEnter(c.index)"
691
+ @pointerleave="onSlicePointerLeave(c.index)"
692
+ @pointerdown="onSlicePointerDown(c.index, $event)"
693
+ @contextmenu="onSliceContextMenu(c.index)"
694
+ >
695
+ <polyline
696
+ :points="c.points"
697
+ fill="none"
698
+ :stroke="arcs[c.index].color"
699
+ stroke-width="1"
700
+ />
701
+ <text
702
+ :x="c.x"
703
+ :y="c.y"
704
+ :text-anchor="c.side === 'right' ? 'start' : 'end'"
705
+ dominant-baseline="middle"
706
+ class="text-[11px]"
707
+ :class="active === c.index ? 'fill-[var(--color-ink)]' : 'fill-[var(--color-muted-strong)]'"
708
+ >
709
+ {{ arcs[c.index].label }}: {{ pct(arcs[c.index].fraction) }}
710
+ </text>
711
+ </g>
712
+ </g>
713
+ </svg>
714
+
715
+ <!-- The hole carries the headline: the hovered slice, or the total. -->
716
+ <div
717
+ v-if="type === 'donut' && (showTotal || $slots.center)"
718
+ class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center"
719
+ >
720
+ <slot
721
+ name="center"
722
+ :total="total"
723
+ :active="focused >= 0 ? arcs[focused] : null"
724
+ :selection="selectedArcs"
725
+ >
726
+ <!-- One slice in focus reads out itself; several selected read out
727
+ their sum, which is the question a multi-selection asks.
728
+ The figure counts across and the caption rolls, so moving
729
+ between slices reads as one readout changing rather than as
730
+ two unrelated labels blinking. -->
731
+ <LpNumberFlow
732
+ class="font-mono text-xl font-medium text-ink"
733
+ :value="centreValue"
734
+ :duration="360"
735
+ :format="fmt"
736
+ :animate-on-mount="false"
737
+ />
738
+ <LpRollingText
739
+ class="max-w-[80%] text-[11px] text-muted"
740
+ :value="centreLabel"
741
+ :duration="260"
742
+ />
743
+ </slot>
744
+ </div>
745
+ </div>
746
+ </LpTooltip>
747
+
748
+ <!-- The legend is the same control surface as the circle: identical
749
+ modifiers, and slivers too small to hit are reachable here. -->
750
+ <div v-if="legendShown" class="flex min-w-0 flex-col gap-1.5" @pointerup="endDrag">
751
+ <button
752
+ v-for="(a, i) in arcs"
753
+ :key="a.label"
754
+ type="button"
755
+ class="group flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs outline-none transition-[opacity,background-color] duration-fast focus-visible:ring-2 focus-visible:ring-ring"
756
+ :class="[dimmed(i) ? 'opacity-45' : '', isSelected(i) ? 'bg-surface-soft' : '']"
757
+ :aria-pressed="isSelected(i)"
758
+ @pointerenter="onSlicePointerEnter(i)"
759
+ @pointerleave="onSlicePointerLeave(i)"
760
+ @pointerdown="onSlicePointerDown(i, $event)"
761
+ @contextmenu="onSliceContextMenu(i)"
762
+ >
763
+ <span
764
+ class="size-2 shrink-0 rounded-full transition-transform duration-fast group-hover:scale-125"
765
+ :style="{ backgroundColor: a.color }"
766
+ />
767
+ <span class="truncate text-muted-strong group-hover:text-ink">{{ a.label }}</span>
768
+ <!-- What the bucket stands for, so a fold is never a silent loss. -->
769
+ <span v-if="a.isOther" class="shrink-0 text-muted">({{ a.count }})</span>
770
+ <span class="ml-auto shrink-0 pl-2 font-mono tabular-nums text-muted">
771
+ {{ showPercent ? pct(a.fraction) : fmt(a.value) }}
772
+ </span>
773
+ </button>
774
+ </div>
775
+ </div>
776
+ </LpContextMenu>
777
+ </div>
778
+ </template>