🔬 This block wraps particles around the screen edges like Pac-Man so they never vanish. What do you think happens visually if you comment out all four lines?
if (this.pos.x < 0) this.pos.x += width;
if (this.pos.x > width) this.pos.x -= width;
if (this.pos.y < 0) this.pos.y += height;
if (this.pos.y > height) this.pos.y -= height;
🔬 Fast particles get hue 320 (magenta) and slow ones get hue 180 (turquoise). What happens if you swap those two hue numbers?
const hue = lerp(180, 320, norm);
const bright = lerp(60, 100, norm);
const alpha = lerp(30, 90, norm);
class Particle {
constructor(x, y) {
this.pos = createVector(x, y);
this.prevPos = this.pos.copy();
this.vel = p5.Vector.random2D().mult(random(0.3, 2.0));
this.acc = createVector(0, 0);
this.size = random(1.0, 2.5);
}
applyField(field) {
field.applyTo(this);
}
update() {
this.prevPos.set(this.pos);
this.vel.add(this.acc);
// friction/damping
this.vel.mult(0.99);
// clamp max speed
const maxSpeed = 8;
if (this.vel.magSq() > maxSpeed * maxSpeed) {
this.vel.setMag(maxSpeed);
}
this.pos.add(this.vel);
this.acc.mult(0);
// screen wrap
if (this.pos.x < 0) this.pos.x += width;
if (this.pos.x > width) this.pos.x -= width;
if (this.pos.y < 0) this.pos.y += height;
if (this.pos.y > height) this.pos.y -= height;
}
draw() {
const speed = this.vel.mag();
const norm = constrain(speed / 8, 0, 1);
// Velocity → color gradient (turquoise → magenta)
const hue = lerp(180, 320, norm);
const bright = lerp(60, 100, norm);
const alpha = lerp(30, 90, norm);
stroke(hue, 100, bright, alpha);
strokeWeight(this.size);
line(this.prevPos.x, this.prevPos.y, this.pos.x, this.pos.y);
// subtle glow dot
noStroke();
fill(hue, 100, bright, 30);
ellipse(this.pos.x, this.pos.y, this.size * 3, this.size * 3);
}
}
Line-by-line explanation (8 lines)
🔧 Subcomponents:
calculation
Random Starting Velocity
this.vel = p5.Vector.random2D().mult(random(0.3, 2.0));
Gives every new particle a random direction and speed so the swarm starts with organic motion
conditional
Screen Wrap
if (this.pos.x < 0) this.pos.x += width;
Teleports a particle to the opposite edge when it exits the canvas, so nothing is ever lost
calculation
Velocity-to-Color Mapping
const hue = lerp(180, 320, norm);
Converts each particle's current speed into a hue so fast particles glow magenta and slow ones glow turquoise
this.pos = createVector(x, y);
- Stores the particle's current position as a p5.Vector so x/y can be updated together
this.vel = p5.Vector.random2D().mult(random(0.3, 2.0));
- Picks a random direction (random2D) and scales it to a random small speed to kick off motion
this.vel.mult(0.99);
- Multiplies velocity by 0.99 every frame - a tiny friction that slowly bleeds energy so the system doesn't spin out of control
if (this.vel.magSq() > maxSpeed * maxSpeed) {
- Checks squared magnitude (faster than sqrt) against a squared speed limit to detect if the particle is moving too fast
this.pos.add(this.vel);
- Moves the particle by adding its velocity vector to its position - the core of the animation
this.acc.mult(0);
- Resets acceleration to zero after it's been applied, since forces should only affect this one frame
if (this.pos.x < 0) this.pos.x += width;
- If the particle drifts off the left edge, it reappears on the right edge instead of disappearing
line(this.prevPos.x, this.prevPos.y, this.pos.x, this.pos.y);
- Draws a short line from the previous position to the current one, which is what creates the streaking trail look
🔬 This calculates an inverse-square-style force. What happens visually if you remove the division by distSq entirely, so distance no longer weakens the pull?
let forceMag = (this.strength * falloff) / distSq;
if (this.isRepulsor) forceMag *= -1;
class Field {
// strength > 0, isRepulsor toggles push/pull
constructor(x, y, strength, radius, isRepulsor = false) {
this.pos = createVector(x, y);
this.strength = strength;
this.radius = radius;
this.isRepulsor = isRepulsor;
}
applyTo(p) {
const dx = this.pos.x - p.pos.x;
const dy = this.pos.y - p.pos.y;
let distSq = dx * dx + dy * dy;
const radiusSq = this.radius * this.radius;
if (distSq > radiusSq) return; // outside influence
// Soften near center to avoid crazy accelerations
const softening = 100;
distSq += softening;
let dist = sqrt(distSq);
if (dist === 0) return;
let dir = createVector(dx, dy);
dir.div(dist); // normalize
// Strength falls off from center to edge
let falloff = 1 - distSq / (radiusSq + softening);
falloff = constrain(falloff, 0, 1);
let forceMag = (this.strength * falloff) / distSq;
if (this.isRepulsor) forceMag *= -1;
dir.mult(forceMag);
p.acc.add(dir);
}
draw() {
const alpha = 40;
noFill();
strokeWeight(1.5);
if (this.isRepulsor) {
// repulsor: magenta
stroke(320, 100, 100, alpha);
} else {
// attractor: cyan
stroke(190, 100, 100, alpha);
}
ellipse(this.pos.x, this.pos.y, this.radius * 2, this.radius * 2);
// core
if (this.isRepulsor) {
fill(320, 100, 100, 80);
} else {
fill(190, 100, 100, 80);
}
noStroke();
ellipse(this.pos.x, this.pos.y, 8, 8);
}
}
Line-by-line explanation (7 lines)
🔧 Subcomponents:
conditional
Outside Influence Check
if (distSq > radiusSq) return; // outside influence
Skips particles that are too far away from this field, saving computation and preventing force from acting outside its radius
calculation
Force Falloff
let falloff = 1 - distSq / (radiusSq + softening);
Makes the force weaken smoothly from the center of the field toward its edge instead of cutting off abruptly
conditional
Repulsor Flip
if (this.isRepulsor) forceMag *= -1;
Reverses the force direction so repulsion zones push particles away instead of pulling them in
const dx = this.pos.x - p.pos.x;
- Finds how far the field is from the particle horizontally, pointing toward the field
if (distSq > radiusSq) return; // outside influence
- Early-exits for particles outside this field's radius, so distant particles aren't affected at all
const softening = 100;
- Adds a fixed amount to the squared distance so forces near the very center don't explode toward infinity
dir.div(dist); // normalize
- Turns the direction vector into a unit vector (length 1) so only forceMag controls its strength
let forceMag = (this.strength * falloff) / distSq;
- Combines the field's base strength, the edge falloff, and an inverse-square drop-off with distance - just like real gravity
if (this.isRepulsor) forceMag *= -1;
- Flips the sign of the force for repulsion zones so particles are pushed away instead of pulled in
p.acc.add(dir);
- Adds this field's contribution onto the particle's acceleration; multiple fields can stack their effects on the same particle
function initParticles(count) {
particles = [];
for (let i = 0; i < count; i++) {
particles.push(new Particle(random(width), random(height)));
}
// Keep existing fields
}
Line-by-line explanation (2 lines)
🔧 Subcomponents:
for-loop
Spawn Particles Loop
for (let i = 0; i < count; i++) {
Creates 'count' new Particle objects at random positions and adds them to the particles array
particles = [];
- Empties the particles array completely before repopulating it, so old particles don't linger
particles.push(new Particle(random(width), random(height)));
- Creates a brand new Particle at a random x/y position anywhere on the canvas and adds it to the array
🔬 New particles are only born when colliding particles are moving fast relative to each other. What happens if you raise relSpeed > 1.5 to relSpeed > 5 instead?
if (relSpeed > 1.5 && particles.length < maxParticles) {
// Spawn a new particle from this collision
const midX = (p1.pos.x + p2.pos.x) * 0.5;
const midY = (p1.pos.y + p2.pos.y) * 0.5;
function handleCollisions() {
const n = particles.length;
if (n > maxParticles) return;
const collisionRadiusSq = 9; // 3px radius
for (let i = 0; i < n; i++) {
const p1 = particles[i];
for (let j = i + 1; j < n; j++) {
const p2 = particles[j];
const dx = p1.pos.x - p2.pos.x;
const dy = p1.pos.y - p2.pos.y;
const distSq = dx * dx + dy * dy;
if (distSq < collisionRadiusSq) {
const relSpeed = p5.Vector.sub(p1.vel, p2.vel).mag();
if (relSpeed > 1.5 && particles.length < maxParticles) {
// Spawn a new particle from this collision
const midX = (p1.pos.x + p2.pos.x) * 0.5;
const midY = (p1.pos.y + p2.pos.y) * 0.5;
const newborn = new Particle(midX, midY);
newborn.vel = p5.Vector.add(p1.vel, p2.vel)
.mult(0.5)
.rotate(random(-0.5, 0.5));
newborn.size = (p1.size + p2.size) * 0.45;
particles.push(newborn);
}
// Gentle separating impulse so they don't stick
const normal = createVector(dx, dy).normalize().mult(0.05);
p1.vel.add(normal);
p2.vel.sub(normal);
}
}
}
}
Line-by-line explanation (6 lines)
🔧 Subcomponents:
for-loop
Pairwise Collision Check
for (let j = i + 1; j < n; j++) {
Compares every unique pair of particles (never the same pair twice) to see if they're close enough to collide
conditional
Spawn New Particle
if (relSpeed > 1.5 && particles.length < maxParticles) {
Only creates a new particle when two colliding particles are moving fast relative to each other, and the population cap hasn't been reached
if (n > maxParticles) return;
- Skips all collision checking entirely once the particle count is already over the cap, as a performance safeguard
const distSq = dx * dx + dy * dy;
- Uses squared distance to avoid an expensive sqrt() call when just comparing against a fixed collision radius
const relSpeed = p5.Vector.sub(p1.vel, p2.vel).mag();
- Calculates how fast the two particles are moving relative to each other - a slow graze shouldn't spawn a new particle
const newborn = new Particle(midX, midY);
- Creates a brand-new particle exactly between the two colliding particles
newborn.vel = p5.Vector.add(p1.vel, p2.vel)
.mult(0.5)
.rotate(random(-0.5, 0.5));
- Gives the new particle a velocity that's the average of its two parents, then nudges the angle randomly for variety
const normal = createVector(dx, dy).normalize().mult(0.05);
- Creates a tiny push-apart force so colliding particles don't stack directly on top of each other forever
🔬 This condition prevents duplicate lines by only storing a connection once. What happens if you remove the if-check and always push the line?
if (i < neighborIndex) {
crystalLines.push({
x1: p.pos.x,
y1: p.pos.y,
x2: q.pos.x,
y2: q.pos.y
});
}
function buildCrystalGeometry() {
crystalLines = [];
if (particles.length < 2) return;
// Connect each particle to its 2 nearest neighbors
const k = 2;
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
let nearest = [];
for (let j = 0; j < particles.length; j++) {
if (i === j) continue;
const q = particles[j];
const dx = p.pos.x - q.pos.x;
const dy = p.pos.y - q.pos.y;
const d = dx * dx + dy * dy;
nearest.push({ j, d });
}
nearest.sort((a, b) => a.d - b.d);
const limit = min(k, nearest.length);
for (let n = 0; n < limit; n++) {
const neighborIndex = nearest[n].j;
const q = particles[neighborIndex];
// Store unique pairs (i < j) to avoid duplicates
if (i < neighborIndex) {
crystalLines.push({
x1: p.pos.x,
y1: p.pos.y,
x2: q.pos.x,
y2: q.pos.y
});
}
}
}
}
Line-by-line explanation (5 lines)
🔧 Subcomponents:
for-loop
Measure Distance To Every Other Particle
for (let j = 0; j < particles.length; j++) {
Computes the distance from one particle to every other particle so they can be ranked by closeness
calculation
Sort By Nearest
nearest.sort((a, b) => a.d - b.d);
Orders all other particles from closest to farthest so the nearest ones can be picked
if (particles.length < 2) return;
- Bails out immediately if there aren't at least two particles to connect
const k = 2;
- Sets how many nearest neighbors each particle will try to connect to
nearest.push({ j, d });
- Records both the index and squared distance of every other particle for later sorting
const limit = min(k, nearest.length);
- Prevents trying to connect to more neighbors than actually exist
if (i < neighborIndex) {
- Only stores a line when the current index is smaller than the neighbor's, which prevents the same connection being added twice
function drawCrystals() {
// faint background so crystals glow
background(0, 0, 0, 10);
// flicker color slightly over time
const t = frameCount * 0.02;
const baseHue = (200 + 40 * sin(t)) % 360;
strokeWeight(1.4);
noFill();
for (let lineSeg of crystalLines) {
const hue = (baseHue + random(-6, 6)) % 360;
stroke(hue, 80, 100, 80);
line(lineSeg.x1, lineSeg.y1, lineSeg.x2, lineSeg.y2);
// small crystalline nodes
const mx = (lineSeg.x1 + lineSeg.x2) * 0.5;
const my = (lineSeg.y1 + lineSeg.y2) * 0.5;
noStroke();
fill(hue, 90, 100, 60);
ellipse(mx, my, 3, 3);
}
}
Line-by-line explanation (4 lines)
🔧 Subcomponents:
calculation
Hue Flicker Over Time
const baseHue = (200 + 40 * sin(t)) % 360;
Slowly oscillates the base color of the crystal lines using a sine wave tied to frameCount
for-loop
Draw Each Crystal Line
for (let lineSeg of crystalLines) {
Draws every stored nearest-neighbor connection as a glowing line with a small node at its midpoint
const t = frameCount * 0.02;
- Turns the frame counter into a slowly increasing time value used to drive the sine wave
const baseHue = (200 + 40 * sin(t)) % 360;
- Oscillates the base hue between 160 and 240 over time, creating a subtle color shimmer
const hue = (baseHue + random(-6, 6)) % 360;
- Adds a small random jitter to each individual line's hue so the crystal doesn't look flat and uniform
ellipse(mx, my, 3, 3);
- Draws a tiny dot at the midpoint of each line to look like a crystalline node or joint
function analyzeFlow() {
const cx = width * 0.5;
const cy = height * 0.5;
let bins = [];
for (let i = 0; i < NUM_BINS; i++) {
bins.push({
count: 0,
distSum: 0,
speedSum: 0
});
}
for (let p of particles) {
const dx = p.pos.x - cx;
const dy = p.pos.y - cy;
let angle = atan2(dy, dx); // -PI..PI
if (angle < 0) angle += TWO_PI;
let idx = floor(map(angle, 0, TWO_PI, 0, NUM_BINS));
if (idx >= NUM_BINS) idx = NUM_BINS - 1;
const dist = sqrt(dx * dx + dy * dy);
const speed = p.vel.mag();
const bin = bins[idx];
bin.count++;
bin.distSum += dist;
bin.speedSum += speed;
}
const baseRadius = min(width, height) * 0.3;
for (let bin of bins) {
if (bin.count > 0) {
bin.avgDist = bin.distSum / bin.count;
bin.avgSpeed = bin.speedSum / bin.count;
} else {
bin.avgDist = baseRadius;
bin.avgSpeed = 0;
}
}
return bins;
}
Line-by-line explanation (5 lines)
🔧 Subcomponents:
for-loop
Sort Particles Into Angle Bins
for (let p of particles) {
Places every particle into one of NUM_BINS angular slices based on its direction from the canvas center
for-loop
Average Each Bin
for (let bin of bins) {
Converts each bin's running totals into averages so they can be compared fairly regardless of how many particles landed in each bin
let angle = atan2(dy, dx); // -PI..PI
- Calculates the angle from the canvas center to this particle using atan2, which returns a value between -PI and PI
if (angle < 0) angle += TWO_PI;
- Shifts any negative angle into the 0 to TWO_PI range so it maps cleanly onto bin indices
let idx = floor(map(angle, 0, TWO_PI, 0, NUM_BINS));
- Maps the angle into a bin index between 0 and NUM_BINS, then rounds down to get a whole number
bin.distSum += dist;
- Accumulates the total distance-from-center for this bin, used later to compute an average
bin.avgSpeed = bin.speedSum / bin.count;
- Divides the summed speed by the particle count in that bin to get the average speed for that angular slice
function generateSymmetrySuggestions() {
if (particles.length === 0) return;
const bins = analyzeFlow();
let indices = [...Array(NUM_BINS).keys()];
// sort by lowest density first (we want to "balance" them)
indices.sort((a, b) => bins[a].count - bins[b].count);
const cx = width * 0.5;
const cy = height * 0.5;
const numSuggestions = 4;
suggestions = [];
for (let i = 0; i < numSuggestions; i++) {
const idx = indices[i % indices.length];
const bin = bins[idx];
const angleCenter = (idx + 0.5) * (TWO_PI / NUM_BINS);
let radius = bin.avgDist;
const minR = min(width, height) * 0.18;
const maxR = min(width, height) * 0.4;
if (!isFinite(radius) || radius < minR) radius = minR;
if (radius > maxR) radius = maxR;
const pos = p5.Vector.fromAngle(angleCenter).mult(radius).add(cx, cy);
suggestions.push({
x: pos.x,
y: pos.y,
radius: radius * 0.9,
isRepulsor: false,
type: 'symmetry'
});
}
aiMode = 'symmetry';
}
Line-by-line explanation (5 lines)
🔧 Subcomponents:
calculation
Sort Bins By Density
indices.sort((a, b) => bins[a].count - bins[b].count);
Orders the 8 angular bins from emptiest to most crowded so suggestions can target the sparse areas
for-loop
Build Suggestions
for (let i = 0; i < numSuggestions; i++) {
Generates 4 suggested attractor positions, one for each of the sparsest angular regions
const bins = analyzeFlow();
- Runs the flow analysis to get up-to-date density and speed stats for every angular slice
indices.sort((a, b) => bins[a].count - bins[b].count);
- Sorts bin indices ascending by particle count, so emptiest regions come first
const angleCenter = (idx + 0.5) * (TWO_PI / NUM_BINS);
- Calculates the middle angle of a bin's angular slice, used to position the suggestion radially
const pos = p5.Vector.fromAngle(angleCenter).mult(radius).add(cx, cy);
- Converts an angle and radius into an actual x/y position on the canvas, relative to the center
isRepulsor: false,
- Symmetry suggestions are always attractors, meant to gently pull particles into empty regions
function generateChaosSuggestions() {
if (particles.length === 0) return;
const bins = analyzeFlow();
let indices = [...Array(NUM_BINS).keys()];
// turbulence score: faster + more particles
indices.sort(
(a, b) =>
bins[b].avgSpeed * bins[b].count - bins[a].avgSpeed * bins[a].count
);
const cx = width * 0.5;
const cy = height * 0.5;
const numSuggestions = 5;
suggestions = [];
for (let i = 0; i < numSuggestions; i++) {
const idx = indices[i % indices.length];
const bin = bins[idx];
const angleCenter = (idx + 0.5) * (TWO_PI / NUM_BINS);
let radius = bin.avgDist * 0.9;
const minR = min(width, height) * 0.15;
const maxR = min(width, height) * 0.45;
if (!isFinite(radius) || radius < minR) radius = minR;
if (radius > maxR) radius = maxR;
const pos = p5.Vector.fromAngle(angleCenter).mult(radius).add(cx, cy);
const isRep = i % 2 === 0; // alternate repulsor/attractor
suggestions.push({
x: pos.x,
y: pos.y,
radius: radius * 0.8,
isRepulsor: isRep,
type: 'chaos'
});
}
aiMode = 'chaos';
}
Line-by-line explanation (3 lines)
🔧 Subcomponents:
calculation
Sort Bins By Turbulence
bins[b].avgSpeed * bins[b].count - bins[a].avgSpeed * bins[a].count
Ranks angular bins by a 'turbulence score' combining how fast and how crowded each region is
conditional
Alternate Repulsor/Attractor
const isRep = i % 2 === 0; // alternate repulsor/attractor
Makes every other suggestion a repulsor instead of an attractor, to intensify turbulence rather than calm it
bins[b].avgSpeed * bins[b].count - bins[a].avgSpeed * bins[a].count
- Sorts descending by 'speed times count' so the busiest, fastest-moving regions of the canvas come first
let radius = bin.avgDist * 0.9;
- Slightly shrinks the suggested radius compared to symmetry mode, making chaos zones feel tighter and more intense
const isRep = i % 2 === 0; // alternate repulsor/attractor
- Uses the modulo operator to alternate true/false, mixing pushes and pulls to maximize disorder
function drawSuggestions() {
if (suggestions.length === 0) return;
const t = frameCount * 0.08;
const pulse = map(sin(t), -1, 1, 0.7, 1.2);
noFill();
strokeWeight(1.4);
for (let s of suggestions) {
const r = s.radius * pulse;
const baseAlpha = 45;
if (s.type === 'symmetry') {
stroke(190, 100, 100, baseAlpha);
} else {
stroke(320, 100, 100, baseAlpha);
}
ellipse(s.x, s.y, r * 2, r * 2);
// center dot
noStroke();
if (s.isRepulsor) {
fill(320, 100, 100, 80);
} else {
fill(190, 100, 100, 80);
}
ellipse(s.x, s.y, 6, 6);
}
}
Line-by-line explanation (3 lines)
🔧 Subcomponents:
calculation
Pulsing Animation
const pulse = map(sin(t), -1, 1, 0.7, 1.2);
Creates a gentle breathing/pulsing size animation for the ghost circles using a sine wave
for-loop
Draw Each Ghost Suggestion
for (let s of suggestions) {
Draws a pulsing outline circle and center dot for every pending AI suggestion
if (suggestions.length === 0) return;
- Skips all drawing work if there are no active AI suggestions to show
const pulse = map(sin(t), -1, 1, 0.7, 1.2);
- Converts a sine wave (-1 to 1) into a scale factor between 0.7 and 1.2 so the circles gently grow and shrink
if (s.type === 'symmetry') {
- Colors symmetry suggestions cyan and chaos suggestions magenta, matching the colors used for real fields
function drawHUD() {
fill(0, 0, 100, 80);
noStroke();
textSize(12);
textAlign(LEFT, TOP);
let lines = [];
lines.push('AI GRAVITY PAINTER');
lines.push(`Particles: ${particles.length.toString().padStart(3, ' ')}`);
lines.push(`Wells/zones: ${fields.length}`);
let aiLabel = 'OFF';
if (aiMode === 'symmetry') aiLabel = 'Symmetry (1)';
else if (aiMode === 'chaos') aiLabel = 'Chaos (2)';
lines.push(`AI suggestions: ${aiLabel}`);
if (crystallized) {
lines.push('Mode: CRYSTALLIZED (press C to return to fluid)');
} else {
lines.push('Mode: Fluid (press C to crystallize geometry)');
}
if (showHelp) {
lines.push('');
lines.push('Controls:');
lines.push(' Click: add gravity well (attractor)');
lines.push(' Drag: create repulsion zone');
lines.push(' 1: AI symmetry suggestions');
lines.push(' 2: AI chaos suggestions');
lines.push(' ENTER: apply AI suggestions');
lines.push(' C: crystallize / un-crystallize');
lines.push(' R: reset particles & fields');
lines.push(' H: toggle this help');
} else {
lines.push('');
lines.push('Press H for help');
}
let y = 10;
for (let line of lines) {
text(line, 12, y);
y += 15;
}
}
Line-by-line explanation (4 lines)
🔧 Subcomponents:
conditional
AI Mode Label
if (aiMode === 'symmetry') aiLabel = 'Symmetry (1)';
Chooses which text label to show depending on the current AI suggestion mode
conditional
Help Text Toggle
if (showHelp) {
Shows the full control list or a short reminder, depending on whether help is toggled on
for-loop
Render Each HUD Line
for (let line of lines) {
Draws every accumulated text line to the screen, stacking them vertically
fill(0, 0, 100, 80);
- Sets the HUD text color to near-white (in HSB, 0 hue/0 saturation/100 brightness) with some transparency
lines.push(`Particles: ${particles.length.toString().padStart(3, ' ')}`);
- Builds a text string showing the current particle count, padded so the display doesn't jitter as digits change
let y = 10;
- Starting vertical position for the first line of text
y += 15;
- Moves the next line of text down by 15 pixels, stacking each HUD line below the previous one
function mouseReleased() {
if (crystallized) return;
if (!isDragging || !dragStart) return;
const dx = mouseX - dragStart.x;
const dy = mouseY - dragStart.y;
const dragDist = sqrt(dx * dx + dy * dy);
const baseStrength = 2000;
const baseRadius = min(width, height) * 0.2;
if (dragDist < dragThreshold) {
// Click → gravity well (attractor)
const radius = baseRadius;
fields.push(new Field(mouseX, mouseY, baseStrength, radius, false));
} else {
// Drag → repulsion zone, radius = drag distance (clamped)
const maxR = min(width, height) * 0.45;
const radius = constrain(dragDist, 40, maxR);
fields.push(new Field(dragStart.x, dragStart.y, baseStrength, radius, true));
}
isDragging = false;
dragStart = null;
}
Line-by-line explanation (5 lines)
🔧 Subcomponents:
conditional
Click vs Drag Decision
if (dragDist < dragThreshold) {
Decides whether the mouse gesture counts as a simple click (small movement) or an intentional drag (larger movement)
const dragDist = sqrt(dx * dx + dy * dy);
- Calculates the straight-line distance between where the mouse was pressed and where it was released
if (dragDist < dragThreshold) {
- If the mouse barely moved, treats the gesture as a click rather than a drag
fields.push(new Field(mouseX, mouseY, baseStrength, radius, false));
- Creates a new attracting gravity well (isRepulsor = false) at the click location
const radius = constrain(dragDist, 40, maxR);
- Uses the drag distance itself as the repulsion zone's radius, clamped between a minimum of 40px and a maximum size
fields.push(new Field(dragStart.x, dragStart.y, baseStrength, radius, true));
- Creates a repulsion zone (isRepulsor = true) anchored at the original drag start point, not the release point