Taylor Series
Polynomials pretending to be functions.
Beginner / approximation / calculus
A Taylor polynomial rebuilds a function from its derivatives at a single point. Near that point a few terms are uncannily good; far away the approximation falls apart, and for functions like 1/(1 + x²) the failure line is the radius of convergence. Move the marker and read the error where you land.
Tagged: approximation, calculus, convergence, radius of convergence
Live simulation
Move the pointer to place the evaluation marker. Keyboard: Space runs or pauses, . steps, r resets.
Speed
Move the pointer to place the evaluation marker.
Mathematics
A Taylor polynomial rebuilds a function near a point from its derivatives there: value, slope, curvature, and so on. Each additional term extends the region where the polynomial hugs the curve.
f(x) ≈ Pₙ(x) = Σ_{k=0}^{n} f⁽ᵏ⁾(0) / k! · xᵏ
Lagrange remainder: Rₙ(x) = f⁽ⁿ⁺¹⁾(ξ) / (n+1)! · xⁿ⁺¹ for some ξ between 0 and x
1/(1+x²): converges only for |x| < 1. The radius of convergence is real
The remainder bound explains what the plot shows: near the expansion point the factorial in the denominator crushes the error, and far away the polynomial is useless. For 1/(1 + x²) the failure at |x| = 1 has nothing to do with the function's smoothness at 0; it comes from poles hiding at x = ±i, which complex analysis makes visible in real calculus.
How it works
The function and its partial sum are evaluated point by point across the visible domain; the marker tracks the pointer and reports f, Pₙ, and their difference at that exact location. The max-error metric sweeps 201 sample points across the domain so the numbers cannot be cherry-picked.
Switch to 1/(1 + x²) and add terms one at a time: inside |x| < 1 the fit tightens, outside it gets dramatically worse. That is the radius of convergence doing exactly what the theorem says.
Implementation
src/engine/experiments/taylor-series.ts · 217 lines · the module running above
TypeScript · fixed-timestep engine
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import type { ParameterDef, PointerState, ThemeColors } from "../core/types"; |
| 3 | |
| 4 | type TaylorFn = { |
| 5 | label: string; |
| 6 | domain: [number, number]; |
| 7 | /** f at x; NaN outside its domain. */ |
| 8 | f: (x: number) => number; |
| 9 | /** k-th Taylor coefficient (about 0), k >= 0. */ |
| 10 | coefficient: (k: number) => number; |
| 11 | }; |
| 12 | |
| 13 | const FUNCTIONS: Record<string, TaylorFn> = { |
| 14 | sin: { |
| 15 | label: "sin(x)", |
| 16 | domain: [-9, 9], |
| 17 | f: Math.sin, |
| 18 | coefficient: (k) => (k % 2 === 0 ? 0 : (k === 1 ? 1 : ((-1) ** ((k - 1) / 2)) / factorial(k))), |
| 19 | }, |
| 20 | "1/(1+x^2)": { |
| 21 | label: "1 / (1 + x²)", |
| 22 | domain: [-4, 4], |
| 23 | f: (x) => 1 / (1 + x * x), |
| 24 | coefficient: (k) => (k % 2 === 0 ? ((-1) ** (k / 2)) : 0), |
| 25 | }, |
| 26 | "ln(1+x)": { |
| 27 | label: "ln(1 + x)", |
| 28 | domain: [-0.95, 3], |
| 29 | f: (x) => (x > -1 ? Math.log(1 + x) : NaN), |
| 30 | coefficient: (k) => (k === 0 ? 0 : ((-1) ** (k + 1)) / k), |
| 31 | }, |
| 32 | }; |
| 33 | |
| 34 | function factorial(n: number): number { |
| 35 | let out = 1; |
| 36 | for (let i = 2; i <= n; i++) out *= i; |
| 37 | return out; |
| 38 | } |
| 39 | |
| 40 | /** |
| 41 | * Taylor polynomials with a live error readout. The marker position is set by |
| 42 | * the pointer; the max-error metric is sampled across the visible domain. |
| 43 | */ |
| 44 | export class TaylorSeriesExperiment extends BaseExperiment { |
| 45 | readonly id = "taylor-series"; |
| 46 | |
| 47 | private fn = FUNCTIONS.sin; |
| 48 | private markerX = 2; |
| 49 | private pointer = { x: 0, y: 0, down: false, inside: false }; |
| 50 | |
| 51 | protected params(): ParameterDef[] { |
| 52 | return [ |
| 53 | { |
| 54 | key: "fn", |
| 55 | label: "Function", |
| 56 | options: [ |
| 57 | { value: "sin", label: "sin(x) about 0" }, |
| 58 | { value: "1/(1+x^2)", label: "1/(1 + x²) about 0" }, |
| 59 | { value: "ln(1+x)", label: "ln(1 + x) about 0" }, |
| 60 | ], |
| 61 | defaultValue: "sin", |
| 62 | }, |
| 63 | { key: "terms", label: "Polynomial terms", min: 1, max: 21, step: 2, defaultValue: 5 }, |
| 64 | ]; |
| 65 | } |
| 66 | |
| 67 | protected onReset(): void { |
| 68 | this.fn = FUNCTIONS[this.str("fn")]; |
| 69 | this.markerX = 2; |
| 70 | } |
| 71 | |
| 72 | protected onParameterChange(key: string): void { |
| 73 | if (key === "fn") this.reset(); |
| 74 | } |
| 75 | |
| 76 | /** Static visualization: state changes only through parameters and pointer. */ |
| 77 | protected onUpdate(): void {} |
| 78 | |
| 79 | private partialSum(x: number, terms: number): number { |
| 80 | let sum = 0; |
| 81 | let power = 1; |
| 82 | for (let k = 0; k < terms; k++) { |
| 83 | sum += this.fn.coefficient(k) * power; |
| 84 | power *= x; |
| 85 | } |
| 86 | return sum; |
| 87 | } |
| 88 | |
| 89 | onPointer(state: PointerState): void { |
| 90 | this.pointer = state; |
| 91 | if (state.inside) { |
| 92 | const [x0, x1] = this.fn.domain; |
| 93 | this.markerX = x0 + (state.x / this.width) * (x1 - x0); |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 98 | ctx.fillStyle = theme.bg; |
| 99 | ctx.fillRect(0, 0, this.width, this.height); |
| 100 | const [x0, x1] = this.fn.domain; |
| 101 | const yRange: [number, number] = [-2.4, 2.4]; |
| 102 | const toX = (x: number) => ((x - x0) / (x1 - x0)) * this.width; |
| 103 | const toY = (y: number) => this.height / 2 - (y / (yRange[1] - yRange[0])) * this.height; |
| 104 | // Axes |
| 105 | ctx.strokeStyle = theme.line; |
| 106 | ctx.lineWidth = 1; |
| 107 | ctx.beginPath(); |
| 108 | ctx.moveTo(0, toY(0)); |
| 109 | ctx.lineTo(this.width, toY(0)); |
| 110 | ctx.stroke(); |
| 111 | if (x0 < 0 && x1 > 0) { |
| 112 | ctx.beginPath(); |
| 113 | ctx.moveTo(toX(0), 0); |
| 114 | ctx.lineTo(toX(0), this.height); |
| 115 | ctx.stroke(); |
| 116 | } |
| 117 | const terms = this.num("terms"); |
| 118 | let maxError = 0; |
| 119 | // f |
| 120 | ctx.strokeStyle = theme.fgSecondary; |
| 121 | ctx.lineWidth = 1.5; |
| 122 | ctx.beginPath(); |
| 123 | let pen = false; |
| 124 | for (let px = 0; px <= this.width; px += 2) { |
| 125 | const xv = x0 + (px / this.width) * (x1 - x0); |
| 126 | const yv = this.fn.f(xv); |
| 127 | if (Number.isNaN(yv) || Math.abs(yv) > 12) { |
| 128 | pen = false; |
| 129 | continue; |
| 130 | } |
| 131 | const py = toY(yv); |
| 132 | if (pen) ctx.lineTo(px, py); |
| 133 | else ctx.moveTo(px, py); |
| 134 | pen = true; |
| 135 | } |
| 136 | ctx.stroke(); |
| 137 | // Partial sum |
| 138 | ctx.strokeStyle = theme.accent; |
| 139 | ctx.lineWidth = 2; |
| 140 | ctx.beginPath(); |
| 141 | pen = false; |
| 142 | for (let px = 0; px <= this.width; px += 2) { |
| 143 | const xv = x0 + (px / this.width) * (x1 - x0); |
| 144 | const yv = this.partialSum(xv, terms); |
| 145 | if (Number.isNaN(yv) || Math.abs(yv) > 12) { |
| 146 | pen = false; |
| 147 | continue; |
| 148 | } |
| 149 | if (Number.isFinite(this.fn.f(xv))) { |
| 150 | maxError = Math.max(maxError, Math.abs(yv - this.fn.f(xv))); |
| 151 | } |
| 152 | const py = toY(yv); |
| 153 | if (pen) ctx.lineTo(px, py); |
| 154 | else ctx.moveTo(px, py); |
| 155 | pen = true; |
| 156 | } |
| 157 | ctx.stroke(); |
| 158 | // Marker |
| 159 | const mx = toX(this.markerX); |
| 160 | const fy = this.fn.f(this.markerX); |
| 161 | const py = this.partialSum(this.markerX, terms); |
| 162 | ctx.strokeStyle = theme.fgTertiary; |
| 163 | ctx.setLineDash([3, 3]); |
| 164 | ctx.beginPath(); |
| 165 | ctx.moveTo(mx, 0); |
| 166 | ctx.lineTo(mx, this.height); |
| 167 | ctx.stroke(); |
| 168 | ctx.setLineDash([]); |
| 169 | ctx.fillStyle = theme.fg; |
| 170 | ctx.beginPath(); |
| 171 | ctx.arc(mx, toY(fy), 3.5, 0, Math.PI * 2); |
| 172 | ctx.fill(); |
| 173 | ctx.fillStyle = theme.accent; |
| 174 | ctx.beginPath(); |
| 175 | ctx.arc(mx, toY(py), 3.5, 0, Math.PI * 2); |
| 176 | ctx.fill(); |
| 177 | // Legend |
| 178 | ctx.font = "11px ui-monospace, monospace"; |
| 179 | ctx.fillStyle = theme.fgSecondary; |
| 180 | ctx.fillText(this.fn.label, 10, 16); |
| 181 | ctx.fillStyle = theme.accent; |
| 182 | ctx.fillText(`P${terms}(x), ${terms} TERMS`, 10, 30); |
| 183 | } |
| 184 | |
| 185 | getMetrics() { |
| 186 | const terms = this.num("terms"); |
| 187 | const f = this.fn.f(this.markerX); |
| 188 | const p = this.partialSum(this.markerX, terms); |
| 189 | let maxError = 0; |
| 190 | const [x0, x1] = this.fn.domain; |
| 191 | for (let i = 0; i <= 200; i++) { |
| 192 | const xv = x0 + ((x1 - x0) * i) / 200; |
| 193 | const fv = this.fn.f(xv); |
| 194 | if (Number.isNaN(fv)) continue; |
| 195 | maxError = Math.max(maxError, Math.abs(this.partialSum(xv, terms) - fv)); |
| 196 | } |
| 197 | return { |
| 198 | function: this.fn.label, |
| 199 | terms, |
| 200 | markerX: this.markerX.toFixed(2), |
| 201 | fAtMarker: Number.isNaN(f) ? "undefined" : f.toFixed(4), |
| 202 | pAtMarker: p.toExponential(3), |
| 203 | errorAtMarker: Number.isNaN(f) ? "n/a" : Math.abs(f - p).toExponential(2), |
| 204 | maxError: maxError.toExponential(2), |
| 205 | }; |
| 206 | } |
| 207 | |
| 208 | describe(): string { |
| 209 | const terms = this.num("terms"); |
| 210 | return `The order-${terms} Taylor polynomial of ${this.fn.label} compared against the true curve.`; |
| 211 | } |
| 212 | |
| 213 | entities(): number { |
| 214 | return this.num("terms"); |
| 215 | } |
| 216 | } |
| 217 | |
Performance
- Time per tick
- O(terms) per evaluated point
- Space
- None beyond the coefficient sequence
- Notes
- Coefficients are generated from each function's derivative recurrence, not hardcoded tables.
Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.