/ proof-of-concept / phase-perception-2
Sign in
proof-of-concept / phase-perception-2
phase-perception-2 / src / components / LinePlot.tsx
170 lines · 5.0 KBCodeBlameHistory
2import { useCanvas } from '../hooks/useCanvas'
3import { AXIS, FONT, FRAME, GRID, formatTick, niceTicks } from '../plot'
5export type Series = {
6 x: ArrayLike<number>
7 y: ArrayLike<number>
8 color: string
9 width?: number
10 /** draw points instead of a line */
11 dots?: boolean
14type Props = {
15 series: Series[]
16 xRange: [number, number]
17 /** fixed y range; otherwise fitted to the data, symmetric if `symmetric` */
18 yRange?: [number, number]
19 symmetric?: boolean
20 xLabel?: string
21 yLabel?: string
22 /** a shaded horizontal band, e.g. a threshold */
23 band?: { y0: number; y1: number; color: string; label?: string }
24 zeroLine?: boolean
25 className?: string
28const M = { l: 48, r: 12, t: 8, b: 30 }
30/** A plain x-y plot with ticks, an optional shaded band, and a zero line. */
31export function LinePlot({ series, xRange, yRange, symmetric, xLabel, yLabel, band, zeroLine, className }: Props) {
32 const draw = useCallback(
33 (ctx: CanvasRenderingContext2D, w: number, h: number) => {
34 const pw = w - M.l - M.r
35 const ph = h - M.t - M.b
36 let [y0, y1] = yRange ?? [Infinity, -Infinity]
37 if (!yRange) {
38 for (const s of series) for (let i = 0; i < s.y.length; i++) {
39 const v = s.y[i]
40 if (!Number.isFinite(v)) continue
41 if (v < y0) y0 = v
42 if (v > y1) y1 = v
43 }
44 if (band) {
45 y0 = Math.min(y0, band.y0)
46 y1 = Math.max(y1, band.y1)
47 }
48 if (!(y1 > y0)) {
49 y0 = Number.isFinite(y0) ? y0 - 1 : -1
50 y1 = Number.isFinite(y1) ? y1 + 1 : 1
51 }
52 if (symmetric) {
53 const m = Math.max(Math.abs(y0), Math.abs(y1))
54 y0 = -m
55 y1 = m
56 }
57 const padY = (y1 - y0) * 0.06
58 y0 -= padY
59 y1 += padY
60 }
61 const [x0, x1] = xRange
62 const toX = (v: number) => M.l + ((v - x0) / (x1 - x0)) * pw
63 const toY = (v: number) => M.t + ph - ((v - y0) / (y1 - y0)) * ph
65 ctx.font = FONT
66 ctx.lineWidth = 1
68 if (band) {
69 ctx.fillStyle = band.color
70 const by0 = Math.max(M.t, Math.min(M.t + ph, toY(band.y1)))
71 const by1 = Math.max(M.t, Math.min(M.t + ph, toY(band.y0)))
72 ctx.fillRect(M.l, by0, pw, by1 - by0)
73 }
75 ctx.fillStyle = AXIS
76 ctx.strokeStyle = GRID
77 ctx.textAlign = 'right'
78 ctx.textBaseline = 'middle'
79 for (const v of niceTicks(y0, y1, 4)) {
80 const y = Math.round(toY(v)) + 0.5
81 if (y < M.t || y > M.t + ph) continue
82 ctx.beginPath()
83 ctx.moveTo(M.l, y)
84 ctx.lineTo(M.l + pw, y)
85 ctx.stroke()
86 ctx.fillText(formatTick(v), M.l - 5, y)
87 }
88 ctx.textAlign = 'center'
89 ctx.textBaseline = 'top'
90 for (const v of niceTicks(x0, x1, 6)) {
91 const x = Math.round(toX(v)) + 0.5
92 if (x < M.l || x > M.l + pw) continue
93 ctx.beginPath()
94 ctx.moveTo(x, M.t)
95 ctx.lineTo(x, M.t + ph)
96 ctx.stroke()
97 ctx.fillText(formatTick(v), x, M.t + ph + 4)
98 }
99 if (zeroLine && y0 < 0 && y1 > 0) {
100 ctx.strokeStyle = '#b8bcc2'
101 const y = Math.round(toY(0)) + 0.5
102 ctx.beginPath()
103 ctx.moveTo(M.l, y)
104 ctx.lineTo(M.l + pw, y)
105 ctx.stroke()
106 }
107 ctx.strokeStyle = FRAME
108 ctx.strokeRect(M.l + 0.5, M.t + 0.5, pw, ph)
110 if (xLabel) {
111 ctx.textAlign = 'center'
112 ctx.textBaseline = 'bottom'
113 ctx.fillText(xLabel, M.l + pw / 2, h - 2)
114 }
115 if (yLabel) {
116 ctx.save()
117 ctx.translate(11, M.t + ph / 2)
118 ctx.rotate(-Math.PI / 2)
119 ctx.textAlign = 'center'
120 ctx.textBaseline = 'middle'
121 ctx.fillText(yLabel, 0, 0)
122 ctx.restore()
123 }
124 if (band?.label) {
125 ctx.fillStyle = '#2f7d32'
126 ctx.textAlign = 'right'
127 ctx.textBaseline = 'bottom'
128 ctx.fillText(band.label, M.l + pw - 4, Math.max(M.t + 12, toY(band.y1)) - 2)
129 }
131 ctx.save()
132 ctx.beginPath()
133 ctx.rect(M.l, M.t, pw, ph)
134 ctx.clip()
135 for (const s of series) {
136 ctx.strokeStyle = s.color
137 ctx.fillStyle = s.color
138 ctx.lineWidth = s.width ?? 1.5
139 ctx.lineJoin = 'round'
140 if (s.dots) {
141 for (let i = 0; i < s.x.length; i++) {
142 ctx.beginPath()
143 ctx.arc(toX(s.x[i]), toY(s.y[i]), 1.8, 0, 2 * Math.PI)
144 ctx.fill()
145 }
146 } else {
147 ctx.beginPath()
148 let pen = false
149 for (let i = 0; i < s.x.length; i++) {
150 const v = s.y[i]
151 if (!Number.isFinite(v)) {
152 pen = false
153 continue
154 }
155 const px = toX(s.x[i])
156 const py = toY(v)
157 if (!pen) ctx.moveTo(px, py)
158 else ctx.lineTo(px, py)
159 pen = true
160 }
161 ctx.stroke()
162 }
163 }
164 ctx.restore()
165 },
166 [series, xRange, yRange, symmetric, xLabel, yLabel, band, zeroLine],
167 )
168 const ref = useCanvas(draw)
169 return <canvas ref={ref} className={`plot ${className ?? 'line-plot'}`} />
↑↓ move↵openescclose