Quick Reference
SWFigure8 is a SketchWave parametric class that draws a lemniscate of Bernoulli — the classic figure‑8 / infinity‑symbol curve. The shape is defined by the polar equation r² = a²·cos(2θ) and sampled at SAMPLE_COUNT = 400 points using a smooth parametric form that traces both lobes in one continuous pass. The pulse animation sinusoidally oscillates the scale to create a breathing figure‑8 effect.
- Design Pattern: Parametric curve (not composition or inheritance)
- Polar equation: r² = a²·cos(2θ)
- Cartesian equivalent: (x² + y²)² = a²(x² − y²)
- Parametric equations: x(t) = a·cos(t)/(1+sin²t), y(t) = a·sin(t)cos(t)/(1+sin²t), t ∈ [0, 2π)
- Both lobes: Traced in a single smooth pass; no domain gaps or discontinuities
- Internal structure: Cartesian coordinates sampled at
SAMPLE_COUNT = 400points - Dependencies: SWPoint, SWColor, SWGrid, p5.js
- Key features: Scale control, spin animation, pulse animation, fill & stroke with independent alpha, draggable center
- Default figure: scale = 5 → width 10 grid units, fitting neatly in a standard [−10,10] × [−10,10] SWGrid
Historical Context
The lemniscate of Bernoulli was first described by Swiss mathematician Jakob Bernoulli in 1694 in a paper published in Acta Eruditorum, the premier scientific journal of the time. While studying the elastic curve of a bent beam, Bernoulli observed a self‑intersecting figure‑8 shape and gave it the name lemniscate.
The name derives from the Latin lemniscus — a ribbon or pendant — which traces back to the Greek lemniskos, the woolen ribbons tied to garlands awarded to victors in ancient Greek athletic games. Bernoulli chose it because the curve's two interlocking lobes resembled a ribbon tied in a figure‑8 bow.
Mathematical Significance
The arc length of a full lemniscate cannot be expressed using elementary functions. This discovery, investigated by Leonhard Euler in the 1750s, gave rise to a new class of integrals — elliptic integrals — and ultimately to the theory of elliptic functions developed by Gauss, Abel, and Jacobi in the 19th century. The lemniscate constant
ω = 2.622 057 554…
plays exactly the same role for the lemniscate that π plays for the circle: a full circuit of the lemniscate has arc length 2ω·a. Gauss proved in 1797 that the lemniscate can be divided into equal arcs using compass and straightedge (for certain numbers of divisions), an exact analog of the classical result for the regular polygon.
Geometric Definition
The lemniscate is the locus of all points P in the plane such that the product of P's distances from two fixed foci F1 = (−c, 0) and F2 = (c, 0) equals c². When a² = 2c² this gives the standard lemniscate r² = a²·cos(2θ). Compare with the ellipse, where the sum of distances is constant — the lemniscate keeps the product constant.
The Math
Polar Equation
r² = a² · cos(2θ)
Solutions exist only when cos(2θ) ≥ 0, i.e., θ ∈ [−π/4, π/4] ∪ [3π/4, 5π/4]. This defines the two lobes of the figure‑8.
Cartesian Equation
(x² + y²)² = a² · (x² − y²)
Parametric Equations (used by SWFigure8)
x(t) = a · cos(t) / (1 + sin²(t))
y(t) = a · sin(t) · cos(t) / (1 + sin²(t))
for t ∈ [0, 2π)
These equations trace both lobes in one continuous pass. The denominator (1 + sin²(t)) is always in [1, 2] — never zero — so there are no singularities or domain gaps.
Derivation (Why These Equations Work)
Starting from the Cartesian form (x² + y²)² = a²(x² − y²), we verify that x(t) = a·cos(t)/d and y(t) = a·sin(t)cos(t)/d, where d = 1 + sin²(t), satisfy the equation:
Step 1: x² + y² = a²cos²(t)(1 + sin²(t)) / d² = a²cos²(t) / d
Step 2: (x² + y²)² = a&sup4;cos&sup4;(t) / d²
Step 3: a²(x² − y²) = a&sup4;cos²(t)(1 − sin²(t)) / d²
= a&sup4;cos&sup4;(t) / d² ✓ (since 1 − sin²(t) = cos²(t))
Key Geometric Values (scale = a)
| Property | Value | Note |
|---|---|---|
| Width (tip to tip) | 2a | Rightmost point (a, 0); leftmost (−a, 0) |
| Max lobe height | ≈ 0.707·a | a√2/2; occurs at x ≈ ±0.612a |
| Crossing point | (0, 0) | t = π/2 and t = 3π/2 |
| Width : height ratio | ≈ 2.83 : 1 | 2a : a√2/2 = 2√2 : 1 |
| Arc length (full curve) | 2ω·a ≈ 5.244a | ω = lemniscate constant ≈ 2.622 |
| Rotation 0° | Lobes left & right | Standard orientation |
| Rotation 90° | Lobes up & down | Classic ∞ symbol orientation |
At these values the figure spans ±5 grid units in x and fits neatly within a standard [−10, 10] × [−10, 10] SWGrid.
Constructor
new SWFigure8(center, scale, fillColor, strokeColor, thickness, rotationDeg)
| Parameter | Type | Default | Description |
|---|---|---|---|
center | SWPoint | required | Center of the figure in user (grid) coordinates |
scale | number | 5 | Half‑width of each lobe in grid units (the 'a' parameter) |
fillColor | SWColor|undefined | undefined | Fill color; undefined = no fill |
strokeColor | SWColor|undefined | undefined | Stroke / outline color |
thickness | number | 2 | Stroke weight in pixels |
rotationDeg | number | 0 | Static base rotation in CCW degrees |
// Minimal — stroke only, horizontal lobes
const fig = new SWFigure8(
new SWPoint(0, 0),
5, // scale (half-lobe width)
undefined, // no fill
SWColor.fromHex('#00e5cc', 100, 'stroke'),
2 // thickness
);
// With fill, 90° rotation (lobes up/down, classic ∞ symbol)
const infty = new SWFigure8(
new SWPoint(0, 0),
4,
SWColor.fromHex('#007a6e', 20, 'fill'),
SWColor.fromHex('#00e5cc', 100, 'stroke'),
2,
90 // rotationDeg
);
Properties
| Property | Type | Notes |
|---|---|---|
center | SWPoint | Center in user (grid) coordinates; drag it live |
scale | number | Half‑width of each lobe (the 'a' parameter); set via setScale() |
thickness | number | Stroke weight in pixels |
rotationDeg | number | Static base rotation (CCW degrees); does not clear on reset |
rotation | number | Accumulated spin rotation; cleared by reset() |
fillColor | SWColor|undefined | Fill color object; undefined = no fill pass |
strokeColor | SWColor|undefined | Stroke color object |
originalScale… | various | Read-only originals stored at construction; used by reset() |
Methods
draw()
Draws the figure in raw screen (pixel) coordinates without a grid. Prefer drawOnGrid() for standard use.
drawOnGrid(grid)
Draws the figure through the given SWGrid's coordinate system. This is the standard method to call in a p5.js draw() loop. Handles the math-space ↔ screen y-flip automatically. Also draws the center dot if center.shouldShow is true.
rotate(deltaAngle)
Accumulates deltaAngle degrees into this.rotation. CCW positive, CW negative. Call once per frame: fig.rotate(spinSpeed * deltaT).
Setters
| Method | Description |
|---|---|
setScale(s) | Set scale / half-lobe width (min 0.01) |
setRotation(deg) | Set static base rotation in CCW degrees |
setFillColor(fc) | Set fill SWColor (pass undefined to clear) |
setStrokeColor(sc) | Set stroke SWColor |
setStrokeWeight(w) | Set stroke thickness in pixels |
setFillAlpha(a) | Set fill alpha 0–100 and rebuild p5 color |
setStrokeAlpha(a) | Set stroke alpha 0–100 and rebuild p5 color |
reset()
Restores scale, rotation, and colors to the values passed to the constructor. Clears accumulated spin rotation (this.rotation = 0). Does not move the center position.
static copy(other)
Creates a deep copy of the given SWFigure8, preserving all current and original state including the center point.
const backup = SWFigure8.copy(fig);
toString()
Returns a human-readable string summary for debugging:
// Example output:
SWFigure8(center=SWPoint(0.0, 0.0), scale=5.00, rotationDeg=0.0, rotation=0.0)
Pulse Animation
The pulse animation sinusoidally oscillates the scale parameter, creating a continuously breathing figure‑8 that swells and contracts. This is the signature animation of SWFigure8, analogous to the Beat animation of SWHeart.
How It Works
// In the p5.js draw() loop:
const t = millis() / 1000;
const deltaT = (prevT > 0) ? (t - prevT) : 0;
prevT = t;
if (shouldPulse) {
const rawScale = pulseBaseScale + pulseAmount * Math.sin(2 * Math.PI * pulseSpeed * t);
const clampedScale = Math.max(0.1, rawScale);
figure8.setScale(clampedScale);
// mirror live to the scale slider:
scaleSlider.value = String(parseFloat(clampedScale.toFixed(1)));
scaleValueSpan.textContent = clampedScale.toFixed(1);
}
figure8.drawOnGrid(grid);
When pulse is toggled on, pulseBaseScale is set to the current figure8.scale, so the pulse oscillates around whatever size the figure was when you enabled it.
Effect of Settings
| pulseSpeed (Hz) | Period | Effect |
|---|---|---|
| 0.1 | 10 seconds | Very slow, meditative breathing |
| 0.5 | 2 seconds | Default — relaxed heartbeat rhythm |
| 1.0 | 1 second | Brisk pulse |
| 3.0 | 0.33 seconds | Rapid flutter effect |
Trail Effect With Pulse
Setting background opacity to a low value (5–25) combined with pulse and spin creates an oscilloscope‑like glowing trail effect:
// In draw():
background(bgColor); // bgColor alpha ≈ 10–25 (SWColor with low alpha)
if (shouldPulse) {
const s = pulseBaseScale + pulseAmount * Math.sin(2 * Math.PI * pulseSpeed * t);
figure8.setScale(Math.max(0.1, s));
}
if (shouldSpin) figure8.rotate(spinSpeed * deltaT);
figure8.drawOnGrid(grid);
Code Examples
1. Basic Figure‑8 on a Grid
let grid, fig;
function setup() {
createCanvas(400, 400);
colorMode(HSB, 360, 100, 100, 100);
initializeSWColors();
grid = new SWGrid({ UL: new SWPoint(-10, 10), LR: new SWPoint(10, -10) });
const stroke = SWColor.fromHex('#00e5cc', 100, 'stroke');
const fill = SWColor.fromHex('#007a6e', 20, 'fill');
fig = new SWFigure8(new SWPoint(0, 0), 5, fill, stroke, 2);
}
function draw() {
background(color(0, 0, 93, 100));
grid.draw();
fig.drawOnGrid(grid);
}
2. Spinning Figure‑8
let grid, fig, prevT = 0;
function setup() {
createCanvas(400, 400);
colorMode(HSB, 360, 100, 100, 100);
initializeSWColors();
grid = new SWGrid({ UL: new SWPoint(-10, 10), LR: new SWPoint(10, -10) });
fig = new SWFigure8(new SWPoint(0, 0), 5,
undefined,
SWColor.fromHex('#00e5cc', 100, 'stroke'),
3);
}
function draw() {
const t = millis() / 1000;
const deltaT = (prevT > 0) ? t - prevT : 0;
prevT = t;
background(color(0, 0, 93, 100));
grid.draw();
fig.rotate(45 * deltaT); // 45 degrees/second
fig.drawOnGrid(grid);
}
3. Pulse + Low Background Opacity (trail effect)
let bgColor, grid, fig, prevT = 0;
const BASE_SCALE = 4;
const PULSE_SPEED = 0.5; // Hz
const PULSE_AMT = 2.5; // grid units
function setup() {
createCanvas(400, 400);
colorMode(HSB, 360, 100, 100, 100);
initializeSWColors();
const bg = new SWColor(0, 0, 5, 15, 'bgTrail'); // alpha = 15 for trails
bgColor = bg.col;
grid = new SWGrid({ UL: new SWPoint(-10, 10), LR: new SWPoint(10, -10) });
fig = new SWFigure8(new SWPoint(0, 0), BASE_SCALE,
undefined,
SWColor.fromHex('#00ffcc', 100, 'stroke'),
3);
}
function draw() {
const t = millis() / 1000;
const deltaT = (prevT > 0) ? t - prevT : 0;
prevT = t;
background(bgColor); // semi-transparent background → trails
const s = BASE_SCALE + PULSE_AMT * Math.sin(2 * Math.PI * PULSE_SPEED * t);
fig.setScale(Math.max(0.1, s));
fig.rotate(30 * deltaT);
fig.drawOnGrid(grid);
}
4. Multiple Figure‑8 Curves (nested scales)
let grid, figures;
const CENTER = new SWPoint(0, 0);
function setup() {
createCanvas(400, 400);
colorMode(HSB, 360, 100, 100, 100);
initializeSWColors();
grid = new SWGrid({ UL: new SWPoint(-10, 10), LR: new SWPoint(10, -10) });
figures = [
new SWFigure8(CENTER, 2, undefined, SWColor.fromHex('#ff6644', 100, 's1'), 2),
new SWFigure8(CENTER, 4, undefined, SWColor.fromHex('#44aaff', 100, 's2'), 2, 90),
new SWFigure8(CENTER, 6, undefined, SWColor.fromHex('#44ff88', 100, 's3'), 2, 45),
];
}
function draw() {
background(color(0, 0, 93, 100));
grid.draw();
for (const f of figures) f.drawOnGrid(grid);
}
Design Notes
- SAMPLE_COUNT = 400 (vs. SWLissajous's 500) — The lemniscate is a simpler curve (single set of lobes, no frequency ratio to create extra oscillations). 400 uniformly-spaced samples give visually smooth results at all scales used in typical demos.
- Single-pass tracing — Unlike the polar form r = a√cos(2θ) which requires two separate domain pieces for the two lobes, the parametric form traces both lobes continuously for t ∈ [0, 2π). The crossing at the origin (t = π/2 and t = 3π/2) is a true self‑intersection, not a discontinuity, so
endShape(CLOSE)closes the curve cleanly. - Denominator always positive — (1 + sin²(t)) ∈ [1, 2] everywhere, so no NaN or Infinity values are ever produced by the parametric equations. No guard clauses are needed.
- Pulse vs. Drift — SWLissajous has
driftPhase()which morphs the figure's topology by changing the phase offset. SWFigure8 has no morphing phase (its shape is fully determined by scale and rotation), so instead the demo provides a Pulse animation that oscillates the scale. This gives the figure a similar sense of fluid motion through a sinusoidal parameter. - rotationDeg vs. rotation — Same convention as all SketchWaveJS classes.
rotationDegis the static base (set by slider/constructor);rotationis accumulated spin (cleared by reset). Effective rotation = sum of both. - Fill pass before stroke pass — Mirrors the SWHeart and SWLissajous pattern. Fill is rendered first, stroke on top. This prevents the stroke from being half-obscured by fill at any opacity level.
Source Code
▼ Show / Hide swFigure8.js Source
/*
File: swFigure8.js
Date: 2026-04-27
Author: klp
App: SketchWaveTNT2026-04-21-Stg8
Purpose: SWFigure8 class for SketchWaveJS
SWFigure8 draws a lemniscate of Bernoulli -- the classic figure-8 / infinity curve
defined by the polar equation:
r^2 = a^2 * cos(2*theta)
which is equivalent to the Cartesian equation:
(x^2 + y^2)^2 = a^2 * (x^2 - y^2)
Represented as smooth parametric equations for t in [0, 2*pi):
x(t) = a * cos(t) / (1 + sin^2(t))
y(t) = a * sin(t) * cos(t) / (1 + sin^2(t))
where 'a' is the 'scale' parameter -- the half-width of each lobe in grid units.
The entire figure-8 is traced smoothly in one pass as t goes from 0 to 2*pi.
Key geometry (scale = a):
Width (tip to tip) = 2a (rightmost at (a,0), leftmost at (-a,0))
Max lobe height ~= 0.707*a (at x ~= +/-0.612*a)
Crossing point at origin: t = pi/2 (right-to-left) and t = 3*pi/2 (left-to-right)
The denominator (1 + sin^2(t)) ranges in [1, 2], so it is always positive.
Both lobes are traced continuously.
Dependencies: p5.js, SWColor, SWPoint, SWGrid.
*/
console.log("[swFigure8.js] SWFigure8 class loaded.");
class SWFigure8 {
static SAMPLE_COUNT = 400;
/**
* @param {SWPoint} center - Center in user (grid) coordinates
* @param {number} [scale=5] - Half-width of each lobe (grid units)
* @param {SWColor} [fillColor] - Fill color (undefined = no fill)
* @param {SWColor} [strokeColor] - Stroke color (undefined = no stroke)
* @param {number} [thickness=2] - Stroke weight in pixels
* @param {number} [rotationDeg=0] - Static base rotation (CCW degrees)
*/
constructor(center, scale = 5, fillColor = undefined, strokeColor = undefined,
thickness = 2, rotationDeg = 0) {
this.center = center;
this.scale = scale;
this.fillColor = fillColor ? SWColor.copy(fillColor) : undefined;
this.strokeColor = strokeColor ? SWColor.copy(strokeColor) : undefined;
this.thickness = thickness;
this.rotationDeg = rotationDeg;
this.rotation = 0;
this.originalScale = scale;
this.originalFillColor = fillColor ? SWColor.copy(fillColor) : undefined;
this.originalStrokeColor = strokeColor ? SWColor.copy(strokeColor) : undefined;
this.originalThickness = thickness;
this.originalRotationDeg = rotationDeg;
}//end constructor
_totalRotDeg() { return this.rotationDeg + this.rotation; }
_rotateLocal(lx, ly) {
const rad = this._totalRotDeg() * Math.PI / 180;
const cosR = Math.cos(rad);
const sinR = Math.sin(rad);
return {
x: lx * cosR - ly * sinR,
y: lx * sinR + ly * cosR,
};
}
_buildUserPts() {
const cx = this.center.x;
const cy = this.center.y;
const n = SWFigure8.SAMPLE_COUNT;
const a = this.scale;
const pts = [];
for (let i = 0; i < n; i++) {
const t = (i / n) * 2 * Math.PI;
const st = Math.sin(t);
const ct = Math.cos(t);
const den = 1 + st * st;
const lx = a * ct / den;
const ly = a * st * ct / den;
const rot = this._rotateLocal(lx, ly);
pts.push({ x: cx + rot.x, y: cy + rot.y });
}
return pts;
}
_buildScreenPtsGrid(grid) {
return this._buildUserPts().map(p => grid.userToScreen(p.x, p.y));
}
_buildScreenPtsDirect() {
const cx = this.center.x;
const cy = this.center.y;
const n = SWFigure8.SAMPLE_COUNT;
const a = this.scale;
const pts = [];
for (let i = 0; i < n; i++) {
const t = (i / n) * 2 * Math.PI;
const st = Math.sin(t);
const ct = Math.cos(t);
const den = 1 + st * st;
const lx = a * ct / den;
const ly = a * st * ct / den;
const rot = this._rotateLocal(lx, ly);
pts.push({ x: cx + rot.x, y: cy - rot.y });
}
return pts;
}
_drawShape(screenPts) {
if (screenPts.length < 2) return;
if (this.fillColor && this.fillColor.col) {
fill(this.fillColor.col);
noStroke();
beginShape();
for (const sp of screenPts) vertex(sp.x, sp.y);
endShape(CLOSE);
}
if (this.strokeColor && this.strokeColor.col) {
noFill();
stroke(this.strokeColor.col);
strokeWeight(this.thickness);
beginShape();
for (const sp of screenPts) vertex(sp.x, sp.y);
endShape(CLOSE);
}
noStroke(); noFill(); strokeWeight(1);
}
draw() {
const screenPts = this._buildScreenPtsDirect();
this._drawShape(screenPts);
if (this.center && this.center.draw) this.center.draw(this.strokeColor);
}
drawOnGrid(grid) {
const screenPts = this._buildScreenPtsGrid(grid);
this._drawShape(screenPts);
if (this.center && this.center.drawOnGrid) this.center.drawOnGrid(grid, this.strokeColor);
}
rotate(deltaAngle) { this.rotation += deltaAngle; }
setScale(s) { this.scale = Math.max(0.01, s); }
setRotation(deg) { this.rotationDeg = deg; }
setFillColor(fc) { this.fillColor = fc ? SWColor.copy(fc) : undefined; }
setStrokeColor(sc) { this.strokeColor = sc ? SWColor.copy(sc) : undefined; }
setStrokeWeight(w) { this.thickness = w; }
setFillAlpha(alpha) {
if (this.fillColor) {
this.fillColor.a = Math.max(0, Math.min(100, alpha));
this.fillColor.col = color(this.fillColor.h, this.fillColor.s,
this.fillColor.b, this.fillColor.a);
}
}
setStrokeAlpha(alpha) {
if (this.strokeColor) {
this.strokeColor.a = Math.max(0, Math.min(100, alpha));
this.strokeColor.col = color(this.strokeColor.h, this.strokeColor.s,
this.strokeColor.b, this.strokeColor.a);
}
}
reset() {
this.scale = this.originalScale;
this.rotationDeg = this.originalRotationDeg;
this.rotation = 0;
this.thickness = this.originalThickness;
this.fillColor = this.originalFillColor
? SWColor.copy(this.originalFillColor) : undefined;
this.strokeColor = this.originalStrokeColor
? SWColor.copy(this.originalStrokeColor) : undefined;
}
static copy(other) {
const c = new SWFigure8(
SWPoint.copy(other.center),
other.originalScale,
other.originalFillColor,
other.originalStrokeColor,
other.originalThickness,
other.originalRotationDeg
);
c.scale = other.scale;
c.rotationDeg = other.rotationDeg;
c.rotation = other.rotation;
return c;
}
toString() {
return 'SWFigure8(center=' + this.center +
', scale=' + this.scale.toFixed(2) +
', rotationDeg=' + this.rotationDeg.toFixed(1) +
', rotation=' + this.rotation.toFixed(1) + ')';
}
}//end SWFigure8 class