Double pendulum · seed-2.0-mini
Simulate a chaotic double pendulum on a canvas using the correct equations of motion, drawing the arms and a colorful fading trail of the tip.
transcript · 4 messages
Simulate a chaotic double pendulum on a canvas using the correct equations of motion, drawing the arms and a colorful fading trail of the tip.
thinking
Got it, let's tackle this. First, I need to make a single-page web app with a canvas, double pendulum simulation using the correct equations. Let's start by recalling the double pendulum physics.
First, the double pendulum equations: let's list the parameters. Two masses m1, m2, lengths L1, L2. The state is theta1, theta2, their angular velocities omega1, omega2. The equations of motion are derived from Lagrangian mechanics. Let's get the formulas right:
The acceleration terms (alpha1, alpha2) are:
alpha1 = ( -g (2m1 + m2) sinθ1 - m2 g sin(θ1 - 2θ2) - 2 sin(θ1 - θ2) m2 (L2 ω2² + L1 ω1² cos(θ1 - θ2)) ) / ( L1 (2m1 + m2 - m2 cos(2θ1 - 2θ2)) )
alpha2 = ( 2 sin(θ1 - θ2) ( L1 ω1² (m1 + m2) + g (m1 + m2) cosθ1 + L2 ω2² m2 cos(θ1 - θ2) ) ) / ( L2 (2m1 + m2 - m2 cos(2θ1 - 2θ2)) )
Right, and g is gravity, like 9.81 m/s² but we can scale it for the canvas.
Then, we need to set up the canvas: full width/height, black background? Wait, no, white background with trails. The trail should fade, so we can use a semi-transparent overlay each frame instead of clearing the whole canvas every time, that way the old points fade out.
Wait, for the trail: every time we draw the current tip position, we can draw a line from the previous tip to current, but with a stroke that fades, or use a globalAlpha that's low, and each frame draw a semi-transparent black over the canvas to make the trails fade over time. Yeah, that's easier.
Let's outline the steps:
1. Create an HTML file with a canvas element that takes the full viewport, no margins.
2. Style the canvas to be full screen, overflow hidden on body so no scrollbars.
3. Get the canvas context in JS, set its width/height to window.innerWidth/innerHeight, handle resize.
4. Initialize the double pendulum parameters: let's set m1 = m2 = 1 kg, L1 = 150 px, L2 = 150 px, g = 981 cm/s² (scaled for pixels, since 1m is 100px so g is 981 instead of 9.81).
5. Initialize the state variables: theta1 = Math.PI/2, theta2 = Math.PI/2 (starting angles, perpendicular up? Or start with some offset, like theta1=90 degrees, theta2=45 degrees). Omega1 = 0, omega2=0 initially.
6. Set up a simulation loop using requestAnimationFrame.
7. Each frame:
a. Apply the equations to compute alpha1 and alpha2.
b. Update omega1 += alpha1 * dt, omega2 += alpha2 * dt.
c. Update theta1 += omega1 * dt, theta2 += omega2 * dt.
d. Compute the positions: origin is at (canvas.width/2, 50) maybe, so the pendulum hangs from the top middle.
- x1 = originX + L1 * Math.sin(theta1)
- y1 = originY + L1 * Math.cos(theta1) (since cos(theta) is vertical, theta=0 is straight up, wait no: wait if theta is 0 pointing straight down, then y would be originY + L1*cos(theta - Math.PI/2)? Wait no, let's get the coordinate system right: standard canvas has (0,0) top-left, y increases downward. So if theta1 is the angle from the vertical (down is positive?), wait no, let's use theta1 as the angle from the positive y-axis downward? Wait no, let's just use: for a pendulum arm at angle theta from the vertical (upwards is negative y), so:
Wait, let's correct: the first pendulum is attached at (ox, oy). The end of the first arm is at:
x1 = ox + L1 * Math.sin(theta1)
y1 = oy + L1 * Math.cos(theta1)
Wait because when theta1 is 0, sin(0)=0, cos(0)=1, so y1 is oy + L1, which is straight down from oy? Wait no, oy is the pivot point, so if oy is 50, then y1=50+150=200, which is below the pivot. Oh right, that's correct. Because cos(theta1) when theta1 is 0 is 1, so y increases downward, which matches canvas coordinates. Perfect. Then the second pendulum is attached at (x1,y1), so its end is:
x2 = x1 + L2 * Math.sin(theta2)
y2 = y1 + L2 * Math.cos(theta2)
Yes, that makes sense.
8. Then, draw the trail: we need to keep a list of past (x2,y2) positions, but to make it fade, instead of keeping all points, we can draw a semi-transparent rectangle over the canvas each frame, then draw the current arms and the trail up to the current point. Wait, or use globalAlpha for the trail. Let's say we have an array trail that stores the last, say, 200 points, but even better: each frame, draw a line from the previous tip to current, but with a stroke style that's a gradient or semi-transparent, but actually the fade effect is better done by filling the canvas with a semi-transparent color each frame, so that older drawings get more transparent over time. So for each frame:
- Save the context, set globalAlpha to 0.05, fillRect(0,0,canvas.width,canvas.height) with black, then restore globalAlpha. That way each frame adds a new layer of semi-transparent black, making the old trails fade slowly.
- Then draw the pendulum arms: two lines, first from pivot to x1,y1, then from x1,y1 to x2,y2. Draw circles at the pivot, m1, m2: maybe larger circle for m2, since mass is same here, but same size, or different.
- Then draw the current tip (x2,y2) as a small circle, and add it to the trail? Wait no, wait if we do the fade each frame, then if we draw the trail every frame as a line from all previous points? Wait no, better to have a trail array that keeps the last N positions, and each frame draw a polyline through the trail, with a stroke that gets thinner or more transparent as it goes, but actually for simplicity, just draw a line from each previous point to the current, but since we have the semi-transparent overlay, the trail will fade naturally. Wait, no, let's just store the last, say, 100 positions of the second pendulum's tip, then each frame draw a line connecting all of them, with a stroke style of, say, hsl( hue, saturation, lightness ), where hue changes based on time or something for colorful. Oh right, the user asked for a colorful fading trail, so let's make the trail rainbow colored, fading over time.
9. Also, handle window resizing: add an event listener to resize the canvas when the window changes size.
10. Let's set the pivot point at (window.innerWidth/2, 50), so top middle of the screen.
11. Let's initialize the trail array as empty, and each frame push the current (x2,y2) into it, and if the trail is longer than, say, 200 points, shift the first element out, so we don't have too many points.
12. Now, the time step dt: we need to use a fixed time step for the simulation, or use delta time from requestAnimationFrame. Wait, requestAnimationFrame gives a timestamp, so we can compute dt as the time since last frame, but to keep the simulation stable, better to use a fixed dt, like 1/60 seconds, and accumulate the time, then step the simulation each fixed dt. Oh right, because if the frame rate drops, the delta time increases, which can make the simulation unstable. So let's use a fixed time step of 0.016666 (1/60) seconds, and accumulate the elapsed time since last frame, then run multiple simulation steps if needed to catch up.
13. Let's code all that into a single index.html file. Let's put all CSS in a <style> tag, JS in a <script> tag, no external resources.
Wait, let's write the code step by step:
First, the HTML structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chaotic Double Pendulum</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #000; }
canvas { display: block; }
</style>
</head>
<body>
<canvas id="pendulumCanvas"></canvas>
<script>
// JS code here
</script>
</body>
</html>
Then, inside the script tag:
First, get the canvas and context:
const canvas = document.getElementById('pendulumCanvas');
const ctx = canvas.getContext('2d');
Then set canvas size to window size:
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
Then, set up the pendulum parameters:
// Pendulum parameters
const m1 = 1; // mass 1 (kg)
const m2 = 1; // mass 2 (kg)
const L1 = 150; // length 1 (px)
const L2 = 150; // length 2 (px)
const g = 981; // gravity (scaled for pixels: 9.81 m/s² = 981 cm/s², since 1px = 0.01m)
// Initial state: angles in radians, angular velocities
let theta1 = Math.PI / 2; // 90 degrees from vertical (left horizontal)
let theta2 = Math.PI / 3; // 60 degrees from vertical
let omega1 = 0; // angular velocity 1
let omega2 = 0; // angular velocity 2
// Trail for the second pendulum tip
const trail = [];
const MAX_TRAIL_LENGTH = 200;
// Pivot point
let originX = canvas.width / 2;
let originY = 50;
// Time tracking for fixed step simulation
let lastTime = 0;
const FIXED_DT = 1/60; // 60 FPS fixed step
let accumulator = 0;
Then, the simulation function: function to compute the accelerations:
function computeAccelerations() {
const deltaTheta = theta1 - theta2;
const sinDelta = Math.sin(deltaTheta);
const cosDelta = Math.cos(deltaTheta);
const cos2Delta = Math.cos(2 * deltaTheta);
// Denominator for alpha1 and alpha2
const denom = 2 * m1 + m2 - m2 * cos2Delta;
// Alpha1 (angular acceleration 1)
const num1 = -g * (2 * m1 + m2) * Math.sin(theta1) - m2 * g * Math.sin(theta1 - 2 * theta2) - 2 * sinDelta * m2 * (L2 * omega2**2 + L1 * omega1**2 * cosDelta);
const alpha1 = num1 / (L1 * denom);
// Alpha2 (angular acceleration 2)
const num2 = 2 * sinDelta * (L1 * omega1**2 * (m1 + m2) + g * (m1 + m2) * Math.cos(theta1) + L2 * omega2**2 * m2 * cosDelta);
const alpha2 = num2 / (L2 * denom);
return { alpha1, alpha2 };
}
Then, the update function for the physics:
function updatePhysics(dt) {
// Update angular velocities
const { alpha1, alpha2 } = computeAccelerations();
omega1 += alpha1 * dt;
omega2 += alpha2 * dt;
// Update angles
theta1 += omega1 * dt;
theta2 += omega2 * dt;
// Compute current positions
const x1 = originX + L1 * Math.sin(theta1);
const y1 = originY + L1 * Math.cos(theta1);
const x2 = x1 + L2 * Math.sin(theta2);
const y2 = y1 + L2 * Math.cos(theta2);
return { x1, y1, x2, y2 };
}
Then, the draw function:
function drawFrame(x1, y1, x2, y2) {
// Add current tip to trail
trail.push({ x: x2, y: y2 });
if (trail.length > MAX_TRAIL_LENGTH) {
trail.shift();
}
// Fade trails: semi-transparent black overlay
ctx.save();
ctx.globalAlpha = 0.05;
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore();
// Draw pivot point
ctx.beginPath();
ctx.arc(originX, originY, 8, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
// Draw first arm and mass
ctx.beginPath();
ctx.moveTo(originX, originY);
ctx.lineTo(x1, y1);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 3;
ctx.stroke();
ctx.beginPath();
ctx.arc(x1, y1, 12, 0, Math.PI * 2);
ctx.fillStyle = '#4a90e2';
ctx.fill();
// Draw second arm and mass
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 3;
ctx.stroke();
ctx.beginPath();
ctx.arc(x2, y2, 12, 0, Math.PI * 2);
ctx.fillStyle = '#e24a90';
ctx.fill();
// Draw colorful trail
if (trail.length > 1) {
ctx.beginPath();
ctx.moveTo(trail[0].x, trail[0].y);
for (let i = 1; i < trail.length; i++) {
// HSL color based on index in trail, so it's rainbow and fades
const hue = (i / trail.length) * 360;
ctx.strokeStyle = `hsl(${hue}, 100%, 60%)`;
ctx.lineWidth = 2;
ctx.lineTo(trail[i].x, trail[i].y);
ctx.stroke();
// Wait no, that will start a new path each time, better to do a single path with gradient? Wait no, actually, for each segment, set the stroke style based on the position. Wait, alternatively, use a loop and draw each segment with increasing hue:
// Wait let's correct that: instead of drawing the whole trail at once with one color, each segment between trail[i-1] and trail[i] has a color that's a function of time or position. Let's use the hue based on the current time, so it changes over time. Oh right, the user wanted colorful fading trail, so let's make the hue change over time, so the trail is rainbow and shifts. Let's use:
// const hue = (Date.now() / 50) % 360; No, wait, each part of the trail should have a different hue. Wait, actually, the earlier idea of hue based on index in the trail is good, because the oldest part of the trail is one hue, the newest is another. Let's adjust the trail drawing:
}
// Wait, let's redo the trail drawing properly:
ctx.beginPath();
if (trail.length > 1) {
// Move to the first point
ctx.moveTo(trail[0].x, trail[0].y);
// Draw each segment
for (let i = 1; i < trail.length; i++) {
ctx.lineTo(trail[i].x, trail[i].y);
// Set stroke style for this segment
const hue = (i / trail.length) * 360 + (Date.now() / 100) % 360;
ctx.strokeStyle = `hsl(${hue % 360}, 100%, 60%)`;
ctx.lineWidth = 2;
ctx.stroke();
// Wait no, again, that's per segment, but you need to beginPath each time? No, wait no, if you do ctx.lineTo then ctx.stroke, it will stroke the current path each time, which is not right. Oh right, better to create a gradient or use a single path with a stroke that's a gradient, but actually for simplicity, let's just use a single stroke style that changes over time, but make the trail fade by the alpha we set earlier. Wait, no, the semi-transparent overlay already makes the trail fade over time, so the trail will get lighter as it gets older. Alternatively, we can set the globalAlpha for the trail based on the index: for the i-th point in the trail, alpha is i / trail.length, so the oldest points are more transparent. Oh right, that's a better way for the trail to fade along its length. Let's do that:
Wait let's rewrite the trail drawing part:
// Draw colorful fading trail
if (trail.length > 1) {
ctx.beginPath();
ctx.moveTo(trail[0].x, trail[0].y);
for (let i = 1; i < trail.length; i++) {
ctx.lineTo(trail[i].x, trail[i].y);
}
// Set stroke style and width
ctx.lineWidth = 2;
// Create a linear gradient along the trail? Or use HSL with hue changing based on time, and alpha based on position in trail
const baseHue = (Date.now() / 50) % 360;
ctx.strokeStyle = `hsl(${baseHue}, 100%, 60%)`;
// But to make the trail fade along its length, we can use globalAlpha, but wait no, per segment:
// Alternatively, use a loop to draw each segment with its own alpha and hue:
ctx.beginPath();
for (let i = 0; i < trail.length - 1; i++) {
const alpha = (i + 1) / trail.length;
const hue = (baseHue + (i / trail.length) * 360) % 360;
ctx.strokeStyle = `hsla(${hue}, 100%, 60%, ${alpha})`;
ctx.lineWidth = 2;
ctx.moveTo(trail[i].x, trail[i].y);
ctx.lineTo(trail[i+1].x, trail[i+1].y);
ctx.stroke();
}
}
That way, each segment of the trail has a hue that shifts along the trail, and the oldest segments are more transparent (since alpha is (i+1)/trail.length, so i=0 is 1/trail.length, i=trail.length-1 is 1, so newest segment is fully opaque, oldest is almost transparent). Perfect, that's what the user asked for: colorful fading trail.
Then, the main animation loop:
function animate(timestamp) {
// Calculate delta time
if (lastTime === 0) lastTime = timestamp;
const deltaTime = (timestamp - lastTime) / 1000; // convert to seconds
lastTime = timestamp;
// Accumulate time
accumulator += deltaTime;
// Update physics in fixed time steps
while (accumulator >= FIXED_DT) {
// Update origin in case window was resized
originX = canvas.width / 2;
originY = 50;
const { x1, y1, x2, y2 } = updatePhysics(FIXED_DT);
accumulator -= FIXED_DT;
}
// Draw the current state
// Wait, wait, no: the updatePhysics gives the current positions, but we need to draw the latest positions. Wait, actually, the last call to updatePhysics will have the correct positions for the current accumulated time. Wait, no, let's adjust: actually, we should call updatePhysics as many times as needed in the fixed step loop, and each time compute the positions, but wait no, the positions are updated each step, so after the loop, the latest positions are the ones from the last updatePhysics call. Wait, but actually, let's compute the current positions once after the fixed step loop:
// Wait, let's rework the animate function:
// Wait, no, let's compute the positions inside the loop? No, better to compute the positions once after all the physics steps. Wait, let's see:
// Let's reinitialize the positions variables:
let currentX1, currentY1, currentX2, currentY2;
// Then, inside the fixed step loop:
while (accumulator >= FIXED_DT) {
originX = canvas.width / 2;
originY = 50;
const pos = updatePhysics(FIXED_DT);
currentX1 = pos.x1;
currentY1 = pos.y1;
currentX2 = pos.x2;
currentY2 = pos.y2;
accumulator -= FIXED_DT;
}
// Then draw the current positions:
drawFrame(currentX1, currentY1, currentX2, currentY2);
// Request next frame
requestAnimationFrame(animate);
}
// Start the animation
requestAnimationFrame(animate);
Wait, but wait, the first time lastTime is 0, so deltaTime is 0, then accumulator is deltaTime, which is 0, so the loop doesn't run, then currentX1 etc are undefined. Oh right, so we need to initialize currentX1, currentY1, currentX2, currentY2 before the loop. Let's do that:
// Initialize current positions
let currentX1 = originX + L1 * Math.sin(theta1);
let currentY1 = originY + L1 * Math.cos(theta1);
let currentX2 = currentX1 + L2 * Math.sin(theta2);
let currentY2 = currentY1 + L2 * Math.cos(theta2);
Then, inside the animate function, after the fixed step loop, we can draw those positions. Wait, no, because the updatePhysics function updates the state, so after the first frame, the positions will be correct. Wait, actually, let's test: the first time animate runs, timestamp is 0, lastTime is 0, deltaTime is 0, accumulator is 0, so the loop doesn't run. Then we draw the initial positions, which is correct. Then next frame, timestamp is higher, deltaTime is the time since last frame, accumulator increases, then we run the fixed steps, update the physics, get the current positions, then draw them. That makes sense.
Wait, but in the drawFrame function, we push the currentX2, currentY2 into the trail, which is correct.
Wait, let's also add some controls? No, the user didn't ask for controls, just the simulation. But maybe let's make the initial angles random? Or let the user click to set the pivot? No, no, the user said "simulate a chaotic double pendulum", so the default initial state is fine, but maybe let's make the initial angles random so each load is different. Oh right, that's better, so that every time you reload the page, you get a different chaotic path. Let's change the initial theta1 and theta2:
// Initial state: random angles
let theta1 = Math.random() * Math.PI * 2;
let theta2 = Math.random() * Math.PI * 2;
// Or start with some offset, like:
// let theta1 = Math.PI / 2 + (Math.random() - 0.5) * 0.1;
// let theta2 = Math.PI / 2 + (Math.random() - 0.5) * 0.1;
// But random is fine for chaotic behavior.
Wait, but if we start with omega1 and omega2 as 0, that's okay, the chaos will build up.
Another thing: the trail array is global, and each frame we push the currentX2, currentY2 into it, which is correct.
Wait, let's check the computeAccelerations function again. Let's make sure the formulas are correct. Let's look up the double pendulum equations again: yes, the Lagrangian gives the same equations as we have. The denominator is 2m1 + m2 - m2 cos(2(theta1 - theta2)), which is correct. The numerators are correct too.
Another thing: the coordinate system. Let's confirm: when theta1 is Math.PI/2, sin(theta1) is 1, cos(theta1) is 0, so x1 is originX + L1*1, y1 is originY + 0, so the first pendulum is pointing to the right, horizontal, which matches. If theta1 is 0, then x1 is originX, y1 is originY + L1, which is straight down, correct for canvas coordinates. Perfect.
What about the mass sizes? We made m1's circle blue, m2's pink, that's colorful. The pivot is white, arms are white, trail is rainbow fading, that's what the user asked for.
Wait, the CSS: we set body to overflow hidden, canvas to display block, so no scrollbars, full screen. Correct.
Now, let's put all this together into a single index.html file. Let's check for any errors:
Wait, in the drawFrame function, we have:
ctx.save();
ctx.globalAlpha = 0.05;
ctx.fillStyle = '#000';
ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.restore();
That's correct, this makes the trails fade over time, because each frame we add a semi-transparent black layer, so older drawings become more transparent.
Then, the trail drawing: we loop through the trail array, draw each segment with a hue that's based on the index and the current time, so the trail is rainbow and shifts over time, and the alpha is based on the index, so the oldest parts are more transparent. Perfect.
Wait, let's test the code: when we run this, the pendulum should start moving, the arms are white, the masses are blue and pink, the trail is a colorful fading line behind the second mass. The chaos should be obvious: small changes in initial conditions lead to very different paths.
Another thing: the FIXED_DT is 1/60, which is 16.666ms per step, which is correct for 60 FPS. The accumulator makes sure that even if the frame rate drops, the simulation doesn't speed up or slow down, it just runs more steps per frame to catch up. That's important for stable physics.
Wait, let's adjust the MAX_TRAIL_LENGTH to 300, so the trail is longer. Or leave it at 200, that's fine.
Also, let's set the lineWidth for the arms to 3, which is visible, not too thin.
Another thing: the originY is 50, so the pivot is 50 pixels from the top of the screen, which is good, so the pendulum has enough space to swing down.
Now, let's write the full code into index.html. Let's make sure all the code is inside the script tag, no external resources.
Wait, let's also add a comment at the top, but no, it's self-contained.
Wait, let's check for any missing variables: originX and originY are updated each frame in the fixed step loop, so if the window is resized, the pivot is always at the middle top. Correct.
Wait, in the updatePhysics function, we use originX and originY, which are updated each frame, so that's correct.
Another thing: when we push the trail into the array, we push the currentX2 and currentY2, which are the positions after the physics steps, correct.
Okay, let's put all this together into the index.html file. Let's use the write tool to create the file./tmp/ws/index.html (6916 bytes)Successfully wrote 6916 bytes to /tmp/ws/index.html
thinking
Great, the file was written successfully. Now let's verify that it's a self-contained single-page app with no external resources. Let's check the file: it has all CSS in a <style> tag, JS in a <script> tag, uses only the canvas element, no external CDNs, no network requests. Perfect. The simulation uses the correct double pendulum equations of motion, has a fading colorful trail, draws the arms and masses, handles window resizing, and uses fixed time steps for stable physics. The user asked for exactly that: a chaotic double pendulum on a canvas with correct equations, arms, and colorful fading trail. Now I should confirm that the file is ready to be opened in a browser.
The double pendulum simulation has been successfully created as a single self-contained `index.html` file in your current working directory. Key features: 1. Uses exact Lagrangian equations of motion for chaotic double pendulum behavior 2. Full-screen canvas with black background 3. White pendulum arms, blue/pink colored masses, and a shifting rainbow fading trail for the second pendulum tip 4. Automatic window resizing support 5. Fixed time-step physics for stable, consistent simulation regardless of frame rate 6. Colorful, fading trail that shifts hue over time and fades with age To run the simulation, simply open `index.html` in any modern web browser. Each reload will start with a unique random initial condition for different chaotic patterns.
TAKE IT TO CODE
A single self-contained HTML file — copy it and open in any browser, or reuse the prompt with your own pi-agent run.
RUN
EVALUATION
Double pendulum simulation animates with colorful fading trails, but motion is oddly constrained near the pivot, resembling a cone rather than chaotic swings.
Vision grade · anthropic/claude-sonnet-5
MEASUREMENTS
Raw readings from the capture, not a verdict — the grade above is the only judgement. Percentages are how much of the screen changed; the measure under-reports sparse motion on a dark background.