add multiplayer via serve.py

Hunter
Thursday, July 23rd, 2026 at 11:31 PM

.gitignorenew
index.html+9-1393
readme.md+6-3
resources/brush.jsnew
resources/canvas.jsnew
resources/input.jsnew
resources/io.jsnew
resources/net.jsnew
resources/render.jsnew
resources/styles.cssnew
resources/view.jsnew
serve.pynew

.gitignore
1 +__pycache__/
2 +.DS_Store
index.html
 5  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
 6  <title>animus</title>
 7  <link id="favicon" rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect width='1' height='1' fill='white'/%3E%3C/svg%3E">
   -<style>
   -	html, body {
   -		margin: 0;
   -		padding: 0;
   -		width: 100%;
   -		height: 100%;
   -		overflow: hidden;
   -		background: #000;
   -		cursor: none;
   -		overscroll-behavior: none;
   -		touch-action: none;
   -		-webkit-user-select: none;
   -		user-select: none;
   -		-webkit-touch-callout: none;
   -	}
   -	canvas {
   -		display: block;
   -		position: absolute;
   -		top: 0;
   -		left: 0;
   -		image-rendering: pixelated;
   -		image-rendering: crisp-edges;
   -	}
   -</style>
 8 +<link rel="stylesheet" href="resources/styles.css">
 9  </head>
10  <body>
11  <canvas id="view"></canvas>
   -<script>
   -(() => {
   -	const view = document.getElementById('view');
   -	const vctx = view.getContext('2d', { alpha: false });
   -
   -	const CHUNK = 256;
   -	let frames = [new Map()];
   -	let frameIdx = 0;
   -	let chunks = frames[0];
   -	let dir = 1; // playback direction: 1 forward, -1 reverse
   -	let onionskin = false;
   -	let playing = false;
   -	let playTimer = null;
   -	let frameInterval = 250;
   -	let lastTap = 0;
   -
   -	function chunkKey(cx, cy) { return cx + ',' + cy; }
   -
   -	function getOrCreateChunk(cx, cy, map = chunks) {
   -		const k = chunkKey(cx, cy);
   -		let c = map.get(k);
   -		if (c) return c;
   -		const cnv = document.createElement('canvas');
   -		cnv.width = CHUNK;
   -		cnv.height = CHUNK;
   -		const cctx = cnv.getContext('2d', { alpha: false });
   -		cctx.fillStyle = '#000';
   -		cctx.fillRect(0, 0, CHUNK, CHUNK);
   -		c = { canvas: cnv, ctx: cctx };
   -		map.set(k, c);
   -		return c;
   -	}
   -
   -	function paintRect(wx, wy, w, h, color) {
   -		const x0 = wx, y0 = wy, x1 = wx + w, y1 = wy + h;
   -		const cx0 = Math.floor(x0 / CHUNK);
   -		const cy0 = Math.floor(y0 / CHUNK);
   -		const cx1 = Math.floor((x1 - 1) / CHUNK);
   -		const cy1 = Math.floor((y1 - 1) / CHUNK);
   -		for (let cy = cy0; cy <= cy1; cy++) {
   -			for (let cx = cx0; cx <= cx1; cx++) {
   -				const c = getOrCreateChunk(cx, cy);
   -				const lx = Math.max(x0, cx * CHUNK) - cx * CHUNK;
   -				const ly = Math.max(y0, cy * CHUNK) - cy * CHUNK;
   -				const rx = Math.min(x1, (cx + 1) * CHUNK) - cx * CHUNK;
   -				const ry = Math.min(y1, (cy + 1) * CHUNK) - cy * CHUNK;
   -				c.ctx.fillStyle = color;
   -				c.ctx.fillRect(lx, ly, rx - lx, ry - ly);
   -			}
   -		}
   -	}
   -
   -	function readPixel(wx, wy) {
   -		const cx = Math.floor(wx / CHUNK);
   -		const cy = Math.floor(wy / CHUNK);
   -		const k = chunkKey(cx, cy);
   -		const c = chunks.get(k);
   -		if (!c) return [0, 0, 0];
   -		const lx = wx - cx * CHUNK;
   -		const ly = wy - cy * CHUNK;
   -		const d = c.ctx.getImageData(lx, ly, 1, 1).data;
   -		return [d[0], d[1], d[2]];
   -	}
   -
   -	function setFrame(i) {
   -		frameIdx = ((i % frames.length) + frames.length) % frames.length;
   -		chunks = frames[frameIdx];
   -		// a held pick tracks the frame under it as frames change
   -		if (picking) pickAt(curX, curY);
   -		requestDraw();
   -	}
   -
   -	function addFrame() {
   -		const at = dir === 1 ? frameIdx + 1 : frameIdx;
   -		frames.splice(at, 0, new Map());
   -		setFrame(at);
   -	}
   -
   -	function deleteFrame() {
   -		if (frames.length === 1) return;
   -		frames.splice(frameIdx, 1);
   -		if (playing && frames.length === 1) stopPlayback();
   -		setFrame(Math.min(frameIdx, frames.length - 1));
   -	}
   -
   -	function stopPlayback() {
   -		playing = false;
   -		clearTimeout(playTimer);
   -		requestDraw();
   -	}
   -
   -	function startPlayback() {
   -		if (frames.length === 1) return;
   -		playing = true;
   -		playTimer = setTimeout(function step() {
   -			setFrame(frameIdx + dir);
   -			playTimer = setTimeout(step, frameInterval);
   -		}, frameInterval);
   -	}
   -
   -	function tapArrow(d) {
   -		dir = d;
   -		const now = performance.now();
   -		if (lastTap && now - lastTap <= 10000) frameInterval = now - lastTap;
   -		lastTap = now;
   -		if (playing) { stopPlayback(); return; }
   -		setFrame(frameIdx + d);
   -	}
   -
   -	let dpr = window.devicePixelRatio || 1;
   -	let cssW = 0, cssH = 0;
   -
   -	const ZOOM_STOPS = [2, 3, 4, 5, 6, 8, 11, 15, 21, 30];
   -	const MIN_ZOOM = ZOOM_STOPS[0];
   -	const ZOOM_SENSITIVITY = 3.5;	// fractional levels per unit log(pinch factor)
   -	let zoomIdx = 0;
   -	let zoom = MIN_ZOOM;
   -
   -	let camX = 0, camY = 0;
   -
   -	let curX = 0, curY = 0;
   -	// used to keep the brush pinned under the real cursor while panning, since
   -	// the OS does not emit pointermove events when only the camera moves.
   -	let curClientX = null, curClientY = null;
   -	let mouseInside = false;
   -	let cursorAlpha = 0;
   -	let cursorFadeFrom = 0;
   -	let cursorFadeTo = 0;
   -	let cursorFadeStart = 0;
   -	const CURSOR_FADE_IN_MS = 100;
   -	const CURSOR_FADE_OUT_MS = 400;
   -	function setCursorFadeTarget(target) {
   -		if (target === cursorFadeTo) return;
   -		cursorFadeFrom = cursorAlpha;
   -		cursorFadeTo = target;
   -		cursorFadeStart = performance.now();
   -		requestDraw();
   -	}
   -	function showCursor() {
   -		mouseInside = true;
   -		setCursorFadeTarget(1);
   -	}
   -	function hideCursorNow() {
   -		mouseInside = false;
   -		cursorAlpha = 0;
   -		cursorFadeFrom = 0;
   -		cursorFadeTo = 0;
   -	}
   -
   -	let r = 255, g = 255, b = 255;
   -	let brush = 1;
   -	let roundness = 0;
   -
   -	let brushShape = null;
   -	let brushShapeKey = '';
   -	function getBrushShape() {
   -		const key = brush + ',' + roundness + ',' + r + ',' + g + ',' + b;
   -		if (brushShapeKey === key && brushShape) return brushShape;
   -		const n = brush;
   -		const inside = new Uint8Array(n * n);
   -		const rad = roundness * (n / 2);
   -		const rad2 = rad * rad;
   -		const lo = rad - 0.5;
   -		const hi = n - 0.5 - rad;
   -		for (let dy = 0; dy < n; dy++) {
   -			for (let dx = 0; dx < n; dx++) {
   -				let qx = 0, qy = 0;
   -				if (dx < lo) qx = lo - dx;
   -				else if (dx > hi) qx = dx - hi;
   -				if (dy < lo) qy = lo - dy;
   -				else if (dy > hi) qy = dy - hi;
   -				if (qx * qx + qy * qy <= rad2 + 1e-9) inside[dy * n + dx] = 1;
   -			}
   -		}
   -		const sprite = document.createElement('canvas');
   -		sprite.width = n;
   -		sprite.height = n;
   -		const sctx = sprite.getContext('2d');
   -		const img = sctx.createImageData(n, n);
   -		const data = img.data;
   -		for (let i = 0; i < n * n; i++) {
   -			if (inside[i]) {
   -				data[i * 4] = r;
   -				data[i * 4 + 1] = g;
   -				data[i * 4 + 2] = b;
   -				data[i * 4 + 3] = 255;
   -			}
   -		}
   -		sctx.putImageData(img, 0, 0);
   -
   -		const fillRuns = [];
   -		for (let dy = 0; dy < n; dy++) {
   -			let dx = 0;
   -			while (dx < n) {
   -				if (!inside[dy * n + dx]) { dx++; continue; }
   -				let dx1 = dx + 1;
   -				while (dx1 < n && inside[dy * n + dx1]) dx1++;
   -				fillRuns.push(dx, dy, dx1 - dx);
   -				dx = dx1;
   -			}
   -		}
   -
   -		// 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)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);
   -		const isIn = (x, y) => x >= 0 && y >= 0 && x < n && y < n && inside[y * n + x] === 1;
   -		for (let y = -1; y <= n; y++) {
   -			for (let x = -1; x <= n; x++) {
   -				if (isIn(x, y)) continue;
   -				let adj = false;
   -				for (let oy = -1; oy <= 1 && !adj; oy++) {
   -					for (let ox = -1; ox <= 1 && !adj; ox++) {
   -						if (ox === 0 && oy === 0) continue;
   -						if (isIn(x + ox, y + oy)) adj = true;
   -					}
   -				}
   -				if (adj) ring[(y + 1) * m + (x + 1)] = 1;
   -			}
   -		}
   -		const outlineRuns = [];
   -		for (let y = 0; y < m; y++) {
   -			let x = 0;
   -			while (x < m) {
   -				if (!ring[y * m + x]) { x++; continue; }
   -				let x1 = x + 1;
   -				while (x1 < m && ring[y * m + x1]) x1++;
   -				outlineRuns.push(x - 1, y - 1, x1 - x);
   -				x = x1;
   -			}
   -		}
   -
   -		brushShape = { inside, sprite, fillRuns, outlineRuns, n };
   -		brushShapeKey = key;
   -		return brushShape;
   -	}
   -
   -	// 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;
   -	const OUTLINE_ANIM_MS = 250;
   -	function setOutlineTarget(target) {
   -		if (target === outlineAnimTo) return;
   -		outlineAnimFrom = outlineLightness;
   -		outlineAnimTo = target;
   -		outlineAnimStart = performance.now();
   -		requestDraw();
   -	}
   -
   -	// 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;
   -	let colorAnimToR = 255, colorAnimToG = 255, colorAnimToB = 255;
   -	function setDisplayColorTarget(nr, ng, nb) {
   -		if (nr === colorAnimToR && ng === colorAnimToG && nb === colorAnimToB) return;
   -		colorAnimFromR = dispR; colorAnimFromG = dispG; colorAnimFromB = dispB;
   -		colorAnimToR = nr; colorAnimToG = ng; colorAnimToB = nb;
   -		colorAnimStart = performance.now();
   -		requestDraw();
   -	}
   -
   -	const keys = {};
   -	let painting = false;
   -	let picking = false;
   -	let lastPaintX = null, lastPaintY = null;
   -	let dirty = false;
   -
   -	function resize() {
   -		const firstResize = cssW === 0;
   -		dpr = window.devicePixelRatio || 1;
   -		cssW = window.innerWidth;
   -		cssH = window.innerHeight;
   -		view.style.width = cssW + 'px';
   -		view.style.height = cssH + 'px';
   -		view.width = Math.floor(cssW * dpr);
   -		view.height = Math.floor(cssH * dpr);
   -		if (firstResize) {
   -			curX = Math.floor(cssW / (2 * zoom));
   -			curY = Math.floor(cssH / (2 * zoom));
   -		}
   -		requestDraw();
   -	}
   -
   -	function brushTopLeft(cx, cy) {
   -		const off = Math.floor(brush / 2);
   -		return { x: cx - off, y: cy - off };
   -	}
   -
   -	function paintAt(cx, cy) {
   -		dirty = true;
   -		const tl = brushTopLeft(cx, cy);
   -		if (roundness === 0) {
   -			paintRect(tl.x, tl.y, brush, brush, 'rgb(' + r + ',' + g + ',' + b + ')');
   -			return;
   -		}
   -		const sprite = getBrushShape().sprite;
   -		const cx0 = Math.floor(tl.x / CHUNK);
   -		const cy0 = Math.floor(tl.y / CHUNK);
   -		const cx1 = Math.floor((tl.x + brush - 1) / CHUNK);
   -		const cy1 = Math.floor((tl.y + brush - 1) / CHUNK);
   -		for (let ccy = cy0; ccy <= cy1; ccy++) {
   -			for (let ccx = cx0; ccx <= cx1; ccx++) {
   -				const c = getOrCreateChunk(ccx, ccy);
   -				c.ctx.drawImage(sprite, tl.x - ccx * CHUNK, tl.y - ccy * CHUNK);
   -			}
   -		}
   -	}
   -
   -	function paintLine(x0, y0, x1, y1) {
   -		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;
   -		let x = x0, y = y0;
   -		while (true) {
   -			paintAt(x, y);
   -			if (x === x1 && y === y1) break;
   -			const e2 = 2 * err;
   -			if (e2 >= dy) { err += dy; x += sx; }
   -			if (e2 <= dx) { err += dx; y += sy; }
   -		}
   -	}
   -
   -	const faviconEl = document.getElementById('favicon');
   -	function updateFavicon() {
   -		const svg = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'><rect width='1' height='1' fill='rgb(" + r + "," + g + "," + b + ")'/></svg>";
   -		faviconEl.href = 'data:image/svg+xml,' + encodeURIComponent(svg);
   -	}
   -
   -	function pickAt(cx, cy) {
   -		const c = readPixel(cx, cy);
   -		r = c[0]; g = c[1]; b = c[2];
   -		setOutlineTarget((r + g + b > 384) ? 1 : 0);
   -		setDisplayColorTarget(r, g, b);
   -		updateFavicon();
   -	}
   -
   -	function setColor(nr, ng, nb) {
   -		r = nr; g = ng; b = nb;
   -		setOutlineTarget((r + g + b > 384) ? 1 : 0);
   -		updateFavicon();
   -		dispR = r; dispG = g; dispB = b;
   -		colorAnimFromR = r; colorAnimFromG = g; colorAnimFromB = b;
   -		colorAnimToR = r; colorAnimToG = g; colorAnimToB = b;
   -	}
   -
   -	function clientToWorld(clientX, clientY) {
   -		const lx = Math.floor(clientX / zoom + camX);
   -		const ly = Math.floor(clientY / zoom + camY);
   -		return { x: lx, y: ly };
   -	}
   -
   -	let drawQueued = false;
   -	function requestDraw() {
   -		if (drawQueued) return;
   -		drawQueued = true;
   -		requestAnimationFrame(() => {
   -			drawQueued = false;
   -			draw();
   -		});
   -	}
   -
   -	function draw() {
   -		const now = performance.now();
   -		if (outlineLightness !== outlineAnimTo) {
   -			const t = (now - outlineAnimStart) / OUTLINE_ANIM_MS;
   -			if (t >= 1) {
   -				outlineLightness = outlineAnimTo;
   -			} else {
   -				const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
   -				outlineLightness = outlineAnimFrom + (outlineAnimTo - outlineAnimFrom) * e;
   -				requestDraw();
   -			}
   -		}
   -		if (dispR !== colorAnimToR || dispG !== colorAnimToG || dispB !== colorAnimToB) {
   -			const t = (now - colorAnimStart) / OUTLINE_ANIM_MS;
   -			if (t >= 1) {
   -				dispR = colorAnimToR; dispG = colorAnimToG; dispB = colorAnimToB;
   -			} else {
   -				const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
   -				dispR = Math.round(colorAnimFromR + (colorAnimToR - colorAnimFromR) * e);
   -				dispG = Math.round(colorAnimFromG + (colorAnimToG - colorAnimFromG) * e);
   -				dispB = Math.round(colorAnimFromB + (colorAnimToB - colorAnimFromB) * e);
   -				requestDraw();
   -			}
   -		}
   -
   -		const W = view.width, H = view.height;
   -		vctx.setTransform(1, 0, 0, 1, 0, 0);
   -		vctx.imageSmoothingEnabled = false;
   -		vctx.fillStyle = '#000';
   -		vctx.fillRect(0, 0, W, H);
   -
   -		// one logical pixel on screen = integer device px. we round here so
   -		// every logical pixel occupies the exact same number of device pixels:
   -		// otherwise fractional dpr (1.25/1.5/1.75) makes nearest-neighbor
   -		// resampling drop or duplicate rows, producing transparent stripes
   -		// through painted content at high zoom.
   -		const pxD = Math.max(1, Math.round(zoom * dpr));
   -
   -		const viewWLog = cssW / zoom;
   -		const viewHLog = cssH / zoom;
   -		const wx0 = camX;
   -		const wy0 = camY;
   -		const wx1 = camX + viewWLog;
   -		const wy1 = camY + viewHLog;
   -
   -		const cx0 = Math.floor(wx0 / CHUNK);
   -		const cy0 = Math.floor(wy0 / CHUNK);
   -		const cx1 = Math.floor((wx1 - 1e-9) / CHUNK);
   -		const cy1 = Math.floor((wy1 - 1e-9) / CHUNK);
   -
   -		// round destinations to integer device pixels to avoid seams between
   -		// adjacent chunks. compute right/bottom edges from the neighbor's
   -		// rounded left/top so shared edges line up exactly.
   -		const destX = (wx) => Math.round((wx - camX) * pxD);
   -		const destY = (wy) => Math.round((wy - camY) * pxD);
   -
   -		for (let cy = cy0; cy <= cy1; cy++) {
   -			for (let cx = cx0; cx <= cx1; cx++) {
   -				const c = chunks.get(chunkKey(cx, cy));
   -				if (!c) continue;
   -				const x0 = destX(cx * CHUNK);
   -				const y0 = destY(cy * CHUNK);
   -				const x1 = destX((cx + 1) * CHUNK);
   -				const y1 = destY((cy + 1) * CHUNK);
   -				vctx.drawImage(c.canvas, x0, y0, x1 - x0, y1 - y0);
   -			}
   -		}
   -
   -		// onionskin: ghost the frame behind us in playback order, screen-blended
   -		// so black contributes nothing over the opaque chunks
   -		if (onionskin && !playing && frames.length > 1) {
   -			const ghost = frames[(frameIdx - dir + frames.length) % frames.length];
   -			vctx.save();
   -			vctx.globalAlpha = 0.35;
   -			vctx.globalCompositeOperation = 'screen';
   -			for (let cy = cy0; cy <= cy1; cy++) {
   -				for (let cx = cx0; cx <= cx1; cx++) {
   -					const c = ghost.get(chunkKey(cx, cy));
   -					if (!c) continue;
   -					const x0 = destX(cx * CHUNK);
   -					const y0 = destY(cy * CHUNK);
   -					vctx.drawImage(c.canvas, x0, y0, destX((cx + 1) * CHUNK) - x0, destY((cy + 1) * CHUNK) - y0);
   -				}
   -			}
   -			vctx.restore();
   -		}
   -
   -		if (cursorAlpha !== cursorFadeTo) {
   -			const dur = cursorFadeTo > cursorFadeFrom ? CURSOR_FADE_IN_MS : CURSOR_FADE_OUT_MS;
   -			const t = (now - cursorFadeStart) / dur;
   -			if (t >= 1) {
   -				cursorAlpha = cursorFadeTo;
   -				if (cursorFadeTo === 0) mouseInside = false;
   -			} else {
   -				const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
   -				cursorAlpha = cursorFadeFrom + (cursorFadeTo - cursorFadeFrom) * e;
   -				requestDraw();
   -			}
   -		}
   -		if (mouseInside) {
   -			const tl = brushTopLeft(curX, curY);
   -			// 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
   -			// position (rounded only to device pixels) so it tracks smoothly
   -			// instead of jittering as the logical-pixel floor flips back and
   -			// forth under a fractional camera. when the pan ends the cursor
   -			// snaps back to the logical-pixel grid via the idle timer below.
   -			let sx, sy;
   -			if (panning && curClientX !== null) {
   -				const off = Math.floor(brush / 2);
   -				sx = Math.round((curClientX - off * zoom) * dpr);
   -				sy = Math.round((curClientY - off * zoom) * dpr);
   -			} else {
   -				sx = Math.round((tl.x - camX) * pxD);
   -				sy = Math.round((tl.y - camY) * pxD);
   -			}
   -			const n = brush;
   -
   -			vctx.save();
   -			vctx.globalAlpha = cursorAlpha;
   -			vctx.fillStyle = 'rgb(' + dispR + ',' + dispG + ',' + dispB + ')';
   -			if (roundness === 0) {
   -				vctx.fillRect(sx, sy, n * pxD, n * pxD);
   -			} else {
   -				const runs = getBrushShape().fillRuns;
   -				for (let i = 0; i < runs.length; i += 3) {
   -					vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD);
   -				}
   -			}
   -
   -			// 1-logical-pixel outline, cross-faded darken/lighten blend.
   -			const shape = roundness === 0 ? null : getBrushShape();
   -			const drawOutline = () => {
   -				if (shape) {
   -					const runs = shape.outlineRuns;
   -					for (let i = 0; i < runs.length; i += 3) {
   -						vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD);
   -					}
   -				} else {
   -					vctx.fillRect(sx - pxD, sy - pxD, (n + 2) * pxD, pxD); // top
   -					vctx.fillRect(sx - pxD, sy + n * pxD, (n + 2) * pxD, pxD); // bottom
   -					vctx.fillRect(sx - pxD, sy, pxD, n * pxD); // left
   -					vctx.fillRect(sx + n * pxD, sy, pxD, n * pxD); // right
   -				}
   -			};
   -			const L = outlineLightness;
   -			vctx.save();
   -			if (L > 0) {
   -				vctx.globalCompositeOperation = 'multiply';
   -				vctx.globalAlpha = L * cursorAlpha;
   -				vctx.fillStyle = 'rgb(128,128,128)';
   -				drawOutline();
   -			}
   -			if (L < 1) {
   -				vctx.globalCompositeOperation = 'screen';
   -				vctx.globalAlpha = (1 - L) * cursorAlpha;
   -				vctx.fillStyle = 'rgb(128,128,128)';
   -				drawOutline();
   -			}
   -			vctx.restore();
   -			vctx.restore();
   -		}
   -	}
   -
   -	const fileInput = document.createElement('input');
   -	fileInput.type = 'file';
   -	fileInput.accept = 'image/*';
   -	fileInput.style.display = 'none';
   -	document.body.appendChild(fileInput);
   -	fileInput.addEventListener('change', (e) => {
   -		const f = e.target.files && e.target.files[0];
   -		if (f) importFile(f);
   -		fileInput.value = '';
   -	});
   -
   -	function formatTimestamp(d) {
   -		const p = (n) => String(n).padStart(2, '0');
   -		return p(d.getFullYear() % 100) + '\u00b7' + p(d.getMonth() + 1) + '\u00b7' + p(d.getDate()) + '\u00b7' + p(d.getHours()) + '\u00b7' + p(d.getMinutes()) + '\u00b7' + p(d.getSeconds());
   -	}
   -
   -	// union of painted chunk bounds across all frames, so every exported
   -	// frame shares the canvas of the biggest one
   -	function frameBounds() {
   -		let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity;
   -		for (const f of frames) {
   -			for (const k of f.keys()) {
   -				any = true;
   -				const [cx, cy] = k.split(',').map(Number);
   -				if (cx < minCx) minCx = cx;
   -				if (cy < minCy) minCy = cy;
   -				if (cx > maxCx) maxCx = cx;
   -				if (cy > maxCy) maxCy = cy;
   -			}
   -		}
   -		return any ? { minCx, minCy, maxCx, maxCy } : null;
   -	}
   -
   -	function renderFrame(f, b, w, h) {
   -		const out = document.createElement('canvas');
   -		out.width = w;
   -		out.height = h;
   -		const octx = out.getContext('2d');
   -		octx.fillStyle = '#000';
   -		octx.fillRect(0, 0, w, h);
   -		for (const [k, c] of f) {
   -			const [cx, cy] = k.split(',').map(Number);
   -			octx.drawImage(c.canvas, (cx - b.minCx) * CHUNK, (cy - b.minCy) * CHUNK);
   -		}
   -		return octx.getImageData(0, 0, w, h);
   -	}
   -
   -	function lzwEncode(minCode, data, lookup, out) {
   -		const clear = 1 << minCode, eoi = clear + 1;
   -		let codeSize = minCode + 1, next = eoi + 1;
   -		let dict = new Map();
   -		let acc = 0, accBits = 0;
   -		let block = [];
   -		const flushBlock = () => {
   -			if (block.length) { out.push(block.length, ...block); block = []; }
   -		};
   -		const emit = (code) => {
   -			acc |= code << accBits;
   -			accBits += codeSize;
   -			while (accBits >= 8) {
   -				block.push(acc & 255);
   -				acc >>= 8;
   -				accBits -= 8;
   -				if (block.length === 255) flushBlock();
   -			}
   -		};
   -		const idx = (i) => lookup((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]);
   -		emit(clear);
   -		let prev = idx(0);
   -		for (let i = 4; i < data.length; i += 4) {
   -			const k = idx(i);
   -			const key = prev * 256 + k;
   -			if (dict.has(key)) { prev = dict.get(key); continue; }
   -			emit(prev);
   -			if (next === 4096) {
   -				emit(clear);
   -				dict = new Map();
   -				next = eoi + 1;
   -				codeSize = minCode + 1;
   -			} else {
   -				if (next >= (1 << codeSize)) codeSize++;
   -				dict.set(key, next++);
   -			}
   -			prev = k;
   -		}
   -		emit(prev);
   -		emit(eoi);
   -		if (accBits) block.push(acc & 255);
   -		flushBlock();
   -	}
   -
   -	// minimal GIF89a encoder: exact global palette when <=256 colors,
   -	// else uniform 6x6x6 quantization
   -	function encodeGIF(images, w, h, delayMs) {
   -		const colorIdx = new Map();
   -		let over = false;
   -		for (const img of images) {
   -			const d = img.data;
   -			for (let i = 0; i < d.length && !over; i += 4) {
   -				const c = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2];
   -				if (!colorIdx.has(c)) {
   -					if (colorIdx.size === 256) over = true;
   -					else colorIdx.set(c, colorIdx.size);
   -				}
   -			}
   -			if (over) break;
   -		}
   -		if (over) {
   -			colorIdx.clear();
   -			for (let i = 0; i < 216; i++) {
   -				colorIdx.set(((Math.floor(i / 36) * 51) << 16) | ((Math.floor(i / 6) % 6 * 51) << 8) | (i % 6 * 51), i);
   -			}
   -		}
   -		const lookup = (c) => over
   -			? Math.round(((c >> 16) & 255) / 51) * 36 + Math.round(((c >> 8) & 255) / 51) * 6 + Math.round((c & 255) / 51)
   -			: colorIdx.get(c);
   -		let bits = 2;
   -		while ((1 << bits) < colorIdx.size) bits++;
   -		const out = [];
   -		const u16 = (v) => { out.push(v & 255, (v >> 8) & 255); };
   -		out.push(71, 73, 70, 56, 57, 97); // "GIF89a"
   -		u16(w); u16(h);
   -		out.push(0x80 | ((bits - 1) << 4) | (bits - 1), 0, 0);
   -		const pal = [...colorIdx.keys()];
   -		for (let i = 0; i < (1 << bits); i++) {
   -			const c = pal[i] || 0;
   -			out.push((c >> 16) & 255, (c >> 8) & 255, c & 255);
   -		}
   -		const animated = images.length > 1;
   -		if (animated) {
   -			// NETSCAPE2.0 loop forever
   -			out.push(0x21, 0xff, 11, 78, 69, 84, 83, 67, 65, 80, 69, 50, 46, 48, 3, 1, 0, 0, 0);
   -		}
   -		const delay = clamp(Math.round(delayMs / 10), 2, 65535);
   -		for (const img of images) {
   -			if (animated) out.push(0x21, 0xf9, 4, 0, delay & 255, (delay >> 8) & 255, 0, 0);
   -			out.push(0x2c);
   -			u16(0); u16(0); u16(w); u16(h);
   -			out.push(0);
   -			const minCode = Math.max(2, bits);
   -			out.push(minCode);
   -			lzwEncode(minCode, img.data, lookup, out);
   -			out.push(0);
   -		}
   -		out.push(0x3b);
   -		return new Uint8Array(out);
   -	}
   -
   -	function exportGIF() {
   -		const b = frameBounds();
   -		if (!b) return;
   -		const w = (b.maxCx - b.minCx + 1) * CHUNK;
   -		const h = (b.maxCy - b.minCy + 1) * CHUNK;
   -		const imgs = frames.map((f) => renderFrame(f, b, w, h));
   -		if (dir === -1) imgs.reverse();
   -		const blob = new Blob([encodeGIF(imgs, w, h, frameInterval)], { type: 'image/gif' });
   -		const url = URL.createObjectURL(blob);
   -		const a = document.createElement('a');
   -		a.href = url;
   -		a.download = 'animus \u00b7 ' + formatTimestamp(new Date()) + '.gif';
   -		document.body.appendChild(a);
   -		a.click();
   -		document.body.removeChild(a);
   -		URL.revokeObjectURL(url);
   -		dirty = false;
   -	}
   -
   -	function imageToFrame(src, w, h) {
   -		const f = new Map();
   -		const ox = -Math.floor(w / 2);
   -		const oy = -Math.floor(h / 2);
   -		const cx0 = Math.floor(ox / CHUNK);
   -		const cy0 = Math.floor(oy / CHUNK);
   -		const cx1 = Math.floor((ox + w - 1) / CHUNK);
   -		const cy1 = Math.floor((oy + h - 1) / CHUNK);
   -		for (let cy = cy0; cy <= cy1; cy++) {
   -			for (let cx = cx0; cx <= cx1; cx++) {
   -				getOrCreateChunk(cx, cy, f).ctx.drawImage(src, ox - cx * CHUNK, oy - cy * CHUNK);
   -			}
   -		}
   -		return { f, w, h, ox, oy };
   -	}
   -
   -	function finishImport(newFrames, w, h, ox, oy) {
   -		frames = newFrames;
   -		dir = 1;
   -		setFrame(0);
   -		camX = ox - (cssW / zoom - w) / 2;
   -		camY = oy - (cssH / zoom - h) / 2;
   -		dirty = false;
   -		requestDraw();
   -	}
   -
   -	async function importFile(file) {
   -		if (dirty && !confirm('Importing will discard the current ' + (frames.length > 1 ? 'animation' : 'painting') + '. Continue?')) return;
   -		if (playing) stopPlayback();
   -		// ImageDecoder gives us every frame of an animated gif; fall back to
   -		// single-image import where unsupported
   -		if (typeof ImageDecoder !== 'undefined') {
   -			try {
   -				const dec = new ImageDecoder({ data: await file.arrayBuffer(), type: file.type });
   -				await dec.tracks.ready;
   -				const count = dec.tracks.selectedTrack.frameCount;
   -				const newFrames = [];
   -				let r = null;
   -				for (let i = 0; i < count; i++) {
   -					const { image } = await dec.decode({ frameIndex: i });
   -					if (i === 0 && image.duration) frameInterval = Math.max(20, image.duration / 1000);
   -					r = imageToFrame(image, image.displayWidth, image.displayHeight);
   -					newFrames.push(r.f);
   -					image.close();
   -				}
   -				finishImport(newFrames, r.w, r.h, r.ox, r.oy);
   -				return;
   -			} catch (err) {}
   -		}
   -		const img = new Image();
   -		img.onload = () => {
   -			const r = imageToFrame(img, img.width, img.height);
   -			finishImport([r.f], r.w, r.h, r.ox, r.oy);
   -			URL.revokeObjectURL(img.src);
   -		};
   -		img.onerror = () => URL.revokeObjectURL(img.src);
   -		img.src = URL.createObjectURL(file);
   -	}
   -
   -	window.addEventListener('resize', resize);
   -
   -	// mouse and pen share the pointer-event path; finger touches are handled
   -	// by the touch-event path below
   -	function hoverPointer(e) { return e.pointerType === 'mouse' || e.pointerType === 'pen'; }
   -
   -	window.addEventListener('pointerover', (e) => {
   -		if (!hoverPointer(e)) return;
   -		showCursor();
   -		curClientX = e.clientX;
   -		curClientY = e.clientY;
   -		const p = clientToWorld(e.clientX, e.clientY);
   -		curX = p.x;
   -		curY = p.y;
   -		requestDraw();
   -	});
   -
   -	// brush resize curve: linear (fine, precise) up to BRUSH_LINEAR_MAX, then a
   -	// power-law ramp beyond, tuned by BRUSH_ACCEL.
   -	function brushFromDrag(start, dyPx) {
   -		const A = BRUSH_DRAG_PX_PER_STEP, T = BRUSH_LINEAR_MAX, c = BRUSH_ACCEL;
   -		const seam = T * A; // drag distance from size 0 to the seam
   -		const brushToPos = (b) => b <= T ? b * A : seam + (seam / c) * (Math.pow(b / T, c) - 1);
   -		const posToBrush = (p) => p <= seam ? p / A : T * Math.pow(1 + c * (p - seam) / seam, 1 / c);
   -		return posToBrush(brushToPos(start) + dyPx);
   -	}
   -
   -	// modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor
   -	// drives brush size / roundness / color channels. drag up (dy negative)
   -	// -> increase. when a value hits its bound and the drag continues past,
   -	// re-anchor so reversing direction responds immediately. each drag is
   -	// independent so any combination can run at once.
   -	function applyDrags(clientY) {
   -		if (dragBrush) {
   -			const dyPx = (dragBrush.anchorY - clientY) * dpr;
   -			const target = brushFromDrag(dragBrush.start, dyPx);
   -			const clamped = clamp(Math.round(target), 1, MAX_BRUSH);
   -			brush = clamped;
   -			if (target < 1 || target > MAX_BRUSH) {
   -				dragBrush.start = clamped;
   -				dragBrush.anchorY = clientY;
   -			}
   -		}
   -		if (dragRoundness) {
   -			const dyPx = (dragRoundness.anchorY - clientY) * dpr;
   -			const target = dragRoundness.start - dyPx / ROUNDNESS_DRAG_FULL_PX;
   -			const clamped = clamp(target, 0, 1);
   -			roundness = clamped;
   -			if (target !== clamped) {
   -				dragRoundness.start = clamped;
   -				dragRoundness.anchorY = clientY;
   -			}
   -		}
   -		if (dragColor) {
   -			const dyPx = (dragColor.anchorY - clientY) * dpr;
   -			const delta = (dyPx / COLOR_DRAG_FULL_PX) * 255;
   -			let nr = r, ng = g, nb = b;
   -			let overshoot = 0;
   -			if (keys['r']) {
   -				const t = dragColor.r0 + delta;
   -				nr = clamp(t, 0, 255);
   -				if (t !== nr) overshoot = Math.max(overshoot, Math.abs(t - nr));
   -			}
   -			if (keys['g']) {
   -				const t = dragColor.g0 + delta;
   -				ng = clamp(t, 0, 255);
   -				if (t !== ng) overshoot = Math.max(overshoot, Math.abs(t - ng));
   -			}
   -			if (keys['b']) {
   -				const t = dragColor.b0 + delta;
   -				nb = clamp(t, 0, 255);
   -				if (t !== nb) overshoot = Math.max(overshoot, Math.abs(t - nb));
   -			}
   -			setColor(Math.round(nr), Math.round(ng), Math.round(nb));
   -			if (overshoot > 0) {
   -				dragColor.r0 = nr; dragColor.g0 = ng; dragColor.b0 = nb;
   -				dragColor.anchorY = clientY;
   -			}
   -		}
   -	}
   -
   -	window.addEventListener('pointermove', (e) => {
   -		if (!hoverPointer(e)) return;
   -		// a pen only proves it can hover by moving while lifted. without hover
   -		// it gets the finger treatment: modifiers adjust instead of marking
   -		if (e.pointerType === 'pen' && e.buttons === 0) penHover = true;
   -		showCursor();
   -		curClientX = e.clientX;
   -		curClientY = e.clientY;
   -		const p = clientToWorld(e.clientX, e.clientY);
   -		const nx = p.x, ny = p.y;
   -
   -		applyDrags(e.clientY);
   -
   -		if (painting) {
   -			if (lastPaintX !== null) {
   -				paintLine(lastPaintX, lastPaintY, nx, ny);
   -			} else {
   -				paintAt(nx, ny);
   -			}
   -			lastPaintX = nx;
   -			lastPaintY = ny;
   -		} else if (picking) {
   -			pickAt(nx, ny);
   -		}
   -		curX = nx;
   -		curY = ny;
   -		requestDraw();
   -	});
   -
   -	window.addEventListener('pointerleave', (e) => {
   -		if (!hoverPointer(e)) return;
   -		hideCursorNow();
   -		requestDraw();
   -	});
   -
   -	// a pen without hover support sends no move events before contact, so the
   -	// down event must place the cursor itself
   -	let penDown = false;
   -	let penHover = false;
   -	window.addEventListener('pointerdown', (e) => {
   -		if (!hoverPointer(e)) return;
   -		if (e.button !== 0) return;
   -		e.preventDefault();
   -		stopInertia();
   -		if (e.pointerType === 'pen') penDown = true;
   -		showCursor();
   -		curClientX = e.clientX;
   -		curClientY = e.clientY;
   -		const p = clientToWorld(e.clientX, e.clientY);
   -		curX = p.x;
   -		curY = p.y;
   -		// hoverless pen + modifier held -> the contact adjusts, like a finger
   -		if (e.pointerType === 'pen' && !penHover && !keys['c'] && (dragBrush || dragRoundness || dragColor)) {
   -			anchorDrags(e.clientY);
   -			requestDraw();
   -			return;
   -		}
   -		if (keys['c']) {
   -			picking = true;
   -			pickAt(curX, curY);
   -			requestDraw();
   -			return;
   -		}
   -		painting = true;
   -		lastPaintX = curX;
   -		lastPaintY = curY;
   -		paintAt(curX, curY);
   -		requestDraw();
   -	});
   -
   -	function pointerUp(e) {
   -		if (!hoverPointer(e)) return;
   -		if (e.button === 0 || e.type === 'pointercancel') {
   -			painting = false;
   -			picking = false;
   -			lastPaintX = null;
   -			lastPaintY = null;
   -			if (e.pointerType === 'pen') {
   -				penDown = false;
   -				// hoverless pens get no more events after lift, so fade like touch
   -				if (mouseInside) setCursorFadeTarget(0);
   -				requestDraw();
   -			}
   -		}
   -	}
   -	window.addEventListener('pointerup', pointerUp);
   -	window.addEventListener('pointercancel', pointerUp);
   -
   -	// touch: one finger paints, two fingers pan/pinch-zoom. once a second
   -	// finger lands the whole touch becomes a gesture (no painting) until all
   -	// fingers lift, so a stray finger during a pan never leaves a mark.
   -	// stylus touches are excluded here
   -	let touchMode = null; // 'paint' | 'adjust' | 'gesture'
   -	let touchPainted = false;
   -	let pinchDist = 0, pinchMidX = 0, pinchMidY = 0;
   -
   -	// inertial pan: centroid velocity (css px/ms) tracked during the gesture,
   -	// thrown on release and decayed with exponential friction
   -	let panVX = 0, panVY = 0;
   -	let panLastT = 0;
   -	let inertiaRAF = null;
   -	function stopInertia() {
   -		if (inertiaRAF !== null) {
   -			cancelAnimationFrame(inertiaRAF);
   -			inertiaRAF = null;
   -		}
   -	}
   -	function startInertia() {
   -		// no throw if the finger paused before lifting or was barely moving
   -		const speed = Math.hypot(panVX, panVY);
   -		if (performance.now() - panLastT > 100 || speed < 0.05) return;
   -		// exponential decay per ms
   -		const DECEL = 0.995;
   -		const MAX_SPEED = 3; // css px/ms
   -		if (speed > MAX_SPEED) {
   -			panVX *= MAX_SPEED / speed;
   -			panVY *= MAX_SPEED / speed;
   -		}
   -		let prev = performance.now();
   -		const step = (now) => {
   -			const dt = now - prev;
   -			prev = now;
   -			camX -= panVX * dt / zoom;
   -			camY -= panVY * dt / zoom;
   -			const f = Math.pow(DECEL, dt);
   -			panVX *= f;
   -			panVY *= f;
   -			requestDraw();
   -			inertiaRAF = Math.hypot(panVX, panVY) > 0.02 ? requestAnimationFrame(step) : null;
   -		};
   -		inertiaRAF = requestAnimationFrame(step);
   -	}
   -
   -	function touchCentroid(touches) {
   -		let x = 0, y = 0;
   -		for (const t of touches) { x += t.clientX; y += t.clientY; }
   -		return { x: x / touches.length, y: y / touches.length };
   -	}
   -
   -	function fingerTouches(list) {
   -		const out = [];
   -		for (const t of list) if (t.touchType !== 'stylus') out.push(t);
   -		return out;
   -	}
   -
   -	// re-anchor active drags to a new y and re-baseline to the current values
   -	// so successive drag strokes accumulate instead of snapping back
   -	function anchorDrags(clientY) {
   -		if (dragBrush) { dragBrush.anchorY = clientY; dragBrush.start = brush; }
   -		if (dragRoundness) { dragRoundness.anchorY = clientY; dragRoundness.start = roundness; }
   -		if (dragColor) { dragColor.anchorY = clientY; dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; }
   -	}
   -
   -	function anchorGesture(touches) {
   -		const m = touchCentroid(touches);
   -		pinchMidX = m.x;
   -		pinchMidY = m.y;
   -		pinchDist = touches.length >= 2
   -			? Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY)
   -			: 0;
   -	}
   -
   -	window.addEventListener('touchstart', (e) => {
   -		e.preventDefault();
   -		const fingers = fingerTouches(e.touches);
   -		// fingers are ignored while the pen is down so a resting hand can't
   -		// hijack or extend the pen's stroke
   -		if (fingers.length === 0 || penDown) return;
   -		stopInertia();
   -		if (fingers.length === 1 && touchMode === null) {
   -			const t = fingers[0];
   -			curClientX = t.clientX;
   -			curClientY = t.clientY;
   -			if (!keys['c'] && (dragBrush || dragRoundness || dragColor)) {
   -				touchMode = 'adjust';
   -				const p = clientToWorld(t.clientX, t.clientY);
   -				curX = p.x;
   -				curY = p.y;
   -				showCursor();
   -				anchorDrags(t.clientY);
   -			} else {
   -				const p = clientToWorld(t.clientX, t.clientY);
   -				curX = p.x;
   -				curY = p.y;
   -				showCursor();
   -				touchMode = 'paint';
   -				touchPainted = false;
   -				if (keys['c']) {
   -					picking = true;
   -				} else {
   -					painting = true;
   -					lastPaintX = curX;
   -					lastPaintY = curY;
   -				}
   -			}
   -		} else {
   -			touchMode = 'gesture';
   -			painting = false;
   -			picking = false;
   -			lastPaintX = null;
   -			lastPaintY = null;
   -			hideCursorNow();
   -			panVX = 0;
   -			panVY = 0;
   -			panLastT = 0;
   -			anchorGesture(fingers);
   -		}
   -		requestDraw();
   -	}, { passive: false });
   -
   -	window.addEventListener('touchmove', (e) => {
   -		e.preventDefault();
   -		const fingers = fingerTouches(e.touches);
   -		if (fingers.length === 0) return;
   -		if (touchMode === 'paint') {
   -			const t = fingers[0];
   -			curClientX = t.clientX;
   -			curClientY = t.clientY;
   -			// on hover devices held keys adjust during the stroke, like mouse
   -			applyDrags(t.clientY);
   -			const p = clientToWorld(t.clientX, t.clientY);
   -			if (picking) {
   -				pickAt(p.x, p.y);
   -			} else if (painting) {
   -				paintLine(lastPaintX, lastPaintY, p.x, p.y);
   -				lastPaintX = p.x;
   -				lastPaintY = p.y;
   -			}
   -			touchPainted = true;
   -			curX = p.x;
   -			curY = p.y;
   -		} else if (touchMode === 'adjust') {
   -			const t = fingers[0];
   -			curClientX = t.clientX;
   -			curClientY = t.clientY;
   -			applyDrags(t.clientY);
   -			const p = clientToWorld(t.clientX, t.clientY);
   -			curX = p.x;
   -			curY = p.y;
   -		} else if (touchMode === 'gesture') {
   -			const m = touchCentroid(fingers);
   -			const dxs = m.x - pinchMidX;
   -			const dys = m.y - pinchMidY;
   -			camX -= dxs / zoom;
   -			camY -= dys / zoom;
   -			const nowT = performance.now();
   -			const dt = nowT - panLastT;
   -			if (dt > 0 && dt < 100) {
   -				panVX = panVX * 0.5 + (dxs / dt) * 0.5;
   -				panVY = panVY * 0.5 + (dys / dt) * 0.5;
   -			}
   -			panLastT = nowT;
   -			if (fingers.length >= 2) {
   -				const dist = Math.hypot(fingers[0].clientX - fingers[1].clientX, fingers[0].clientY - fingers[1].clientY);
   -				if (pinchDist > 0) applyZoom(dist / pinchDist, m.x, m.y);
   -				pinchDist = dist;
   -			}
   -			pinchMidX = m.x;
   -			pinchMidY = m.y;
   -		} else {
   -			return;
   -		}
   -		requestDraw();
   -	}, { passive: false });
   -
   -	function touchEnd(e) {
   -		e.preventDefault();
   -		if (touchMode === null) return;
   -		const fingers = fingerTouches(e.touches);
   -		if (fingers.length === 0) {
   -			// a tap that never moved still paints its dot (or picks its point)
   -			if (touchMode === 'paint' && !touchPainted) {
   -				if (picking) pickAt(curX, curY);
   -				else paintAt(curX, curY);
   -			}
   -			if (touchMode === 'gesture') startInertia();
   -			touchMode = null;
   -			painting = false;
   -			picking = false;
   -			lastPaintX = null;
   -			lastPaintY = null;
   -			// fade the preview out instead of hiding it instantly
   -			if (mouseInside) setCursorFadeTarget(0);
   -			requestDraw();
   -		} else if (touchMode === 'gesture') {
   -			// re-anchor to the remaining fingers so the camera doesn't jump
   -			anchorGesture(fingers);
   -		}
   -	}
   -	window.addEventListener('touchend', touchEnd, { passive: false });
   -	window.addEventListener('touchcancel', touchEnd, { passive: false });
   -
   -	function startDragMode(mode) {
   -		const anchorY = curClientY !== null ? curClientY : 0;
   -		if (mode === 'brush' && !dragBrush) dragBrush = { anchorY, start: brush };
   -		else if (mode === 'roundness' && !dragRoundness) dragRoundness = { anchorY, start: roundness };
   -		else if (mode === 'color' && !dragColor) dragColor = { anchorY, r0: r, g0: g, b0: b };
   -		// hoverless input: a modifier pressed mid-stroke ends the stroke and
   -		// the rest of the contact becomes an adjust drag. hover-capable pens
   -		// keep drawing (they can adjust while lifted instead)
   -		if (touchMode === 'paint' || (penDown && !penHover)) {
   -			if (touchMode === 'paint') touchMode = 'adjust';
   -			painting = false;
   -			picking = false;
   -			lastPaintX = null;
   -			lastPaintY = null;
   -		}
   -	}
   -	function endDragMode(mode) {
   -		if (mode === 'brush') dragBrush = null;
   -		else if (mode === 'roundness') dragRoundness = null;
   -		else if (mode === 'color') dragColor = null;
   -	}
   -
   -	window.addEventListener('keydown', (e) => {
   -		if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 's') {
   -			e.preventDefault();
   -			exportGIF();
   -			return;
   -		}
   -		if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'o') {
   -			e.preventDefault();
   -			fileInput.click();
   -			return;
   -		}
   -		if ((e.metaKey || e.ctrlKey) && !e.altKey) {
   -			if (e.key === '=' || e.key === '+') { e.preventDefault(); stepZoom(1); return; }
   -			if (e.key === '-' || e.key === '_') { e.preventDefault(); stepZoom(-1); return; }
   -			if (e.key === '0') { e.preventDefault(); resetZoom(); return; }
   -		}
   -		if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat) {
   -			const lk = e.key.toLowerCase();
   -			if (e.key === 'ArrowLeft') { e.preventDefault(); tapArrow(-1); return; }
   -			if (e.key === 'ArrowRight') { e.preventDefault(); tapArrow(1); return; }
   -			if (e.key === ' ') { e.preventDefault(); playing ? stopPlayback() : startPlayback(); return; }
   -			if (lk === 'a') { addFrame(); return; }
   -			if (lk === 'd') { deleteFrame(); return; }
   -			if (lk === 'o') {
   -				if (playing) { stopPlayback(); onionskin = true; }
   -				else onionskin = !onionskin;
   -				requestDraw();
   -				return;
   -			}
   -		}
   -		const k = e.key.toLowerCase();
   -		const wasDown = keys[k];
   -		keys[k] = true;
   -		if (!e.metaKey && !e.ctrlKey) {
   -			if (k === 'z' || k === 'x' || k === 'c' || k === 'r' || k === 'g' || k === 'b') e.preventDefault();
   -			if (k === 'x') startDragMode('roundness');
   -			else if (k === 'z') startDragMode('brush');
   -			else if (!wasDown && (k === 'r' || k === 'g' || k === 'b')) startDragMode('color');
   -			else if (k === 'c' && !wasDown && (touchMode === 'paint' || touchMode === 'adjust')) {
   -				touchMode = 'paint';
   -				painting = false;
   -				picking = true;
   -				lastPaintX = null;
   -				lastPaintY = null;
   -				touchPainted = true;
   -				pickAt(curX, curY);
   -				requestDraw();
   -			}
   -		}
   -	});
   -	window.addEventListener('keyup', (e) => {
   -		const k = e.key.toLowerCase();
   -		keys[k] = false;
   -		if (k === 'x') endDragMode('roundness');
   -		else if (k === 'z') endDragMode('brush');
   -		else if (k === 'r' || k === 'g' || k === 'b') {
   -			if (!keys['r'] && !keys['g'] && !keys['b']) endDragMode('color');
   -			else if (dragColor) {
   -				// 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
   -				dragColor.anchorY = curClientY !== null ? curClientY : dragColor.anchorY;
   -				dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b;
   -			}
   -		}
   -		// releasing c mid-pick-drag -> back to adjusting if a modifier is still
   -		// held on a hoverless contact, else seamlessly switch to painting
   -		if (k === 'c' && picking) {
   -			picking = false;
   -			const hoverless = touchMode !== null || (penDown && !penHover);
   -			if (hoverless && (dragBrush || dragRoundness || dragColor)) {
   -				if (touchMode !== null) touchMode = 'adjust';
   -				painting = false;
   -				if (curClientY !== null) anchorDrags(curClientY);
   -			} else {
   -				painting = true;
   -				lastPaintX = curX;
   -				lastPaintY = curY;
   -				paintAt(curX, curY);
   -			}
   -			requestDraw();
   -		}
   -	});
   -	window.addEventListener('blur', () => {
   -		for (const k in keys) keys[k] = false;
   -		dragBrush = null; dragRoundness = null; dragColor = null;
   -		painting = false;
   -		picking = false;
   -		penDown = false;
   -		lastPaintX = null;
   -		lastPaintY = null;
   -	});
   -
   -	function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
   -
   -	function applyZoom(factor, ax, ay) {
   -		const worldAtAnchorX = camX + ax / zoom;
   -		const worldAtAnchorY = camY + ay / zoom;
   -		zoomIdx = clamp(zoomIdx + Math.log(factor) * ZOOM_SENSITIVITY, 0, ZOOM_STOPS.length - 1);
   -		const newZoom = ZOOM_STOPS[Math.round(zoomIdx)];
   -		if (newZoom !== zoom) {
   -			zoom = newZoom;
   -			camX = worldAtAnchorX - ax / zoom;
   -			camY = worldAtAnchorY - ay / zoom;
   -		}
   -	}
   -
   -	function zoomTo(newZoom) {
   -		if (newZoom === zoom) return;
   -		const ax = cssW / 2, ay = cssH / 2;
   -		const worldAtAnchorX = camX + ax / zoom;
   -		const worldAtAnchorY = camY + ay / zoom;
   -		zoom = newZoom;
   -		zoomIdx = ZOOM_STOPS.indexOf(zoom);
   -		camX = worldAtAnchorX - ax / zoom;
   -		camY = worldAtAnchorY - ay / zoom;
   -		requestDraw();
   -	}
   -
   -	function stepZoom(dir) {
   -		const i = ZOOM_STOPS.indexOf(zoom);
   -		zoomTo(ZOOM_STOPS[clamp(i + dir, 0, ZOOM_STOPS.length - 1)]);
   -	}
   -	function resetZoom() { zoomTo(MIN_ZOOM); }
   -
   -	// wheel gesture lock: once a wheel gesture starts in a given mode,
   -	// subsequent events in the same burst stay in that mode until the wheel
   -	// goes idle or the controlling modifier set changes. this keeps trackpad
   -	// momentum from leaking into pan after a modifier release, while still
   -	// letting the user swap between modes mid-flick without stale state.
   -	let wheelMode = null;
   -	let wheelIdleTimer = null;
   -	let panning = false;
   -
   -	const BRUSH_DRAG_PX_PER_STEP = 25;	// device px per +/- 1 brush px (below the seam)
   -	const BRUSH_LINEAR_MAX = 6;			// brush px below which resize stays linear/fine
   -	const BRUSH_ACCEL = 0.5;
   -	const MAX_BRUSH = 150;
   -	const ROUNDNESS_DRAG_FULL_PX = 200;
   -	const COLOR_DRAG_FULL_PX = 256;
   -	// each drag carries its own anchor so any combination can run at once
   -	let dragBrush = null;		// {anchorY, start}
   -	let dragRoundness = null;	// {anchorY, start}
   -	let dragColor = null;		// {anchorY, r0, g0, b0}
   -	function touchWheelGesture() {
   -		if (wheelIdleTimer) clearTimeout(wheelIdleTimer);
   -		wheelIdleTimer = setTimeout(() => {
   -			wheelMode = null;
   -			if (panning) {
   -				// 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) {
   -					const p = clientToWorld(curClientX, curClientY);
   -					curX = p.x;
   -					curY = p.y;
   -				}
   -				requestDraw();
   -			}
   -		}, 150);
   -	}
   -	function pickModifierMode(e) {
   -		if (e.ctrlKey) return 'zoom';
   -		return null;
   -	}
   -
   -	window.addEventListener('wheel', (e) => {
   -		e.preventDefault();
   -		stopInertia();
   -
   -		// re-pick mode each event from live modifiers so releasing cmd and
   -		// immediately starting a shift scroll switches over cleanly. if a
   -		// burst started with a modifier and the modifier is then released
   -		// mid-flick, suppress rather than leaking into pan.
   -		const modMode = pickModifierMode(e);
   -		if (modMode !== null) {
   -			wheelMode = modMode;
   -		} else if (wheelMode === null) {
   -			wheelMode = 'pan';
   -		} else if (wheelMode !== 'pan') {
   -			// modifier released mid-burst: drop the remaining momentum
   -			// instead of letting it leak into pan.
   -			touchWheelGesture();
   -			return;
   -		}
   -		touchWheelGesture();
   -
   -		if (wheelMode === 'zoom') {
   -			// exponential zoom on the float accumulator so small pinches add up
   -			applyZoom(Math.exp(-e.deltaY * 0.02), e.clientX, e.clientY);
   -			requestDraw();
   -			return;
   -		}
   -
   -		// 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.
   -		panning = true;
   -		camX += e.deltaX / zoom;
   -		camY += e.deltaY / zoom;
   -		requestDraw();
   -	}, { passive: false });
   -
   -	window.addEventListener('beforeunload', (e) => {
   -		if (dirty) { e.preventDefault(); }
   -	});
   -
   -	window.addEventListener('contextmenu', (e) => e.preventDefault());
   -
   -	// block the OS pinch gesture events too (Safari)
   -	window.addEventListener('gesturestart', (e) => e.preventDefault());
   -	window.addEventListener('gesturechange', (e) => e.preventDefault());
   -	window.addEventListener('gestureend', (e) => e.preventDefault());
   -
   -	resize();
   -})();
   -</script>
12 +<script src="resources/canvas.js"></script>
13 +<script src="resources/brush.js"></script>
14 +<script src="resources/view.js"></script>
15 +<script src="resources/render.js"></script>
16 +<script src="resources/io.js"></script>
17 +<script src="resources/input.js"></script>
18 +<script src="resources/net.js"></script>
19 +<script>resize(); netConnect();</script>
20  </body>
21  </html>
readme.md
 2  
 3  an infinite canvas for radical digital painting.
 4  
   -<!-- > inspired by KidPix, MS Paint, Mario Paint, and Flipnote Studio. -->
 5 +<!-- > inspired by Kid Pix, Mario Paint, Flipnote Studio, and iScribble. -->
 6  
 7  <p align="center">
 8  	<img src="readme_images/doodle.gif" width=300>

42  
43  ### importing / exporting
44  - hold `⌘` (or `ctrl`) and press `S` to save your painting as a .GIF file
   -- hold `⌘` (or `ctrl`) and press `O` to open an existing painting from your filesystem (will clear the current canvas)
45 +- hold `⌘` (or `ctrl`) and press `O` to open an existing painting from your filesystem (will clear the current canvas)
46 +
47 +### multiplayer
48 +run `./serve.py` and share the printed URL with anyone on your local network.
resources/brush.js
  1 +let r = 255, g = 255, b = 255;
  2 +let brush = 1;
  3 +let roundness = 0;
  4 +
  5 +// keyed cache: the local brush and every peer's brush pull from the same one
  6 +const brushShapes = new Map();
  7 +const BRUSH_CACHE_MAX = 64;
  8 +function getBrushShape() { return brushShapeFor(brush, roundness, r, g, b); }
  9 +function brushShapeFor(n, round, sr, sg, sb) {
 10 +	const key = n + ',' + round + ',' + sr + ',' + sg + ',' + sb;
 11 +	const hit = brushShapes.get(key);
 12 +	if (hit) return hit;
 13 +	if (brushShapes.size >= BRUSH_CACHE_MAX) brushShapes.clear();
 14 +	const inside = new Uint8Array(n * n);
 15 +	const rad = round * (n / 2);
 16 +	const rad2 = rad * rad;
 17 +	const lo = rad - 0.5;
 18 +	const hi = n - 0.5 - rad;
 19 +	for (let dy = 0; dy < n; dy++) {
 20 +		for (let dx = 0; dx < n; dx++) {
 21 +			let qx = 0, qy = 0;
 22 +			if (dx < lo) qx = lo - dx;
 23 +			else if (dx > hi) qx = dx - hi;
 24 +			if (dy < lo) qy = lo - dy;
 25 +			else if (dy > hi) qy = dy - hi;
 26 +			if (qx * qx + qy * qy <= rad2 + 1e-9) inside[dy * n + dx] = 1;
 27 +		}
 28 +	}
 29 +	const sprite = document.createElement('canvas');
 30 +	sprite.width = n;
 31 +	sprite.height = n;
 32 +	const sctx = sprite.getContext('2d');
 33 +	const img = sctx.createImageData(n, n);
 34 +	const data = img.data;
 35 +	for (let i = 0; i < n * n; i++) {
 36 +		if (inside[i]) {
 37 +			data[i * 4] = sr;
 38 +			data[i * 4 + 1] = sg;
 39 +			data[i * 4 + 2] = sb;
 40 +			data[i * 4 + 3] = 255;
 41 +		}
 42 +	}
 43 +	sctx.putImageData(img, 0, 0);
 44 +
 45 +	const fillRuns = [];
 46 +	for (let dy = 0; dy < n; dy++) {
 47 +		let dx = 0;
 48 +		while (dx < n) {
 49 +			if (!inside[dy * n + dx]) { dx++; continue; }
 50 +			let dx1 = dx + 1;
 51 +			while (dx1 < n && inside[dy * n + dx1]) dx1++;
 52 +			fillRuns.push(dx, dy, dx1 - dx);
 53 +			dx = dx1;
 54 +		}
 55 +	}
 56 +
 57 +	// 1-pixel outline ring: cells outside the shape that are 8-way adjacent
 58 +	// to any inside cell. stored as row-runs in an (n+2)x(n+2) grid with
 59 +	// coordinates offset by -1 so they index directly in shape-local space.
 60 +	const m = n + 2;
 61 +	const ring = new Uint8Array(m * m);
 62 +	const isIn = (x, y) => x >= 0 && y >= 0 && x < n && y < n && inside[y * n + x] === 1;
 63 +	for (let y = -1; y <= n; y++) {
 64 +		for (let x = -1; x <= n; x++) {
 65 +			if (isIn(x, y)) continue;
 66 +			let adj = false;
 67 +			for (let oy = -1; oy <= 1 && !adj; oy++) {
 68 +				for (let ox = -1; ox <= 1 && !adj; ox++) {
 69 +					if (ox === 0 && oy === 0) continue;
 70 +					if (isIn(x + ox, y + oy)) adj = true;
 71 +				}
 72 +			}
 73 +			if (adj) ring[(y + 1) * m + (x + 1)] = 1;
 74 +		}
 75 +	}
 76 +	const outlineRuns = [];
 77 +	for (let y = 0; y < m; y++) {
 78 +		let x = 0;
 79 +		while (x < m) {
 80 +			if (!ring[y * m + x]) { x++; continue; }
 81 +			let x1 = x + 1;
 82 +			while (x1 < m && ring[y * m + x1]) x1++;
 83 +			outlineRuns.push(x - 1, y - 1, x1 - x);
 84 +			x = x1;
 85 +		}
 86 +	}
 87 +
 88 +	const shape = { inside, sprite, fillRuns, outlineRuns, n };
 89 +	brushShapes.set(key, shape);
 90 +	return shape;
 91 +}
 92 +
 93 +// outline blend: 0 = dark color (lighten with screen), 1 = light color (darken with multiply).
 94 +let outlineLightness = 1;
 95 +let outlineAnimStart = 0;
 96 +let outlineAnimFrom = 1;
 97 +let outlineAnimTo = 1;
 98 +const OUTLINE_ANIM_MS = 250;
 99 +function setOutlineTarget(target) {
100 +	if (target === outlineAnimTo) return;
101 +	outlineAnimFrom = outlineLightness;
102 +	outlineAnimTo = target;
103 +	outlineAnimStart = performance.now();
104 +	requestDraw();
105 +}
106 +
107 +// displayed cursor fill eases toward r/g/b; painting still uses r/g/b immediately.
108 +let dispR = 255, dispG = 255, dispB = 255;
109 +let colorAnimStart = 0;
110 +let colorAnimFromR = 255, colorAnimFromG = 255, colorAnimFromB = 255;
111 +let colorAnimToR = 255, colorAnimToG = 255, colorAnimToB = 255;
112 +function setDisplayColorTarget(nr, ng, nb) {
113 +	if (nr === colorAnimToR && ng === colorAnimToG && nb === colorAnimToB) return;
114 +	colorAnimFromR = dispR; colorAnimFromG = dispG; colorAnimFromB = dispB;
115 +	colorAnimToR = nr; colorAnimToG = ng; colorAnimToB = nb;
116 +	colorAnimStart = performance.now();
117 +	requestDraw();
118 +}
119 +
120 +const faviconEl = document.getElementById('favicon');
121 +function updateFavicon() {
122 +	const svg = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'><rect width='1' height='1' fill='rgb(" + r + "," + g + "," + b + ")'/></svg>";
123 +	faviconEl.href = 'data:image/svg+xml,' + encodeURIComponent(svg);
124 +}
125 +
126 +function pickAt(cx, cy) {
127 +	const c = readPixel(cx, cy);
128 +	r = c[0]; g = c[1]; b = c[2];
129 +	setOutlineTarget((r + g + b > 384) ? 1 : 0);
130 +	setDisplayColorTarget(r, g, b);
131 +	updateFavicon();
132 +}
133 +
134 +function setColor(nr, ng, nb) {
135 +	r = nr; g = ng; b = nb;
136 +	setOutlineTarget((r + g + b > 384) ? 1 : 0);
137 +	updateFavicon();
138 +	dispR = r; dispG = g; dispB = b;
139 +	colorAnimFromR = r; colorAnimFromG = g; colorAnimFromB = b;
140 +	colorAnimToR = r; colorAnimToG = g; colorAnimToB = b;
141 +}
resources/canvas.js
  1 +const view = document.getElementById('view');
  2 +const vctx = view.getContext('2d', { alpha: false });
  3 +
  4 +const CHUNK = 256;
  5 +let frameSeq = 0;
  6 +// frames carry a stable id so collaborators can name the same frame even
  7 +// as the list shifts under inserts and deletes
  8 +function newFrameMap(id) {
  9 +	const m = new Map();
 10 +	m.id = id || ('l' + (++frameSeq));
 11 +	return m;
 12 +}
 13 +let frames = [newFrameMap()];
 14 +let frameIdx = 0;
 15 +let chunks = frames[0];
 16 +let dir = 1; // playback direction: 1 forward, -1 reverse
 17 +let onionskin = false;
 18 +// how anything belonging to another frame reads: onionskinned content and
 19 +// the brushes of painters standing on it
 20 +const GHOST_ALPHA = 0.35;
 21 +let playing = false;
 22 +let playTimer = null;
 23 +let frameInterval = 250;
 24 +let lastTap = 0;
 25 +
 26 +function chunkKey(cx, cy) { return cx + ',' + cy; }
 27 +
 28 +// remote strokes redirect every paint write into the frame they belong to
 29 +let paintMap = null;
 30 +
 31 +function getOrCreateChunk(cx, cy, map) {
 32 +	map = map || paintMap || chunks;
 33 +	const k = chunkKey(cx, cy);
 34 +	let c = map.get(k);
 35 +	if (c) return c;
 36 +	const cnv = document.createElement('canvas');
 37 +	cnv.width = CHUNK;
 38 +	cnv.height = CHUNK;
 39 +	// overlay chunks stay transparent so they composite over the frame
 40 +	const cctx = cnv.getContext('2d', { alpha: !!map.overlay });
 41 +	if (!map.overlay) {
 42 +		cctx.fillStyle = '#000';
 43 +		cctx.fillRect(0, 0, CHUNK, CHUNK);
 44 +	}
 45 +	c = { canvas: cnv, ctx: cctx };
 46 +	map.set(k, c);
 47 +	return c;
 48 +}
 49 +
 50 +// unacked local strokes paint here, on top of the frame, until their echo
 51 +// commits them into it in the room's order
 52 +function overlayFor(fid) {
 53 +	let m = net.overlays.get(fid);
 54 +	if (!m) {
 55 +		m = new Map();
 56 +		m.id = fid;
 57 +		m.overlay = true;
 58 +		net.overlays.set(fid, m);
 59 +	}
 60 +	return m;
 61 +}
 62 +
 63 +function paintRect(wx, wy, w, h, color) {
 64 +	const x0 = wx, y0 = wy, x1 = wx + w, y1 = wy + h;
 65 +	const cx0 = Math.floor(x0 / CHUNK);
 66 +	const cy0 = Math.floor(y0 / CHUNK);
 67 +	const cx1 = Math.floor((x1 - 1) / CHUNK);
 68 +	const cy1 = Math.floor((y1 - 1) / CHUNK);
 69 +	for (let cy = cy0; cy <= cy1; cy++) {
 70 +		for (let cx = cx0; cx <= cx1; cx++) {
 71 +			const c = getOrCreateChunk(cx, cy);
 72 +			const lx = Math.max(x0, cx * CHUNK) - cx * CHUNK;
 73 +			const ly = Math.max(y0, cy * CHUNK) - cy * CHUNK;
 74 +			const rx = Math.min(x1, (cx + 1) * CHUNK) - cx * CHUNK;
 75 +			const ry = Math.min(y1, (cy + 1) * CHUNK) - cy * CHUNK;
 76 +			c.ctx.fillStyle = color;
 77 +			c.ctx.fillRect(lx, ly, rx - lx, ry - ly);
 78 +		}
 79 +	}
 80 +}
 81 +
 82 +function readPixel(wx, wy) {
 83 +	const cx = Math.floor(wx / CHUNK);
 84 +	const cy = Math.floor(wy / CHUNK);
 85 +	const k = chunkKey(cx, cy);
 86 +	const lx = wx - cx * CHUNK;
 87 +	const ly = wy - cy * CHUNK;
 88 +	// an unacked stroke sits on the overlay; pick what's actually on screen
 89 +	const ov = net.overlays.get(chunks.id);
 90 +	const oc = ov && ov.get(k);
 91 +	if (oc) {
 92 +		const d = oc.ctx.getImageData(lx, ly, 1, 1).data;
 93 +		if (d[3] === 255) return [d[0], d[1], d[2]];
 94 +	}
 95 +	const c = chunks.get(k);
 96 +	if (!c) return [0, 0, 0];
 97 +	const d = c.ctx.getImageData(lx, ly, 1, 1).data;
 98 +	return [d[0], d[1], d[2]];
 99 +}
100 +
101 +function setFrame(i) {
102 +	frameIdx = ((i % frames.length) + frames.length) % frames.length;
103 +	chunks = frames[frameIdx];
104 +	// a held pick tracks the frame under it as frames change
105 +	if (picking) pickAt(curX, curY);
106 +	requestDraw();
107 +}
108 +
109 +// frame structure changes round-trip through the server when connected, so
110 +// every painter ends up with the same list in the same order
111 +function addFrame() {
112 +	const at = dir === 1 ? frameIdx + 1 : frameIdx;
113 +	if (net.on) { net.send({ t: 'af', at }); return; }
114 +	applyAddFrame(at, null, true);
115 +}
116 +
117 +function applyAddFrame(at, id, mine) {
118 +	at = clamp(at, 0, frames.length);
119 +	const cur = chunks;
120 +	frames.splice(at, 0, newFrameMap(id));
121 +	setFrame(mine ? at : frames.indexOf(cur));
122 +}
123 +
124 +function deleteFrame() {
125 +	if (frames.length === 1) return;
126 +	if (net.on) { net.send({ t: 'df', id: chunks.id }); return; }
127 +	applyDeleteFrame(chunks.id);
128 +}
129 +
130 +function applyDeleteFrame(id) {
131 +	if (frames.length === 1) return;
132 +	const i = frames.findIndex((f) => f.id === id);
133 +	if (i < 0) return;
134 +	const cur = chunks;
135 +	frames.splice(i, 1);
136 +	if (playing && frames.length === 1) stopPlayback();
137 +	const j = frames.indexOf(cur);
138 +	setFrame(j >= 0 ? j : Math.min(i, frames.length - 1));
139 +}
140 +
141 +function stopPlayback() {
142 +	playing = false;
143 +	clearTimeout(playTimer);
144 +	requestDraw();
145 +}
146 +
147 +function startPlayback() {
148 +	if (frames.length === 1) return;
149 +	playing = true;
150 +	playTimer = setTimeout(function step() {
151 +		setFrame(frameIdx + dir);
152 +		playTimer = setTimeout(step, frameInterval);
153 +	}, frameInterval);
154 +}
155 +
156 +function tapArrow(d) {
157 +	dir = d;
158 +	const now = performance.now();
159 +	if (lastTap && now - lastTap <= 10000) {
160 +		frameInterval = now - lastTap;
161 +		// stored, not applied to anyone live: whoever joins next inherits it
162 +		net.send({ t: 'interval', v: Math.round(frameInterval) });
163 +	}
164 +	lastTap = now;
165 +	if (playing) { stopPlayback(); return; }
166 +	setFrame(frameIdx + d);
167 +}
168 +
169 +const keys = {};
170 +let painting = false;
171 +let picking = false;
172 +let lastPaintX = null, lastPaintY = null;
173 +let dirty = false;
174 +
175 +function brushTopLeft(cx, cy) {
176 +	const off = Math.floor(brush / 2);
177 +	return { x: cx - off, y: cy - off };
178 +}
179 +
180 +function paintAt(cx, cy) {
181 +	dirty = true;
182 +	const tl = brushTopLeft(cx, cy);
183 +	if (roundness === 0) {
184 +		paintRect(tl.x, tl.y, brush, brush, 'rgb(' + r + ',' + g + ',' + b + ')');
185 +		return;
186 +	}
187 +	const sprite = getBrushShape().sprite;
188 +	const cx0 = Math.floor(tl.x / CHUNK);
189 +	const cy0 = Math.floor(tl.y / CHUNK);
190 +	const cx1 = Math.floor((tl.x + brush - 1) / CHUNK);
191 +	const cy1 = Math.floor((tl.y + brush - 1) / CHUNK);
192 +	for (let ccy = cy0; ccy <= cy1; ccy++) {
193 +		for (let ccx = cx0; ccx <= cx1; ccx++) {
194 +			const c = getOrCreateChunk(ccx, ccy);
195 +			c.ctx.drawImage(sprite, tl.x - ccx * CHUNK, tl.y - ccy * CHUNK);
196 +		}
197 +	}
198 +}
199 +
200 +// every mark the local user makes goes out as a segment; a dot
201 +// is just a zero-length one. in a room the pixels land on the frame's
202 +// overlay - the frame itself only takes strokes in the server's order
203 +function localPaintAt(x, y) {
204 +	if (net.on) paintMap = overlayFor(chunks.id);
205 +	try {
206 +		paintAt(x, y);
207 +	} finally {
208 +		paintMap = null;
209 +	}
210 +	net.paint(x, y, x, y);
211 +}
212 +
213 +function localPaintLine(x0, y0, x1, y1) {
214 +	if (net.on) paintMap = overlayFor(chunks.id);
215 +	try {
216 +		paintLine(x0, y0, x1, y1);
217 +	} finally {
218 +		paintMap = null;
219 +	}
220 +	net.paint(x0, y0, x1, y1);
221 +}
222 +
223 +// replay someone else's segment into whichever frame it belongs to, with
224 +// their brush, without disturbing the local one
225 +function applyPaintOp(op) {
226 +	const f = frames.find((fr) => fr.id === op.f);
227 +	if (!f || !op.o || !op.c) return;
228 +	const pm = paintMap, pr = r, pg = g, pb = b, pbrush = brush, pround = roundness;
229 +	paintMap = f;
230 +	r = op.c[0]; g = op.c[1]; b = op.c[2];
231 +	brush = op.b; roundness = op.s;
232 +	try {
233 +		for (let i = 0; i + 3 < op.o.length; i += 4) {
234 +			paintLine(op.o[i], op.o[i + 1], op.o[i + 2], op.o[i + 3]);
235 +		}
236 +	} finally {
237 +		paintMap = pm;
238 +		r = pr; g = pg; b = pb;
239 +		brush = pbrush; roundness = pround;
240 +	}
241 +	if (f === chunks) requestDraw();
242 +}
243 +
244 +function paintLine(x0, y0, x1, y1) {
245 +	let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
246 +	let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
247 +	let err = dx + dy;
248 +	let x = x0, y = y0;
249 +	while (true) {
250 +		paintAt(x, y);
251 +		if (x === x1 && y === y1) break;
252 +		const e2 = 2 * err;
253 +		if (e2 >= dy) { err += dy; x += sx; }
254 +		if (e2 <= dx) { err += dx; y += sy; }
255 +	}
256 +}
resources/input.js
  1 +window.addEventListener('resize', resize);
  2 +
  3 +// mouse and pen share the pointer-event path; finger touches are handled
  4 +// by the touch-event path below
  5 +function hoverPointer(e) { return e.pointerType === 'mouse' || e.pointerType === 'pen'; }
  6 +
  7 +window.addEventListener('pointerover', (e) => {
  8 +	if (!hoverPointer(e)) return;
  9 +	showCursor();
 10 +	curClientX = e.clientX;
 11 +	curClientY = e.clientY;
 12 +	const p = clientToWorld(e.clientX, e.clientY);
 13 +	curX = p.x;
 14 +	curY = p.y;
 15 +	requestDraw();
 16 +});
 17 +
 18 +// brush resize curve: linear (fine, precise) up to BRUSH_LINEAR_MAX, then a
 19 +// power-law ramp beyond, tuned by BRUSH_ACCEL.
 20 +function brushFromDrag(start, dyPx) {
 21 +	const A = BRUSH_DRAG_PX_PER_STEP, T = BRUSH_LINEAR_MAX, c = BRUSH_ACCEL;
 22 +	const seam = T * A; // drag distance from size 0 to the seam
 23 +	const brushToPos = (b) => b <= T ? b * A : seam + (seam / c) * (Math.pow(b / T, c) - 1);
 24 +	const posToBrush = (p) => p <= seam ? p / A : T * Math.pow(1 + c * (p - seam) / seam, 1 / c);
 25 +	return posToBrush(brushToPos(start) + dyPx);
 26 +}
 27 +
 28 +// modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor
 29 +// drives brush size / roundness / color channels. drag up (dy negative)
 30 +// -> increase. when a value hits its bound and the drag continues past,
 31 +// re-anchor so reversing direction responds immediately. each drag is
 32 +// independent so any combination can run at once.
 33 +function applyDrags(clientY) {
 34 +	if (dragBrush) {
 35 +		const dyPx = (dragBrush.anchorY - clientY) * dpr;
 36 +		const target = brushFromDrag(dragBrush.start, dyPx);
 37 +		const clamped = clamp(Math.round(target), 1, MAX_BRUSH);
 38 +		brush = clamped;
 39 +		if (target < 1 || target > MAX_BRUSH) {
 40 +			dragBrush.start = clamped;
 41 +			dragBrush.anchorY = clientY;
 42 +		}
 43 +	}
 44 +	if (dragRoundness) {
 45 +		const dyPx = (dragRoundness.anchorY - clientY) * dpr;
 46 +		const target = dragRoundness.start - dyPx / ROUNDNESS_DRAG_FULL_PX;
 47 +		const clamped = clamp(target, 0, 1);
 48 +		roundness = clamped;
 49 +		if (target !== clamped) {
 50 +			dragRoundness.start = clamped;
 51 +			dragRoundness.anchorY = clientY;
 52 +		}
 53 +	}
 54 +	if (dragColor) {
 55 +		const dyPx = (dragColor.anchorY - clientY) * dpr;
 56 +		const delta = (dyPx / COLOR_DRAG_FULL_PX) * 255;
 57 +		let nr = r, ng = g, nb = b;
 58 +		let overshoot = 0;
 59 +		if (keys['r']) {
 60 +			const t = dragColor.r0 + delta;
 61 +			nr = clamp(t, 0, 255);
 62 +			if (t !== nr) overshoot = Math.max(overshoot, Math.abs(t - nr));
 63 +		}
 64 +		if (keys['g']) {
 65 +			const t = dragColor.g0 + delta;
 66 +			ng = clamp(t, 0, 255);
 67 +			if (t !== ng) overshoot = Math.max(overshoot, Math.abs(t - ng));
 68 +		}
 69 +		if (keys['b']) {
 70 +			const t = dragColor.b0 + delta;
 71 +			nb = clamp(t, 0, 255);
 72 +			if (t !== nb) overshoot = Math.max(overshoot, Math.abs(t - nb));
 73 +		}
 74 +		setColor(Math.round(nr), Math.round(ng), Math.round(nb));
 75 +		if (overshoot > 0) {
 76 +			dragColor.r0 = nr; dragColor.g0 = ng; dragColor.b0 = nb;
 77 +			dragColor.anchorY = clientY;
 78 +		}
 79 +	}
 80 +}
 81 +
 82 +window.addEventListener('pointermove', (e) => {
 83 +	if (!hoverPointer(e)) return;
 84 +	// a pen only proves it can hover by moving while lifted. without hover
 85 +	// it gets the finger treatment: modifiers adjust instead of marking
 86 +	if (e.pointerType === 'pen' && e.buttons === 0) penHover = true;
 87 +	showCursor();
 88 +	curClientX = e.clientX;
 89 +	curClientY = e.clientY;
 90 +	const p = clientToWorld(e.clientX, e.clientY);
 91 +	const nx = p.x, ny = p.y;
 92 +
 93 +	applyDrags(e.clientY);
 94 +
 95 +	if (painting) {
 96 +		if (lastPaintX !== null) {
 97 +			localPaintLine(lastPaintX, lastPaintY, nx, ny);
 98 +		} else {
 99 +			localPaintAt(nx, ny);
100 +		}
101 +		lastPaintX = nx;
102 +		lastPaintY = ny;
103 +	} else if (picking) {
104 +		pickAt(nx, ny);
105 +	}
106 +	curX = nx;
107 +	curY = ny;
108 +	requestDraw();
109 +});
110 +
111 +window.addEventListener('pointerleave', (e) => {
112 +	if (!hoverPointer(e)) return;
113 +	hideCursorNow();
114 +	requestDraw();
115 +});
116 +
117 +// a pen without hover support sends no move events before contact, so the
118 +// down event must place the cursor itself
119 +let penDown = false;
120 +let penHover = false;
121 +window.addEventListener('pointerdown', (e) => {
122 +	if (!hoverPointer(e)) return;
123 +	if (e.button !== 0) return;
124 +	e.preventDefault();
125 +	stopInertia();
126 +	if (e.pointerType === 'pen') penDown = true;
127 +	showCursor();
128 +	curClientX = e.clientX;
129 +	curClientY = e.clientY;
130 +	const p = clientToWorld(e.clientX, e.clientY);
131 +	curX = p.x;
132 +	curY = p.y;
133 +	// hoverless pen + modifier held -> the contact adjusts, like a finger
134 +	if (e.pointerType === 'pen' && !penHover && !keys['c'] && (dragBrush || dragRoundness || dragColor)) {
135 +		anchorDrags(e.clientY);
136 +		requestDraw();
137 +		return;
138 +	}
139 +	if (keys['c']) {
140 +		picking = true;
141 +		pickAt(curX, curY);
142 +		requestDraw();
143 +		return;
144 +	}
145 +	painting = true;
146 +	lastPaintX = curX;
147 +	lastPaintY = curY;
148 +	localPaintAt(curX, curY);
149 +	requestDraw();
150 +});
151 +
152 +function pointerUp(e) {
153 +	if (!hoverPointer(e)) return;
154 +	if (e.button === 0 || e.type === 'pointercancel') {
155 +		painting = false;
156 +		picking = false;
157 +		lastPaintX = null;
158 +		lastPaintY = null;
159 +		if (e.pointerType === 'pen') {
160 +			penDown = false;
161 +			// hoverless pens get no more events after lift, so fade like touch
162 +			if (mouseInside) setCursorFadeTarget(0);
163 +			requestDraw();
164 +		}
165 +	}
166 +}
167 +window.addEventListener('pointerup', pointerUp);
168 +window.addEventListener('pointercancel', pointerUp);
169 +
170 +// touch: one finger paints, two fingers pan/pinch-zoom. once a second
171 +// finger lands the whole touch becomes a gesture (no painting) until all
172 +// fingers lift, so a stray finger during a pan never leaves a mark.
173 +// stylus touches are excluded here
174 +let touchMode = null; // 'paint' | 'adjust' | 'gesture'
175 +let touchPainted = false;
176 +let pinchDist = 0, pinchMidX = 0, pinchMidY = 0;
177 +
178 +// inertial pan: centroid velocity (css px/ms) tracked during the gesture,
179 +// thrown on release and decayed with exponential friction
180 +let panVX = 0, panVY = 0;
181 +let panLastT = 0;
182 +let inertiaRAF = null;
183 +function stopInertia() {
184 +	if (inertiaRAF !== null) {
185 +		cancelAnimationFrame(inertiaRAF);
186 +		inertiaRAF = null;
187 +	}
188 +}
189 +function startInertia() {
190 +	// no throw if the finger paused before lifting or was barely moving
191 +	const speed = Math.hypot(panVX, panVY);
192 +	if (performance.now() - panLastT > 100 || speed < 0.05) return;
193 +	// exponential decay per ms
194 +	const DECEL = 0.995;
195 +	const MAX_SPEED = 3; // css px/ms
196 +	if (speed > MAX_SPEED) {
197 +		panVX *= MAX_SPEED / speed;
198 +		panVY *= MAX_SPEED / speed;
199 +	}
200 +	let prev = performance.now();
201 +	const step = (now) => {
202 +		const dt = now - prev;
203 +		prev = now;
204 +		camX -= panVX * dt / zoom;
205 +		camY -= panVY * dt / zoom;
206 +		const f = Math.pow(DECEL, dt);
207 +		panVX *= f;
208 +		panVY *= f;
209 +		requestDraw();
210 +		inertiaRAF = Math.hypot(panVX, panVY) > 0.02 ? requestAnimationFrame(step) : null;
211 +	};
212 +	inertiaRAF = requestAnimationFrame(step);
213 +}
214 +
215 +function touchCentroid(touches) {
216 +	let x = 0, y = 0;
217 +	for (const t of touches) { x += t.clientX; y += t.clientY; }
218 +	return { x: x / touches.length, y: y / touches.length };
219 +}
220 +
221 +function fingerTouches(list) {
222 +	const out = [];
223 +	for (const t of list) if (t.touchType !== 'stylus') out.push(t);
224 +	return out;
225 +}
226 +
227 +// re-anchor active drags to a new y and re-baseline to the current values
228 +// so successive drag strokes accumulate instead of snapping back
229 +function anchorDrags(clientY) {
230 +	if (dragBrush) { dragBrush.anchorY = clientY; dragBrush.start = brush; }
231 +	if (dragRoundness) { dragRoundness.anchorY = clientY; dragRoundness.start = roundness; }
232 +	if (dragColor) { dragColor.anchorY = clientY; dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; }
233 +}
234 +
235 +function anchorGesture(touches) {
236 +	const m = touchCentroid(touches);
237 +	pinchMidX = m.x;
238 +	pinchMidY = m.y;
239 +	pinchDist = touches.length >= 2
240 +		? Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY)
241 +		: 0;
242 +}
243 +
244 +window.addEventListener('touchstart', (e) => {
245 +	e.preventDefault();
246 +	const fingers = fingerTouches(e.touches);
247 +	// fingers are ignored while the pen is down so a resting hand can't
248 +	// hijack or extend the pen's stroke
249 +	if (fingers.length === 0 || penDown) return;
250 +	stopInertia();
251 +	if (fingers.length === 1 && touchMode === null) {
252 +		const t = fingers[0];
253 +		curClientX = t.clientX;
254 +		curClientY = t.clientY;
255 +		if (!keys['c'] && (dragBrush || dragRoundness || dragColor)) {
256 +			touchMode = 'adjust';
257 +			const p = clientToWorld(t.clientX, t.clientY);
258 +			curX = p.x;
259 +			curY = p.y;
260 +			showCursor();
261 +			anchorDrags(t.clientY);
262 +		} else {
263 +			const p = clientToWorld(t.clientX, t.clientY);
264 +			curX = p.x;
265 +			curY = p.y;
266 +			showCursor();
267 +			touchMode = 'paint';
268 +			touchPainted = false;
269 +			if (keys['c']) {
270 +				picking = true;
271 +			} else {
272 +				painting = true;
273 +				lastPaintX = curX;
274 +				lastPaintY = curY;
275 +			}
276 +		}
277 +	} else {
278 +		touchMode = 'gesture';
279 +		painting = false;
280 +		picking = false;
281 +		lastPaintX = null;
282 +		lastPaintY = null;
283 +		hideCursorNow();
284 +		panVX = 0;
285 +		panVY = 0;
286 +		panLastT = 0;
287 +		anchorGesture(fingers);
288 +	}
289 +	requestDraw();
290 +}, { passive: false });
291 +
292 +window.addEventListener('touchmove', (e) => {
293 +	e.preventDefault();
294 +	const fingers = fingerTouches(e.touches);
295 +	if (fingers.length === 0) return;
296 +	if (touchMode === 'paint') {
297 +		const t = fingers[0];
298 +		curClientX = t.clientX;
299 +		curClientY = t.clientY;
300 +		// on hover devices held keys adjust during the stroke, like mouse
301 +		applyDrags(t.clientY);
302 +		const p = clientToWorld(t.clientX, t.clientY);
303 +		if (picking) {
304 +			pickAt(p.x, p.y);
305 +		} else if (painting) {
306 +			localPaintLine(lastPaintX, lastPaintY, p.x, p.y);
307 +			lastPaintX = p.x;
308 +			lastPaintY = p.y;
309 +		}
310 +		touchPainted = true;
311 +		curX = p.x;
312 +		curY = p.y;
313 +	} else if (touchMode === 'adjust') {
314 +		const t = fingers[0];
315 +		curClientX = t.clientX;
316 +		curClientY = t.clientY;
317 +		applyDrags(t.clientY);
318 +		const p = clientToWorld(t.clientX, t.clientY);
319 +		curX = p.x;
320 +		curY = p.y;
321 +	} else if (touchMode === 'gesture') {
322 +		const m = touchCentroid(fingers);
323 +		const dxs = m.x - pinchMidX;
324 +		const dys = m.y - pinchMidY;
325 +		camX -= dxs / zoom;
326 +		camY -= dys / zoom;
327 +		const nowT = performance.now();
328 +		const dt = nowT - panLastT;
329 +		if (dt > 0 && dt < 100) {
330 +			panVX = panVX * 0.5 + (dxs / dt) * 0.5;
331 +			panVY = panVY * 0.5 + (dys / dt) * 0.5;
332 +		}
333 +		panLastT = nowT;
334 +		if (fingers.length >= 2) {
335 +			const dist = Math.hypot(fingers[0].clientX - fingers[1].clientX, fingers[0].clientY - fingers[1].clientY);
336 +			if (pinchDist > 0) applyZoom(dist / pinchDist, m.x, m.y);
337 +			pinchDist = dist;
338 +		}
339 +		pinchMidX = m.x;
340 +		pinchMidY = m.y;
341 +	} else {
342 +		return;
343 +	}
344 +	requestDraw();
345 +}, { passive: false });
346 +
347 +function touchEnd(e) {
348 +	e.preventDefault();
349 +	if (touchMode === null) return;
350 +	const fingers = fingerTouches(e.touches);
351 +	if (fingers.length === 0) {
352 +		// a tap that never moved still paints its dot (or picks its point)
353 +		if (touchMode === 'paint' && !touchPainted) {
354 +			if (picking) pickAt(curX, curY);
355 +			else localPaintAt(curX, curY);
356 +		}
357 +		if (touchMode === 'gesture') startInertia();
358 +		touchMode = null;
359 +		painting = false;
360 +		picking = false;
361 +		lastPaintX = null;
362 +		lastPaintY = null;
363 +		// fade the preview out instead of hiding it instantly
364 +		if (mouseInside) setCursorFadeTarget(0);
365 +		requestDraw();
366 +	} else if (touchMode === 'gesture') {
367 +		// re-anchor to the remaining fingers so the camera doesn't jump
368 +		anchorGesture(fingers);
369 +	}
370 +}
371 +window.addEventListener('touchend', touchEnd, { passive: false });
372 +window.addEventListener('touchcancel', touchEnd, { passive: false });
373 +
374 +function startDragMode(mode) {
375 +	const anchorY = curClientY !== null ? curClientY : 0;
376 +	if (mode === 'brush' && !dragBrush) dragBrush = { anchorY, start: brush };
377 +	else if (mode === 'roundness' && !dragRoundness) dragRoundness = { anchorY, start: roundness };
378 +	else if (mode === 'color' && !dragColor) dragColor = { anchorY, r0: r, g0: g, b0: b };
379 +	// hoverless input: a modifier pressed mid-stroke ends the stroke and
380 +	// the rest of the contact becomes an adjust drag. hover-capable pens
381 +	// keep drawing (they can adjust while lifted instead)
382 +	if (touchMode === 'paint' || (penDown && !penHover)) {
383 +		if (touchMode === 'paint') touchMode = 'adjust';
384 +		painting = false;
385 +		picking = false;
386 +		lastPaintX = null;
387 +		lastPaintY = null;
388 +	}
389 +}
390 +function endDragMode(mode) {
391 +	if (mode === 'brush') dragBrush = null;
392 +	else if (mode === 'roundness') dragRoundness = null;
393 +	else if (mode === 'color') dragColor = null;
394 +}
395 +
396 +window.addEventListener('keydown', (e) => {
397 +	if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 's') {
398 +		e.preventDefault();
399 +		exportGIF();
400 +		return;
401 +	}
402 +	if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'o') {
403 +		e.preventDefault();
404 +		fileInput.click();
405 +		return;
406 +	}
407 +	if ((e.metaKey || e.ctrlKey) && !e.altKey) {
408 +		if (e.key === '=' || e.key === '+') { e.preventDefault(); stepZoom(1); return; }
409 +		if (e.key === '-' || e.key === '_') { e.preventDefault(); stepZoom(-1); return; }
410 +		if (e.key === '0') { e.preventDefault(); resetZoom(); return; }
411 +	}
412 +	if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat) {
413 +		const lk = e.key.toLowerCase();
414 +		if (e.key === 'ArrowLeft') { e.preventDefault(); tapArrow(-1); return; }
415 +		if (e.key === 'ArrowRight') { e.preventDefault(); tapArrow(1); return; }
416 +		if (e.key === ' ') { e.preventDefault(); playing ? stopPlayback() : startPlayback(); return; }
417 +		if (lk === 'a') { addFrame(); return; }
418 +		if (lk === 'd') { deleteFrame(); return; }
419 +		if (lk === 'o') {
420 +			if (playing) { stopPlayback(); onionskin = true; }
421 +			else onionskin = !onionskin;
422 +			requestDraw();
423 +			return;
424 +		}
425 +	}
426 +	const k = e.key.toLowerCase();
427 +	const wasDown = keys[k];
428 +	keys[k] = true;
429 +	if (!e.metaKey && !e.ctrlKey) {
430 +		if (k === 'z' || k === 'x' || k === 'c' || k === 'r' || k === 'g' || k === 'b') e.preventDefault();
431 +		if (k === 'x') startDragMode('roundness');
432 +		else if (k === 'z') startDragMode('brush');
433 +		else if (!wasDown && (k === 'r' || k === 'g' || k === 'b')) startDragMode('color');
434 +		else if (k === 'c' && !wasDown && (touchMode === 'paint' || touchMode === 'adjust')) {
435 +			touchMode = 'paint';
436 +			painting = false;
437 +			picking = true;
438 +			lastPaintX = null;
439 +			lastPaintY = null;
440 +			touchPainted = true;
441 +			pickAt(curX, curY);
442 +			requestDraw();
443 +		}
444 +	}
445 +});
446 +window.addEventListener('keyup', (e) => {
447 +	const k = e.key.toLowerCase();
448 +	keys[k] = false;
449 +	if (k === 'x') endDragMode('roundness');
450 +	else if (k === 'z') endDragMode('brush');
451 +	else if (k === 'r' || k === 'g' || k === 'b') {
452 +		if (!keys['r'] && !keys['g'] && !keys['b']) endDragMode('color');
453 +		else if (dragColor) {
454 +			// still holding at least one rgb key - re-anchor from current
455 +			// values so the remaining keys don't jump based on released key's history
456 +			dragColor.anchorY = curClientY !== null ? curClientY : dragColor.anchorY;
457 +			dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b;
458 +		}
459 +	}
460 +	// releasing c mid-pick-drag -> back to adjusting if a modifier is still
461 +	// held on a hoverless contact, else seamlessly switch to painting
462 +	if (k === 'c' && picking) {
463 +		picking = false;
464 +		const hoverless = touchMode !== null || (penDown && !penHover);
465 +		if (hoverless && (dragBrush || dragRoundness || dragColor)) {
466 +			if (touchMode !== null) touchMode = 'adjust';
467 +			painting = false;
468 +			if (curClientY !== null) anchorDrags(curClientY);
469 +		} else {
470 +			painting = true;
471 +			lastPaintX = curX;
472 +			lastPaintY = curY;
473 +			localPaintAt(curX, curY);
474 +		}
475 +		requestDraw();
476 +	}
477 +});
478 +window.addEventListener('blur', () => {
479 +	for (const k in keys) keys[k] = false;
480 +	dragBrush = null; dragRoundness = null; dragColor = null;
481 +	painting = false;
482 +	picking = false;
483 +	penDown = false;
484 +	lastPaintX = null;
485 +	lastPaintY = null;
486 +});
487 +
488 +// wheel gesture lock: once a wheel gesture starts in a given mode,
489 +// subsequent events in the same burst stay in that mode until the wheel
490 +// goes idle or the controlling modifier set changes. this keeps trackpad
491 +// momentum from leaking into pan after a modifier release, while still
492 +// letting the user swap between modes mid-flick without stale state.
493 +let wheelMode = null;
494 +let wheelIdleTimer = null;
495 +let panning = false;
496 +
497 +const BRUSH_DRAG_PX_PER_STEP = 25;	// device px per +/- 1 brush px (below the seam)
498 +const BRUSH_LINEAR_MAX = 6;			// brush px below which resize stays linear/fine
499 +const BRUSH_ACCEL = 0.5;
500 +const MAX_BRUSH = 150;
501 +const ROUNDNESS_DRAG_FULL_PX = 200;
502 +const COLOR_DRAG_FULL_PX = 256;
503 +// each drag carries its own anchor so any combination can run at once
504 +let dragBrush = null;		// {anchorY, start}
505 +let dragRoundness = null;	// {anchorY, start}
506 +let dragColor = null;		// {anchorY, r0, g0, b0}
507 +function touchWheelGesture() {
508 +	if (wheelIdleTimer) clearTimeout(wheelIdleTimer);
509 +	wheelIdleTimer = setTimeout(() => {
510 +		wheelMode = null;
511 +		if (panning) {
512 +			// pan ended - resync the logical-pixel cursor from the real
513 +			// client position so the brush snaps onto its final cell.
514 +			panning = false;
515 +			if (curClientX !== null) {
516 +				const p = clientToWorld(curClientX, curClientY);
517 +				curX = p.x;
518 +				curY = p.y;
519 +			}
520 +			requestDraw();
521 +		}
522 +	}, 150);
523 +}
524 +function pickModifierMode(e) {
525 +	if (e.ctrlKey) return 'zoom';
526 +	return null;
527 +}
528 +
529 +window.addEventListener('wheel', (e) => {
530 +	e.preventDefault();
531 +	stopInertia();
532 +
533 +	// re-pick mode each event from live modifiers so releasing cmd and
534 +	// immediately starting a shift scroll switches over cleanly. if a
535 +	// burst started with a modifier and the modifier is then released
536 +	// mid-flick, suppress rather than leaking into pan.
537 +	const modMode = pickModifierMode(e);
538 +	if (modMode !== null) {
539 +		wheelMode = modMode;
540 +	} else if (wheelMode === null) {
541 +		wheelMode = 'pan';
542 +	} else if (wheelMode !== 'pan') {
543 +		// modifier released mid-burst: drop the remaining momentum
544 +		// instead of letting it leak into pan.
545 +		touchWheelGesture();
546 +		return;
547 +	}
548 +	touchWheelGesture();
549 +
550 +	if (wheelMode === 'zoom') {
551 +		// exponential zoom on the float accumulator so small pinches add up
552 +		applyZoom(Math.exp(-e.deltaY * 0.02), e.clientX, e.clientY);
553 +		requestDraw();
554 +		return;
555 +	}
556 +
557 +	// pan - smooth fractional camera. during the pan gesture the draw
558 +	// loop anchors the brush to the real client cursor position so it
559 +	// tracks the pointer smoothly; curX/curY get resynced onto the
560 +	// logical-pixel grid when the wheel idle timer fires.
561 +	panning = true;
562 +	camX += e.deltaX / zoom;
563 +	camY += e.deltaY / zoom;
564 +	requestDraw();
565 +}, { passive: false });
566 +
567 +window.addEventListener('beforeunload', (e) => {
568 +	if (dirty) { e.preventDefault(); }
569 +});
570 +
571 +window.addEventListener('contextmenu', (e) => e.preventDefault());
572 +
573 +// block the OS pinch gesture events too (Safari)
574 +window.addEventListener('gesturestart', (e) => e.preventDefault());
575 +window.addEventListener('gesturechange', (e) => e.preventDefault());
576 +window.addEventListener('gestureend', (e) => e.preventDefault());
resources/io.js
  1 +const fileInput = document.createElement('input');
  2 +fileInput.type = 'file';
  3 +fileInput.accept = 'image/*';
  4 +fileInput.style.display = 'none';
  5 +document.body.appendChild(fileInput);
  6 +fileInput.addEventListener('change', (e) => {
  7 +	const f = e.target.files && e.target.files[0];
  8 +	if (f) importFile(f);
  9 +	fileInput.value = '';
 10 +});
 11 +
 12 +function formatTimestamp(d) {
 13 +	const p = (n) => String(n).padStart(2, '0');
 14 +	return p(d.getFullYear() % 100) + '\u00b7' + p(d.getMonth() + 1) + '\u00b7' + p(d.getDate()) + '\u00b7' + p(d.getHours()) + '\u00b7' + p(d.getMinutes()) + '\u00b7' + p(d.getSeconds());
 15 +}
 16 +
 17 +// union of painted chunk bounds across all frames, so every exported
 18 +// frame shares the canvas of the biggest one
 19 +function frameBounds() {
 20 +	let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity;
 21 +	for (const f of frames) {
 22 +		for (const k of f.keys()) {
 23 +			any = true;
 24 +			const [cx, cy] = k.split(',').map(Number);
 25 +			if (cx < minCx) minCx = cx;
 26 +			if (cy < minCy) minCy = cy;
 27 +			if (cx > maxCx) maxCx = cx;
 28 +			if (cy > maxCy) maxCy = cy;
 29 +		}
 30 +	}
 31 +	return any ? { minCx, minCy, maxCx, maxCy } : null;
 32 +}
 33 +
 34 +function renderFrame(f, b, w, h) {
 35 +	const out = document.createElement('canvas');
 36 +	out.width = w;
 37 +	out.height = h;
 38 +	const octx = out.getContext('2d');
 39 +	octx.fillStyle = '#000';
 40 +	octx.fillRect(0, 0, w, h);
 41 +	for (const [k, c] of f) {
 42 +		const [cx, cy] = k.split(',').map(Number);
 43 +		octx.drawImage(c.canvas, (cx - b.minCx) * CHUNK, (cy - b.minCy) * CHUNK);
 44 +	}
 45 +	return octx.getImageData(0, 0, w, h);
 46 +}
 47 +
 48 +function lzwEncode(minCode, data, lookup, out) {
 49 +	const clear = 1 << minCode, eoi = clear + 1;
 50 +	let codeSize = minCode + 1, next = eoi + 1;
 51 +	let dict = new Map();
 52 +	let acc = 0, accBits = 0;
 53 +	let block = [];
 54 +	const flushBlock = () => {
 55 +		if (block.length) { out.push(block.length, ...block); block = []; }
 56 +	};
 57 +	const emit = (code) => {
 58 +		acc |= code << accBits;
 59 +		accBits += codeSize;
 60 +		while (accBits >= 8) {
 61 +			block.push(acc & 255);
 62 +			acc >>= 8;
 63 +			accBits -= 8;
 64 +			if (block.length === 255) flushBlock();
 65 +		}
 66 +	};
 67 +	const idx = (i) => lookup((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]);
 68 +	emit(clear);
 69 +	let prev = idx(0);
 70 +	for (let i = 4; i < data.length; i += 4) {
 71 +		const k = idx(i);
 72 +		const key = prev * 256 + k;
 73 +		if (dict.has(key)) { prev = dict.get(key); continue; }
 74 +		emit(prev);
 75 +		if (next === 4096) {
 76 +			emit(clear);
 77 +			dict = new Map();
 78 +			next = eoi + 1;
 79 +			codeSize = minCode + 1;
 80 +		} else {
 81 +			if (next >= (1 << codeSize)) codeSize++;
 82 +			dict.set(key, next++);
 83 +		}
 84 +		prev = k;
 85 +	}
 86 +	emit(prev);
 87 +	emit(eoi);
 88 +	if (accBits) block.push(acc & 255);
 89 +	flushBlock();
 90 +}
 91 +
 92 +// minimal GIF89a encoder: exact global palette when <=256 colors,
 93 +// else uniform 6x6x6 quantization
 94 +function encodeGIF(images, w, h, delayMs) {
 95 +	const colorIdx = new Map();
 96 +	let over = false;
 97 +	for (const img of images) {
 98 +		const d = img.data;
 99 +		for (let i = 0; i < d.length && !over; i += 4) {
100 +			const c = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2];
101 +			if (!colorIdx.has(c)) {
102 +				if (colorIdx.size === 256) over = true;
103 +				else colorIdx.set(c, colorIdx.size);
104 +			}
105 +		}
106 +		if (over) break;
107 +	}
108 +	if (over) {
109 +		colorIdx.clear();
110 +		for (let i = 0; i < 216; i++) {
111 +			colorIdx.set(((Math.floor(i / 36) * 51) << 16) | ((Math.floor(i / 6) % 6 * 51) << 8) | (i % 6 * 51), i);
112 +		}
113 +	}
114 +	const lookup = (c) => over
115 +		? Math.round(((c >> 16) & 255) / 51) * 36 + Math.round(((c >> 8) & 255) / 51) * 6 + Math.round((c & 255) / 51)
116 +		: colorIdx.get(c);
117 +	let bits = 2;
118 +	while ((1 << bits) < colorIdx.size) bits++;
119 +	const out = [];
120 +	const u16 = (v) => { out.push(v & 255, (v >> 8) & 255); };
121 +	out.push(71, 73, 70, 56, 57, 97); // "GIF89a"
122 +	u16(w); u16(h);
123 +	out.push(0x80 | ((bits - 1) << 4) | (bits - 1), 0, 0);
124 +	const pal = [...colorIdx.keys()];
125 +	for (let i = 0; i < (1 << bits); i++) {
126 +		const c = pal[i] || 0;
127 +		out.push((c >> 16) & 255, (c >> 8) & 255, c & 255);
128 +	}
129 +	const animated = images.length > 1;
130 +	if (animated) {
131 +		// NETSCAPE2.0 loop forever
132 +		out.push(0x21, 0xff, 11, 78, 69, 84, 83, 67, 65, 80, 69, 50, 46, 48, 3, 1, 0, 0, 0);
133 +	}
134 +	const delay = clamp(Math.round(delayMs / 10), 2, 65535);
135 +	for (const img of images) {
136 +		if (animated) out.push(0x21, 0xf9, 4, 0, delay & 255, (delay >> 8) & 255, 0, 0);
137 +		out.push(0x2c);
138 +		u16(0); u16(0); u16(w); u16(h);
139 +		out.push(0);
140 +		const minCode = Math.max(2, bits);
141 +		out.push(minCode);
142 +		lzwEncode(minCode, img.data, lookup, out);
143 +		out.push(0);
144 +	}
145 +	out.push(0x3b);
146 +	return new Uint8Array(out);
147 +}
148 +
149 +function exportGIF() {
150 +	const b = frameBounds();
151 +	if (!b) return;
152 +	const w = (b.maxCx - b.minCx + 1) * CHUNK;
153 +	const h = (b.maxCy - b.minCy + 1) * CHUNK;
154 +	const imgs = frames.map((f) => renderFrame(f, b, w, h));
155 +	if (dir === -1) imgs.reverse();
156 +	const blob = new Blob([encodeGIF(imgs, w, h, frameInterval)], { type: 'image/gif' });
157 +	const url = URL.createObjectURL(blob);
158 +	const a = document.createElement('a');
159 +	a.href = url;
160 +	a.download = 'animus \u00b7 ' + formatTimestamp(new Date()) + '.gif';
161 +	document.body.appendChild(a);
162 +	a.click();
163 +	document.body.removeChild(a);
164 +	URL.revokeObjectURL(url);
165 +	dirty = false;
166 +}
167 +
168 +function imageToFrame(src, w, h) {
169 +	const ox = -Math.floor(w / 2);
170 +	const oy = -Math.floor(h / 2);
171 +	const f = newFrameMap();
172 +	drawIntoFrame(f, src, w, h, ox, oy);
173 +	return { f, w, h, ox, oy };
174 +}
175 +
176 +function drawIntoFrame(f, src, w, h, ox, oy) {
177 +	const cx0 = Math.floor(ox / CHUNK);
178 +	const cy0 = Math.floor(oy / CHUNK);
179 +	const cx1 = Math.floor((ox + w - 1) / CHUNK);
180 +	const cy1 = Math.floor((oy + h - 1) / CHUNK);
181 +	for (let cy = cy0; cy <= cy1; cy++) {
182 +		for (let cx = cx0; cx <= cx1; cx++) {
183 +			getOrCreateChunk(cx, cy, f).ctx.drawImage(src, ox - cx * CHUNK, oy - cy * CHUNK);
184 +		}
185 +	}
186 +}
187 +
188 +function finishImport(newFrames, w, h, ox, oy) {
189 +	frames = newFrames;
190 +	dir = 1;
191 +	setFrame(0);
192 +	camX = ox - (cssW / zoom - w) / 2;
193 +	camY = oy - (cssH / zoom - h) / 2;
194 +	dirty = false;
195 +	requestDraw();
196 +}
197 +
198 +function loadImage(src) {
199 +	return new Promise((resolve, reject) => {
200 +		const img = new Image();
201 +		img.onload = () => resolve(img);
202 +		img.onerror = reject;
203 +		img.src = src;
204 +	});
205 +}
206 +
207 +// decoded frames of the file as {src, w, h, close}
208 +async function decodeSources(file) {
209 +	// ImageDecoder gives us every frame of an animated gif; fall back to
210 +	// single-image import where unsupported
211 +	if (typeof ImageDecoder !== 'undefined') {
212 +		try {
213 +			const dec = new ImageDecoder({ data: await file.arrayBuffer(), type: file.type });
214 +			await dec.tracks.ready;
215 +			const count = dec.tracks.selectedTrack.frameCount;
216 +			const srcs = [];
217 +			let interval = 0;
218 +			for (let i = 0; i < count; i++) {
219 +				const { image } = await dec.decode({ frameIndex: i });
220 +				if (i === 0 && image.duration) interval = Math.max(20, image.duration / 1000);
221 +				srcs.push({ src: image, w: image.displayWidth, h: image.displayHeight, close: () => image.close() });
222 +			}
223 +			return { srcs, interval };
224 +		} catch (err) {}
225 +	}
226 +	const url = URL.createObjectURL(file);
227 +	try {
228 +		const img = await loadImage(url);
229 +		return { srcs: [{ src: img, w: img.width, h: img.height }], interval: 0 };
230 +	} catch (err) {
231 +		return null;
232 +	} finally {
233 +		URL.revokeObjectURL(url);
234 +	}
235 +}
236 +
237 +function applyImport(srcs) {
238 +	const newFrames = [];
239 +	let last = null;
240 +	for (const s of srcs) {
241 +		last = imageToFrame(s.src, s.w, s.h);
242 +		newFrames.push(last.f);
243 +	}
244 +	if (last) finishImport(newFrames, last.w, last.h, last.ox, last.oy);
245 +}
246 +
247 +// flat png per frame, positioned in world space, so an import can be handed
248 +// to everyone else in the room (and to whoever joins later)
249 +function sourcesToBases(srcs) {
250 +	const cnv = document.createElement('canvas');
251 +	const cctx = cnv.getContext('2d');
252 +	return srcs.map((s) => {
253 +		cnv.width = s.w;
254 +		cnv.height = s.h;
255 +		cctx.drawImage(s.src, 0, 0);
256 +		return { img: cnv.toDataURL('image/png'), ox: -Math.floor(s.w / 2), oy: -Math.floor(s.h / 2), w: s.w, h: s.h };
257 +	});
258 +}
259 +
260 +async function importFile(file) {
261 +	if (dirty && !confirm('Importing will discard the current ' + (frames.length > 1 ? 'animation' : 'painting') + '. Continue?' + (net.on ? ' (for everyone)' : ''))) return;
262 +	if (playing) stopPlayback();
263 +	const dec = await decodeSources(file);
264 +	if (!dec || !dec.srcs.length) return;
265 +	if (dec.interval) frameInterval = dec.interval;
266 +	if (net.on) net.send({ t: 'reset', frames: sourcesToBases(dec.srcs), interval: frameInterval });
267 +	else applyImport(dec.srcs);
268 +	for (const s of dec.srcs) if (s.close) s.close();
269 +}
resources/net.js
  1 +// painting together: mirrors strokes, frames, and brushes between
  2 +// everyone in the room
  3 +
  4 +const net = {
  5 +	on: false,
  6 +	id: null,
  7 +	ws: null,
  8 +	peers: new Map(),
  9 +	joined: false,	// has this page ever been in a room
 10 +	queue: null,	// messages held while a snapshot loads
 11 +	lastCursor: '',
 12 +	batch: null,	// segments painted this frame, not yet sent
 13 +	key: 0,
 14 +	pending: [],	// own strokes still waiting for their echo
 15 +	overlays: new Map(),	// frame id -> chunk map of those unacked strokes
 16 +};
 17 +
 18 +net.send = function (msg) {
 19 +	if (!net.on) return;
 20 +	try {
 21 +		net.ws.send(JSON.stringify(msg));
 22 +	} catch (err) {}
 23 +};
 24 +
 25 +// a fast pen reports far more moves than anyone can see, so segments pile up
 26 +// into one message per animation frame. a new frame, color, or brush starts
 27 +// a new batch, since a batch carries a single style
 28 +net.paint = function (x0, y0, x1, y1) {
 29 +	if (!net.on) return;
 30 +	const cur = net.batch;
 31 +	if (cur && cur.f === chunks.id && cur.b === brush && cur.s === roundness
 32 +		&& cur.c[0] === r && cur.c[1] === g && cur.c[2] === b) {
 33 +		cur.o.push(x0, y0, x1, y1);
 34 +		return;
 35 +	}
 36 +	net.flush();
 37 +	net.batch = { t: 'p', k: ++net.key, f: chunks.id, o: [x0, y0, x1, y1], c: [r, g, b], b: brush, s: roundness };
 38 +	// tracked from the first segment, so the overlay it painted onto is held
 39 +	// until the echo commits it to the frame proper
 40 +	net.pending.push({ k: net.batch.k, f: chunks.id });
 41 +};
 42 +
 43 +net.flush = function () {
 44 +	const out = net.batch;
 45 +	if (!out) return;
 46 +	net.batch = null;
 47 +	net.send(out);
 48 +};
 49 +
 50 +// an overlay lives exactly as long as some stroke on it awaits its echo
 51 +function clearOverlayIfIdle(fid) {
 52 +	if (net.pending.some((p) => p.f === fid)) return;
 53 +	if (net.overlays.delete(fid)) requestDraw();
 54 +}
 55 +
 56 +// called at the end of every draw, so the brush other people see moves
 57 +// exactly when ours does, and never more often than a frame
 58 +net.sendCursor = function () {
 59 +	if (!net.on) return;
 60 +	// the target, not the current alpha: everyone else runs the same fade
 61 +	// from the moment it starts, so a slow fade-out never reads as a blink
 62 +	const v = cursorFadeTo > 0;
 63 +	const key = v ? curX + ',' + curY + ',' + brush + ',' + roundness + ',' + r + ',' + g + ',' + b + ',' + chunks.id : '0';
 64 +	if (key === net.lastCursor) return;
 65 +	net.lastCursor = key;
 66 +	net.send({ t: 'c', x: curX, y: curY, b: brush, s: roundness, c: [r, g, b], f: chunks.id, v });
 67 +};
 68 +
 69 +function frameSnapshot(f) {
 70 +	let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity;
 71 +	for (const k of f.keys()) {
 72 +		any = true;
 73 +		const [cx, cy] = k.split(',').map(Number);
 74 +		if (cx < minCx) minCx = cx;
 75 +		if (cy < minCy) minCy = cy;
 76 +		if (cx > maxCx) maxCx = cx;
 77 +		if (cy > maxCy) maxCy = cy;
 78 +	}
 79 +	if (!any) return { id: f.id, img: null };
 80 +	const w = (maxCx - minCx + 1) * CHUNK;
 81 +	const h = (maxCy - minCy + 1) * CHUNK;
 82 +	const cnv = document.createElement('canvas');
 83 +	cnv.width = w;
 84 +	cnv.height = h;
 85 +	const cctx = cnv.getContext('2d');
 86 +	cctx.fillStyle = '#000';
 87 +	cctx.fillRect(0, 0, w, h);
 88 +	for (const [k, c] of f) {
 89 +		const [cx, cy] = k.split(',').map(Number);
 90 +		cctx.drawImage(c.canvas, (cx - minCx) * CHUNK, (cy - minCy) * CHUNK);
 91 +	}
 92 +	return { id: f.id, img: cnv.toDataURL('image/png'), ox: minCx * CHUNK, oy: minCy * CHUNK, w, h };
 93 +}
 94 +
 95 +// rebuild frames from flat images the server is holding
 96 +async function framesFromBases(list) {
 97 +	const out = [];
 98 +	for (const e of list) {
 99 +		const f = newFrameMap(e.id);
100 +		if (e.img) {
101 +			try {
102 +				const img = await loadImage(e.img);
103 +				drawIntoFrame(f, img, e.w || img.width, e.h || img.height, e.ox || 0, e.oy || 0);
104 +			} catch (err) {}
105 +		}
106 +		out.push(f);
107 +	}
108 +	return out;
109 +}
110 +
111 +// hold live messages until the frames they refer to exist
112 +function netDefer(msg) {
113 +	if (!net.queue) return false;
114 +	net.queue.push(msg);
115 +	return true;
116 +}
117 +
118 +function netFlush() {
119 +	const q = net.queue;
120 +	net.queue = null;
121 +	if (!q) return;
122 +	for (const msg of q) netHandle(msg);
123 +}
124 +
125 +// swap in a server-supplied frame list, replaying anything that arrived
126 +// while the images were decoding only after `after` has caught us up
127 +async function netAdopt(list, after) {
128 +	net.queue = [];
129 +	const built = await framesFromBases(list);
130 +	frames = built;
131 +	setFrame(0);
132 +	if (after) after();
133 +	netFlush();
134 +	requestDraw();
135 +}
136 +
137 +function netHandle(msg) {
138 +	if (msg.t === 'init') {
139 +		net.id = msg.id;
140 +		net.batch = null;
141 +		net.pending.length = 0;
142 +		net.overlays.clear();
143 +		if (msg.interval) frameInterval = msg.interval;
144 +		netAdopt(msg.frames, () => {
145 +			for (const e of msg.frames) {
146 +				for (const op of e.ops || []) applyPaintOp({ ...op, f: e.id });
147 +			}
148 +			dirty = false;
149 +		});
150 +		return;
151 +	}
152 +	if (netDefer(msg)) return;
153 +	if (msg.t === 'p') {
154 +		// strokes land in the frame strictly in the order the server sent
155 +		// them - ours included. until now our own stroke existed only on the
156 +		// overlay; this echo is what commits it, so every painter's frame is
157 +		// the same ops in the same order
158 +		applyPaintOp(msg);
159 +		if (msg.by === net.id) {
160 +			const i = net.pending.findIndex((p) => p.k === msg.k);
161 +			if (i < 0) return;
162 +			// earlier entries can only be strokes the server dropped (their
163 +			// frame was deleted); sweep them along
164 +			const gone = net.pending.splice(0, i + 1);
165 +			for (const p of gone) clearOverlayIfIdle(p.f);
166 +		}
167 +	} else if (msg.t === 'c') {
168 +		const p = net.peers.get(msg.id) || { alpha: 0, fadeFrom: 0, fadeTo: 0, fadeStart: 0 };
169 +		const target = msg.v ? 1 : 0;
170 +		if (target !== p.fadeTo) {
171 +			p.fadeFrom = p.alpha;
172 +			p.fadeTo = target;
173 +			p.fadeStart = performance.now();
174 +		}
175 +		p.x = msg.x; p.y = msg.y; p.b = msg.b; p.s = msg.s; p.c = msg.c; p.f = msg.f;
176 +		net.peers.set(msg.id, p);
177 +		requestDraw();
178 +	} else if (msg.t === 'bye') {
179 +		const p = net.peers.get(msg.id);
180 +		if (p && p.fadeTo !== 0) {
181 +			p.fadeFrom = p.alpha;
182 +			p.fadeTo = 0;
183 +			p.fadeStart = performance.now();
184 +		}
185 +		requestDraw();
186 +	} else if (msg.t === 'af') {
187 +		applyAddFrame(msg.at, msg.id, msg.by === net.id);
188 +	} else if (msg.t === 'df') {
189 +		// the server drops strokes aimed at a deleted frame, so their echoes
190 +		// never come; the frame is going away, and its overlay with it
191 +		net.pending = net.pending.filter((p) => p.f !== msg.id);
192 +		net.overlays.delete(msg.id);
193 +		applyDeleteFrame(msg.id);
194 +	} else if (msg.t === 'reset') {
195 +		if (playing) stopPlayback();
196 +		net.pending.length = 0;
197 +		net.overlays.clear();
198 +		if (msg.interval) frameInterval = msg.interval;
199 +		netAdopt(msg.frames, () => {
200 +			const first = msg.frames[0] || {};
201 +			dir = 1;
202 +			camX = (first.ox || 0) - (cssW / zoom - (first.w || 0)) / 2;
203 +			camY = (first.oy || 0) - (cssH / zoom - (first.h || 0)) / 2;
204 +			dirty = false;
205 +		});
206 +	} else if (msg.t === 'snap') {
207 +		// the server's stroke log got long - hand it flat images instead so
208 +		// the next person to join doesn't have to replay the whole session
209 +		net.send({ t: 'snapshot', upto: msg.upto, frames: frames.map(frameSnapshot) });
210 +	}
211 +}
212 +
213 +function netConnect() {
214 +	if (location.protocol !== 'http:' && location.protocol !== 'https:') return;
215 +	const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
216 +	ws.onopen = () => {
217 +		net.ws = ws;
218 +		net.on = true;
219 +		net.joined = true;
220 +		net.lastCursor = '';
221 +	};
222 +	ws.onmessage = (e) => {
223 +		let msg = null;
224 +		try { msg = JSON.parse(e.data); } catch (err) { return; }
225 +		netHandle(msg);
226 +	};
227 +	ws.onclose = () => {
228 +		const wasOn = net.on;
229 +		net.on = false;
230 +		net.ws = null;
231 +		net.peers.clear();
232 +		net.queue = null;
233 +		// whatever was in flight is moot: the server's state wins on the
234 +		// way back in
235 +		net.batch = null;
236 +		net.pending.length = 0;
237 +		net.overlays.clear();
238 +		if (wasOn) requestDraw();
239 +		// a page that was never in a room (opened from a file, or from a
240 +		// static host) stays solo; one that was keeps reaching for the
241 +		// server, which may just be restarting
242 +		if (net.joined) setTimeout(netConnect, 1500);
243 +	};
244 +	ws.onerror = () => ws.close();
245 +}
resources/render.js
  1 +let drawQueued = false;
  2 +function requestDraw() {
  3 +	if (drawQueued) return;
  4 +	drawQueued = true;
  5 +	requestAnimationFrame(() => {
  6 +		drawQueued = false;
  7 +		draw();
  8 +	});
  9 +}
 10 +
 11 +function draw() {
 12 +	const now = performance.now();
 13 +	if (outlineLightness !== outlineAnimTo) {
 14 +		const t = (now - outlineAnimStart) / OUTLINE_ANIM_MS;
 15 +		if (t >= 1) {
 16 +			outlineLightness = outlineAnimTo;
 17 +		} else {
 18 +			const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
 19 +			outlineLightness = outlineAnimFrom + (outlineAnimTo - outlineAnimFrom) * e;
 20 +			requestDraw();
 21 +		}
 22 +	}
 23 +	if (dispR !== colorAnimToR || dispG !== colorAnimToG || dispB !== colorAnimToB) {
 24 +		const t = (now - colorAnimStart) / OUTLINE_ANIM_MS;
 25 +		if (t >= 1) {
 26 +			dispR = colorAnimToR; dispG = colorAnimToG; dispB = colorAnimToB;
 27 +		} else {
 28 +			const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
 29 +			dispR = Math.round(colorAnimFromR + (colorAnimToR - colorAnimFromR) * e);
 30 +			dispG = Math.round(colorAnimFromG + (colorAnimToG - colorAnimFromG) * e);
 31 +			dispB = Math.round(colorAnimFromB + (colorAnimToB - colorAnimFromB) * e);
 32 +			requestDraw();
 33 +		}
 34 +	}
 35 +
 36 +	const W = view.width, H = view.height;
 37 +	vctx.setTransform(1, 0, 0, 1, 0, 0);
 38 +	vctx.imageSmoothingEnabled = false;
 39 +	vctx.fillStyle = '#000';
 40 +	vctx.fillRect(0, 0, W, H);
 41 +
 42 +	// one logical pixel on screen = integer device px. we round here so
 43 +	// every logical pixel occupies the exact same number of device pixels:
 44 +	// otherwise fractional dpr (1.25/1.5/1.75) makes nearest-neighbor
 45 +	// resampling drop or duplicate rows, producing transparent stripes
 46 +	// through painted content at high zoom.
 47 +	const pxD = Math.max(1, Math.round(zoom * dpr));
 48 +
 49 +	const viewWLog = cssW / zoom;
 50 +	const viewHLog = cssH / zoom;
 51 +	const wx0 = camX;
 52 +	const wy0 = camY;
 53 +	const wx1 = camX + viewWLog;
 54 +	const wy1 = camY + viewHLog;
 55 +
 56 +	const cx0 = Math.floor(wx0 / CHUNK);
 57 +	const cy0 = Math.floor(wy0 / CHUNK);
 58 +	const cx1 = Math.floor((wx1 - 1e-9) / CHUNK);
 59 +	const cy1 = Math.floor((wy1 - 1e-9) / CHUNK);
 60 +
 61 +	// round destinations to integer device pixels to avoid seams between
 62 +	// adjacent chunks. compute right/bottom edges from the neighbor's
 63 +	// rounded left/top so shared edges line up exactly.
 64 +	const destX = (wx) => Math.round((wx - camX) * pxD);
 65 +	const destY = (wy) => Math.round((wy - camY) * pxD);
 66 +
 67 +	for (let cy = cy0; cy <= cy1; cy++) {
 68 +		for (let cx = cx0; cx <= cx1; cx++) {
 69 +			const c = chunks.get(chunkKey(cx, cy));
 70 +			if (!c) continue;
 71 +			const x0 = destX(cx * CHUNK);
 72 +			const y0 = destY(cy * CHUNK);
 73 +			const x1 = destX((cx + 1) * CHUNK);
 74 +			const y1 = destY((cy + 1) * CHUNK);
 75 +			vctx.drawImage(c.canvas, x0, y0, x1 - x0, y1 - y0);
 76 +		}
 77 +	}
 78 +
 79 +	// our own strokes still waiting for their echo, composited over the frame
 80 +	const overlay = net.overlays.get(chunks.id);
 81 +	if (overlay) {
 82 +		for (let cy = cy0; cy <= cy1; cy++) {
 83 +			for (let cx = cx0; cx <= cx1; cx++) {
 84 +				const c = overlay.get(chunkKey(cx, cy));
 85 +				if (!c) continue;
 86 +				const x0 = destX(cx * CHUNK);
 87 +				const y0 = destY(cy * CHUNK);
 88 +				vctx.drawImage(c.canvas, x0, y0, destX((cx + 1) * CHUNK) - x0, destY((cy + 1) * CHUNK) - y0);
 89 +			}
 90 +		}
 91 +	}
 92 +
 93 +	// onionskin: ghost the frame behind us in playback order, screen-blended
 94 +	// so black contributes nothing over the opaque chunks
 95 +	if (onionskin && !playing && frames.length > 1) {
 96 +		const ghost = frames[(frameIdx - dir + frames.length) % frames.length];
 97 +		const ghostOverlay = net.overlays.get(ghost.id);
 98 +		vctx.save();
 99 +		vctx.globalAlpha = GHOST_ALPHA;
100 +		vctx.globalCompositeOperation = 'screen';
101 +		for (const layer of ghostOverlay ? [ghost, ghostOverlay] : [ghost]) {
102 +			for (let cy = cy0; cy <= cy1; cy++) {
103 +				for (let cx = cx0; cx <= cx1; cx++) {
104 +					const c = layer.get(chunkKey(cx, cy));
105 +					if (!c) continue;
106 +					const x0 = destX(cx * CHUNK);
107 +					const y0 = destY(cy * CHUNK);
108 +					vctx.drawImage(c.canvas, x0, y0, destX((cx + 1) * CHUNK) - x0, destY((cy + 1) * CHUNK) - y0);
109 +				}
110 +			}
111 +		}
112 +		vctx.restore();
113 +	}
114 +
115 +	if (cursorAlpha !== cursorFadeTo) {
116 +		const dur = cursorFadeTo > cursorFadeFrom ? CURSOR_FADE_IN_MS : CURSOR_FADE_OUT_MS;
117 +		const t = (now - cursorFadeStart) / dur;
118 +		if (t >= 1) {
119 +			cursorAlpha = cursorFadeTo;
120 +			if (cursorFadeTo === 0) mouseInside = false;
121 +		} else {
122 +			const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
123 +			cursorAlpha = cursorFadeFrom + (cursorFadeTo - cursorFadeFrom) * e;
124 +			requestDraw();
125 +		}
126 +	}
127 +	// peers first, so the local brush always sits on top of theirs. their
128 +	// fades run here rather than arriving over the wire, so a brush leaving
129 +	// a phone still eases out on everyone else's screen
130 +	for (const [id, p] of net.peers) {
131 +		if (p.alpha !== p.fadeTo) {
132 +			const dur = p.fadeTo > p.fadeFrom ? CURSOR_FADE_IN_MS : CURSOR_FADE_OUT_MS;
133 +			const t = (now - p.fadeStart) / dur;
134 +			if (t >= 1) {
135 +				p.alpha = p.fadeTo;
136 +			} else {
137 +				const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
138 +				p.alpha = p.fadeFrom + (p.fadeTo - p.fadeFrom) * e;
139 +				requestDraw();
140 +			}
141 +		}
142 +		if (p.alpha <= 0) {
143 +			if (p.fadeTo === 0) net.peers.delete(id);
144 +			continue;
145 +		}
146 +		if (!p.b) continue;
147 +		const off = Math.floor(p.b / 2);
148 +		const sx = Math.round((p.x - off - camX) * pxD);
149 +		const sy = Math.round((p.y - off - camY) * pxD);
150 +		if (sx > W || sy > H || sx + p.b * pxD < 0 || sy + p.b * pxD < 0) continue;
151 +		// someone standing on another frame is drawn the way that frame's
152 +		// own marks would be: ghosted, so their brush can't read as
153 +		// something on the frame you're actually painting
154 +		const ghost = p.f !== chunks.id;
155 +		const a = p.alpha * (ghost ? GHOST_ALPHA : 1);
156 +		drawCursorShape(sx, sy, p.b, p.s, p.c, p.c[0] + p.c[1] + p.c[2] > 384 ? 1 : 0, a, pxD, ghost);
157 +	}
158 +
159 +	if (mouseInside) {
160 +		const tl = brushTopLeft(curX, curY);
161 +		// round to integer device pixels - camX/camY are fractional (smooth
162 +		// pan), and fractional fillRect coordinates antialias their edges,
163 +		// which would leave transparent lines between adjacent row strips.
164 +		// during an active pan, anchor the cursor to the real client
165 +		// position (rounded only to device pixels) so it tracks smoothly
166 +		// instead of jittering as the logical-pixel floor flips back and
167 +		// forth under a fractional camera. when the pan ends the cursor
168 +		// snaps back to the logical-pixel grid via the idle timer below.
169 +		let sx, sy;
170 +		if (panning && curClientX !== null) {
171 +			const off = Math.floor(brush / 2);
172 +			sx = Math.round((curClientX - off * zoom) * dpr);
173 +			sy = Math.round((curClientY - off * zoom) * dpr);
174 +		} else {
175 +			sx = Math.round((tl.x - camX) * pxD);
176 +			sy = Math.round((tl.y - camY) * pxD);
177 +		}
178 +		drawCursorShape(sx, sy, brush, roundness, [dispR, dispG, dispB], outlineLightness, cursorAlpha, pxD);
179 +	}
180 +
181 +	net.flush();
182 +	net.sendCursor();
183 +}
184 +
185 +// a brush preview: solid fill in its own color, wrapped in a
186 +// 1-logical-pixel outline cross-faded between darken and lighten. as a
187 +// ghost it drops the outline and screens like onionskinned content, since
188 +// that is exactly what it is - a brush on some other frame
189 +function drawCursorShape(sx, sy, n, round, col, lightness, alpha, pxD, ghost) {
190 +	if (alpha <= 0) return;
191 +	const shape = round === 0 ? null : brushShapeFor(n, round, col[0], col[1], col[2]);
192 +	vctx.save();
193 +	vctx.globalAlpha = alpha;
194 +	if (ghost) vctx.globalCompositeOperation = 'screen';
195 +	vctx.fillStyle = 'rgb(' + col[0] + ',' + col[1] + ',' + col[2] + ')';
196 +	if (!shape) {
197 +		vctx.fillRect(sx, sy, n * pxD, n * pxD);
198 +	} else {
199 +		const runs = shape.fillRuns;
200 +		for (let i = 0; i < runs.length; i += 3) {
201 +			vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD);
202 +		}
203 +	}
204 +	if (ghost) {
205 +		vctx.restore();
206 +		return;
207 +	}
208 +
209 +	const drawOutline = () => {
210 +		if (shape) {
211 +			const runs = shape.outlineRuns;
212 +			for (let i = 0; i < runs.length; i += 3) {
213 +				vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD);
214 +			}
215 +		} else {
216 +			vctx.fillRect(sx - pxD, sy - pxD, (n + 2) * pxD, pxD); // top
217 +			vctx.fillRect(sx - pxD, sy + n * pxD, (n + 2) * pxD, pxD); // bottom
218 +			vctx.fillRect(sx - pxD, sy, pxD, n * pxD); // left
219 +			vctx.fillRect(sx + n * pxD, sy, pxD, n * pxD); // right
220 +		}
221 +	};
222 +	vctx.save();
223 +	if (lightness > 0) {
224 +		vctx.globalCompositeOperation = 'multiply';
225 +		vctx.globalAlpha = lightness * alpha;
226 +		vctx.fillStyle = 'rgb(128,128,128)';
227 +		drawOutline();
228 +	}
229 +	if (lightness < 1) {
230 +		vctx.globalCompositeOperation = 'screen';
231 +		vctx.globalAlpha = (1 - lightness) * alpha;
232 +		vctx.fillStyle = 'rgb(128,128,128)';
233 +		drawOutline();
234 +	}
235 +	vctx.restore();
236 +	vctx.restore();
237 +}
resources/styles.css
 1 +html, body {
 2 +	margin: 0;
 3 +	padding: 0;
 4 +	width: 100%;
 5 +	height: 100%;
 6 +	overflow: hidden;
 7 +	background: #000;
 8 +	cursor: none;
 9 +	overscroll-behavior: none;
10 +	touch-action: none;
11 +	-webkit-user-select: none;
12 +	user-select: none;
13 +	-webkit-touch-callout: none;
14 +}
15 +canvas {
16 +	display: block;
17 +	position: absolute;
18 +	top: 0;
19 +	left: 0;
20 +	image-rendering: pixelated;
21 +	image-rendering: crisp-edges;
22 +}
resources/view.js
 1 +let dpr = window.devicePixelRatio || 1;
 2 +let cssW = 0, cssH = 0;
 3 +
 4 +const ZOOM_STOPS = [2, 3, 4, 5, 6, 8, 11, 15, 21, 30];
 5 +const MIN_ZOOM = ZOOM_STOPS[0];
 6 +const ZOOM_SENSITIVITY = 3.5;	// fractional levels per unit log(pinch factor)
 7 +let zoomIdx = 0;
 8 +let zoom = MIN_ZOOM;
 9 +
10 +let camX = 0, camY = 0;
11 +
12 +let curX = 0, curY = 0;
13 +// used to keep the brush pinned under the real cursor while panning, since
14 +// the OS does not emit pointermove events when only the camera moves.
15 +let curClientX = null, curClientY = null;
16 +let mouseInside = false;
17 +let cursorAlpha = 0;
18 +let cursorFadeFrom = 0;
19 +let cursorFadeTo = 0;
20 +let cursorFadeStart = 0;
21 +const CURSOR_FADE_IN_MS = 100;
22 +const CURSOR_FADE_OUT_MS = 400;
23 +function setCursorFadeTarget(target) {
24 +	if (target === cursorFadeTo) return;
25 +	cursorFadeFrom = cursorAlpha;
26 +	cursorFadeTo = target;
27 +	cursorFadeStart = performance.now();
28 +	requestDraw();
29 +}
30 +function showCursor() {
31 +	mouseInside = true;
32 +	setCursorFadeTarget(1);
33 +}
34 +function hideCursorNow() {
35 +	mouseInside = false;
36 +	cursorAlpha = 0;
37 +	cursorFadeFrom = 0;
38 +	cursorFadeTo = 0;
39 +}
40 +
41 +function resize() {
42 +	const firstResize = cssW === 0;
43 +	dpr = window.devicePixelRatio || 1;
44 +	cssW = window.innerWidth;
45 +	cssH = window.innerHeight;
46 +	view.style.width = cssW + 'px';
47 +	view.style.height = cssH + 'px';
48 +	view.width = Math.floor(cssW * dpr);
49 +	view.height = Math.floor(cssH * dpr);
50 +	if (firstResize) {
51 +		curX = Math.floor(cssW / (2 * zoom));
52 +		curY = Math.floor(cssH / (2 * zoom));
53 +	}
54 +	requestDraw();
55 +}
56 +
57 +function clientToWorld(clientX, clientY) {
58 +	const lx = Math.floor(clientX / zoom + camX);
59 +	const ly = Math.floor(clientY / zoom + camY);
60 +	return { x: lx, y: ly };
61 +}
62 +
63 +function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
64 +
65 +function applyZoom(factor, ax, ay) {
66 +	const worldAtAnchorX = camX + ax / zoom;
67 +	const worldAtAnchorY = camY + ay / zoom;
68 +	zoomIdx = clamp(zoomIdx + Math.log(factor) * ZOOM_SENSITIVITY, 0, ZOOM_STOPS.length - 1);
69 +	const newZoom = ZOOM_STOPS[Math.round(zoomIdx)];
70 +	if (newZoom !== zoom) {
71 +		zoom = newZoom;
72 +		camX = worldAtAnchorX - ax / zoom;
73 +		camY = worldAtAnchorY - ay / zoom;
74 +	}
75 +}
76 +
77 +function zoomTo(newZoom) {
78 +	if (newZoom === zoom) return;
79 +	const ax = cssW / 2, ay = cssH / 2;
80 +	const worldAtAnchorX = camX + ax / zoom;
81 +	const worldAtAnchorY = camY + ay / zoom;
82 +	zoom = newZoom;
83 +	zoomIdx = ZOOM_STOPS.indexOf(zoom);
84 +	camX = worldAtAnchorX - ax / zoom;
85 +	camY = worldAtAnchorY - ay / zoom;
86 +	requestDraw();
87 +}
88 +
89 +function stepZoom(dir) {
90 +	const i = ZOOM_STOPS.indexOf(zoom);
91 +	zoomTo(ZOOM_STOPS[clamp(i + dir, 0, ZOOM_STOPS.length - 1)]);
92 +}
93 +function resetZoom() { zoomTo(MIN_ZOOM); }
serve.py
  1 +#!/usr/bin/env python3
  2 +"""serve animus over the local network with a shared canvas.
  3 +
  4 +usage: ./serve.py [port]
  5 +"""
  6 +
  7 +import base64
  8 +import hashlib
  9 +import json
 10 +import os
 11 +import queue
 12 +import socket
 13 +import struct
 14 +import sys
 15 +import threading
 16 +import time
 17 +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
 18 +
 19 +ROOT = os.path.dirname(os.path.abspath(__file__))
 20 +DEFAULT_PORT = 8000
 21 +# per-painter outbound backlog. a stroke burst is a few hundred small messages,
 22 +# so this is generous; past it the connection is too far behind to be correct
 23 +SEND_QUEUE_MAX = 4096
 24 +# rfc 6455 fixes this string
 25 +WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
 26 +MAX_PAYLOAD = 64 * 1024 * 1024
 27 +
 28 +# once the stored stroke log for the room passes this, ask a painter for flat
 29 +# images of the frames so joiners don't have to replay an entire session
 30 +SNAPSHOT_AFTER_OPS = 4000
 31 +SNAPSHOT_COOLDOWN = 15.0
 32 +
 33 +# the ways a connection ends when the other side simply walks off
 34 +DEAD_SOCKET = (ConnectionError, BrokenPipeError, TimeoutError, socket.timeout)
 35 +
 36 +
 37 +def read_exact(rfile, n):
 38 +	buf = b''
 39 +	while len(buf) < n:
 40 +		chunk = rfile.read(n - len(buf))
 41 +		if not chunk:
 42 +			return None
 43 +		buf += chunk
 44 +	return buf
 45 +
 46 +
 47 +def unmask(payload, mask):
 48 +	if not payload:
 49 +		return payload
 50 +	n = len(payload)
 51 +	# xor the whole payload as one big int: the repeated key is trimmed back
 52 +	# down to n bytes so byte i lines up with mask[i % 4]
 53 +	rep = mask * (n // 4 + 1)
 54 +	key = int.from_bytes(rep, 'big') >> (8 * (len(rep) - n))
 55 +	return (int.from_bytes(payload, 'big') ^ key).to_bytes(n, 'big')
 56 +
 57 +
 58 +def ws_recv(rfile, on_control=None):
 59 +	"""next complete data message as (opcode, payload), or None when the peer
 60 +	closes. control frames are answered inline so they can't interrupt a
 61 +	message that arrives in fragments."""
 62 +	data = b''
 63 +	msg_op = None
 64 +	while True:
 65 +		hdr = read_exact(rfile, 2)
 66 +		if hdr is None:
 67 +			return None
 68 +		fin = hdr[0] & 0x80
 69 +		op = hdr[0] & 0x0f
 70 +		masked = hdr[1] & 0x80
 71 +		length = hdr[1] & 0x7f
 72 +		if length == 126:
 73 +			ext = read_exact(rfile, 2)
 74 +			if ext is None:
 75 +				return None
 76 +			length = struct.unpack('>H', ext)[0]
 77 +		elif length == 127:
 78 +			ext = read_exact(rfile, 8)
 79 +			if ext is None:
 80 +				return None
 81 +			length = struct.unpack('>Q', ext)[0]
 82 +		if length > MAX_PAYLOAD:
 83 +			return None
 84 +		mask = read_exact(rfile, 4) if masked else b''
 85 +		if masked and mask is None:
 86 +			return None
 87 +		payload = read_exact(rfile, length) if length else b''
 88 +		if payload is None:
 89 +			return None
 90 +		if masked:
 91 +			payload = unmask(payload, mask)
 92 +		if op & 0x8:
 93 +			if op == 0x8:
 94 +				return None
 95 +			if op == 0x9 and on_control:
 96 +				on_control(0xa, payload)
 97 +			continue
 98 +		if op != 0:
 99 +			msg_op = op
100 +			data = payload
101 +		else:
102 +			data += payload
103 +		if fin:
104 +			return msg_op or 1, data
105 +
106 +
107 +def ws_frame(payload, opcode=1):
108 +	head = bytearray([0x80 | opcode])
109 +	n = len(payload)
110 +	if n < 126:
111 +		head.append(n)
112 +	elif n < 65536:
113 +		head.append(126)
114 +		head += struct.pack('>H', n)
115 +	else:
116 +		head.append(127)
117 +		head += struct.pack('>Q', n)
118 +	return bytes(head) + payload
119 +
120 +
121 +class Conn:
122 +	"""one painter. writes go through a queue and a dedicated thread so a
123 +	painter on bad wifi can never stall the room: their backlog is theirs."""
124 +
125 +	_seq = 0
126 +	_seq_lock = threading.Lock()
127 +
128 +	def __init__(self, sock):
129 +		self.sock = sock
130 +		self.alive = True
131 +		self.joined = time.time()
132 +		self.q = queue.Queue(maxsize=SEND_QUEUE_MAX)
133 +		with Conn._seq_lock:
134 +			Conn._seq += 1
135 +			self.id = 'c%d' % Conn._seq
136 +		self.writer = threading.Thread(target=self._pump, daemon=True)
137 +		self.writer.start()
138 +
139 +	def _pump(self):
140 +		while True:
141 +			item = self.q.get()
142 +			if item is None:
143 +				return
144 +			payload, opcode = item
145 +			try:
146 +				self.sock.sendall(ws_frame(payload, opcode))
147 +			except OSError:
148 +				self.kill()
149 +				return
150 +
151 +	def kill(self):
152 +		"""drop the connection; the page reconnects and re-syncs from scratch"""
153 +		if not self.alive:
154 +			return
155 +		self.alive = False
156 +		try:
157 +			self.sock.shutdown(socket.SHUT_RDWR)
158 +		except OSError:
159 +			pass
160 +		try:
161 +			self.q.put_nowait(None)
162 +		except queue.Full:
163 +			pass
164 +
165 +	def raw(self, payload, opcode=1, droppable=False):
166 +		if not self.alive:
167 +			return
168 +		try:
169 +			self.q.put_nowait((payload, opcode))
170 +		except queue.Full:
171 +			# a stale brush position is worth nothing, so let it go. a lost
172 +			# stroke would desync this painter for good, so cut them loose
173 +			# instead and let the reconnect hand them the room again
174 +			if not droppable:
175 +				self.kill()
176 +
177 +	def send(self, obj, droppable=False):
178 +		self.raw(json.dumps(obj, separators=(',', ':')).encode('utf-8'), droppable=droppable)
179 +
180 +
181 +class Room:
182 +	def __init__(self):
183 +		self.lock = threading.RLock()
184 +		self.clients = []
185 +		self.frame_seq = 0
186 +		self.op_seq = 0
187 +		self.op_count = 0	# stored strokes, tracked rather than recounted
188 +		self.frames = [self.blank_frame()]
189 +		self.index = {f['id']: f for f in self.frames}
190 +		self.interval = 250
191 +		self.snap_pending = False
192 +		self.snap_at = 0.0
193 +
194 +	def blank_frame(self):
195 +		self.frame_seq += 1
196 +		return {'id': 'f%d' % self.frame_seq, 'img': None, 'ox': 0, 'oy': 0, 'w': 0, 'h': 0, 'ops': []}
197 +
198 +	def base_frame(self, src):
199 +		f = self.blank_frame()
200 +		if isinstance(src, dict) and src.get('img'):
201 +			f['img'] = src['img']
202 +			f['ox'] = int(src.get('ox', 0))
203 +			f['oy'] = int(src.get('oy', 0))
204 +			f['w'] = int(src.get('w', 0))
205 +			f['h'] = int(src.get('h', 0))
206 +		return f
207 +
208 +	def frame(self, fid):
209 +		return self.index.get(fid)
210 +
211 +	def wire_frames(self, with_ops=True):
212 +		out = []
213 +		for f in self.frames:
214 +			e = {'id': f['id'], 'img': f['img'], 'ox': f['ox'], 'oy': f['oy'], 'w': f['w'], 'h': f['h']}
215 +			if with_ops:
216 +				e['ops'] = f['ops']
217 +			out.append(e)
218 +		return out
219 +
220 +	def broadcast(self, msg, skip=None, droppable=False):
221 +		payload = json.dumps(msg, separators=(',', ':')).encode('utf-8')
222 +		with self.lock:
223 +			targets = [c for c in self.clients if c is not skip]
224 +		for c in targets:
225 +			c.raw(payload, droppable=droppable)
226 +
227 +	def join(self, conn):
228 +		with self.lock:
229 +			self.clients.append(conn)
230 +			conn.send({
231 +				't': 'init',
232 +				'id': conn.id,
233 +				'frames': self.wire_frames(),
234 +				'interval': self.interval,
235 +			})
236 +			print('  + %s joined (%d painting)' % (conn.id, len(self.clients)))
237 +
238 +	def leave(self, conn):
239 +		with self.lock:
240 +			if conn in self.clients:
241 +				self.clients.remove(conn)
242 +			# the painter we asked for a flat copy may be the one leaving; let
243 +			# the next stroke ask someone else
244 +			self.snap_pending = False
245 +			print('  - %s left   (%d painting)' % (conn.id, len(self.clients)))
246 +		self.broadcast({'t': 'bye', 'id': conn.id})
247 +
248 +	def maybe_snapshot(self):
249 +		"""ask one painter to flatten the room so the stroke log stays bounded"""
250 +		now = time.time()
251 +		if self.snap_pending or now - self.snap_at < SNAPSHOT_COOLDOWN:
252 +			return
253 +		if self.op_count < SNAPSHOT_AFTER_OPS or not self.clients:
254 +			return
255 +		self.snap_pending = True
256 +		self.snap_at = now
257 +		self.clients[0].send({'t': 'snap', 'upto': self.op_seq})
258 +
259 +	def set_frames(self, frames):
260 +		self.frames = frames
261 +		self.index = {f['id']: f for f in frames}
262 +		self.op_count = sum(len(f['ops']) for f in frames)
263 +
264 +	def handle(self, conn, msg):
265 +		t = msg.get('t')
266 +		if t == 'p':
267 +			with self.lock:
268 +				f = self.frame(msg.get('f'))
269 +				if f is None:
270 +					return
271 +				self.op_seq += 1
272 +				op = {
273 +					'n': self.op_seq,
274 +					'o': msg.get('o'),
275 +					'c': msg.get('c'),
276 +					'b': msg.get('b'),
277 +					's': msg.get('s'),
278 +				}
279 +				f['ops'].append(op)
280 +				self.op_count += 1
281 +				self.maybe_snapshot()
282 +				out = dict(op)
283 +				out['t'] = 'p'
284 +				out['f'] = f['id']
285 +				out['by'] = conn.id
286 +				# the painter's own tag for this batch, so they can recognise it
287 +				# coming back; not stored, it means nothing to anyone else
288 +				if msg.get('k') is not None:
289 +					out['k'] = msg['k']
290 +				# broadcast under the lock so wire order matches sequence order;
291 +				# sends only enqueue, so nothing slow happens in here. echoed to
292 +				# the painter too: the ack is what commits their stroke
293 +				self.broadcast(out)
294 +		elif t == 'c':
295 +			msg['id'] = conn.id
296 +			self.broadcast(msg, skip=conn, droppable=True)
297 +		elif t == 'af':
298 +			with self.lock:
299 +				at = max(0, min(int(msg.get('at', len(self.frames))), len(self.frames)))
300 +				f = self.blank_frame()
301 +				self.frames.insert(at, f)
302 +				self.index[f['id']] = f
303 +				self.broadcast({'t': 'af', 'at': at, 'id': f['id'], 'by': conn.id})
304 +		elif t == 'df':
305 +			with self.lock:
306 +				if len(self.frames) <= 1:
307 +					return
308 +				f = self.frame(msg.get('id'))
309 +				if f is None:
310 +					return
311 +				self.frames.remove(f)
312 +				del self.index[f['id']]
313 +				self.op_count -= len(f['ops'])
314 +				self.broadcast({'t': 'df', 'id': f['id'], 'by': conn.id})
315 +		elif t == 'reset':
316 +			srcs = msg.get('frames') or []
317 +			if not srcs:
318 +				return
319 +			with self.lock:
320 +				self.set_frames([self.base_frame(s) for s in srcs])
321 +				self.interval = int(msg.get('interval') or self.interval)
322 +				self.snap_pending = False
323 +				self.broadcast({
324 +					't': 'reset',
325 +					'frames': self.wire_frames(with_ops=False),
326 +					'interval': self.interval,
327 +					'by': conn.id,
328 +				})
329 +		elif t == 'snapshot':
330 +			upto = int(msg.get('upto') or 0)
331 +			with self.lock:
332 +				self.snap_pending = False
333 +				for src in msg.get('frames') or []:
334 +					f = self.frame(src.get('id'))
335 +					if f is None:
336 +						continue
337 +					f['img'] = src.get('img')
338 +					f['ox'] = int(src.get('ox', 0))
339 +					f['oy'] = int(src.get('oy', 0))
340 +					f['w'] = int(src.get('w', 0))
341 +					f['h'] = int(src.get('h', 0))
342 +				# strokes newer than the snapshot are kept; a stroke that made it
343 +				# into the image and also survives here just paints itself twice
344 +				kept = 0
345 +				for f in self.frames:
346 +					f['ops'] = [o for o in f['ops'] if o['n'] > upto]
347 +					kept += len(f['ops'])
348 +				self.op_count = kept
349 +		elif t == 'interval':
350 +			with self.lock:
351 +				self.interval = int(msg.get('v') or self.interval)
352 +
353 +
354 +room = Room()
355 +
356 +
357 +class Server(ThreadingHTTPServer):
358 +	daemon_threads = True
359 +	# a whole request thread can still unwind on a dropped socket, outside any
360 +	# handler we control. same story, same silence
361 +	def handle_error(self, request, client_address):
362 +		if isinstance(sys.exc_info()[1], DEAD_SOCKET):
363 +			return
364 +		super().handle_error(request, client_address)
365 +
366 +
367 +class Handler(SimpleHTTPRequestHandler):
368 +	protocol_version = 'HTTP/1.1'
369 +	server_version = 'animus'
370 +	# tiny realtime frames must go out now, not sit in nagle's buffer waiting
371 +	# on a watcher's delayed acks
372 +	disable_nagle_algorithm = True
373 +
374 +	def __init__(self, *a, **kw):
375 +		super().__init__(*a, directory=ROOT, **kw)
376 +
377 +	def log_message(self, fmt, *args):
378 +		pass
379 +
380 +	def handle(self):
381 +		try:
382 +			super().handle()
383 +		except DEAD_SOCKET:
384 +			self.close_connection = True
385 +
386 +	def finish(self):
387 +		try:
388 +			super().finish()
389 +		except DEAD_SOCKET:
390 +			pass
391 +
392 +	def end_headers(self):
393 +		self.send_header('Cache-Control', 'no-store')
394 +		super().end_headers()
395 +
396 +	def do_GET(self):
397 +		if self.path.split('?')[0] == '/ws':
398 +			self.do_ws()
399 +			return
400 +		super().do_GET()
401 +
402 +	def do_ws(self):
403 +		key = self.headers.get('Sec-WebSocket-Key')
404 +		if not key or 'websocket' not in (self.headers.get('Upgrade') or '').lower():
405 +			self.send_error(400, 'expected a websocket upgrade')
406 +			return
407 +		accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode()
408 +		self.close_connection = True
409 +		self.send_response(101, 'Switching Protocols')
410 +		self.send_header('Upgrade', 'websocket')
411 +		self.send_header('Connection', 'Upgrade')
412 +		self.send_header('Sec-WebSocket-Accept', accept)
413 +		self.end_headers()
414 +		self.wfile.flush()
415 +
416 +		conn = Conn(self.connection)
417 +		room.join(conn)
418 +		try:
419 +			pong = lambda code, data: conn.raw(data, code)
420 +			while conn.alive:
421 +				msg = ws_recv(self.rfile, pong)
422 +				if msg is None:
423 +					break
424 +				op, payload = msg
425 +				if op != 0x1:
426 +					continue
427 +				try:
428 +					data = json.loads(payload.decode('utf-8'))
429 +				except (ValueError, UnicodeDecodeError):
430 +					continue
431 +				if isinstance(data, dict):
432 +					room.handle(conn, data)
433 +		except OSError:
434 +			pass
435 +		finally:
436 +			conn.kill()
437 +			room.leave(conn)
438 +
439 +
440 +def lan_ip():
441 +	s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
442 +	try:
443 +		s.connect(('10.255.255.255', 1))
444 +		return s.getsockname()[0]
445 +	except OSError:
446 +		return '127.0.0.1'
447 +	finally:
448 +		s.close()
449 +
450 +
451 +def main():
452 +	port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT
453 +	for attempt in range(20):
454 +		try:
455 +			httpd = Server(('0.0.0.0', port + attempt), Handler)
456 +			break
457 +		except OSError:
458 +			continue
459 +	else:
460 +		print('no free port near %d' % port)
461 +		return 1
462 +	port = httpd.server_address[1]
463 +
464 +	print('\nPress Ctrl+C to stop the server.\n')
465 +	print('http://%s:%d\n' % (lan_ip(), port))
466 +
467 +
468 +	try:
469 +		httpd.serve_forever()
470 +	except KeyboardInterrupt:
471 +		print('\n\nShutting down server...')
472 +	return 0
473 +
474 +
475 +if __name__ == '__main__':
476 +	sys.stdout.reconfigure(line_buffering=True)
477 +	sys.exit(main())