<!DOCTYPE html>
<html lang="en-us">
<head>
  <meta charset="utf-8">
  <meta name="robots" content="noindex">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <title>Mandelbrot Touch Navigation</title>
  <style>
    html,
    body,
    canvas {
      margin: 0;
      padding: 0;
      width: 100%;
      height: 100%;
      overflow: hidden;
    }
  </style>
</head>
<body>
  <canvas id="canvas"></canvas>
<!--
  © Adam Murray 2024
  https://adammurray.link/

  Creative Commons License
  Attribution-NonCommercial-ShareAlike 4.0 International
  https://creativecommons.org/licenses/by-nc-sa/4.0/
-->    
  
  <script id="vertexShader" type="x-shader/x-vertex">
    #version 300 es
    in vec4 vertexPosition;
    void main() { // no-op vertex shader
      gl_Position = vertexPosition;
    }
  </script>
  
  <script id="fragmentShader" type="x-shader/x-fragment">
    #version 300 es
    precision highp float;
  
    uniform vec2 canvasSize;
    uniform vec3 offset;
    out vec4 fragColor;
  
    vec2 complexMultiply(vec2 a, vec2 b) {
      return vec2(a.x*b.x - a.y*b.y, a.x*b.y + a.y*b.x);
    }
  
    const float MAX_ITERATIONS = 200.;
    const float QUALITY = 2.; // anti-aliasing amount
  
    vec3 draw(vec2 c) {
      vec2 z = vec2(0, 0);
      float len = 0.;
      float i;
      for(i=0.; len < 2. && i<MAX_ITERATIONS; i++) {
        z = complexMultiply(z, z) + c;
        len = length(z);
      }
      if (i >= MAX_ITERATIONS) {
        return vec3(0,0,0);
      } else {
        float shade = (i - log2(log(len))) / MAX_ITERATIONS;
        return vec3(shade, pow(shade,0.5), 1.-shade);
      }
    }
  
    void main() {
      vec3 color = vec3(0,0,0);
      float samples = 0.;
      float minDim = min(canvasSize.x, canvasSize.y);
  
      float subpixel = 1./float(QUALITY);
      for (float x=0.; x<1.; x+=subpixel) {
        for (float y=0.; y<1.; y+=subpixel) {
          vec2 fragCoord = gl_FragCoord.xy + vec2(x,y);
          vec2 coord = (2.*fragCoord - canvasSize)/minDim;
          vec2 c = coord * offset.z - offset.xy;
  
          color += draw(c);
          samples++;
        }
      }
      fragColor = vec4(color/samples, 1);
    }
  </script>
  
  <script type="text/javascript">
    const canvas = document.querySelector('canvas');
    const vertexShader = document.querySelector('script[type="x-shader/x-vertex"]');
    const fragmentShader = document.querySelector('script[type="x-shader/x-fragment"]');
  
    const gl = canvas.getContext("webgl2");
    if (!gl) {
      main.innerHTML = '<p>Error: WebGL2 is <a href="https://get.webgl.org/webgl2/">not supported by your browser</a></p>';
      throw "WebGL2 not supported";
    }
  
    function createShader(shaderType, sourceCode) {
      const shader = gl.createShader(shaderType);
      gl.shaderSource(shader, sourceCode);
      gl.compileShader(shader);
      if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw gl.getShaderInfoLog(shader);
      return shader;
    }
  
    const program = gl.createProgram();
    gl.attachShader(program, createShader(gl.VERTEX_SHADER, vertexShader.textContent.trim()));
    gl.attachShader(program, createShader(gl.FRAGMENT_SHADER, fragmentShader.textContent.trim()));
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw gl.getProgramInfoLog(program);
    gl.useProgram(program);
  
    const vertices = [[-1, -1], [1, -1], [-1, 1], [1, 1]];
    const vertexBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices.flat()), gl.STATIC_DRAW);
    const vertexPosition = gl.getAttribLocation(program, "vertexPosition");
    gl.enableVertexAttribArray(vertexPosition);
    gl.vertexAttribPointer(vertexPosition, 2, gl.FLOAT, false, 0, 0);
  
    const offsetUniform = gl.getUniformLocation(program, 'offset');
    const canvasSizeUniform = gl.getUniformLocation(program, 'canvasSize');
  
    this.offset = [0.5, 0, 1];
  
    function draw(pointerEvent) {
      const width = canvas.clientWidth;
      const height = canvas.clientHeight;
      canvas.width = width;
      canvas.height = height;
      gl.viewport(0, 0, width, height);
      gl.uniform2f(canvasSizeUniform, width, height);
  
      gl.uniform3f(offsetUniform, ...this.offset);
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, vertices.length);
    }
    draw();
  
    function distance(event1, event2) {
      const dx = (event1.clientX - event2.clientX);
      const dy = (event1.clientY - event2.clientY);
      return Math.sqrt(dx * dx + dy * dy);
    }
  
    canvas.addEventListener('pointerdown', (event) => {
      // Track two fingers (or a mouse click)
      if (!this.primary) {
        this.primary = event;
      } else if (!this.secondary) {
        this.secondary = event;
        this.lastDistance = distance(this.primary, this.secondary);
      }
    });
    window.addEventListener('pointermove', (event) => {
      const isPrimary = this.primary?.pointerId === event.pointerId;
      const isSecondary = this.secondary?.pointerId === event.pointerId;
      if (isPrimary || isSecondary) {
        // We can assume there's always a primary finger from the 'pointerup' logic below, so
        // a secondary finger indicates it's a pinch gesture, otherwise it's a single finger dragging.
        if (this.secondary) {
          // Pinch-to-zoom gesture
          if (isPrimary) {
            this.primary = event;
          } else {
            this.secondary = event;
          }
          const newDistance = distance(this.primary, this.secondary);
          this.offset[2] *= this.lastDistance / newDistance;
          this.lastDistance = newDistance;
        }
        else {
          // Drag gesture
  
          // Perform the same 1/minDim adjustment that's applied to gl_FragCoord in the shader:
          const canvasRect = canvas.getBoundingClientRect();
          const minDim = Math.min(canvasRect.width, canvasRect.height);
          const deltaX = (event.clientX - this.primary.clientX) / minDim;
          const deltaY = -(event.clientY - this.primary.clientY) / minDim;
  
          const zoomFactor = 2 * this.offset[2];
          this.offset[0] += deltaX * zoomFactor;
          this.offset[1] += deltaY * zoomFactor;
          this.primary = event;
        }
        draw();
      }
    });
    canvas.addEventListener('wheel', (event) => {
      // mousewheel and track pad 2-finger scroll
      if (event.deltaY > 0) {
        this.offset[2] *= 0.95;
      } else if (event.deltaY < 0) {
        this.offset[2] /= 0.95;
      }
      draw();
      event.preventDefault(); // don't scroll the page
    });
    window.addEventListener('pointerup', (event) => {
      if (event.pointerId === this.primary?.pointerId) {
        this.primary = null;
      } else if (event.pointerId === this.secondary?.pointerId) {
        this.secondary = null;
      }
      if (this.secondary && !this.primary) {
        // The drag gesture works with the primary finger, so if
        // we're still tracking a finger, make it the primary:
        this.primary = this.secondary;
        this.secondary = null;
      }
    });
  
    // And don't scroll when sliding around the canvas on mobile:
    canvas.addEventListener('touchmove', (event) => event.preventDefault());
  
    window.addEventListener('resize', () => draw());
  </script>
  
</body>
</html>