Polynomial Interpolation
Computational Math
Interpolation
Parametric Curves
Compare piecewise linear and cubic spline interpolation through a set of points.
Author
Apurva Nakade
Published
July 13, 2026
urlParams = new URLSearchParams(window.location.search)
initialFText = urlParams.get("f") ?? "sin(x)"
initialA0 = urlParams.get("a") ?? "0"
initialB0 = urlParams.get("b") ?? "2*pi"
initialMaxNText = urlParams.get("max") ?? "40"
initialN = {
const raw = Number(urlParams.get("n"))
return Number.isInteger(raw) && raw >= 1 ? raw : 10
}
interpolantOptions = ["Original curve", "Linear interpolant", "Cubic spline interpolant", "Interpolation nodes"]
// Matches the line colors mainPlot uses for each trace, so the overlay
// legend's swatches double as a color key.
// vmTheme is what makes the colors below reactive. The chart's own chrome
// (background, axes, gridlines) repaints on a dark-mode toggle by itself,
// via the relayout patch in plotly-fullscreen-button.js -- but the *trace*
// colors are values this page bakes into the traces it builds, so nothing
// repaints them unless mainPlot re-runs. Depending on vmTheme is what
// re-runs it. See VM.plotting.onThemeChange for why it fires a couple of
// frames after the toggle rather than inline.
vmTheme = Generators.observe(notify => VM.plotting.onThemeChange(notify))
chartColors = VM.plotting.colors(vmTheme)
interpolantColors = new Map([
["Original curve", chartColors.fn],
["Linear interpolant", chartColors.alt],
["Cubic spline interpolant", chartColors.ok],
["Interpolation nodes", chartColors.ink]
])
initialInterpolants = {
const raw = urlParams.get("show")
const all = interpolantOptions
// Missing means the default (all); present but empty ("show=") is what the
// URL sync writes when the reader hid every trace, so keep them hidden.
if (raw === null) return all
if (raw.trim() === "") return []
const map = {
original: "Original curve",
f: "Original curve",
linear: "Linear interpolant",
cubic: "Cubic spline interpolant",
spline: "Cubic spline interpolant",
nodes: "Interpolation nodes"
}
const chosen = []
const seen = new Set()
for (const part of raw.split(",")) {
const key = part.trim().toLowerCase()
const label = map[key]
if (label && !seen.has(label)) {
chosen.push(label)
seen.add(label)
}
}
return chosen.length ? chosen : all
}// The page's examples and method live in method.js (the fit is the
// library's VM.numerical.orderFromErrorsVsN), so
// method.test.js can run every example and check what the convergence plot
// reports under npm test.
import {examples, interpolantErrors, cubicSpline, arcLengthErrors} from "./method.js"// Plot commits the current function/endpoint fields — edits above don't
// take effect until this is clicked, so result/mainPlot don't recompute on
// every keystroke. The n slider and the interpolant legend overlaying the
// chart are unaffected and stay reactive.
viewof plotTrigger = {
const plotButton = Inputs.button("Plot", {value: 0, reduce: v => v + 1})
plotButton.classList.add("ojs-auto")
return plotButton
}// Always-live (not gated behind Plot) — it bounds the step slider rather
// than feeding result, so there's no reason to wait for a commit. Shares
// this row with the commit-gated fields now that a shorter label leaves
// room; see urlSyncControls below for how it stays in the URL.
viewof maxNInput = {
const input = Inputs.text({value: initialMaxNText, label: "Max n", placeholder: "Example: 20, 5*4"})
input.dataset.exampleField = "max"
return input
}// Plain CSS selectors, not direct viewof references -- see
// js/ui/apply-example.js's comment for why (nControl's view is reactively
// recreated whenever maxN changes, so referencing it by name here would
// make this cell -- and therefore applyExampleFromSelect -- reactive on
// it, forming a feedback loop with the fact that applying an example is
// what changes maxN in the first place).
exampleFieldSelectors = ({
f: '[data-example-field="f"]',
a: '[data-example-field="a"]',
b: '[data-example-field="b"]',
max: '[data-example-field="max"]',
n: '[data-example-field="n"]'
})// Applies the selected example's params to the fields above (including the
// n/max fields overlaid on the chart below) and clicks Plot, in place — no
// page reload, so no scroll jump either. A plain "change" listener attached
// once to the stable native <select> (rather than an OJS cell reactive on
// exampleSelect's value) — a <select> fires both "input" and "change" per
// selection, and a reactive cell would run once per event, with two
// overlapping calls racing each other.
applyExampleFromSelect = {
const nativeSelect = (viewof exampleSelect).querySelector("select")
if (!nativeSelect) return
nativeSelect.addEventListener("change", () => {
const example = (viewof exampleSelect).value
if (!example) return
VM.ui.applyExampleParams(exampleFieldSelectors, example.params, viewof plotTrigger)
})
}// Keeps the dropdown naming the example the fields above hold ("Custom
// inputs" once none does). Reads every example field's value so it re-runs
// whenever one changes; it only sets the dropdown, never a field.
syncExampleFromFields = {
fText; a0; b0; maxNInput; nControl
VM.ui.syncExampleSelect(viewof exampleSelect, examples, exampleFieldSelectors)
}// Pressing Enter in the function/endpoint fields clicks Plot instead of
// submitting the field's own (invisible) form. Depends only on the stable
// views, so this wiring runs once and is never re-attached.
enterToPlot = {
for (const view of [viewof fText, viewof a0, viewof b0]) {
const input = view.querySelector("input")
if (!input) continue
input.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return
e.preventDefault()
viewof plotTrigger.querySelector("button").click()
})
}
}// committed snapshots fText/a0/b0 only when Plot is clicked, and writes them
// back into the URL so the address bar stays a shareable link. It reads
// `viewof fText`/`viewof a0`/`viewof b0` (the stable DOM views) rather than
// `fText`/`a0`/`b0` (the reactive values), so it does NOT rerun on every
// keystroke — only when `plotTrigger` changes. It still runs once on
// initial page load, so the first render uses the URL-provided (or
// default) values with no click.
committed = {
plotTrigger
const math = window.math
const fVal = (viewof fText).value
const aText = (viewof a0).value
const bText = (viewof b0).value
const aVal = VM.expressions.makeNumber(math, aText)
const bVal = VM.expressions.makeNumber(math, bText)
const params = new URLSearchParams(window.location.search)
params.set("f", fVal)
params.set("a", aText)
params.set("b", bText)
params.set("n", String(nControl))
params.set("max", maxNInput)
params.set(
"show",
interpolants.map(label => {
if (label === "Original curve") return "original"
if (label === "Linear interpolant") return "linear"
if (label === "Cubic spline interpolant") return "cubic"
if (label === "Interpolation nodes") return "nodes"
return label
}).join(",")
)
history.replaceState(null, "", `${window.location.pathname}?${params}`)
return {fText: fVal, a0: aVal, b0: bVal}
}// Keep n/max/interpolant changes in the URL too. Unlike f/a/b, these
// controls are reactive immediately, so their URL state should update
// immediately without requiring Plot.
urlSyncControls = {
const params = new URLSearchParams(window.location.search)
params.set("f", (viewof fText).value)
params.set("a", (viewof a0).value)
params.set("b", (viewof b0).value)
params.set("n", String(nControl))
params.set("max", maxNInput)
params.set(
"show",
interpolants.map(label => {
if (label === "Original curve") return "original"
if (label === "Linear interpolant") return "linear"
if (label === "Cubic spline interpolant") return "cubic"
if (label === "Interpolation nodes") return "nodes"
return label
}).join(",")
)
params.set("k", String(kControl))
// basis= used to toggle the basis chart's own legend; drop it from old links.
params.delete("basis")
history.replaceState(null, "", `${window.location.pathname}?${params}`)
}// result: for the current committed function/endpoints, builds the arc-length
// history used by the convergence plots and tables (see arcLengthErrors in
// method.js for the shape of rows).
// f — parsed function, reused for curve sampling in setup
result = {
const math = window.math
const f = VM.expressions.makeFunction(math, committed.fText)
const fallback = x => NaN
if (!f) return {rows: [], f: fallback, trueArcLength: NaN}
const a = committed.a0
const b = committed.b0
if (!Number.isFinite(a) || !Number.isFinite(b) || a === b) return {rows: [], f, trueArcLength: NaN}
const {rows, trueArcLength} = arcLengthErrors(f, Math.min(a, b), Math.max(a, b), maxN)
return {rows, f, trueArcLength}
}// setup computes the data needed by the main chart at the current n: the
// interpolation nodes, the linear polyline, a dense sample of the cubic
// spline, and the axis ranges / smooth f(x) curve.
setup = {
if (result.rows.length === 0) {
const empty = {lo: -3, hi: 3}
return {n: 0, nodesX: [], nodesY: [], linearX: [], linearY: [], cubicX: [], cubicY: [], sampleXs: [], sampleYs: [], xRange: empty, xBuffer: empty, yRange: {lo: -1, hi: 1}}
}
const n = Math.min(Math.max(Math.round(Number(nControl)), 1), result.rows.length)
const a = committed.a0
const b = committed.b0
const lo = Math.min(a, b)
const hi = Math.max(a, b)
// Interpolation nodes for the current n.
const nodesX = []
const nodesY = []
for (let k = 0; k <= n; k++) {
const x = lo + (hi - lo) * k / n
nodesX.push(x)
nodesY.push(result.f(x))
}
// Linear interpolant is just the node polyline.
const linearX = nodesX.slice()
const linearY = nodesY.slice()
// Cubic spline: dense samples for a smooth curve.
const spline = cubicSpline(nodesX, nodesY)
const cubicX = []
const cubicY = []
if (spline) {
const sampleCount = Math.max(200, n * 20)
for (let k = 0; k <= sampleCount; k++) {
const x = lo + (hi - lo) * k / sampleCount
cubicX.push(x)
cubicY.push(spline(x))
}
}
const xRange = VM.plotting.paddedRange([lo, hi], {emptyRange: [-3, 3], relativePadding: 0.1, minPadding: 0.25})
// A wider domain (double the padding of xRange) so the curve is already
// sampled beyond the initial view — panning/zooming out reveals more of
// the curve instead of blank canvas. The initial view stays xRange.
const xBuffer = VM.plotting.paddedRange([lo, hi], {emptyRange: [-6, 6], relativePadding: 0.25, minPadding: 0.5})
// Sample f across the buffer range for the smooth curve trace.
const sampleCount = 700
const sampleXs = Array.from({length: sampleCount}, (_, i) => xBuffer.lo + (xBuffer.hi - xBuffer.lo) * i / (sampleCount - 1))
const sampleYs = []
for (const x of sampleXs) {
const y = result.f(x)
if (Number.isFinite(y) && Math.abs(y) < 1e8) {
sampleYs.push(y)
} else {
sampleYs.push(null)
}
}
// y-range from f over [lo, hi] only, not the n-dependent nodes and
// splines, so moving the n slider never rescales the axis.
const yValues = []
for (let i = 0; i < sampleXs.length; i++) {
const x = sampleXs[i]
if (x >= xRange.lo && x <= xRange.hi && sampleYs[i] !== null) yValues.push(sampleYs[i])
}
const yRange = VM.plotting.paddedRange(yValues, {emptyRange: [-1, 1], relativePadding: 0.25, minPadding: 0.5})
return {n, nodesX, nodesY, linearX, linearY, cubicX, cubicY, sampleXs, sampleYs, xRange, xBuffer, yRange}
}// mainPlot draws f(x) and, depending on the checkboxes, the linear and/or
// cubic spline interpolant through the current n's nodes.
//
// _plotDiv is kept in a closure so Plotly reuses the same DOM node across
// reactive updates — this preserves zoom and pan when n or the checkboxes
// change.
mainPlot = {
const Plotly = window.Plotly
let _plotDiv = null
return (data, showOriginal, showLinear, showCubic, showNodes) => {
const traces = []
if (showOriginal) {
traces.push({x: data.sampleXs, y: data.sampleYs, type: "scatter", mode: "lines", name: "f(x)", showlegend: false, line: {color: chartColors.fn, width: 3}})
}
if (showLinear && data.linearX.length > 0) {
traces.push({x: data.linearX, y: data.linearY, type: "scatter", mode: "lines", name: "Linear interpolant", showlegend: false, line: {color: chartColors.alt, width: 2.5}})
}
if (showCubic && data.cubicX.length > 0) {
traces.push({x: data.cubicX, y: data.cubicY, type: "scatter", mode: "lines", name: "Cubic spline interpolant", showlegend: false, line: {color: chartColors.ok, width: 2.5}})
}
if (showNodes && data.nodesX.length > 0) {
traces.push({x: data.nodesX, y: data.nodesY, type: "scatter", mode: "markers", name: "Interpolation nodes", showlegend: false, marker: {color: chartColors.ink, size: 8, symbol: "circle", line: {color: chartColors.halo, width: 1}}})
}
const layout = {
xaxis: {title: "x", range: [data.xRange.lo, data.xRange.hi], zeroline: true},
yaxis: {title: "y", range: [data.yRange.lo, data.yRange.hi], zeroline: true},
hovermode: "closest",
uirevision: "static", // keeps zoom/pan state across reactive updates
autosize: true
}
const config = VM.plotting.config()
// First render: create the div. Later renders: update in place (preserves zoom).
if (!_plotDiv) {
_plotDiv = document.createElement("div")
_plotDiv.className = "plotly-box-large"
Plotly.newPlot(_plotDiv, traces, layout, config)
// newPlot fires before the div is in the DOM, so Plotly measures 0 width.
// A ResizeObserver catches the real size once it's inserted and laid out,
// and keeps correcting it on any later layout/viewport change.
VM.plotting.autoResize(_plotDiv)
} else {
Plotly.react(_plotDiv, traces, layout, config)
}
return _plotDiv
}
}// basis samples the terms y_k * phi_k(x) of both interpolants for the
// current nodes. phi_k is the cardinal function: the interpolant of the data
// (0, ..., 0, 1, 0, ..., 0) with the 1 at node k, so phi_k(x_k) = 1 and
// phi_k(x_j) = 0 at every other node. Each interpolant is the sum of its
// terms, p(x) = sum_k y_k phi_k(x), and term k passes through (x_k, y_k).
// hats[k] — {x, y}: y_k times the piecewise-linear phi_k, a tent on
// [x_{k-1}, x_{k+1}] and zero elsewhere
// cardinals[k] — {x, y}: y_k times the natural-spline phi_k, sampled over
// all of [a, b] (it is nonzero almost everywhere)
// yLo, yHi — extremes over every term of each family, for the y-axis
basis = {
const n = setup.nodesX.length - 1
const hats = []
const cardinals = []
const hatRange = {lo: 0, hi: 0}
const cardinalRange = {lo: 0, hi: 0}
if (n < 1) return {hats, cardinals, hatRange, cardinalRange}
const lo = setup.nodesX[0]
const hi = setup.nodesX[n]
// About 30 samples per subinterval keeps each term smooth, but there are
// n + 1 terms, so that alone grows like n^2 points. Cap the total at
// pointBudget so a large Max n can't stall the page; past the cap the
// terms are too crowded to read individually anyway.
const pointBudget = 150000
const sampleCount = Math.max(50, Math.min(Math.max(300, n * 30), Math.floor(pointBudget / (n + 1))))
for (let k = 0; k <= n; k++) {
let yk = setup.nodesY[k]
if (!Number.isFinite(yk)) yk = 0
if (yk < hatRange.lo) hatRange.lo = yk
if (yk > hatRange.hi) hatRange.hi = yk
// Hat: zero up to the previous node, y_k at node k, zero from the next.
const hatX = []
const hatY = []
if (k > 1) {
hatX.push(lo)
hatY.push(0)
}
if (k > 0) {
hatX.push(setup.nodesX[k - 1])
hatY.push(0)
}
hatX.push(setup.nodesX[k])
hatY.push(yk)
if (k < n) {
hatX.push(setup.nodesX[k + 1])
hatY.push(0)
}
if (k < n - 1) {
hatX.push(hi)
hatY.push(0)
}
hats.push({x: hatX, y: hatY})
// Spline: the natural cubic spline through the k-th unit vector.
const unit = new Array(n + 1).fill(0)
unit[k] = 1
const phi = cubicSpline(setup.nodesX, unit)
const cardX = []
const cardY = []
for (let i = 0; i <= sampleCount; i++) {
const x = lo + (hi - lo) * i / sampleCount
const y = yk * phi(x)
cardX.push(x)
cardY.push(y)
if (y < cardinalRange.lo) cardinalRange.lo = y
if (y > cardinalRange.hi) cardinalRange.hi = y
}
cardinals.push({x: cardX, y: cardY})
}
return {hats, cardinals, hatRange, cardinalRange}
}// basisPlot draws every term y_j * phi_j of each family that is switched on,
// faint, with the slider's term k drawn bold on top. The nodes sit on y = 0,
// where every term except term j vanishes at x_j; the bold term also passes
// through (x_k, y_k), marked. Same _plotDiv reuse as mainPlot, so zoom/pan
// survive a slider move.
basisPlot = {
const Plotly = window.Plotly
let _plotDiv = null
return (data, basis, k, showHats, showCardinal) => {
const traces = []
const families = []
if (showHats) families.push({terms: basis.hats, color: chartColors.alt, kind: "hat"})
if (showCardinal) families.push({terms: basis.cardinals, color: chartColors.ok, kind: "spline"})
// Faint pass first, then the highlighted phi_k of each family on top.
for (const family of families) {
for (let j = 0; j < family.terms.length; j++) {
if (j === k) continue
const term = family.terms[j]
traces.push({x: term.x, y: term.y, type: "scatter", mode: "lines", name: `y${j}·φ${j} (${family.kind})`, showlegend: false, opacity: 0.35, line: {color: family.color, width: 1.25}})
}
}
for (const family of families) {
const term = family.terms[k]
if (!term) continue
traces.push({x: term.x, y: term.y, type: "scatter", mode: "lines", name: `y${k}·φ${k} (${family.kind})`, showlegend: false, line: {color: family.color, width: 3}})
}
if (data.nodesX.length > 0) {
const zeros = new Array(data.nodesX.length).fill(0)
traces.push({x: data.nodesX, y: zeros, type: "scatter", mode: "markers", name: "Nodes", showlegend: false, marker: {color: chartColors.ink, size: 7, line: {color: chartColors.halo, width: 1}}})
traces.push({x: [data.nodesX[k]], y: [data.nodesY[k]], type: "scatter", mode: "markers", name: `(x${k}, y${k})`, showlegend: false, marker: {color: chartColors.ink, size: 9, line: {color: chartColors.halo, width: 1}}})
}
// Fit whichever families are on (both include 0), plus a little room.
let yLo = basis.hatRange.lo
let yHi = basis.hatRange.hi
if (showCardinal) {
yLo = Math.min(yLo, basis.cardinalRange.lo)
yHi = Math.max(yHi, basis.cardinalRange.hi)
}
const yRange = VM.plotting.paddedRange([yLo, yHi], {emptyRange: [-1, 1], relativePadding: 0.1, minPadding: 0.1})
const layout = {
xaxis: {title: "x", range: [data.xRange.lo, data.xRange.hi], zeroline: true},
yaxis: {title: "yₖ φₖ(x)", range: [yRange.lo, yRange.hi], zeroline: true},
hovermode: "closest",
uirevision: "static",
autosize: true
}
const config = VM.plotting.config()
if (!_plotDiv) {
_plotDiv = document.createElement("div")
_plotDiv.className = "plotly-box-large"
Plotly.newPlot(_plotDiv, traces, layout, config)
VM.plotting.autoResize(_plotDiv)
} else {
Plotly.react(_plotDiv, traces, layout, config)
}
return _plotDiv
}
}// maxN parses the Max field's expression, falling back to 20 for invalid
// input so the n slider always gets a usable bound.
maxN = {
const math = window.math
const parsed = VM.expressions.makeNumber(math, maxNInput)
if (parsed === null || !Number.isFinite(parsed)) return 20
return Math.max(1, Math.round(parsed))
}Connects \(n+1\) samples \((x_k,y_k)\), \(y_k=f(x_k)\), of \(f\) on \([a,b]\) with straight segments (linear),
\[ L(x) = y_k + \frac{y_{k+1}-y_k}{x_{k+1}-x_k}\,(x-x_k),\qquad x\in[x_k,x_{k+1}], \]
or with the natural cubic spline \(S\): the unique piecewise cubic through every node with \(S\), \(S'\), and \(S''\) continuous at each interior node and \(S''(x_0)=S''(x_n)=0\) at the ends. The page then compares how fast each interpolant’s arc length converges to \(f\)’s true arc length as \(n\) grows.
Both interpolants depend linearly on the data, so each is a sum of fixed cardinal functions weighted by the data,
\[ p(x) = \sum_{k=0}^{n} y_k\,\varphi_k(x),\qquad \varphi_k(x_j) = \begin{cases}1 & j = k\\ 0 & j \ne k,\end{cases} \]
where \(\varphi_k\) is the interpolant of the data \((0,\dots,0,1,0,\dots,0)\) and depends only on the nodes, not on \(f\). The basis chart draws the terms \(y_k\varphi_k\) of that sum: add them up and you get back the interpolant in the main chart. For the linear interpolant, \(\varphi_k\) is a hat function: it rises from \(0\) at \(x_{k-1}\) to \(1\) at \(x_k\) and falls back to \(0\) at \(x_{k+1}\), so each \(y_k\) affects only the two neighboring intervals. The spline’s \(\varphi_k\) also equals \(1\) at \(x_k\) and \(0\) at the other nodes, but it ripples, with decaying amplitude, across the whole of \([a,b]\): changing one \(y_k\) moves the spline everywhere, just less and less farther away.
// k picks the term y_k * phi_k drawn bold. Its range follows n, so this
// view is recreated whenever n changes; it re-reads k from the URL (which
// urlSyncControls keeps current) rather than from a reactive cell, so the
// reader's k survives an n change instead of snapping back to the default.
// With no k in the URL it starts at the node with the largest |y_k|, so the
// bold term is never a flat line (the middle node of sin on [0, 2pi] is pi,
// where y_k = 0).
viewof kControl = {
const n = Math.max(1, setup.n)
const rawText = new URLSearchParams(window.location.search).get("k")
const raw = Number(rawText)
let k = 0
let biggest = -1
for (let j = 0; j < setup.nodesY.length; j++) {
const size = Math.abs(setup.nodesY[j])
if (Number.isFinite(size) && size > biggest) {
biggest = size
k = j
}
}
if (rawText !== null && Number.isInteger(raw) && raw >= 0) k = Math.min(raw, n)
const kSlider = Inputs.range([0, n], {step: 1, value: k, label: "k (highlighted yₖφₖ)"})
kSlider.classList.add("ojs-fill")
return kSlider
}
NoteConvergence plots
// iteratesPlot draws the linear and cubic spline arc lengths vs n for the
// entire run, against a reference line at the true arc length of f — it
// doesn't depend on the current step, so it's just plotted once per run.
iteratesPlot = {
const linearName = "Linear interpolant"
const cubicName = "Cubic spline interpolant"
const trueName = "True arc length"
const marks = [
Plot.ruleY([result.trueArcLength], {stroke: () => trueName, strokeDasharray: "4,3"}),
Plot.lineY(result.rows, {x: "n", y: "linearLength", stroke: () => linearName}),
Plot.dot(result.rows, {x: "n", y: "linearLength", fill: () => linearName, r: 3}),
Plot.lineY(result.rows, {x: "n", y: "cubicLength", stroke: () => cubicName}),
Plot.dot(result.rows, {x: "n", y: "cubicLength", fill: () => cubicName, r: 3})
]
const colorDomain = [linearName, cubicName, trueName]
const colorRange = [chartColors.alt, chartColors.ok, chartColors.ink]
const chart = Plot.plot({
width: 340, height: 300, marginLeft: 46,
x: {label: "n"},
y: {label: "arc length"},
color: {domain: colorDomain, range: colorRange},
marks
})
const legend = Plot.legend({color: {domain: colorDomain, range: colorRange}})
const container = document.createElement("div")
container.append(chart, legend)
return container
}
// convergencePlot draws log₁₀|error| vs log₁₀ n for both interpolants, with
// a linear regression line for each. The slope of each line estimates the
// order of convergence of that interpolant's arc length (negated, since
// error shrinks as n grows) — for the entire run, so it's just plotted once
// per run.
//
// The fit itself (which points count, and when an interpolant is reported
// exact) is VM.numerical.orderFromErrorsVsN.
convergencePlot = {
const specs = [
{name: "Linear interpolant error", color: chartColors.alt, fitColor: "#f87171"},
{name: "Cubic spline error", color: chartColors.ok, fitColor: "#4ade80"}
]
const marks = []
const colorDomain = []
const colorRange = []
// legendDomain/legendRange hold only the regression lines (and "exact"
// entries) — the legend shown below the chart displays just the fitted
// orders, not the raw point series, to keep it uncluttered.
const legendDomain = []
const legendRange = []
for (let i = 0; i < specs.length; i++) {
const spec = specs[i]
const interpolant = interpolantErrors[i]
const {points, fit, order, exact, exactFromN} = VM.numerical.orderFromErrorsVsN(result.rows, result.trueArcLength, interpolant.errorKey)
marks.push(Plot.dot(points, {x: "x", y: "y", fill: () => spec.name, r: 4}))
colorDomain.push(spec.name)
colorRange.push(spec.color)
let legendName = null
if (exact) {
legendName = `${interpolant.name}: exact (error at rounding level)`
} else if (fit) {
legendName = `${interpolant.name} order ≈ ${order.toFixed(2)}`
marks.push(Plot.line(
[{x: fit.xlo, y: fit.slope * fit.xlo + fit.intercept}, {x: fit.xhi, y: fit.slope * fit.xhi + fit.intercept}],
{x: "x", y: "y", stroke: () => legendName, strokeDasharray: "6,4"}
))
} else if (exactFromN !== null) {
legendName = `${interpolant.name}: exact from n = ${exactFromN}`
}
if (legendName === null) continue
colorDomain.push(legendName)
colorRange.push(spec.fitColor)
legendDomain.push(legendName)
legendRange.push(spec.fitColor)
}
const chart = Plot.plot({
width: 340, height: 300, marginLeft: 46,
x: {label: "log₁₀ n"},
y: {label: "log₁₀ |error|"},
color: {domain: colorDomain, range: colorRange},
marks
})
const container = document.createElement("div")
container.append(chart)
if (legendDomain.length > 0) {
const legend = Plot.legend({color: {domain: legendDomain, range: legendRange}})
container.append(legend)
}
return container
}
NoteArc length table
// Arc length table — shows the entire run, not just the current n, with
// both arc lengths and both errors against the true arc length of f.
arcLengthTable = {
if (result.rows.length === 0) {
// Always return a Node so Quarto's OJS runtime never latches this
// declaration cell as hidden.
return document.createElement("div")
}
const formattedRows = []
for (const row of result.rows) {
let cubicCell = "NaN"
if (Number.isFinite(row.cubicLength)) cubicCell = row.cubicLength.toPrecision(10)
let cubicErrorCell = "NaN"
if (Number.isFinite(row.cubicError)) cubicErrorCell = row.cubicError.toExponential(4)
formattedRows.push([
row.n,
row.linearLength.toPrecision(10),
cubicCell,
row.trueArcLength.toPrecision(10),
row.linearError.toExponential(4),
cubicErrorCell
])
}
return VM.ui.renderTable({
html,
headers: ["n", tex`L_{\text{linear}}`, tex`L_{\text{cubic}}`, tex`L_{\text{true}}`, tex`|L_{\text{linear}} - L_{\text{true}}|`, tex`|L_{\text{cubic}} - L_{\text{true}}|`],
csvHeaders: ["n", "L_linear", "L_cubic", "L_true", "|L_linear - L_true|", "|L_cubic - L_true|"],
filename: "linear-cubic-arc-lengths.csv",
rows: formattedRows
})
}