julia set

[github]

a julia set changing shape as its constant c goes round

generative 66×24 20 fps 2.3 kB

use it

html

<script type="module" src="https://ascii.rest/ascii.js"></script>

<ascii-art piece="julia-set"></ascii-art>

No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.

react

// npm install github:bas3line/ascii
import { Ascii } from "ascii.rest/react";
import { juliaSet } from "ascii.rest/pieces";

<Ascii piece={juliaSet} />

A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="julia-set", to fetch the piece only when it mounts.

astro

---
// npm install github:bas3line/ascii
import Ascii from "ascii.rest/astro";
---

<Ascii piece="julia-set" />

Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.

typescript

// npm install github:bas3line/ascii
import { mount } from "ascii.rest";
import { juliaSet } from "ascii.rest/pieces";

const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, juliaSet);

mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.

julia-set.ts

[js] [github]
/*
 * julia-set: the Julia set of z*z + c, drawn as a glow on its edge, with c
 * going round a circle so the set melts from one shape into the next.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "julia set",
  category: "generative",
  note: "a julia set changing shape as its constant c goes round",
  cols: 66,
  rows: 24,
  fps: 20,
} satisfies Meta;

const RAMP = " .:-=+*#%@";
const P = 40; // seconds for c to go once round
const CX = -0.4, R = 0.68; // c's circle: it stays close to the Mandelbrot set's edge
const A0 = Math.PI / 2; // c starts at -0.4 + 0.68i
const MAX = 120;
const SPAN = 3.7; // the width of the view
const REACH = 4; // how far the glow reaches from the edge, in cells

export default function juliaSet(): Frame {
  const { cols, rows } = meta;
  const N = RAMP.length;
  const dx = SPAN / cols, dy = dx * 2; // a cell is twice as tall as it is wide
  const half = rows / 2;
  const grid = Array.from({ length: half }, () => new Array<string>(cols));

  return (t) => {
    const a = A0 + (2 * Math.PI * t) / P;
    const cr = CX + R * Math.cos(a), ci = R * Math.sin(a);
    // The set is symmetric through the origin, so the bottom half is the top turned round.
    for (let r = 0; r < half; r++) {
      const y0 = (r + 0.5 - half) * dy;
      for (let c = 0; c < cols; c++) {
        let x = (c + 0.5 - cols / 2) * dx, y = y0, n = 0, m = 0;
        let ex = 1, ey = 0; // the orbit's derivative, for the distance to the set
        for (; n < MAX; n++) {
          const xx = x * x, yy = y * y;
          m = xx + yy;
          if (m > 1e4) break;
          const t2 = 2 * (x * ex - y * ey);
          ey = 2 * (x * ey + y * ex);
          ex = t2;
          y = 2 * x * y + ci;
          x = xx - yy + cr;
        }
        if (n === MAX) {
          grid[r][c] = " "; // inside: the glow is on the edge alone
          continue;
        }
        const z = Math.sqrt(m);
        const d = (0.5 * z * Math.log(z)) / Math.hypot(ex, ey) / dx; // cells to the edge
        const k = Math.max(0, 1 - Math.pow(d / REACH, 0.6));
        grid[r][c] = RAMP[Math.min(N - 1, Math.floor(k * N))];
      }
    }
    const lines = grid.map((row) => row.join(""));
    for (let r = half - 1; r >= 0; r--) lines.push(grid[r].slice().reverse().join(""));
    return lines.join("\n");
  };
}