From cfc0d34a584254a9ed76620951c810771aff6f3b Mon Sep 17 00:00:00 2001 From: Alexander Hunt Date: Sun, 21 Jun 2026 12:43:31 -0700 Subject: STUFF --- static/js/canvas.js | 216 +++++++++++++++++++++++++++++++++++++++--------- static/js/dns-wizard.js | 43 ++++++++++ 2 files changed, 222 insertions(+), 37 deletions(-) create mode 100644 static/js/dns-wizard.js (limited to 'static/js') 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); - }; - - // 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. + const drawPixel = (x, y, hex) => { ctx.fillStyle = hex; ctx.fillRect(x, y, 1, 1); }; + 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 }); + })(); diff --git a/static/js/dns-wizard.js b/static/js/dns-wizard.js new file mode 100644 index 0000000..2ee7162 --- /dev/null +++ b/static/js/dns-wizard.js @@ -0,0 +1,43 @@ +(function () { + const cfg = window.DNS; + if (!cfg) return; + + const form = (obj) => { + const body = new URLSearchParams(obj); + return { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body }; + }; + const addRecord = (type, name, content, ttl) => + fetch("/dns", form({ type, name, content, ttl: String(ttl || 300) })); + const setPrimary = (mac) => fetch("/profile/primary", form({ mac })); + + // The wizard seeds the basics: . -> primary device, plus a starter + // alias per device you can rename or delete later. The server replaces an + // existing CNAME at a name, so re-running this is safe. + const wiz = document.getElementById("dns-wizard"); + if (wiz) { + wiz.addEventListener("click", async () => { + wiz.disabled = true; + wiz.textContent = "wiring things up…"; + const primary = cfg.devices.find((d) => d.mac === cfg.primaryMac) || cfg.devices[0]; + if (primary) await addRecord("CNAME", cfg.userDomain, primary.name, 300); + let n = 1; + for (const d of cfg.devices) { + await addRecord("CNAME", "device-" + n + "." + cfg.userDomain, d.name, 300); + n++; + } + location.reload(); + }); + } + + // Picking a primary updates the stored choice and repoints . at it + // in one go, all client-side. + document.querySelectorAll(".set-primary").forEach((btn) => { + btn.addEventListener("click", async (e) => { + e.preventDefault(); + btn.disabled = true; + await setPrimary(btn.dataset.mac); + await addRecord("CNAME", cfg.userDomain, btn.dataset.name, 300); + location.reload(); + }); + }); +})(); -- cgit v1.3