@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,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
|
+
}
|