initial commit

Hunter
Wednesday, April 15th, 2026 at 3:19 PM

index.htmlnew

index.html
  1 +<!DOCTYPE html>
  2 +<html lang="en">
  3 +<head>
  4 +<meta charset="UTF-8">
  5 +<title>paint</title>
  6 +<style>
  7 +	html, body {
  8 +		margin: 0;
  9 +		padding: 0;
 10 +		width: 100%;
 11 +		height: 100%;
 12 +		overflow: hidden;
 13 +		background: #000;
 14 +		cursor: none;
 15 +		overscroll-behavior: none;
 16 +	}
 17 +	canvas {
 18 +		display: block;
 19 +		position: absolute;
 20 +		top: 0;
 21 +		left: 0;
 22 +		image-rendering: pixelated;
 23 +		image-rendering: crisp-edges;
 24 +	}
 25 +</style>
 26 +</head>
 27 +<body>
 28 +<canvas id="view"></canvas>
 29 +<script>
 30 +(() => {
 31 +	const view = document.getElementById('view');
 32 +	const vctx = view.getContext('2d', { alpha: false });
 33 +
 34 +	/* world: chunked tiles of logical pixels */
 35 +	const CHUNK = 256; // logical pixels per tile side
 36 +	const chunks = new Map(); // key "cx,cy" -> { canvas, ctx }
 37 +
 38 +	function chunkKey(cx, cy) { return cx + ',' + cy; }
 39 +
 40 +	function getOrCreateChunk(cx, cy) {
 41 +		const k = chunkKey(cx, cy);
 42 +		let c = chunks.get(k);
 43 +		if (c) return c;
 44 +		const cnv = document.createElement('canvas');
 45 +		cnv.width = CHUNK;
 46 +		cnv.height = CHUNK;
 47 +		const cctx = cnv.getContext('2d', { alpha: false });
 48 +		cctx.fillStyle = '#000';
 49 +		cctx.fillRect(0, 0, CHUNK, CHUNK);
 50 +		c = { canvas: cnv, ctx: cctx };
 51 +		chunks.set(k, c);
 52 +		return c;
 53 +	}
 54 +
 55 +	function paintRect(wx, wy, w, h, color) {
 56 +		// fill a rect of logical pixels in world coords, writing to chunks
 57 +		const x0 = wx, y0 = wy, x1 = wx + w, y1 = wy + h;
 58 +		const cx0 = Math.floor(x0 / CHUNK);
 59 +		const cy0 = Math.floor(y0 / CHUNK);
 60 +		const cx1 = Math.floor((x1 - 1) / CHUNK);
 61 +		const cy1 = Math.floor((y1 - 1) / CHUNK);
 62 +		for (let cy = cy0; cy <= cy1; cy++) {
 63 +			for (let cx = cx0; cx <= cx1; cx++) {
 64 +				const c = getOrCreateChunk(cx, cy);
 65 +				const lx = Math.max(x0, cx * CHUNK) - cx * CHUNK;
 66 +				const ly = Math.max(y0, cy * CHUNK) - cy * CHUNK;
 67 +				const rx = Math.min(x1, (cx + 1) * CHUNK) - cx * CHUNK;
 68 +				const ry = Math.min(y1, (cy + 1) * CHUNK) - cy * CHUNK;
 69 +				c.ctx.fillStyle = color;
 70 +				c.ctx.fillRect(lx, ly, rx - lx, ry - ly);
 71 +			}
 72 +		}
 73 +	}
 74 +
 75 +	function readPixel(wx, wy) {
 76 +		const cx = Math.floor(wx / CHUNK);
 77 +		const cy = Math.floor(wy / CHUNK);
 78 +		const k = chunkKey(cx, cy);
 79 +		const c = chunks.get(k);
 80 +		if (!c) return [0, 0, 0];
 81 +		const lx = wx - cx * CHUNK;
 82 +		const ly = wy - cy * CHUNK;
 83 +		const d = c.ctx.getImageData(lx, ly, 1, 1).data;
 84 +		return [d[0], d[1], d[2]];
 85 +	}
 86 +
 87 +	// ------- view / camera -------
 88 +	let dpr = window.devicePixelRatio || 1;
 89 +	let cssW = 0, cssH = 0;
 90 +
 91 +	// zoom: CSS pixels per logical pixel. integer >= 1 keeps pixels tangible.
 92 +	// zoomF accumulates fractional pinch input; zoom is the snapped integer used for rendering.
 93 +	let zoomF = 2;
 94 +	let zoom = 2;
 95 +	const MIN_ZOOM = 1, MAX_ZOOM = 64;
 96 +
 97 +	// camera: top-left of view in world (logical pixel) coords — can be fractional for smooth pan
 98 +	let camX = 0, camY = 0;
 99 +
100 +	// cursor in world coords (logical pixels)
101 +	let curX = 0, curY = 0;
102 +	// show cursor immediately on load; hidden only after an explicit mouseleave
103 +	let mouseInside = true;
104 +
105 +	// color / brush
106 +	let r = 255, g = 255, b = 255;
107 +	let brush = 1;
108 +
109 +	// outline light/dark blend: animates between 0 (dark color -> lighten)
110 +	// and 1 (light color -> darken). cross-fades over 0.25s.
111 +	let outlineLightness = 1; // starts white selected
112 +	let outlineAnimStart = 0;
113 +	let outlineAnimFrom = 1;
114 +	let outlineAnimTo = 1;
115 +	const OUTLINE_ANIM_MS = 250;
116 +	function setOutlineTarget(target) {
117 +		if (target === outlineAnimTo) return;
118 +		outlineAnimFrom = outlineLightness;
119 +		outlineAnimTo = target;
120 +		outlineAnimStart = performance.now();
121 +		requestDraw();
122 +	}
123 +
124 +	// displayed cursor fill color eases toward the real r/g/b over 0.25s.
125 +	// the real r/g/b is used for painting immediately; this is purely visual.
126 +	let dispR = 255, dispG = 255, dispB = 255;
127 +	let colorAnimStart = 0;
128 +	let colorAnimFromR = 255, colorAnimFromG = 255, colorAnimFromB = 255;
129 +	let colorAnimToR = 255, colorAnimToG = 255, colorAnimToB = 255;
130 +	function setDisplayColorTarget(nr, ng, nb) {
131 +		if (nr === colorAnimToR && ng === colorAnimToG && nb === colorAnimToB) return;
132 +		colorAnimFromR = dispR; colorAnimFromG = dispG; colorAnimFromB = dispB;
133 +		colorAnimToR = nr; colorAnimToG = ng; colorAnimToB = nb;
134 +		colorAnimStart = performance.now();
135 +		requestDraw();
136 +	}
137 +
138 +	const keys = {};
139 +	let painting = false;
140 +	let picking = false;
141 +	let lastPaintX = null, lastPaintY = null;
142 +
143 +	function resize() {
144 +		const firstResize = cssW === 0;
145 +		dpr = window.devicePixelRatio || 1;
146 +		cssW = window.innerWidth;
147 +		cssH = window.innerHeight;
148 +		view.style.width = cssW + 'px';
149 +		view.style.height = cssH + 'px';
150 +		view.width = Math.floor(cssW * dpr);
151 +		view.height = Math.floor(cssH * dpr);
152 +		if (firstResize) {
153 +			// default cursor to center of viewport until first real mouse event
154 +			curX = Math.floor(cssW / (2 * zoom));
155 +			curY = Math.floor(cssH / (2 * zoom));
156 +		}
157 +		requestDraw();
158 +	}
159 +
160 +	function brushTopLeft(cx, cy) {
161 +		const off = Math.floor(brush / 2);
162 +		return { x: cx - off, y: cy - off };
163 +	}
164 +
165 +	function paintAt(cx, cy) {
166 +		const tl = brushTopLeft(cx, cy);
167 +		const color = 'rgb(' + r + ',' + g + ',' + b + ')';
168 +		paintRect(tl.x, tl.y, brush, brush, color);
169 +	}
170 +
171 +	function paintLine(x0, y0, x1, y1) {
172 +		// Bresenham between logical pixel coords, stamping brush at each step
173 +		let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
174 +		let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
175 +		let err = dx + dy;
176 +		let x = x0, y = y0;
177 +		while (true) {
178 +			paintAt(x, y);
179 +			if (x === x1 && y === y1) break;
180 +			const e2 = 2 * err;
181 +			if (e2 >= dy) { err += dy; x += sx; }
182 +			if (e2 <= dx) { err += dx; y += sy; }
183 +		}
184 +	}
185 +
186 +	function pickAt(cx, cy) {
187 +		const c = readPixel(cx, cy);
188 +		r = c[0]; g = c[1]; b = c[2];
189 +		setOutlineTarget((r + g + b > 384) ? 1 : 0);
190 +		setDisplayColorTarget(r, g, b);
191 +	}
192 +
193 +	function setColor(nr, ng, nb) {
194 +		r = nr; g = ng; b = nb;
195 +		setOutlineTarget((r + g + b > 384) ? 1 : 0);
196 +		// direct color edits snap the display immediately (no transition)
197 +		dispR = r; dispG = g; dispB = b;
198 +		colorAnimFromR = r; colorAnimFromG = g; colorAnimFromB = b;
199 +		colorAnimToR = r; colorAnimToG = g; colorAnimToB = b;
200 +	}
201 +
202 +	function clientToWorld(clientX, clientY) {
203 +		// logical pixel = floor((clientPixel / zoom) + cam)
204 +		const lx = Math.floor(clientX / zoom + camX);
205 +		const ly = Math.floor(clientY / zoom + camY);
206 +		return { x: lx, y: ly };
207 +	}
208 +
209 +	// ------- rendering -------
210 +	let drawQueued = false;
211 +	function requestDraw() {
212 +		if (drawQueued) return;
213 +		drawQueued = true;
214 +		requestAnimationFrame(() => {
215 +			drawQueued = false;
216 +			draw();
217 +		});
218 +	}
219 +
220 +	function draw() {
221 +		const now = performance.now();
222 +		// tick outline lightness animation
223 +		if (outlineLightness !== outlineAnimTo) {
224 +			const t = (now - outlineAnimStart) / OUTLINE_ANIM_MS;
225 +			if (t >= 1) {
226 +				outlineLightness = outlineAnimTo;
227 +			} else {
228 +				const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
229 +				outlineLightness = outlineAnimFrom + (outlineAnimTo - outlineAnimFrom) * e;
230 +				requestDraw();
231 +			}
232 +		}
233 +		// tick displayed cursor color animation
234 +		if (dispR !== colorAnimToR || dispG !== colorAnimToG || dispB !== colorAnimToB) {
235 +			const t = (now - colorAnimStart) / OUTLINE_ANIM_MS;
236 +			if (t >= 1) {
237 +				dispR = colorAnimToR; dispG = colorAnimToG; dispB = colorAnimToB;
238 +			} else {
239 +				const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
240 +				dispR = Math.round(colorAnimFromR + (colorAnimToR - colorAnimFromR) * e);
241 +				dispG = Math.round(colorAnimFromG + (colorAnimToG - colorAnimFromG) * e);
242 +				dispB = Math.round(colorAnimFromB + (colorAnimToB - colorAnimFromB) * e);
243 +				requestDraw();
244 +			}
245 +		}
246 +
247 +		const W = view.width, H = view.height;
248 +		// clear to black
249 +		vctx.setTransform(1, 0, 0, 1, 0, 0);
250 +		vctx.imageSmoothingEnabled = false;
251 +		vctx.fillStyle = '#000';
252 +		vctx.fillRect(0, 0, W, H);
253 +
254 +		// one logical pixel on screen = zoom CSS px = zoom*dpr device px
255 +		const pxD = zoom * dpr;
256 +
257 +		// visible logical-pixel range
258 +		const viewWLog = cssW / zoom;
259 +		const viewHLog = cssH / zoom;
260 +		const wx0 = camX;
261 +		const wy0 = camY;
262 +		const wx1 = camX + viewWLog;
263 +		const wy1 = camY + viewHLog;
264 +
265 +		// visible chunk range
266 +		const cx0 = Math.floor(wx0 / CHUNK);
267 +		const cy0 = Math.floor(wy0 / CHUNK);
268 +		const cx1 = Math.floor((wx1 - 1e-9) / CHUNK);
269 +		const cy1 = Math.floor((wy1 - 1e-9) / CHUNK);
270 +
271 +		// round destinations to integer device pixels to avoid seams between
272 +		// adjacent chunks. compute right/bottom edges from the neighbor's
273 +		// rounded left/top so shared edges line up exactly.
274 +		const destX = (wx) => Math.round((wx - camX) * pxD);
275 +		const destY = (wy) => Math.round((wy - camY) * pxD);
276 +
277 +		for (let cy = cy0; cy <= cy1; cy++) {
278 +			for (let cx = cx0; cx <= cx1; cx++) {
279 +				const c = chunks.get(chunkKey(cx, cy));
280 +				if (!c) continue;
281 +				const x0 = destX(cx * CHUNK);
282 +				const y0 = destY(cy * CHUNK);
283 +				const x1 = destX((cx + 1) * CHUNK);
284 +				const y1 = destY((cy + 1) * CHUNK);
285 +				vctx.drawImage(c.canvas, x0, y0, x1 - x0, y1 - y0);
286 +			}
287 +		}
288 +
289 +		if (mouseInside) {
290 +			const tl = brushTopLeft(curX, curY);
291 +			const sx = (tl.x - camX) * pxD;
292 +			const sy = (tl.y - camY) * pxD;
293 +			const sSize = brush * pxD;
294 +
295 +			// fill first (uses eased display color so pick transitions smoothly)
296 +			vctx.fillStyle = 'rgb(' + dispR + ',' + dispG + ',' + dispB + ')';
297 +			vctx.fillRect(sx, sy, sSize, sSize);
298 +
299 +			// 1-logical-pixel outline with cross-faded darken/lighten blend.
300 +			// light weight -> multiply (darken); dark weight -> screen (lighten).
301 +			const ow = pxD;
302 +			const drawOutline = () => {
303 +				vctx.fillRect(sx - ow, sy - ow, sSize + 2 * ow, ow); // top
304 +				vctx.fillRect(sx - ow, sy + sSize, sSize + 2 * ow, ow); // bottom
305 +				vctx.fillRect(sx - ow, sy, ow, sSize); // left
306 +				vctx.fillRect(sx + sSize, sy, ow, sSize); // right
307 +			};
308 +			const L = outlineLightness;
309 +			vctx.save();
310 +			if (L > 0) {
311 +				vctx.globalCompositeOperation = 'multiply';
312 +				vctx.globalAlpha = L;
313 +				vctx.fillStyle = 'rgb(128,128,128)';
314 +				drawOutline();
315 +			}
316 +			if (L < 1) {
317 +				vctx.globalCompositeOperation = 'screen';
318 +				vctx.globalAlpha = 1 - L;
319 +				vctx.fillStyle = 'rgb(128,128,128)';
320 +				drawOutline();
321 +			}
322 +			vctx.restore();
323 +		}
324 +	}
325 +
326 +	// ------- input -------
327 +	window.addEventListener('resize', resize);
328 +
329 +	window.addEventListener('mouseover', (e) => {
330 +		mouseInside = true;
331 +		const p = clientToWorld(e.clientX, e.clientY);
332 +		curX = p.x;
333 +		curY = p.y;
334 +		requestDraw();
335 +	});
336 +
337 +	window.addEventListener('mousemove', (e) => {
338 +		mouseInside = true;
339 +		const p = clientToWorld(e.clientX, e.clientY);
340 +		const nx = p.x, ny = p.y;
341 +		if (painting) {
342 +			if (lastPaintX !== null) {
343 +				paintLine(lastPaintX, lastPaintY, nx, ny);
344 +			} else {
345 +				paintAt(nx, ny);
346 +			}
347 +			lastPaintX = nx;
348 +			lastPaintY = ny;
349 +		} else if (picking) {
350 +			// shift + click + drag -> live color pick under cursor
351 +			pickAt(nx, ny);
352 +		}
353 +		curX = nx;
354 +		curY = ny;
355 +		requestDraw();
356 +	});
357 +
358 +	window.addEventListener('mouseleave', () => {
359 +		mouseInside = false;
360 +		requestDraw();
361 +	});
362 +
363 +	window.addEventListener('mousedown', (e) => {
364 +		if (e.button !== 0) return;
365 +		e.preventDefault();
366 +		if (e.shiftKey) {
367 +			picking = true;
368 +			pickAt(curX, curY);
369 +			requestDraw();
370 +			return;
371 +		}
372 +		painting = true;
373 +		lastPaintX = curX;
374 +		lastPaintY = curY;
375 +		paintAt(curX, curY);
376 +		requestDraw();
377 +	});
378 +
379 +	window.addEventListener('mouseup', (e) => {
380 +		if (e.button === 0) {
381 +			painting = false;
382 +			picking = false;
383 +			lastPaintX = null;
384 +			lastPaintY = null;
385 +		}
386 +	});
387 +
388 +	window.addEventListener('keydown', (e) => {
389 +		keys[e.key.toLowerCase()] = true;
390 +	});
391 +	window.addEventListener('keyup', (e) => {
392 +		keys[e.key.toLowerCase()] = false;
393 +		// releasing shift mid-pick-drag -> seamlessly switch to painting
394 +		if (e.key === 'Shift' && picking) {
395 +			picking = false;
396 +			painting = true;
397 +			lastPaintX = curX;
398 +			lastPaintY = curY;
399 +			paintAt(curX, curY);
400 +			requestDraw();
401 +		}
402 +	});
403 +	window.addEventListener('blur', () => {
404 +		for (const k in keys) keys[k] = false;
405 +		painting = false;
406 +		picking = false;
407 +		lastPaintX = null;
408 +		lastPaintY = null;
409 +	});
410 +
411 +	function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
412 +
413 +	// wheel gesture lock: once a wheel gesture starts in a given mode
414 +	// (zoom / brush / color / pan), subsequent events in the same burst
415 +	// stay in that mode until the wheel goes idle. this prevents trackpad
416 +	// momentum from leaking into a different mode if the user releases a
417 +	// modifier key mid-flick.
418 +	let wheelMode = null;
419 +	let wheelIdleTimer = null;
420 +	function touchWheelGesture() {
421 +		if (wheelIdleTimer) clearTimeout(wheelIdleTimer);
422 +		wheelIdleTimer = setTimeout(() => { wheelMode = null; }, 150);
423 +	}
424 +
425 +	window.addEventListener('wheel', (e) => {
426 +		e.preventDefault();
427 +
428 +		// determine the mode this gesture belongs to; lock it for the duration
429 +		if (wheelMode === null) {
430 +			if (e.ctrlKey) wheelMode = 'zoom';
431 +			else if (e.shiftKey) wheelMode = 'brush';
432 +			else if (keys['r'] || keys['g'] || keys['b']) wheelMode = 'color';
433 +			else wheelMode = 'pan';
434 +		}
435 +		touchWheelGesture();
436 +
437 +		if (wheelMode === 'zoom') {
438 +			// zoom around cursor: world point under cursor stays fixed
439 +			const mx = e.clientX, my = e.clientY;
440 +			const worldAtCursorX = camX + mx / zoom;
441 +			const worldAtCursorY = camY + my / zoom;
442 +			// exponential zoom on the float accumulator so small pinches add up
443 +			const factor = Math.exp(-e.deltaY * 0.02);
444 +			zoomF = clamp(zoomF * factor, MIN_ZOOM, MAX_ZOOM);
445 +			const newZoom = Math.round(zoomF);
446 +			if (newZoom !== zoom) {
447 +				zoom = newZoom;
448 +				camX = worldAtCursorX - mx / zoom;
449 +				camY = worldAtCursorY - my / zoom;
450 +			}
451 +			requestDraw();
452 +			return;
453 +		}
454 +
455 +		if (wheelMode === 'brush') {
456 +			// inverted: scroll up = bigger, scroll down = smaller
457 +			const d = e.deltaY !== 0 ? e.deltaY : e.deltaX;
458 +			const dir = d < 0 ? -1 : 1;
459 +			brush = clamp(brush + dir, 1, 256);
460 +			requestDraw();
461 +			return;
462 +		}
463 +
464 +		if (wheelMode === 'color') {
465 +			// inverted: scroll up = less, scroll down = more
466 +			const dir = e.deltaY < 0 ? -1 : 1;
467 +			const step = 8 * dir;
468 +			let nr = r, ng = g, nb = b;
469 +			if (keys['r']) nr = clamp(r + step, 0, 255);
470 +			if (keys['g']) ng = clamp(g + step, 0, 255);
471 +			if (keys['b']) nb = clamp(b + step, 0, 255);
472 +			setColor(nr, ng, nb);
473 +			requestDraw();
474 +			return;
475 +		}
476 +
477 +		// pan — smooth fractional camera
478 +		camX += e.deltaX / zoom;
479 +		camY += e.deltaY / zoom;
480 +		requestDraw();
481 +	}, { passive: false });
482 +
483 +	window.addEventListener('contextmenu', (e) => e.preventDefault());
484 +
485 +	// block the OS pinch gesture events too (Safari)
486 +	window.addEventListener('gesturestart', (e) => e.preventDefault());
487 +	window.addEventListener('gesturechange', (e) => e.preventDefault());
488 +	window.addEventListener('gestureend', (e) => e.preventDefault());
489 +
490 +	resize();
491 +})();
492 +</script>
493 +</body>
494 +</html>