@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.
- package/package.json +6 -4
- package/src/client/dashboard.ts +1 -8
- package/src/client/parts/logs.ts +9 -10
- package/src/client/parts/metrics.ts +251 -0
- package/src/client/parts/sessions.ts +26 -124
- package/src/client/parts/sparkline-tooltip.ts +158 -0
- package/src/client/parts/sparkline.ts +383 -0
- package/src/client/parts/stats.ts +33 -758
- package/src/client/parts/utils.ts +18 -64
- package/src/components/DBBrowser.tsx +0 -2
- package/src/components/LogsPanel.tsx +0 -2
- package/src/components/SessionsPanel.tsx +0 -3
- package/src/components/StatsPanel.tsx +0 -19
- package/src/components/TopPagesPanel.tsx +0 -1
- package/src/setup.ts +63 -2
- package/src/shell.tsx +35 -2
- package/src/client/parts/effects.ts +0 -327
|
@@ -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
|
+
}
|