Sierpiński curve animation JavaScript

    /**
     * Originally written on ObservableHQ, later translated to Vanilla JS
     * https://observablehq.com/@forresto/sierpinski-curve-animation
     *
     * Inspired by
     * - This 1972 computer animation from Nelson Max's Topology Films Project - https://youtu.be/2e8QJBkCwvo?t=1028
     * - Nadieh Bremer and piratefsh's versions (the curve drawing code is forked from them) - https://observablehq.com/@nbremer/sierpinski-curve
     * - Eddie Woo's lecture on how the curve is constructed by subdividing a square into isosceles right triangles - https://youtu.be/Ps9itT9KcdM
     *
     * Post: https://www.forresto.com/2020s/sierpinski.html
     * Copyright ©️ 2020 Forrest O. Interactive
     */

    // Constants
    const MIN = 0;
    const MAX = 11;
    const DELTA = MAX - MIN;
    const SIZE = 720;
    const PADDING = 8;
    const SECONDS_PER = 3;
    const TIME_PER = SECONDS_PER * 1000;
    const TIME_TOTAL = TIME_PER * DELTA;

    // Utility functions
    function midpoint(a, b) {
      return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
    }

    function line(points) {
      return points.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]},${p[1]}`).join(" ");
    }

    function subdivideTriangle(triangle, iterations = 1) {
      const [p1, p2, p3] = triangle;
      const points = [];
      const triangles = [];

      if (iterations === 0) {
        // Find center of current triangle
        const centroid = [(p1[0] + p2[0] + p3[0]) / 3, (p1[1] + p2[1] + p3[1]) / 3];
        points.push(centroid);
        triangles.push(triangle);
      } else {
        const mid = midpoint(p1, p3);
        const sub1 = [p1, mid, p2];
        const sub1d = subdivideTriangle(sub1, iterations - 1);
        points.push(...sub1d.points);
        triangles.push(...sub1d.triangles);

        const sub2 = [p2, mid, p3];
        const sub2d = subdivideTriangle(sub2, iterations - 1);
        points.push(...sub2d.points);
        triangles.push(...sub2d.triangles);
      }

      return { points, triangles };
    }

    function sierpinskiCurve(len, iterations) {
      const triangle_1 = [
        [0, len],
        [0, 0],
        [len, 0],
      ];
      const triangle_2 = [
        [len, 0],
        [len, len],
        [0, len],
      ];

      const half1 = subdivideTriangle(triangle_1, iterations);
      const half2 = subdivideTriangle(triangle_2, iterations);

      const points = [...half1.points, ...half2.points];
      const triangles = [...half1.triangles, ...half2.triangles];

      return { points, triangles };
    }

    function doubleArray(arr) {
      return arr.flatMap((item) => [item, item]);
    }

    function interpolatePoints(pointsA, pointsB, t) {
      return pointsA.map((a, i) => [
        a[0] + (pointsB[i][0] - a[0]) * t,
        a[1] + (pointsB[i][1] - a[1]) * t,
      ]);
    }

    // Setup SVG
    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    svg.setAttribute(
      "viewBox",
      `${-PADDING} ${-PADDING} ${SIZE + PADDING * 2} ${SIZE + PADDING * 2}`,
    );
    svg.setAttribute("width", SIZE);
    svg.setAttribute("height", SIZE);
    svg.style.backgroundColor = "rgb(135, 0, 35)";

    const gridPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
    gridPath.setAttribute("fill", "none");
    gridPath.setAttribute("stroke", "rgb(215, 88, 134)");
    gridPath.setAttribute("stroke-width", "1");

    const curvePath = document.createElementNS("http://www.w3.org/2000/svg", "path");
    curvePath.setAttribute("fill", "none");
    curvePath.setAttribute("stroke", "rgb(251, 139, 192)");
    curvePath.setAttribute("stroke-width", "4");

    svg.appendChild(gridPath);
    svg.appendChild(curvePath);
    document.body.prepend(svg);

    // Animation
    let startTime = Date.now();

    function animate() {
      const elapsed = Date.now() - startTime;
      const playhead = Math.abs(((elapsed + TIME_TOTAL) % (TIME_TOTAL * 2)) - TIME_TOTAL);
      const interp = DELTA * (playhead / TIME_TOTAL);
      const interpIndex = Math.floor(interp);
      const interpRemainder = interp % 1;
      const smoothRemainder = Math.sin(interpRemainder * Math.PI + Math.PI / 2) / -2 + 0.5;

      if (interpIndex < DELTA) {
        const fromPoints = doubleArray(sierpinskiCurve(SIZE, interpIndex + MIN).points);
        const toPoints = sierpinskiCurve(SIZE, interpIndex + MIN + 1).points;
        const currentPoints = interpolatePoints(fromPoints, toPoints, smoothRemainder);

        curvePath.setAttribute("d", line(currentPoints) + "Z");

        // Update grid
        const triangles = sierpinskiCurve(SIZE, Math.ceil(interp) + MIN).triangles;
        const gridPathData = triangles
          .map(
            (tri) =>
              `M ${tri[0][0]},${tri[0][1]} L ${tri[1][0]},${tri[1][1]} L ${tri[2][0]},${tri[2][1]} Z `,
          )
          .join("");
        gridPath.setAttribute("d", gridPathData);
      }

      requestAnimationFrame(animate);
    }

    animate();

    // Make code viewable
    const scriptEl = document.getElementById("sierpinski-script");
    const codeEl = document.getElementById("sierpinski-code");
    codeEl.textContent = scriptEl.textContent;