10
Fibonacci Spiral and the Golden Ratio
which is the positive root of . Convergence is geometric, so by the ratio already matches to four decimals.
idle
191 lines · vanilla
view source
// Fibonacci Spiral — squares of side F_n laid corner-to-corner, joined by
// quarter-circle arcs into the golden spiral. F_n / F_{n-1} -> phi.
let W, H, squares, scale, cx, cy, build, restartAt;
const MAX_N = 12; // F_12 = 144; ratio converges fast
const PHI = (1 + Math.sqrt(5)) / 2;
function fibs(n) {
const f = [1, 1];
for (let i = 2; i < n; i++) f.push(f[i - 1] + f[i - 2]);
return f;
}
// Build squares in math coords (y up). Direction cycle L, U, R, D
// (each successive square attaches to that side of the current bounding box).
function layout() {
const F = fibs(MAX_N);
const sq = [];
// Square 0: unit at origin.
sq.push({ x: 0, y: 0, s: F[0], dir: 0 });
// Bounding box tracker.
let minX = 0, minY = 0, maxX = F[0], maxY = F[0];
// Direction order: L, U, R, D, L, U, ... attach to that side of the bbox.
const DIRS = ["L", "U", "R", "D"];
for (let i = 1; i < MAX_N; i++) {
const s = F[i];
const d = DIRS[(i - 1) % 4];
let x, y;
if (d === "L") { x = minX - s; y = minY; minX -= s; }
else if (d === "U") { x = minX; y = maxY; maxY += s; }
else if (d === "R") { x = maxX; y = maxY - s; maxX += s; }
else { x = maxX - s; y = minY - s; minY -= s; }
sq.push({ x, y, s, dir: d });
}
return sq;
}
function fitToCanvas() {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const s of squares) {
if (s.x < minX) minX = s.x;
if (s.y < minY) minY = s.y;
if (s.x + s.s > maxX) maxX = s.x + s.s;
if (s.y + s.s > maxY) maxY = s.y + s.s;
}
const bw = maxX - minX, bh = maxY - minY;
const pad = 50;
scale = Math.min((W - pad * 2) / bw, (H - pad * 2) / bh);
cx = W / 2 - ((minX + maxX) / 2) * scale;
cy = H / 2 + ((minY + maxY) / 2) * scale; // y inverted
}
function toCanvas(x, y) {
return [cx + x * scale, cy - y * scale];
}
function squareColor(i) {
const h = (i * 34 + 200) % 360;
return `hsl(${h} 80% 60%)`;
}
function drawSquare(ctx, s, i, alpha) {
const [px, py] = toCanvas(s.x, s.y + s.s); // top-left in canvas coords
const sp = s.s * scale;
ctx.globalAlpha = alpha * 0.14;
ctx.fillStyle = squareColor(i);
ctx.fillRect(px, py, sp, sp);
ctx.globalAlpha = alpha * 0.85;
ctx.strokeStyle = squareColor(i);
ctx.lineWidth = 1.25;
ctx.strokeRect(px, py, sp, sp);
ctx.globalAlpha = alpha;
if (sp > 22) {
ctx.fillStyle = "rgba(255,255,255,0.88)";
const fs = Math.min(15, Math.max(10, sp * 0.14)) | 0;
ctx.font = `${fs}px ui-monospace, monospace`;
ctx.textBaseline = "top";
ctx.textAlign = "left";
const pad = Math.max(4, fs * 0.4);
ctx.fillText(`F${i + 1}=${s.s}`, px + pad, py + pad);
}
ctx.globalAlpha = 1;
}
function drawBBox(ctx) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const sq of squares) {
if (sq.x < minX) minX = sq.x;
if (sq.y < minY) minY = sq.y;
if (sq.x + sq.s > maxX) maxX = sq.x + sq.s;
if (sq.y + sq.s > maxY) maxY = sq.y + sq.s;
}
const [x0, y0] = toCanvas(minX, maxY);
const [x1, y1] = toCanvas(maxX, minY);
ctx.save();
ctx.strokeStyle = "rgba(255,255,255,0.10)";
ctx.setLineDash([4, 6]);
ctx.lineWidth = 1;
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
ctx.restore();
}
// Arc inside each square. Each arc connects the two diagonal corners
// shared with the previous and next squares in build order. Pivots are
// chosen so the radius at every shared corner is collinear with the
// radius of the neighbouring arc at that corner, giving C^1 continuity
// (matching tangents) across every join. Seed (i=0) uses the same
// (TL pivot, start at TR, sweep CW) recipe as a D-attached square.
// All arcs sweep math-CW (a0 decreasing by pi/2):
// dir L: pivot TR; arc BR -> TL.
// dir U: pivot BR; arc BL -> TR.
// dir R: pivot BL; arc TL -> BR.
// dir D / seed: pivot TL; arc TR -> BL.
function arcParams(s) {
const x = s.x, y = s.y, sp = s.s;
const HALF = Math.PI / 2;
const dir = s.dir;
let ax, ay, a0;
if (dir === "L") { ax = x + sp; ay = y + sp; a0 = 3 * HALF; } // pivot TR, start at BR (south of pivot)
else if (dir === "U") { ax = x + sp; ay = y; a0 = Math.PI; } // pivot BR, start at BL (west of pivot)
else if (dir === "R") { ax = x; ay = y; a0 = HALF; } // pivot BL, start at TL (north of pivot)
else /* D or seed */ { ax = x; ay = y + sp; a0 = 0; } // pivot TL, start at TR (east of pivot)
return { ax, ay, a0, sweep: -HALF, r: sp };
}
function drawArc(ctx, s, i, t) {
const p = arcParams(s);
const [px, py] = toCanvas(p.ax, p.ay);
const r = p.r * scale;
// Canvas y is flipped: math angle a -> canvas angle -a.
// Math CW sweep (a0 decreasing) -> canvas angle increasing -> anticlockwise=false.
const cStart = -p.a0;
const cEnd = -(p.a0 + p.sweep * t);
const acw = p.sweep > 0;
ctx.beginPath();
ctx.arc(px, py, r, cStart, cEnd, acw);
ctx.strokeStyle = "#ffd166";
ctx.lineWidth = 2.4;
ctx.shadowColor = "rgba(255,209,102,0.55)";
ctx.shadowBlur = 6;
ctx.stroke();
ctx.shadowBlur = 0;
}
function drawHUD(ctx) {
const built = build.idx; // 0..MAX_N-1
const n = built + 1; // human-facing index: F_1 = 1
const fn = squares[built].s;
const fnm1 = built >= 1 ? squares[built - 1].s : 1;
const ratio = built >= 1 ? fn / fnm1 : 1;
const err = Math.abs(ratio - PHI);
const ratioStr = built >= 1 ? ratio.toFixed(8) : "—";
const errStr = built >= 1 ? err.toExponential(2) : "—";
const panelW = 264;
const panelH = 142;
ctx.save();
ctx.fillStyle = "rgba(0,0,0,0.68)";
ctx.fillRect(10, 10, panelW, panelH);
ctx.strokeStyle = "rgba(255,255,255,0.10)";
ctx.lineWidth = 1;
ctx.strokeRect(10.5, 10.5, panelW, panelH);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.font = "bold 13px ui-monospace, monospace";
ctx.fillStyle = "#fff";
ctx.fillText("Fibonacci spiral", 20, 30);
ctx.font = "12px ui-monospace, monospace";
ctx.fillStyle = "#cfe8ff";
ctx.fillText(`n = ${n}`.padEnd(10) + `Fn = ${fn}`, 20, 52);
ctx.fillStyle = "#ffd166";
ctx.fillText(`Fn / Fn-1 = ${ratioStr}`, 20, 74);
ctx.fillStyle = "#9be3a3";
ctx.fillText(`|phi - ratio| = ${errStr}`, 20, 94);
ctx.fillStyle = "rgba(255,255,255,0.55)";
ctx.fillText(`phi = ${PHI.toFixed(8)}...`, 20, 114);
ctx.fillStyle = "rgba(255,255,255,0.45)";
ctx.font = "11px ui-monospace, monospace";
ctx.fillText("Fn / Fn-1 -> phi as n grows", 20, 132);
ctx.restore();
}
function init({ width, height }) {
W = width; H = height;
squares = layout();
fitToCanvas();
build = { idx: 0, t: 0 };
restartAt = 0;
}
function tick({ ctx, dt, width, height, time }) {
if (width !== W || height !== H) {
W = width; H = height;
fitToCanvas();
}
ctx.fillStyle = "#070914";
ctx.fillRect(0, 0, W, H);
// Each square lands in ~900 ms; total build ~10.8 s; hold 2 s; loop ~13 s.
const STEP_SECONDS = 0.9;
const HOLD_SECONDS = 2.0;
build.t += (dt || 0.016) / STEP_SECONDS;
if (build.t >= 1) {
if (build.idx < squares.length - 1) {
build.idx++;
build.t = 0;
} else {
build.t = 1;
if (!restartAt) restartAt = time + HOLD_SECONDS;
if (time >= restartAt) {
build.idx = 0;
build.t = 0;
restartAt = 0;
}
}
}
// Faint bounding rectangle for context.
drawBBox(ctx);
// Squares first, then arcs on top so the spiral is the visual focus.
for (let i = 0; i <= build.idx; i++) {
const a = i < build.idx ? 1 : Math.min(1, 0.35 + build.t * 0.65);
drawSquare(ctx, squares[i], i, a);
}
for (let i = 0; i <= build.idx; i++) {
const t = i < build.idx ? 1 : build.t;
drawArc(ctx, squares[i], i, t);
}
drawHUD(ctx);
}
Comments (2)
Log in to comment.