About this technique →
julia/sketch.js
// Julia Set
//
// Iterate z = z² + c where c is a fixed complex constant and z starts at the
// pixel's complex coordinate. Same escape-time logic as Mandelbrot but a very
// different shape for each choice of c.

export const PARAMS = {
  cReal:      { value: -0.7,     min: -2,    max: 2,    step: 0.001, label: "c (real)",       folder: "Constant c" },
  cImag:      { value: 0.27015,  min: -2,    max: 2,    step: 0.001, label: "c (imaginary)",  folder: "Constant c" },
  preset:     { value: "Custom", options: ["Custom", "Dragon", "Dendrite", "Rabbit", "San Marco"], label: "Preset c", folder: "Constant c" },
  iterations: { value: 200,      min: 50,    max: 1000, step: 10,    label: "Max Iterations", folder: "Performance" },
  step:       { value: 2, options: ["1", "2", "3", "4"], label: "Pixel Step", folder: "Performance", rebuildOnChange: true },
  hue:        { value: 30,       min: 0,     max: 360,  step: 1,     label: "Base Hue",       folder: "Appearance" },
  hueSpread:  { value: 180,      min: 0,     max: 360,  step: 1,     label: "Hue Spread",     folder: "Appearance" },
};

// Preset c values
const PRESETS = {
  "Dragon":    [-0.74543, 0.11301],
  "Dendrite":  [0,        1.0],
  "Rabbit":    [-0.123,   0.745],
  "San Marco": [-0.75,    0.0],
};

// HSL to RGB (h: 0-360, s/l: 0-1) → [r,g,b] 0-255
function hslToRgb(h, s, l) {
  const c = (1 - Math.abs(2 * l - 1)) * s;
  const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
  const m = l - c / 2;
  let r = 0, g = 0, b = 0;
  if      (h < 60)  { r = c; g = x; b = 0; }
  else if (h < 120) { r = x; g = c; b = 0; }
  else if (h < 180) { r = 0; g = c; b = x; }
  else if (h < 240) { r = 0; g = x; b = c; }
  else if (h < 300) { r = x; g = 0; b = c; }
  else              { r = c; g = 0; b = x; }
  return [
    Math.round((r + m) * 255),
    Math.round((g + m) * 255),
    Math.round((b + m) * 255),
  ];
}

export function sketchSetup(p, w, h) {
  p.pixelDensity(1);
  return {};
}

export function sketchDraw(p, w, h, params) {
  const step = parseInt(params.step, 10) || 2;
  const maxIter = params.iterations | 0;
  const { hue, hueSpread } = params;

  // Resolve c — preset overrides sliders (unless "Custom")
  let cr, ci;
  if (params.preset !== "Custom" && PRESETS[params.preset]) {
    [cr, ci] = PRESETS[params.preset];
  } else {
    cr = params.cReal;
    ci = params.cImag;
  }

  // Scale: at zoom=1 the full width spans about 3.5 units (same as Mandelbrot)
  const scale = 3.5 / w;

  p.pixelDensity(1);
  p.loadPixels();

  for (let py = 0; py < h; py += step) {
    for (let px = 0; px < w; px += step) {
      // Map pixel to initial z in complex plane
      let zr = (px - w / 2) * scale;
      let zi = (py - h / 2) * scale;

      let n = 0;
      while (n < maxIter) {
        const zr2 = zr * zr;
        const zi2 = zi * zi;
        if (zr2 + zi2 > 4.0) break;
        zi = 2 * zr * zi + ci;
        zr = zr2 - zi2 + cr;
        n++;
      }

      let r, g, b;
      if (n === maxIter) {
        r = 0; g = 0; b = 0;
      } else {
        const log2 = Math.log(2);
        const absZ = Math.sqrt(zr * zr + zi * zi);
        const smooth = n + 1 - Math.log(Math.log(Math.max(1.001, absZ)) / log2) / log2;
        const t = (smooth / maxIter) * hueSpread;
        const h2 = ((hue + t * 3) % 360 + 360) % 360;
        const lit = 0.45 + 0.2 * Math.sin(smooth * 0.3);
        [r, g, b] = hslToRgb(h2, 0.75, Math.min(0.85, Math.max(0.05, lit)));
      }

      for (let dy = 0; dy < step && py + dy < h; dy++) {
        for (let dx = 0; dx < step && px + dx < w; dx++) {
          const idx = ((py + dy) * w + (px + dx)) * 4;
          p.pixels[idx]     = r;
          p.pixels[idx + 1] = g;
          p.pixels[idx + 2] = b;
          p.pixels[idx + 3] = 255;
        }
      }
    }
  }

  p.updatePixels();
}