multiplicative zoom; clean up comments
Hunter
Friday, April 17th, 2026 at 11:04 AM
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.