@leavepulse/ui 0.24.1 → 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 (91) 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/LpChart-tNXs1ttr.js +671 -0
  7. package/dist/chunks/{LpConfirmDialog-CObRah9c.js → LpConfirmDialog-CjOGcj1e.js} +32 -4
  8. package/dist/chunks/{LpFileTree-H7Ho8uoq.js → LpFileTree-BaV7f0O7.js} +1 -1
  9. package/dist/chunks/{LpFileTreeNode-BEZIvxHi.js → LpFileTreeNode-BzpBa7R7.js} +5 -7
  10. package/dist/chunks/{LpLogViewer-B1aLUVdz.js → LpLogViewer-CcQ3s3Fp.js} +2 -1
  11. package/dist/chunks/{LpMapPicker-DFKrd1Ne.js → LpMapPicker-CXvoTrAe.js} +2 -4
  12. package/dist/chunks/{LpNotificationBell-X1M30e0X.js → LpNotificationBell-Dvu-b6Vn.js} +56 -27
  13. package/dist/chunks/LpNumberFlow-amWYaeuA.js +221 -0
  14. package/dist/chunks/LpPie-BkjWlfSw.js +525 -0
  15. package/dist/chunks/LpProgress-DoIP4jQR.js +198 -0
  16. package/dist/chunks/LpRollingText-CM5gkSeF.js +50 -0
  17. package/dist/chunks/LpSparkline-C6xQ2dwv.js +175 -0
  18. package/dist/chunks/{LpStat-CdbEFOvn.js → LpStat-CDanpzU0.js} +43 -5
  19. package/dist/chunks/{LpThemeSwitcher-BgpJgrnP.js → LpThemeSwitcher-7mAK6bI7.js} +2 -4
  20. package/dist/chunks/LpTooltip-hCLPAqMG.js +131 -0
  21. package/dist/chunks/LpUptimeBar-DcQ8HikG.js +152 -0
  22. package/dist/component-names.d.ts +1 -1
  23. package/dist/components/LpChart.vue.d.ts +88 -0
  24. package/dist/components/LpChart.vue.js +4 -0
  25. package/dist/components/LpConfirmDialog.vue.d.ts +18 -2
  26. package/dist/components/LpConfirmDialog.vue.js +1 -1
  27. package/dist/components/LpFileTree.vue.d.ts +1 -1
  28. package/dist/components/LpFileTree.vue.js +1 -1
  29. package/dist/components/LpFileTreeNode.vue.js +1 -1
  30. package/dist/components/LpLightbox.vue.d.ts +1 -1
  31. package/dist/components/LpLogViewer.vue.d.ts +1 -1
  32. package/dist/components/LpLogViewer.vue.js +1 -1
  33. package/dist/components/LpMapPicker.vue.js +1 -1
  34. package/dist/components/LpNotificationBell.vue.d.ts +12 -0
  35. package/dist/components/LpNotificationBell.vue.js +1 -1
  36. package/dist/components/LpNumberFlow.vue.d.ts +35 -0
  37. package/dist/components/LpNumberFlow.vue.js +4 -0
  38. package/dist/components/LpPie.vue.d.ts +134 -0
  39. package/dist/components/LpPie.vue.js +4 -0
  40. package/dist/components/LpProgress.vue.d.ts +56 -2
  41. package/dist/components/LpProgress.vue.js +1 -1
  42. package/dist/components/LpRollingText.vue.d.ts +33 -0
  43. package/dist/components/LpRollingText.vue.js +4 -0
  44. package/dist/components/LpSparkline.vue.d.ts +48 -0
  45. package/dist/components/LpSparkline.vue.js +4 -0
  46. package/dist/components/LpStat.vue.d.ts +25 -2
  47. package/dist/components/LpStat.vue.js +1 -1
  48. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  49. package/dist/components/LpTooltip.vue.d.ts +24 -3
  50. package/dist/components/LpTooltip.vue.js +1 -1
  51. package/dist/components/LpUptimeBar.vue.js +1 -1
  52. package/dist/components/chart.d.ts +180 -0
  53. package/dist/components/chart.js +353 -0
  54. package/dist/composables/easing.d.ts +46 -0
  55. package/dist/composables/easing.js +23 -0
  56. package/dist/composables/usePillTransition.d.ts +13 -1
  57. package/dist/composables/usePillTransition.js +22 -2
  58. package/dist/composables/useReveal.d.ts +26 -0
  59. package/dist/composables/useReveal.js +45 -0
  60. package/dist/composables/useSettled.js +2 -2
  61. package/dist/composables/useShift.js +2 -4
  62. package/dist/index.d.ts +14 -0
  63. package/dist/index.js +125 -102
  64. package/package.json +2 -1
  65. package/src/component-names.ts +5 -0
  66. package/src/components/LpChart.vue +846 -0
  67. package/src/components/LpConfirmDialog.vue +49 -0
  68. package/src/components/LpFileTreeNode.vue +5 -9
  69. package/src/components/LpLogViewer.vue +2 -3
  70. package/src/components/LpMapPicker.vue +2 -5
  71. package/src/components/LpNotificationBell.vue +76 -15
  72. package/src/components/LpNumberFlow.vue +524 -0
  73. package/src/components/LpPie.vue +778 -0
  74. package/src/components/LpProgress.vue +283 -6
  75. package/src/components/LpRollingText.vue +157 -0
  76. package/src/components/LpSparkline.vue +312 -0
  77. package/src/components/LpStat.vue +52 -4
  78. package/src/components/LpTooltip.vue +185 -3
  79. package/src/components/LpUptimeBar.vue +150 -53
  80. package/src/components/chart.ts +686 -0
  81. package/src/composables/easing.ts +88 -0
  82. package/src/composables/usePillTransition.ts +71 -6
  83. package/src/composables/useReveal.ts +113 -0
  84. package/src/composables/useSettled.ts +2 -4
  85. package/src/composables/useShift.ts +2 -8
  86. package/src/index.ts +19 -0
  87. package/src/theme/useTheme.ts +2 -8
  88. package/src/tokens/tokens.css +36 -0
  89. package/dist/chunks/LpProgress-BhI0IbjC.js +0 -37
  90. package/dist/chunks/LpTooltip-D1hbjQWG.js +0 -63
  91. package/dist/chunks/LpUptimeBar-CB366k0O.js +0 -127
@@ -0,0 +1,686 @@
1
+ /*
2
+ * Geometry and formatting for LpChart, kept out of the .vue so the maths is
3
+ * testable and readable on its own. Everything here is pure: given data and a
4
+ * pixel box, return paths and tick positions.
5
+ */
6
+
7
+ export interface ChartPoint {
8
+ /** Numeric value. `null` breaks the line — a real gap, not a zero. */
9
+ y: number | null
10
+ /** Optional label for this point; falls back to the shared `labels` prop. */
11
+ x?: string
12
+ }
13
+
14
+ export interface ChartSeries {
15
+ name: string
16
+ /** Bare numbers are accepted and normalised to points. */
17
+ data: (number | null | ChartPoint)[]
18
+ /** Any CSS colour. Unset series cycle through the palette. */
19
+ color?: string
20
+ /** Draw this series as a dashed line (targets, budgets, forecasts). */
21
+ dashed?: boolean
22
+ /**
23
+ * Which y-axis this series is measured against. A series in different units
24
+ * or of a wildly different magnitude (errors beside requests, a rate beside a
25
+ * count) flattens against a shared axis; putting it on `"right"` gives it its
26
+ * own scale and its own ticks.
27
+ */
28
+ axis?: "left" | "right"
29
+ }
30
+
31
+ export type ChartAxis = "left" | "right"
32
+
33
+ export interface NormalSeries {
34
+ name: string
35
+ color: string
36
+ dashed: boolean
37
+ axis: ChartAxis
38
+ values: (number | null)[]
39
+ }
40
+
41
+ export interface Box {
42
+ width: number
43
+ height: number
44
+ left: number
45
+ right: number
46
+ top: number
47
+ bottom: number
48
+ }
49
+
50
+ /** Plot area inside the padding — where marks may actually be drawn. */
51
+ export const plotWidth = (b: Box): number => Math.max(0, b.width - b.left - b.right)
52
+ export const plotHeight = (b: Box): number => Math.max(0, b.height - b.top - b.bottom)
53
+
54
+ /** Series colours cycle through the theme's semantic palette. */
55
+ export const CHART_PALETTE = [
56
+ "var(--color-brand)",
57
+ "var(--color-action)",
58
+ "var(--color-accent)",
59
+ "var(--color-danger)",
60
+ "var(--color-muted-strong)",
61
+ ]
62
+
63
+ const valueOf = (d: number | null | ChartPoint): number | null =>
64
+ d == null ? null : typeof d === "number" ? d : d.y
65
+
66
+ export const normalise = (series: ChartSeries[]): NormalSeries[] =>
67
+ series.map((s, i) => ({
68
+ name: s.name,
69
+ color: s.color ?? CHART_PALETTE[i % CHART_PALETTE.length],
70
+ dashed: s.dashed ?? false,
71
+ axis: s.axis ?? "left",
72
+ values: s.data.map(valueOf),
73
+ }))
74
+
75
+ /** Point labels win over the shared axis labels; index is the last resort. */
76
+ export const resolveLabels = (
77
+ series: ChartSeries[],
78
+ labels: string[] | undefined,
79
+ count: number,
80
+ ): string[] =>
81
+ Array.from({ length: count }, (_, i) => {
82
+ for (const s of series) {
83
+ const d = s.data[i]
84
+ if (d && typeof d !== "number" && d.x != null) return d.x
85
+ }
86
+ return labels?.[i] ?? String(i + 1)
87
+ })
88
+
89
+ /** Longest series wins; shorter ones simply stop early. */
90
+ export const pointCount = (series: NormalSeries[]): number =>
91
+ series.reduce((n, s) => Math.max(n, s.values.length), 0)
92
+
93
+ /**
94
+ * "Nice" axis bounds: round the extent out to a human step (1/2/5 × 10ⁿ) so
95
+ * ticks land on readable numbers instead of 0.37, 1.24, 2.11.
96
+ */
97
+ export const niceScale = (
98
+ min: number,
99
+ max: number,
100
+ ticks: number,
101
+ ): { min: number; max: number; step: number } => {
102
+ if (!Number.isFinite(min) || !Number.isFinite(max)) return { min: 0, max: 1, step: 1 }
103
+ if (min === max) {
104
+ // A flat series still needs a band to sit in.
105
+ const pad = Math.abs(min) || 1
106
+ min -= pad / 2
107
+ max += pad / 2
108
+ }
109
+ const raw = (max - min) / Math.max(1, ticks)
110
+ const mag = 10 ** Math.floor(Math.log10(raw))
111
+ const norm = raw / mag
112
+ const step = (norm >= 5 ? 10 : norm >= 2 ? 5 : norm >= 1 ? 2 : 1) * mag
113
+ return { min: Math.floor(min / step) * step, max: Math.ceil(max / step) * step, step }
114
+ }
115
+
116
+ export const axisTicks = (min: number, max: number, step: number): number[] => {
117
+ const out: number[] = []
118
+ // Guard against a degenerate step producing an unbounded loop.
119
+ if (!(step > 0)) return [min, max]
120
+ for (let v = min; v <= max + step / 2; v += step) out.push(Number(v.toFixed(10)))
121
+ return out
122
+ }
123
+
124
+ /** Extent across every visible series, with zero pulled in when asked. */
125
+ export const extent = (
126
+ series: NormalSeries[],
127
+ includeZero: boolean,
128
+ ): { min: number; max: number } => {
129
+ let min = Infinity
130
+ let max = -Infinity
131
+ for (const s of series)
132
+ for (const v of s.values) {
133
+ if (v == null) continue
134
+ if (v < min) min = v
135
+ if (v > max) max = v
136
+ }
137
+ if (min === Infinity) return { min: 0, max: 1 }
138
+ if (includeZero) {
139
+ min = Math.min(min, 0)
140
+ max = Math.max(max, 0)
141
+ }
142
+ return { min, max }
143
+ }
144
+
145
+ /** x pixel for point i. Bars sit at band centres, lines span edge to edge. */
146
+ export const xAt = (i: number, count: number, box: Box, banded: boolean): number => {
147
+ const w = plotWidth(box)
148
+ if (banded) return box.left + (w / Math.max(1, count)) * (i + 0.5)
149
+ if (count <= 1) return box.left + w / 2
150
+ return box.left + (w / (count - 1)) * i
151
+ }
152
+
153
+ export const yAt = (v: number, min: number, max: number, box: Box): number => {
154
+ const h = plotHeight(box)
155
+ const span = max - min || 1
156
+ return box.top + h - ((v - min) / span) * h
157
+ }
158
+
159
+ interface Scale {
160
+ min: number
161
+ max: number
162
+ box: Box
163
+ count: number
164
+ banded: boolean
165
+ }
166
+
167
+ /** Contiguous runs of non-null points — one run per unbroken stretch. */
168
+ const runs = (values: (number | null)[], count: number): number[][] => {
169
+ const out: number[][] = []
170
+ let cur: number[] = []
171
+ for (let i = 0; i < count; i++) {
172
+ if (values[i] == null) {
173
+ if (cur.length) out.push(cur)
174
+ cur = []
175
+ } else cur.push(i)
176
+ }
177
+ if (cur.length) out.push(cur)
178
+ return out
179
+ }
180
+
181
+ /**
182
+ * Catmull-Rom → cubic Bézier, clamped so a smoothed curve can never overshoot
183
+ * past its own data points (which would draw, say, a negative request count).
184
+ */
185
+ const smoothSegment = (pts: { x: number; y: number }[], tension: number): string => {
186
+ let d = `M${pts[0].x},${pts[0].y}`
187
+ for (let i = 0; i < pts.length - 1; i++) {
188
+ const p0 = pts[i - 1] ?? pts[i]
189
+ const p1 = pts[i]
190
+ const p2 = pts[i + 1]
191
+ const p3 = pts[i + 2] ?? p2
192
+ const c1x = p1.x + ((p2.x - p0.x) / 6) * tension
193
+ const c2x = p2.x - ((p3.x - p1.x) / 6) * tension
194
+ let c1y = p1.y + ((p2.y - p0.y) / 6) * tension
195
+ let c2y = p2.y - ((p3.y - p1.y) / 6) * tension
196
+ const lo = Math.min(p1.y, p2.y)
197
+ const hi = Math.max(p1.y, p2.y)
198
+ c1y = Math.min(hi, Math.max(lo, c1y))
199
+ c2y = Math.min(hi, Math.max(lo, c2y))
200
+ d += `C${c1x},${c1y} ${c2x},${c2y} ${p2.x},${p2.y}`
201
+ }
202
+ return d
203
+ }
204
+
205
+ const segmentPath = (
206
+ idx: number[],
207
+ values: (number | null)[],
208
+ s: Scale,
209
+ smooth: number,
210
+ ): string => {
211
+ const pts = idx.map((i) => ({
212
+ x: xAt(i, s.count, s.box, s.banded),
213
+ y: yAt(values[i] as number, s.min, s.max, s.box),
214
+ }))
215
+ if (pts.length === 1) return `M${pts[0].x},${pts[0].y}L${pts[0].x},${pts[0].y}`
216
+ if (smooth > 0) return smoothSegment(pts, smooth)
217
+ return pts.map((p, i) => `${i ? "L" : "M"}${p.x},${p.y}`).join("")
218
+ }
219
+
220
+ export const linePath = (
221
+ values: (number | null)[],
222
+ s: Scale,
223
+ smooth: number,
224
+ ): string =>
225
+ runs(values, s.count)
226
+ .map((idx) => segmentPath(idx, values, s, smooth))
227
+ .join(" ")
228
+
229
+ /** The line closed down to the baseline — one filled shape per unbroken run. */
230
+ export const areaPath = (
231
+ values: (number | null)[],
232
+ s: Scale,
233
+ smooth: number,
234
+ ): string => {
235
+ const base = yAt(Math.max(s.min, Math.min(s.max, 0)), s.min, s.max, s.box)
236
+ return runs(values, s.count)
237
+ .map((idx) => {
238
+ const d = segmentPath(idx, values, s, smooth)
239
+ const x0 = xAt(idx[0], s.count, s.box, s.banded)
240
+ const x1 = xAt(idx[idx.length - 1], s.count, s.box, s.banded)
241
+ return `${d}L${x1},${base}L${x0},${base}Z`
242
+ })
243
+ .join(" ")
244
+ }
245
+
246
+ /** Nearest point index for a pixel x — what the hover crosshair snaps to. */
247
+ export const nearestIndex = (px: number, s: Scale): number => {
248
+ if (s.count === 0) return -1
249
+ let best = 0
250
+ let bestDist = Infinity
251
+ for (let i = 0; i < s.count; i++) {
252
+ const d = Math.abs(xAt(i, s.count, s.box, s.banded) - px)
253
+ if (d < bestDist) {
254
+ bestDist = d
255
+ best = i
256
+ }
257
+ }
258
+ return best
259
+ }
260
+
261
+ /**
262
+ * A bar as a path, rounded only on the corners named. A stack is one shape cut
263
+ * into segments: rounding every segment would bevel the internal joins and read
264
+ * as separate tiles rather than as one bar, so only the outermost segment of a
265
+ * stack rounds, and only on the end it actually terminates.
266
+ *
267
+ * `r` is clamped to half the box so a short segment degrades to a slab instead
268
+ * of inverting its own corners.
269
+ */
270
+ export const barPath = (
271
+ x: number,
272
+ y: number,
273
+ w: number,
274
+ h: number,
275
+ r: number,
276
+ round: { top: boolean; bottom: boolean },
277
+ ): string => {
278
+ const k = Math.max(0, Math.min(r, w / 2, h / 2))
279
+ const rt = round.top ? k : 0
280
+ const rb = round.bottom ? k : 0
281
+ return [
282
+ `M${x},${y + rt}`,
283
+ rt ? `A${rt},${rt} 0 0 1 ${x + rt},${y}` : `L${x},${y}`,
284
+ `L${x + w - rt},${y}`,
285
+ rt ? `A${rt},${rt} 0 0 1 ${x + w},${y + rt}` : "",
286
+ `L${x + w},${y + h - rb}`,
287
+ rb ? `A${rb},${rb} 0 0 1 ${x + w - rb},${y + h}` : `L${x + w},${y + h}`,
288
+ `L${x + rb},${y + h}`,
289
+ rb ? `A${rb},${rb} 0 0 1 ${x},${y + h - rb}` : "",
290
+ "Z",
291
+ ]
292
+ .filter(Boolean)
293
+ .join("")
294
+ }
295
+
296
+ // ── shares of a whole (pie / donut) ────────────
297
+
298
+ export interface PieSlice {
299
+ label: string
300
+ value: number
301
+ /** Any CSS colour. Unset slices cycle through the palette. */
302
+ color?: string
303
+ }
304
+
305
+ export interface ResolvedSlice {
306
+ label: string
307
+ value: number
308
+ color: string
309
+ /** 0–1 of the total. */
310
+ fraction: number
311
+ /** True for the synthesised remainder slice. */
312
+ isOther: boolean
313
+ /** How many input slices this one stands for (1, or N when collapsed). */
314
+ count: number
315
+ }
316
+
317
+ /**
318
+ * Fold an arbitrary number of slices down to something a circle can actually
319
+ * show, WITHOUT dropping data.
320
+ *
321
+ * A pie is only legible while its slices are distinguishable by angle; past a
322
+ * handful they become slivers no one can compare or click. But a shared kit
323
+ * can't know how many categories a caller has, so refusing to draw twenty is
324
+ * not an option either. So: keep the largest `max - 1` and sum the rest into a
325
+ * single "Other" slice that keeps its own total, its own share, and a count of
326
+ * what it stands for — the tail stays visible and addressable instead of being
327
+ * silently truncated.
328
+ *
329
+ * Negative values are dropped: a share of a whole has no meaning below zero,
330
+ * and letting one in makes every other slice's fraction wrong.
331
+ */
332
+ export const resolveSlices = (
333
+ slices: PieSlice[],
334
+ max: number | undefined,
335
+ otherLabel: string,
336
+ /**
337
+ * Keep the caller's ordering for the slices that survive. Ranking decides
338
+ * WHICH slices are kept; it needn't decide the order they're shown in, and a
339
+ * caller that ordered its data deliberately ("Free" last, states worst-first)
340
+ * would see that shuffled for no reason. "Other" always sorts last.
341
+ */
342
+ preserveOrder = true,
343
+ ): { slices: ResolvedSlice[]; total: number } => {
344
+ const usable = slices.filter((s) => Number.isFinite(s.value) && s.value > 0)
345
+ const total = usable.reduce((sum, s) => sum + s.value, 0)
346
+ if (!total) return { slices: [], total: 0 }
347
+
348
+ // Colours are assigned from the ORIGINAL order, so a slice keeps its colour
349
+ // whether or not the tail happens to be collapsed on this render.
350
+ const coloured = usable.map((s) => ({
351
+ ...s,
352
+ color: s.color ?? CHART_PALETTE[slices.indexOf(s) % CHART_PALETTE.length],
353
+ }))
354
+
355
+ const ranked = [...coloured].sort((a, b) => b.value - a.value)
356
+ const collapse = max != null && max > 0 && ranked.length > max
357
+
358
+ const keptRanked = collapse ? ranked.slice(0, max - 1) : ranked
359
+ const tail = collapse ? ranked.slice(max - 1) : []
360
+
361
+ const kept = preserveOrder
362
+ ? coloured.filter((s) => keptRanked.includes(s))
363
+ : keptRanked
364
+
365
+ const out: ResolvedSlice[] = kept.map((s) => ({
366
+ label: s.label,
367
+ value: s.value,
368
+ color: s.color,
369
+ fraction: s.value / total,
370
+ isOther: false,
371
+ count: 1,
372
+ }))
373
+
374
+ if (tail.length) {
375
+ const value = tail.reduce((sum, s) => sum + s.value, 0)
376
+ out.push({
377
+ label: otherLabel,
378
+ value,
379
+ color: "var(--color-line-strong)",
380
+ fraction: value / total,
381
+ isOther: true,
382
+ count: tail.length,
383
+ })
384
+ }
385
+
386
+ return { slices: out, total }
387
+ }
388
+
389
+ /**
390
+ * One slice as a path. `inner > 0` makes it a donut segment (an annulus) rather
391
+ * than a wedge from the centre.
392
+ *
393
+ * A slice covering the whole circle is drawn as two half-arcs: a single arc
394
+ * whose start and end coincide is a zero-length path, so "100% of one category"
395
+ * would render as nothing at all.
396
+ */
397
+ export const arcPath = (
398
+ cx: number,
399
+ cy: number,
400
+ outer: number,
401
+ inner: number,
402
+ startAngle: number,
403
+ endAngle: number,
404
+ /** Corner radius in px, clamped to what the slice can actually carry. */
405
+ corner = 0,
406
+ ): string => {
407
+ const full = endAngle - startAngle >= Math.PI * 2 - 1e-9
408
+ if (full) {
409
+ // One arc whose ends coincide has zero length; draw the ring as two halves.
410
+ // A full ring has no corners to round, so `corner` is deliberately dropped.
411
+ const mid = startAngle + Math.PI
412
+ return (
413
+ arcPath(cx, cy, outer, inner, startAngle, mid) +
414
+ arcPath(cx, cy, outer, inner, mid, startAngle + Math.PI * 2)
415
+ )
416
+ }
417
+
418
+ const at = (r: number, a: number) => `${cx + r * Math.cos(a)},${cy + r * Math.sin(a)}`
419
+ const large = endAngle - startAngle > Math.PI ? 1 : 0
420
+
421
+ if (corner <= 0) {
422
+ if (inner <= 0) {
423
+ return [
424
+ `M${cx},${cy}`,
425
+ `L${at(outer, startAngle)}`,
426
+ `A${outer},${outer} 0 ${large} 1 ${at(outer, endAngle)}`,
427
+ "Z",
428
+ ].join("")
429
+ }
430
+ return [
431
+ `M${at(outer, startAngle)}`,
432
+ `A${outer},${outer} 0 ${large} 1 ${at(outer, endAngle)}`,
433
+ `L${at(inner, endAngle)}`,
434
+ `A${inner},${inner} 0 ${large} 0 ${at(inner, startAngle)}`,
435
+ "Z",
436
+ ].join("")
437
+ }
438
+
439
+ /*
440
+ * Rounded corners. Each corner is a quarter-turn joining the radial edge to
441
+ * the circular one, so the radius has to be clamped twice over: by the band's
442
+ * thickness (a corner can't be deeper than the slice is wide) and by the
443
+ * slice's own sweep at each radius (a thin sliver has no room to curve, and
444
+ * an unclamped radius would make it fold through itself).
445
+ *
446
+ * The angular cost of a corner differs on the two edges — the same px is a
447
+ * bigger angle on the inner circle than on the outer one — so the two are
448
+ * computed separately rather than sharing one offset.
449
+ */
450
+ const band = inner > 0 ? outer - inner : outer
451
+ const sweep = endAngle - startAngle
452
+ const rOuter = Math.min(corner, band / 2, (sweep * outer) / 4)
453
+ const rInner = inner > 0 ? Math.min(corner, band / 2, (sweep * inner) / 4) : 0
454
+
455
+ const aOuter = rOuter / outer
456
+ const aInner = inner > 0 ? rInner / inner : 0
457
+
458
+ // Radial inset: where the corner arc leaves the straight edge.
459
+ const outerIn = outer - rOuter
460
+ const innerOut = inner + rInner
461
+
462
+ const oStart = startAngle + aOuter
463
+ const oEnd = endAngle - aOuter
464
+ const iStart = startAngle + aInner
465
+ const iEnd = endAngle - aInner
466
+
467
+ if (inner <= 0) {
468
+ /*
469
+ * A wedge. Rounding only the outer end leaves a needle at the centre, and
470
+ * the slice reads as a teardrop rather than a cut of a circle — so the tip
471
+ * is blunted too, by the same radius. The blunt is a small arc across the
472
+ * point, not a hole: neighbouring slices still meet at the middle.
473
+ */
474
+ const tip = Math.min(corner, (sweep * outer) / 6)
475
+ if (tip <= 0.25) {
476
+ return [
477
+ `M${cx},${cy}`,
478
+ `L${at(outerIn, startAngle)}`,
479
+ `A${rOuter},${rOuter} 0 0 1 ${at(outer, oStart)}`,
480
+ `A${outer},${outer} 0 ${oEnd - oStart > Math.PI ? 1 : 0} 1 ${at(outer, oEnd)}`,
481
+ `A${rOuter},${rOuter} 0 0 1 ${at(outerIn, endAngle)}`,
482
+ "Z",
483
+ ].join("")
484
+ }
485
+ return [
486
+ `M${at(tip, startAngle)}`,
487
+ `L${at(outerIn, startAngle)}`,
488
+ `A${rOuter},${rOuter} 0 0 1 ${at(outer, oStart)}`,
489
+ `A${outer},${outer} 0 ${oEnd - oStart > Math.PI ? 1 : 0} 1 ${at(outer, oEnd)}`,
490
+ `A${rOuter},${rOuter} 0 0 1 ${at(outerIn, endAngle)}`,
491
+ `L${at(tip, endAngle)}`,
492
+ `A${tip},${tip} 0 0 0 ${at(tip, startAngle)}`,
493
+ "Z",
494
+ ].join("")
495
+ }
496
+
497
+ return [
498
+ `M${at(outerIn, startAngle)}`,
499
+ `A${rOuter},${rOuter} 0 0 1 ${at(outer, oStart)}`,
500
+ `A${outer},${outer} 0 ${oEnd - oStart > Math.PI ? 1 : 0} 1 ${at(outer, oEnd)}`,
501
+ `A${rOuter},${rOuter} 0 0 1 ${at(outerIn, endAngle)}`,
502
+ `L${at(innerOut, endAngle)}`,
503
+ `A${rInner},${rInner} 0 0 1 ${at(inner, iEnd)}`,
504
+ `A${inner},${inner} 0 ${iEnd - iStart > Math.PI ? 1 : 0} 0 ${at(inner, iStart)}`,
505
+ `A${rInner},${rInner} 0 0 1 ${at(innerOut, startAngle)}`,
506
+ "Z",
507
+ ].join("")
508
+ }
509
+
510
+ export interface CalloutLabel {
511
+ index: number
512
+ /** Which side of the circle the label sits on. */
513
+ side: "left" | "right"
514
+ /** Elbow polyline: leader out of the slice, a bend, then a run to the text. */
515
+ points: string
516
+ /** Where the text is anchored. */
517
+ x: number
518
+ y: number
519
+ }
520
+
521
+ /**
522
+ * Lay out callout labels around a circle: a leader line leaves each slice along
523
+ * its own bisector, bends, and runs horizontally to text stacked down the side.
524
+ *
525
+ * The whole difficulty is collision. Slices cluster — twenty versions of a
526
+ * server jar are mostly slivers packed into one arc — so labels placed at their
527
+ * natural angles would land on top of each other. Each side is therefore
528
+ * relaxed independently: labels keep their order around the circle, then get
529
+ * pushed apart to a minimum spacing and shifted back inside the box. That's why
530
+ * a leader can end well away from its slice's angle and still be unambiguous —
531
+ * the line, not the position, is what identifies it.
532
+ */
533
+ export const calloutLabels = (
534
+ angles: { index: number; mid: number }[],
535
+ cx: number,
536
+ cy: number,
537
+ radius: number,
538
+ /** How far past the radius the elbow sits. */
539
+ elbow: number,
540
+ /** Horizontal run from the elbow to the text. */
541
+ run: number,
542
+ /** Minimum vertical gap between two labels on the same side. */
543
+ spacing: number,
544
+ height: number,
545
+ ): CalloutLabel[] => {
546
+ const sides: Record<"left" | "right", { index: number; mid: number; y: number }[]> = {
547
+ left: [],
548
+ right: [],
549
+ }
550
+
551
+ /*
552
+ * Side assignment follows GEOMETRY: a slice on the right half is named on the
553
+ * right. A label opposite its own slice is confusing — the leader has to cross
554
+ * the circle to reach it — so that only happens when the natural side genuinely
555
+ * cannot hold another row.
556
+ *
557
+ * The earlier version capped each side at half the labels regardless of the
558
+ * space available, which sent a label across for no reason at all: six labels
559
+ * split 3/3 still tripped the cap, and the one slice that lost the tie had its
560
+ * name printed on the wrong side of the chart.
561
+ */
562
+ const fits = Math.max(1, Math.floor(height / spacing))
563
+ const ordered = [...angles].sort(
564
+ (p, q) => Math.abs(Math.cos(q.mid)) - Math.abs(Math.cos(p.mid)),
565
+ )
566
+
567
+ for (const a of ordered) {
568
+ const natural = Math.cos(a.mid) >= 0 ? "right" : "left"
569
+ const other = natural === "right" ? "left" : "right"
570
+ // Only spill over when this side is actually full and the other has room.
571
+ const side =
572
+ sides[natural].length >= fits && sides[other].length < fits ? other : natural
573
+ sides[side].push({ ...a, y: cy + Math.sin(a.mid) * (radius + elbow) })
574
+ }
575
+
576
+ const out: CalloutLabel[] = []
577
+
578
+ for (const side of ["left", "right"] as const) {
579
+ /*
580
+ * Ordered the way the eye reads the column: down the right of the circle
581
+ * (12 o'clock → 6) and back up the left (6 → 12). Sorting by the raw y of
582
+ * each bisector looks equivalent but isn't — a sliver moved to the lighter
583
+ * side to balance the columns lands out of sequence, and its leader then has
584
+ * to cross its neighbours' to reach its row.
585
+ */
586
+ const rank = (mid: number) => {
587
+ // Normalise to [-π/2, 3π/2) so the run starts at twelve o'clock.
588
+ let a = mid
589
+ while (a < -Math.PI / 2) a += Math.PI * 2
590
+ while (a >= (3 * Math.PI) / 2) a -= Math.PI * 2
591
+ // On the left the circle is walked upward, so its order is reversed.
592
+ return side === "right" ? a : -a
593
+ }
594
+ const list = sides[side].sort((p, q) => rank(p.mid) - rank(q.mid))
595
+
596
+ /*
597
+ * Resolve collisions by NUDGING, not by stacking.
598
+ *
599
+ * Laying the column out with a running cursor (`y = max(y, prev + spacing)`)
600
+ * is the obvious approach and it is what made the leaders cut across the
601
+ * pie: one crowded label at the top pushes every label below it down by a
602
+ * full row, so labels end up nowhere near the slice they name and their
603
+ * lines have to cross the circle to get back.
604
+ *
605
+ * Instead each pair that overlaps is pushed apart by half the shortfall
606
+ * EACH, repeatedly, so a label only ever drifts as far from its own bisector
607
+ * as its neighbours actually require. This is the standard relaxation used
608
+ * for outside pie labels; a handful of passes is plenty at these counts.
609
+ */
610
+ const top = spacing / 2
611
+ const bottom = height - spacing / 2
612
+
613
+ for (let pass = 0; pass < 24; pass++) {
614
+ let moved = false
615
+ for (let i = 0; i < list.length - 1; i++) {
616
+ const gap = list[i + 1].y - list[i].y
617
+ if (gap >= spacing) continue
618
+ const push = (spacing - gap) / 2
619
+ list[i].y -= push
620
+ list[i + 1].y += push
621
+ moved = true
622
+ }
623
+ // Keep the run inside the box; the next pass redistributes any squeeze.
624
+ list[0].y = Math.max(list[0].y, top)
625
+ list[list.length - 1].y = Math.min(list[list.length - 1].y, bottom)
626
+ if (!moved) break
627
+ }
628
+
629
+ // Final guarantee of both edges and of monotonic order, in case the clamps
630
+ // above fought each other on a column that genuinely cannot fit.
631
+ let cursor = top
632
+ for (const item of list) {
633
+ item.y = Math.max(item.y, cursor)
634
+ cursor = item.y + spacing
635
+ }
636
+ let limit = bottom
637
+ for (let i = list.length - 1; i >= 0; i--) {
638
+ list[i].y = Math.min(list[i].y, limit)
639
+ limit = list[i].y - spacing
640
+ }
641
+
642
+ list.forEach((item, rank) => {
643
+ const dir = side === "right" ? 1 : -1
644
+ /*
645
+ * Three points, not two, and the first leg is strictly RADIAL — straight
646
+ * out along the bisector to just past the rim.
647
+ *
648
+ * Going directly from the rim to the (relaxed) label height draws a chord
649
+ * across the circle whenever a label has been pushed far from its slice,
650
+ * and the leaders for a cluster of slivers then cut visibly through the
651
+ * neighbouring wedges. Leaving radially first means every line clears the
652
+ * pie before it turns.
653
+ */
654
+ /*
655
+ * Each leader in a side's stack gets a slightly longer radial leg than the
656
+ * one before it, so a cluster of slivers — whose bisectors are almost the
657
+ * same angle — turns at staggered radii instead of all bending at once
658
+ * into a single overlapping bundle.
659
+ */
660
+ const stagger = elbow + rank * 1.6
661
+ const startX = cx + Math.cos(item.mid) * radius
662
+ const startY = cy + Math.sin(item.mid) * radius
663
+ const outX = cx + Math.cos(item.mid) * (radius + stagger)
664
+ const outY = cy + Math.sin(item.mid) * (radius + stagger)
665
+ const endX = cx + dir * (radius + elbow + run)
666
+ out.push({
667
+ index: item.index,
668
+ side,
669
+ points: `${startX},${startY} ${outX},${outY} ${endX},${item.y}`,
670
+ x: endX + dir * 4,
671
+ y: item.y,
672
+ })
673
+ })
674
+ }
675
+
676
+ return out
677
+ }
678
+
679
+ /** Compact default tick formatting: 1.2k, 3.4M — never 1200.0000001. */
680
+ export const formatTick = (v: number): string => {
681
+ const abs = Math.abs(v)
682
+ if (abs >= 1e9) return `${Number((v / 1e9).toFixed(1))}B`
683
+ if (abs >= 1e6) return `${Number((v / 1e6).toFixed(1))}M`
684
+ if (abs >= 1e3) return `${Number((v / 1e3).toFixed(1))}k`
685
+ return String(Number(v.toFixed(2)))
686
+ }