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