first pass at mobile support
Hunter
Friday, July 17th, 2026 at 10:52 AM
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 }