@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.
- package/dist/assets/LpNumberFlow-rZkwQwlU.css +116 -0
- package/dist/assets/LpRollingText-CR1mb0Vt.css +74 -0
- package/dist/assets/LpSkeleton-D7xU8IPV.css +29 -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/LpChart-tNXs1ttr.js +671 -0
- package/dist/chunks/{LpConfirmDialog-CObRah9c.js → LpConfirmDialog-CjOGcj1e.js} +32 -4
- package/dist/chunks/{LpFileTree-H7Ho8uoq.js → LpFileTree-BaV7f0O7.js} +1 -1
- package/dist/chunks/{LpFileTreeNode-BEZIvxHi.js → LpFileTreeNode-BzpBa7R7.js} +5 -7
- 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/LpSkeleton-t5mMYl3a.js +29 -0
- 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/LpChart.vue.d.ts +88 -0
- package/dist/components/LpChart.vue.js +4 -0
- package/dist/components/LpConfirmDialog.vue.d.ts +18 -2
- package/dist/components/LpConfirmDialog.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/LpSkeleton.vue.d.ts +11 -1
- package/dist/components/LpSkeleton.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 +125 -102
- package/package.json +2 -1
- package/src/component-names.ts +5 -0
- package/src/components/LpChart.vue +846 -0
- package/src/components/LpConfirmDialog.vue +49 -0
- package/src/components/LpFileTreeNode.vue +5 -9
- 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/LpSkeleton.vue +81 -8
- 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/LpSkeleton-eSFIo4kD.js +0 -22
- package/dist/chunks/LpTooltip-D1hbjQWG.js +0 -63
- 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>
|