viewof fText = {
const params = new URLSearchParams(window.location.search)
const fTextInput = Inputs.text({label: "Function f(x)", value: params.get("f") ?? "x^3 - x - 2", placeholder: "Example: x^3 - x - 2, cos(x) - x"})
fTextInput.classList.add("ojs-fill")
fTextInput.dataset.exampleField = "f"
return fTextInput
}Secant Method
Computational Math
Convergence
Interpolation
Find a root like Newton’s method, but without the derivative.
Author
Apurva Nakade
Published
June 25, 2026
traceOptions = ["f(x)", "Interpolant", "Interpolation points", "x-intercepts"]
// Matches the line/marker 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)
// Each method has its own color, distinct from f(x)'s: the main chart draws
// the interpolant and its points in it, and the convergence plots draw that
// method's run in it.
methodColors = ({secant: chartColors.alt, iqi: chartColors.accent2})
traceColors = new Map([
["f(x)", chartColors.fn],
["Interpolant", methodColors[method]],
["Interpolation points", methodColors[method]],
["x-intercepts", chartColors.ok]
])
// Colors the legend's swatches; see viewof traces for why it isn't rebuilt.
paintLegend = {
const swatches = (viewof traces).querySelectorAll(".ojs-legend-swatch")
traceOptions.forEach((label, k) => { swatches[k].style.background = traceColors.get(label) })
}// Plot commits the current function/x₀/x₁ fields — edits above don't take
// effect until this is clicked, so result/mainPlot don't recompute on
// every keystroke. The step slider overlaid on the plot is unaffected and stays reactive.
viewof plotTrigger = {
const plotButton = Inputs.button("Plot", {value: 0, reduce: v => v + 1})
plotButton.classList.add("ojs-auto")
return plotButton
}// Only inverse quadratic interpolation uses a third starting point. It stays
// enabled in secant mode too: the convergence plots run both methods.
viewof x2 = {
const params = new URLSearchParams(window.location.search)
const x2Input = Inputs.text({label: "Initial guess x₂", value: params.get("x2") ?? "3.5", placeholder: "Example: 1.5"})
x2Input.dataset.exampleField = "x2"
return x2Input
}// 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 maxIterationsInput = {
const input = Inputs.text({value: initialMax, 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.
exampleFieldSelectors = ({method: '[data-example-field="method"]', f: '[data-example-field="f"]', x0: '[data-example-field="x0"]', x1: '[data-example-field="x1"]', x2: '[data-example-field="x2"]', max: '[data-example-field="max"]'})// Applies the selected example's params to the fields above 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 = {
method; fText; x0; x1; x2; maxIterationsInput
VM.ui.syncExampleSelect(viewof exampleSelect, examples, exampleFieldSelectors)
}// Pressing Enter in the function/x₀/x₁/x₂ 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 x0, viewof x1, viewof x2]) {
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/x0/x1/x2 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 x0`/`viewof x1` (the stable DOM views) rather than
// `fText`/`x0`/`x1` (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 x0Text = (viewof x0).value
const x1Text = (viewof x1).value
const x2Text = (viewof x2).value
const x0Val = VM.expressions.makeNumber(math, x0Text)
const x1Val = VM.expressions.makeNumber(math, x1Text)
const x2Val = VM.expressions.makeNumber(math, x2Text)
const params = new URLSearchParams(window.location.search)
params.set("f", fVal)
params.set("x0", x0Text)
params.set("x1", x1Text)
params.set("x2", x2Text)
history.replaceState(null, "", `${window.location.pathname}?${params}`)
return {fText: fVal, x0: x0Val, x1: x1Val, x2: x2Val}
}// max and method are always-live rather than commit-gated, so it gets its own sync cell
// instead of piggybacking on committed above (which only runs when Plot is
// clicked).
urlSyncControls = {
const params = new URLSearchParams(window.location.search)
params.set("max", maxIterationsInput)
params.set("method", method)
history.replaceState(null, "", `${window.location.pathname}?${params}`)
return true
}// result: runs the selected method with the current committed inputs.
// rows, points — see runMethod
// f — parsed function, reused for curve sampling in setup
// method — "secant" or "iqi", the method these rows came from
result = {
const math = window.math
// Parse f(x) from the committed string. makeFunction returns null if the
// expression is invalid.
const f = VM.expressions.makeFunction(math, committed.fText)
const fallback = x => NaN
if (!f) return {rows: [], points: [], f: fallback, method}
// Secant interpolates the last two points, IQI the last three.
let initialGuesses = [committed.x0, committed.x1]
if (method === "iqi") initialGuesses = [committed.x0, committed.x1, committed.x2]
const run = runMethod(f, initialGuesses, maxIterations)
return {rows: run.rows, points: run.points, f, method}
}// comparison runs both methods on the committed inputs, whatever the Method
// toggle says, so the convergence plots can show them side by side.
comparison = {
const f = VM.expressions.makeFunction(window.math, committed.fText)
const runs = [
{label: "Secant", color: methodColors.secant, guesses: [committed.x0, committed.x1]},
{label: "Inverse quadratic", color: methodColors.iqi, guesses: [committed.x0, committed.x1, committed.x2]}
]
for (const run of runs) {
let outcome = {rows: [], points: []}
if (f) outcome = runMethod(f, run.guesses, maxIterations)
run.rows = outcome.rows
run.points = outcome.points
}
return runs
}// setup computes the data shared by all three plots below: which rows are
// visible at the current step, and the axis ranges / curve samples for the
// main chart.
setup = {
const visibleRows = result.rows.slice(0, Math.min(Number(stepControl), result.rows.length) + 1)
// Axis ranges are computed from ALL rows, not just visibleRows,
// so the axes stay fixed as the user steps through iterations.
const xValues = []
for (const point of result.points) xValues.push(point.x)
for (const row of result.rows) xValues.push(row.xNext)
const xRange = VM.plotting.paddedRange(xValues, {emptyRange: [-3, 3], relativePadding: 0.25, minPadding: 0.5})
// 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(xValues, {emptyRange: [-6, 6], relativePadding: 0.5, minPadding: 1.0})
// 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)
}
}
// Collect y values for the y-axis range, including every interpolation
// node. Only samples within the initial view (xRange) count, so yRange —
// and the initial view — stay unaffected by the wider sampling.
// Cap at 1e8 so a runaway iterate doesn't blow up the axis range.
const yValues = [0]
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 row of result.rows) {
for (const node of row.nodes) {
if (Number.isFinite(node.fx) && Math.abs(node.fx) <= 1e8) yValues.push(node.fx)
}
}
const yRange = VM.plotting.paddedRange(yValues, {emptyRange: [-1, 1], relativePadding: 0.25, minPadding: 0.5})
return {visibleRows, xRange, xBuffer, yRange, sampleXs, sampleYs, method: result.method}
}// interpolantCurve samples x = q(y) (see inverseInterpolant) for one row,
// over the f-values of its nodes and 0, plus a margin on either side, so the
// drawn curve always runs through every node and the x-intercept. Returns
// null when the row has no usable interpolant (diverged or degenerate).
function interpolantCurve(row) {
if (!Number.isFinite(row.xNext)) return null
let yLo = 0
let yHi = 0
for (const node of row.nodes) {
if (!Number.isFinite(node.x) || !Number.isFinite(node.fx)) return null
yLo = Math.min(yLo, node.fx)
yHi = Math.max(yHi, node.fx)
}
const margin = 0.25 * (yHi - yLo)
yLo -= margin
yHi += margin
const sampleCount = 200
const xs = []
const ys = []
for (let k = 0; k < sampleCount; k++) {
const y = yLo + (yHi - yLo) * k / (sampleCount - 1)
const x = inverseInterpolant(row.nodes, y)
if (Number.isFinite(x)) {
xs.push(x)
ys.push(y)
}
}
return {xs, ys}
}// mainPlot draws f(x), the interpolant (secant line or inverse quadratic),
// the interpolation points, and the x-intercepts.
//
// _plotDiv is kept in a closure so Plotly reuses the same DOM node across
// reactive updates — this preserves zoom and pan when the step changes.
mainPlot = {
const Plotly = window.Plotly
let _plotDiv = null
return ({visibleRows, xRange, xBuffer, yRange, sampleXs, sampleYs, method}, showFCurve, showSecant, showSecantPoints, showIntercepts) => {
const data = []
// Hover name only — the legend label stays "Interpolant" for both methods.
let interpolantName = "Secant line"
if (method === "iqi") interpolantName = "Inverse quadratic"
const methodColor = methodColors[method]
if (showFCurve) {
// The function curve
data.push({x: sampleXs, y: sampleYs, type: "scatter", mode: "lines", name: "f(x)", showlegend: false, line: {color: chartColors.fn, width: 3}})
}
// Every point used so far: all nodes of the first visible row, then
// the newest node of each later row.
const secantPoints = []
for (let r = 0; r < visibleRows.length; r++) {
const nodes = visibleRows[r].nodes
let first = nodes.length - 1
if (r === 0) first = 0
for (let k = first; k < nodes.length; k++) {
if (Number.isFinite(nodes[k].x) && Number.isFinite(nodes[k].fx)) secantPoints.push(nodes[k])
}
}
// Dotted vertical lines from the x-axis up to each interpolation point
if (showSecantPoints) {
for (const p of secantPoints) {
data.push({x: [p.x, p.x], y: [0, p.fx], type: "scatter", mode: "lines", showlegend: false, hoverinfo: "skip", line: {color: chartColors.muted, width: 1.5, dash: "dot"}})
}
}
// Interpolants: previous ones faded, current one solid. Each spans its
// nodes and the x-intercept (plus a margin), so the intercept marker is
// always on the drawn curve.
const curves = []
for (const r of visibleRows) {
const curve = interpolantCurve(r)
if (curve) curves.push(curve)
}
if (showSecant) {
for (let k = 0; k < curves.length - 1; k++) {
data.push({
x: curves[k].xs, y: curves[k].ys,
type: "scatter", mode: "lines", showlegend: false, hoverinfo: "skip",
line: {color: VM.plotting.alpha(methodColor, 0.18), width: 1.5}
})
}
if (curves.length > 0) {
const current = curves[curves.length - 1]
data.push({
x: current.xs, y: current.ys,
type: "scatter", mode: "lines", name: interpolantName, showlegend: false,
line: {color: methodColor, width: 2.5}
})
}
}
// Markers at each (x_i, f(x_i)) interpolation point
if (showSecantPoints && secantPoints.length > 0) {
const xs = [], ys = [], labels = []
for (const p of secantPoints) {
xs.push(p.x)
ys.push(p.fx)
labels.push(`x${VM.plotting.subscript(p.i)}`)
}
data.push({
x: xs, y: ys,
type: "scatter", mode: "markers+text", name: "Interpolation points",
text: labels, textposition: "top center", showlegend: false,
marker: {color: methodColor, size: 10, symbol: "circle", line: {color: chartColors.halo, width: 1}}
})
}
// Green markers at each x-intercept (x_{i+1})
const intercepts = []
for (const r of visibleRows) {
if (Number.isFinite(r.xNext)) intercepts.push({i: r.i + 1, x: r.xNext})
}
if (showIntercepts && intercepts.length > 0) {
const xs = [], ys = [], labels = []
for (const r of intercepts) {
xs.push(r.x)
ys.push(0)
labels.push(`x${VM.plotting.subscript(r.i)}`)
}
data.push({
x: xs, y: ys,
type: "scatter", mode: "markers+text", name: "x-intercepts",
text: labels, textposition: "bottom center", showlegend: false,
marker: {color: chartColors.ok, size: 10, symbol: "circle", line: {color: chartColors.halo, width: 1}}
})
}
const layout = {
xaxis: {title: "x", range: [xRange.lo, xRange.hi], zeroline: true},
yaxis: {title: "f(x)", range: [yRange.lo, 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, data, 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, data, layout, config)
}
return _plotDiv
}
}// maxIterations parses the Max field's expression, falling back to 20 for
// invalid input so the step slider and result loop always get a usable bound.
maxIterations = {
const math = window.math
const parsed = VM.expressions.makeNumber(math, maxIterationsInput)
if (parsed === null || !Number.isFinite(parsed)) return 20
return Math.max(1, Math.round(parsed))
}// Built once, without colors: rebuilding it whenever traceColors changes (a
// method switch, a theme toggle) would reset whatever the reader toggled off.
// paintLegend colors the swatches instead.
viewof traces = {
const items = []
for (const label of traceOptions) items.push({label, color: ""})
return VM.ui.legendOverlay(items, {value: traceOptions})
}Both methods find a root of \(f(x)=0\) without a derivative, by interpolating the most recent iterates and taking the point where the interpolant crosses the \(x\)-axis as the next iterate. Write \(y_k = f(x_k)\).
The secant method uses the line through the last two points \((x_{n-1}, y_{n-1})\) and \((x_n, y_n)\), replacing Newton’s tangent line with a secant:
\[ x_{n+1}=x_n-y_n\,\frac{x_n-x_{n-1}}{y_n-y_{n-1}}. \]
Inverse quadratic interpolation (IQI) uses the last three points, and fits \(x\) as a quadratic function of \(y\), a sideways parabola \(x=q(y)\), rather than \(y\) as a function of \(x\). The next iterate is \(x_{n+1}=q(0)\):
\[ x_{n+1}=\frac{x_{n-2}\,y_{n-1}\,y_n}{(y_{n-2}-y_{n-1})(y_{n-2}-y_n)}+\frac{x_{n-1}\,y_{n-2}\,y_n}{(y_{n-1}-y_{n-2})(y_{n-1}-y_n)}+\frac{x_n\,y_{n-2}\,y_{n-1}}{(y_n-y_{n-2})(y_n-y_{n-1})}. \]
Because a sideways parabola crosses the \(x\)-axis exactly once, there is no need to solve a quadratic equation. (Read the same way, the secant line is \(x\) as a linear function of \(y\), evaluated at \(y=0\).)
Near a simple root the secant method converges with order \(\varphi=(1+\sqrt5)/2\approx 1.62\) and IQI with order about \(1.84\), both between bisection’s linear rate and Newton’s quadratic one. The slope of the log-log plot in “Convergence plots” estimates this order. Each method breaks down when two of the interpolated \(f\)-values coincide.
NoteConvergence plots
// iteratesPlot draws xₙ vs n for the entire run of both methods, including
// their initial guesses — it doesn't depend on the current step, so it's
// just plotted once per run.
iteratesPlot = {
const pointRows = []
for (const run of comparison) {
for (let k = 0; k < run.points.length; k++) {
const point = run.points[k]
if (Number.isFinite(point.x)) pointRows.push({method: run.label, i: k, x: point.x})
}
}
const domain = [], range = []
for (const run of comparison) {
domain.push(run.label)
range.push(run.color)
}
const chart = Plot.plot({
width: 340, height: 300, marginLeft: 46,
x: {label: "n"},
y: {label: "xₙ"},
color: {domain, range},
marks: [
Plot.lineY(pointRows, {x: "i", y: "x", z: "method", stroke: "method"}),
Plot.dot(pointRows, {x: "i", y: "x", fill: "method", r: 4})
]
})
const container = document.createElement("div")
container.append(chart)
container.append(Plot.legend({color: {domain, range}}))
return container
}
// convergencePlot draws log₁₀|eₙ₊₁| vs log₁₀|eₙ| (successive step sizes,
// eₙ = |xₙ₊₁ − xₙ|) for both methods on one chart, each with its own linear
// regression line, for the entire run. Both methods converge superlinearly
// for a simple root: the secant method with order φ = (1+√5)/2 ≈ 1.618 (the
// golden ratio), inverse quadratic interpolation with order ≈ 1.839 — both
// slower than Newton's quadratic order but without needing a derivative.
// This log-log successive-error plot is the standard diagnostic: its slope
// estimates the order of convergence p directly.
convergencePlot = {
const marks = []
const colorDomain = [], colorRange = []
// legendDomain/legendRange name each method with its fitted order; the
// dots share their method's color, so they need no legend entry of their own.
const legendDomain = [], legendRange = []
for (const run of comparison) {
const {points: logRows, fit} = VM.numerical.orderFromSuccessiveErrors(run.rows, "error", "xNext")
let name = run.label
if (fit) {
name = `${run.label}: order ≈ ${fit.slope.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: () => name, strokeDasharray: "6,4"}
))
}
marks.push(Plot.dot(logRows, {x: "x", y: "y", fill: () => name, r: 4}))
colorDomain.push(name)
colorRange.push(run.color)
legendDomain.push(name)
legendRange.push(run.color)
}
const chart = Plot.plot({
width: 340, height: 300, marginLeft: 46,
x: {label: "log₁₀|eₙ|"},
y: {label: "log₁₀|eₙ₊₁|"},
color: {domain: colorDomain, range: colorRange},
marks
})
const container = document.createElement("div")
container.append(chart)
container.append(Plot.legend({color: {domain: legendDomain, range: legendRange}}))
return container
}
NoteIteration table
// Iteration table — shows the entire run, not just the current step.
// One x and one f(x) column per interpolation node, so secant runs get
// x_{n-1}, x_n and IQI runs x_{n-2}, x_{n-1}, x_n.
iterationTable = {
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 nodeCount = result.rows[0].nodes.length
const nodeLabels = []
for (let k = nodeCount - 1; k >= 0; k--) {
if (k === 0) {
nodeLabels.push("x_n")
} else {
nodeLabels.push(`x_{n-${k}}`)
}
}
const headers = ["n"]
const csvHeaders = ["n"]
for (const label of nodeLabels) {
headers.push(tex`${label}`)
csvHeaders.push(label)
}
for (const label of nodeLabels) {
headers.push(tex`f(${label})`)
csvHeaders.push(`f(${label})`)
}
headers.push(tex`x_{n+1}`, tex`|x_{n+1} - x_n|`)
csvHeaders.push("x_{n+1}", "|x_{n+1} - x_n|")
const formattedRows = []
for (const row of result.rows) {
const cells = [row.i]
for (const node of row.nodes) {
let xCell = "NaN"
if (Number.isFinite(node.x)) xCell = node.x.toPrecision(10)
cells.push(xCell)
}
for (const node of row.nodes) {
let fxCell = "NaN"
if (Number.isFinite(node.fx)) fxCell = node.fx.toExponential(5)
cells.push(fxCell)
}
let xNextCell = "NaN"
if (Number.isFinite(row.xNext)) xNextCell = row.xNext.toPrecision(10)
let errorCell = "NaN"
if (Number.isFinite(row.error)) errorCell = row.error.toExponential(4)
cells.push(xNextCell, errorCell)
formattedRows.push(cells)
}
let filename = "secant-method-iterations.csv"
if (result.method === "iqi") filename = "inverse-quadratic-interpolation-iterations.csv"
return VM.ui.renderTable({html, headers, csvHeaders, filename, rows: formattedRows})
}