commit 2dd7d44d941749d7ab0db19f8450b2cbfee81c6d
parent f1c7b9eb6c0a7c5ee0626a70f5434064d1741da8
Author: Hunter
Date: Fri, 17 Jul 2026 10:52:32 -0400
first pass at mobile support
Diffstat:
| M | index.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;
}