Julia Set Morph
A Julia set, redrawn every frame while its constant drifts along the edge of the Mandelbrot set, breathing in and out across it. The set shimmers and reshapes as it goes, from fat islands to curling spirals, rabbits and lacy dust, and never quite repeats.
How to use it
- Paste the snippet just before
</head>. - Add
data-julia-set-morphto a box with a height.
Options
Add any of these attributes to the same element.
data-path | edge (along the edge of the Mandelbrot set’s main body) or circle (round a circle). Default edge. |
data-radius | Size of the circle, with data-path="circle". Default 0.7885. |
data-speed | Laps per minute. Default 1.5. |
data-palette | violet, fire or ocean. Default violet. |
data-iterations | Detail, 20 to 400. More is sharper but slower. Default 110. |
data-zoom | Magnification. Default 1, which fits the whole set. |
Each frame is worked out at a modest resolution and smoothed up to size, and only half of it is computed, because a Julia set looks the same turned upside down. It’s paused when off screen, and with reduced motion it shows a still frame.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Julia Set Morph: a Julia set redrawn every frame while its constant c
// drifts. By default c rides just along the edge of the Mandelbrot set's
// main body, breathing in and out across it, so the Julia set shimmers
// and reshapes from fat islands to curling spirals, rabbits and lacy dust.
// Each frame is worked out at a modest resolution and smoothed up to
// size, and only half of it is computed (a Julia set looks the same
// turned upside down). Paused when off screen.
// Usage: <div data-julia-set-morph style="height:360px"></div>
// Optional: data-path ("edge", along the Mandelbrot set's edge, or
// "circle", round a circle of data-radius; default edge), data-radius
// (default 0.7885), data-speed (laps per minute, default 1.5),
// data-palette ("violet", "fire" or "ocean", default violet),
// data-iterations (detail, default 110), data-zoom (default 1; bigger is
// closer).
var DEFAULTS = { path: 'edge', radius: 0.7885, speed: 1.5, palette: 'violet', iterations: 110, zoom: 1, budget: 80000 };
var PALETTES = {
violet: ['#08031a', '#2e1065', '#7c3aed', '#e879f9', '#fdf4ff'],
fire: ['#0a0202', '#7f1d1d', '#ea580c', '#fbbf24', '#fffbeb'],
ocean: ['#020617', '#1e3a8a', '#0891b2', '#5eead4', '#f0fdfa']
};
var css =
'[data-julia-set-morph]{position:relative;isolation:isolate;overflow:hidden;background:#08031a}' +
'.jsm-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function rgb(hex) {
var n = parseInt(hex.replace('#', ''), 16);
return [n >> 16, n >> 8 & 255, n & 255];
}
// 256 colours from the dark outside, through the palette, to the set's edge.
function makePalette(stops) {
var cs = stops.map(rgb), out = new Uint8Array(256 * 3);
for (var i = 0; i < 256; i++) {
var f = i / 255 * (cs.length - 1), a = Math.min(cs.length - 2, Math.floor(f)), k = f - a;
for (var j = 0; j < 3; j++) out[i * 3 + j] = cs[a][j] + (cs[a + 1][j] - cs[a][j]) * k;
}
return out;
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'jsm-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var radius = d.radius ? +d.radius : DEFAULTS.radius;
var circle = (d.path || DEFAULTS.path) === 'circle';
var speed = (d.speed !== undefined ? +d.speed : DEFAULTS.speed) * Math.PI * 2 / 60000;
var maxIter = Math.max(20, Math.min(400, +d.iterations || DEFAULTS.iterations));
var zoom = +d.zoom || DEFAULTS.zoom;
var pal = makePalette(PALETTES[d.palette] || PALETTES[DEFAULTS.palette]);
var inside = rgb((PALETTES[d.palette] || PALETTES[DEFAULTS.palette])[0]);
var buf = document.createElement('canvas'), bctx = buf.getContext('2d');
var w = 0, h = 0, dpr = 1, bw = 0, bh = 0, img = null, t = 0;
function resize() {
var nw = el.clientWidth, nh = el.clientHeight;
if (nw === w && nh === h) return;
w = nw; h = nh;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
var q = Math.min(1, Math.sqrt(DEFAULTS.budget / Math.max(1, w * h)));
bw = Math.max(8, Math.round(w * q));
bh = Math.max(8, Math.round(h * q));
buf.width = bw;
buf.height = bh;
img = bctx.createImageData(bw, bh);
}
function draw() {
if (!img) return;
var a = t * speed + 2.1, cr, ci;
if (circle) {
// c drifts round a circle, wobbling in and out a little.
var r = radius * (1 + Math.sin(t * speed * 2.3) * 0.035);
cr = Math.cos(a) * r;
ci = Math.sin(a) * r;
} else {
// c follows the main cardioid's edge, e^ia / 2 - e^2ia / 4, scaled a
// touch in or out so the set swings between solid and dust.
var k = 1.012 + Math.sin(t * speed * 3.7) * 0.03;
cr = (Math.cos(a) / 2 - Math.cos(2 * a) / 4) * k;
ci = (Math.sin(a) / 2 - Math.sin(2 * a) / 4) * k;
}
// The set is about 3.2 across; fit it to the shorter side.
var sc = 3.2 / zoom / Math.min(bw, bh);
var data = img.data, half = Math.ceil(bh / 2), n = bw * bh;
var logMax = Math.log(maxIter + 1);
for (var y = 0; y < half; y++) {
var y0 = (y - (bh - 1) / 2) * sc;
for (var x = 0; x < bw; x++) {
var zr = (x - (bw - 1) / 2) * sc, zi = y0, zr2 = zr * zr, zi2 = zi * zi, i = 0;
while (i < maxIter && zr2 + zi2 < 64) {
zi = 2 * zr * zi + ci;
zr = zr2 - zi2 + cr;
zr2 = zr * zr;
zi2 = zi * zi;
i++;
}
var R, G, B;
if (i >= maxIter) {
R = inside[0]; G = inside[1]; B = inside[2];
} else {
var mu = i + 1 - Math.log(Math.log(zr2 + zi2) / 2 / Math.LN2) / Math.LN2;
var idx = Math.max(0, Math.min(255, Math.floor(Math.log(1 + Math.max(0, mu)) / logMax * 300))) * 3;
R = pal[idx]; G = pal[idx + 1]; B = pal[idx + 2];
}
// This pixel and its mirror image through the centre.
var o = (y * bw + x) * 4, m = (n - 1 - (y * bw + x)) * 4;
data[o] = data[m] = R;
data[o + 1] = data[m + 1] = G;
data[o + 2] = data[m + 2] = B;
data[o + 3] = data[m + 3] = 255;
}
}
bctx.putImageData(img, 0, 0);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(buf, 0, 0, w, h);
}
resize();
if (reduced()) {
t = 9000;
draw();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
last = now;
t += dt;
draw();
}
});
// Pause while off screen to save battery.
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-julia-set-morph]').forEach(function (el) {
if (el._jsm) return;
el._jsm = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-julia-set-morph style="height: 360px"></div>