@bakery-framework/plugin-dashboard 2.0.0-alpha.12 → 2.0.0-alpha.13

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.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The sparkline tooltip: the element, what it says, and when it is shown.
3
+ *
4
+ * Split out of `stats.ts`. Everything here creates or positions DOM;
5
+ * `sparkline.ts` next door owns the canvas and the geometry, and this file
6
+ * depends on it in one direction only.
7
+ */
8
+
9
+ import { activeTimescale, METRICS, type Metric } from './metrics'
10
+ import {
11
+ drawSparkline,
12
+ resolveHoverPoint,
13
+ sparklineHoverStates,
14
+ } from './sparkline'
15
+
16
+ function ensureSparklineTooltip(canvas: HTMLCanvasElement) {
17
+ const chartCard = canvas.closest('.chart-card') as HTMLElement | null
18
+ if (!chartCard) return null
19
+
20
+ let tooltip = chartCard.querySelector('.chart-tooltip') as HTMLElement | null
21
+ if (!tooltip) {
22
+ tooltip = document.createElement('div')
23
+ tooltip.className = 'chart-tooltip'
24
+ chartCard.appendChild(tooltip)
25
+ }
26
+
27
+ return tooltip
28
+ }
29
+
30
+ function formatSparklineTooltipValue(value: number, unitSuffix: string) {
31
+ return Math.round(value).toString() + unitSuffix
32
+ }
33
+
34
+ function formatAge30d(agePoints: number): string {
35
+ return agePoints === 1 ? '1 day ago' : `${agePoints} days ago`
36
+ }
37
+
38
+ function formatAge7d(agePoints: number): string {
39
+ const hours = agePoints * 6
40
+ if (hours >= 24) {
41
+ const days = Math.floor(hours / 24)
42
+ const remHours = hours % 24
43
+ return remHours > 0 ? `${days}d ${remHours}h ago` : `${days}d ago`
44
+ }
45
+ return `${hours}h ago`
46
+ }
47
+
48
+ function formatAge1d(agePoints: number): string {
49
+ const mins = agePoints * 30
50
+ if (mins >= 60) {
51
+ const hours = Math.floor(mins / 60)
52
+ const remMins = mins % 60
53
+ return remMins > 0 ? `${hours}h ${remMins}m ago` : `${hours}h ago`
54
+ }
55
+ return `${mins}m ago`
56
+ }
57
+
58
+ function formatAgeOther(agePoints: number, activeTimescale: string): string {
59
+ if (activeTimescale === '1h') {
60
+ return agePoints === 1 ? '1 min ago' : `${agePoints} mins ago`
61
+ }
62
+ return agePoints === 1 ? '1s ago' : `${agePoints}s ago`
63
+ }
64
+
65
+ function formatSparklineAge(index: number, length: number) {
66
+ const agePoints = Math.max(length - 1 - index, 0)
67
+ if (agePoints === 0) return 'now'
68
+
69
+ if (activeTimescale === '30d') return formatAge30d(agePoints)
70
+ if (activeTimescale === '7d') return formatAge7d(agePoints)
71
+ if (activeTimescale === '1d') return formatAge1d(agePoints)
72
+ return formatAgeOther(agePoints, activeTimescale)
73
+ }
74
+
75
+ export function updateSparklineTooltip(config: Metric) {
76
+ const state = sparklineHoverStates[config.canvas]
77
+ if (!state?.visible) return
78
+
79
+ const canvas = document.getElementById(
80
+ config.canvas,
81
+ ) as HTMLCanvasElement | null
82
+ if (!canvas) return
83
+
84
+ const tooltip = ensureSparklineTooltip(canvas)
85
+ if (!tooltip) return
86
+
87
+ const data = config.history
88
+ if (data.length === 0) {
89
+ tooltip.classList.remove('visible')
90
+ return
91
+ }
92
+
93
+ const rect = canvas.getBoundingClientRect()
94
+ const chartCard = canvas.closest('.chart-card') as HTMLElement | null
95
+ const chartRect = chartCard?.getBoundingClientRect() || rect
96
+
97
+ const point = resolveHoverPoint(config.canvas, data, rect)
98
+ if (!point) {
99
+ tooltip.classList.remove('visible')
100
+ return
101
+ }
102
+
103
+ tooltip.textContent = `${formatSparklineTooltipValue(point.value, config.unit)} (${formatSparklineAge(point.index, data.length)})`
104
+ tooltip.dataset.placement = point.y < 28 ? 'below' : 'above'
105
+ tooltip.style.left = `${rect.left - chartRect.left + point.x}px`
106
+ tooltip.style.top = `${rect.top - chartRect.top + point.y}px`
107
+ tooltip.classList.add('visible')
108
+ }
109
+
110
+ export function refreshSparklineTooltips() {
111
+ for (const config of METRICS) {
112
+ updateSparklineTooltip(config)
113
+ }
114
+ }
115
+
116
+ export function bindSparklineTooltips() {
117
+ for (const config of METRICS) {
118
+ const canvas = document.getElementById(
119
+ config.canvas,
120
+ ) as HTMLCanvasElement | null
121
+ if (!canvas || canvas.dataset.sparklineTooltipBound === 'true') continue
122
+
123
+ canvas.dataset.sparklineTooltipBound = 'true'
124
+ sparklineHoverStates[config.canvas] = {
125
+ visible: false,
126
+ clientX: 0,
127
+ clientY: 0,
128
+ }
129
+
130
+ const state = sparklineHoverStates[config.canvas]
131
+
132
+ canvas.addEventListener('pointermove', event => {
133
+ state.visible = true
134
+ state.clientX = event.clientX
135
+ state.clientY = event.clientY
136
+ updateSparklineTooltip(config)
137
+ // The marker is painted *into* the canvas, so it only moves when the
138
+ // canvas is repainted. Without this it would lag the pointer by up to a
139
+ // second — the polling redraw's interval — and read as a stuck dot.
140
+ drawSparkline(config.canvas, config.history, config.stroke, config.fill)
141
+ })
142
+
143
+ canvas.addEventListener('pointerleave', () => {
144
+ state.visible = false
145
+ const tooltip = ensureSparklineTooltip(canvas)
146
+ if (tooltip) tooltip.classList.remove('visible')
147
+ // Repaint to clear the marker, for the same reason.
148
+ drawSparkline(config.canvas, config.history, config.stroke, config.fill)
149
+ })
150
+ }
151
+
152
+ window.addEventListener('resize', refreshSparklineTooltips, {
153
+ passive: true,
154
+ })
155
+ window.addEventListener('scroll', refreshSparklineTooltips, {
156
+ passive: true,
157
+ })
158
+ }
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Drawing a sparkline, and the geometry that goes with it.
3
+ *
4
+ * Split out of `stats.ts`. The seam between this file and
5
+ * `sparkline-tooltip.ts` is **geometry against DOM**: everything here answers
6
+ * "where on the canvas", and nothing here creates or positions an element. The
7
+ * split runs that way because `drawSparkline` needs the hovered point in order
8
+ * to mark it, so a seam drawn around "hover" instead would have put the two
9
+ * files in a cycle.
10
+ */
11
+
12
+ import { activeTimescale, getTimescaleLimit, METRICS } from './metrics'
13
+
14
+ function drawSparklineGrid(
15
+ ctx: CanvasRenderingContext2D,
16
+ width: number,
17
+ height: number,
18
+ min: number,
19
+ range: number,
20
+ ) {
21
+ ctx.save()
22
+ ctx.beginPath()
23
+ ctx.setLineDash([4, 4])
24
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)'
25
+ ctx.lineWidth = 1
26
+
27
+ const gridLines = [0.25, 0.5, 0.75]
28
+ gridLines.forEach(ratio => {
29
+ const y = height - 12 - ratio * (height - 24)
30
+ ctx.moveTo(0, y)
31
+ ctx.lineTo(width - 50, y)
32
+
33
+ const val = min + ratio * range
34
+ const roundedVal = range < 5 ? Math.round(val * 10) / 10 : Math.round(val)
35
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.35)'
36
+ ctx.font = '9px monospace'
37
+ ctx.fillText(roundedVal.toString(), width - 42, y + 3)
38
+ })
39
+ ctx.stroke()
40
+ ctx.restore()
41
+ }
42
+
43
+ function getSparklineSegments(dataPoints: number[]) {
44
+ const segments: { start: number; end: number }[] = []
45
+ let inSegment = false
46
+ let segmentStart = 0
47
+
48
+ for (let i = 0; i < dataPoints.length; i++) {
49
+ const isValValid =
50
+ dataPoints[i] !== null &&
51
+ dataPoints[i] !== undefined &&
52
+ !Number.isNaN(dataPoints[i])
53
+ if (isValValid) {
54
+ if (!inSegment) {
55
+ inSegment = true
56
+ segmentStart = i
57
+ }
58
+ } else {
59
+ if (inSegment) {
60
+ segments.push({ start: segmentStart, end: i - 1 })
61
+ inSegment = false
62
+ }
63
+ }
64
+ }
65
+ if (inSegment) {
66
+ segments.push({ start: segmentStart, end: dataPoints.length - 1 })
67
+ }
68
+ return segments
69
+ }
70
+
71
+ function drawSinglePointSegment(
72
+ ctx: CanvasRenderingContext2D,
73
+ start: number,
74
+ dataPoints: number[],
75
+ min: number,
76
+ max: number,
77
+ range: number,
78
+ width: number,
79
+ height: number,
80
+ L: number,
81
+ M: number,
82
+ colorStart: string,
83
+ ) {
84
+ const val = Math.max(min, Math.min(dataPoints[start], max))
85
+ const j = L - M + start
86
+ const x = (j / (L - 1)) * (width - 50)
87
+ const y = height - 12 - ((val - min) / range) * (height - 24)
88
+
89
+ ctx.beginPath()
90
+ ctx.arc(x, y, 2.5, 0, Math.PI * 2)
91
+ ctx.fillStyle = colorStart
92
+ ctx.fill()
93
+ }
94
+
95
+ function drawLineSegment(
96
+ ctx: CanvasRenderingContext2D,
97
+ start: number,
98
+ end: number,
99
+ dataPoints: number[],
100
+ min: number,
101
+ max: number,
102
+ range: number,
103
+ width: number,
104
+ height: number,
105
+ L: number,
106
+ M: number,
107
+ colorStart: string,
108
+ ) {
109
+ ctx.beginPath()
110
+ for (let i = start; i <= end; i++) {
111
+ const val = Math.max(min, Math.min(dataPoints[i], max))
112
+ const j = L - M + i
113
+ const x = (j / (L - 1)) * (width - 50)
114
+ const y = height - 12 - ((val - min) / range) * (height - 24)
115
+ if (i === start) ctx.moveTo(x, y)
116
+ else ctx.lineTo(x, y)
117
+ }
118
+ ctx.lineWidth = 2.5
119
+ ctx.strokeStyle = colorStart
120
+ ctx.lineCap = 'round'
121
+ ctx.lineJoin = 'round'
122
+ ctx.stroke()
123
+ }
124
+
125
+ function drawFillSegment(
126
+ ctx: CanvasRenderingContext2D,
127
+ start: number,
128
+ end: number,
129
+ dataPoints: number[],
130
+ min: number,
131
+ max: number,
132
+ range: number,
133
+ width: number,
134
+ height: number,
135
+ L: number,
136
+ M: number,
137
+ colorEnd: string,
138
+ ) {
139
+ ctx.beginPath()
140
+ let firstX = 0
141
+ let lastX = 0
142
+ for (let i = start; i <= end; i++) {
143
+ const val = Math.max(min, Math.min(dataPoints[i], max))
144
+ const j = L - M + i
145
+ const x = (j / (L - 1)) * (width - 50)
146
+ const y = height - 12 - ((val - min) / range) * (height - 24)
147
+ if (i === start) {
148
+ ctx.moveTo(x, y)
149
+ firstX = x
150
+ } else {
151
+ ctx.lineTo(x, y)
152
+ }
153
+ if (i === end) {
154
+ lastX = x
155
+ }
156
+ }
157
+ ctx.lineTo(lastX, height)
158
+ ctx.lineTo(firstX, height)
159
+ ctx.closePath()
160
+
161
+ const gradient = ctx.createLinearGradient(0, 0, 0, height)
162
+ gradient.addColorStop(0, colorEnd)
163
+ gradient.addColorStop(1, 'rgba(0, 0, 0, 0)')
164
+ ctx.fillStyle = gradient
165
+ ctx.fill()
166
+ }
167
+
168
+ export function getSparklineScale(dataPoints: number[]) {
169
+ const validPoints = dataPoints.filter(
170
+ p =>
171
+ typeof p === 'number' &&
172
+ !Number.isNaN(p) &&
173
+ p !== null &&
174
+ p !== undefined,
175
+ )
176
+ if (validPoints.length === 0) {
177
+ return { min: 0, max: 0, range: 1 }
178
+ }
179
+ const sum = validPoints.reduce((a, b) => a + b, 0)
180
+ const avg = sum / validPoints.length || 1
181
+ const actualMax = Math.max(...validPoints)
182
+ const min = 0
183
+ const max = Math.max(avg * 2, actualMax, 50)
184
+ const range = max - min === 0 ? 1 : max - min
185
+ return { min, max, range }
186
+ }
187
+
188
+ export interface SparklineHoverState {
189
+ visible: boolean
190
+ clientX: number
191
+ clientY: number
192
+ }
193
+
194
+ export const sparklineHoverStates: Record<string, SparklineHoverState> = {}
195
+
196
+ export interface HoverPoint {
197
+ index: number
198
+ value: number
199
+ /** Where the point sits on the canvas, in CSS pixels within its box. */
200
+ x: number
201
+ y: number
202
+ }
203
+
204
+ /**
205
+ * Which sample the pointer is over, and where that sample is drawn.
206
+ *
207
+ * Shared by the two things that must agree about it: the marker painted on the
208
+ * canvas and the tooltip positioned over the card. This arithmetic — the 50px
209
+ * reserved for the axis labels, the 24 and 12 of vertical padding, and the
210
+ * `L - M` offset for a series shorter than the window — used to live only in
211
+ * the tooltip. Copying it into the draw path would have worked exactly until
212
+ * one copy was adjusted, at which point the dot and its label would point at
213
+ * different samples and look like a rounding bug.
214
+ */
215
+ export function resolveHoverPoint(
216
+ canvasId: string,
217
+ data: number[],
218
+ rect: { left: number; width: number; height: number },
219
+ ): HoverPoint | null {
220
+ const state = sparklineHoverStates[canvasId]
221
+ if (!state?.visible || data.length === 0) return null
222
+
223
+ const { min, max, range } = getSparklineScale(data)
224
+ const graphWidth = Math.max(rect.width - 50, 1)
225
+ const graphHeight = Math.max(rect.height - 24, 1)
226
+ const localX = Math.min(Math.max(state.clientX - rect.left, 0), graphWidth)
227
+
228
+ const L = getTimescaleLimit(activeTimescale)
229
+ const M = data.length
230
+ const j = L === 1 ? 0 : Math.round((localX / graphWidth) * (L - 1))
231
+ const index = j - (L - M)
232
+ if (index < 0 || index >= M) return null
233
+
234
+ const value = data[index]
235
+ if (value === null || value === undefined || Number.isNaN(value)) return null
236
+
237
+ const safeValue = Math.max(min, Math.min(value, max))
238
+ return {
239
+ index,
240
+ value,
241
+ x: L === 1 ? 0 : (j / (L - 1)) * graphWidth,
242
+ y: rect.height - 12 - ((safeValue - min) / range) * graphHeight,
243
+ }
244
+ }
245
+
246
+ /**
247
+ * The hover marker: a guide line down the chart and a ringed dot on the sample.
248
+ *
249
+ * The ring is drawn in the card's own background rather than left transparent,
250
+ * so the dot reads as sitting *on* the line instead of merging into it wherever
251
+ * the series is dense.
252
+ */
253
+ export function drawHoverMarker(
254
+ ctx: CanvasRenderingContext2D,
255
+ point: HoverPoint,
256
+ height: number,
257
+ color: string,
258
+ ) {
259
+ ctx.save()
260
+
261
+ ctx.beginPath()
262
+ ctx.moveTo(point.x, 8)
263
+ ctx.lineTo(point.x, height - 10)
264
+ ctx.strokeStyle = 'rgba(148, 163, 184, 0.35)'
265
+ ctx.lineWidth = 1
266
+ ctx.setLineDash([3, 3])
267
+ ctx.stroke()
268
+ ctx.setLineDash([])
269
+
270
+ ctx.beginPath()
271
+ ctx.arc(point.x, point.y, 4.5, 0, Math.PI * 2)
272
+ ctx.fillStyle = color
273
+ ctx.fill()
274
+ ctx.lineWidth = 2
275
+ ctx.strokeStyle = 'rgba(15, 17, 21, 0.9)'
276
+ ctx.stroke()
277
+
278
+ ctx.restore()
279
+ }
280
+
281
+ export function drawSparkline(
282
+ canvasId: string,
283
+ dataPoints: number[],
284
+ colorStart: string,
285
+ colorEnd: string,
286
+ ) {
287
+ const canvas = document.getElementById(canvasId) as HTMLCanvasElement | null
288
+ if (!canvas) return
289
+ const ctx = canvas.getContext('2d')
290
+ if (!ctx) return
291
+
292
+ const dpr = window.devicePixelRatio || 1
293
+ const rect = canvas.getBoundingClientRect()
294
+
295
+ // Assigning to width/height reallocates the backing store and resets the
296
+ // whole context, so the old unconditional resize threw away and rebuilt nine
297
+ // canvases every second even when nothing had moved. Only resize on an
298
+ // actual size change, and set the DPR transform outright rather than
299
+ // relying on the reset to make a cumulative `scale` safe.
300
+ const targetW = Math.trunc(rect.width * dpr)
301
+ const targetH = Math.trunc(rect.height * dpr)
302
+ if (canvas.width !== targetW || canvas.height !== targetH) {
303
+ canvas.width = targetW
304
+ canvas.height = targetH
305
+ }
306
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
307
+
308
+ const width = rect.width
309
+ const height = rect.height
310
+ ctx.clearRect(0, 0, width, height)
311
+
312
+ if (dataPoints.length === 0) return
313
+
314
+ const { min, max, range } = getSparklineScale(dataPoints)
315
+ drawSparklineGrid(ctx, width, height, min, range)
316
+
317
+ const L = getTimescaleLimit(activeTimescale)
318
+ const M = dataPoints.length
319
+ const segments = getSparklineSegments(dataPoints)
320
+
321
+ if (segments.length === 0) return
322
+
323
+ segments.forEach(segment => {
324
+ if (segment.start === segment.end) {
325
+ drawSinglePointSegment(
326
+ ctx,
327
+ segment.start,
328
+ dataPoints,
329
+ min,
330
+ max,
331
+ range,
332
+ width,
333
+ height,
334
+ L,
335
+ M,
336
+ colorStart,
337
+ )
338
+ } else {
339
+ drawLineSegment(
340
+ ctx,
341
+ segment.start,
342
+ segment.end,
343
+ dataPoints,
344
+ min,
345
+ max,
346
+ range,
347
+ width,
348
+ height,
349
+ L,
350
+ M,
351
+ colorStart,
352
+ )
353
+ drawFillSegment(
354
+ ctx,
355
+ segment.start,
356
+ segment.end,
357
+ dataPoints,
358
+ min,
359
+ max,
360
+ range,
361
+ width,
362
+ height,
363
+ L,
364
+ M,
365
+ colorEnd,
366
+ )
367
+ }
368
+ })
369
+
370
+ // Last, so the marker sits above the fill rather than under it.
371
+ const hovered = resolveHoverPoint(canvasId, dataPoints, {
372
+ left: rect.left,
373
+ width,
374
+ height,
375
+ })
376
+ if (hovered) drawHoverMarker(ctx, hovered, height, colorStart)
377
+ }
378
+
379
+ export function drawAllSparklines() {
380
+ for (const m of METRICS) {
381
+ drawSparkline(m.canvas, m.history, m.stroke, m.fill)
382
+ }
383
+ }