<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Triple Pendulum — Chaotic Trails</title>
<style>
:root{
--bg1:#0b1020;
--bg2:#0a0d18;
--grid:#1a2346;
--rod:#e8eefc;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background: radial-gradient(120% 100% at 50% 0%, var(--bg1), var(--bg2) 55%);
color:#cfe3ff;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
overflow:hidden;
}
.stage{
position:fixed;
inset:0;
display:block;
width:100vw;
height:100vh;
touch-action:none;
user-select:none;
}
canvas{
position:absolute;
inset:0;
width:100%;
height:100%;
display:block;
pointer-events:none;
}
.overlay{
position:absolute;
left:2.2vw;
bottom:2vh;
color:#a9bde8;
letter-spacing:.06em;
font-weight:600;
font-size:clamp(12px, 1.4vw, 18px);
opacity:.85;
text-shadow:0 1px 0 rgba(0,0,0,.35);
mix-blend-mode:screen;
}
.title{
position:absolute;
left:50%;
top:5vh;
transform:translateX(-50%);
font-weight:800;
font-size:clamp(20px, 4.4vw, 54px);
letter-spacing:.04em;
color:#eaf2ff;
text-shadow:0 6px 32px rgba(0,0,0,.55);
opacity:.9;
pointer-events:none;
white-space:nowrap;
}
.grid{
position:absolute;
inset:0;
background:
radial-gradient(circle at 50% 15%, rgba(255,255,255,.06), rgba(255,255,255,0) 35%),
repeating-linear-gradient(to right, transparent 0 94px, rgba(255,255,255,.02) 94px 95px),
repeating-linear-gradient(to bottom, transparent 0 94px, rgba(255,255,255,.02) 94px 95px);
mix-blend-mode:overlay;
opacity:.4;
pointer-events:none;
}
.legend{
position:absolute;
right:2vw;
bottom:2vh;
display:flex;
gap:1.2em;
font-size:clamp(10px, 1.2vw, 14px);
color:#dbe8ff;
opacity:.9;
text-shadow:0 1px 0 rgba(0,0,0,.35);
}
.chip{
display:flex;align-items:center;gap:.5em
}
.swatch{
width:1em;height:.5em;border-radius:2px;box-shadow:0 0 0 1px rgba(0,0,0,.2) inset
}
</style>
</head>
<body>
<div class="stage">
<div class="grid"></div>
<canvas id="trail"></canvas>
<canvas id="scene"></canvas>
<div class="title">Triple Pendulum</div>
<div class="overlay">Chaotic motion under gravity · No controls</div>
<div class="legend">
<div class="chip"><span class="swatch" style="background:#00e5ff"></span>Joint 1 trail</div>
<div class="chip"><span class="swatch" style="background:#ff00c8"></span>Joint 2 trail</div>
<div class="chip"><span class="swatch" style="background:#ffcc00"></span>Bob trail</div>
</div>
</div>
<script>
(function(){
const canvasTrail = document.getElementById('trail');
const canvasScene = document.getElementById('scene');
const ctxT = canvasTrail.getContext('2d');
const ctx = canvasScene.getContext('2d');
let DPR = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
let W = 0, H = 0, MIN = 0;
// Pendulum parameters (scaled with viewport)
let anchor = {x:0, y:0};
let L1=0, L2=0, L3=0; // rod lengths (px)
let r1=0, r2=0, r3=0; // bob radii (px)
let g = 980; // gravity (px/s^2) ~ 1px ~ 1/100 cm @ typical scale; tuned below on resize
let damping = 0.999; // velocity damping for numerical stability
let subSteps = 2; // verlet substeps per frame
let iterations = 10; // constraint iterations per substep
// Particles: 0: anchor (fixed), 1: joint1, 2: joint2, 3: bob
const pts = [
{x:0,y:0, px:0,py:0, fixed:true, m:Infinity},
{x:0,y:0, px:0,py:0, fixed:false, m:1},
{x:0,y:0, px:0,py:0, fixed:false, m:1},
{x:0,y:0, px:0,py:0, fixed:false, m:1},
];
// Trails (store last position to draw segments)
const trailColors = ['#00e5ff', '#ff00c8', '#ffcc00'];
const lastTrail = [
{x:null, y:null}, // joint1
{x:null, y:null}, // joint2
{x:null, y:null}, // bob
];
function resetTransforms(ctx){
ctx.setTransform(1,0,0,1,0,0);
ctx.clearRect(0,0,ctx.canvas.width,ctx.canvas.height);
}
function resize(){
const prevW = W, prevH = H, prevMIN = MIN;
const prevAnchor = {...anchor};
DPR = Math.max(1, Math.min(window.devicePixelRatio || 1, 2.5));
W = Math.floor(window.innerWidth);
H = Math.floor(window.innerHeight);
MIN = Math.min(W,H);
// set canvas sizes with DPR
[canvasTrail, canvasScene].forEach(c=>{
c.width = Math.floor(W * DPR);
c.height = Math.floor(H * DPR);
c.style.width = W + 'px';
c.style.height = H + 'px';
});
resetTransforms(ctxT);
resetTransforms(ctx);
ctxT.scale(DPR, DPR);
ctx.scale(DPR, DPR);
// Anchor near top center
anchor.x = W * 0.5;
anchor.y = H * (H<600 ? 0.10 : 0.14);
// Lengths scale with viewport
L1 = MIN * 0.18;
L2 = MIN * 0.22;
L3 = MIN * 0.26;
// Bob sizes
r1 = Math.max(2, MIN * 0.012);
r2 = Math.max(2, MIN * 0.013);
r3 = Math.max(2, MIN * 0.014);
// Gravity tuned to scale so the motion time-scale feels natural (~1s per swing)
g = 1.3 * MIN; // px/s^2
// Line widths scale
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctxT.lineCap = 'round';
ctxT.lineJoin = 'round';
// If first init (prevMIN===0), initialize fresh
if (!prevMIN){
initializeState();
// Clear trails fully
ctxT.clearRect(0,0,W,H);
// Seed trail starting points
lastTrail[0].x = pts[1].x; lastTrail[0].y = pts[1].y;
lastTrail[1].x = pts[2].x; lastTrail[1].y = pts[2].y;
lastTrail[2].x = pts[3].x; lastTrail[2].y = pts[3].y;
return;
}
// Otherwise, scale positions to new viewport smoothly
const s = MIN / prevMIN;
const dx = anchor.x - prevAnchor.x * s;
const dy = anchor.y - prevAnchor.y * s;
pts.forEach(p=>{
p.x = p.x * s + (anchor.x - prevAnchor.x * s);
p.y = p.y * s + (anchor.y - prevAnchor.y * s);
p.px = p.px * s + (anchor.x - prevAnchor.x * s);
p.py = p.py * s + (anchor.y - prevAnchor.y * s);
});
// Move trails too
lastTrail.forEach(t=>{
if(t.x!=null){
t.x = t.x * s + (anchor.x - prevAnchor.x * s);
t.y = t.y * s + (anchor.y - prevAnchor.y * s);
}
});
}
function initializeState(){
// Set anchor
pts[0].x = pts[0].px = anchor.x;
pts[0].y = pts[0].py = anchor.y;
// Initial angles (slightly different to spark chaos)
const th1 = Math.PI*0.70 + (Math.random()*0.04 - 0.02);
const th2 = Math.PI*0.70 + (Math.random()*0.08 - 0.04);
const th3 = Math.PI*0.70 + (Math.random()*0.12 - 0.06);
// Forward kinematics from anchor
const p1 = {
x: anchor.x + L1*Math.sin(th1),
y: anchor.y + L1*Math.cos(th1)
};
const p2 = {
x: p1.x + L2*Math.sin(th2),
y: p1.y + L2*Math.cos(th2)
};
const p3 = {
x: p2.x + L3*Math.sin(th3),
y: p2.y + L3*Math.cos(th3)
};
[pts[1],pts[2],pts[3]].forEach((p,i)=>{
const src = [p1,p2,p3][i];
p.x = p.px = src.x;
p.y = p.py = src.y;
});
// Give a tiny initial nudge
pts[3].px = pts[3].x - 0.5;
pts[2].px = pts[2].x + 0.3;
pts[1].py = pts[1].y - 0.2;
// Reset trails
lastTrail[0].x = pts[1].x; lastTrail[0].y = pts[1].y;
lastTrail[1].x = pts[2].x; lastTrail[1].y = pts[2].y;
lastTrail[2].x = pts[3].x; lastTrail[2].y = pts[3].y;
ctxT.clearRect(0,0,W,H);
}
function integrate(dt){
const dt2 = dt*dt;
// Verlet integration with gravity
for(let i=1;i<=3;i++){
const p = pts[i];
if(p.fixed) continue;
const vx = (p.x - p.px) * damping;
const vy = (p.y - p.py) * damping;
const nx = p.x + vx;
const ny = p.y + vy + g*dt2;
p.px = p.x;
p.py = p.y;
p.x = nx;
p.y = ny;
}
// Constraint solver iterations (keep rods rigid)
for(let k=0;k<iterations;k++){
// 0-1
satisfyStick(0,1,L1);
// 1-2
satisfyStick(1,2,L2);
// 2-3
satisfyStick(2,3,L3);
// Re-pin anchor precisely
pts[0].x = anchor.x;
pts[0].y = anchor.y;
pts[0].px = anchor.x;
pts[0].py = anchor.y;
}
}
function satisfyStick(iA,iB,rest){
const A = pts[iA], B = pts[iB];
let dx = B.x - A.x;
let dy = B.y - A.y;
let dist = Math.hypot(dx,dy) || 1e-8;
const diff = (dist - rest) / dist;
const invA = A.fixed ? 0 : 1/A.m;
const invB = B.fixed ? 0 : 1/B.m;
const invSum = invA + invB || 1e-8;
const corrAx = invA / invSum * dx * diff;
const corrAy = invA / invSum * dy * diff;
const corrBx = invB / invSum * dx * diff;
const corrBy = invB / invSum * dy * diff;
if(!A.fixed){ A.x += corrAx; A.y += corrAy; }
if(!B.fixed){ B.x -= corrBx; B.y -= corrBy; }
}
function draw(){
// Clear scene canvas only (not trail)
ctx.clearRect(0,0,W,H);
// Faint fade on the trail to create smooth decaying paths
ctxT.save();
ctxT.globalCompositeOperation = 'destination-out';
ctxT.globalAlpha = 0.04;
ctxT.fillRect(0,0,W,H);
ctxT.restore();
// Draw new trail segments for each joint (1,2,3)
const joints = [pts[1], pts[2], pts[3]];
const widths = [Math.max(1.2, r1*0.35), Math.max(1.4, r2*0.35), Math.max(1.6, r3*0.35)];
for(let i=0;i<3;i++){
const p = joints[i];
const last = lastTrail[i];
if(last.x!=null){
ctxT.beginPath();
ctxT.moveTo(last.x, last.y);
ctxT.lineTo(p.x, p.y);
ctxT.strokeStyle = trailColors[i];
ctxT.lineWidth = widths[i];
ctxT.globalAlpha = 0.9;
ctxT.stroke();
}
last.x = p.x;
last.y = p.y;
}
// Draw rods
ctx.strokeStyle = 'rgba(234,242,255,0.9)';
ctx.lineWidth = Math.max(1.25, MIN*0.004);
ctx.beginPath();
ctx.moveTo(anchor.x, anchor.y);
ctx.lineTo(pts[1].x, pts[1].y);
ctx.lineTo(pts[2].x, pts[2].y);
ctx.lineTo(pts[3].x, pts[3].y);
ctx.stroke();
// Draw joints/bobs
drawNode(anchor.x, anchor.y, Math.max(2, MIN*0.006), '#90a8ff', true);
drawNode(pts[1].x, pts[1].y, r1, '#00e5ff');
drawNode(pts[2].x, pts[2].y, r2, '#ff00c8');
drawNode(pts[3].x, pts[3].y, r3, '#ffcc00');
}
function drawNode(x,y,r,color, isAnchor=false){
const glow = isAnchor ? 'rgba(144,168,255,0.65)' : color+'55';
ctx.save();
// Glow
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(x,y,r*2.2,0,Math.PI*2);
ctx.fill();
// Core
ctx.beginPath();
ctx.arc(x,y,r,0,Math.PI*2);
ctx.fillStyle = color;
ctx.fill();
// Rim
ctx.lineWidth = Math.max(1, r*0.25);
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
ctx.stroke();
ctx.restore();
}
// Animation loop
let lastTime = performance.now();
let accumulator = 0;
const fixedDt = 1/240; // physics step
function tick(now){
const frameDt = Math.min(0.032, (now - lastTime)/1000); // clamp
lastTime = now;
accumulator += frameDt;
// Multiple sub-steps for stability
while(accumulator >= fixedDt){
for(let n=0;n<subSteps;n++){
integrate(fixedDt / subSteps);
}
accumulator -= fixedDt;
}
draw();
requestAnimationFrame(tick);
}
window.addEventListener('resize', resize);
resize();
requestAnimationFrame(tick);
// In case of visibility change, keep timing sane
document.addEventListener('visibilitychange', ()=>{ lastTime = performance.now(); });
})();
</script>
</body>
</html>*