1import { useCallback } from 'react'
2import { useCanvas } from '../hooks/useCanvas'
4type Props = {
5 values: number[]
6 yMin: number
7 yMax: number
8 yTicks: number[]
9 color: string
10 tickLabel?: (v: number) => string
11}
13const M = { l: 40, r: 10, t: 8, b: 22 }
15/** Discrete spectrum as stems at harmonic numbers 1..N. */
16export function StemPlot({ values, yMin, yMax, yTicks, color, tickLabel }: Props) {
17 const draw = useCallback(
18 (ctx: CanvasRenderingContext2D, w: number, h: number) => {
19 const pw = w - M.l - M.r
20 const ph = h - M.t - M.b
21 const n = values.length
22 const toY = (v: number) => M.t + ph - ((v - yMin) / (yMax - yMin)) * ph
23 const toX = (k: number) => M.l + (pw * (k + 0.5)) / n
24 const fmt = tickLabel ?? ((v: number) => String(v))
26 ctx.font = '11px system-ui, sans-serif'
27 ctx.lineWidth = 1
28 ctx.strokeStyle = '#ddd'
29 ctx.strokeRect(M.l + 0.5, M.t + 0.5, pw, ph)
31 // horizontal grid + tick labels
32 ctx.fillStyle = '#666'
33 ctx.textAlign = 'right'
34 ctx.textBaseline = 'middle'
35 for (const v of yTicks) {
36 const y = Math.round(toY(v)) + 0.5
37 ctx.strokeStyle = v === 0 ? '#bbb' : '#eee'
38 ctx.beginPath()
39 ctx.moveTo(M.l, y)
40 ctx.lineTo(M.l + pw, y)
41 ctx.stroke()
42 ctx.fillText(fmt(v), M.l - 4, y)
43 }
45 // x labels (harmonic number)
46 ctx.textAlign = 'center'
47 ctx.textBaseline = 'top'
48 const step = n > 16 ? 2 : 1
49 for (let k = 0; k < n; k += step) ctx.fillText(String(k + 1), toX(k), M.t + ph + 4)
51 // stems
52 const base = toY(Math.min(Math.max(0, yMin), yMax))
53 ctx.strokeStyle = color
54 ctx.fillStyle = color
55 ctx.lineWidth = 2
56 for (let k = 0; k < n; k++) {
57 const x = toX(k)
58 const y = toY(values[k])
59 ctx.beginPath()
60 ctx.moveTo(x, base)
61 ctx.lineTo(x, y)
62 ctx.stroke()
63 ctx.beginPath()
64 ctx.arc(x, y, 3.5, 0, 2 * Math.PI)
65 ctx.fill()
66 }
67 },
68 [values, yMin, yMax, yTicks, color, tickLabel],
69 )
70 const ref = useCanvas(draw)
71 return <canvas ref={ref} className="plot stem-plot" />
72}