1import { useState } from 'react'
2import Plot from '../plot/Plot.tsx'
3import Legend from '../plot/Legend.tsx'
4import More from './More.tsx'
5import SeriesToggle from './SeriesToggle.tsx'
6import { ink, series, status } from '../plot/palette.ts'
7import { extent, linePath, padDomain, type Frame } from '../plot/scales.ts'
8import type { ExploreOut } from '../engine/types.ts'
10interface Props {
11 out: ExploreOut
12 showClassical: boolean
13 onToggleClassical: (on: boolean) => void
14}
16export default function PolesPanel({ out, showClassical, onToggleClassical }: Props) {
17 const [hoverRoot, setHoverRoot] = useState<number | null>(null)
18 const p = out.poles
19 if (!p) return <div className="panel">Loading.</div>
21 const xd: [number, number] = [p.t[0], p.t[p.t.length - 1]]
22 const clean = p.realPoles.length === 0
23 // a root counts as sitting on the real axis if its imaginary part is a
24 // rounding error next to the interval it lives on
25 const axisTol = (out.x[out.x.length - 1] - out.x[0]) * 1e-7
26 const nOnAxis = p.rootsIm.filter((v) => Math.abs(v) <= axisTol).length
28 const rootRange = Math.max(
29 ...p.rootsRe.map((v) => Math.abs(v - (out.x[0] + out.x[out.x.length - 1]) / 2)),
30 ...p.rootsIm.map(Math.abs),
31 (out.x[out.x.length - 1] - out.x[0]) / 2,
32 )
33 const cx0 = (out.x[0] + out.x[out.x.length - 1]) / 2
34 const cd: [number, number] = [cx0 - rootRange * 1.12, cx0 + rootRange * 1.12]
35 const cyd: [number, number] = [-rootRange * 1.12, rootRange * 1.12]
37 return (
38 <div className="panel">
39 <div className={`verdict ${clean ? 'good' : 'bad'}`}>
40 <span className="verdict-mark" aria-hidden="true">
41 {clean ? '✓' : '✕'}
42 </span>
43 <span>
44 {clean ? (
45 <>
46 <b>No real poles.</b> The denominator keeps one sign across the whole real line, which is
47 Theorem 1, and every one of its {p.rootsShown ? p.rootsRe.length : 'roots'} roots sits off the
48 real axis.
49 </>
50 ) : (
51 <>
52 <b>
53 {p.realPoles.length} real pole{p.realPoles.length === 1 ? '' : 's'}.
54 </b>{' '}
55 The denominator changes sign, so r blows up inside the interval. These weights are not the ones
56 of equation (18).
57 </>
58 )}
59 </span>
60 </div>
62 <p className="panel-lede">
63 A rational function can only blow up where its denominator crosses zero, so everything on this tab
64 watches the denominator of r.
65 </p>
66 <More>
67 <p>
68 Writing r as a quotient of polynomials (equation 7) puts everything on the denominator s of
69 equation (10). Its zeros are exactly the poles of r, so Theorem 1 amounts to the claim that s never
70 crosses zero on the real line.
71 </p>
72 </More>
74 <h4 className="sub">The denominator on the real line</h4>
75 <p className="panel-note">
76 The denominator along the real line; the shaded band is the data interval. If this curve touched zero
77 anywhere, r would have a pole there.
78 </p>
79 <More>
80 <p>
81 s(x) spans many orders of magnitude, so what is drawn is the signed n-th root of it. That leaves
82 every sign and every zero exactly where it was, and brings the rest into a range that fits on a
83 page. The range shown runs well past both ends of the interpolation interval, since Theorem 1 is a
84 statement about all of <b>R</b>, not just [a, b].
85 </p>
86 </More>
87 <Plot
88 height={200}
89 xDomain={xd}
90 yDomain={[-1.15, 1.15]}
91 xLabel="x"
92 yLabel="signed n-th root of s"
93 margin={{ l: 74 }}
94 under={(f) => (
95 <>
96 <rect
97 x={f.sx(out.x[0])}
98 width={f.sx(out.x[out.x.length - 1]) - f.sx(out.x[0])}
99 y={0}
100 height={f.ih}
101 fill={ink.node}
102 opacity={0.05}
103 />
104 <line x1={0} x2={f.iw} y1={f.sy(0)} y2={f.sy(0)} stroke={ink.axis} strokeWidth={1.5} />
105 </>
106 )}
107 >
108 {(f) => (
109 <>
110 <path d={linePath(p.t, p.u, f)} fill="none" stroke={series.r} strokeWidth={2.5} />
111 {p.realPoles.map((xv, i) => (
112 <g key={i}>
113 <line
114 x1={f.sx(xv)}
115 x2={f.sx(xv)}
116 y1={0}
117 y2={f.ih}
118 stroke={status.critical}
119 strokeWidth={1.5}
120 strokeDasharray="4 3"
121 />
122 <path
123 d={`M${f.sx(xv) - 4} ${f.sy(0) - 4} L${f.sx(xv) + 4} ${f.sy(0) + 4} M${f.sx(xv) + 4} ${
124 f.sy(0) - 4
125 } L${f.sx(xv) - 4} ${f.sy(0) + 4}`}
126 stroke={status.critical}
127 strokeWidth={2}
128 />
129 </g>
130 ))}
131 {out.x.map((xv, i) => (
132 <line
133 key={i}
134 x1={f.sx(xv)}
135 x2={f.sx(xv)}
136 y1={f.ih}
137 y2={f.ih - 5}
138 stroke={ink.node}
139 strokeWidth={1.5}
140 />
141 ))}
142 </>
143 )}
144 </Plot>
146 <h4 className="sub">Where the roots actually are</h4>
147 {p.rootsShown ? (
148 <>
149 <p className="panel-note">
150 All {p.rootsRe.length} roots of the denominator, in the complex plane. A root on the horizontal
151 axis would be a real pole
152 {nOnAxis > 0 ? '; the crosses mark roots that have landed there.' : ', and none of them is.'}
153 </p>
154 <More>
155 <p>
156 Theorem 1 says the roots of s stay off the real line for every d and every set of distinct
157 nodes. The grey band is the interpolation interval; the ticks on the axis are the nodes.
158 </p>
159 </More>
160 <Legend
161 items={[
162 { label: 'root of s', color: series.r, shape: 'dot' },
163 ...(nOnAxis > 0 ? [{ label: 'root on the real axis: a pole', color: status.critical, shape: 'cross' as const }] : []),
164 ]}
165 />
166 <Plot
167 height={300}
168 xDomain={cd}
169 yDomain={cyd}
170 xLabel="Re"
171 yLabel="Im"
172 under={(f: Frame) => (
173 <>
174 <rect
175 x={f.sx(out.x[0])}
176 width={f.sx(out.x[out.x.length - 1]) - f.sx(out.x[0])}
177 y={0}
178 height={f.ih}
179 fill={ink.node}
180 opacity={0.05}
181 />
182 <line x1={0} x2={f.iw} y1={f.sy(0)} y2={f.sy(0)} stroke={ink.axis} strokeWidth={1.5} />
183 <line x1={f.sx(0)} x2={f.sx(0)} y1={0} y2={f.ih} stroke={ink.grid} strokeWidth={1} />
184 </>
185 )}
186 overlay={
187 hoverRoot != null && (
188 <div className="tooltip">
189 {p.rootsRe[hoverRoot].toPrecision(4)}
190 {p.rootsIm[hoverRoot] >= 0 ? ' + ' : ' − '}
191 {Math.abs(p.rootsIm[hoverRoot]).toPrecision(4)} i
192 </div>
193 )
194 }
195 >
196 {(f) => (
197 <>
198 {out.x.map((xv, i) => (
199 <line
200 key={i}
201 x1={f.sx(xv)}
202 x2={f.sx(xv)}
203 y1={f.sy(0) - 4}
204 y2={f.sy(0) + 4}
205 stroke={ink.node}
206 strokeWidth={1.5}
207 />
208 ))}
209 {p.rootsRe.map((re, i) => {
210 const onAxis = Math.abs(p.rootsIm[i]) <= axisTol
211 return onAxis ? (
212 <path
213 key={i}
214 d={`M${f.sx(re) - 5} ${f.sy(p.rootsIm[i]) - 5} L${f.sx(re) + 5} ${
215 f.sy(p.rootsIm[i]) + 5
216 } M${f.sx(re) + 5} ${f.sy(p.rootsIm[i]) - 5} L${f.sx(re) - 5} ${f.sy(p.rootsIm[i]) + 5}`}
217 stroke={status.critical}
218 strokeWidth={2.5}
219 strokeLinecap="round"
220 onPointerEnter={() => setHoverRoot(i)}
221 onPointerLeave={() => setHoverRoot(null)}
222 />
223 ) : (
224 <circle
225 key={i}
226 cx={f.sx(re)}
227 cy={f.sy(p.rootsIm[i])}
228 r={hoverRoot === i ? 7 : 5}
229 fill={series.r}
230 stroke="#151a21"
231 strokeWidth={2}
232 onPointerEnter={() => setHoverRoot(i)}
233 onPointerLeave={() => setHoverRoot(null)}
234 />
235 )
236 })}
237 </>
238 )}
239 </Plot>
240 </>
241 ) : (
242 <p className="panel-note">
243 {out.n > 40
244 ? 'Not drawn above n = 40: recovering roots from the coefficients of a polynomial of that degree is not reliable enough to be worth showing. The sign test above still holds at any n.'
245 : 'The denominator reduces to a constant, so it has no roots at all — which is one way for an interpolant to have no poles. That is what the Lagrange weights of equation (2) do.'}
246 </p>
247 )}
249 <h4 className="sub">The classical alternative</h4>
250 <div className="row-controls">
251 <span className="field-label">compare with</span>
252 <SeriesToggle color={series.classical} on={showClassical} onChange={onToggleClassical}>
253 classical rational fit p<sub>M</sub> / q<sub>N</sub>
254 </SeriesToggle>
255 </div>
256 <p className="panel-note">
257 The textbook way to fit a rational function: one polynomial divided by another. It is often a fine
258 approximation where it is finite, and there is no way to control where its poles land.
259 </p>
260 <More>
261 <p>
262 This is the construction the paper's introduction rejects: fit the same data with a quotient of
263 polynomials of degrees M and N summing to n. The dashed lines mark the real poles it put in the
264 window shown.
265 </p>
266 </More>
267 {showClassical && p.classical ? (
268 <>
269 <Legend
270 items={[
271 { label: 'f(x)', color: ink.reference, dash: '5 4' },
272 { label: 'rational r(x), this page', color: series.r },
273 { label: 'classical p_M / q_N', color: series.classical },
274 ...(p.classicalPoles && p.classicalPoles.length
275 ? [{ label: 'its poles', color: status.critical, shape: 'cross' as const }]
276 : []),
277 ]}
278 />
279 <ClassicalPlot out={out} />
280 </>
281 ) : (
282 <p className="panel-note muted">Turn it on to draw it.</p>
283 )}
284 </div>
285 )
286}
288function ClassicalPlot({ out }: { out: ExploreOut }) {
289 const p = out.poles!
290 const xd: [number, number] = [p.t[0], p.t[p.t.length - 1]]
291 const yd = padDomain(extent(out.ft, out.r), 0.45)
292 const inView = (p.classicalPoles ?? []).filter((v) => v >= xd[0] && v <= xd[1])
293 return (
294 <>
295 <Plot
296 height={260}
297 xDomain={xd}
298 yDomain={yd}
299 xLabel="x"
300 yLabel="f, r, classical"
301 under={(f) => (
302 <rect
303 x={f.sx(out.x[0])}
304 width={f.sx(out.x[out.x.length - 1]) - f.sx(out.x[0])}
305 y={0}
306 height={f.ih}
307 fill={ink.node}
308 opacity={0.05}
309 />
310 )}
311 >
312 {(f) => (
313 <>
314 {inView.map((xv, i) => (
315 <line
316 key={i}
317 x1={f.sx(xv)}
318 x2={f.sx(xv)}
319 y1={0}
320 y2={f.ih}
321 stroke={status.critical}
322 strokeWidth={1.5}
323 strokeDasharray="4 3"
324 />
325 ))}
326 <path d={linePath(out.t, out.ft, f)} fill="none" stroke={ink.reference} strokeWidth={2} strokeDasharray="5 4" />
327 <path d={linePath(p.t, p.classical!, f)} fill="none" stroke={series.classical} strokeWidth={2} />
328 <path d={linePath(out.t, out.r, f)} fill="none" stroke={series.r} strokeWidth={2.5} />
329 {out.x.map((xv, i) => (
330 <circle
331 key={i}
332 cx={f.sx(xv)}
333 cy={f.sy(out.y[i])}
334 r={3}
335 fill={ink.node}
336 stroke="#151a21"
337 strokeWidth={1.5}
338 />
339 ))}
340 </>
341 )}
342 </Plot>
343 <p className="panel-note">
344 {inView.length === 0
345 ? 'On this data it happens to have no poles in the window shown. Change n, or the function, or the nodes, and that is not something you can rely on.'
346 : `${inView.length} real pole${inView.length === 1 ? '' : 's'} in the window, at ${inView
347 .map((v) => v.toFixed(3))
348 .join(', ')}.`}
349 </p>
350 </>
351 )
352}