commit e82e460539319a0464e78722fe890aa09f0916fe
parent 1cbc776e7398352d9c88c217106882f4c32a9fb6
Author: Hunter
Date:   Fri, 17 Apr 2026 11:04:26 -0400

multiplicative zoom; clean up comments

Diffstat:
Mindex.html | 89++++++++++++++++++++++---------------------------------------------------------
1 file changed, 24 insertions(+), 65 deletions(-)

diff --git a/index.html b/index.html @@ -32,9 +32,8 @@ const view = document.getElementById('view'); const vctx = view.getContext('2d', { alpha: false }); - /* world: chunked tiles of logical pixels */ - const CHUNK = 256; // logical pixels per tile side - const chunks = new Map(); // key "cx,cy" -> { canvas, ctx } + const CHUNK = 256; + const chunks = new Map(); function chunkKey(cx, cy) { return cx + ',' + cy; } @@ -54,7 +53,6 @@ } function paintRect(wx, wy, w, h, color) { - // fill a rect of logical pixels in world coords, writing to chunks const x0 = wx, y0 = wy, x1 = wx + w, y1 = wy + h; const cx0 = Math.floor(x0 / CHUNK); const cy0 = Math.floor(y0 / CHUNK); @@ -85,44 +83,26 @@ return [d[0], d[1], d[2]]; } - // ------- view / camera ------- let dpr = window.devicePixelRatio || 1; let cssW = 0, cssH = 0; - // zoom: CSS pixels per logical pixel. integer >= 1 keeps pixels tangible. // 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; - // camera: top-left of view in world (logical pixel) coords — can be fractional for smooth pan let camX = 0, camY = 0; - // cursor in world coords (logical pixels) 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. + // 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. let curClientX = null, curClientY = null; - // show cursor immediately on load; hidden only after an explicit mouseleave let mouseInside = true; - // color / brush let r = 255, g = 255, b = 255; let brush = 1; - // brush roundness: 0 = square (default), 1 = circle inscribed in the square. - // preserved as a fraction when the brush size changes. let roundness = 0; - // 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. let brushShape = null; let brushShapeKey = ''; function getBrushShape() { @@ -130,9 +110,6 @@ if (brushShapeKey === key && brushShape) return brushShape; const n = brush; 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. const rad = roundness * (n / 2); const rad2 = rad * rad; const lo = rad - 0.5; @@ -163,7 +140,6 @@ } sctx.putImageData(img, 0, 0); - // row-runs over `inside` for the cursor fill const fillRuns = []; for (let dy = 0; dy < n; dy++) { let dx = 0; @@ -177,7 +153,7 @@ } // 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 + // to any inside cell. stored as row-runs in an (n+2)x(n+2) grid with // coordinates offset by -1 so they index directly in shape-local space. const m = n + 2; const ring = new Uint8Array(m * m); @@ -212,9 +188,8 @@ return brushShape; } - // 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 + // outline blend: 0 = dark color (lighten with screen), 1 = light color (darken with multiply). + let outlineLightness = 1; let outlineAnimStart = 0; let outlineAnimFrom = 1; let outlineAnimTo = 1; @@ -227,8 +202,7 @@ requestDraw(); } - // 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. + // displayed cursor fill eases toward r/g/b; painting still uses r/g/b immediately. let dispR = 255, dispG = 255, dispB = 255; let colorAnimStart = 0; let colorAnimFromR = 255, colorAnimFromG = 255, colorAnimFromB = 255; @@ -257,7 +231,6 @@ view.width = Math.floor(cssW * dpr); view.height = Math.floor(cssH * dpr); if (firstResize) { - // default cursor to center of viewport until first real mouse event curX = Math.floor(cssW / (2 * zoom)); curY = Math.floor(cssH / (2 * zoom)); } @@ -276,8 +249,6 @@ paintRect(tl.x, tl.y, brush, brush, 'rgb(' + r + ',' + g + ',' + b + ')'); return; } - // stamp the cached brush sprite into every chunk the brush touches — - // one drawImage per chunk, regardless of brush size. const sprite = getBrushShape().sprite; const cx0 = Math.floor(tl.x / CHUNK); const cy0 = Math.floor(tl.y / CHUNK); @@ -292,7 +263,6 @@ } function paintLine(x0, y0, x1, y1) { - // Bresenham between logical pixel coords, stamping brush at each step let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1; let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1; let err = dx + dy; @@ -324,20 +294,17 @@ r = nr; g = ng; b = nb; setOutlineTarget((r + g + b > 384) ? 1 : 0); updateFavicon(); - // direct color edits snap the display immediately (no transition) dispR = r; dispG = g; dispB = b; colorAnimFromR = r; colorAnimFromG = g; colorAnimFromB = b; colorAnimToR = r; colorAnimToG = g; colorAnimToB = b; } function clientToWorld(clientX, clientY) { - // logical pixel = floor((clientPixel / zoom) + cam) const lx = Math.floor(clientX / zoom + camX); const ly = Math.floor(clientY / zoom + camY); return { x: lx, y: ly }; } - // ------- rendering ------- let drawQueued = false; function requestDraw() { if (drawQueued) return; @@ -350,7 +317,6 @@ function draw() { const now = performance.now(); - // tick outline lightness animation if (outlineLightness !== outlineAnimTo) { const t = (now - outlineAnimStart) / OUTLINE_ANIM_MS; if (t >= 1) { @@ -361,7 +327,6 @@ requestDraw(); } } - // tick displayed cursor color animation if (dispR !== colorAnimToR || dispG !== colorAnimToG || dispB !== colorAnimToB) { const t = (now - colorAnimStart) / OUTLINE_ANIM_MS; if (t >= 1) { @@ -376,7 +341,6 @@ } const W = view.width, H = view.height; - // clear to black vctx.setTransform(1, 0, 0, 1, 0, 0); vctx.imageSmoothingEnabled = false; vctx.fillStyle = '#000'; @@ -389,7 +353,6 @@ // through painted content at high zoom. const pxD = Math.max(1, Math.round(zoom * dpr)); - // visible logical-pixel range const viewWLog = cssW / zoom; const viewHLog = cssH / zoom; const wx0 = camX; @@ -397,7 +360,6 @@ const wx1 = camX + viewWLog; const wy1 = camY + viewHLog; - // visible chunk range const cx0 = Math.floor(wx0 / CHUNK); const cy0 = Math.floor(wy0 / CHUNK); const cx1 = Math.floor((wx1 - 1e-9) / CHUNK); @@ -423,7 +385,7 @@ if (mouseInside) { const tl = brushTopLeft(curX, curY); - // round to integer device pixels — camX/camY are fractional (smooth + // round to integer device pixels - camX/camY are fractional (smooth // pan), and fractional fillRect coordinates antialias their edges, // which would leave transparent lines between adjacent row strips. // during an active pan, anchor the cursor to the real client @@ -485,7 +447,6 @@ } } - // ------- input ------- window.addEventListener('resize', resize); window.addEventListener('mouseover', (e) => { @@ -564,7 +525,6 @@ lastPaintX = nx; lastPaintY = ny; } else if (picking) { - // shift + click + drag -> live color pick under cursor pickAt(nx, ny); } curX = nx; @@ -631,7 +591,7 @@ else if (k === 'r' || k === 'g' || k === 'b') { if (!keys['r'] && !keys['g'] && !keys['b']) endDragMode('color'); else { - // still holding at least one rgb key — re-anchor from current + // still holding at least one rgb key - re-anchor from current // values so the remaining keys don't jump based on released key's history dragAnchorY = curClientY !== null ? curClientY : dragAnchorY; dragRStart = r; dragGStart = g; dragBStart = b; @@ -670,13 +630,9 @@ let panning = false; let metaSeq = 0, shiftSeq = 0, seqCounter = 0; - // 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 + 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 let dragMode = null; // 'brush' | 'roundness' | 'color' | null let dragAnchorY = 0; let dragBrushStart = 1; @@ -687,7 +643,7 @@ wheelIdleTimer = setTimeout(() => { wheelMode = null; if (panning) { - // pan ended — resync the logical-pixel cursor from the real + // pan ended - resync the logical-pixel cursor from the real // client position so the brush snaps onto its final cell. panning = false; if (curClientX !== null) { @@ -732,13 +688,16 @@ // 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); - // 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. + // 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 + 0.15) newZoom = Math.min(MAX_ZOOM, zoom + 1); - else if (zoomF < zoom - 0.15) newZoom = Math.max(MIN_ZOOM, zoom - 1); + 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 @@ -749,7 +708,7 @@ return; } - // pan — smooth fractional camera. during the pan gesture the draw + // pan - smooth fractional camera. during the pan gesture the draw // loop anchors the brush to the real client cursor position so it // tracks the pointer smoothly; curX/curY get resynced onto the // logical-pixel grid when the wheel idle timer fires.