first pass at mobile support

Hunter
Friday, July 17th, 2026 at 10:52 AM

index.html+119-22

index.html
   2  <html lang="en">
   3  <head>
   4  <meta charset="UTF-8">
   5 +<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
   6  <title>animus</title>
   7  <link id="favicon" rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect width='1' height='1' fill='white'/%3E%3C/svg%3E">
   8  <style>

  15  		background: #000;
  16  		cursor: none;
  17  		overscroll-behavior: none;
  18 +		touch-action: none;
  19 +		-webkit-user-select: none;
  20 +		user-select: none;
  21 +		-webkit-touch-callout: none;
  22  	}
  23  	canvas {
  24  		display: block;

 865  		}
 866  	});
 867  
 868 +	// touch: one finger paints, two fingers pan/pinch-zoom. once a second
 869 +	// finger lands the whole touch becomes a gesture (no painting) until all
 870 +	// fingers lift, so a stray finger during a pan never leaves a mark.
 871 +	let touchMode = null; // 'paint' | 'gesture'
 872 +	let touchPainted = false;
 873 +	let pinchDist = 0, pinchMidX = 0, pinchMidY = 0;
 874 +
 875 +	function touchCentroid(touches) {
 876 +		let x = 0, y = 0;
 877 +		for (const t of touches) { x += t.clientX; y += t.clientY; }
 878 +		return { x: x / touches.length, y: y / touches.length };
 879 +	}
 880 +
 881 +	function anchorGesture(touches) {
 882 +		const m = touchCentroid(touches);
 883 +		pinchMidX = m.x;
 884 +		pinchMidY = m.y;
 885 +		pinchDist = touches.length >= 2
 886 +			? Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY)
 887 +			: 0;
 888 +	}
 889 +
 890 +	window.addEventListener('touchstart', (e) => {
 891 +		e.preventDefault();
 892 +		if (e.touches.length === 1 && touchMode === null) {
 893 +			const t = e.touches[0];
 894 +			curClientX = t.clientX;
 895 +			curClientY = t.clientY;
 896 +			const p = clientToWorld(t.clientX, t.clientY);
 897 +			curX = p.x;
 898 +			curY = p.y;
 899 +			mouseInside = true;
 900 +			touchMode = 'paint';
 901 +			painting = true;
 902 +			touchPainted = false;
 903 +			lastPaintX = curX;
 904 +			lastPaintY = curY;
 905 +		} else {
 906 +			touchMode = 'gesture';
 907 +			painting = false;
 908 +			lastPaintX = null;
 909 +			lastPaintY = null;
 910 +			mouseInside = false;
 911 +			anchorGesture(e.touches);
 912 +		}
 913 +		requestDraw();
 914 +	}, { passive: false });
 915 +
 916 +	window.addEventListener('touchmove', (e) => {
 917 +		e.preventDefault();
 918 +		if (touchMode === 'paint') {
 919 +			const t = e.touches[0];
 920 +			curClientX = t.clientX;
 921 +			curClientY = t.clientY;
 922 +			const p = clientToWorld(t.clientX, t.clientY);
 923 +			paintLine(lastPaintX, lastPaintY, p.x, p.y);
 924 +			touchPainted = true;
 925 +			lastPaintX = p.x;
 926 +			lastPaintY = p.y;
 927 +			curX = p.x;
 928 +			curY = p.y;
 929 +		} else if (touchMode === 'gesture') {
 930 +			const m = touchCentroid(e.touches);
 931 +			camX -= (m.x - pinchMidX) / zoom;
 932 +			camY -= (m.y - pinchMidY) / zoom;
 933 +			if (e.touches.length >= 2) {
 934 +				const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
 935 +				if (pinchDist > 0) applyZoom(dist / pinchDist, m.x, m.y);
 936 +				pinchDist = dist;
 937 +			}
 938 +			pinchMidX = m.x;
 939 +			pinchMidY = m.y;
 940 +		} else {
 941 +			return;
 942 +		}
 943 +		requestDraw();
 944 +	}, { passive: false });
 945 +
 946 +	function touchEnd(e) {
 947 +		e.preventDefault();
 948 +		if (e.touches.length === 0) {
 949 +			// a tap that never moved still paints its dot
 950 +			if (touchMode === 'paint' && !touchPainted) paintAt(curX, curY);
 951 +			touchMode = null;
 952 +			painting = false;
 953 +			lastPaintX = null;
 954 +			lastPaintY = null;
 955 +			mouseInside = false;
 956 +			requestDraw();
 957 +		} else if (touchMode === 'gesture') {
 958 +			// re-anchor to the remaining fingers so the camera doesn't jump
 959 +			anchorGesture(e.touches);
 960 +		}
 961 +	}
 962 +	window.addEventListener('touchend', touchEnd, { passive: false });
 963 +	window.addEventListener('touchcancel', touchEnd, { passive: false });
 964 +
 965  	function startDragMode(mode) {
 966  		const anchorY = curClientY !== null ? curClientY : 0;
 967  		if (mode === 'brush' && !dragBrush) dragBrush = { anchorY, start: brush };

1044  
1045  	function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
1046  
1047 +	function applyZoom(factor, ax, ay) {
1048 +		const worldAtAnchorX = camX + ax / zoom;
1049 +		const worldAtAnchorY = camY + ay / zoom;
1050 +		zoomF = clamp(zoomF * factor, MIN_ZOOM, MAX_ZOOM);
1051 +		const STEP_RATIO = 1.2;
1052 +		let newZoom = zoom;
1053 +		if (zoomF > zoom * STEP_RATIO) newZoom = Math.min(MAX_ZOOM, zoom + 1);
1054 +		else if (zoomF < zoom / STEP_RATIO) newZoom = Math.max(MIN_ZOOM, zoom - 1);
1055 +		if (newZoom !== zoom) {
1056 +			zoom = newZoom;
1057 +			zoomF = zoom; // resync so next step needs the same delta
1058 +			camX = worldAtAnchorX - ax / zoom;
1059 +			camY = worldAtAnchorY - ay / zoom;
1060 +		}
1061 +	}
1062 +
1063  	// wheel gesture lock: once a wheel gesture starts in a given mode,
1064  	// subsequent events in the same burst stay in that mode until the wheel
1065  	// goes idle or the controlling modifier set changes. this keeps trackpad

1119  		touchWheelGesture();
1120  
1121  		if (wheelMode === 'zoom') {
     -			// zoom around cursor: world point under cursor stays fixed
     -			const mx = e.clientX, my = e.clientY;
     -			const worldAtCursorX = camX + mx / zoom;
     -			const worldAtCursorY = camY + my / zoom;
1122  			// exponential zoom on the float accumulator so small pinches add up
     -			const factor = Math.exp(-e.deltaY * 0.04);
     -			zoomF = clamp(zoomF * factor, MIN_ZOOM, MAX_ZOOM);
     -			// multiplicative threshold: trigger a step once zoomF has moved
     -			// the same *ratio* past the current integer in either direction.
     -			// fixed additive thresholds felt jarring at low zoom because 1 to 2
     -			// is a 2x jump while 32 to 33 is only ~1.03x - requiring equal pinch
     -			// effort for a huge perceptual leap. ratio-based thresholds make
     -			// every step cost roughly the same perceptual work.
     -			const STEP_RATIO = 1.2;
     -			let newZoom = zoom;
     -			if (zoomF > zoom * STEP_RATIO) newZoom = Math.min(MAX_ZOOM, zoom + 1);
     -			else if (zoomF < zoom / STEP_RATIO) newZoom = Math.max(MIN_ZOOM, zoom - 1);
     -			if (newZoom !== zoom) {
     -				zoom = newZoom;
     -				zoomF = zoom; // resync so next step needs the same delta
     -				camX = worldAtCursorX - mx / zoom;
     -				camY = worldAtCursorY - my / zoom;
     -			}
1123 +			applyZoom(Math.exp(-e.deltaY * 0.04), e.clientX, e.clientY);
1124  			requestDraw();
1125  			return;
1126  		}