import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { useWebSocket } from '@vueuse/core' import { placeApiCanvas } from '@/api' // Mirrors place/wire.py: a one-letter type tag then '|'-separated fields, in // a fixed order the server always uses. See that file for the message table. // Everyone reaching this page is already authenticated (the Django view is // behind @login_required), so unlike pplace's original there's no // "not logged in" branch to handle here. const RECONNECT_DELAY_MS = 1000 const DEFAULT_GRID_SIZE = 64 // Mirrors place/models.py's PLACE_COOLDOWN_SECONDS - the server is the // source of truth for enforcement, this is only used to size the cooldown // progress bar. const PLACE_COOLDOWN_SECONDS = 30 export type OnPixel = (x: number, y: number, colorIndex: number) => void export type OnResize = (width: number, height: number) => void // Plain (non-reactive) - a several-thousand-cell reactive array is // pointless overhead. Kept module-level, alongside its dimensions, so the // grid survives across component remounts. The grid isn't a fixed size - // an admin can resize it - so it's reallocated whenever the dimensions // change (see applyGridSize below). let grid = new Uint8Array(DEFAULT_GRID_SIZE * DEFAULT_GRID_SIZE) const gridWidth = ref(DEFAULT_GRID_SIZE) const gridHeight = ref(DEFAULT_GRID_SIZE) export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?: OnResize) { const cooldownEndMs = ref(null) const cooldownRemainingMs = ref(0) let animationFrame: number | null = null function applyCooldown(nextMs: number | null) { cooldownEndMs.value = nextMs ?? null } function tickCooldown() { cooldownRemainingMs.value = cooldownEndMs.value !== null ? Math.max(0, cooldownEndMs.value - Date.now()) : 0 animationFrame = requestAnimationFrame(tickCooldown) } // Returns true if the size actually changed, so callers can decide // whether to re-fetch the canvas (the old pixel buffer is meaningless at // the new dimensions). function applyGridSize(width: number, height: number): boolean { if (width === gridWidth.value && height === gridHeight.value) return false gridWidth.value = width gridHeight.value = height grid = new Uint8Array(width * height) onResize?.(width, height) return true } function setPixel(x: number, y: number, colorIndex: number) { grid[y * gridWidth.value + x] = colorIndex onPixel?.(x, y, colorIndex) } function handleMessage(_ws: WebSocket, event: MessageEvent) { const parts = event.data.split('|') switch (parts[0]) { case 's': { const [, next, width, height] = parts applyCooldown(next !== '' ? Number(next) : null) applyGridSize(Number(width), Number(height)) break } case 'p': { const [, x, y, c] = parts setPixel(Number(x), Number(y), Number(c)) break } case 'k': { applyCooldown(Number(parts[1])) break } case 'r': { if (parts[1] === 'c') applyCooldown(Number(parts[2])) break } case 'e': { const [, width, height] = parts // The server already rebuilt its canvas at the new size (see // place/signals.py) - just re-fetch it rather than guess how to // remap our local pixels. if (applyGridSize(Number(width), Number(height))) loadCanvas() break } default: break } } const wsUrl = computed(() => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' return `${protocol}//${window.location.host}/ws/place/${eventPath}/` }) const { status, send } = useWebSocket(wsUrl, { onMessage: handleMessage, autoReconnect: { retries: -1, delay: RECONNECT_DELAY_MS }, }) const connected = computed(() => status.value === 'OPEN') async function loadCanvas() { const response = await placeApiCanvas({ path: { event_path: eventPath } }) if (!response.data) return const bytes = new Uint8Array(await (response.data as Blob).arrayBuffer()) // 4-byte little-endian header (uint16 width, uint16 height) - see // place/api.py - then width*height pixel bytes. const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) const width = view.getUint16(0, true) const height = view.getUint16(2, true) applyGridSize(width, height) grid.set(bytes.subarray(4)) for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { onPixel?.(x, y, grid[y * width + x]) } } } function place(x: number, y: number, colorIndex: number) { send(`p|${x}|${y}|${colorIndex}`) } onMounted(() => { loadCanvas() animationFrame = requestAnimationFrame(tickCooldown) }) onBeforeUnmount(() => { if (animationFrame !== null) cancelAnimationFrame(animationFrame) }) return { connected, cooldownRemainingMs, cooldownDurationSeconds: PLACE_COOLDOWN_SECONDS, gridWidth, gridHeight, place, } }