commit 2c750ff48ae7a1cc5126084df9970b428f2fd748
parent 77cba162654c5447803afec92b58b967571b4339
Author: Hunter
Date:   Wed, 22 Jul 2026 23:46:12 -0400

tune brush and zoom gesture sensitivity

Diffstat:
Mindex.html | 67++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mreadme.md | 2+-
2 files changed, 51 insertions(+), 18 deletions(-)

diff --git a/index.html b/index.html @@ -144,10 +144,11 @@ let dpr = window.devicePixelRatio || 1; let cssW = 0, cssH = 0; - // zoomF accumulates fractional pinch input; zoom is the snapped integer used for rendering. - let zoomF = 2; - let zoom = 2; - const MIN_ZOOM = 1, MAX_ZOOM = 64; + const ZOOM_STOPS = [2, 3, 4, 5, 6, 8, 11, 15, 21, 30]; + const MIN_ZOOM = ZOOM_STOPS[0]; + const ZOOM_SENSITIVITY = 3.5; // fractional levels per unit log(pinch factor) + let zoomIdx = 0; + let zoom = MIN_ZOOM; let camX = 0, camY = 0; @@ -806,6 +807,16 @@ requestDraw(); }); + // brush resize curve: linear (fine, precise) up to BRUSH_LINEAR_MAX, then a + // power-law ramp beyond, tuned by BRUSH_ACCEL. + function brushFromDrag(start, dyPx) { + const A = BRUSH_DRAG_PX_PER_STEP, T = BRUSH_LINEAR_MAX, c = BRUSH_ACCEL; + const seam = T * A; // drag distance from size 0 to the seam + const brushToPos = (b) => b <= T ? b * A : seam + (seam / c) * (Math.pow(b / T, c) - 1); + const posToBrush = (p) => p <= seam ? p / A : T * Math.pow(1 + c * (p - seam) / seam, 1 / c); + return posToBrush(brushToPos(start) + dyPx); + } + // modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor // drives brush size / roundness / color channels. drag up (dy negative) // -> increase. when a value hits its bound and the drag continues past, @@ -814,10 +825,10 @@ function applyDrags(clientY) { if (dragBrush) { const dyPx = (dragBrush.anchorY - clientY) * dpr; - const target = dragBrush.start + Math.trunc(dyPx / BRUSH_DRAG_PX_PER_STEP); - const clamped = clamp(target, 1, 256); + const target = brushFromDrag(dragBrush.start, dyPx); + const clamped = clamp(Math.round(target), 1, MAX_BRUSH); brush = clamped; - if (target !== clamped) { + if (target < 1 || target > MAX_BRUSH) { dragBrush.start = clamped; dragBrush.anchorY = clientY; } @@ -1185,6 +1196,11 @@ fileInput.click(); return; } + if ((e.metaKey || e.ctrlKey) && !e.altKey) { + if (e.key === '=' || e.key === '+') { e.preventDefault(); stepZoom(1); return; } + if (e.key === '-' || e.key === '_') { e.preventDefault(); stepZoom(-1); return; } + if (e.key === '0') { e.preventDefault(); resetZoom(); return; } + } if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat) { const lk = e.key.toLowerCase(); if (e.key === 'ArrowLeft') { e.preventDefault(); tapArrow(-1); return; } @@ -1266,19 +1282,33 @@ 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); + zoomIdx = clamp(zoomIdx + Math.log(factor) * ZOOM_SENSITIVITY, 0, ZOOM_STOPS.length - 1); + const newZoom = ZOOM_STOPS[Math.round(zoomIdx)]; if (newZoom !== zoom) { zoom = newZoom; - zoomF = zoom; // resync so next step needs the same delta camX = worldAtAnchorX - ax / zoom; camY = worldAtAnchorY - ay / zoom; } } + function zoomTo(newZoom) { + if (newZoom === zoom) return; + const ax = cssW / 2, ay = cssH / 2; + const worldAtAnchorX = camX + ax / zoom; + const worldAtAnchorY = camY + ay / zoom; + zoom = newZoom; + zoomIdx = ZOOM_STOPS.indexOf(zoom); + camX = worldAtAnchorX - ax / zoom; + camY = worldAtAnchorY - ay / zoom; + requestDraw(); + } + + function stepZoom(dir) { + const i = ZOOM_STOPS.indexOf(zoom); + zoomTo(ZOOM_STOPS[clamp(i + dir, 0, ZOOM_STOPS.length - 1)]); + } + function resetZoom() { zoomTo(MIN_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 @@ -1288,9 +1318,12 @@ let wheelIdleTimer = null; let panning = false; - const BRUSH_DRAG_PX_PER_STEP = 4; // device px per +/- 1 brush px - const ROUNDNESS_DRAG_FULL_PX = 200; // device px to traverse 0 to 1 - const COLOR_DRAG_FULL_PX = 256; // device px to traverse 0 to 255 + const BRUSH_DRAG_PX_PER_STEP = 25; // device px per +/- 1 brush px (below the seam) + const BRUSH_LINEAR_MAX = 6; // brush px below which resize stays linear/fine + const BRUSH_ACCEL = 0.5; + const MAX_BRUSH = 150; + const ROUNDNESS_DRAG_FULL_PX = 200; + const COLOR_DRAG_FULL_PX = 256; // each drag carries its own anchor so any combination can run at once let dragBrush = null; // {anchorY, start} let dragRoundness = null; // {anchorY, start} @@ -1340,7 +1373,7 @@ if (wheelMode === 'zoom') { // exponential zoom on the float accumulator so small pinches add up - applyZoom(Math.exp(-e.deltaY * 0.04), e.clientX, e.clientY); + applyZoom(Math.exp(-e.deltaY * 0.02), e.clientX, e.clientY); requestDraw(); return; } diff --git a/readme.md b/readme.md @@ -25,7 +25,7 @@ an infinite canvas for radical digital painting. - hold any of the ![R](readme_images/R.svg), ![G](readme_images/G.svg), and/or ![B](readme_images/B.svg) keys and move your cursor up/down to change the ![Redness](readme_images/Redness.svg), ![Greenness](readme_images/Greenness.svg), and/or ![Blueness](readme_images/Blueness.svg) of the active color - hold any combination of the above to change size, shape, and/or color at the same time - hold `C` and click anywhere to pick up the color underneath your brush -- pinch with two fingers to zoom in/out +- pinch with two fingers to zoom in / out (or use `⌘ +` / `⌘ -`) - drag with two fingers to pan around the canvas ### animation