oneshotlm
Compare all models on this prompt →

Double pendulum · qwen3-235b-a22b-thinking-2507

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.

⛨ sandboxed Open in new tab ↗
transcript · 4 messages
user

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.

assistant
thinking
Okay, I need to create a single-page web app that simulates a chaotic double pendulum on a canvas. The user wants it all in one index.html file with CSS in a style tag and JS in a script tag. No external resources allowed, so everything has to be inline.

First, I should recall the equations of motion for a double pendulum. It's a classic chaotic system, so the math might be a bit complex. The double pendulum has two arms with masses at the ends. The equations involve angles, angular velocities, and accelerations derived from Lagrangian mechanics.

Let me think about the steps. The HTML structure needs a canvas element. Then, CSS to style the canvas, maybe center it. The JavaScript will handle the physics simulation and rendering.

For the physics part, I need to set up variables for the lengths of the arms (l1, l2), masses (m1, m2), gravity (g), initial angles (theta1, theta2), and their angular velocities (omega1, omega2). The equations of motion are differential equations that can be solved numerically, probably using the Runge-Kutta method or Euler's method. But since it's a simulation, maybe Euler's method is sufficient for simplicity, even if it's less accurate.

Wait, the correct equations for a double pendulum. Let me recall. The acceleration terms are derived from the Lagrangian. The equations are:

For the first pendulum:
theta1'' = [ -g*(2*m1 + m2)*sin(theta1) - m2*g*sin(theta1 - 2*theta2) - 2*sin(theta1 - theta2)*m2*(theta2'^2*l2 + theta1'^2*l1*cos(theta1 - theta2)) ] / [ l1*(2*m1 + m2 - m2*cos(2*theta1 - 2*theta2)) ]

And for the second:
theta2'' = [ 2*sin(theta1 - theta2)*(theta1'^2*l1*(m1 + m2) + g*(m1 + m2)*cos(theta1) + theta2'^2*l2*m2*cos(theta1 - theta2)) ] / [ l2*(2*m1 + m2 - m2*cos(2*theta1 - 2*theta2)) ]

Hmm, that's right. So in the code, I'll need to compute these accelerations at each time step, then update the velocities and angles.

The simulation loop will use requestAnimationFrame to continuously update the positions and redraw the canvas.

For the trail, I need to store the positions of the tip (end of the second arm) over time. Each frame, add the current position to an array, and draw lines between recent points with decreasing opacity or color to create a fading effect. Maybe use an array that's limited in size, so older points are dropped.

The canvas should be set to the window size, but maybe a fixed size for simplicity. Let's set width and height in the canvas element.

Now, structuring the HTML. The index.html will have:

<!DOCTYPE html>
<html>
<head>
  <title>Double Pendulum</title>
  <style>
    body { margin: 0; overflow: hidden; background: #000; }
    canvas { display: block; }
  </style>
</head>
<body>
  <canvas id="pendulum"></canvas>
  <script>
    // JavaScript code here
  </script>
</body>
</html>

In the JS, first get the canvas context, set dimensions.

Then define the parameters: l1, l2, m1, m2, g, initial angles (in radians), time step (dt), etc.

Initialize variables for theta1, theta2, omega1, omega2 (angular velocities).

The trail will be an array of points, each with x, y, and maybe a timestamp or index for fading.

In the animation loop:

1. Compute the accelerations alpha1 and alpha2 using the equations.
2. Update omega1 and omega2 by adding alpha * dt.
3. Update theta1 and theta2 by adding omega * dt.
4. Calculate the positions of the two joints.
5. Store the tip position (end of second arm) in the trail array.
6. If trail array exceeds a certain length, remove the oldest point.
7. Clear the canvas.
8. Draw the trail with fading colors (e.g., from bright to transparent).
9. Draw the arms and masses.

For the trail, maybe use a gradient where older points are more transparent. So when drawing the trail, loop through the array and draw line segments with decreasing alpha.

Wait, the user said "colorful fading trail". So maybe the trail changes color over time, like a rainbow effect. But fading could mean opacity decreases. Maybe combine both: start with a bright color and fade to transparent, while shifting hue.

Alternatively, each segment of the trail has a color based on its age, using HSL with varying hue and alpha.

In the draw function, for each point in the trail (except the first), draw a line from previous to current point, with color depending on the index.

Let me outline the JS steps:

- Set up canvas and context.
- Define constants and initial conditions.
- Trail array.
- Animation function:
   - Compute next state.
   - Update trail.
   - Clear canvas.
   - Draw trail with fading.
   - Draw pendulum arms.

Need to handle the physics correctly. Let's code the acceleration calculations.

Let me write the equations in code:

function update() {
  const delta = theta1 - theta2;
  const den1 = (2*m1 + m2) - m2 * Math.cos(2*delta);
  
  const alpha1 = (-g*(2*m1 + m2)*Math.sin(theta1) 
                - m2*g*Math.sin(theta1 - 2*theta2)
                - 2*Math.sin(delta)*m2*(omega2*omega2*l2 + omega1*omega1*l1*Math.cos(delta))
                ) / (l1 * den1);
  
  const alpha2 = (2*Math.sin(delta) * (
                omega1*omega1*l1*(m1 + m2) 
                + g*(m1 + m2)*Math.cos(theta1)
                + omega2*omega2*l2*m2*Math.cos(delta)
                )) / (l2 * den1);
  
  omega1 += alpha1 * dt;
  omega2 += alpha2 * dt;
  theta1 += omega1 * dt;
  theta2 += omega2 * dt;
}

Wait, but den1 is the denominator for both, which is (2*m1 + m2 - m2*cos(2*delta)), yes.

Now, the positions:

x1 = l1 * Math.sin(theta1);
y1 = l1 * Math.cos(theta1);

x2 = x1 + l2 * Math.sin(theta2);
y2 = y1 + l2 * Math.cos(theta2);

Wait, actually, the first arm is from origin to (x1, y1), second from (x1, y1) to (x2, y2).

But in canvas, y increases downward, so maybe invert the y-axis. Or adjust the coordinates.

Wait, in standard canvas, (0,0) is top-left, so y increases downward. To have the pendulum hang down, the y-coordinate should be positive downward. So when calculating positions, y = l * cos(theta), but since theta=0 is downward, cos(0)=1, so y = l, which would be downward. Wait, maybe better to have theta=0 as straight down, so when theta increases, it goes to the right.

Wait, the standard double pendulum has angles measured from the vertical. So theta1 is the angle of the first arm from the vertical, positive to the right. Then x1 = l1 * sin(theta1), y1 = l1 * cos(theta1). But in canvas, if y increases downward, then y1 would be l1 * cos(theta1), but if theta1 is 0 (hanging down), cos(0)=1, so y1 = l1, which is downward. That works.

But maybe to have the pendulum start at the top, but no, the standard is hanging down. Wait, initial angles could be set to, say, PI/2 for both to start horizontally.

Wait, in the code, theta1 and theta2 are angles from the vertical. So if theta1 is PI, it's pointing straight up.

But for the simulation, initial angles might be set to, say, 110 degrees and 200 degrees or something to create chaos.

Now, the trail: every frame, push {x: x2, y: y2} into the trail array. Then, when drawing, loop through the trail array and draw lines between consecutive points, with color based on index.

To create a fading effect, for each segment i in the trail, set the stroke color with alpha = 1 - (i / trail.length), and maybe hue varying.

Alternatively, use HSL where hue is (i * some factor) % 360, and alpha decreases.

For example:

ctx.strokeStyle = `hsla(${i * 2}, 100%, 50%, ${1 - i/trailLength})`;

But need to normalize i to trail length.

Wait, trail array might have a maximum length, say 500 points. So when the array exceeds 500, shift out the first element.

In the draw loop:

for (let i = 0; i < trail.length - 1; i++) {
  const alpha = 1 - i / trail.length;
  const hue = (i * 0.5) % 360; // adjust factor for color change speed
  ctx.strokeStyle = `hsla(${hue}, 100%, 50%, ${alpha})`;
  ctx.beginPath();
  ctx.moveTo(trail[i].x, trail[i].y);
  ctx.lineTo(trail[i+1].x, trail[i+1].y);
  ctx.stroke();
}

But trail points are stored as absolute positions on the canvas. Wait, but the canvas might be centered, so need to translate the origin.

Wait, the pendulum is drawn with origin at the center of the canvas. So when calculating x1, y1, etc., they should be relative to the center.

So canvas width and height are set, say, to window.innerWidth and window.innerHeight.

In setup:

canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const cx = canvas.width / 2;
const cy = canvas.height / 2;

Then, the positions are calculated as:

x1 = cx + l1 * Math.sin(theta1);
y1 = cy + l1 * Math.cos(theta1);

Wait, no. Wait, if theta is measured from the vertical (downward), then when theta=0, the arm is pointing down, so y increases downward. But in canvas, y increases downward, so if the pivot is at (cx, cy), then the first mass is at:

x1 = cx + l1 * sin(theta1)
y1 = cy + l1 * cos(theta1)

Wait, no. Let's think: theta=0 is straight down, so the coordinates would be (cx, cy + l1). But sin(0) is 0, cos(0) is 1, so x = cx + 0, y = cy + l1*1. Yes, that's correct.

Wait, but in standard math, angles are measured from the x-axis, but here theta is from the vertical (y-axis). So theta is the angle between the arm and the downward vertical. So the x displacement is l * sin(theta), y displacement is l * cos(theta).

Yes. So for theta=0 (hanging down), x = 0, y = l. For theta=PI/2 (to the right), x = l, y = 0.

Therefore, in canvas coordinates, with pivot at (cx, cy):

x1 = cx + l1 * sin(theta1)
y1 = cy + l1 * cos(theta1)

Similarly, x2 = x1 + l2 * sin(theta2)
y2 = y1 + l2 * cos(theta2)

Wait, but theta2 is the angle of the second arm relative to the vertical, or relative to the first arm?

Ah, here's a possible confusion. In the double pendulum equations, theta2 is the angle of the second arm relative to the vertical, not relative to the first arm. Wait, no. Actually, in the standard formulation, theta1 is the angle of the first arm from the vertical, and theta2 is the angle of the second arm from the vertical. So the angle between the two arms is theta1 - theta2.

Wait, no. Let me check. The Lagrangian uses theta1 and theta2 as the angles of each arm from the vertical. So the position of the first mass is (l1 sin theta1, l1 cos theta1), and the second mass is (l1 sin theta1 + l2 sin theta2, l1 cos theta1 + l2 cos theta2).

Yes, that's correct. So theta2 is the angle of the second arm from the vertical, not relative to the first arm.

Therefore, in the code, x2 = cx + l1 * sin(theta1) + l2 * sin(theta2)
y2 = cy + l1 * cos(theta1) + l2 * cos(theta2)

Wait, but that would mean the second arm is measured from the vertical, not from the first arm's angle. But in reality, the second arm is attached to the first, so its angle is relative to the first arm. Wait, no, in the standard double pendulum, theta1 and theta2 are both measured from the vertical. So the angle between the two arms is theta1 - theta2.

Wait, perhaps I'm mixing up different formulations. Let me confirm.

In the double pendulum, the two angles are usually defined as the angles each arm makes with the vertical. So the first arm has angle theta1 from vertical, the second arm has angle theta2 from vertical. Therefore, the relative angle between them is theta1 - theta2.

Yes, that's how the equations are derived. So the positions are as I mentioned.

Therefore, in code:

x1 = cx + l1 * Math.sin(theta1);
y1 = cy + l1 * Math.cos(theta1);
x2 = x1 + l2 * Math.sin(theta2);
y2 = y1 + l2 * Math.cos(theta2);

Wait, no. Wait, if theta2 is the angle of the second arm from the vertical, then relative to the first mass's position, the second mass is displaced by l2 * sin(theta2) in x and l2 * cos(theta2) in y. But that would mean the second arm's angle is measured from the vertical, not from the first arm. However, in reality, the second arm is attached to the first, so its angle is relative to the first arm's direction.

Wait, this is a critical point. Let me check a reference.

Upon checking, in the standard double pendulum, theta1 is the angle of the first arm from the vertical, and theta2 is the angle of the second arm from the vertical. So the angle between the two arms is theta1 - theta2.

Wait, no. Actually, theta2 is the angle of the second arm relative to the first arm. Or is it relative to the vertical?

Wait, different sources define it differently. Let's look at the equations.

The correct equations for the double pendulum have theta1 and theta2 as the angles of each arm with respect to the vertical. So both angles are measured from the same reference (the vertical), so the angle between the arms is theta1 - theta2.

Yes, for example, in the Wikipedia article on double pendulum, the angles are measured from the vertical.

Therefore, the position of the first mass is (l1 sin theta1, -l1 cos theta1) if y is upward, but in our case, y is downward, so it's (l1 sin theta1, l1 cos theta1).

Wait, but in standard physics, y is upward, so the position would be (l1 sin theta1, -l1 cos theta1) to have downward as positive y. But in canvas, y increases downward, so if we take the pivot at (cx, cy), then the first mass is at (cx + l1 sin theta1, cy + l1 cos theta1), since cos theta1 gives the downward component.

Yes, that makes sense. So theta=0 (hanging down) gives y = cy + l1, which is downward.

Therefore, the code for positions is correct as:

x1 = cx + l1 * Math.sin(theta1);
y1 = cy + l1 * Math.cos(theta1);
x2 = cx + l1 * Math.sin(theta1) + l2 * Math.sin(theta2);
y2 = cy + l1 * Math.cos(theta1) + l2 * Math.cos(theta2);

Wait, no. Wait, the second arm is attached to the first mass, so its position is relative to (x1, y1). So:

x2 = x1 + l2 * Math.sin(theta2);
y2 = y1 + l2 * Math.cos(theta2);

But theta2 here is the angle of the second arm from the vertical. Wait, no. If theta2 is the angle from the vertical, then relative to the global coordinate system, the displacement from the first mass is l2 sin theta2 in x and l2 cos theta2 in y. But that would mean the second arm's angle is measured from the vertical, not from the first arm.

However, in reality, the second arm's angle is relative to the first arm. Wait, this is where confusion arises.

Actually, in the standard derivation, both angles are measured from the vertical. So theta1 is the angle of the first arm from vertical, theta2 is the angle of the second arm from vertical. Therefore, the angle between the two arms is theta1 - theta2.

Therefore, the position of the second mass is:

x = l1 sin theta1 + l2 sin theta2

y = l1 cos theta1 + l2 cos theta2

But in canvas coordinates, with y downward, this would place the masses correctly.

Therefore, in code:

x1 = cx + l1 * Math.sin(theta1);
y1 = cy + l1 * Math.cos(theta1);
x2 = cx + l1 * Math.sin(theta1) + l2 * Math.sin(theta2);
y2 = cy + l1 * Math.cos(theta1) + l2 * Math.cos(theta2);

Wait, no. Wait, the first mass is at (l1 sin theta1, l1 cos theta1) relative to the pivot. The second mass is at (l1 sin theta1 + l2 sin theta2, l1 cos theta1 + l2 cos theta2) relative to the pivot.

Yes. So in canvas coordinates, with pivot at (cx, cy):

x1 = cx + l1 * Math.sin(theta1);
y1 = cy + l1 * Math.cos(theta1);
x2 = cx + l1 * Math.sin(theta1) + l2 * Math.sin(theta2);
y2 = cy + l1 * Math.cos(theta1) + l2 * Math.cos(theta2);

But theta2 is the angle of the second arm from the vertical, same as theta1.

Therefore, the code for positions is correct.

Now, the trail is the path of (x2, y2) over time.

Now, putting it all together.

Let me outline the JS code structure.

Initialize variables:

const canvas = document.getElementById('pendulum');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const cx = canvas.width / 2;
const cy = canvas.height / 2;

// Pendulum parameters
const l1 = 100;
const l2 = 100;
const m1 = 10;
const m2 = 10;
const g = 9.81;
let theta1 = Math.PI / 2; // initial angle for first arm
let theta2 = Math.PI / 2; // initial angle for second arm
let omega1 = 0;
let omega2 = 0;
const dt = 0.015; // time step

let trail = [];

function update() {
  // Compute accelerations
  const delta = theta1 - theta2;
  const den1 = (2*m1 + m2) - m2 * Math.cos(2*delta);
  
  const alpha1 = (-g*(2*m1 + m2)*Math.sin(theta1) 
                - m2*g*Math.sin(theta1 - 2*theta2)
                - 2*Math.sin(delta)*m2*(omega2*omega2*l2 + omega1*omega1*l1*Math.cos(delta))
                ) / (l1 * den1);
  
  const alpha2 = (2*Math.sin(delta) * (
                omega1*omega1*l1*(m1 + m2) 
                + g*(m1 + m2)*Math.cos(theta1)
                + omega2*omega2*l2*m2*Math.cos(delta)
                )) / (l2 * den1);
  
  // Update velocities
  omega1 += alpha1 * dt;
  omega2 += alpha2 * dt;
  
  // Update angles
  theta1 += omega1 * dt;
  theta2 += omega2 * dt;
}

function draw() {
  // Clear canvas
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  
  // Calculate positions
  const x1 = cx + l1 * Math.sin(theta1);
  const y1 = cy + l1 * Math.cos(theta1);
  const x2 = x1 + l2 * Math.sin(theta2);
  const y2 = y1 + l2 * Math.cos(theta2);
  
  // Add to trail
  trail.push({x: x2, y: y2});
  if (trail.length > 500) trail.shift();
  
  // Draw trail
  for (let i = 0; i < trail.length - 1; i++) {
    const alpha = 1 - i / trail.length;
    const hue = (i * 0.5) % 360;
    ctx.strokeStyle = `hsla(${hue}, 100%, 50%, ${alpha})`;
    ctx.beginPath();
    ctx.moveTo(trail[i].x, trail[i].y);
    ctx.lineTo(trail[i+1].x, trail[i+1].y);
    ctx.stroke();
  }
  
  // Draw pendulum arms
  ctx.strokeStyle = '#fff';
  ctx.lineWidth = 2;
  
  ctx.beginPath();
  ctx.moveTo(cx, cy);
  ctx.lineTo(x1, y1);
  ctx.stroke();
  
  ctx.beginPath();
  ctx.moveTo(x1, y1);
  ctx.lineTo(x2, y2);
  ctx.stroke();
  
  // Draw masses
  ctx.fillStyle = '#f00';
  ctx.beginPath();
  ctx.arc(cx, cy, 5, 0, Math.PI * 2);
  ctx.fill();
  
  ctx.fillStyle = '#0f0';
  ctx.beginPath();
  ctx.arc(x1, y1, m1, 0, Math.PI * 2);
  ctx.fill();
  
  ctx.fillStyle = '#00f';
  ctx.beginPath();
  ctx.arc(x2, y2, m2, 0, Math.PI * 2);
  ctx.fill();
}

function animate() {
  update();
  draw();
  requestAnimationFrame(animate);
}

animate();

Wait, but the trail is being added every frame. However, in the draw function, we add the current position to the trail. But the update happens before draw, so the trail gets the latest position.

But in the code above, update() is called first, then draw(), which adds the new position to the trail. That should be correct.

Wait, but in the code as written, the trail is pushed in draw(), after update(), so yes, it's the updated position.

Now, check for possible errors.

The equations for alpha1 and alpha2: let me verify the signs.

In the Wikipedia article, the equations are:

(theta1)'' = [ -g (2 m1 + m2) sin theta1 - m2 g sin(theta1 - 2 theta2) - 2 sin(theta1 - theta2) m2 ( (theta2')^2 l2 + (theta1')^2 l1 cos(theta1 - theta2) ) ] / [ l1 (2 m1 + m2 - m2 cos(2(theta1 - theta2))) ]

Yes, so the code matches.

Denominator is l1 * [2m1 + m2 - m2 cos(2 delta)], where delta = theta1 - theta2.

Yes.

Now, the trail might be too dense, but limiting to 500 points should manage it.

The trail is drawn with segments between consecutive points, each with a color based on their index.

The arms are drawn as lines, masses as circles.

The initial angles are set to PI/2, which is 90 degrees from vertical, so horizontal to the right.

This should create a chaotic motion.

Potential issues:

- The time step dt might be too large, causing instability. But 0.015 is 15ms, which is reasonable for 60fps (16.67ms per frame). However, the simulation updates at each frame, so dt is fixed.

Wait, in the animate function, requestAnimationFrame runs at the display's refresh rate, so dt should be based on actual time elapsed, not fixed. But the user said to use the correct equations, which require a fixed time step for numerical integration. However, using a fixed dt might cause issues if the frame rate varies.

But for simplicity, using a fixed dt is acceptable here, as the user didn't specify high accuracy.

Alternatively, compute dt based on performance.now(), but the user might prefer simplicity.

Given the problem statement, using a fixed dt is okay.

Now, putting all into index.html.

Need to make sure the HTML is self-contained.

Also, the CSS sets body margin to 0 and canvas as block.

Now, writing the entire content into index.html.

Check for any missing parts.

Wait, the trail array is initialized as empty, and in draw(), we push the current x2, y2.

Yes.

Testing the code mentally: when the pendulum moves, the trail builds up, and each segment is drawn with a color that fades.

The arms are white lines, masses are red, green, blue circles.

Wait, the pivot point is drawn as a red circle of radius 5. The first mass (m1) is green with radius m1 (which is 10), and the second mass (m2) is blue with radius m2 (10). So the masses are visible as circles.

This should work.

Now, write the entire index.html file with all code inline.

Make sure there are no external resources.

Let me structure the HTML:

<!DOCTYPE html>
<html>
<head>
  <title>Double Pendulum</title>
  <style>
    body { margin: 0; overflow: hidden; background: #000; }
    canvas { display: block; }
  </style>
</head>
<body>
  <canvas id="pendulum"></canvas>
  <script>
    // JavaScript code here
  </script>
</body>
</html>

Now, the JS code inside the script tag.

Need to handle window resize? The user didn't specify, but for simplicity, maybe not. The canvas is set once on load.

Wait, the code sets canvas.width and height to window.innerWidth/Height at start, but if the window is resized, it won't adjust. But the user didn't ask for responsiveness, so it's okay.

Now, writing the JS.

Potential typo: in the alpha2 calculation, the numerator has g*(m1 + m2)*cos(theta1). Wait, in the equation, it's g*(m1 + m2)*cos(theta1), yes.

Wait, in the alpha2 equation:

2*sin(delta) * [ ... + g*(m1 + m2)*cos(theta1) + ... ]

Yes.

Now, code the JS.

Check variable names: theta1, theta2, omega1, omega2.

Yes.

Now, write the entire content.

Wait, in the trail drawing loop, the alpha is 1 - i / trail.length. So for i=0 (oldest point), alpha is nearly 1, but as i increases, alpha decreases. Wait, but the trail array is built from oldest at index 0 to newest at last index. So when drawing from i=0 to trail.length-1, the segments go from old to new. So the oldest segment (between 0 and 1) has alpha 1 - 0/trail.length = 1, which is not fading. Wait, that's the opposite of what we want.

We want the oldest points to have lower alpha. So if the trail array has points from oldest (index 0) to newest (index n-1), then for segment i (from point i to i+1), the age is i, so alpha should be (trail.length - i) / trail.length.

Wait, let's see: trail.length is 500. For i=0 (first segment), it's the oldest, so alpha should be low. For i=499 (last segment), alpha is high.

So alpha = (trail.length - 1 - i) / (trail.length - 1)

Or alpha = 1 - i / (trail.length - 1)

Wait, in the code:

for (let i = 0; i < trail.length - 1; i++) {
  const alpha = 1 - i / trail.length;
  ...
}

If trail.length is 500, then for i=0, alpha=1 - 0/500=1 (fully opaque), which is the oldest segment. But we want the oldest to be transparent. So this is backwards.

Ah, right. The trail array is [oldest, ..., newest]. So the first segment (i=0) is between oldest and next, which should be the most faded.

Therefore, alpha should be i / trail.length, but that would make older segments have lower alpha. Wait, no:

Wait, if trail.length is N, then for segment i (0-based), the age is i (older when i is small). So alpha should decrease as i increases? No, older segments should have lower alpha.

Wait, trail[0] is the oldest point. trail[N-1] is the newest.

The segments are between trail[i] and trail[i+1], for i from 0 to N-2.

So segment i=0 is between oldest and next, which is the oldest segment, should have lowest alpha.

Thus, alpha = i / (N-1), but that would make alpha increase with i. Wait, no.

Wait, we want alpha to be high for recent segments, low for old.

So alpha = (N - 1 - i) / (N - 1)

For N=500, when i=0 (oldest segment), alpha = 499/499=1? No, N-1=499.

Wait, N = trail.length. The number of segments is N-1.

So for i in 0..N-2:

alpha = (N - 1 - i) / (N - 1)

So when i=0, alpha= (N-1)/ (N-1) =1 (newest segment?), no.

Wait, no. If trail is [p0, p1, p2, ..., p_{N-1}], then segments are p0-p1 (i=0), p1-p2 (i=1), ..., p_{N-2}-p_{N-1} (i=N-2).

The newest segment is i=N-2, which should have alpha=1.

The oldest segment is i=0, alpha= (N-1 -0)/(N-1) =1, which is not correct.

Wait, I'm confused.

Let me think: the trail array is filled such that the oldest point is at index 0, newest at index trail.length-1.

The segments are from index i to i+1.

The segment i is between points i and i+1, so the age of the segment is related to i: lower i means older segment.

We want older segments to have lower alpha.

So alpha = (trail.length - 1 - i) / (trail.length - 1)

For example, if trail.length=5 (so 4 segments):

i=0: alpha=(4-0)/4=1.0 → but this is the oldest segment, should have low alpha.

Wait, no. Wait, trail.length=5 means 5 points, 4 segments.

The newest segment is i=3 (between 3 and 4), which should have alpha=1.

The oldest segment is i=0 (between 0 and1), alpha=(4-0)/4=1, which is wrong.

Ah, I see. The correct formula is alpha = i / (trail.length -1 )

Wait, for i=3 (newest segment), alpha=3/4=0.75, which is not 1.

No.

Wait, perhaps alpha = 1 - i / (trail.length -1 )

For i=0: alpha=1 -0=1 (oldest segment, should be transparent, but this is opaque)

i=3: alpha=1 - 3/4=0.25 (newest segment, should be opaque, but this is transparent)

That's the opposite.

Ah, right. We need alpha to be high for new segments, low for old.

So alpha = (trail.length -1 - i) / (trail.length -1 )

For i=0: (4-0)/4=1 → opaque (oldest segment should be transparent)

No.

Wait, trail.length is the number of points. The number of segments is trail.length -1.

The newest segment is the last one, i=trail.length-2.

We want its alpha to be 1, and the oldest segment (i=0) to have alpha approaching 0.

Thus, alpha = ( (trail.length -1 ) - i ) / (trail.length -1 )

For trail.length=5 (4 segments):

i=0: (4-0)/4=1 → alpha=1 (oldest segment, should be transparent)

i=3: (4-3)/4=0.25 → alpha=0.25 (newest segment, should be opaque)

No, that's inverted.

Wait, no. Wait, trail.length=5 means 5 points: [0,1,2,3,4]

Segments: 0-1 (i=0), 1-2 (i=1), 2-3 (i=2), 3-4 (i=3)

The newest segment is i=3 (3-4), which should have alpha=1.

The oldest segment is i=0 (0-1), alpha=0.

So alpha = i / (trail.length -1 )

For i=3: 3/4=0.75, not 1.

Wait, but trail.length -1 is 4.

alpha = (i) / (trail.length -1 )

For i=3: 3/4=0.75.

But we want alpha=1 for i=3.

Ah, perhaps alpha = (i +1 ) / trail.length

For trail.length=5:

i=0: 1/5=0.2

i=3:4/5=0.8

Still not 1.

Alternatively, alpha = (trail.length - i -1 ) / trail.length

For trail.length=5:

i=0: (5-0-1)/5=4/5=0.8

i=3: (5-3-1)/5=1/5=0.2

Which means newer segments have lower alpha. Not desired.

Wait, maybe the trail array is stored in reverse, with newest points first. But in the code, we push new points to the end, so trail[0] is oldest.

To have newer segments with higher alpha, alpha should be proportional to (trail.length -1 -i )

Wait, let's take trail.length=5 (4 segments):

We want i=3 (newest) to have alpha=1, i=0 (oldest) alpha=0.

So alpha = (i) / (trail.length -1 )

i=3: 3/4=0.75

Not 1.

Alternatively, scale it so that the maximum i (trail.length-2) gives alpha=1.

alpha = (i +1 ) / (trail.length )

For trail.length=5:

i=0: 1/5=0.2

i=3:4/5=0.8

Still not 1.

Alternatively, alpha = 1 - (i / trail.length )

For i=0: 1-0=1 (oldest, should be transparent)

i=3:1 - 3/5=0.4

No.

This is getting complicated. Maybe instead of linear alpha, use a function that gives higher alpha for recent points.

Alternatively, use alpha = Math.pow(0.98, trail.length -1 -i )

But that's more complex.

Alternatively, in the code, when pushing to trail, do trail.unshift({x,y}), so the newest points are at the front. Then, when drawing, the segments from i=0 to i+1 are newer.

But trail.shift() when exceeding max length would remove the newest points, which is not desired.

Alternatively, keep trail as [newest, ..., oldest], but that complicates the trail management.

Alternatively, in the drawing loop, iterate from the end of the trail.

for (let i = trail.length -1; i >0; i--) {
  const alpha = (trail.length - i) / trail.length;
  ...
}

But the code as written loops from 0 to trail.length-2.

Perhaps the easiest way is to reverse the alpha calculation.

In the current code:

for (let i = 0; i < trail.length -1; i++) {
  const alpha = 1 - i / trail.length;
  ...
}

If trail.length is 500, then for i=0 (oldest segment), alpha=1 - 0/500=1 (fully opaque), which is wrong. For i=499 (but trail.length-1 is 499, so i goes up to 498), alpha=1 - 498/500=0.004, which is almost transparent. So the newest segments (higher i) have lower alpha. That's the opposite of what we want.

Ah! So the current code has the alpha inverted. The oldest segments are opaque, newest are transparent.

To fix this, alpha should be i / trail.length.

Wait:

alpha = i / (trail.length -1 )

For i=0: 0 → transparent (oldest)

For i=trail.length-2: (trail.length-2)/(trail.length-1) ≈1 → opaque (newest)

Yes.

So in code:

const alpha = i / (trail.length - 1 || 1);

But trail.length could be 1, so need to handle division by zero.

Modify the loop:

for (let i = 0; i < trail.length - 1; i++) {
  const alpha = i / (trail.length - 1);
  const hue = (i * 0.5) % 360;
  ctx.strokeStyle = `hsla(${hue}, 100%, 50%, ${alpha})`;
  ...
}

This way, older segments (low i) have low alpha, newer (high i) have high alpha.

Yes, that's correct.

So in the code, change alpha to i / (trail.length -1 )

But when trail.length is 1, trail.length -1 is 0, so need to avoid division by zero.

Add a check:

const alpha = trail.length > 1 ? i / (trail.length - 1) : 1;

But in practice, trail.length starts at 0, then 1, but the loop runs only when trail.length >=2.

Because the loop is for i from 0 to trail.length-2, which requires trail.length >=2.

So when trail.length is 1, the loop doesn't run.

Thus, in the loop, trail.length >=2, so trail.length -1 >=1.

Therefore, safe to compute i / (trail.length -1 )

So the corrected alpha is i / (trail.length -1 )

Thus, in the code:

for (let i = 0; i < trail.length - 1; i++) {
  const alpha = i / (trail.length - 1);
  const hue = (i * 0.5) % 360;
  ctx.strokeStyle = `hsla(${hue}, 100%, 50%, ${alpha})`;
  ctx.beginPath();
  ctx.moveTo(trail[i].x, trail[i].y);
  ctx.lineTo(trail[i+1].x, trail[i+1].y);
  ctx.stroke();
}

This way, the oldest segment (i=0) has alpha 0, newest (i=trail.length-2) has alpha ~1.

Wait, no. If trail.length is 500, then trail.length-1 is 499.

For i=0: alpha=0/499=0 (transparent)

For i=498: alpha=498/499 ≈1 (opaque)

Yes, which is correct: the newest segments are opaque, oldest are transparent.

Perfect.

So the code for the trail should use alpha = i / (trail.length -1 )

Therefore, need to adjust the code.

Now, updating the JS code accordingly.

Another thing: the hue calculation. i * 0.5 might not give a smooth transition. Maybe i * 2 or another factor to make the color change more noticeable.

But the user said "colorful fading trail", so varying hue is good.

Now, putting all together.

Let me write the final index.html content.
write index.html (3009 bytes)
toolResult

Successfully wrote 3009 bytes to index.html

assistant
thinking
Okay, let me check what the user asked for. They wanted a single-page web app simulating a chaotic double pendulum with a canvas, CSS in a style tag, JS in a script tag, all inline, no external resources.

I generated the index.html file with the write function. The response says it successfully wrote 3009 bytes. Now I need to confirm that the file meets all requirements.

First, check if it's a single HTML file. Yes, index.html contains everything: HTML structure, inline CSS, and JS.

CSS is in a <style> tag inside the head, which is correct. The body has a canvas element, and the script is in a <script> tag at the end of the body.

The JS code sets up the canvas, defines the physics parameters, handles the double pendulum equations, updates the positions, draws the trail with fading colors, and animates it using requestAnimationFrame.

I made sure there are no external resources—no CDNs, all code is inline. The trail uses HSLA for colors with varying hue and alpha to create the fading effect. The physics equations are correctly implemented based on Lagrangian mechanics for a double pendulum.

The user specified a "colorful fading trail of the tip," which is handled by the trail array and the loop that draws segments with increasing alpha and hue as the trail progresses.

Everything seems to fit the requirements. The file is self-contained, runs sandboxed without network, and should display the chaotic motion as intended.

The chaotic double pendulum simulation has been successfully created as a single self-contained HTML file. The implementation features: - Physically accurate double pendulum equations of motion - Dynamic canvas rendering with automatic window sizing - Colorful fading trail that follows the pendulum tip (using HSLA gradients) - Real-time physics simulation with numerical integration - Visual differentiation of pivot point and masses - Fully sandboxed operation with no external dependencies To use it, simply open the generated `index.html` file in any modern browser. The simulation will automatically start, showing the chaotic motion characteristic of double pendulum systems. The trail effect demonstrates the system's sensitivity to initial conditions through its evolving color pattern.

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

modelqwen3-235b-a22b-thinking-2507
vendorQwen
harnesspi-agent
generated2026-08-01

EVALUATION

3/5 working

Double pendulum swings with colorful fading trail, small scale but physically animated correctly.

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.

idle motion0.1%
on click0.6%
on drag0.6%
on wheel0.5%
on enter + space0.4%
on arrow keys0.5%
on w/a/s/d0.6%
frame spread3.5 / 255
console errors0
js errors none

METRICS

runtime215.2s
tokens in13k
tokens out11k
cached0
cost / run$0.0358