v1.0.0 — Hexagonal architecture rewrite
All checks were successful
CI / ci (push) Successful in 7m16s

Restructure the monolithic 252-line main.rs into a 10-crate workspace
with clean hexagonal architecture, swappable adapters, and a production-
ready deployment pipeline.

Backend architecture:
- domain: Canvas, Color/Position/PixelUpdate value objects, port traits
  (CanvasStore, CanvasPersistence, EventBroadcaster), BroadcastEvent
- application: use cases (place_pixel, get_state, save/restore snapshot,
  connect/disconnect), AppState with Arc snapshot cache
- config: AppConfig structs + ConfigSource trait
- api-types: shared DTOs, event name constants
- adapters: config-env, canvas-file, http-axum (rust-embed), socketio,
  websocket — all behind port traits, swappable via feature flags
- server: composition root with graceful shutdown (SIGTERM/SIGINT)

Frontend:
- Transport abstraction: Socket.IO and native WebSocket via VITE_TRANSPORT
- Canvas zoom/pan with mouse wheel, pinch-to-zoom, and +/- buttons
- ImageData rendering (~50x faster than fillRect loop)
- Touch support, responsive CSS scaling, mobile-friendly layout
- OG/Twitter Card meta tags for rich link previews

Production:
- Docker: musl static build on scratch — 2.73MB image
- CI workflows for Gitea and GitHub Actions (fmt, clippy, test, Docker push)
- deploy.sh for private registry
- 39 unit tests across domain and application
- Zero unwraps, zero unsafe, graceful error handling with tracing
- Periodic canvas snapshots with rotation, restored on startup
- All config via environment variables with typed defaults
This commit is contained in:
2026-08-18 01:41:40 +02:00
parent e9bea5e1e5
commit f652785acb
85 changed files with 4240 additions and 3736 deletions

Binary file not shown.

View File

@@ -1,267 +0,0 @@
import {
hexToU32,
u32ToHex,
getColorFromElementCSS,
rgbToHex,
} from "./utils.js";
import {
pixelSize,
pixelCooldown,
canvasEndpoint,
WIDTH,
HEIGHT,
} from "./constants.js";
let socket = null;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const countdownDiv = document.getElementById("countdown");
let lastPixelTime = parseInt(localStorage.getItem("lastPixelTime") || "0");
const colorPicker = document.getElementById("color-picker");
const redButton = document.getElementById("red");
const greenButton = document.getElementById("green");
const blueButton = document.getElementById("blue");
const yellowButton = document.getElementById("yellow");
const purpleButton = document.getElementById("purple");
const pinkButton = document.getElementById("pink");
const cyanButton = document.getElementById("cyan");
const whiteButton = document.getElementById("white");
const blackButton = document.getElementById("black");
const orangeButton = document.getElementById("orange");
const brownButton = document.getElementById("brown");
const currentColorSpan = document.getElementById("current-color-span");
const toggleGridToggle = document.getElementById("toggle-grid");
const placePixelButton = document.getElementById("place-pixel");
const saveCanvasButton = document.getElementById("save-canvas");
colorPicker.value = localStorage.getItem("currentColor") || "#000000";
let currentColor = colorPicker.value;
let showGrid = toggleGridToggle.checked;
currentColorSpan.style.backgroundColor = currentColor;
let canvasState = [];
let confirmPlacePixel = false;
let previewPixel = null;
const setCurrentColor = (color, isColorPicker = false) => {
const hexColor = isColorPicker ? color : rgbToHex(color);
currentColor = hexColor;
currentColorSpan.style.backgroundColor = hexColor;
localStorage.setItem("currentColor", hexColor);
};
const handleColorPicker = () => {
colorPicker.addEventListener("input", (event) => {
setCurrentColor(event.target.value, true);
});
redButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(redButton));
});
greenButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(greenButton));
});
blueButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(blueButton));
});
yellowButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(yellowButton));
});
purpleButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(purpleButton));
});
pinkButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(pinkButton));
});
cyanButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(cyanButton));
});
whiteButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(whiteButton));
});
blackButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(blackButton));
});
orangeButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(orangeButton));
});
brownButton.addEventListener("click", () => {
setCurrentColor(getColorFromElementCSS(brownButton));
});
};
const fetchCanvasState = async () => {
fetch(canvasEndpoint)
.then((response) => response.json())
.then((data) => {
canvasState = data;
drawCanvasState(data);
})
.catch((error) => {
alert("Error fetching canvas state from server. Please try again later.");
});
};
const drawCanvasState = (canvasState) => {
for (let y = 0; y < HEIGHT; y++) {
for (let x = 0; x < WIDTH; x++) {
const index = y * WIDTH + x;
const color = u32ToHex(canvasState[index]);
ctx.fillStyle = color;
ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
}
}
};
const checkIfCanPlacePixel = () => {
const now = Date.now();
return now - lastPixelTime >= pixelCooldown;
};
const setLastPixelTime = () => {
lastPixelTime = Date.now();
localStorage.setItem("lastPixelTime", lastPixelTime.toString());
};
const handlePlacePixel = (pixelData) => {
if (!checkIfCanPlacePixel()) {
alert("You can't place a pixel yet");
return;
}
socket.emit("place-pixel", pixelData);
const index = pixelData.y * WIDTH + pixelData.x;
canvasState[index] = pixelData.color;
setLastPixelTime();
pixelData = null;
};
canvas.addEventListener("click", (event) => {
const rect = canvas.getBoundingClientRect();
const x = Math.floor((event.clientX - rect.left) / pixelSize);
const y = Math.floor((event.clientY - rect.top) / pixelSize);
const color = hexToU32(currentColor);
const oldPreviewPixel = previewPixel;
const update = { x, y, color };
if (confirmPlacePixel) {
handlePlacePixel(update);
} else {
previewPixel = update;
}
if (previewPixel) {
if (oldPreviewPixel) {
ctx.clearRect(
oldPreviewPixel.x * pixelSize,
oldPreviewPixel.y * pixelSize,
pixelSize,
pixelSize
);
}
ctx.fillStyle = `rgba(${(color >> 16) & 0xff}, ${(color >> 8) & 0xff}, ${
color & 0xff
}, 0.5)`;
ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
}
});
const removePreviewPixel = () => {
previewPixel = null;
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawCanvasState(canvasState);
};
const drawGrid = () => {
ctx.strokeStyle = "#000";
for (let x = 0; x < canvas.width; x += pixelSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
for (let y = 0; y < canvas.height; y += pixelSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
}
};
const handleToggleGrid = () => {
toggleGridToggle.addEventListener("change", (event) => {
showGrid = event.target.checked;
localStorage.setItem("showGrid", showGrid);
if (showGrid) {
drawGrid();
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawCanvasState(canvasState);
}
});
};
window.onkeydown = (event) => {
// on enter (keycode 13 is enter)
if (event.keyCode === 13) {
if (previewPixel) {
handlePlacePixel(previewPixel);
removePreviewPixel();
}
}
};
placePixelButton.addEventListener("click", () => {
if (previewPixel) {
handlePlacePixel(previewPixel);
removePreviewPixel();
}
});
saveCanvasButton.addEventListener("click", () => {
const a = document.createElement("a");
a.href = canvas.toDataURL();
a.download = "canvas.png";
a.click();
});
handleColorPicker();
handleToggleGrid();
export const handleSocketEvents = (_socket) => {
socket = _socket;
socket.on("connect", () => {
fetchCanvasState();
});
socket.on("pixel-updated", (update) => {
const color = u32ToHex(update.color);
ctx.fillStyle = color;
ctx.fillRect(
update.x * pixelSize,
update.y * pixelSize,
pixelSize,
pixelSize
);
const index = update.y * WIDTH + update.x;
canvasState[index] = update.color;
});
};

View File

@@ -1,139 +0,0 @@
import * as three from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
let scene, camera, renderer, character, mixer, clock;
let walkAction, idleAction;
let moving = false;
const keys = {}
let boxes = [];
const container = document.getElementById('challenge');
const onWindowResize = () => {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
}
const onKeyDown = (e) => {
keys[e.code] = true;
}
const onKeyUp = (e) => {
keys[e.code] = false;
}
const init = () => {
scene = new three.Scene();
camera = new three.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
renderer = new three.WebGLRenderer({ antialias: true});
renderer.setSize(window.innerWidth, window.innerHeight);
container.appendChild(renderer.domElement);
clock = new three.Clock();
// Adjust light positions and intensity
const light = new three.DirectionalLight(0xffffff, 1);
light.position.set(5, 10, 7.5);
light.castShadow = true;
scene.add(light);
for (let i = 0; i < 10; i++) {
const box = new three.Mesh(
new three.BoxGeometry(1, 1, 1),
new three.MeshStandardMaterial({ color: 0x00ff00 })
);
box.position.set(Math.random() * 10 - 5, 0.5, Math.random() * 10 - 5);
box.castShadow = true;
scene.add(box);
boxes.push(box);
}
const loader = new GLTFLoader();
loader.load('src/Astronaut.glb',(gltf) => {
character = gltf.scene;
scene.add(character);
mixer = new three.AnimationMixer(character);
gltf.animations.forEach((clip) => {
if (clip.name === 'CharacterArmature|Walk') {
walkAction = mixer.clipAction(clip);
}
if (clip.name === 'CharacterArmature|Idle') {
idleAction = mixer.clipAction(clip);
}
});
if (walkAction) walkAction.play();
if (idleAction) idleAction.play();
character.position.set(-10, 0, -60);
character.rotation.y = Math.PI;
})
//blue sky
scene.background = new three.Color(0x87ceeb);
camera.position.set(20, 10, 10)
camera.lookAt(0, 10, 0)
window.addEventListener('resize', onWindowResize, false);
document.addEventListener('keydown', onKeyDown, false);
document.addEventListener('keyup', onKeyUp, false);
}
const animate = () => {
requestAnimationFrame(animate);
const delta = clock.getDelta();
if (mixer) mixer.update(delta);
if (character) {
if (keys['KeyW']) {
character.position.z -= 0.1;
character.rotation.y = Math.PI; // North
}
if (keys['KeyS']) {
character.position.z += 0.1;
character.rotation.y = 0; // South
}
if (keys['KeyA']) {
character.position.x -= 0.1;
character.rotation.y = Math.PI / 2; // West
}
if (keys['KeyD']) {
character.position.x += 0.1;
character.rotation.y = -Math.PI / 2; // East
}
// check if any key is pressed
if (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']) {
moving = true;
} else {
moving = false;
}
// Play walking animation if moving
if (mixer) {
if (walkAction && idleAction) {
walkAction.enabled = moving;
idleAction.enabled = !moving;
}
}
}
renderer.render(scene, camera);
}
const threejs = import.meta.env.VITE_THREE_JS === "true";
if (threejs) {
init();
animate();
}

View File

@@ -1,12 +0,0 @@
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
export const pixelSize = 10;
export const pixelCooldown = 10 * 1000; // 10 seconds
export const canvasEndpoint = isDebug
? "http://localhost:3000/canvas/"
: "/canvas/";
export const checkEndpoint = isDebug
? "http://localhost:3000/check/"
: "/check/";
export const WIDTH = 500;
export const HEIGHT = 500;

View File

@@ -1,20 +0,0 @@
import { pixelCooldown } from "./constants.js";
const countdownDiv = document.getElementById("countdown");
export const updateCountdown = () => {
setInterval(() => {
const lastPixelTime = parseInt(
localStorage.getItem("lastPixelTime") || "0"
);
const now = Date.now();
const timeLeft = Math.max(0, pixelCooldown - (now - lastPixelTime));
if (timeLeft > 0) {
countdownDiv.textContent = `You can place a pixel in ${Math.ceil(
timeLeft / 1000
)} seconds`;
} else {
countdownDiv.textContent = "You can place a pixel now";
}
}, 1000);
};

View File

@@ -0,0 +1,11 @@
import { WIDTH } from "./constants.js";
export const getPixelIndex = (x, y) => y * WIDTH + x;
export const setPixel = (state, x, y, color) => {
state[getPixelIndex(x, y)] = color;
};
export const getPixel = (state, x, y) => {
return state[getPixelIndex(x, y)];
};

View File

@@ -6,13 +6,16 @@ export const hexToU32 = (color) => {
return parseInt(color.slice(1), 16);
};
export const getColorFromElementCSS = (element) => {
return window.getComputedStyle(element).backgroundColor;
};
export const rgbToHex = (rgbProperty) => {
const rgb = rgbProperty.match(/\d+/g);
return `#${rgb
.map((x) => parseInt(x).toString(16).padStart(2, "0"))
.join("")}`;
};
export const u32ToRGBA = (color, alpha) => {
const r = (color >> 16) & 0xff;
const g = (color >> 8) & 0xff;
const b = color & 0xff;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};

View File

@@ -0,0 +1,4 @@
export const PIXEL_SIZE = 1;
export const PIXEL_COOLDOWN = 10 * 1000;
export const WIDTH = 500;
export const HEIGHT = 500;

View File

@@ -0,0 +1,9 @@
import { PIXEL_COOLDOWN } from "./constants.js";
export const canPlacePixel = (lastPlacementTime) => {
return Date.now() - lastPlacementTime >= PIXEL_COOLDOWN;
};
export const timeRemaining = (lastPlacementTime) => {
return Math.max(0, PIXEL_COOLDOWN - (Date.now() - lastPlacementTime));
};

View File

@@ -0,0 +1,19 @@
import { WIDTH, HEIGHT } from "./constants.js";
export const getCanvasCoords = (event, canvas) => {
const rect = canvas.getBoundingClientRect();
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
const clientY = event.touches ? event.touches[0].clientY : event.clientY;
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: Math.min(
Math.max(Math.floor((clientX - rect.left) * scaleX), 0),
WIDTH - 1,
),
y: Math.min(
Math.max(Math.floor((clientY - rect.top) * scaleY), 0),
HEIGHT - 1,
),
};
};

View File

@@ -0,0 +1,5 @@
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
const CHECK_ENDPOINT = isDebug ? "http://localhost:3000/check/" : "/check/";
export const checkServer = () => fetch(CHECK_ENDPOINT);

View File

@@ -0,0 +1,72 @@
import io from "socket.io-client";
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
const transport = import.meta.env.VITE_TRANSPORT || "socketio";
const createSocketIoTransport = () => {
const url = isDebug ? "ws://localhost:3000" : undefined;
const socket = url ? io(url) : io({ transports: ["websocket"] });
return {
on: (event, handler) => socket.on(event, handler),
emit: (event, data) => socket.emit(event, data),
};
};
const createWebSocketTransport = () => {
const handlers = {};
const wsUrl = isDebug
? "ws://localhost:3000/ws"
: `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`;
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
const on = (event, handler) => {
if (!handlers[event]) handlers[event] = [];
handlers[event].push(handler);
};
const emit = (event, data) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: event, ...data }));
};
const dispatch = (event, ...args) => {
(handlers[event] || []).forEach((handler) => handler(...args));
};
ws.addEventListener("open", () => dispatch("connect"));
ws.addEventListener("message", (event) => {
if (event.data instanceof ArrayBuffer) {
const pixels = new Uint32Array(event.data);
dispatch("canvas_state", Array.from(pixels));
return;
}
const message = JSON.parse(event.data);
switch (message.type) {
case "pixel-updated":
dispatch("pixel-updated", message);
break;
case "current_soldiers":
dispatch("current_soldiers", message.count);
break;
case "error":
dispatch("error", message.message);
break;
}
});
ws.addEventListener("close", () => dispatch("disconnect"));
return { on, emit };
};
export const createSocketConnection = () => {
if (transport === "websocket") {
return createWebSocketTransport();
}
return createSocketIoTransport();
};

View File

@@ -1,58 +1,88 @@
import { connectToWS } from "./socket.js";
import "./canvas.js";
import "./counter.js";
import { updateCountdown } from "./counter.js";
import { checkEndpoint, pixelSize } from "./constants.js";
import { handleSocketEvents } from "./canvas.js";
import "./challenge.js"
import { createSocketConnection } from "./infrastructure/socket-client.js";
import { checkServer } from "./infrastructure/api.js";
import { createCanvasRenderer } from "./ui/canvas-renderer.js";
import { createColorPalette } from "./ui/color-palette.js";
import { createPixelPlacer } from "./ui/pixel-placer.js";
import { startCooldownDisplay } from "./ui/cooldown-display.js";
import { createCanvasViewport } from "./ui/canvas-viewport.js";
import { setPixel } from "./domain/canvas-state.js";
import { getCanvasCoords } from "./domain/coords.js";
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
const currentSoldiersSpan = document.getElementById("current-soldiers");
let coords = [];
const canvas = document.getElementById("canvas");
const canvasEl = document.getElementById("canvas");
const coordsText = document.getElementById("coords");
const ogCanvasStyle = canvas.style.display;
canvas.style.display = "none";
const currentSoldiersSpan = document.getElementById("current-soldiers");
const statusEl = document.getElementById("connection-status");
fetch(checkEndpoint)
const savedDisplay = canvasEl.style.display;
canvasEl.style.display = "none";
let canvasState = [];
const renderer = createCanvasRenderer(canvasEl);
const palette = createColorPalette();
startCooldownDisplay();
createCanvasViewport(canvasEl);
canvasEl.addEventListener("mousemove", (event) => {
const { x, y } = getCanvasCoords(event, canvasEl);
coordsText.textContent = `${x}, ${y}`;
});
document.getElementById("save-canvas").addEventListener("click", () => {
const a = document.createElement("a");
a.href = renderer.toDataURL();
a.download = "canvas.png";
a.click();
});
const showStatus = (message, isError) => {
if (!statusEl) return;
statusEl.textContent = message;
statusEl.className = isError
? "text-red-500 text-sm"
: "text-green-500 text-sm";
};
checkServer()
.then((response) => {
if (response.ok) {
const socket = connectToWS();
if (!response.ok) throw new Error("Server unavailable");
socket.on("connect", () => {
canvas.style.display = ogCanvasStyle;
console.log("connect");
});
const socket = createSocketConnection();
socket.on("error", (message) => {
alert(message);
});
socket.on("connect", () => {
canvasEl.style.display = savedDisplay;
showStatus("Connected", false);
});
socket.on("current_soldiers", (currentSoldiers) => {
currentSoldiersSpan.textContent = currentSoldiers;
});
socket.on("canvas_state", (data) => {
canvasState = data;
renderer.drawState(data);
});
handleSocketEvents(socket);
socket.on("error", (message) => showStatus(message, true));
requestAnimationFrame(updateCountdown);
socket.on("current_soldiers", (count) => {
currentSoldiersSpan.textContent = count;
});
window.addEventListener("mousemove", (event) => {
// get coordinates of the mouse inside the canvas
const rect = canvas.getBoundingClientRect();
const x = Math.floor((event.clientX - rect.left) / pixelSize);
const y = Math.floor((event.clientY - rect.top) / pixelSize);
coords = [x, y];
socket.on("pixel-updated", (update) => {
renderer.drawPixel(update.x, update.y, update.color);
setPixel(canvasState, update.x, update.y, update.color);
});
coordsText.textContent = `${x}, ${y}`;
});
} else {
throw new Error("Can't connect to the server");
}
socket.on("disconnect", () => {
showStatus("Disconnected — reconnecting...", true);
});
createPixelPlacer({
canvas: canvasEl,
renderer,
getColor: palette.getColor,
getState: () => canvasState,
socket,
});
})
.catch((error) => {
alert(
"You have already connected to the server from another tab or window. Please close the other tab or window and refresh this page."
);
.catch(() => {
showStatus("Cannot connect to server", true);
});

View File

@@ -1,21 +0,0 @@
import io from "socket.io-client";
const isDebug = import.meta.env.VITE_IS_DEBUG === "true";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = window.location.host;
let socket;
export const connectToWS = () => {
if (isDebug) {
socket = io("ws://localhost:3000");
} else {
socket = io(`${wsProtocol}//${wsHost}`, {
transports: ["websocket"],
});
}
return socket;
};
export default socket;

View File

@@ -2,20 +2,17 @@
@tailwind components;
@tailwind utilities;
@layer base {
.rainbow-border {
position: relative;
display: inline-block;
background: linear-gradient(to right, red, orange, yellow, green, blue, indigo, violet);
.canvas-viewport {
overflow: auto;
width: min(92vw, 70vh);
height: min(92vw, 70vh);
border: 2px solid #94a3b8;
border-radius: 4px;
cursor: crosshair;
}
.rainbow-border::before {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: white;
z-index: -1; /* Place behind the content */
#canvas {
image-rendering: pixelated;
image-rendering: crisp-edges;
display: block;
}
}

View File

@@ -0,0 +1,39 @@
import { u32ToHex, u32ToRGBA } from "../domain/color.js";
import { WIDTH, HEIGHT } from "../domain/constants.js";
export const createCanvasRenderer = (canvas) => {
const ctx = canvas.getContext("2d");
const drawState = (state) => {
const imageData = ctx.createImageData(WIDTH, HEIGHT);
const data = imageData.data;
for (let i = 0; i < state.length; i++) {
const color = state[i];
const offset = i * 4;
data[offset] = (color >> 16) & 0xff;
data[offset + 1] = (color >> 8) & 0xff;
data[offset + 2] = color & 0xff;
data[offset + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
};
const drawPixel = (x, y, colorU32) => {
ctx.fillStyle = u32ToHex(colorU32);
ctx.fillRect(x, y, 1, 1);
};
const drawPreview = (x, y, colorU32) => {
ctx.fillStyle = u32ToRGBA(colorU32, 0.5);
ctx.fillRect(x, y, 1, 1);
};
const restorePixel = (state, x, y) => {
ctx.fillStyle = u32ToHex(state[y * WIDTH + x]);
ctx.fillRect(x, y, 1, 1);
};
const toDataURL = () => canvas.toDataURL();
return { drawState, drawPixel, drawPreview, restorePixel, toDataURL };
};

View File

@@ -0,0 +1,78 @@
const MIN_ZOOM = 1;
const MAX_ZOOM = 40;
export const createCanvasViewport = (canvas) => {
const viewport = canvas.parentElement;
const zoomLabel = document.getElementById("zoom-level");
let zoom = 1;
let baseSize = viewport.clientWidth;
const applyZoom = () => {
const size = baseSize * zoom;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
if (zoomLabel) zoomLabel.textContent = `${zoom.toFixed(1)}x`;
};
const setZoom = (newZoom, centerX, centerY) => {
const oldZoom = zoom;
zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, newZoom));
if (zoom === oldZoom) return;
if (centerX !== undefined && centerY !== undefined) {
const ratio = zoom / oldZoom;
viewport.scrollLeft = (viewport.scrollLeft + centerX) * ratio - centerX;
viewport.scrollTop = (viewport.scrollTop + centerY) * ratio - centerY;
}
applyZoom();
};
viewport.addEventListener("wheel", (event) => {
event.preventDefault();
const factor = event.deltaY > 0 ? 0.8 : 1.25;
const rect = viewport.getBoundingClientRect();
setZoom(zoom * factor, event.clientX - rect.left, event.clientY - rect.top);
});
let lastPinchDist = 0;
viewport.addEventListener("touchmove", (event) => {
if (event.touches.length !== 2) return;
event.preventDefault();
const dist = Math.hypot(
event.touches[0].clientX - event.touches[1].clientX,
event.touches[0].clientY - event.touches[1].clientY,
);
if (lastPinchDist > 0) {
const midX =
(event.touches[0].clientX + event.touches[1].clientX) / 2 -
viewport.getBoundingClientRect().left;
const midY =
(event.touches[0].clientY + event.touches[1].clientY) / 2 -
viewport.getBoundingClientRect().top;
setZoom(zoom * (dist / lastPinchDist), midX, midY);
}
lastPinchDist = dist;
});
viewport.addEventListener("touchend", () => {
lastPinchDist = 0;
});
document.getElementById("zoom-in")?.addEventListener("click", () => {
setZoom(zoom * 1.5);
applyZoom();
});
document.getElementById("zoom-out")?.addEventListener("click", () => {
setZoom(zoom / 1.5);
applyZoom();
});
window.addEventListener("resize", () => {
baseSize = viewport.clientWidth;
applyZoom();
});
applyZoom();
};

View File

@@ -0,0 +1,53 @@
import { rgbToHex } from "../domain/color.js";
const PALETTE_IDS = [
"red",
"green",
"blue",
"yellow",
"purple",
"pink",
"cyan",
"white",
"black",
"orange",
"brown",
];
export const createColorPalette = () => {
const colorPicker = document.getElementById("color-picker");
const currentColorSpan = document.getElementById("current-color-span");
let activeButton = null;
let currentColor = localStorage.getItem("currentColor") || "#000000";
colorPicker.value = currentColor;
currentColorSpan.style.backgroundColor = currentColor;
const setColor = (hex, button) => {
currentColor = hex;
currentColorSpan.textContent = hex;
currentColorSpan.style.backgroundColor = hex;
localStorage.setItem("currentColor", hex);
if (activeButton)
activeButton.classList.remove("ring-2", "ring-offset-2", "ring-cyan-400");
if (button) {
button.classList.add("ring-2", "ring-offset-2", "ring-cyan-400");
activeButton = button;
}
};
colorPicker.addEventListener("input", (e) => setColor(e.target.value, null));
for (const id of PALETTE_IDS) {
const button = document.getElementById(id);
button.addEventListener("click", () => {
setColor(
rgbToHex(window.getComputedStyle(button).backgroundColor),
button,
);
});
}
return { getColor: () => currentColor };
};

View File

@@ -0,0 +1,16 @@
import { timeRemaining } from "../domain/cooldown.js";
export const startCooldownDisplay = () => {
const countdownDiv = document.getElementById("countdown");
setInterval(() => {
const lastPlacementTime = parseInt(
localStorage.getItem("lastPixelTime") || "0",
);
const remaining = timeRemaining(lastPlacementTime);
countdownDiv.textContent =
remaining > 0
? `You can place a pixel in ${Math.ceil(remaining / 1000)} seconds`
: "You can place a pixel now";
}, 1000);
};

View File

@@ -0,0 +1,57 @@
import { hexToU32 } from "../domain/color.js";
import { canPlacePixel } from "../domain/cooldown.js";
import { setPixel } from "../domain/canvas-state.js";
import { getCanvasCoords } from "../domain/coords.js";
export const createPixelPlacer = ({
canvas,
renderer,
getColor,
getState,
socket,
}) => {
let previewPixel = null;
const place = (update) => {
const lastPixelTime = parseInt(
localStorage.getItem("lastPixelTime") || "0",
);
if (!canPlacePixel(lastPixelTime)) return;
socket.emit("place-pixel", update);
setPixel(getState(), update.x, update.y, update.color);
localStorage.setItem("lastPixelTime", Date.now().toString());
};
const clearPreview = () => {
if (!previewPixel) return;
renderer.restorePixel(getState(), previewPixel.x, previewPixel.y);
previewPixel = null;
};
const confirmPlacement = () => {
if (!previewPixel) return;
place(previewPixel);
clearPreview();
};
const selectPixel = (event) => {
const { x, y } = getCanvasCoords(event, canvas);
const color = hexToU32(getColor());
clearPreview();
previewPixel = { x, y, color };
renderer.drawPreview(x, y, color);
};
canvas.addEventListener("click", selectPixel);
window.addEventListener("keydown", (event) => {
if (event.key === "Enter") confirmPlacement();
if (event.key === "Escape") clearPreview();
});
document
.getElementById("place-pixel")
.addEventListener("click", confirmPlacement);
};