/ tutorials / laplace-dirichlet
Sign in
tutorials / laplace-dirichlet
laplace-dirichlet / src / plot / Curves.tsx
249 lines · 6.4 KBBlameHistoryRaw
1// Marks drawn inside a Plot frame: polylines, markers, reference slopes and
2// direct end-labels. Every mark is thin (2px lines, >=8px markers) and every
3// marker carries a shape as well as a colour.
4import type { ReactNode } from 'react'
5import type { Num } from '../engine/types.ts'
6import { ink, surface } from './palette.ts'
7import { linePath, type Frame } from './scales.ts'
9export type Shape = 'circle' | 'square' | 'triangle' | 'diamond' | 'cross' | 'plus' | 'star'
11export function Line({
12 x,
13 y,
14 f,
15 color,
16 dash,
17 width = 2,
18 opacity = 1,
19}: {
20 x: readonly number[]
21 y: readonly Num[]
22 f: Frame
23 color: string
24 dash?: string
25 width?: number
26 opacity?: number
27}) {
28 return (
29 <path
30 d={linePath(x, y, f)}
31 fill="none"
32 stroke={color}
33 strokeWidth={width}
34 strokeDasharray={dash}
35 strokeLinejoin="round"
36 strokeLinecap="round"
37 opacity={opacity}
38 />
39 )
42/** One marker, centred on (cx, cy) in pixels, about 9px across. */
43export function markerPath(shape: Shape, cx: number, cy: number, r = 4.5): string {
44 switch (shape) {
45 case 'square':
46 return `M${cx - r} ${cy - r}h${2 * r}v${2 * r}h${-2 * r}z`
47 case 'triangle':
48 return `M${cx} ${cy - r * 1.15}L${cx + r} ${cy + r * 0.8}L${cx - r} ${cy + r * 0.8}z`
49 case 'diamond':
50 return `M${cx} ${cy - r * 1.2}L${cx + r * 1.2} ${cy}L${cx} ${cy + r * 1.2}L${cx - r * 1.2} ${cy}z`
51 case 'cross':
52 return `M${cx - r} ${cy - r}L${cx + r} ${cy + r}M${cx + r} ${cy - r}L${cx - r} ${cy + r}`
53 case 'plus':
54 return `M${cx - r * 1.2} ${cy}h${2.4 * r}M${cx} ${cy - r * 1.2}v${2.4 * r}`
55 case 'star': {
56 let d = ''
57 for (let k = 0; k < 3; k++) {
58 const a = (Math.PI * k) / 3
59 d += `M${cx - r * Math.cos(a)} ${cy - r * Math.sin(a)}L${cx + r * Math.cos(a)} ${cy + r * Math.sin(a)}`
60 }
61 return d
62 }
63 default:
64 return `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0z`
65 }
68export function Markers({
69 x,
70 y,
71 f,
72 color,
73 shape = 'circle',
74 r = 4.5,
75}: {
76 x: readonly number[]
77 y: readonly Num[]
78 f: Frame
79 color: string
80 shape?: Shape
81 r?: number
82}) {
83 const stroked = shape === 'cross' || shape === 'plus' || shape === 'star'
84 const out: ReactNode[] = []
85 for (let i = 0; i < x.length && i < y.length; i++) {
86 const v = y[i]
87 if (v == null || !isFinite(v) || !isFinite(x[i])) continue
88 const px = f.sx(x[i])
89 const py = f.sy(v)
90 if (py < -20 || py > f.ih + 20) continue
91 out.push(
92 <path
93 key={i}
94 d={markerPath(shape, px, py, r)}
95 fill={stroked ? 'none' : color}
96 stroke={stroked ? color : surface}
97 strokeWidth={stroked ? 2 : 1.5}
98 />,
99 )
100 }
101 return <g>{out}</g>
104/**
105 * A dashed reference line of slope p through a chosen point, for log-log
106 * convergence plots: the eye reads "is the data parallel to this?" much better
107 * than it reads a printed rate.
108 */
109export function RefSlope({
110 f,
111 p,
112 through,
113 label,
114 span = 0.6,
115 labelAt = 'start',
116}: {
117 f: Frame
118 /** the exponent: error ~ n^p */
119 p: number
120 /** a point the line passes through, in data coordinates */
121 through: [number, number]
122 label: string
123 /** fraction of the x range the line covers */
124 span?: number
125 labelAt?: 'start' | 'end'
126}) {
127 const [lx, hx] = f.xDomain
128 const l = Math.log10(lx)
129 const h = Math.log10(hx)
130 const c = Math.log10(through[0])
131 const x0 = Math.pow(10, Math.max(l, c - (span * (h - l)) / 2))
132 const x1 = Math.pow(10, Math.min(h, c + (span * (h - l)) / 2))
133 const y = (xx: number) => through[1] * Math.pow(xx / through[0], p)
134 return (
135 <g>
136 <path
137 d={`M${f.sx(x0)} ${f.sy(y(x0))}L${f.sx(x1)} ${f.sy(y(x1))}`}
138 stroke={ink.muted}
139 strokeWidth={1.5}
140 strokeDasharray="5 4"
141 fill="none"
142 />
143 {labelAt === 'end' ? (
144 <text x={f.sx(x1) - 3} y={f.sy(y(x1)) - 6} fill={ink.muted} fontSize={11} textAnchor="end">
145 {label}
146 </text>
147 ) : (
148 <text x={f.sx(x0) + 3} y={f.sy(y(x0)) + 14} fill={ink.muted} fontSize={11} textAnchor="start">
149 {label}
150 </text>
151 )}
152 </g>
153 )
156/**
157 * A ring around the data point under the cursor, and a box of its numbers.
158 *
159 * Drawn as SVG rather than an HTML overlay, like everything else in this
160 * layer, so it needs no positioning relative to the host element. The box
161 * width is estimated from the character count, which is why the lines are kept
162 * short; it flips to the other side of the point rather than leave the frame.
163 */
164export function PointTip({
165 f,
166 px,
167 py,
168 color,
169 lines,
170}: {
171 f: Frame
172 px: number
173 py: number
174 color: string
175 /** first line is the series, in its own colour; the rest are the numbers */
176 lines: string[]
177}) {
178 const size = 11.5
179 const pad = 6
180 const lh = 15
181 const w = Math.max(...lines.map((l) => l.length)) * 6.15 + 2 * pad
182 const h = lines.length * lh + 2 * pad - 3
183 const right = px + 14 + w < f.iw
184 const bx = Math.min(Math.max(right ? px + 14 : px - 14 - w, 2), Math.max(2, f.iw - w - 2))
185 const by = Math.min(Math.max(py - h / 2, 2), Math.max(2, f.ih - h - 2))
186 return (
187 <g pointerEvents="none">
188 <circle cx={px} cy={py} r={7} fill="none" stroke={color} strokeWidth={2} />
189 <circle cx={px} cy={py} r={7} fill="none" stroke={surface} strokeWidth={1} opacity={0.7} />
190 <rect
191 x={bx}
192 y={by}
193 width={w}
194 height={h}
195 rx={4}
196 fill={surface}
197 fillOpacity={0.94}
198 stroke={ink.axis}
199 strokeWidth={1}
200 />
201 {lines.map((l, i) => (
202 <text
203 key={i}
204 x={bx + pad}
205 y={by + pad + lh * i + size}
206 fill={i === 0 ? color : ink.secondary}
207 fontSize={size}
208 style={i === 0 ? undefined : { fontVariantNumeric: 'tabular-nums' }}
209 >
210 {l}
211 </text>
212 ))}
213 </g>
214 )
217/** A label placed at the end of a curve, so the reader need not use the legend. */
218export function EndLabel({
219 f,
220 x,
221 y,
222 text,
223 color,
224 dx = 6,
225 dy = 4,
226 anchor = 'start',
227}: {
228 f: Frame
229 x: number
230 y: number
231 text: string
232 color: string
233 dx?: number
234 dy?: number
235 anchor?: 'start' | 'end' | 'middle'
236}) {
237 return (
238 <text
239 x={f.sx(x) + dx}
240 y={f.sy(y) + dy}
241 fill={color}
242 fontSize={11}
243 textAnchor={anchor}
244 style={{ paintOrder: 'stroke', stroke: surface, strokeWidth: 3 }}
245 >
246 {text}
247 </text>
248 )
↑↓ move↵openescclose