<!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>