Polynomial Interpolation
Compares piecewise linear and natural cubic spline interpolation through a set of points.
Author
Apurva Nakade
Published
July 13, 2026
// VM is the shared utility library, loaded globally via _includes/head-scripts.html (js/**), plus this page's own cubic-spline.js (loaded via the include-in-header above). This page uses VM.cubicSpline, VM.expressions.makeFunction, VM.expressions.makeNumber, VM.plotting.paddedRange, VM.ui.renderTable, VM.ui.applyExampleParams.
VM = window.VMurlParams = new URLSearchParams(window.location.search)
initialFText = urlParams.get("f") ?? "1/(1+x^2)"
initialA0 = urlParams.get("a") ?? "-5"
initialB0 = urlParams.get("b") ?? "5"
initialMaxNText = urlParams.get("max") ?? "40"
initialN = {
const raw = Number(urlParams.get("n"))
return Number.isInteger(raw) && raw >= 1 ? raw : 5
}
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
if (!raw) return all
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
}// examples backs the "Try an example" dropdown near the top controls.
//
// Each params object lists "max" before "n" — nControl's range depends
// reactively on maxN, so its view gets recreated when max changes;
// VM.ui.applyExampleParams applies params in this order with a yield
// between each, so the freshly-recreated slider is what "n" ends up
// applied to.
examples = [
{
title: "Rational peak",
params: {f: "1/(1+x^2)", a: "-5", b: "5", max: "40", n: "5"}
},
{
title: "Nonsmooth corner",
params: {f: "abs(x)", a: "-2", b: "2", max: "30", n: "4"}
},
{
title: "Sine on one full period",
params: {f: "sin(x)", a: "0", b: "2*pi", max: "20", n: "4"}
},
{
title: "Cubic polynomial",
params: {f: "x^3-x", a: "-2", b: "2", max: "30", n: "4"}
}
]// 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)
})
}// 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(",")
)
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.
// rows — array of { n, linearLength, cubicLength, trueArcLength, linearError, cubicError },
// one entry per subinterval count n = 1..maxN
// f — parsed function, reused for curve sampling in setup
// trueArcLength — reference arc length of f itself, shared by every row
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 lo = Math.min(a, b)
const hi = Math.max(a, b)
// Reference arc length of f: chord-length sum over a very fine uniform
// grid, far finer than any interpolant tested below.
const refCount = 4000
let trueArcLength = 0
let xPrev = lo
let yPrev = f(xPrev)
for (let k = 1; k <= refCount; k++) {
const x = lo + (hi - lo) * k / refCount
const y = f(x)
if (Number.isFinite(yPrev) && Number.isFinite(y)) {
const dx = x - xPrev
const dy = y - yPrev
trueArcLength += Math.sqrt(dx * dx + dy * dy)
}
xPrev = x
yPrev = y
}
const rows = []
let n = 1
while (n <= maxN) {
// n+1 equally spaced interpolation nodes over [lo, hi]
const nodesX = []
const nodesY = []
for (let k = 0; k <= n; k++) {
const x = lo + (hi - lo) * k / n
nodesX.push(x)
nodesY.push(f(x))
}
// Linear interpolant arc length: exact sum of segment lengths.
let linearLength = 0
for (let k = 0; k < n; k++) {
const dx = nodesX[k + 1] - nodesX[k]
const dy = nodesY[k + 1] - nodesY[k]
if (Number.isFinite(dy)) linearLength += Math.sqrt(dx * dx + dy * dy)
}
// Cubic spline arc length: approximated by sampling the spline densely
// and summing chord lengths along the sampled curve.
const spline = VM.cubicSpline(nodesX, nodesY)
let cubicLength = NaN
if (spline) {
const sampleCount = n * 20
let sxPrev = nodesX[0]
let syPrev = spline(sxPrev)
cubicLength = 0
for (let k = 1; k <= sampleCount; k++) {
const x = lo + (hi - lo) * k / sampleCount
const y = spline(x)
if (Number.isFinite(syPrev) && Number.isFinite(y)) {
const dx = x - sxPrev
const dy = y - syPrev
cubicLength += Math.sqrt(dx * dx + dy * dy)
}
sxPrev = x
syPrev = y
}
}
const linearError = Math.abs(linearLength - trueArcLength)
const cubicError = Number.isFinite(cubicLength) ? Math.abs(cubicLength - trueArcLength) : NaN
rows.push({n, linearLength, cubicLength, trueArcLength, linearError, cubicError})
n++
}
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 = VM.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] plus node and cubic-spline values.
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])
}
for (const y of nodesY) if (Number.isFinite(y)) yValues.push(y)
for (const y of cubicY) if (Number.isFinite(y)) yValues.push(y)
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
}
}// 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))
}
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.
convergencePlot = {
const regression = points => {
if (points.length < 2) return null
const pointCount = points.length
let sx = 0, sy = 0, sxy = 0, sxx = 0
for (const p of points) {
sx += p.x
sy += p.y
sxy += p.x * p.y
sxx += p.x * p.x
}
const denom = pointCount * sxx - sx * sx
if (denom === 0) return null
const slope = (pointCount * sxy - sx * sy) / denom
const intercept = (sy - slope * sx) / pointCount
return {slope, intercept, xlo: points[0].x, xhi: points[pointCount - 1].x}
}
const linearPts = []
const cubicPts = []
for (const row of result.rows) {
if (Number.isFinite(row.linearError) && row.linearError > 0) {
linearPts.push({x: Math.log10(row.n), y: Math.log10(row.linearError)})
}
if (Number.isFinite(row.cubicError) && row.cubicError > 0) {
cubicPts.push({x: Math.log10(row.n), y: Math.log10(row.cubicError)})
}
}
const linearName = "Linear interpolant error"
const cubicName = "Cubic spline error"
const marks = [
Plot.dot(linearPts, {x: "x", y: "y", fill: () => linearName, r: 4}),
Plot.dot(cubicPts, {x: "x", y: "y", fill: () => cubicName, r: 4})
]
const colorDomain = [linearName, cubicName]
const colorRange = [chartColors.alt, chartColors.ok]
// legendDomain/legendRange hold only the regression lines — the legend
// shown below the chart displays just the fitted orders, not the raw
// point series, to keep it uncluttered.
const legendDomain = [], legendRange = []
const linearFit = regression(linearPts)
if (linearFit) {
const lineName = `linear order ≈ ${(-linearFit.slope).toFixed(2)}`
marks.push(Plot.line(
[{x: linearFit.xlo, y: linearFit.slope * linearFit.xlo + linearFit.intercept}, {x: linearFit.xhi, y: linearFit.slope * linearFit.xhi + linearFit.intercept}],
{x: "x", y: "y", stroke: () => lineName, strokeDasharray: "6,4"}
))
colorDomain.push(lineName)
colorRange.push("#f87171")
legendDomain.push(lineName)
legendRange.push("#f87171")
}
const cubicFit = regression(cubicPts)
if (cubicFit) {
const lineName = `cubic order ≈ ${(-cubicFit.slope).toFixed(2)}`
marks.push(Plot.line(
[{x: cubicFit.xlo, y: cubicFit.slope * cubicFit.xlo + cubicFit.intercept}, {x: cubicFit.xhi, y: cubicFit.slope * cubicFit.xhi + cubicFit.intercept}],
{x: "x", y: "y", stroke: () => lineName, strokeDasharray: "6,4"}
))
colorDomain.push(lineName)
colorRange.push("#4ade80")
legendDomain.push(lineName)
legendRange.push("#4ade80")
}
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
})
}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.