script.js
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const particleCount = 1000;
const particles = [];
const stableAttractors = [];
const decayRate = 'rgba(2, 2, 2, 0.1)';
function updateStablePositions() {
stableAttractors.length = 0;
stableAttractors.push({
x: canvas.width * 0.82,
y: canvas.height * 0.45,
radius: 9,
color: '#0088ff',
glow: '#0044ff',
fieldOfInfluence: 480,
gravity: 0.1,
spin: 1.5
});
stableAttractors.push({
x: canvas.width * 0.22,
y: canvas.height * 0.45,
radius: 9,
color: '#0088ff',
glow: '#0044ff',
fieldOfInfluence: 480,
gravity: 0.1,
spin: 1.5
});
}
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
updateStablePositions();
}
window.addEventListener('resize', resize);
resize();
// moving attractor
let movingAttractor = {
x: canvas.width / 2,
y: canvas.height / 2,
color: '#ff3333',
glow: '#ff0000',
radius: 6,
fieldOfInfluence: 380,
gravity: 0.1,
spin: 2
};
class Particle {
constructor() {
this.reset();
}
reset() {
this.x = Math.random() * (canvas.width * 0.18);
this.y = Math.random() * canvas.height;
this.speed = Math.random() * 1;
this.history = [];
this.maxHistory = 40;
}
update(cx, cy) {
this.history.push({x: this.x, y: this.y});
if (this.history.length > this.maxHistory) this.history.shift();
let rdx = cx - this.x;
let rdy = cy - this.y;
let rdist = Math.sqrt(rdx * rdx + rdy * rdy) + 1;
if (rdist < movingAttractor.fieldOfInfluence) {
let rInfluence = (1 - (rdist / movingAttractor.fieldOfInfluence));
let rPullX = (rdx / rdist) * movingAttractor.gravity;
let rPullY = (rdy / rdist) * movingAttractor.gravity;
let rOrbitX = (-rdy / rdist) * movingAttractor.spin;
let rOrbitY = (rdx / rdist) * movingAttractor.spin;
this.x += (rPullX + rOrbitX) * rInfluence * 3.5;
this.y += (rPullY + rOrbitY) * rInfluence * 3.5;
}
this.x += this.speed;
stableAttractors.forEach(sat => {
let sdx = sat.x - this.x;
let sdy = sat.y - this.y;
let sdist = Math.sqrt(sdx * sdx + sdy * sdy) + 1;
if (sdist < sat.fieldOfInfluence) {
let sInfluence = (1 - (sdist / sat.fieldOfInfluence));
let sPullX = (sdx / sdist) * sat.gravity;
let sPullY = (sdy / sdist) * sat.gravity;
let sOrbitX = (-sdy / sdist) * sat.spin;
let sOrbitY = (sdx / sdist) * sat.spin;
this.x += (sPullX + sOrbitX) * sInfluence * 4.5;
this.y += (sPullY + sOrbitY) * sInfluence * 4.5;
}
});
if (this.x > canvas.width || this.x < -100 || this.y < -100 || this.y > canvas.height + 100) {
this.reset();
}
}
draw() {
if (!this.history || this.history.length < 2) return;
ctx.beginPath();
ctx.moveTo(this.history[0].x, this.history[0].y);
for(let i = 1; i < this.history.length; i++) {
let p = this.history[i];
if (p) ctx.lineTo(p.x, p.y);
}
ctx.strokeStyle = `rgba(0, 255, 120, ${this.speed / 4.5})`;
ctx.lineWidth = 1;
ctx.lineCap = 'round';
ctx.stroke();
}
}
for(let i=0; i<particleCount; i++) {
particles.push(new Particle());
}
function animate() {
ctx.fillStyle = decayRate;
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (canvas.width > 0 && canvas.height > 0) {
const time = Date.now() * 0.0002;
movingAttractor.x = (canvas.width / 2) + (Math.sin(time * 0.8) * (canvas.width * 0.2));
movingAttractor.y = (canvas.height / 2) + (Math.cos(time * 1.3) * (canvas.height * 0.2));
}
ctx.shadowBlur = 20;
stableAttractors.forEach(sat => {
ctx.beginPath();
ctx.arc(sat.x, sat.y, sat.radius, 0, Math.PI * 2);
ctx.fillStyle = sat.color;
ctx.shadowColor = sat.glow; // Apply blue glow
ctx.fill();
});
ctx.beginPath();
ctx.arc(movingAttractor.x, movingAttractor.y, movingAttractor.radius, 0, Math.PI * 2);
ctx.fillStyle = movingAttractor.color;
ctx.shadowColor = movingAttractor.glow;
ctx.fill();
ctx.shadowBlur = 0;
ctx.shadowColor = 'transparent';
particles.forEach(p => {
p.update(movingAttractor.x, movingAttractor.y);
p.draw();
});
requestAnimationFrame(animate);
}
animate();
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Attractors Test 3</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div id="ui">
<h3>Stable vs. unstable attractors</h3>
</div>
<canvas id="canvas"></canvas>
<script src="./script.js"></script>
</body>
</html>
style.css
body {
margin: 0;
background: #020202; /* Deep black void background */
overflow: hidden; /* Prevent scrollbars from popping up */
color: #00ff66; /* Bright terminal green for UI text */
font-family: 'Courier New', Courier, monospace; /* Monospaced terminal font */
user-select: none; /* Prevent accidental text highlighting */
}
#canvas {
position: absolute;
top: 0;
left: 0;
z-index: 1; /* Place the canvas layer behind the UI */
}
#ui {
position: absolute;
top: 15px;
left: 15px;
z-index: 10; /* Keep the info panel on top */
background: rgba(0, 15, 0, 0.85); /* Semi-transparent dark green box */
padding: 12px;
border: 1px solid #00aa44; /* Subtle green border */
border-radius: 4px;
pointer-events: none; /* Allows clicks to pass through the box */
}
#ui h3 {
margin: 0 0 6px 0;
font-size: 15px;
letter-spacing: 1px;
}
#ui p {
margin: 2px 0;
font-size: 12px;
line-height: 1.4;
color: #aaffaa; /* Lighter shade of green for description text */
}
Set zoom 0.5
Full screen (1k particles):
Full screen: https://niall.gt.tc/ (some phones probably can’t handle it)




