tune brush and zoom gesture sensitivity

Hunter
Wednesday, July 22nd, 2026 at 11:46 PM

index.html+50-17
readme.md+1-1

index.html
 144  	let dpr = window.devicePixelRatio || 1;
 145  	let cssW = 0, cssH = 0;
 146  
     -	// 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;
 147 +	const ZOOM_STOPS = [2, 3, 4, 5, 6, 8, 11, 15, 21, 30];
 148 +	const MIN_ZOOM = ZOOM_STOPS[0];
 149 +	const ZOOM_SENSITIVITY = 3.5;	// fractional levels per unit log(pinch factor)
 150 +	let zoomIdx = 0;
 151 +	let zoom = MIN_ZOOM;
 152  
 153  	let camX = 0, camY = 0;
 154  

 807  		requestDraw();
 808  	});
 809  
 810 +	// brush resize curve: linear (fine, precise) up to BRUSH_LINEAR_MAX, then a
 811 +	// power-law ramp beyond, tuned by BRUSH_ACCEL.
 812 +	function brushFromDrag(start, dyPx) {
 813 +		const A = BRUSH_DRAG_PX_PER_STEP, T = BRUSH_LINEAR_MAX, c = BRUSH_ACCEL;
 814 +		const seam = T * A; // drag distance from size 0 to the seam
 815 +		const brushToPos = (b) => b <= T ? b * A : seam + (seam / c) * (Math.pow(b / T, c) - 1);
 816 +		const posToBrush = (p) => p <= seam ? p / A : T * Math.pow(1 + c * (p - seam) / seam, 1 / c);
 817 +		return posToBrush(brushToPos(start) + dyPx);
 818 +	}
 819 +
 820  	// modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor
 821  	// drives brush size / roundness / color channels. drag up (dy negative)
 822  	// -> increase. when a value hits its bound and the drag continues past,

 825  	function applyDrags(clientY) {
 826  		if (dragBrush) {
 827  			const dyPx = (dragBrush.anchorY - clientY) * dpr;
     -			const target = dragBrush.start + Math.trunc(dyPx / BRUSH_DRAG_PX_PER_STEP);
     -			const clamped = clamp(target, 1, 256);
 828 +			const target = brushFromDrag(dragBrush.start, dyPx);
 829 +			const clamped = clamp(Math.round(target), 1, MAX_BRUSH);
 830  			brush = clamped;
     -			if (target !== clamped) {
 831 +			if (target < 1 || target > MAX_BRUSH) {
 832  				dragBrush.start = clamped;
 833  				dragBrush.anchorY = clientY;
 834  			}

1196  			fileInput.click();
1197  			return;
1198  		}
1199 +		if ((e.metaKey || e.ctrlKey) && !e.altKey) {
1200 +			if (e.key === '=' || e.key === '+') { e.preventDefault(); stepZoom(1); return; }
1201 +			if (e.key === '-' || e.key === '_') { e.preventDefault(); stepZoom(-1); return; }
1202 +			if (e.key === '0') { e.preventDefault(); resetZoom(); return; }
1203 +		}
1204  		if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat) {
1205  			const lk = e.key.toLowerCase();
1206  			if (e.key === 'ArrowLeft') { e.preventDefault(); tapArrow(-1); return; }

1282  	function applyZoom(factor, ax, ay) {
1283  		const worldAtAnchorX = camX + ax / zoom;
1284  		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);
1285 +		zoomIdx = clamp(zoomIdx + Math.log(factor) * ZOOM_SENSITIVITY, 0, ZOOM_STOPS.length - 1);
1286 +		const newZoom = ZOOM_STOPS[Math.round(zoomIdx)];
1287  		if (newZoom !== zoom) {
1288  			zoom = newZoom;
     -			zoomF = zoom; // resync so next step needs the same delta
1289  			camX = worldAtAnchorX - ax / zoom;
1290  			camY = worldAtAnchorY - ay / zoom;
1291  		}
1292  	}
1293  
1294 +	function zoomTo(newZoom) {
1295 +		if (newZoom === zoom) return;
1296 +		const ax = cssW / 2, ay = cssH / 2;
1297 +		const worldAtAnchorX = camX + ax / zoom;
1298 +		const worldAtAnchorY = camY + ay / zoom;
1299 +		zoom = newZoom;
1300 +		zoomIdx = ZOOM_STOPS.indexOf(zoom);
1301 +		camX = worldAtAnchorX - ax / zoom;
1302 +		camY = worldAtAnchorY - ay / zoom;
1303 +		requestDraw();
1304 +	}
1305 +
1306 +	function stepZoom(dir) {
1307 +		const i = ZOOM_STOPS.indexOf(zoom);
1308 +		zoomTo(ZOOM_STOPS[clamp(i + dir, 0, ZOOM_STOPS.length - 1)]);
1309 +	}
1310 +	function resetZoom() { zoomTo(MIN_ZOOM); }
1311 +
1312  	// wheel gesture lock: once a wheel gesture starts in a given mode,
1313  	// subsequent events in the same burst stay in that mode until the wheel
1314  	// goes idle or the controlling modifier set changes. this keeps trackpad

1318  	let wheelIdleTimer = null;
1319  	let panning = false;
1320  
     -	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
1321 +	const BRUSH_DRAG_PX_PER_STEP = 25;	// device px per +/- 1 brush px (below the seam)
1322 +	const BRUSH_LINEAR_MAX = 6;			// brush px below which resize stays linear/fine
1323 +	const BRUSH_ACCEL = 0.5;
1324 +	const MAX_BRUSH = 150;
1325 +	const ROUNDNESS_DRAG_FULL_PX = 200;
1326 +	const COLOR_DRAG_FULL_PX = 256;
1327  	// each drag carries its own anchor so any combination can run at once
1328  	let dragBrush = null;		// {anchorY, start}
1329  	let dragRoundness = null;	// {anchorY, start}

1373  
1374  		if (wheelMode === 'zoom') {
1375  			// exponential zoom on the float accumulator so small pinches add up
     -			applyZoom(Math.exp(-e.deltaY * 0.04), e.clientX, e.clientY);
1376 +			applyZoom(Math.exp(-e.deltaY * 0.02), e.clientX, e.clientY);
1377  			requestDraw();
1378  			return;
1379  		}
readme.md
25  - 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
26  - hold any combination of the above to change size, shape, and/or color at the same time
27  - hold `C` and click anywhere to pick up the color underneath your brush
   -- pinch with two fingers to zoom in/out
28 +- pinch with two fingers to zoom in / out (or use `⌘ +` / `⌘ -`)
29  - drag with two fingers to pan around the canvas
30  
31  ### animation