multiplicative zoom; clean up comments

Hunter
Friday, April 17th, 2026 at 11:04 AM

index.html+24-65

index.html
 32  	const view = document.getElementById('view');
 33  	const vctx = view.getContext('2d', { alpha: false });
 34  
    -	/* world: chunked tiles of logical pixels */
    -	const CHUNK = 256; // logical pixels per tile side
    -	const chunks = new Map(); // key "cx,cy" -> { canvas, ctx }
 35 +	const CHUNK = 256;
 36 +	const chunks = new Map();
 37  
 38  	function chunkKey(cx, cy) { return cx + ',' + cy; }
 39  

 53  	}
 54  
 55  	function paintRect(wx, wy, w, h, color) {
    -		// fill a rect of logical pixels in world coords, writing to chunks
 56  		const x0 = wx, y0 = wy, x1 = wx + w, y1 = wy + h;
 57  		const cx0 = Math.floor(x0 / CHUNK);
 58  		const cy0 = Math.floor(y0 / CHUNK);

 83  		return [d[0], d[1], d[2]];
 84  	}
 85  
    -	// ------- view / camera -------
 86  	let dpr = window.devicePixelRatio || 1;
 87  	let cssW = 0, cssH = 0;
 88  
    -	// zoom: CSS pixels per logical pixel. integer >= 1 keeps pixels tangible.
 89  	// zoomF accumulates fractional pinch input; zoom is the snapped integer used for rendering.
 90  	let zoomF = 2;
 91  	let zoom = 2;
 92  	const MIN_ZOOM = 1, MAX_ZOOM = 64;
 93  
    -	// camera: top-left of view in world (logical pixel) coords — can be fractional for smooth pan
 94  	let camX = 0, camY = 0;
 95  
    -	// cursor in world coords (logical pixels)
 96  	let curX = 0, curY = 0;
    -	// last known cursor position in client (CSS) pixels — used to keep the
    -	// brush pinned under the real cursor while panning, since the OS does
    -	// not emit mousemove events when only the camera moves.
 97 +	// used to keep the brush pinned under the real cursor while panning, since
 98 +	// the OS does not emit mousemove events when only the camera moves.
 99  	let curClientX = null, curClientY = null;
    -	// show cursor immediately on load; hidden only after an explicit mouseleave
100  	let mouseInside = true;
101  
    -	// color / brush
102  	let r = 255, g = 255, b = 255;
103  	let brush = 1;
    -	// brush roundness: 0 = square (default), 1 = circle inscribed in the square.
    -	// preserved as a fraction when the brush size changes.
104  	let roundness = 0;
105  
    -	// cached brush shape for the current (brush, roundness, color):
    -	//   inside:      Uint8Array of length n*n, 1 = pixel belongs to the brush
    -	//   fillRuns:    [dx, dy, len, ...] row runs over `inside` (cursor fill)
    -	//   outlineRuns: [dx, dy, len, ...] row runs over the 1-pixel dilation
    -	//                ring around `inside`, in (-1..n) shape-local coords
    -	//                (cursor outline)
    -	//   sprite:      an n×n offscreen canvas with the shape rasterized in the
    -	//                current paint color, transparent outside — stamped with
    -	//                a single drawImage per paintAt call.
106  	let brushShape = null;
107  	let brushShapeKey = '';
108  	function getBrushShape() {

110  		if (brushShapeKey === key && brushShape) return brushShape;
111  		const n = brush;
112  		const inside = new Uint8Array(n * n);
    -		// rounded-square: corner radius grows uniformly with roundness.
    -		// roundness=0 -> radius 0, full square (corners preserved).
    -		// roundness=1 -> radius n/2, inscribed circle.
113  		const rad = roundness * (n / 2);
114  		const rad2 = rad * rad;
115  		const lo = rad - 0.5;

140  		}
141  		sctx.putImageData(img, 0, 0);
142  
    -		// row-runs over `inside` for the cursor fill
143  		const fillRuns = [];
144  		for (let dy = 0; dy < n; dy++) {
145  			let dx = 0;

153  		}
154  
155  		// 1-pixel outline ring: cells outside the shape that are 8-way adjacent
    -		// to any inside cell. stored as row-runs in an (n+2)×(n+2) grid with
156 +		// to any inside cell. stored as row-runs in an (n+2)x(n+2) grid with
157  		// coordinates offset by -1 so they index directly in shape-local space.
158  		const m = n + 2;
159  		const ring = new Uint8Array(m * m);

188  		return brushShape;
189  	}
190  
    -	// outline light/dark blend: animates between 0 (dark color -> lighten)
    -	// and 1 (light color -> darken). cross-fades over 0.25s.
    -	let outlineLightness = 1; // starts white selected
191 +	// outline blend: 0 = dark color (lighten with screen), 1 = light color (darken with multiply).
192 +	let outlineLightness = 1;
193  	let outlineAnimStart = 0;
194  	let outlineAnimFrom = 1;
195  	let outlineAnimTo = 1;

202  		requestDraw();
203  	}
204  
    -	// displayed cursor fill color eases toward the real r/g/b over 0.25s.
    -	// the real r/g/b is used for painting immediately; this is purely visual.
205 +	// displayed cursor fill eases toward r/g/b; painting still uses r/g/b immediately.
206  	let dispR = 255, dispG = 255, dispB = 255;
207  	let colorAnimStart = 0;
208  	let colorAnimFromR = 255, colorAnimFromG = 255, colorAnimFromB = 255;

231  		view.width = Math.floor(cssW * dpr);
232  		view.height = Math.floor(cssH * dpr);
233  		if (firstResize) {
    -			// default cursor to center of viewport until first real mouse event
234  			curX = Math.floor(cssW / (2 * zoom));
235  			curY = Math.floor(cssH / (2 * zoom));
236  		}

249  			paintRect(tl.x, tl.y, brush, brush, 'rgb(' + r + ',' + g + ',' + b + ')');
250  			return;
251  		}
    -		// stamp the cached brush sprite into every chunk the brush touches —
    -		// one drawImage per chunk, regardless of brush size.
252  		const sprite = getBrushShape().sprite;
253  		const cx0 = Math.floor(tl.x / CHUNK);
254  		const cy0 = Math.floor(tl.y / CHUNK);

263  	}
264  
265  	function paintLine(x0, y0, x1, y1) {
    -		// Bresenham between logical pixel coords, stamping brush at each step
266  		let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
267  		let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
268  		let err = dx + dy;

294  		r = nr; g = ng; b = nb;
295  		setOutlineTarget((r + g + b > 384) ? 1 : 0);
296  		updateFavicon();
    -		// direct color edits snap the display immediately (no transition)
297  		dispR = r; dispG = g; dispB = b;
298  		colorAnimFromR = r; colorAnimFromG = g; colorAnimFromB = b;
299  		colorAnimToR = r; colorAnimToG = g; colorAnimToB = b;
300  	}
301  
302  	function clientToWorld(clientX, clientY) {
    -		// logical pixel = floor((clientPixel / zoom) + cam)
303  		const lx = Math.floor(clientX / zoom + camX);
304  		const ly = Math.floor(clientY / zoom + camY);
305  		return { x: lx, y: ly };
306  	}
307  
    -	// ------- rendering -------
308  	let drawQueued = false;
309  	function requestDraw() {
310  		if (drawQueued) return;

317  
318  	function draw() {
319  		const now = performance.now();
    -		// tick outline lightness animation
320  		if (outlineLightness !== outlineAnimTo) {
321  			const t = (now - outlineAnimStart) / OUTLINE_ANIM_MS;
322  			if (t >= 1) {

327  				requestDraw();
328  			}
329  		}
    -		// tick displayed cursor color animation
330  		if (dispR !== colorAnimToR || dispG !== colorAnimToG || dispB !== colorAnimToB) {
331  			const t = (now - colorAnimStart) / OUTLINE_ANIM_MS;
332  			if (t >= 1) {

341  		}
342  
343  		const W = view.width, H = view.height;
    -		// clear to black
344  		vctx.setTransform(1, 0, 0, 1, 0, 0);
345  		vctx.imageSmoothingEnabled = false;
346  		vctx.fillStyle = '#000';

353  		// through painted content at high zoom.
354  		const pxD = Math.max(1, Math.round(zoom * dpr));
355  
    -		// visible logical-pixel range
356  		const viewWLog = cssW / zoom;
357  		const viewHLog = cssH / zoom;
358  		const wx0 = camX;

360  		const wx1 = camX + viewWLog;
361  		const wy1 = camY + viewHLog;
362  
    -		// visible chunk range
363  		const cx0 = Math.floor(wx0 / CHUNK);
364  		const cy0 = Math.floor(wy0 / CHUNK);
365  		const cx1 = Math.floor((wx1 - 1e-9) / CHUNK);

385  
386  		if (mouseInside) {
387  			const tl = brushTopLeft(curX, curY);
    -			// round to integer device pixels — camX/camY are fractional (smooth
388 +			// round to integer device pixels - camX/camY are fractional (smooth
389  			// pan), and fractional fillRect coordinates antialias their edges,
390  			// which would leave transparent lines between adjacent row strips.
391  			// during an active pan, anchor the cursor to the real client

447  		}
448  	}
449  
    -	// ------- input -------
450  	window.addEventListener('resize', resize);
451  
452  	window.addEventListener('mouseover', (e) => {

525  			lastPaintX = nx;
526  			lastPaintY = ny;
527  		} else if (picking) {
    -			// shift + click + drag -> live color pick under cursor
528  			pickAt(nx, ny);
529  		}
530  		curX = nx;

591  		else if (k === 'r' || k === 'g' || k === 'b') {
592  			if (!keys['r'] && !keys['g'] && !keys['b']) endDragMode('color');
593  			else {
    -				// still holding at least one rgb key — re-anchor from current
594 +				// still holding at least one rgb key - re-anchor from current
595  				// values so the remaining keys don't jump based on released key's history
596  				dragAnchorY = curClientY !== null ? curClientY : dragAnchorY;
597  				dragRStart = r; dragGStart = g; dragBStart = b;

630  	let panning = false;
631  	let metaSeq = 0, shiftSeq = 0, seqCounter = 0;
632  
    -	// modifier-drag: while shift/cmd is held, vertical pointer motion from the
    -	// anchor point modulates brush size or roundness. anchor is the client y
    -	// where the modifier first went down; snapshot is the value at that moment.
    -	// drag up (y decreases) => increase, drag down => decrease.
    -	const BRUSH_DRAG_PX_PER_STEP = 4;		// device px of drag per ±1 brush px
    -	const ROUNDNESS_DRAG_FULL_PX = 200;		// device px to traverse 0→1
    -	const COLOR_DRAG_FULL_PX = 256;			// device px to traverse 0→255
633 +	const BRUSH_DRAG_PX_PER_STEP = 4;		// device px per +/- 1 brush px
634 +	const ROUNDNESS_DRAG_FULL_PX = 200;		// device px to traverse 0 to 1
635 +	const COLOR_DRAG_FULL_PX = 256;			// device px to traverse 0 to 255
636  	let dragMode = null;		// 'brush' | 'roundness' | 'color' | null
637  	let dragAnchorY = 0;
638  	let dragBrushStart = 1;

643  		wheelIdleTimer = setTimeout(() => {
644  			wheelMode = null;
645  			if (panning) {
    -				// pan ended — resync the logical-pixel cursor from the real
646 +				// pan ended - resync the logical-pixel cursor from the real
647  				// client position so the brush snaps onto its final cell.
648  				panning = false;
649  				if (curClientX !== null) {

688  			// exponential zoom on the float accumulator so small pinches add up
689  			const factor = Math.exp(-e.deltaY * 0.04);
690  			zoomF = clamp(zoomF * factor, MIN_ZOOM, MAX_ZOOM);
    -			// step as soon as zoomF moves meaningfully past the current
    -			// integer, rather than waiting until it crosses x.5. without this
    -			// the first step of a burst feels sluggish because zoomF sits
    -			// exactly on an integer after the previous snap.
691 +			// multiplicative threshold: trigger a step once zoomF has moved
692 +			// the same *ratio* past the current integer in either direction.
693 +			// fixed additive thresholds felt jarring at low zoom because 1 to 2
694 +			// is a 2x jump while 32 to 33 is only ~1.03x - requiring equal pinch
695 +			// effort for a huge perceptual leap. ratio-based thresholds make
696 +			// every step cost roughly the same perceptual work.
697 +			const STEP_RATIO = 1.2;
698  			let newZoom = zoom;
    -			if (zoomF > zoom + 0.15) newZoom = Math.min(MAX_ZOOM, zoom + 1);
    -			else if (zoomF < zoom - 0.15) newZoom = Math.max(MIN_ZOOM, zoom - 1);
699 +			if (zoomF > zoom * STEP_RATIO) newZoom = Math.min(MAX_ZOOM, zoom + 1);
700 +			else if (zoomF < zoom / STEP_RATIO) newZoom = Math.max(MIN_ZOOM, zoom - 1);
701  			if (newZoom !== zoom) {
702  				zoom = newZoom;
703  				zoomF = zoom; // resync so next step needs the same delta

708  			return;
709  		}
710  
    -		// pan — smooth fractional camera. during the pan gesture the draw
711 +		// pan - smooth fractional camera. during the pan gesture the draw
712  		// loop anchors the brush to the real client cursor position so it
713  		// tracks the pointer smoothly; curX/curY get resynced onto the
714  		// logical-pixel grid when the wheel idle timer fires.