1import { useCallback, useMemo } from 'react'
2import { useCanvas } from '../hooks/useCanvas'
3import { AXIS, FONT, FRAME, formatTick, niceTicks } from '../plot'
4import type { ImageData2D } from '../types'
6type Props = {
7 image: ImageData2D
8 /** maps a data value to rgb */
9 color: (v: number) => [number, number, number]
10 label: string
11 labelColor: string
12 /** time range to outline, e.g. the waveform view */
13 highlight?: [number, number]
14 className?: string
15}
17const M = { l: 48, r: 12, t: 8, b: 26 }
19/** Draws a time-frequency image (spectrogram, phase field) with axes. */
20export function ImagePlot({ image, color, label, labelColor, highlight, className }: Props) {
21 // Rasterize once per image, then scale to the canvas on every draw.
22 const raster = useMemo(() => {
23 const { rows, cols, data } = image
24 const off = document.createElement('canvas')
25 off.width = cols
26 off.height = rows
27 const ictx = off.getContext('2d')
28 if (!ictx) return null
29 const img = ictx.createImageData(cols, rows)
30 const d = img.data
31 for (let r = 0; r < rows; r++) {
32 const yImg = rows - 1 - r // row 0 (0 Hz) at the bottom
33 for (let c = 0; c < cols; c++) {
34 const [R, G, B] = color(data[r * cols + c])
35 const o = 4 * (yImg * cols + c)
36 d[o] = R
37 d[o + 1] = G
38 d[o + 2] = B
39 d[o + 3] = 255
40 }
41 }
42 ictx.putImageData(img, 0, 0)
43 return off
44 }, [image, color])
46 const draw = useCallback(
47 (ctx: CanvasRenderingContext2D, w: number, h: number) => {
48 const pw = w - M.l - M.r
49 const ph = h - M.t - M.b
50 ctx.font = FONT
51 if (raster) {
52 ctx.imageSmoothingEnabled = false
53 ctx.drawImage(raster, M.l, M.t, pw, ph)
54 }
55 ctx.strokeStyle = FRAME
56 ctx.lineWidth = 1
57 ctx.strokeRect(M.l + 0.5, M.t + 0.5, pw, ph)
58 if (highlight) {
59 const x0 = M.l + (Math.max(0, highlight[0]) / image.durationSec) * pw
60 const x1 = M.l + (Math.min(image.durationSec, highlight[1]) / image.durationSec) * pw
61 ctx.strokeStyle = 'rgba(255, 255, 255, 0.85)'
62 ctx.setLineDash([4, 3])
63 ctx.strokeRect(Math.round(x0) + 0.5, M.t + 0.5, Math.max(2, Math.round(x1 - x0)), ph)
64 ctx.setLineDash([])
65 }
67 ctx.fillStyle = AXIS
68 ctx.textAlign = 'right'
69 ctx.textBaseline = 'middle'
70 for (const v of niceTicks(0, image.fmax, 4)) {
71 if (v > image.fmax) continue
72 const y = M.t + ph - (v / image.fmax) * ph
73 ctx.fillText(formatTick(v), M.l - 5, y)
74 }
75 ctx.textAlign = 'center'
76 ctx.textBaseline = 'top'
77 for (const v of niceTicks(0, image.durationSec, 6)) {
78 if (v > image.durationSec) continue
79 const x = M.l + (v / image.durationSec) * pw
80 ctx.fillText(formatTick(v), x, M.t + ph + 4)
81 }
82 ctx.textBaseline = 'bottom'
83 ctx.fillText('time (s)', M.l + pw / 2, h - 1)
84 ctx.save()
85 ctx.translate(11, M.t + ph / 2)
86 ctx.rotate(-Math.PI / 2)
87 ctx.textBaseline = 'middle'
88 ctx.fillText('Hz', 0, 0)
89 ctx.restore()
91 ctx.fillStyle = labelColor
92 ctx.font = 'bold 12px system-ui, sans-serif'
93 ctx.textAlign = 'left'
94 ctx.textBaseline = 'top'
95 ctx.fillText(label, M.l + 6, M.t + 5)
96 },
97 [raster, image, label, labelColor, highlight],
98 )
99 const ref = useCanvas(draw)
100 return <canvas ref={ref} className={`plot ${className ?? 'image-plot'}`} />
101}