$sloprun.dev

Fractal Explorer

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 7 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

The Mandelbrot set falls out of one absurdly short rule applied to every pixel — start at zero, square it, add the pixel's own coordinates, repeat — where the points that never run away form the black shape and everything else is coloured by how fast it left. The remarkable part is that the boundary never resolves: zoom a thousand times and the same crenellated edge is still there, rebuilt from detail that did not exist a moment earlier. Start with Seahorses, then click straight into one of the spirals and keep going — each click doubles the magnification, and the image is drawn in coarse blocks and sharpened over several passes so it never locks up. Push far enough and you hit a real wall near 1e-13 wide, where ordinary 64-bit numbers run out of digits, the picture goes blocky, and the demo says so rather than pretending.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/fractal-explorer" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Fractal Explorer — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/fractal-explorer

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 995 lines 41.4 KB index.html
demos/fractal-explorer/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Fractal Explorer — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */

/* demo-local additions (scrims need alpha, tokens are opaque hex) */
:root { --scrim: rgba(246,246,243,.9); --shadow: 0 1px 2px rgba(26,29,33,.05); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --scrim: rgba(20,22,26,.9); --shadow: 0 1px 2px rgba(0,0,0,.35); }
}
:root[data-theme="dark"] { --scrim: rgba(20,22,26,.9); --shadow: 0 1px 2px rgba(0,0,0,.35); }

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }

.wrap {
  max-width: 1140px; margin: 0 auto;
  padding: 22px 16px 26px;
  display: flex; flex-direction: column; gap: 16px;
}

h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 68ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.lede code {
  font-family: var(--font-mono); font-size: .87em; color: var(--accent);
  background: var(--accent-soft); border-radius: 4px; padding: 1px 5px;
}

.stage { display: grid; grid-template-columns: minmax(0,1fr) 292px; gap: 16px; align-items: start; }
@media (max-width: 900px) { .stage { grid-template-columns: minmax(0,1fr); } }
.left { display: flex; flex-direction: column; gap: 12px; min-width: 0; }

/* the canvas frame */
.field {
  position: relative; overflow: hidden;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface); box-shadow: var(--shadow);
  aspect-ratio: 4 / 3; max-height: 620px; width: 100%;
}
canvas.main { display: block; width: 100%; height: 100%; touch-action: none; cursor: crosshair; }
canvas.main.dragging { cursor: grabbing; }
canvas.main:focus { outline: none; }
canvas.main:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
canvas.main.ptr-focus:focus-visible { outline: none; }

/* progressive-render bar, pinned to the top edge of the frame */
.prog {
  position: absolute; left: 0; right: 0; top: 0; height: 2px;
  background: transparent; pointer-events: none;
  opacity: 1; transition: opacity .35s ease .1s;
}
.prog.done { opacity: 0; }
.prog i { display: block; height: 100%; width: 0%; background: var(--accent); transition: width .08s linear; }

.chip {
  position: absolute; pointer-events: none;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .01em;
  color: var(--muted); background: var(--scrim);
  border: 1px solid var(--line); border-radius: 999px; padding: 5px 10px;
  max-width: calc(100% - 20px);
}
.hint { left: 10px; bottom: 10px; transition: opacity .45s ease; }
@media (max-width: 520px) { .hint .wide { display: none; } }
.hint.gone { opacity: 0; }
.hint[hidden] { display: none; }
.limit { right: 10px; bottom: 10px; color: var(--accent); border-color: var(--accent); }
.limit[hidden] { display: none; }

/* panel */
.panel { display: flex; flex-direction: column; gap: 12px; }
.card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 12px 13px; box-shadow: var(--shadow);
}
.card h2 {
  margin: 0 0 10px; font-family: var(--font-mono); font-size: 10.5px;
  letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 600;
}
.card h3.sub {
  margin: 15px 0 8px; padding-top: 12px; border-top: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--muted); font-weight: 600;
}

.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; }
.tbsep { width: 1px; align-self: stretch; background: var(--line); margin: 0 2px; }
@media (max-width: 480px) { .tbsep { display: none; } }

/* stats */
.stats { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 11px 10px; }
.stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stat .v {
  font-family: var(--font-mono); font-size: 19px; line-height: 1.05;
  font-variant-numeric: tabular-nums; letter-spacing: -.02em;
  white-space: nowrap; overflow: hidden; text-overflow: clip;
}
.stat .v .u { font-size: 11px; color: var(--muted); margin-left: 2px; letter-spacing: 0; }
.stat .k {
  font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em;
  text-transform: uppercase; color: var(--muted);
}

/* coordinates */
.coords { margin-top: 13px; padding-top: 12px; border-top: 1px solid var(--line); display: grid; gap: 5px; }
.crow { display: flex; align-items: baseline; gap: 9px; min-width: 0; }
.crow .ck {
  font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em;
  text-transform: uppercase; color: var(--muted); width: 4.2em; flex: none;
}
.crow .cv {
  font-family: var(--font-mono); font-size: 12px; font-variant-numeric: tabular-nums;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0;
}
.crow.dim .cv { color: var(--muted); }

/* locator map */
.map {
  display: block; width: 100%; aspect-ratio: 4 / 3; margin-top: 13px;
  border: 1px solid var(--line); border-radius: 6px; background: var(--bg);
}
.cap { margin: 8px 0 0; font-size: 11.5px; line-height: 1.4; color: var(--muted); }
.cap b { color: var(--ink); font-weight: 600; }
.cap .box {
  display: inline-block; width: 9px; height: 8px; margin: 0 1px;
  border: 1.5px solid var(--accent); border-radius: 2px; vertical-align: baseline;
}

/* pill */
.pill {
  display: inline-flex; align-items: center; gap: 6px;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;
  padding: 3px 9px 3px 7px; border-radius: 999px;
  border: 1px solid var(--line); color: var(--muted); background: var(--bg);
  white-space: nowrap;
}
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex: none; }
.pill.on { color: var(--run); background: var(--run-soft); border-color: transparent; }
.pill.on .dot { background: var(--run); }

/* controls */
.ctl { display: block; }
.ctl-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 3px; }
.ctl-top label { font-size: 13px; }
.ctl-top .val { font-family: var(--font-mono); font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }

input[type=range] {
  -webkit-appearance: none; appearance: none; display: block;
  width: 100%; height: 20px; margin: 0; background: transparent; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none; width: 15px; height: 15px; margin-top: -5.5px;
  border-radius: 50%; background: var(--accent); border: 2px solid var(--surface);
  box-shadow: 0 0 0 1px var(--line);
}
input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-moz-range-thumb {
  width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
  border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }

button {
  font: inherit; font-size: 13px; font-family: var(--font-sans);
  color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-radius: 8px;
  padding: 7px 11px; cursor: pointer; line-height: 1.2;
  transition: border-color .12s ease, color .12s ease, background .12s ease;
}
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .42; cursor: not-allowed; }
button.primary { background: var(--accent); color: var(--surface); border-color: var(--accent); font-weight: 600; }
button.primary:hover:not(:disabled) { color: var(--surface); filter: brightness(1.07); }

.places { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; }
.places button { padding: 7px 6px; }
.places button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }

/* switch */
.switch { display: flex; align-items: center; gap: 9px; cursor: pointer; font-size: 13px; margin-top: 11px; }
.switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.track {
  width: 34px; height: 19px; border-radius: 999px; background: var(--line);
  border: 1px solid var(--line); position: relative; flex: none; transition: background .15s ease;
}
.track::after {
  content: ""; position: absolute; top: 1.5px; left: 2px; width: 14px; height: 14px;
  border-radius: 50%; background: var(--surface); transition: transform .15s ease;
  box-shadow: 0 1px 2px rgba(0,0,0,.25);
}
.switch input:checked + .track { background: var(--accent); border-color: var(--accent); }
.switch input:checked + .track::after { transform: translateX(15px); }
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }

.keys { margin: 10px 0 0; font-family: var(--font-mono); font-size: 10.5px; line-height: 1.7; color: var(--muted); }
.keys kbd {
  font-family: inherit; font-size: 10.5px; color: var(--ink);
  border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px;
  padding: 0 4px; background: var(--bg);
}

footer {
  font-family: var(--font-mono); font-size: 11px; color: var(--muted);
  letter-spacing: .05em; padding-top: 2px;
}

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <h1>Fractal Explorer</h1>
    <p class="lede">
      One rule, run on every pixel: start at zero, square it, add the pixel's own position,
      repeat. Points that never run away are the black shape — everything else is coloured by
      <b>how fast it left</b>. <b>Click to zoom in</b>, shift-click to back out, drag to slide
      around. The edge never smooths out, no matter how far you go.
    </p>
  </header>

  <main class="stage">

    <div class="left">
      <div class="field" id="field">
        <canvas class="main" id="c" tabindex="0" role="img"
                aria-describedby="canvasHelp"
                aria-label="Fractal view. Full view, zoom 1 times.">
          A drawing of the Mandelbrot set. Your browser does not support canvas.
        </canvas>
        <div class="prog" id="prog" aria-hidden="true"><i id="progBar"></i></div>
        <div class="chip hint" id="hint">click to zoom in<span class="wide"> · shift-click to zoom out</span> · drag to pan</div>
        <div class="chip limit" id="limitChip" hidden>as deep as 64-bit numbers go</div>
      </div>
      <p id="canvasHelp" class="sr-only">
        Interactive fractal view. Click to zoom in on a point, shift-click to zoom out.
        Drag to pan. With the view focused, use the arrow keys to pan, plus and minus to zoom,
        and zero to return to the full view.
      </p>

      <div class="card toolbar" role="group" aria-label="View controls">
        <button type="button" class="primary" id="zinBtn">Zoom in</button>
        <button type="button" id="zoutBtn">Zoom out</button>
        <span class="tbsep" aria-hidden="true"></span>
        <button type="button" id="resetBtn">Reset view</button>
        <span class="pill" id="pill"><span class="dot"></span><span id="pillText">rendering</span></span>
      </div>
    </div>

    <div class="panel">

      <section class="card" aria-label="View readout">
        <h2>Readout</h2>
        <div class="stats">
          <div class="stat"><span class="v" id="sZoom">×1.00</span><span class="k">zoom</span></div>
          <div class="stat"><span class="v" id="sIter">200</span><span class="k">iterations</span></div>
          <div class="stat"><span class="v" id="sSpan">3.40e+0</span><span class="k">width on the plane</span></div>
          <div class="stat"><span class="v" id="sTime">—<span class="u">ms</span></span><span class="k">last render</span></div>
        </div>

        <div class="coords">
          <div class="crow"><span class="ck">re</span><span class="cv" id="cRe">-0.60000</span></div>
          <div class="crow"><span class="ck">im</span><span class="cv" id="cIm">+0.00000</span></div>
          <div class="crow dim"><span class="ck">cursor</span><span class="cv" id="cCur">—</span></div>
        </div>

        <canvas class="map" id="map" aria-hidden="true"></canvas>
        <p class="cap">The <span class="box" role="img" aria-label="amber rectangle"></span> marks where you are on the whole shape. Below about <b>1e-13</b> wide, ordinary 64-bit numbers run out of digits and the picture turns blocky — that is the floor.</p>
      </section>

      <section class="card" aria-label="Detail controls">
        <h2>Detail</h2>

        <div class="ctl">
          <div class="ctl-top">
            <label for="iter">Iteration depth</label><span class="val" id="iterV" aria-hidden="true">200</span>
          </div>
          <input type="range" id="iter" min="50" max="2000" step="5" value="200">
        </div>

        <label class="switch" for="autoIter">
          <input type="checkbox" id="autoIter" checked>
          <span class="track" aria-hidden="true"></span>
          <span>Raise depth as I zoom</span>
        </label>
        <p class="cap">Deeper zooms need more iterations before a point can be called “never leaves”. Too few and fine threads fill in solid black.</p>

        <h3 class="sub">Places</h3>
        <div class="places" id="places" role="group" aria-label="Preset places">
          <button type="button" data-p="0" aria-pressed="true">Full view</button>
          <button type="button" data-p="1" aria-pressed="false">Seahorses</button>
          <button type="button" data-p="2" aria-pressed="false">Elephants</button>
          <button type="button" data-p="3" aria-pressed="false">Triple spiral</button>
          <button type="button" data-p="4" aria-pressed="false">Tiny twin</button>
          <button type="button" data-p="5" aria-pressed="false">Frost</button>
        </div>

        <p class="keys">
          <kbd>←↑↓→</kbd> pan &nbsp; <kbd>+</kbd> <kbd>−</kbd> zoom &nbsp; <kbd>0</kbd> full view
        </p>
      </section>

    </div>
  </main>

  <footer>demo · sloprun.dev</footer>
</div>

<div class="sr-only" role="status" aria-live="polite" id="live"></div>

<script>
(function () {
  "use strict";

  /* ── dom ─────────────────────────────────────────────────────────────────── */
  var root    = document.documentElement;
  var field   = document.getElementById("field");
  var cv      = document.getElementById("c");
  var ctx     = cv.getContext("2d", { alpha: false, willReadFrequently: true });
  var mapCv   = document.getElementById("map");
  var mapCtx  = mapCv.getContext("2d", { alpha: false });
  var progEl  = document.getElementById("prog");
  var progBar = document.getElementById("progBar");
  var hintEl  = document.getElementById("hint");
  var limitEl = document.getElementById("limitChip");
  var pill    = document.getElementById("pill");
  var pillTxt = document.getElementById("pillText");
  var sZoom   = document.getElementById("sZoom");
  var sIter   = document.getElementById("sIter");
  var sSpan   = document.getElementById("sSpan");
  var sTime   = document.getElementById("sTime");
  var cRe     = document.getElementById("cRe");
  var cIm     = document.getElementById("cIm");
  var cCur    = document.getElementById("cCur");
  var iterEl  = document.getElementById("iter");
  var iterV   = document.getElementById("iterV");
  var autoEl  = document.getElementById("autoIter");
  var places  = document.getElementById("places");
  var live    = document.getElementById("live");

  /* ── constants ───────────────────────────────────────────────────────────── */
  var HOME     = { cx: -0.6, cy: 0, span: 3.4 };
  var REF_SPAN = 3.4;          /* zoom ×1 reference */
  var MIN_SPAN = 1e-13;        /* double-precision floor */
  var MAX_SPAN = 8;
  var BAIL     = 65536;        /* |z|^2 bailout, big for smooth colour */
  var LN2      = Math.LN2;
  var LUTN     = 1024;
  var BANDS    = 8;            /* colour cycles across the whole depth budget */
  var BUDGET   = 13;           /* ms of compute per animation frame */

  var PLACES = [
    { n: "Full view",     cx: -0.6,       cy:  0,          span: 3.4    },
    { n: "Seahorses",     cx: -0.7453,    cy:  0.1127,     span: 0.0065 },
    { n: "Elephants",     cx:  0.274,     cy:  0.0069,     span: 0.009  },
    { n: "Triple spiral", cx: -0.088,     cy:  0.654,      span: 0.006  },
    { n: "Tiny twin",     cx: -1.7597,    cy:  0,          span: 0.08   },
    { n: "Frost",         cx: -0.235125,  cy:  0.827215,   span: 0.004  }
  ];

  /* ── state ───────────────────────────────────────────────────────────────── */
  var view    = { cx: HOME.cx, cy: HOME.cy, span: HOME.span };
  var maxIter = 200;
  var autoOn  = true;
  var W = 0, H = 0, dpr = 1;
  var snap    = document.createElement("canvas");
  var sctx    = snap.getContext("2d", { alpha: false });

  /* ── colour ──────────────────────────────────────────────────────────────── */
  var LR = new Uint8Array(LUTN), LG = new Uint8Array(LUTN), LB = new Uint8Array(LUTN);
  var IN_R = 12, IN_G = 13, IN_B = 15;      /* the set itself */
  var BLANK = "#ffffff";                     /* not-yet-drawn ground */
  var MAP_SET = [17,17,17], MAP_BG = [227,226,220], MAP_BOX = [192,86,0];

  function hexToRgb(h) {
    h = String(h).trim();
    if (h.charAt(0) === "#") h = h.slice(1);
    if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
    var n = parseInt(h, 16);
    if (h.length !== 6 || isNaN(n)) return null;
    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  }
  function tok(name, fallback) {
    var v = getComputedStyle(root).getPropertyValue(name);
    return hexToRgb(v) || fallback;
  }
  function mix(a, b, t) {
    return [a[0]+(b[0]-a[0])*t, a[1]+(b[1]-a[1])*t, a[2]+(b[2]-a[2])*t];
  }
  function css(c) { return "rgb(" + (c[0]|0) + "," + (c[1]|0) + "," + (c[2]|0) + ")"; }
  function lum(c) { return (0.2126*c[0] + 0.7152*c[1] + 0.0722*c[2]) / 255; }

  function buildPalette() {
    var bg  = tok("--bg",      [246,246,243]);
    var acc = tok("--accent",  [192,86,0]);
    var ink = tok("--ink",     [26,29,33]);
    var mut = tok("--muted",   [90,96,104]);
    var sur = tok("--surface", [255,255,255]);
    var lin = tok("--line",    [227,226,220]);
    var dark = lum(bg) < 0.45;

    /* one cyclic ramp, read straight off the tokens, so it re-tunes per theme:
       light → pale grey, amber, ink, slate;  dark → slate, amber, cream, grey */
    var stops = [lin, acc, ink, mut];
    var ns = stops.length;
    for (var i = 0; i < LUTN; i++) {
      var f = i / LUTN * ns;
      var a = Math.floor(f), t = f - a;
      var e = t * t * (3 - 2 * t);            /* smoothstep: soft bands, no hard edges */
      var c = mix(stops[a % ns], stops[(a + 1) % ns], e);
      LR[i] = c[0] | 0; LG[i] = c[1] | 0; LB[i] = c[2] | 0;
    }
    var inner = dark ? mix(bg, [0,0,0], 0.62) : mix(ink, [0,0,0], 0.4);
    IN_R = inner[0] | 0; IN_G = inner[1] | 0; IN_B = inner[2] | 0;

    BLANK   = css(mix(sur, bg, 0.5));
    MAP_SET = inner;
    /* on dark the line token is nearly as dark as the set — lift the map ground
       until both the silhouette and the amber locator box clear 3:1 against it */
    MAP_BG  = dark ? mix(lin, ink, 0.30) : lin;
    MAP_BOX = acc;
    mapBase = null;
  }

  /* ── the kernel ──────────────────────────────────────────────────────────── */
  function mandel(cr, ci) {
    /* the two big interior lobes have closed forms — skip the loop entirely */
    var q = cr - 0.25, y2 = ci * ci;
    q = q * q + y2;
    if (q * (q + (cr - 0.25)) <= 0.25 * y2) return -1;
    var xp = cr + 1;
    if (xp * xp + y2 <= 0.0625) return -1;

    var zx = 0, zy = 0, zx2 = 0, zy2 = 0, i = 0, m = maxIter;
    while (i < m && zx2 + zy2 <= BAIL) {
      zy = 2 * zx * zy + ci;
      zx = zx2 - zy2 + cr;
      zx2 = zx * zx; zy2 = zy * zy;
      i++;
    }
    if (i >= m) return -1;

    /* smooth (fractional) iteration count → continuous colour */
    var lg = Math.log(zx2 + zy2) * 0.5;
    var sm = i + 1 - Math.log(lg / LN2) / LN2;
    if (!(sm > 0)) sm = 0;
    var t = sm * invL;
    t = t - Math.floor(t);
    var idx = (t * LUTN) | 0;
    return idx >= LUTN ? LUTN - 1 : idx;
  }

  /* ── progressive renderer ────────────────────────────────────────────────── */
  var img = null, data = null, passes = [], pinfo = [], pi = 0, bk = 0;
  var rafId = null, workTotal = 1, workDone = 0, tCompute = 0, running = false;
  var xmin = 0, ymax = 0, pxs = 1, invL = 1 / 26;

  function stopRender() {
    if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; }
    running = false;
  }

  function startRender() {
    stopRender();
    if (!W || !H) return;

    invL = 1 / Math.max(18, maxIter / BANDS);   /* band width follows the depth */
    xmin = view.cx - view.span / 2;
    pxs  = view.span / W;
    ymax = view.cy + (H * pxs) / 2;

    /* seed the buffer with whatever is on screen, so untouched areas keep the
       zoom/pan preview instead of flashing empty */
    img = ctx.getImageData(0, 0, W, H);
    data = img.data;

    var first = 16;
    while (first > 1 && (W / first < 6 || H / first < 6)) first >>= 1;
    passes = []; pinfo = [];
    for (var s = first; s >= 1; s >>= 1) passes.push(s);
    workTotal = 0;
    for (var p = 0; p < passes.length; p++) {
      var sp = passes[p];
      var nx = Math.ceil(W / sp), ny = Math.ceil(H / sp);
      var n = nx * ny;
      var skip = p === 0 ? 0 : Math.ceil(nx / 2) * Math.ceil(ny / 2);
      pinfo.push({ s: sp, nx: nx, n: n });
      workTotal += n - skip;
    }
    if (workTotal < 1) workTotal = 1;

    pi = 0; bk = 0; workDone = 0; tCompute = 0; running = true;
    progEl.classList.remove("done");
    progBar.style.width = "0%";
    setPill(false, "rendering 0%");
    rafId = requestAnimationFrame(step);
  }

  function fillBlock(bx, by, s, idx) {
    var r, g, b;
    if (idx < 0) { r = IN_R; g = IN_G; b = IN_B; }
    else { r = LR[idx]; g = LG[idx]; b = LB[idx]; }
    var x1 = bx + s; if (x1 > W) x1 = W;
    var y1 = by + s; if (y1 > H) y1 = H;
    for (var y = by; y < y1; y++) {
      var o = (y * W + bx) * 4;
      for (var x = bx; x < x1; x++) {
        data[o] = r; data[o+1] = g; data[o+2] = b; data[o+3] = 255;
        o += 4;
      }
    }
  }

  function step() {
    rafId = null;
    var t0 = performance.now(), dirty = false, guard = 0;

    while (pi < passes.length) {
      var P = pinfo[pi], s = P.s, nx = P.nx, half = s * 2, n = P.n;
      var k = bk, out = false;
      while (k < n) {
        var bx = (k % nx) * s, by = ((k / nx) | 0) * s;
        k++;
        if (pi > 0 && (bx % half) === 0 && (by % half) === 0) continue;  /* done last pass */
        fillBlock(bx, by, s, mandel(xmin + (bx + 0.5) * pxs, ymax - (by + 0.5) * pxs));
        workDone++; dirty = true;
        if ((++guard & 31) === 0 && performance.now() - t0 >= BUDGET) { out = true; break; }
      }
      bk = k;
      if (out) break;
      pi++; bk = 0;
      if (performance.now() - t0 >= BUDGET) break;
    }

    tCompute += performance.now() - t0;
    if (dirty) ctx.putImageData(img, 0, 0);

    var pct = Math.min(100, Math.round(workDone / workTotal * 100));
    progBar.style.width = pct + "%";

    if (pi < passes.length) {
      setPill(false, "rendering " + pct + "%");
      rafId = requestAnimationFrame(step);
    } else {
      running = false;
      progBar.style.width = "100%";
      progEl.classList.add("done");
      var ms = Math.round(tCompute);
      sTime.innerHTML = ms + '<span class="u">ms</span>';
      setPill(true, "done · " + ms + " ms");
      sctx.drawImage(cv, 0, 0);
      cv.setAttribute("aria-label", describe());
    }
  }

  function setPill(ok, text) {
    pillTxt.textContent = text;
    if (ok) pill.classList.add("on"); else pill.classList.remove("on");
  }

  /* ── previews: reuse the current pixels while the new view computes ──────── */
  function preview(old) {
    if (!W || !H) return;
    sctx.drawImage(cv, 0, 0);                       /* freeze what is on screen */
    var ns = view.span / W;
    var k  = (old.span / W) / ns;
    var tx = (old.cx - old.span / 2 - (view.cx - view.span / 2)) / ns;
    var oldYmax = old.cy + (H * (old.span / W)) / 2;
    var ty = (view.cy + (H * ns) / 2 - oldYmax) / ns;
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.fillStyle = BLANK;
    ctx.fillRect(0, 0, W, H);
    ctx.setTransform(k, 0, 0, k, tx, ty);
    ctx.drawImage(snap, 0, 0);
    ctx.setTransform(1, 0, 0, 1, 0, 0);
  }

  /* ── view changes ────────────────────────────────────────────────────────── */
  function clampSpan(s) { return Math.max(MIN_SPAN, Math.min(MAX_SPAN, s)); }

  function autoIterFor(span) {
    var z = REF_SPAN / span;
    var v = Math.round((120 + 85 * Math.log(z + 1) / LN2) / 5) * 5;
    return Math.max(120, Math.min(2000, v));
  }

  function applyAuto() {
    if (!autoOn) return;
    var v = autoIterFor(view.span);
    if (v !== maxIter) { maxIter = v; iterEl.value = String(v); }
    iterV.textContent = String(maxIter);
    sIter.textContent = String(maxIter);
  }

  function setView(cx, cy, span, opts) {
    var old = { cx: view.cx, cy: view.cy, span: view.span };
    view.cx = cx; view.cy = cy; view.span = clampSpan(span);
    preview(old);
    applyAuto();
    updateReadout();
    drawMap();
    startRender();
    if (!opts || opts.announce !== false) announce();
    markPlace();
    dismissHint();
  }

  function zoomAt(cx, cy, factor) {
    var span = clampSpan(view.span / factor);
    if (span === view.span && Math.abs(cx - view.cx) < span * 1e-12 &&
        Math.abs(cy - view.cy) < span * 1e-12) {
      /* nothing would change (already at the floor or the ceiling) — but a
         pointerdown may have paused a half-finished render, so resume that;
         don't restart a render that already completed */
      if (!running && pi < passes.length) startRender();
      return;
    }
    setView(cx, cy, span);
  }

  function reset() {
    autoOn = true; autoEl.checked = true;
    setView(HOME.cx, HOME.cy, HOME.span);
    live.textContent = "Back to the full view.";
  }

  /* ── readout ─────────────────────────────────────────────────────────────── */
  function digits() {
    var d = Math.ceil(-Math.log(view.span) / Math.LN10) + 5;
    return Math.max(5, Math.min(15, d));
  }
  function fmtNum(v, d) {
    var s = Math.abs(v).toFixed(d);
    return (v < 0 ? "-" : "+") + s;
  }
  function fmtZoom(z) {
    if (z < 9.995) return "×" + z.toFixed(2);
    if (z < 1000) return "×" + Math.round(z);
    var u = ["K", "M", "B", "T"], i = -1;
    while (z >= 1000 && i < 3) { z /= 1000; i++; }
    return "×" + (z < 10 ? z.toFixed(1) : Math.round(z)) + u[i];
  }

  function updateReadout() {
    var d = digits();
    sZoom.textContent = fmtZoom(REF_SPAN / view.span);
    sSpan.textContent = view.span.toExponential(2);
    sIter.textContent = String(maxIter);
    cRe.textContent = fmtNum(view.cx, d);
    cIm.textContent = fmtNum(view.cy, d);
    var atFloor = view.span <= MIN_SPAN * 1.0001;
    limitEl.hidden = !atFloor;
    document.getElementById("zinBtn").disabled = atFloor;
    document.getElementById("zoutBtn").disabled = view.span >= MAX_SPAN * 0.9999;
  }

  function describe() {
    var d = Math.min(digits(), 9);
    return "Fractal view centred at " + fmtNum(view.cx, d) + " " + fmtNum(view.cy, d) +
           "i, zoom " + fmtZoom(REF_SPAN / view.span).replace("×", "") +
           " times, " + maxIter + " iterations.";
  }
  var announceT = null;
  function announce() {
    if (announceT) clearTimeout(announceT);
    announceT = setTimeout(function () { live.textContent = describe(); }, 350);
  }

  function markPlace() {
    var btns = places.querySelectorAll("button");
    for (var i = 0; i < btns.length; i++) {
      var p = PLACES[+btns[i].getAttribute("data-p")];
      var on = Math.abs(view.cx - p.cx) < p.span * 1e-3 &&
               Math.abs(view.cy - p.cy) < p.span * 1e-3 &&
               Math.abs(view.span / p.span - 1) < 1e-3;
      btns[i].setAttribute("aria-pressed", on ? "true" : "false");
    }
  }

  /* ── locator map ─────────────────────────────────────────────────────────── */
  var MX0 = -2.66, MX1 = 1.14, MY0 = -1.425, MY1 = 1.425;
  var mapBase = null, mw = 0, mh = 0;

  function sizeMap() {
    var r = mapCv.getBoundingClientRect();
    var d = Math.min(window.devicePixelRatio || 1, 2);
    var w = Math.max(80, Math.round(r.width * d));
    var h = Math.max(60, Math.round(r.height * d));
    if (w !== mw || h !== mh) { mw = w; mh = h; mapCv.width = mw; mapCv.height = mh; mapBase = null; }
  }

  function buildMap() {
    var im = mapCtx.createImageData(mw, mh), dd = im.data;
    var set = MAP_SET, bgc = MAP_BG;
    var sx = (MX1 - MX0) / mw, sy = (MY1 - MY0) / mh, o = 0;
    for (var j = 0; j < mh; j++) {
      var ci = MY1 - (j + 0.5) * sy;
      for (var i = 0; i < mw; i++) {
        var cr = MX0 + (i + 0.5) * sx;
        var q = cr - 0.25, y2 = ci * ci; q = q * q + y2;
        var inside;
        if (q * (q + (cr - 0.25)) <= 0.25 * y2) inside = true;
        else if ((cr + 1) * (cr + 1) + y2 <= 0.0625) inside = true;
        else {
          var zx = 0, zy = 0, zx2 = 0, zy2 = 0, n = 0;
          while (n < 60 && zx2 + zy2 <= 4) {
            zy = 2 * zx * zy + ci; zx = zx2 - zy2 + cr;
            zx2 = zx * zx; zy2 = zy * zy; n++;
          }
          inside = n >= 60;
        }
        var c = inside ? set : bgc;
        dd[o] = c[0]; dd[o+1] = c[1]; dd[o+2] = c[2]; dd[o+3] = 255;
        o += 4;
      }
    }
    mapBase = im;
  }

  function drawMap() {
    sizeMap();
    if (!mw) return;
    if (!mapBase) buildMap();
    mapCtx.putImageData(mapBase, 0, 0);

    var spanY = view.span * H / (W || 1);
    var x = (view.cx - view.span / 2 - MX0) / (MX1 - MX0) * mw;
    var y = (MY1 - (view.cy + spanY / 2)) / (MY1 - MY0) * mh;
    var w = view.span / (MX1 - MX0) * mw;
    var h = spanY / (MY1 - MY0) * mh;

    mapCtx.strokeStyle = css(MAP_BOX);
    mapCtx.lineWidth = Math.max(1.5, mw / 220);
    if (w < 7 || h < 7) {
      var px = x + w / 2, py = y + h / 2, r = Math.max(4, mw / 40);
      mapCtx.beginPath(); mapCtx.arc(px, py, r, 0, Math.PI * 2); mapCtx.stroke();
      mapCtx.beginPath();
      mapCtx.moveTo(px - r * 2.1, py); mapCtx.lineTo(px - r * 1.2, py);
      mapCtx.moveTo(px + r * 1.2, py); mapCtx.lineTo(px + r * 2.1, py);
      mapCtx.moveTo(px, py - r * 2.1); mapCtx.lineTo(px, py - r * 1.2);
      mapCtx.moveTo(px, py + r * 1.2); mapCtx.lineTo(px, py + r * 2.1);
      mapCtx.stroke();
    } else {
      mapCtx.strokeRect(x, y, w, h);
    }
  }

  /* ── layout ──────────────────────────────────────────────────────────────── */
  /* returns true when the drawing buffer actually changed (and was cleared) */
  function layout(force) {
    var r = field.getBoundingClientRect();
    var cw = Math.max(160, Math.round(r.width));
    var ch = Math.max(120, Math.round(r.height));
    var d = Math.min(window.devicePixelRatio || 1, 2);
    var cap = Math.sqrt(1200000 / (cw * ch));
    if (d > cap) d = Math.max(1, cap);
    var w2 = Math.round(cw * d), h2 = Math.round(ch * d);
    if (!force && w2 === W && h2 === H) return false;
    dpr = d; W = w2; H = h2;
    cv.width = W; cv.height = H;
    snap.width = W; snap.height = H;
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.fillStyle = BLANK;
    ctx.fillRect(0, 0, W, H);
    ctx.imageSmoothingEnabled = true;
    return true;
  }

  /* ── pointer: drag to pan, click to zoom ─────────────────────────────────── */
  var drag = null;

  function localPos(e) {
    var r = cv.getBoundingClientRect();
    return { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height };
  }
  function toComplex(fx, fy) {
    var spanY = view.span * H / W;
    return { re: view.cx + (fx - 0.5) * view.span, im: view.cy - (fy - 0.5) * spanY };
  }

  cv.addEventListener("pointerdown", function (e) {
    if (e.button !== undefined && e.button !== 0) return;
    cv.classList.add("ptr-focus");
    try { cv.focus({ preventScroll: true }); } catch (err) { cv.focus(); }
    stopRender();
    sctx.drawImage(cv, 0, 0);
    var p = localPos(e);
    drag = { id: e.pointerId, sx: p.x, sy: p.y, x: p.x, y: p.y, moved: false, shift: e.shiftKey };
    try { cv.setPointerCapture(e.pointerId); } catch (err) {}
    e.preventDefault();
  });

  cv.addEventListener("pointermove", function (e) {
    if (drag && e.pointerId !== drag.id) return;      /* ignore a second finger */
    var p = localPos(e);
    if (!drag) {
      var c = toComplex(p.x, p.y);
      var d = Math.min(digits(), 8);
      cCur.textContent = fmtNum(c.re, d) + "  " + fmtNum(c.im, d) + "i";
      return;
    }
    drag.x = p.x; drag.y = p.y;
    var dx = (p.x - drag.sx), dy = (p.y - drag.sy);
    if (!drag.moved && (Math.abs(dx) * W > 3 || Math.abs(dy) * H > 3)) {
      drag.moved = true;
      cv.classList.add("dragging");
      dismissHint();
    }
    if (drag.moved) {
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.fillStyle = BLANK;
      ctx.fillRect(0, 0, W, H);
      ctx.drawImage(snap, dx * W, dy * H);
    }
  });

  function endDrag(e) {
    if (!drag) return;
    if (e && e.pointerId !== undefined && e.pointerId !== drag.id) return;
    var d = drag; drag = null;
    cv.classList.remove("dragging");
    try { cv.releasePointerCapture(d.id); } catch (err) {}
    if (d.moved) {
      var spanY = view.span * H / W;
      /* put the un-shifted frame back first: preview() takes its own snapshot of
         the canvas and applies the pan itself, so leaving the dragged frame in
         place would offset the preview twice */
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.drawImage(snap, 0, 0);
      setView(view.cx - (d.x - d.sx) * view.span, view.cy + (d.y - d.sy) * spanY, view.span);
    } else {
      var c = toComplex(d.x, d.y);
      var out = d.shift || (e && e.shiftKey);
      zoomAt(c.re, c.im, out ? 0.5 : 2);
    }
  }
  cv.addEventListener("pointerup", endDrag);
  cv.addEventListener("pointercancel", function (e) {
    if (!drag) return;
    if (e && e.pointerId !== undefined && e.pointerId !== drag.id) return;
    drag = null;
    cv.classList.remove("dragging");
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.drawImage(snap, 0, 0);                       /* undo the live drag offset */
    startRender();
  });
  cv.addEventListener("pointerleave", function () { if (!drag) cCur.textContent = "—"; });

  /* ── keyboard ────────────────────────────────────────────────────────────── */
  cv.addEventListener("keydown", function (e) {
    var k = e.key, spanY = view.span * H / W, s = 0.15, handled = true;
    cv.classList.remove("ptr-focus");
    if (k === "ArrowLeft")       setView(view.cx - view.span * s, view.cy, view.span);
    else if (k === "ArrowRight") setView(view.cx + view.span * s, view.cy, view.span);
    else if (k === "ArrowUp")    setView(view.cx, view.cy + spanY * s, view.span);
    else if (k === "ArrowDown")  setView(view.cx, view.cy - spanY * s, view.span);
    else if (k === "+" || k === "=" || k === "Enter") zoomAt(view.cx, view.cy, 2);
    else if (k === "-" || k === "_")                  zoomAt(view.cx, view.cy, 0.5);
    else if (k === "0" || k === "Home")               reset();
    else handled = false;
    if (handled) e.preventDefault();
  });
  cv.addEventListener("blur", function () { cv.classList.remove("ptr-focus"); });

  function dismissHint() {
    if (hintEl.hidden || hintEl.classList.contains("gone")) return;
    hintEl.classList.add("gone");
    setTimeout(function () { hintEl.hidden = true; }, 600);
  }

  /* ── controls ────────────────────────────────────────────────────────────── */
  document.getElementById("zinBtn").addEventListener("click", function () {
    zoomAt(view.cx, view.cy, 2);
  });
  document.getElementById("zoutBtn").addEventListener("click", function () {
    zoomAt(view.cx, view.cy, 0.5);
  });
  document.getElementById("resetBtn").addEventListener("click", reset);

  iterEl.addEventListener("input", function () {
    maxIter = +iterEl.value;
    iterV.textContent = String(maxIter);
    sIter.textContent = String(maxIter);
    if (autoOn) { autoOn = false; autoEl.checked = false; }
    startRender();
  });
  iterEl.addEventListener("change", function () {
    live.textContent = maxIter + " iterations.";
  });

  autoEl.addEventListener("change", function () {
    autoOn = autoEl.checked;
    if (autoOn) { applyAuto(); startRender(); live.textContent = "Depth follows the zoom."; }
    else live.textContent = "Depth held at " + maxIter + ".";
  });

  places.addEventListener("click", function (e) {
    var b = e.target.closest ? e.target.closest("button[data-p]") : null;
    if (!b) return;
    var p = PLACES[+b.getAttribute("data-p")];
    autoOn = true; autoEl.checked = true;
    setView(p.cx, p.cy, p.span, { announce: false });
    live.textContent = p.n + " — zoom " + fmtZoom(REF_SPAN / view.span).replace("×", "") + " times.";
  });

  /* ── theme ───────────────────────────────────────────────────────────────── */
  function onThemeChange() {
    buildPalette();
    drawMap();
    startRender();
  }
  window.addEventListener("message", function (e) {
    var d = e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      root.setAttribute("data-theme", d.theme);
      onThemeChange();
    }
  });
  var mq = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null;
  if (mq) {
    if (mq.addEventListener) mq.addEventListener("change", onThemeChange);
    else if (mq.addListener) mq.addListener(onThemeChange);
  }

  /* ── resize ──────────────────────────────────────────────────────────────── */
  var rT = null;
  function onResize() {
    if (rT) clearTimeout(rT);
    rT = setTimeout(function () {
      rT = null;
      if (layout()) { drawMap(); startRender(); }
      else { sizeMap(); drawMap(); }
    }, 160);
  }
  if (window.ResizeObserver) new ResizeObserver(onResize).observe(field);
  window.addEventListener("resize", onResize);

  /* ── go ──────────────────────────────────────────────────────────────────── */
  buildPalette();
  layout(true);
  applyAuto();
  updateReadout();
  drawMap();
  markPlace();
  startRender();
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong