Simplify the interface for first-time visitors
The script editor starts collapsed behind a strip under the header, with
the choice persisted. The interval inputs move behind a 'more' fold, so
the controls row leads with the function, n, d and the node distribution.
Panel notes open with a plain sentence; the equation, theorem and table
references fold away behind 'from the paper' disclosures. The polynomial,
spline and classical overlays toggle with buttons carrying their series
colour instead of bare checkboxes, and the hover readout on the
Interpolant tab reserves its space so the error plot no longer jumps.
10 changed files+375−127
scripts/browser-test.mjsmodified+4−3View file
@@ -120,9 +120,7 @@ try {
120120 // ── the overlays ───────────────────────────────────────────────────────
121121 console.log('\nOverlays')
122122 await page.evaluate(() => {
123- document.querySelectorAll('.row-controls input[type=checkbox]').forEach((c) => {
124- if (!c.checked) c.click()
125- })
123+ document.querySelectorAll('.row-controls button.tone[aria-pressed="false"]').forEach((b) => b.click())
126124 })
127125 await settle()
128126 const legends = await page.$$eval('.legend-item', (els) => els.map((e) => e.textContent))
@@ -159,7 +157,10 @@ try {
159157 check('16 roots drawn in the complex plane', roots === 16, `${roots} roots`)
160158
161159 // ── swapping the method changes the verdict ────────────────────────────
160+ // the editor starts collapsed; the method dropdown lives inside it
162161 console.log('\nEqual weights: the counter-example')
162+ await page.evaluate(() => document.querySelector('.editor-strip')?.click())
163+ await page.waitForSelector('.script-head select', { timeout: 10000 })
163164 await page.select('.script-head select', 'equal')
164165 await settle()
165166 await waitText('.verdict', /real pole/)
src/App.tsxmodified+44−9View file
@@ -32,6 +32,22 @@ export default function App() {
3232 const [script, setScript] = useState(() => DEFAULT_METHOD.source)
3333 const [dirty, setDirty] = useState(false)
3434 const [tab, setTab] = useState<TabId>('interpolant')
35+ // The editor is the point of the app for a reader of the paper, but a wall of
36+ // MATLAB for anyone else, so it starts collapsed and the choice persists.
37+ const [editorOpen, setEditorOpen] = useState(() => {
38+ try {
39+ return localStorage.getItem('br-editor-open') === '1'
40+ } catch {
41+ return false
42+ }
43+ })
44+ useEffect(() => {
45+ try {
46+ localStorage.setItem('br-editor-open', editorOpen ? '1' : '0')
47+ } catch {
48+ /* private mode */
49+ }
50+ }, [editorOpen])
3551 const [settings, setSettings] = useState<Settings>({
3652 f: 'runge',
3753 fexpr: 'exp(-x.^2) .* cos(3*x)',
@@ -182,7 +198,8 @@ export default function App() {
182198 <a href="https://doi.org/10.1007/s00211-007-0093-y" target="_blank" rel="noreferrer">
183199 Numer. Math. <b>107</b> (2007) 315–331
184200 </a>
185- . The method is the script on the left; it runs in your browser through{' '}
201+ . Every plot is computed live by a short MATLAB script you can open and edit; it runs in
202+ your browser through{' '}
186203 <a href="https://numbl.org" target="_blank" rel="noreferrer">
187204 numbl
188205 </a>
@@ -199,7 +216,19 @@ export default function App() {
199216 </a>
200217 </header>
201218
219+ {!editorOpen && (
220+ <button className="editor-strip" onClick={() => setEditorOpen(true)}>
221+ <span aria-hidden="true">▸</span>
222+ <span>
223+ Open the MATLAB script that computes these plots
224+ {dirty && <span className="strip-note"> (edited)</span>}
225+ </span>
226+ {error && <span className="strip-alert">the script failed</span>}
227+ </button>
228+ )}
229+
202230 <div className="body">
231+ {editorOpen && (
203232 <section className="left">
204233 <div className="script-head">
205234 <label className="field">
@@ -213,14 +242,19 @@ export default function App() {
213242 ))}
214243 </select>
215244 </label>
216- <button
217- className="primary"
218- onClick={() => setRunToken((v) => v + 1)}
219- disabled={busy || convRunning}
220- title="⌘/Ctrl+Enter"
221- >
222- {busy || convRunning ? 'Running…' : 'Run ▶'}
223- </button>
245+ <div className="script-actions">
246+ <button
247+ className="primary"
248+ onClick={() => setRunToken((v) => v + 1)}
249+ disabled={busy || convRunning}
250+ title="⌘/Ctrl+Enter"
251+ >
252+ {busy || convRunning ? 'Running…' : 'Run ▶'}
253+ </button>
254+ <button className="ghost" onClick={() => setEditorOpen(false)} title="collapse the editor">
255+ hide ◂
256+ </button>
257+ </div>
224258 </div>
225259 {method && !dirty && <p className="method-blurb">{method.blurb}</p>}
226260 {dirty && <p className="method-blurb edited">Edited. Pick a method above to start over.</p>}
@@ -255,6 +289,7 @@ export default function App() {
255289 </details>
256290 )}
257291 </section>
292+ )}
258293
259294 <section className="right">
260295 <nav className="tabs">
src/index.cssmodified+87−2View file
@@ -121,6 +121,34 @@ input[type='range'] {
121121 white-space: nowrap;
122122 }
123123
124+/* ── the collapsed-editor strip ───────────────────────── */
125+.editor-strip {
126+ display: flex;
127+ align-items: center;
128+ gap: 9px;
129+ width: 100%;
130+ text-align: left;
131+ background: var(--panel-2);
132+ border: none;
133+ border-bottom: 1px solid var(--border);
134+ border-radius: 0;
135+ padding: 7px 16px;
136+ font-size: 12.5px;
137+ color: var(--text-dim);
138+}
139+.editor-strip:hover:not(:disabled) {
140+ background: #1a2029;
141+ color: var(--text);
142+}
143+.strip-note {
144+ color: #c98500;
145+}
146+.strip-alert {
147+ margin-left: auto;
148+ color: #ff8f8f;
149+ font-weight: 600;
150+}
151+
124152 /* ── two columns ──────────────────────────────────────── */
125153 .body {
126154 display: flex;
@@ -153,6 +181,11 @@ input[type='range'] {
153181 gap: 10px;
154182 padding: 10px 12px 6px;
155183 }
184+.script-actions {
185+ display: flex;
186+ align-items: center;
187+ gap: 6px;
188+}
156189 .method-blurb {
157190 margin: 0;
158191 padding: 0 12px 8px;
@@ -326,6 +359,28 @@ input[type='range'] {
326359 border-color: #2563eb;
327360 color: #fff;
328361 }
362+/* a toggle that adds a plot series: the swatch previews the curve's colour */
363+.chip.tone {
364+ display: inline-flex;
365+ align-items: center;
366+ gap: 7px;
367+}
368+.chip.tone .swatch {
369+ width: 14px;
370+ height: 3px;
371+ border-radius: 2px;
372+ background: var(--tone);
373+ flex: none;
374+}
375+.chip.tone.on {
376+ background: color-mix(in srgb, var(--tone) 20%, #151a21);
377+ border-color: var(--tone);
378+ color: var(--text);
379+}
380+.more-toggle {
381+ align-self: flex-end;
382+ margin-bottom: 2px;
383+}
329384 .interval {
330385 display: flex;
331386 align-items: center;
@@ -365,6 +420,8 @@ input[type='range'] {
365420 .panel {
366421 padding: 14px 16px 40px;
367422 max-width: 1000px;
423+ /* centred, so the full-width layout with the editor collapsed stays balanced */
424+ margin: 0 auto;
368425 }
369426 .panel-lede {
370427 margin: 0 0 12px;
@@ -381,6 +438,28 @@ input[type='range'] {
381438 .panel-note.muted {
382439 color: var(--muted);
383440 }
441+/* the paper-level detail, folded away until asked for */
442+.more {
443+ margin: -4px 0 12px;
444+ font-size: 12.5px;
445+}
446+.more summary {
447+ cursor: pointer;
448+ width: fit-content;
449+ font-size: 12px;
450+ color: var(--accent);
451+ user-select: none;
452+}
453+.more summary:hover {
454+ text-decoration: underline;
455+}
456+.more-body {
457+ color: var(--text-dim);
458+ line-height: 1.5;
459+}
460+.more-body p {
461+ margin: 4px 0 0;
462+}
384463 h4.sub {
385464 margin: 22px 0 6px;
386465 font-size: 13px;
@@ -428,18 +507,24 @@ h4.sub {
428507
429508 .readout {
430509 display: flex;
431- flex-wrap: wrap;
432510 gap: 6px 18px;
433511 font-size: 12px;
434512 font-variant-numeric: tabular-nums;
435513 color: var(--text-dim);
436514 padding: 4px 0 2px;
437- min-height: 22px;
515+ /* fixed height: this row must never change size on hover, or everything
516+ below it jumps */
517+ height: 24px;
518+ overflow: hidden;
519+ white-space: nowrap;
438520 }
439521 .readout b {
440522 color: inherit;
441523 font-weight: 600;
442524 }
525+.readout-hint {
526+ color: var(--muted);
527+}
443528
444529 .tooltip {
445530 position: absolute;
src/panels/BlendingPanel.tsxmodified+35−14View file
@@ -1,6 +1,7 @@
11 import { useMemo, useState } from 'react'
22 import Plot from '../plot/Plot.tsx'
33 import Legend from '../plot/Legend.tsx'
4+import More from './More.tsx'
45 import { diverging, ink, series } from '../plot/palette.ts'
56 import { extent, linePath, padDomain, type Frame } from '../plot/scales.ts'
67 import type { ExploreOut, Num } from '../engine/types.ts'
@@ -64,9 +65,9 @@ export default function BlendingPanel({ out }: Props) {
6465 return (
6566 <div className="panel">
6667 <p className="panel-lede">
67- This script does not define <code>local_blend</code>, so there is nothing to draw here. Equations (4)
68- and (5) are optional: a script only has to supply <code>bary_weights</code> and{' '}
69- <code>bary_eval</code>. The other three tabs still work.
68+ This script does not define <code>local_blend</code>, so the construction cannot be drawn here.
69+ That is allowed: a script only has to supply <code>bary_weights</code> and <code>bary_eval</code>,
70+ and the other three tabs still work.
7071 </p>
7172 {out.blendError && <pre className="error-box">{out.blendError}</pre>}
7273 <WeightsSection out={out} />
@@ -88,11 +89,18 @@ export default function BlendingPanel({ out }: Props) {
8889 return (
8990 <div className="panel">
9091 <p className="panel-lede">
91- Equation (4) reads r = Σ<sub>i</sub> λ<sub>i</sub> p<sub>i</sub> / Σ<sub>i</sub>{' '}
92- λ<sub>i</sub>: slide a window of d+1 = {out.d + 1} nodes along the data, fit a polynomial of
93- degree {out.d} in each position, and blend the {m} of them together. Hover to follow one; click to pin
94- it.
92+ The interpolant is built from {m} simple pieces: a polynomial of degree {out.d} fitted to each run of{' '}
93+ {out.d + 1} neighbouring points, all blended into one smooth curve. Hover the plot to follow a single
94+ piece; click to pin it.
9595 </p>
96+ <More>
97+ <p>
98+ This is equation (4), r = Σ<sub>i</sub> λ<sub>i</sub> p<sub>i</sub> / Σ
99+ <sub>i</sub> λ<sub>i</sub>: each p<sub>i</sub> interpolates the d+1 points x<sub>i</sub>,
100+ …, x<sub>i+d</sub>, and the blending functions λ<sub>i</sub> of equation (5) carry the
101+ alternating signs that make the poles cancel.
102+ </p>
103+ </More>
96104
97105 <div className="row-controls">
98106 <label className="slider-label">
@@ -173,11 +181,17 @@ export default function BlendingPanel({ out }: Props) {
173181
174182 <h4 className="sub">Blending functions</h4>
175183 <p className="panel-note">
176- The normalised λ<sub>i</sub>, which sum to 1 at every x. Each one is close to 1 across its own
177- window and decays away from it, but with a tail that oscillates in sign and never quite reaches zero:
178- these functions have no local support, which the paper names as the price of the construction. What
179- they do have is that their denominator never vanishes, so they are infinitely smooth.
184+ How much each piece counts at each x. The blending functions sum to 1 everywhere, and each is largest
185+ over its own window.
180186 </p>
187+ <More>
188+ <p>
189+ Each normalised λ<sub>i</sub> decays away from its window, but with a tail that oscillates in
190+ sign and never quite reaches zero: these functions have no local support, which the paper names as
191+ the price of the construction. What they do have is that their common denominator never vanishes, so
192+ they are infinitely smooth.
193+ </p>
194+ </More>
181195 <Plot
182196 height={220}
183197 xDomain={xd}
@@ -239,10 +253,17 @@ function WeightsSection({ out }: { out: ExploreOut }) {
239253 <>
240254 <h4 className="sub">Barycentric weights</h4>
241255 <p className="panel-note">
242- The same interpolant, written in the form of equation (1) with the weights of equation (18). Schneider
243- and Werner proved that a pole-free barycentric rational interpolant must have weights that alternate
244- in sign; these {out.wAlternates ? 'do' : 'do not'}.
256+ The whole construction collapses to a single weight per node. Alternating signs are what pole-freedom
257+ requires, and these weights {out.wAlternates ? 'alternate' : 'do not alternate'}.
245258 </p>
259+ <More>
260+ <p>
261+ This is the barycentric form of equation (1) with the weights of equation (18), which is how the
262+ interpolant is actually evaluated. Schneider and Werner proved that a barycentric rational
263+ interpolant with no poles in the interval must have weights that alternate in sign, so the sign
264+ pattern here is not a coincidence.
265+ </p>
266+ </More>
246267 <Legend
247268 items={[
248269 { label: 'w_k > 0', color: diverging.pos },
src/panels/Controls.tsxmodified+37−25View file
@@ -1,3 +1,4 @@
1+import { useState } from 'react'
12 import type { FuncName, NodeKind } from '../engine/types.ts'
23
34 export interface Settings {
@@ -35,6 +36,7 @@ interface Props {
3536 }
3637
3738 export default function Controls({ value: v, onChange, showN, busy }: Props) {
39+ const [more, setMore] = useState(false)
3840 const dMax = Math.min(v.n, 12)
3941 return (
4042 <div className="controls">
@@ -67,31 +69,6 @@ export default function Controls({ value: v, onChange, showN, busy }: Props) {
6769 </div>
6870 )}
6971
70- <div className="field">
71- <span className="field-label">interval</span>
72- <div className="interval">
73- <input
74- type="number"
75- value={v.a}
76- step={1}
77- onChange={(e) => {
78- const a = Number(e.target.value)
79- if (isFinite(a) && a < v.b) onChange({ a })
80- }}
81- />
82- <span>to</span>
83- <input
84- type="number"
85- value={v.b}
86- step={1}
87- onChange={(e) => {
88- const b = Number(e.target.value)
89- if (isFinite(b) && b > v.a) onChange({ b })
90- }}
91- />
92- </div>
93- </div>
94-
9572 {showN && (
9673 <div className="field grow">
9774 <span className="field-label">
@@ -145,6 +122,41 @@ export default function Controls({ value: v, onChange, showN, busy }: Props) {
145122 )}
146123 </div>
147124 </div>
125+
126+ <button
127+ className="ghost more-toggle"
128+ onClick={() => setMore((m) => !m)}
129+ title="the interval [a, b]"
130+ >
131+ {more ? 'less ▴' : 'more ▾'}
132+ </button>
133+
134+ {more && (
135+ <div className="field">
136+ <span className="field-label">interval</span>
137+ <div className="interval">
138+ <input
139+ type="number"
140+ value={v.a}
141+ step={1}
142+ onChange={(e) => {
143+ const a = Number(e.target.value)
144+ if (isFinite(a) && a < v.b) onChange({ a })
145+ }}
146+ />
147+ <span>to</span>
148+ <input
149+ type="number"
150+ value={v.b}
151+ step={1}
152+ onChange={(e) => {
153+ const b = Number(e.target.value)
154+ if (isFinite(b) && b > v.a) onChange({ b })
155+ }}
156+ />
157+ </div>
158+ </div>
159+ )}
148160 </div>
149161 )
150162 }
src/panels/ConvergencePanel.tsxmodified+33−19View file
@@ -1,6 +1,8 @@
11 import Plot from '../plot/Plot.tsx'
22 import Legend, { type LegendItem } from '../plot/Legend.tsx'
3-import { dColor, ink, series } from '../plot/palette.ts'
3+import More from './More.tsx'
4+import SeriesToggle from './SeriesToggle.tsx'
5+import { dColor, series } from '../plot/palette.ts'
46 import { linePath, type Frame } from '../plot/scales.ts'
57 import type { ConvergeOut, FuncName, NodeKind, Num } from '../engine/types.ts'
68
@@ -42,12 +44,18 @@ export default function ConvergencePanel(props: Props) {
4244 return (
4345 <div className="panel">
4446 <p className="panel-lede">
45- Theorem 2: for d ≥ 1 the error is O(h<sup>d+1</sup>) as h → 0, whatever the nodes look like,
46- provided f is smooth enough. On log-log axes that is a straight line of slope −(d+1), and the
47- slopes measured between consecutive n are printed in the table. With uniform nodes and Runge's
48- function this reproduces Table 1; turn the spline on for Tables 3 and 4. Currently fitting{' '}
49- <b>{F_LABEL[f]}</b> on <b>{nodes}</b> nodes.
47+ Refit with more and more nodes and record the largest error each time. On these log-log axes a power
48+ law is a straight line, and steeper means faster convergence. Currently fitting <b>{F_LABEL[f]}</b>{' '}
49+ on <b>{nodes}</b> nodes.
5050 </p>
51+ <More>
52+ <p>
53+ Theorem 2: for d ≥ 1 the error is O(h<sup>d+1</sup>) as h → 0, whatever the nodes look
54+ like, provided f is smooth enough. On these axes that is a line of slope −(d+1), and the
55+ slopes measured between consecutive n are printed in the table. With uniform nodes and Runge's
56+ function this reproduces the paper's Table 1; turn the spline on for Tables 3 and 4.
57+ </p>
58+ </More>
5159
5260 <div className="conv-controls">
5361 <div className="field">
@@ -83,20 +91,22 @@ export default function ConvergencePanel(props: Props) {
8391 <div className="field">
8492 <span className="field-label">compare with</span>
8593 <div className="chips">
86- <button
87- className={`chip ${showSpline ? 'on' : ''}`}
88- onClick={() => onChange({ showSpline: !showSpline })}
94+ <SeriesToggle
95+ color={series.spline}
96+ on={showSpline}
8997 disabled={running}
98+ onChange={(on) => onChange({ showSpline: on })}
9099 >
91100 cubic spline
92- </button>
93- <button
94- className={`chip ${showPoly ? 'on' : ''}`}
95- onClick={() => onChange({ showPoly: !showPoly })}
101+ </SeriesToggle>
102+ <SeriesToggle
103+ color={series.poly}
104+ on={showPoly}
96105 disabled={running}
106+ onChange={(on) => onChange({ showPoly: on })}
97107 >
98108 polynomial
99- </button>
109+ </SeriesToggle>
100110 </div>
101111 </div>
102112 <button className="primary" onClick={onRun} disabled={running}>
@@ -259,12 +269,16 @@ function ConvergenceChart({
259269 </tbody>
260270 </table>
261271 </div>
262- <p className="panel-note" style={{ color: ink.muted }}>
263- The order column is log(e<sub>prev</sub> / e) / log(n / n<sub>prev</sub>), so d + 1 is what Theorem 2
264- predicts for d ≥ 1. Where a row of errors stops falling, it has reached the point at which the
265- weights themselves, which grow like h<sup>−d</sup>, cost more accuracy than the higher order
266- buys.
272+ <p className="panel-note muted">
273+ The order column is the slope measured between consecutive rows; Theorem 2 predicts d + 1.
267274 </p>
275+ <More>
276+ <p>
277+ The order is log(e<sub>prev</sub> / e) / log(n / n<sub>prev</sub>). Where a row of errors stops
278+ falling, it has reached the point at which the weights themselves, which grow like h
279+ <sup>−d</sup>, cost more accuracy than the higher order buys.
280+ </p>
281+ </More>
268282 </>
269283 )
270284 }
src/panels/InterpolantPanel.tsxmodified+47−33View file
@@ -1,6 +1,8 @@
11 import { useState } from 'react'
22 import Plot from '../plot/Plot.tsx'
33 import Legend, { type LegendItem } from '../plot/Legend.tsx'
4+import More from './More.tsx'
5+import SeriesToggle from './SeriesToggle.tsx'
46 import { ink, series } from '../plot/palette.ts'
57 import { extent, linePath, nearestIndex, padDomain, type Frame } from '../plot/scales.ts'
68 import type { ExploreOut, Num } from '../engine/types.ts'
@@ -82,20 +84,27 @@ export default function InterpolantPanel({ out, showPoly, showSpline, onToggle }
8284 return (
8385 <div className="panel">
8486 <p className="panel-lede">
85- The rational interpolant r of equation (1) through {out.n + 1} nodes, with blend degree d = {out.d}.
86- Turn on the degree-{out.n} polynomial to see what the paper's first page is about, and the clamped
87- C<sup>2</sup> cubic spline for the comparison of Tables 3 and 4.
87+ The rational interpolant r passes exactly through all {out.n + 1} data points and stays close to the
88+ function f everywhere in between. The controls above change the data; the buttons below add two
89+ standard alternatives fitted to the same points.
8890 </p>
91+ <More>
92+ <p>
93+ This is r of equation (1) with the weights of equation (18), using blend degree d = {out.d}. The
94+ degree-{out.n} polynomial through the same points is the paper's opening example: on equally spaced
95+ nodes it diverges as n grows, which is Runge's phenomenon. The clamped C<sup>2</sup> cubic spline is
96+ the standard the paper measures itself against in Tables 3 and 4.
97+ </p>
98+ </More>
8999
90100 <div className="row-controls">
91- <label className="check">
92- <input type="checkbox" checked={showPoly} onChange={(e) => onToggle('poly', e.target.checked)} />
93- polynomial interpolant
94- </label>
95- <label className="check">
96- <input type="checkbox" checked={showSpline} onChange={(e) => onToggle('spline', e.target.checked)} />
101+ <span className="field-label">compare with</span>
102+ <SeriesToggle color={series.poly} on={showPoly} onChange={(on) => onToggle('poly', on)}>
103+ polynomial (degree {out.n})
104+ </SeriesToggle>
105+ <SeriesToggle color={series.spline} on={showSpline} onChange={(on) => onToggle('spline', on)}>
97106 cubic spline
98- </label>
107+ </SeriesToggle>
99108 </div>
100109
101110 <Legend items={legend} />
@@ -124,34 +133,39 @@ export default function InterpolantPanel({ out, showPoly, showSpline, onToggle }
124133 )}
125134 </Plot>
126135
127- {hi >= 0 && (
128- <div className="readout">
129- <span>
130- x = <b>{out.t[hi].toFixed(3)}</b>
131- </span>
132- <span style={{ color: ink.reference }}>
133- f = <b>{out.ft[hi].toFixed(6)}</b>
134- </span>
135- <span style={{ color: series.r }}>
136- r = <b>{at(out.r)?.toFixed(6) ?? '-'}</b>
137- </span>
138- {showPoly && (
139- <span style={{ color: series.poly }}>
140- poly = <b>{fmtSigned(at(out.rpoly))}</b>
136+ {/* always rendered, so the error plot below never jumps when hovering */}
137+ <div className="readout">
138+ {hi >= 0 ? (
139+ <>
140+ <span>
141+ x = <b>{out.t[hi].toFixed(3)}</b>
142+ </span>
143+ <span style={{ color: ink.reference }}>
144+ f = <b>{out.ft[hi].toFixed(6)}</b>
141145 </span>
142- )}
143- {showSpline && (
144- <span style={{ color: series.spline }}>
145- spline = <b>{at(out.rspline)?.toFixed(6) ?? '-'}</b>
146+ <span style={{ color: series.r }}>
147+ r = <b>{at(out.r)?.toFixed(6) ?? '-'}</b>
146148 </span>
147- )}
148- </div>
149- )}
149+ {showPoly && (
150+ <span style={{ color: series.poly }}>
151+ poly = <b>{fmtSigned(at(out.rpoly))}</b>
152+ </span>
153+ )}
154+ {showSpline && (
155+ <span style={{ color: series.spline }}>
156+ spline = <b>{at(out.rspline)?.toFixed(6) ?? '-'}</b>
157+ </span>
158+ )}
159+ </>
160+ ) : (
161+ <span className="readout-hint">hover a plot to read values</span>
162+ )}
163+ </div>
150164
151165 <h4 className="sub">Error</h4>
152166 <p className="panel-note">
153- r(x) − f(x) on the same grid. It vanishes at every node, by construction, and the largest of
154- the bumps between them is the number Tables 1 to 4 tabulate.
167+ The difference r − f. It is zero at every node by construction; the largest bump between nodes
168+ is the number the paper's tables report.
155169 </p>
156170 <Plot
157171 height={190}
src/panels/More.tsxadded+15−0View file
@@ -0,0 +1,15 @@
1+import type { ReactNode } from 'react'
2+
3+/**
4+ * A closed-by-default disclosure holding the paper-level detail: equation and
5+ * theorem numbers, table references, the finer print. The one plain sentence
6+ * that precedes it is what a first-time visitor reads.
7+ */
8+export default function More({ label = 'from the paper', children }: { label?: string; children: ReactNode }) {
9+ return (
10+ <details className="more">
11+ <summary>{label}</summary>
12+ <div className="more-body">{children}</div>
13+ </details>
14+ )
15+}
src/panels/PolesPanel.tsxmodified+43−22View file
@@ -1,6 +1,8 @@
11 import { useState } from 'react'
22 import Plot from '../plot/Plot.tsx'
33 import Legend from '../plot/Legend.tsx'
4+import More from './More.tsx'
5+import SeriesToggle from './SeriesToggle.tsx'
46 import { ink, series, status } from '../plot/palette.ts'
57 import { extent, linePath, padDomain, type Frame } from '../plot/scales.ts'
68 import type { ExploreOut } from '../engine/types.ts'
@@ -58,18 +60,30 @@ export default function PolesPanel({ out, showClassical, onToggleClassical }: Pr
5860 </div>
5961
6062 <p className="panel-lede">
61- Writing r as a quotient of polynomials (equation 7) puts everything on the denominator s of equation
62- (10). Its zeros are exactly the poles of r, so Theorem 1 amounts to the claim that s never crosses
63- zero.
63+ A rational function can only blow up where its denominator crosses zero, so everything on this tab
64+ watches the denominator of r.
6465 </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>
6573
6674 <h4 className="sub">The denominator on the real line</h4>
6775 <p className="panel-note">
68- s(x) spans many orders of magnitude, so what is drawn is the signed n-th root of it. That leaves every
69- sign and every zero exactly where it was, and brings the rest into a range that fits on a page. The
70- range shown runs well past both ends of the interpolation interval, since Theorem 1 is a statement
71- about all of <b>R</b>, not just [a, b].
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.
7278 </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>
7387 <Plot
7488 height={200}
7589 xDomain={xd}
@@ -133,11 +147,16 @@ export default function PolesPanel({ out, showClassical, onToggleClassical }: Pr
133147 {p.rootsShown ? (
134148 <>
135149 <p className="panel-note">
136- The {p.rootsRe.length} roots of s in the complex plane. Theorem 1 says none of them are real, so
137- none of them touch the horizontal axis. The grey band is the interpolation interval; ticks on the
138- axis are the nodes.
139- {nOnAxis > 0 && ' Roots that have landed on the axis are marked with a cross.'}
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.'}
140153 </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>
141160 <Legend
142161 items={[
143162 { label: 'root of s', color: series.r, shape: 'dot' },
@@ -229,20 +248,22 @@ export default function PolesPanel({ out, showClassical, onToggleClassical }: Pr
229248
230249 <h4 className="sub">The classical alternative</h4>
231250 <div className="row-controls">
232- <label className="check">
233- <input
234- type="checkbox"
235- checked={showClassical}
236- onChange={(e) => onToggleClassical(e.target.checked)}
237- />
238- fit p<sub>M</sub> / q<sub>N</sub> with M + N = n
239- </label>
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>
240255 </div>
241256 <p className="panel-note">
242- The construction the paper's introduction rejects: fit the same data with a quotient of polynomials of
243- degrees M and N summing to n. It is a good approximation where it is finite, and there is no way to
244- stop it putting poles wherever it likes.
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.
245259 </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>
246267 {showClassical && p.classical ? (
247268 <>
248269 <Legend
src/panels/SeriesToggle.tsxadded+30−0View file
@@ -0,0 +1,30 @@
1+import type { CSSProperties, ReactNode } from 'react'
2+
3+interface Props {
4+ /** the series colour from the palette; the swatch and the on-state carry it */
5+ color: string
6+ on: boolean
7+ onChange: (on: boolean) => void
8+ disabled?: boolean
9+ children: ReactNode
10+}
11+
12+/**
13+ * A toggle for drawing an extra curve. The swatch shows the colour the curve
14+ * will have before it is turned on, so the button reads as "add this series"
15+ * rather than as an anonymous checkbox.
16+ */
17+export default function SeriesToggle({ color, on, onChange, disabled, children }: Props) {
18+ return (
19+ <button
20+ className={`chip tone ${on ? 'on' : ''}`}
21+ style={{ '--tone': color } as CSSProperties}
22+ aria-pressed={on}
23+ disabled={disabled}
24+ onClick={() => onChange(!on)}
25+ >
26+ <span className="swatch" aria-hidden="true" />
27+ {children}
28+ </button>
29+ )
30+}