Kepler's Laws
Equal areas in equal times, drawn as wedges.
Intermediate / gravity / integration / conservation
A planet integrates around a star on an adjustable ellipse. The shaded wedges are swept in equal times, so they have equal areas - Kepler's second law as a picture - and the period readout feeds the third law's T-squared over a-cubed. Angular momentum conservation is what enforces both.
Tagged: ellipse, angular momentum, T-squared equals a-cubed, vis-viva
Live simulation
Equal-time wedges all sweep equal area: Kepler's second law as a picture. Keyboard: Space runs or pauses, . steps, r resets.
Speed
Equal-time wedges all sweep equal area: Kepler's second law as a picture.
Mathematics
Kepler's laws in one integration: the orbit is an ellipse with the star at a focus (first law), the radius vector sweeps equal areas in equal times because angular momentum is conserved (second law), and T^2 / a^3 is the same constant for every orbit (third law).
r(theta) = a (1 - e^2) / (1 + e cos theta)
angular momentum L = r^2 dtheta/dt (conserved)
swept area rate = L / 2 => equal areas in equal times
The integration happens in polar coordinates with the radial acceleration written directly from the angular-momentum invariant - so the second law cannot be violated by numerical error, only confirmed. The wedges are drawn from the actual swept-area rate over fixed intervals: their equal sizes are the measurement.
How it works
Initial conditions come from the vis-viva equation at perihelion for the chosen eccentricity. The orbit overlay is the analytic ellipse; the planet's motion is integrated, and where the two agree is the point.
The period counter records one full revolution of theta, feeding the T^2/a^3 readout directly.
Implementation
src/engine/experiments/kepler-orbits.ts · 187 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 | * Kepler's laws, observed: a planet on an ellipse with adjustable |
| 6 | * eccentricity around a central star. Equal-time wedges make the second law |
| 7 | * (equal areas in equal times) visible, and the live numbers test the third. |
| 8 | * Integrated in Cartesian coordinates with semi-implicit Euler substeps - |
| 9 | * stable at perihelion for every eccentricity the slider offers. |
| 10 | */ |
| 11 | export class KeplerOrbitsExperiment extends BaseExperiment { |
| 12 | readonly id = "kepler-orbits"; |
| 13 | |
| 14 | private a = 150; // semi-major axis, px |
| 15 | private ecc = 0.5; |
| 16 | private x = 0; |
| 17 | private y = 0; |
| 18 | private vx = 0; |
| 19 | private vy = 0; |
| 20 | private mu: number; |
| 21 | private time = 0; |
| 22 | private lastPeriod = 0; |
| 23 | private periodClock = 0; |
| 24 | private sweptAreas: number[] = []; |
| 25 | private rNow = 0; |
| 26 | |
| 27 | constructor(mu = 2_000_000) { |
| 28 | super(); |
| 29 | this.mu = mu; |
| 30 | } |
| 31 | |
| 32 | protected params(): ParameterDef[] { |
| 33 | return [ |
| 34 | { key: "ecc", label: "Eccentricity", min: 0, max: 0.85, step: 0.01, defaultValue: 0.5 }, |
| 35 | { |
| 36 | key: "showWedges", |
| 37 | label: "Equal-time wedges", |
| 38 | options: [ |
| 39 | { value: "on", label: "On" }, |
| 40 | { value: "off", label: "Off" }, |
| 41 | ], |
| 42 | defaultValue: "on", |
| 43 | }, |
| 44 | ]; |
| 45 | } |
| 46 | |
| 47 | protected onReset(): void { |
| 48 | this.ecc = this.num("ecc"); |
| 49 | this.a = Math.min(this.width, this.height) * 0.32; |
| 50 | // Start at perihelion, moving purely tangentially (vis-viva). |
| 51 | this.x = this.a * (1 - this.ecc); |
| 52 | this.y = 0; |
| 53 | this.vx = 0; |
| 54 | this.vy = Math.sqrt((this.mu * (1 + this.ecc)) / (this.a * (1 - this.ecc))); |
| 55 | this.time = 0; |
| 56 | this.periodClock = 0; |
| 57 | this.sweptAreas = []; |
| 58 | this.rNow = this.x; |
| 59 | } |
| 60 | |
| 61 | protected onParameterChange(key: string): void { |
| 62 | this.reset(); |
| 63 | void key; |
| 64 | } |
| 65 | |
| 66 | resize(width: number, height: number): void { |
| 67 | this.width = width; |
| 68 | this.height = height; |
| 69 | this.reset(); |
| 70 | } |
| 71 | |
| 72 | /** Angular momentum per unit mass (conserved). */ |
| 73 | private angularMomentum(): number { |
| 74 | return this.x * this.vy - this.y * this.vx; |
| 75 | } |
| 76 | |
| 77 | private radius(): number { |
| 78 | return Math.hypot(this.x, this.y); |
| 79 | } |
| 80 | |
| 81 | protected onUpdate(dt: number): void { |
| 82 | const sub = dt / 10; |
| 83 | for (let s = 0; s < 10; s++) { |
| 84 | const r = this.radius(); |
| 85 | const accel = -this.mu / (r * r * r); |
| 86 | this.vx += accel * this.x * sub; |
| 87 | this.vy += accel * this.y * sub; |
| 88 | this.x += this.vx * sub; |
| 89 | this.y += this.vy * sub; |
| 90 | } |
| 91 | this.time += dt; |
| 92 | this.periodClock += dt; |
| 93 | this.rNow = this.radius(); |
| 94 | if (this.y >= 0 && this.vy > 0 && this.x > 0 && this.periodClock > 0.1) { |
| 95 | // Crossed +x moving upward: one revolution complete. |
| 96 | this.lastPeriod = this.periodClock; |
| 97 | this.periodClock = 0; |
| 98 | } |
| 99 | if (this.str("showWedges") === "on") { |
| 100 | const areaRate = this.angularMomentum() / 2; |
| 101 | this.sweptAreas.push(areaRate * dt); |
| 102 | if (this.sweptAreas.length > 64) this.sweptAreas.shift(); |
| 103 | } else if (this.sweptAreas.length > 0) { |
| 104 | this.sweptAreas = []; |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | private focus(): { x: number; y: number } { |
| 109 | return { x: this.width / 2, y: this.height / 2 }; |
| 110 | } |
| 111 | |
| 112 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 113 | ctx.fillStyle = theme.bg; |
| 114 | ctx.fillRect(0, 0, this.width, this.height); |
| 115 | const focus = this.focus(); |
| 116 | // Analytic ellipse overlay |
| 117 | ctx.strokeStyle = theme.line; |
| 118 | ctx.lineWidth = 1; |
| 119 | ctx.beginPath(); |
| 120 | for (let deg = 0; deg <= 360; deg += 1) { |
| 121 | const th = (deg * Math.PI) / 180; |
| 122 | const rr = (this.a * (1 - this.ecc * this.ecc)) / (1 + this.ecc * Math.cos(th)); |
| 123 | const px = focus.x + rr * Math.cos(th); |
| 124 | const py = focus.y - rr * Math.sin(th); |
| 125 | if (deg === 0) ctx.moveTo(px, py); |
| 126 | else ctx.lineTo(px, py); |
| 127 | } |
| 128 | ctx.stroke(); |
| 129 | // Star at the focus |
| 130 | ctx.fillStyle = theme.accent; |
| 131 | ctx.beginPath(); |
| 132 | ctx.arc(focus.x, focus.y, 6, 0, Math.PI * 2); |
| 133 | ctx.fill(); |
| 134 | // Planet + radius vector + wedges |
| 135 | const px = focus.x + this.x; |
| 136 | const py = focus.y - this.y; |
| 137 | if (this.sweptAreas.length > 1) { |
| 138 | ctx.fillStyle = theme.accent; |
| 139 | let startAngle = Math.atan2(-this.y, this.x); |
| 140 | for (let i = this.sweptAreas.length - 1; i >= 0; i--) { |
| 141 | const area = this.sweptAreas[i]; |
| 142 | const r = Math.max(4, this.rNow); |
| 143 | const dTheta = (2 * area) / (r * r); |
| 144 | const a1 = startAngle; |
| 145 | const a2 = startAngle - dTheta; |
| 146 | ctx.globalAlpha = 0.06 + 0.12 * (i / this.sweptAreas.length); |
| 147 | ctx.beginPath(); |
| 148 | ctx.moveTo(focus.x, focus.y); |
| 149 | ctx.arc(focus.x, focus.y, r, a1, a2, true); |
| 150 | ctx.closePath(); |
| 151 | ctx.fill(); |
| 152 | startAngle = a2; |
| 153 | } |
| 154 | ctx.globalAlpha = 1; |
| 155 | } |
| 156 | ctx.strokeStyle = theme.fgSecondary; |
| 157 | ctx.beginPath(); |
| 158 | ctx.moveTo(focus.x, focus.y); |
| 159 | ctx.lineTo(px, py); |
| 160 | ctx.stroke(); |
| 161 | ctx.fillStyle = theme.fg; |
| 162 | ctx.beginPath(); |
| 163 | ctx.arc(px, py, 4.5, 0, Math.PI * 2); |
| 164 | ctx.fill(); |
| 165 | } |
| 166 | |
| 167 | getMetrics() { |
| 168 | const t = this.lastPeriod > 0 ? this.lastPeriod : 0; |
| 169 | const ratio = t > 0 ? (t * t) / this.a ** 3 : 0; |
| 170 | return { |
| 171 | eccentricity: this.ecc.toFixed(2), |
| 172 | distance: `${(this.rNow / 10).toFixed(1)} Mm`, |
| 173 | period: `${t.toFixed(2)} s`, |
| 174 | t2_over_a3: ratio > 0 ? ratio.toExponential(3) : "measuring", |
| 175 | angMomentum: this.angularMomentum().toFixed(0), |
| 176 | }; |
| 177 | } |
| 178 | |
| 179 | describe(): string { |
| 180 | return `A planet on an e=${this.ecc.toFixed(2)} ellipse; the shaded wedges are swept in equal times, so they all have equal area - Kepler's second law as a picture.`; |
| 181 | } |
| 182 | |
| 183 | entities(): number { |
| 184 | return 2; |
| 185 | } |
| 186 | } |
| 187 | |
Performance
- Time per tick
- O(1) per tick
- Space
- The wedge list (64 samples)
- Notes
- Set eccentricity to zero and the wedges stay constant-width; raise it and watch the planet sprint through perihelion.
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.