summaryrefslogtreecommitdiff
path: root/static/js/canvas.js
diff options
context:
space:
mode:
authorAlexander Hunt <me@liz.coffee>2026-06-21 12:43:31 -0700
committerAlexander Hunt <me@liz.coffee>2026-06-21 12:43:31 -0700
commitcfc0d34a584254a9ed76620951c810771aff6f3b (patch)
tree7fba034f7347ce0003e7a3a56adcaf862c834907 /static/js/canvas.js
parentfb653292612eddca5b088ed88466c546d1597107 (diff)
downloadpenguins.lan-cfc0d34a584254a9ed76620951c810771aff6f3b.tar.gz
penguins.lan-cfc0d34a584254a9ed76620951c810771aff6f3b.zip
STUFF
Diffstat (limited to 'static/js/canvas.js')
-rw-r--r--static/js/canvas.js214
1 files changed, 178 insertions, 36 deletions
diff --git a/static/js/canvas.js b/static/js/canvas.js
index f164803..3c4947c 100644
--- a/static/js/canvas.js
+++ b/static/js/canvas.js
@@ -2,34 +2,41 @@
const cfg = window.CANVAS;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
- canvas.style.width = cfg.cols * cfg.scale + "px";
- canvas.style.height = cfg.rows * cfg.scale + "px";
+ const viewport = document.getElementById("viewport");
+ const coordsEl = document.getElementById("coords");
+ const cursorEl = document.getElementById("pixel-cursor");
+ const cdEl = document.getElementById("cooldown");
- // palette picker
- let selected = 0;
- const paletteEl = document.getElementById("palette");
- cfg.palette.forEach((hex, i) => {
- const sw = document.createElement("div");
- sw.className = "swatch" + (i === 0 ? " 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);
- });
+ 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);
- };
+ const drawPixel = (x, y, hex) => { ctx.fillStyle = hex; ctx.fillRect(x, y, 1, 1); };
- // The server is authoritative: we only paint pixels it broadcasts back. Open
- // the socket first and queue messages until the initial state is painted, so
- // nothing placed during the load is lost.
let loaded = false;
const queue = [];
function apply(msg) {
@@ -41,10 +48,7 @@
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.onmessage = (e) => { const msg = JSON.parse(e.data); loaded ? apply(msg) : queue.push(msg); };
ws.onclose = () => setTimeout(connect, 1000);
ws.onerror = () => ws.close();
}
@@ -56,7 +60,7 @@
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] = 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;
@@ -67,9 +71,22 @@
queue.length = 0;
});
- // cooldown indicator (server enforces; this is UI feedback)
+ 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;
- const cdEl = document.getElementById("cooldown");
function startCooldown(ms) {
cooldownUntil = Math.max(cooldownUntil, Date.now() + ms);
tick();
@@ -84,14 +101,139 @@
}
}
- canvas.addEventListener("click", (e) => {
+ 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 rect = canvas.getBoundingClientRect();
- const x = Math.floor(((e.clientX - rect.left) / rect.width) * cfg.cols);
- const y = Math.floor(((e.clientY - rect.top) / rect.height) * cfg.rows);
- if (x < 0 || x >= cfg.cols || y < 0 || y >= cfg.rows) 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 });
+
})();