commit 2dd7d44d941749d7ab0db19f8450b2cbfee81c6d
parent f1c7b9eb6c0a7c5ee0626a70f5434064d1741da8
Author: Hunter
Date:   Fri, 17 Jul 2026 10:52:32 -0400

first pass at mobile support

Diffstat:
Mindex.html | 141++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
1 file changed, 119 insertions(+), 22 deletions(-)

diff --git a/index.html b/index.html @@ -2,6 +2,7 @@ <html lang="en"> <head> <meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"> <title>animus</title> <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"> <style> @@ -14,6 +15,10 @@ background: #000; cursor: none; overscroll-behavior: none; + touch-action: none; + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; } canvas { display: block; @@ -860,6 +865,103 @@ } }); + // touch: one finger paints, two fingers pan/pinch-zoom. once a second + // finger lands the whole touch becomes a gesture (no painting) until all + // fingers lift, so a stray finger during a pan never leaves a mark. + let touchMode = null; // 'paint' | 'gesture' + let touchPainted = false; + let pinchDist = 0, pinchMidX = 0, pinchMidY = 0; + + function touchCentroid(touches) { + let x = 0, y = 0; + for (const t of touches) { x += t.clientX; y += t.clientY; } + return { x: x / touches.length, y: y / touches.length }; + } + + function anchorGesture(touches) { + const m = touchCentroid(touches); + pinchMidX = m.x; + pinchMidY = m.y; + pinchDist = touches.length >= 2 + ? Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY) + : 0; + } + + window.addEventListener('touchstart', (e) => { + e.preventDefault(); + if (e.touches.length === 1 && touchMode === null) { + const t = e.touches[0]; + curClientX = t.clientX; + curClientY = t.clientY; + const p = clientToWorld(t.clientX, t.clientY); + curX = p.x; + curY = p.y; + mouseInside = true; + touchMode = 'paint'; + painting = true; + touchPainted = false; + lastPaintX = curX; + lastPaintY = curY; + } else { + touchMode = 'gesture'; + painting = false; + lastPaintX = null; + lastPaintY = null; + mouseInside = false; + anchorGesture(e.touches); + } + requestDraw(); + }, { passive: false }); + + window.addEventListener('touchmove', (e) => { + e.preventDefault(); + if (touchMode === 'paint') { + const t = e.touches[0]; + curClientX = t.clientX; + curClientY = t.clientY; + const p = clientToWorld(t.clientX, t.clientY); + paintLine(lastPaintX, lastPaintY, p.x, p.y); + touchPainted = true; + lastPaintX = p.x; + lastPaintY = p.y; + curX = p.x; + curY = p.y; + } else if (touchMode === 'gesture') { + const m = touchCentroid(e.touches); + camX -= (m.x - pinchMidX) / zoom; + camY -= (m.y - pinchMidY) / zoom; + if (e.touches.length >= 2) { + const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY); + if (pinchDist > 0) applyZoom(dist / pinchDist, m.x, m.y); + pinchDist = dist; + } + pinchMidX = m.x; + pinchMidY = m.y; + } else { + return; + } + requestDraw(); + }, { passive: false }); + + function touchEnd(e) { + e.preventDefault(); + if (e.touches.length === 0) { + // a tap that never moved still paints its dot + if (touchMode === 'paint' && !touchPainted) paintAt(curX, curY); + touchMode = null; + painting = false; + lastPaintX = null; + lastPaintY = null; + mouseInside = false; + requestDraw(); + } else if (touchMode === 'gesture') { + // re-anchor to the remaining fingers so the camera doesn't jump + anchorGesture(e.touches); + } + } + window.addEventListener('touchend', touchEnd, { passive: false }); + window.addEventListener('touchcancel', touchEnd, { passive: false }); + function startDragMode(mode) { const anchorY = curClientY !== null ? curClientY : 0; if (mode === 'brush' && !dragBrush) dragBrush = { anchorY, start: brush }; @@ -942,6 +1044,22 @@ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } + function applyZoom(factor, ax, ay) { + const worldAtAnchorX = camX + ax / zoom; + const worldAtAnchorY = camY + ay / zoom; + zoomF = clamp(zoomF * factor, MIN_ZOOM, MAX_ZOOM); + 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 = worldAtAnchorX - ax / zoom; + camY = worldAtAnchorY - ay / zoom; + } + } + // wheel gesture lock: once a wheel gesture starts in a given mode, // subsequent events in the same burst stay in that mode until the wheel // goes idle or the controlling modifier set changes. this keeps trackpad @@ -1001,29 +1119,8 @@ touchWheelGesture(); 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; // 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; - } + applyZoom(Math.exp(-e.deltaY * 0.04), e.clientX, e.clientY); requestDraw(); return; }