1import { useMemo, useState } from 'react'
2import Plot from '../plot/Plot.tsx'
3import Legend from '../plot/Legend.tsx'
4import { diverging, ink, series } from '../plot/palette.ts'
5import { extent, linePath, padDomain, type Frame } from '../plot/scales.ts'
6import type { ExploreOut, Num } from '../engine/types.ts'
8interface Props {
9 out: ExploreOut
10}
12/** The samples of t lying in [lo, hi], as a slice of both arrays. */
13function slice(t: number[], v: readonly Num[], lo: number, hi: number): [number[], Num[]] {
14 const ts: number[] = []
15 const vs: Num[] = []
16 for (let i = 0; i < t.length; i++) {
17 if (t[i] >= lo && t[i] <= hi) {
18 ts.push(t[i])
19 vs.push(v[i])
20 }
21 }
22 return [ts, vs]
23}
25export default function BlendingPanel({ out }: Props) {
26 const [pinned, setPinned] = useState<number | null>(null)
27 const [hoverX, setHoverX] = useState<number | null>(null)
29 const P = out.P
30 const L = out.L
31 const wlo = out.wlo
32 const whi = out.whi
33 const m = P?.length ?? 0
35 // which local polynomial the reader is following: the pinned one, or the one
36 // whose window is nearest the pointer
37 const hovered = useMemo(() => {
38 if (!wlo || !whi || hoverX == null) return null
39 let best = 0
40 let bestD = Infinity
41 for (let i = 0; i < wlo.length; i++) {
42 const dd = Math.abs((wlo[i] + whi[i]) / 2 - hoverX)
43 if (dd < bestD) {
44 bestD = dd
45 best = i
46 }
47 }
48 return best
49 }, [wlo, whi, hoverX])
50 const sel = pinned ?? hovered ?? Math.floor(m / 2)
52 const xd: [number, number] = [out.t[0], out.t[out.t.length - 1]]
53 const yd = padDomain(extent(out.ft, out.r), 0.12)
54 const ld = useMemo(() => {
55 if (!L) return [0, 1] as [number, number]
56 const e = extent(...L)
57 return [Math.max(-1.2, Math.min(-0.15, e[0] * 1.1)), Math.min(1.6, Math.max(1.05, e[1] * 1.05))] as [
58 number,
59 number,
60 ]
61 }, [L])
63 if (!out.hasBlend || !P || !L || !wlo || !whi) {
64 return (
65 <div className="panel">
66 <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.
70 </p>
71 {out.blendError && <pre className="error-box">{out.blendError}</pre>}
72 <WeightsSection out={out} />
73 </div>
74 )
75 }
77 const shade = (f: Frame) => (
78 <rect
79 x={f.sx(wlo[sel])}
80 width={Math.max(1, f.sx(whi[sel]) - f.sx(wlo[sel]))}
81 y={0}
82 height={f.ih}
83 fill={ink.familyHi}
84 opacity={0.1}
85 />
86 )
88 return (
89 <div className="panel">
90 <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.
95 </p>
97 <div className="row-controls">
98 <label className="slider-label">
99 <span>
100 window i = <b>{sel}</b> [x<sub>{sel}</sub>, x<sub>{sel + out.d}</sub>] = [
101 {wlo[sel].toFixed(2)}, {whi[sel].toFixed(2)}]
102 </span>
103 <input
104 type="range"
105 min={0}
106 max={m - 1}
107 value={sel}
108 onChange={(e) => setPinned(Number(e.target.value))}
109 />
110 </label>
111 {pinned != null && (
112 <button className="ghost" onClick={() => setPinned(null)}>
113 unpin
114 </button>
115 )}
116 </div>
118 <Legend
119 items={[
120 { label: 'f(x)', color: ink.reference, dash: '5 4' },
121 { label: 'rational r(x)', color: series.r },
122 { label: `the ${m} local polynomials p_i`, color: ink.family },
123 { label: `p_${sel}, on its own window`, color: ink.familyHi },
124 ]}
125 />
127 <Plot
128 height={300}
129 xDomain={xd}
130 yDomain={yd}
131 xLabel="x"
132 yLabel="f, r, local p_i"
133 onHoverX={setHoverX}
134 hoverX={hoverX}
135 under={shade}
136 >
137 {(f) => (
138 <>
139 {P.map((row, i) => {
140 // each p_i is only drawn a little beyond the d+1 points it
141 // interpolates: a degree-d polynomial extrapolated across the
142 // whole interval is a wall of noise
143 const pad = Math.max((whi[i] - wlo[i]) * 0.35, (xd[1] - xd[0]) / (out.n * 2))
144 const [ts, vs] = slice(out.t, row, wlo[i] - pad, whi[i] + pad)
145 return i === sel ? null : (
146 <path key={i} d={linePath(ts, vs, f)} fill="none" stroke={ink.family} strokeWidth={1.2} />
147 )
148 })}
149 <path d={linePath(out.t, out.ft, f)} fill="none" stroke={ink.reference} strokeWidth={2} strokeDasharray="5 4" />
150 <path d={linePath(out.t, out.r, f)} fill="none" stroke={series.r} strokeWidth={2.5} />
151 {(() => {
152 const pad = (whi[sel] - wlo[sel]) * 0.9 + (xd[1] - xd[0]) / (out.n * 2)
153 const [ts, vs] = slice(out.t, P[sel], wlo[sel] - pad, whi[sel] + pad)
154 return <path d={linePath(ts, vs, f)} fill="none" stroke={ink.familyHi} strokeWidth={2.5} />
155 })()}
156 {out.x.map((xv, i) => {
157 const inWin = xv >= wlo[sel] - 1e-12 && xv <= whi[sel] + 1e-12
158 return (
159 <circle
160 key={i}
161 cx={f.sx(xv)}
162 cy={f.sy(out.y[i])}
163 r={inWin ? 4.2 : 3}
164 fill={inWin ? ink.familyHi : ink.node}
165 stroke="#151a21"
166 strokeWidth={1.5}
167 />
168 )
169 })}
170 </>
171 )}
172 </Plot>
174 <h4 className="sub">Blending functions</h4>
175 <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.
180 </p>
181 <Plot
182 height={220}
183 xDomain={xd}
184 yDomain={ld}
185 xLabel="x"
186 yLabel="normalised lambda_i"
187 onHoverX={setHoverX}
188 hoverX={hoverX}
189 under={(f) => (
190 <>
191 {shade(f)}
192 <line x1={0} x2={f.iw} y1={f.sy(0)} y2={f.sy(0)} stroke={ink.axis} strokeWidth={1} />
193 <line
194 x1={0}
195 x2={f.iw}
196 y1={f.sy(1)}
197 y2={f.sy(1)}
198 stroke={ink.axis}
199 strokeWidth={1}
200 strokeDasharray="2 4"
201 />
202 </>
203 )}
204 >
205 {(f) => (
206 <>
207 {L.map((row, i) =>
208 i === sel ? null : (
209 <path key={i} d={linePath(out.t, row, f)} fill="none" stroke={ink.family} strokeWidth={1.2} />
210 ),
211 )}
212 <path d={linePath(out.t, L[sel], f)} fill="none" stroke={ink.familyHi} strokeWidth={2.5} />
213 {out.x.map((xv, i) => (
214 <line
215 key={i}
216 x1={f.sx(xv)}
217 x2={f.sx(xv)}
218 y1={f.ih}
219 y2={f.ih - 5}
220 stroke={ink.node}
221 strokeWidth={1.5}
222 />
223 ))}
224 </>
225 )}
226 </Plot>
228 <WeightsSection out={out} />
229 </div>
230 )
231}
233function WeightsSection({ out }: { out: ExploreOut }) {
234 const wmax = Math.max(...out.w.map(Math.abs), Number.MIN_VALUE)
235 const norm = out.w.map((v) => v / wmax)
236 const spread = Math.max(...out.wscaled) / Math.min(...out.wscaled.filter((v) => v > 0))
238 return (
239 <>
240 <h4 className="sub">Barycentric weights</h4>
241 <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'}.
245 </p>
246 <Legend
247 items={[
248 { label: 'w_k > 0', color: diverging.pos },
249 { label: 'w_k < 0', color: diverging.neg },
250 ]}
251 />
252 <Plot
253 height={170}
254 xDomain={[out.x[0], out.x[out.x.length - 1]]}
255 yDomain={[-1.15, 1.15]}
256 xLabel="x_k"
257 yLabel="w_k / max |w|"
258 margin={{ l: 62 }}
259 under={(f) => <line x1={0} x2={f.iw} y1={f.sy(0)} y2={f.sy(0)} stroke={ink.axis} strokeWidth={1} />}
260 >
261 {(f) =>
262 norm.map((v, k) => (
263 <g key={k}>
264 <line
265 x1={f.sx(out.x[k])}
266 x2={f.sx(out.x[k])}
267 y1={f.sy(0)}
268 y2={f.sy(v)}
269 stroke={v >= 0 ? diverging.pos : diverging.neg}
270 strokeWidth={2}
271 />
272 <circle
273 cx={f.sx(out.x[k])}
274 cy={f.sy(v)}
275 r={3}
276 fill={v >= 0 ? diverging.pos : diverging.neg}
277 stroke="#151a21"
278 strokeWidth={1}
279 />
280 </g>
281 ))
282 }
283 </Plot>
285 {out.wIsInteger ? (
286 <div className="weights-int">
287 <div className="weights-int-head">
288 δ<sub>k</sub> = |w<sub>k</sub>| / min |w|, which Section 4 predicts are integers on a uniform
289 mesh:
290 </div>
291 <div className="weights-int-values">
292 {out.wscaled.map((v, k) => (
293 <span key={k}>{Math.round(v)}</span>
294 ))}
295 </div>
296 </div>
297 ) : (
298 <div className="weights-int">
299 <div className="weights-int-head">
300 The weights are not integer multiples of the smallest one, so this is not a uniform mesh. Their
301 magnitudes span a factor of {spread < 1e5 ? spread.toFixed(0) : spread.toExponential(1)}.
302 </div>
303 </div>
304 )}
305 </>
306 )
307}