<!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>Touch rotate and zoom</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 primaryPosition;
    uniform vec2 secondaryPosition;
    uniform mat3 transform;
    out vec4 fragColor;
  
    const float QUALITY = 2.; // anti-aliasing amount
    const float POINTER_SIZE_IN_PIXELS = 30.;
    const float MINOR_BAR_WIDTH = .005;
    const float MINOR_BAR_GAP = .2;
    const float MAJOR_BAR_WIDTH = 5.*MINOR_BAR_WIDTH;
    const float MAJOR_BAR_GAP = 5.*MINOR_BAR_GAP;
  
    float drawCircle(vec2 coord, vec2 center, float radius) {
      return 1. - smoothstep(0., radius, length(coord - center));
    }
  
    float drawBars(vec2 coord, float barWidth, float barGap) {
      float horizontalBars = mod(coord.y, barGap);
      float verticalBars = mod(coord.x, barGap);
      return
        smoothstep(barGap-barWidth, barGap, horizontalBars) - smoothstep(0., barWidth, horizontalBars) + 1. +
        smoothstep(barGap-barWidth, barGap, verticalBars) - smoothstep(0., barWidth, verticalBars) + 1.;
    }
  
    void main() {
      // use pixel coordinate/dimensions for pointer indicators so they aren't affected by the zoom level
      float primaryPointer = drawCircle(gl_FragCoord.xy, primaryPosition, POINTER_SIZE_IN_PIXELS);
      float secondaryPointer = drawCircle(gl_FragCoord.xy, secondaryPosition, POINTER_SIZE_IN_PIXELS);
  
      // everything else is relative to the transformed coordinate system and we'll anti-alias it:
      vec3 color = vec3(0,0,0);
      float samples = 0.;
  
      // Grids are really bad for aliasing, so draw the grid with anti-aliasing:
      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 = (transform * vec3(fragCoord.x, fragCoord.y, 1)).xy;
  
          float origin = drawCircle(coord, vec2(0, 0), MINOR_BAR_GAP);
          float grid = (
            drawBars(coord, MINOR_BAR_WIDTH, MINOR_BAR_GAP) +
            drawBars(coord, MAJOR_BAR_WIDTH, MAJOR_BAR_GAP)
          ) / 2.;
  
          // draw the grid in white and the origin in red:
          color += vec3(grid + origin, grid, grid);
          samples++;
        }
      }
      color /= samples;
  
      // draw the primary pointer in green, and the secondary pointer in blue:
      fragColor = vec4(color.r, color.g + primaryPointer, color.b + secondaryPointer, 1);
    }
  </script>
  
  <script>
    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) {
      document.body.innerHTML = 'WebGL2 is <a href="https://get.webgl.org/webgl2/">not supported by your browser</a>.';
      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);
  
    class Transform2D {
      constructor(matrix = [
        // identity
        1, 0, 0,
        0, 1, 0,
        0, 0, 1
      ]) {
        this.matrix = matrix;
      }
  
      translate(x, y) {
        return this.multiply(
          1, 0, 0,
          0, 1, 0,
          x, y, 1,
        );
      }
  
      rotate(angle) {
        const c = Math.cos(angle);
        const s = Math.sin(angle);
        return this.multiply(
          c, -s, 0,
          s, c, 0,
          0, 0, 1,
        );
      }
  
      scale(s) {
        return this.multiply(
          s, 0, 0,
          0, s, 0,
          0, 0, 1,
        );
      }
  
      multiply(a11, a12, a13, a21, a22, a23, a31, a32, a33) {
        const [
          b11, b12, b13,
          b21, b22, b23,
          b31, b32, b33,
        ] = this.matrix;
  
        this.matrix[0] = a11 * b11 + a12 * b21 + a13 * b31;
        this.matrix[1] = a11 * b12 + a12 * b22 + a13 * b32;
        this.matrix[2] = a11 * b13 + a12 * b23 + a13 * b33;
  
        this.matrix[3] = a21 * b11 + a22 * b21 + a23 * b31;
        this.matrix[4] = a21 * b12 + a22 * b22 + a23 * b32;
        this.matrix[5] = a21 * b13 + a22 * b23 + a23 * b33;
  
        this.matrix[6] = a31 * b11 + a32 * b21 + a33 * b31;
        this.matrix[7] = a31 * b12 + a32 * b22 + a33 * b32;
        this.matrix[8] = a31 * b13 + a32 * b23 + a33 * b33;
  
        return this;
      }
    }
    const transform = new Transform2D();
  
    const transformUniform = gl.getUniformLocation(program, 'transform');
    const primaryPositionUniform = gl.getUniformLocation(program, 'primaryPosition');
    const secondaryPositionUniform = gl.getUniformLocation(program, 'secondaryPosition');
  
    function draw() {
      const width = canvas.clientWidth;
      const height = canvas.clientHeight;
      canvas.width = width;
      canvas.height = height;
      gl.viewport(0, 0, width, height);
  
      // normalize coordinates to [-1,1] in the smaller dimension
      // via `(2 * coord - canvasSize)/smallerDimension`
      const normalizedTransform = new Transform2D()
        .scale(1 / Math.min(width, height))
        .translate(-width, -height)
        .scale(2)
        .multiply(...transform.matrix);
  
      gl.uniformMatrix3fv(transformUniform, false, normalizedTransform.matrix);
  
      const canvasRect = canvas.getBoundingClientRect();
      gl.uniform2f(primaryPositionUniform, ...relativePosition(this.primary, canvasRect));
      gl.uniform2f(secondaryPositionUniform, ...relativePosition(this.secondary, canvasRect));
  
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, vertices.length);
    }
    draw();
  
    function relativePosition(event, rect) {
      if (event) {
        return [
          (event.clientX - rect.left),
          rect.height - (event.clientY - rect.top),
        ];
      } else {
        return [Infinity, Infinity];
      }
    }
  
    function differenceVector(event1, event2) {
      return [
        event2.clientX - event1.clientX,
        event2.clientY - event1.clientY,
      ];
    }
  
    function length(vector) {
      const [x, y] = vector;
      return Math.sqrt(x * x + y * y);
    }
  
    function angle(v1, v2) {
      return Math.atan2(
        v1[0] * v2[1] - v1[1] * v2[0],
        v1[0] * v2[0] + v1[1] * v2[1]
      );
    }
  
    canvas.addEventListener('mousemove', (event) => {
      this.currentMousePosition = relativePosition(event, canvas.getBoundingClientRect());
    });
    canvas.addEventListener('mouseleave', (event) => {
      this.currentMousePosition = null;
    });
    window.addEventListener('keydown', (event) => {
      if (event.key == 'Control' || event.key == 'Alt') {
        this.isAltCtrlKeyDown = true;
      }
    });
    window.addEventListener('keyup', (event) => {
      if (event.key == 'Control' || event.key == 'Alt') {
        this.isAltCtrlKeyDown = false;
      }
    });
  
    canvas.addEventListener('pointerdown', (event) => {
      // Track two fingers or a mouse/trackpad click
      if (!this.primary) {
        this.primary = this.initial = event;
      } else if (!this.secondary) {
        this.secondary = event;
      }
      draw();
    });
    window.addEventListener('pointermove', (event) => {
      const isPrimary = this.primary?.pointerId === event.pointerId;
      const isSecondary = this.secondary?.pointerId === event.pointerId;
      if (!isPrimary && !isSecondary) return;
  
      const newPrimary = isPrimary ? event : this.primary;
      const newSecondary = isSecondary ? event : this.secondary;
      const canvasRect = canvas.getBoundingClientRect();
  
      if (isPrimary) {
        // Update offset (translation) for primary touch position or mouse/trackpad click
        const deltaX = (event.clientX - this.primary.clientX);
        const deltaY = -(event.clientY - this.primary.clientY);
  
        if (this.isAltCtrlKeyDown) {
          // Update rotation for non-touch interface (keyboard/trackpad)
          // Use the stable initial primary position when rotating with the mouse/keyboard because we constantly
          // update primary to be the current mouse position, but we want the rotation center to be stationary:
          const [rotationCenterX, rotationCenterY] = relativePosition(this.initial, canvasRect);
          transform
            .translate(rotationCenterX, rotationCenterY)
            .rotate(deltaY / 100)
            .translate(-rotationCenterX, -rotationCenterY);
        } else {
          transform.translate(-deltaX, -deltaY);
        }
      }
      if (this.primary && this.secondary) {
        // Update rotation and scale based on secondary touch position
        const [rotationCenterX, rotationCenterY] = relativePosition(newPrimary, canvasRect);
        const oldDiffVector = differenceVector(this.primary, this.secondary);
        const newDiffVector = differenceVector(newPrimary, newSecondary);
  
        transform
          .translate(rotationCenterX, rotationCenterY)
          .scale(length(oldDiffVector) / length(newDiffVector))
          .rotate(-angle(oldDiffVector, newDiffVector))
          .translate(-rotationCenterX, -rotationCenterY);
      }
  
      // Transforms are updated based on the relative change between each pointermove event,
      // so update the primary and secondary events:
      this.primary = newPrimary;
      this.secondary = newSecondary;
  
      draw();
    });
  
    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;
      }
      draw();
    });
  
    const wheelZoomIn = 0.95;
    const wheelZoomOut = 1 / wheelZoomIn;
    canvas.addEventListener('wheel', (event) => {
      // mousewheel and track pad 2-finger scroll
      if (this.currentMousePosition) {
        transform.translate(this.currentMousePosition[0], this.currentMousePosition[1]);
      }
      if (event.deltaY > 0) {
        transform.scale(wheelZoomIn);
      } else if (event.deltaY < 0) {
        transform.scale(wheelZoomOut);
      }
      if (this.currentMousePosition) {
        transform.translate(-this.currentMousePosition[0], -this.currentMousePosition[1]);
      }
      draw();
      event.preventDefault(); // don't scroll the page
    });
  
    window.addEventListener('resize', () => draw());
  
    // And don't scroll when sliding around the canvas on mobile:
    canvas.addEventListener('touchmove', (event) => event.preventDefault());
  </script>
  
</body>
</html>