(function () { const cfg = window.CANVAS; const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d"); const viewport = document.getElementById("viewport"); const coordsEl = document.getElementById("coords"); const cursorEl = document.getElementById("pixel-cursor"); const cdEl = document.getElementById("cooldown"); const ZOOM_MIN = 1, ZOOM_MAX = 64, DEFAULT_ZOOM = 16; let cam = { x: 0, y: 0, zoom: DEFAULT_ZOOM }; function clamp() { const vw = viewport.clientWidth, vh = viewport.clientHeight; const w = cfg.cols * cam.zoom, h = cfg.rows * cam.zoom; const margin = Math.min(vw, vh) * 0.5; cam.x = Math.min(margin, Math.max(vw - w - margin, cam.x)); cam.y = Math.min(margin, Math.max(vh - h - margin, cam.y)); } function applyTransform() { clamp(); canvas.style.transform = `translate(${cam.x}px,${cam.y}px) scale(${cam.zoom})`; } function resetView() { const vw = viewport.clientWidth, vh = viewport.clientHeight; cam.zoom = DEFAULT_ZOOM; cam.x = Math.round(vw / 2 - (cfg.cols / 2) * cam.zoom); cam.y = Math.round(vh / 2 - (cfg.rows / 2) * cam.zoom); applyTransform(); } resetView(); window.addEventListener("resize", applyTransform); const hexFromInt = (c) => "#" + c.toString(16).padStart(6, "0"); const drawPixel = (x, y, hex) => { ctx.fillStyle = hex; ctx.fillRect(x, y, 1, 1); }; let loaded = false; const queue = []; function apply(msg) { if (msg.type === "place") drawPixel(msg.x, msg.y, hexFromInt(msg.c)); else if (msg.type === "cooldown") startCooldown(msg.ms); } let ws; function connect() { const proto = location.protocol === "https:" ? "wss" : "ws"; ws = new WebSocket(`${proto}://${location.host}/canvas/ws`); ws.onmessage = (e) => { const msg = JSON.parse(e.data); loaded ? apply(msg) : queue.push(msg); }; ws.onclose = () => setTimeout(connect, 1000); ws.onerror = () => ws.close(); } connect(); fetch("/canvas/state") .then((r) => r.arrayBuffer()) .then((buf) => { const bytes = new Uint8Array(buf); const img = ctx.createImageData(cfg.cols, cfg.rows); for (let p = 0; p < cfg.cols * cfg.rows; p++) { img.data[p * 4] = bytes[p * 3]; img.data[p * 4 + 1] = bytes[p * 3 + 1]; img.data[p * 4 + 2] = bytes[p * 3 + 2]; img.data[p * 4 + 3] = 255; } ctx.putImageData(img, 0, 0); loaded = true; queue.forEach(apply); queue.length = 0; }); let selected = 6; const paletteEl = document.getElementById("palette"); cfg.palette.forEach((hex, i) => { const sw = document.createElement("div"); sw.className = "swatch" + (i === selected ? " selected" : ""); sw.style.background = hex; sw.title = hex; sw.addEventListener("click", () => { selected = i; [...paletteEl.children].forEach((c) => c.classList.remove("selected")); sw.classList.add("selected"); }); paletteEl.appendChild(sw); }); let cooldownUntil = 0; function startCooldown(ms) { cooldownUntil = Math.max(cooldownUntil, Date.now() + ms); tick(); } function tick() { const rem = cooldownUntil - Date.now(); if (rem > 0) { cdEl.textContent = `cooldown: ${(rem / 1000).toFixed(1)}s`; requestAnimationFrame(tick); } else { cdEl.textContent = ""; } } function toCanvas(vx, vy) { return { x: Math.floor((vx - cam.x) / cam.zoom), y: Math.floor((vy - cam.y) / cam.zoom) }; } function inBounds(x, y) { return x >= 0 && x < cfg.cols && y >= 0 && y < cfg.rows; } function place(vx, vy) { if (Date.now() < cooldownUntil) return; if (!ws || ws.readyState !== WebSocket.OPEN) return; const { x, y } = toCanvas(vx, vy); if (!inBounds(x, y)) return; ws.send(JSON.stringify({ type: "place", x, y, i: selected })); startCooldown(cfg.cooldownMs); } function updateCursor(vx, vy) { const { x, y } = toCanvas(vx, vy); if (inBounds(x, y) && cam.zoom >= 3) { const px = cam.x + x * cam.zoom; const py = cam.y + y * cam.zoom; cursorEl.style.cssText = `display:block;width:${cam.zoom}px;height:${cam.zoom}px;transform:translate(${px}px,${py}px)`; coordsEl.textContent = `${x}, ${y}`; } else { cursorEl.style.display = "none"; coordsEl.textContent = inBounds(x, y) ? `${x}, ${y}` : ""; } } function zoomAt(vx, vy, factor) { const z = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, cam.zoom * factor)); cam.x = vx - (vx - cam.x) * (z / cam.zoom); cam.y = vy - (vy - cam.y) * (z / cam.zoom); cam.zoom = z; applyTransform(); } let drag = { on: false, sx: 0, sy: 0, cx: 0, cy: 0, moved: false }; viewport.addEventListener("mousedown", (e) => { if (e.button !== 0) return; drag = { on: true, sx: e.clientX, sy: e.clientY, cx: cam.x, cy: cam.y, moved: false }; viewport.classList.add("dragging"); e.preventDefault(); }); window.addEventListener("mousemove", (e) => { if (drag.on) { const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) drag.moved = true; cam.x = drag.cx + dx; cam.y = drag.cy + dy; applyTransform(); } const rect = viewport.getBoundingClientRect(); updateCursor(e.clientX - rect.left, e.clientY - rect.top); }); window.addEventListener("mouseup", (e) => { if (!drag.on) return; if (!drag.moved) { const rect = viewport.getBoundingClientRect(); place(e.clientX - rect.left, e.clientY - rect.top); } drag.on = false; viewport.classList.remove("dragging"); }); viewport.addEventListener("mouseleave", () => { cursorEl.style.display = "none"; coordsEl.textContent = ""; }); viewport.addEventListener("wheel", (e) => { e.preventDefault(); const rect = viewport.getBoundingClientRect(); zoomAt(e.clientX - rect.left, e.clientY - rect.top, e.deltaY < 0 ? 1.15 : 1 / 1.15); }, { passive: false }); let pinchDist = null; let tapOk = false; function tdist(a, b) { const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY; return Math.sqrt(dx * dx + dy * dy); } function tmid(a, b) { return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 }; } viewport.addEventListener("touchstart", (e) => { e.preventDefault(); if (e.touches.length === 1) { const t = e.touches[0]; drag = { on: true, sx: t.clientX, sy: t.clientY, cx: cam.x, cy: cam.y, moved: false }; pinchDist = null; tapOk = true; } else if (e.touches.length === 2) { drag.on = false; tapOk = false; pinchDist = tdist(e.touches[0], e.touches[1]); } }, { passive: false }); viewport.addEventListener("touchmove", (e) => { e.preventDefault(); const rect = viewport.getBoundingClientRect(); if (e.touches.length === 1 && drag.on) { const t = e.touches[0]; const dx = t.clientX - drag.sx, dy = t.clientY - drag.sy; if (Math.abs(dx) > 4 || Math.abs(dy) > 4) { drag.moved = true; tapOk = false; } cam.x = drag.cx + dx; cam.y = drag.cy + dy; applyTransform(); } else if (e.touches.length === 2 && pinchDist !== null) { const d = tdist(e.touches[0], e.touches[1]); const m = tmid(e.touches[0], e.touches[1]); zoomAt(m.x - rect.left, m.y - rect.top, d / pinchDist); pinchDist = d; } }, { passive: false }); viewport.addEventListener("touchend", (e) => { e.preventDefault(); if (tapOk && e.changedTouches.length === 1) { const t = e.changedTouches[0]; const rect = viewport.getBoundingClientRect(); place(t.clientX - rect.left, t.clientY - rect.top); } drag.on = false; tapOk = false; if (e.touches.length < 2) pinchDist = null; }, { passive: false }); })();