• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

atomic14 / web-serial-plotter / 17380292065

01 Sep 2025 02:18PM UTC coverage: 63.442% (-5.4%) from 68.885%
17380292065

push

github

cgreening
Export data

407 of 517 branches covered (78.72%)

Branch coverage included in aggregate %.

22 of 23 new or added lines in 3 files covered. (95.65%)

142 existing lines in 4 files now uncovered.

1941 of 3184 relevant lines covered (60.96%)

36.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

71.1
/src/utils/plotRendering.ts
1
import type { PlotSnapshot } from '../types/plot'
2

3
/**
4
 * Utility functions for plot canvas rendering.
5
 * Breaks down the complex draw() function into focused, testable pieces.
6
 */
7

8
export interface ChartBounds {
9
  x: number
10
  y: number
11
  width: number
12
  height: number
13
}
14

15
export interface Theme {
16
  plotBg: string
17
  plotGrid: string
18
  textColor: string
19
}
20

21
export interface AxisConfig {
22
  yMin: number
23
  yMax: number
24
  showYAxis: boolean
25
}
26

27
/**
28
 * Calculates chart layout bounds based on canvas dimensions and axis visibility.
29
 */
30
export function calculateChartBounds(
1✔
31
  canvasWidth: number, 
4✔
32
  canvasHeight: number, 
4✔
33
  showYAxis: boolean
4✔
34
): ChartBounds {
4✔
35
  const leftAxis = showYAxis ? 44 : 8
4✔
36
  const rightPadding = 8
4✔
37
  const topPadding = 8
4✔
38
  const bottomPadding = 26
4✔
39
  
40
  return {
4✔
41
    x: leftAxis,
4✔
42
    y: topPadding,
4✔
43
    width: Math.max(1, canvasWidth - leftAxis - rightPadding),
4✔
44
    height: Math.max(1, canvasHeight - topPadding - bottomPadding),
4✔
45
  }
4✔
46
}
4✔
47

48
/**
49
 * Extracts theme colors from CSS custom properties.
50
 */
51
export function getThemeColors(): Theme {
1✔
52
  const getVar = (name: string, fallback: string) => 
2✔
53
    getComputedStyle(document.documentElement).getPropertyValue(name) || fallback
4✔
54
  
55
  return {
2✔
56
    plotBg: getVar('--plot-bg', 'rgba(10,10,10,0.9)'),
2✔
57
    plotGrid: getVar('--plot-grid', 'rgba(255,255,255,0.06)'),
2✔
58
    textColor: getComputedStyle(document.documentElement).getPropertyValue('--text') || '#e5e5e5',
2✔
59
  }
2✔
60
}
2✔
61

62
/**
63
 * Calculates nice tick values for Y-axis labeling.
64
 */
65
export function calculateYAxisTicks(yMin: number, yMax: number, approxTickCount = 6) {
1✔
66
  const range = yMax - yMin
3✔
67
  
68
  const niceNumber = (value: number, round: boolean) => {
3✔
69
    const exp = Math.floor(Math.log10(value))
3✔
70
    const fraction = value / Math.pow(10, exp)
3✔
71
    let niceFraction
3✔
72
    
73
    if (round) {
3✔
74
      if (fraction < 1.5) niceFraction = 1
3!
75
      else if (fraction < 3) niceFraction = 2
3✔
76
      else if (fraction < 7) niceFraction = 5
2!
77
      else niceFraction = 10
×
78
    } else {
3!
79
      if (fraction <= 1) niceFraction = 1
×
80
      else if (fraction <= 2) niceFraction = 2
×
81
      else if (fraction <= 5) niceFraction = 5
×
82
      else niceFraction = 10
×
83
    }
×
84
    
85
    return niceFraction * Math.pow(10, exp)
3✔
86
  }
3✔
87
  
88
  const step = niceNumber(range / approxTickCount, true)
3✔
89
  const tickMin = Math.ceil(yMin / step) * step
3✔
90
  const tickMax = Math.floor(yMax / step) * step
3✔
91
  
92
  const ticks: number[] = []
3✔
93
  for (let value = tickMin; value <= tickMax + 1e-9; value += step) {
3✔
94
    ticks.push(value)
20✔
95
  }
20✔
96
  
97
  return { ticks, step }
3✔
98
}
3✔
99

100
/**
101
 * Draws the background and grid lines.
102
 */
103
export function drawBackgroundAndGrid(
1✔
104
  ctx: CanvasRenderingContext2D,
2✔
105
  canvasWidth: number,
2✔
106
  canvasHeight: number,
2✔
107
  chart: ChartBounds,
2✔
108
  theme: Theme,
2✔
109
  yTicks: number[],
2✔
110
  yMin: number,
2✔
111
  yMax: number
2✔
112
) {
2✔
113
  // Clear and fill background
114
  ctx.save()
2✔
115
  ctx.setTransform(1, 0, 0, 1, 0, 0)
2✔
116
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
2✔
117
  ctx.restore()
2✔
118
  
119
  ctx.fillStyle = theme.plotBg
2✔
120
  ctx.fillRect(0, 0, canvasWidth, canvasHeight)
2✔
121
  
122
  const yScale = (yMax - yMin) !== 0 ? chart.height / (yMax - yMin) : 1
2!
123
  
124
  ctx.strokeStyle = theme.plotGrid
2✔
125
  ctx.lineWidth = 1
2✔
126
  
127
  // Horizontal grid lines at Y tick positions
128
  for (const tickValue of yTicks) {
2✔
129
    const y = chart.y + chart.height - (tickValue - yMin) * yScale
14✔
130
    ctx.beginPath()
14✔
131
    ctx.moveTo(chart.x, y + 0.5)
14✔
132
    ctx.lineTo(chart.x + chart.width, y + 0.5)
14✔
133
    ctx.stroke()
14✔
134
  }
14✔
135
}
2✔
136

137
/**
138
 * Draws the Y-axis line and labels.
139
 */
140
export function drawYAxis(
1✔
141
  ctx: CanvasRenderingContext2D,
2✔
142
  chart: ChartBounds,
2✔
143
  theme: Theme,
2✔
144
  ticks: number[],
2✔
145
  step: number,
2✔
146
  yMin: number,
2✔
147
  yMax: number
2✔
148
) {
2✔
149
  const yScale = (yMax - yMin) !== 0 ? chart.height / (yMax - yMin) : 1
2!
150
  
151
  // Y-axis line
152
  ctx.strokeStyle = theme.plotGrid
2✔
153
  ctx.beginPath()
2✔
154
  ctx.moveTo(chart.x + 0.5, chart.y)
2✔
155
  ctx.lineTo(chart.x + 0.5, chart.y + chart.height)
2✔
156
  ctx.stroke()
2✔
157
  
158
  // Y-axis labels
159
  ctx.fillStyle = theme.textColor.trim() || '#e5e5e5'
2!
160
  ctx.font = '12px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial'
2✔
161
  ctx.textAlign = 'right'
2✔
162
  ctx.textBaseline = 'middle'
2✔
163
  
164
  for (const tickValue of ticks) {
2✔
165
    const y = chart.y + chart.height - (tickValue - yMin) * yScale
14✔
166
    const label = tickValue.toFixed(Math.max(0, -Math.floor(Math.log10(step))))
14✔
167
    ctx.fillText(label, chart.x - 6, y)
14✔
168
  }
14✔
169
}
2✔
170

171
/**
172
 * Formats timestamp for X-axis labels based on time mode and window duration.
173
 */
174
export function formatTimeLabel(
1✔
175
  timestamp: number,
12✔
176
  rightTime: number,
12✔
177
  windowMs: number,
12✔
178
  timeMode: 'absolute' | 'relative',
12✔
179
  firstTimestamp?: number | null
12✔
180
): string {
12✔
181
  if (timeMode === 'absolute') {
12✔
182
    const date = new Date(timestamp)
10✔
183
    const hh = date.getHours().toString().padStart(2, '0')
10✔
184
    const mm = date.getMinutes().toString().padStart(2, '0')
10✔
185
    const ss = date.getSeconds().toString().padStart(2, '0')
10✔
186
    const ms = date.getMilliseconds().toString().padStart(3, '0')
10✔
187
    
188
    if (windowMs >= 60_000) return `${hh}:${mm}:${ss}`
10✔
189
    return `${hh}:${mm}:${ss}.${ms}`
9✔
190
  } else {
10✔
191
    // Use first timestamp for consistent relative time, fallback to old behavior
192
    const baseTime = (firstTimestamp != null) ? firstTimestamp : rightTime
2!
193
    const deltaTime = timestamp - baseTime
2✔
194
    const seconds = Math.abs(deltaTime) / 1000
2✔
195
    return seconds >= 1 
2✔
196
      ? `${seconds.toFixed((seconds >= 10 || windowMs >= 60_000) ? 0 : 1)}s`
1!
197
      : `${Math.round(seconds * 1000)}ms`
1✔
198
  }
2✔
199
}
12✔
200

201
/**
202
 * Draws the X-axis using anchor points for time labels.
203
 */
204
export function drawXAxis(
1✔
205
  ctx: CanvasRenderingContext2D,
2✔
206
  chart: ChartBounds,
2✔
207
  theme: Theme,
2✔
208
  snapshot: PlotSnapshot,
2✔
209
  timeMode: 'absolute' | 'relative'
2✔
210
) {
2✔
211
  const times = snapshot.getTimes?.() ?? new Float64Array(0)
2!
212
  if (times.length < 2) return
2!
213

214
  ctx.strokeStyle = theme.plotGrid
2✔
215
  ctx.lineWidth = 1
2✔
216
  ctx.fillStyle = theme.textColor.trim() || '#e5e5e5'
2!
217
  ctx.textAlign = 'center'
2✔
218
  ctx.textBaseline = 'top'
2✔
219
  ctx.font = '12px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial'
2✔
220

221
  // Create stable tick marks anchored to sample indices
222
  const targetTickCount = 5
2✔
223
  const stepSize = Math.max(1, Math.floor(snapshot.viewPortSize / targetTickCount))
2✔
224
  
225
  // Calculate the absolute sample index range for the current viewport
226
  const viewportStartSample = snapshot.viewPortCursor - snapshot.viewPortSize + 1
2✔
227
  const viewportEndSample = snapshot.viewPortCursor
2✔
228
  
229
  // Find first stable tick position - align to stepSize grid
230
  const firstTickSample = Math.ceil(viewportStartSample / stepSize) * stepSize
2✔
231
  
232
  const xScale = snapshot.viewPortSize > 1 ? chart.width / (snapshot.viewPortSize - 1) : 1
2!
233
    
234
  // Draw ticks at stable sample indices
235
  for (let sampleIndex = firstTickSample; sampleIndex <= viewportEndSample; sampleIndex += stepSize) {
2✔
236
    // Convert absolute sample index to viewport relative index (0 to viewPortSize-1)
237
    const viewportIndex = sampleIndex - viewportStartSample
8✔
238
    
239
    if (viewportIndex < 0 || viewportIndex >= snapshot.viewPortSize) continue
8!
240
    
241
    // Check if we have valid time data at this index
242
    if (viewportIndex >= times.length || !Number.isFinite(times[viewportIndex])) continue
8!
243
    
244
    // Calculate screen position for this viewport index
245
    const screenX = chart.x + viewportIndex * xScale
8✔
246
    
247
    // Draw label using the timestamp at this viewport index
248
    const timestamp = times[viewportIndex]
8✔
249
    const windowMs = Math.max(1, times[times.length - 1] - times[0])
8✔
250
    const label = formatTimeLabel(timestamp, times[times.length - 1], windowMs, timeMode, snapshot.firstTimestamp)
8✔
251
    ctx.fillText(label, screenX, chart.y + chart.height + 6)
8✔
252
  }
8✔
253
  ctx.beginPath()
2✔
254
  for (let sampleIndex = firstTickSample; sampleIndex <= viewportEndSample; sampleIndex += stepSize) {
2✔
255
    // Convert absolute sample index to viewport relative index (0 to viewPortSize-1)
256
    const viewportIndex = sampleIndex - viewportStartSample
8✔
257
    
258
    if (viewportIndex < 0 || viewportIndex >= snapshot.viewPortSize) continue
8!
259
    
260
    // Check if we have valid time data at this index
261
    if (viewportIndex >= times.length || !Number.isFinite(times[viewportIndex])) continue
8!
262
    
263
    // Calculate screen position for this viewport index
264
    const screenX = chart.x + viewportIndex * xScale
8✔
265
    ctx.moveTo(screenX + 0.5, chart.y)
8✔
266
    ctx.lineTo(screenX + 0.5, chart.y + chart.height)
8✔
267
  }
8✔
268
  ctx.stroke()
2✔
269
}
2✔
270

271
/**
272
 * Draws all data series as line plots.
273
 */
274
export function drawSeries(
1✔
275
  ctx: CanvasRenderingContext2D,
3✔
276
  chart: ChartBounds,
3✔
277
  snapshot: PlotSnapshot,
3✔
278
  yMin: number,
3✔
279
  yMax: number
3✔
280
) {
3✔
281
  if (snapshot.viewPortSize === 0) return
3!
282
  
283
  const xScale = snapshot.viewPortSize > 1 ? chart.width / (snapshot.viewPortSize - 1) : 1
3!
284
  const yScale = (yMax - yMin) !== 0 ? chart.height / (yMax - yMin) : 1
3!
285
  
286
  // Clip to chart area to prevent drawing outside bounds
287
  ctx.save()
3✔
288
  ctx.beginPath()
3✔
289
  ctx.rect(chart.x, chart.y, chart.width, chart.height)
3✔
290
  ctx.clip()
3✔
291
  
292
  // Draw each series
293
  for (const series of snapshot.series) {
3✔
294
    const data = snapshot.getSeriesData(series.id)
3✔
295
    if (!data || data.length === 0) continue
3!
296
    
297
    ctx.beginPath()
3✔
298
    ctx.strokeStyle = series.color
3✔
299
    ctx.lineWidth = 1.5
3✔
300
    
301
    let hasPath = false
3✔
302
    let lastX = -Infinity
3✔
303
    let lastY = -Infinity
3✔
304
    
305
    for (let i = 0; i < data.length; i++) {
3✔
306
      const value = data[i]
12✔
307
      if (!Number.isFinite(value)) {
12✔
308
        // Skip NaN/undefined values - break the path
309
        hasPath = false
1✔
310
        continue
1✔
311
      }
1✔
312
      
313
      const x = chart.x + i * xScale
11✔
314
      const y = chart.y + chart.height - (value - yMin) * yScale
11✔
315
      
316
      // Skip subpixel movements for performance
317
      if (Math.abs(x - lastX) < 0.5 && Math.abs(y - lastY) < 0.5 && hasPath) {
12✔
318
        continue
4✔
319
      }
4✔
320
      
321
      if (!hasPath) {
12✔
322
        ctx.moveTo(x, y)
4✔
323
        hasPath = true
4✔
324
      } else {
6✔
325
        ctx.lineTo(x, y)
3✔
326
      }
3✔
327
      
328
      lastX = x
7✔
329
      lastY = y
7✔
330
    }
7✔
331
    
332
    ctx.stroke()
3✔
333
  }
3✔
334
  
335
  ctx.restore()
3✔
336
}
3✔
337

338
/**
339
 * Draws a vertical crosshair line at the hovered sample position.
340
 */
341
export function drawHoverCrosshair(
1✔
342
  ctx: CanvasRenderingContext2D,
×
343
  chart: ChartBounds,
×
344
  theme: Theme,
×
345
  snapshot: PlotSnapshot,
×
346
  hover: { x: number; y: number; sampleIndex: number }
×
347
) {
×
348
  const { sampleIndex } = hover
×
349
  if (sampleIndex < 0 || sampleIndex >= snapshot.viewPortSize) return
×
350
  
351
  // Draw vertical line at sample position
352
  const xScale = snapshot.viewPortSize > 1 ? chart.width / (snapshot.viewPortSize - 1) : 1
×
353
  const lineX = chart.x + sampleIndex * xScale
×
354
  
355
  ctx.save()
×
356
  ctx.strokeStyle = theme.textColor
×
357
  ctx.globalAlpha = 0.5
×
358
  ctx.setLineDash([3, 3])
×
359
  ctx.lineWidth = 1
×
360
  ctx.beginPath()
×
361
  ctx.moveTo(lineX, chart.y)
×
362
  ctx.lineTo(lineX, chart.y + chart.height)
×
363
  ctx.stroke()
×
364
  ctx.restore()
×
365
}
×
366

367
/**
368
 * Draws a hover tooltip showing sample values at the specified index.
369
 */
370
export function drawHoverTooltip(
1✔
371
  ctx: CanvasRenderingContext2D,
×
372
  chart: ChartBounds,
×
373
  theme: Theme,
×
374
  snapshot: PlotSnapshot,
×
375
  timeMode: 'absolute' | 'relative',
×
376
  hover: { x: number; y: number; sampleIndex: number }
×
377
) {
×
378
  const { sampleIndex } = hover
×
379
  if (sampleIndex < 0 || sampleIndex >= snapshot.viewPortSize) return
×
380
  
381
  const times = snapshot.getTimes?.() ?? new Float64Array(0)
×
382
  if (sampleIndex >= times.length) return
×
383
  
384
  const timestamp = times[sampleIndex]
×
385
  if (!Number.isFinite(timestamp)) return // Skip NaN timestamps
×
386
  
387
  // Collect all series values at this index
388
  const values: Array<{ name: string; value: number; color: string }> = []
×
389
  for (const series of snapshot.series) {
×
390
    const data = snapshot.getSeriesData(series.id)
×
391
    if (sampleIndex < data.length) {
×
392
      const value = data[sampleIndex]
×
393
      if (Number.isFinite(value)) {
×
394
        values.push({ name: series.name, value, color: series.color })
×
395
      }
×
396
    }
×
397
  }
×
398
  
399
  if (values.length === 0) return
×
400
  
401
  // Format timestamp
402
  const rightTime = times[times.length - 1]
×
403
  const windowMs = Math.max(1, rightTime - times[0])
×
NEW
404
  const timeLabel = formatTimeLabel(timestamp, rightTime, windowMs, timeMode, snapshot.firstTimestamp)
×
405
  
406
  // Calculate tooltip content and size
407
  const lines = [`Time: ${timeLabel}`, ...values.map(v => `${v.name}: ${v.value.toFixed(3)}`)]
×
408
  const padding = 8
×
409
  const lineHeight = 14
×
410
  const fontSize = 12
×
411
  
412
  ctx.font = `${fontSize}px system-ui, -apple-system, sans-serif`
×
413
  ctx.textAlign = 'left'
×
414
  ctx.textBaseline = 'top'
×
415
  
416
  // Measure text for tooltip sizing
417
  const maxWidth = Math.max(...lines.map(line => ctx.measureText(line).width))
×
418
  const tooltipWidth = maxWidth + padding * 2
×
419
  const tooltipHeight = lines.length * lineHeight + padding * 2
×
420
  
421
  // Position tooltip (avoid edges)
422
  let tooltipX = hover.x + 10
×
423
  let tooltipY = hover.y - tooltipHeight - 10
×
424
  
425
  if (tooltipX + tooltipWidth > chart.x + chart.width) {
×
426
    tooltipX = hover.x - tooltipWidth - 10
×
427
  }
×
428
  if (tooltipY < chart.y) {
×
429
    tooltipY = hover.y + 10
×
430
  }
×
431
  
432
  // Draw tooltip background
433
  ctx.fillStyle = theme.plotBg
×
434
  ctx.strokeStyle = theme.plotGrid
×
435
  ctx.lineWidth = 1
×
436
  ctx.fillRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight)
×
437
  ctx.strokeRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight)
×
438
  
439
  // Draw tooltip text
440
  ctx.fillStyle = theme.textColor
×
441
  lines.forEach((line, i) => {
×
442
    ctx.fillText(line, tooltipX + padding, tooltipY + padding + i * lineHeight)
×
443
  })
×
444
}
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc