Eigenvectors & Eigenvalues
Directions a matrix cannot turn.
Intermediate / linear-algebra / geometry
Drag the entries of a 2x2 matrix and watch the unit circle's image deform. The accent lines are the eigenvector directions - the only vectors the matrix merely stretches - and they exist only when the characteristic polynomial has real roots, which the metrics report live.
Tagged: characteristic polynomial, invariant directions, trace, determinant
Live simulation
Drag the matrix entries; accent lines are the invariant directions. Keyboard: Space runs or pauses, . steps, r resets.
Speed
Drag the matrix entries; accent lines are the invariant directions.
Mathematics
An eigenvector of A is a vector that only gets scaled: Av = lambda v. For a 2x2 real matrix they come from the characteristic polynomial lambda^2 - (trace) lambda + det = 0 - real when the discriminant trace^2 - 4 det is non-negative.
lambda = (tr +- sqrt(tr^2 - 4 det)) / 2
eigenvector direction (when b != 0): v = (b, lambda - a)
verification: A v = lambda v
The drawn lines are those invariant directions. When the discriminant goes negative the eigenvalues leave the real axis: the circle's image becomes a rotating, scaling ellipse with no fixed directions - which is what the plot shows the moment the metric says 'complex'. The verification identity Av = lambda v is asserted by the test suite, not assumed.
How it works
The four matrix entries are sliders. The render draws the image of the unit circle under A, the transformed basis square, and the eigenvector lines computed from the closed-form roots, rescaled to the viewport.
Determinant and trace are shown live because every geometric statement about the transform - area factor, flip, trace-determinant classification - flows from those two numbers.
Implementation
src/engine/experiments/eigenvectors.ts · 147 lines · the module running above
TypeScript · fixed-timestep engine
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import type { ParameterDef, ThemeColors } from "../core/types"; |
| 3 | |
| 4 | /** |
| 5 | * Eigenvalues and eigenvectors of a real 2x2 matrix, computed from the |
| 6 | * characteristic polynomial and verified: each drawn eigenvector v satisfies |
| 7 | * A v = lambda v to machine precision (the test suite checks it too). |
| 8 | */ |
| 9 | export class EigenvectorsExperiment extends BaseExperiment { |
| 10 | readonly id = "eigen-basis"; |
| 11 | |
| 12 | private a = 2; |
| 13 | private b = 1; |
| 14 | private c = 0.5; |
| 15 | private d = 1; |
| 16 | |
| 17 | protected params(): ParameterDef[] { |
| 18 | return [ |
| 19 | { key: "a", label: "Matrix a (row 1, col 1)", min: -3, max: 3, step: 0.1, defaultValue: 2 }, |
| 20 | { key: "b", label: "Matrix b (row 1, col 2)", min: -2, max: 2, step: 0.1, defaultValue: 1 }, |
| 21 | { key: "c", label: "Matrix c (row 2, col 1)", min: -2, max: 2, step: 0.1, defaultValue: 0.5 }, |
| 22 | { key: "d", label: "Matrix d (row 2, col 2)", min: -3, max: 3, step: 0.1, defaultValue: 1 }, |
| 23 | ]; |
| 24 | } |
| 25 | |
| 26 | protected onReset(): void { |
| 27 | this.a = this.num("a"); |
| 28 | this.b = this.num("b"); |
| 29 | this.c = this.num("c"); |
| 30 | this.d = this.num("d"); |
| 31 | } |
| 32 | |
| 33 | protected onParameterChange(): void { |
| 34 | this.reset(); |
| 35 | } |
| 36 | |
| 37 | protected onUpdate(): void {} |
| 38 | |
| 39 | private apply(x: number, y: number): [number, number] { |
| 40 | return [this.a * x + this.b * y, this.c * x + this.d * y]; |
| 41 | } |
| 42 | |
| 43 | /** Real eigenpairs from the characteristic polynomial when they exist. */ |
| 44 | private eigenPairs(): { lambda: number; vx: number; vy: number }[] { |
| 45 | const trace = this.a + this.d; |
| 46 | const det = this.a * this.d - this.b * this.c; |
| 47 | const disc = trace * trace - 4 * det; |
| 48 | if (disc < 0) return []; |
| 49 | const sqrt = Math.sqrt(disc); |
| 50 | const l1 = (trace + sqrt) / 2; |
| 51 | const l2 = (trace - sqrt) / 2; |
| 52 | const vectorFor = (lambda: number): { vx: number; vy: number } => { |
| 53 | // (A - lambda I) v = 0: rows are [a-l, b] and [c, d-l]. |
| 54 | if (Math.abs(this.b) > 1e-9) return { vx: this.b, vy: lambda - this.a }; |
| 55 | if (Math.abs(this.c) > 1e-9) return { vx: lambda - this.d, vy: this.c }; |
| 56 | if (Math.abs(this.a - lambda) <= Math.abs(this.d - lambda)) return { vx: 1, vy: 0 }; |
| 57 | return { vx: 0, vy: 1 }; |
| 58 | }; |
| 59 | const pairs = [{ lambda: l1, ...vectorFor(l1) }]; |
| 60 | if (Math.abs(sqrt) > 1e-9) pairs.push({ lambda: l2, ...vectorFor(l2) }); |
| 61 | return pairs; |
| 62 | } |
| 63 | |
| 64 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 65 | ctx.fillStyle = theme.bg; |
| 66 | ctx.fillRect(0, 0, this.width, this.height); |
| 67 | const scale = Math.min(this.width, this.height) / 6.4; |
| 68 | const cx = this.width / 2; |
| 69 | const cy = this.height / 2; |
| 70 | ctx.strokeStyle = theme.line; |
| 71 | ctx.beginPath(); |
| 72 | ctx.moveTo(0, cy); |
| 73 | ctx.lineTo(this.width, cy); |
| 74 | ctx.moveTo(cx, 0); |
| 75 | ctx.lineTo(cx, this.height); |
| 76 | ctx.stroke(); |
| 77 | // Transformed unit circle: the image of the circle under A. |
| 78 | ctx.strokeStyle = theme.viz[1]; |
| 79 | ctx.lineWidth = 1.5; |
| 80 | ctx.beginPath(); |
| 81 | for (let deg = 0; deg <= 360; deg += 2) { |
| 82 | const rad = (deg * Math.PI) / 180; |
| 83 | const [px, py] = this.apply(Math.cos(rad), Math.sin(rad)); |
| 84 | const sx = cx + px * scale; |
| 85 | const sy = cy - py * scale; |
| 86 | if (deg === 0) ctx.moveTo(sx, sy); |
| 87 | else ctx.lineTo(sx, sy); |
| 88 | } |
| 89 | ctx.stroke(); |
| 90 | // Transformed basis square (light) |
| 91 | ctx.strokeStyle = theme.line; |
| 92 | ctx.beginPath(); |
| 93 | const corners: [number, number][] = [[1, 1], [-1, 1], [-1, -1], [1, -1]]; |
| 94 | corners.forEach(([x, y], index) => { |
| 95 | const [px, py] = this.apply(x, y); |
| 96 | const sx = cx + px * scale; |
| 97 | const sy = cy - py * scale; |
| 98 | if (index === 0) ctx.moveTo(sx, sy); |
| 99 | else ctx.lineTo(sx, sy); |
| 100 | }); |
| 101 | ctx.closePath(); |
| 102 | ctx.stroke(); |
| 103 | // Eigenvector directions: invariant lines through the origin. |
| 104 | let pairIndex = 0; |
| 105 | for (const pair of this.eigenPairs()) { |
| 106 | const norm = Math.hypot(pair.vx, pair.vy) || 1; |
| 107 | const ex = (pair.vx / norm) * 2.8 * scale; |
| 108 | const ey = (pair.vy / norm) * 2.8 * scale; |
| 109 | ctx.strokeStyle = pairIndex === 0 ? theme.accent : theme.accentStrong; |
| 110 | ctx.lineWidth = 2; |
| 111 | ctx.beginPath(); |
| 112 | ctx.moveTo(cx - ex, cy + ey); |
| 113 | ctx.lineTo(cx + ex, cy - ey); |
| 114 | ctx.stroke(); |
| 115 | pairIndex += 1; |
| 116 | } |
| 117 | ctx.font = "11px ui-monospace, monospace"; |
| 118 | ctx.fillStyle = theme.fgTertiary; |
| 119 | ctx.fillText(`A = [[${this.a.toFixed(1)}, ${this.b.toFixed(1)}], [${this.c.toFixed(1)}, ${this.d.toFixed(1)}]]`, 10, 16); |
| 120 | } |
| 121 | |
| 122 | getMetrics() { |
| 123 | const pairs = this.eigenPairs(); |
| 124 | const trace = this.a + this.d; |
| 125 | const det = this.a * this.d - this.b * this.c; |
| 126 | return { |
| 127 | lambda1: pairs[0] ? pairs[0].lambda.toFixed(3) : "complex", |
| 128 | lambda2: pairs[1] ? pairs[1].lambda.toFixed(3) : "complex", |
| 129 | trace: trace.toFixed(2), |
| 130 | determinant: det.toFixed(2), |
| 131 | realEigenvectors: pairs.length, |
| 132 | }; |
| 133 | } |
| 134 | |
| 135 | describe(): string { |
| 136 | const pairs = this.eigenPairs(); |
| 137 | if (pairs.length === 0) { |
| 138 | return "This matrix has complex eigenvalues: the unit circle's image rotates and scales without invariant directions."; |
| 139 | } |
| 140 | return `Two real eigenvector directions drawn in accent; along them the matrix acts as pure scaling by ${pairs[0].lambda.toFixed(2)} (and ${pairs[1]?.lambda.toFixed(2) ?? "n/a"}).`; |
| 141 | } |
| 142 | |
| 143 | entities(): number { |
| 144 | return 2; |
| 145 | } |
| 146 | } |
| 147 | |
Performance
- Time per tick
- O(1): a handful of arithmetic operations
- Space
- None
- Notes
- Set b = c = 0 and the matrix is diagonal: the eigenvectors snap to the axes, exactly as the algebra predicts.
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.