"use strict" const $ = (s) => document.querySelector(s) const stage = $("#stage") const world = $("#world") const splash = $("#splash") const flash = $("#flash") const startBtn = $("#start") const GAP = 8 let TILE = window.matchMedia("(max-width: 700px)").matches ? 150 : 240 const ARROW = { N: "↑", E: "→", S: "↓", W: "←" } const DELTA = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] } const tiles = new Map() const coordOf = new Map() let cur = null let curExits = [] let fetchSeq = 0 let panX = 0 let panY = 0 let drag = null const key = (x, y) => `${x},${y}` function applyPan() { world.style.transform = `translate(${panX}px, ${panY}px)` } function center() { if (!cur) return const cx = cur.x * (TILE + GAP) + TILE / 2 const cy = cur.y * (TILE + GAP) + TILE / 2 panX = window.innerWidth / 2 - cx panY = window.innerHeight / 2 - cy applyPan() } function addTile(info, x, y) { const el = document.createElement("div") el.className = "tile" el.dataset.id = info.id el.dataset.x = x el.dataset.y = y el.style.left = x * (TILE + GAP) + "px" el.style.top = y * (TILE + GAP) + "px" el.style.width = TILE + "px" el.style.height = TILE + "px" const img = document.createElement("img") img.src = info.thumb img.alt = info.title || "photo" img.loading = "lazy" el.appendChild(img) world.appendChild(el) requestAnimationFrame(() => el.classList.add("lock")) tiles.set(key(x, y), { el, info }) coordOf.set(info.id, { x, y }) return el } function setCurrent(id, x, y) { for (const t of tiles.values()) t.el.classList.remove("current") cur = { id, x, y } tiles.get(key(x, y)).el.classList.add("current") curExits = [] renderNav() center() fetchExits(id) } function flashText(text) { flash.textContent = text flash.classList.remove("show") void flash.offsetWidth flash.classList.add("show") clearTimeout(flashText._t) flashText._t = setTimeout(() => flash.classList.remove("show"), 1600) } function renderNav() { if (!cur) return const tileEl = tiles.get(key(cur.x, cur.y)).el tileEl.querySelectorAll(".nav").forEach((n) => n.remove()) const nav = document.createElement("div") nav.className = "nav" for (const ex of curExits) { const b = document.createElement("button") b.className = "nav-btn" b.dataset.dir = ex.dir b.textContent = ARROW[ex.dir] const lab = document.createElement("span") lab.className = "nav-label" lab.textContent = ex.path.name b.appendChild(lab) b.addEventListener("pointerdown", (e) => e.stopPropagation()) b.addEventListener("click", (e) => { e.stopPropagation(); move(ex.dir) }) nav.appendChild(b) } tileEl.appendChild(nav) } async function fetchExits(id) { const seq = ++fetchSeq try { const r = await fetch(`/api/exits/${id}`) if (!r.ok) return const data = await r.json() if (seq !== fetchSeq || !cur || cur.id !== id) return curExits = data.exits || [] renderNav() } catch { /* ignore */ } } async function move(dir) { const ex = curExits.find((e) => e.dir === dir) if (!ex) return flashText(ex.path.name) const [dx, dy] = DELTA[dir] const at = coordOf.get(ex.next.id) if (at) { setCurrent(ex.next.id, at.x, at.y) } else { let px = cur.x + dx let py = cur.y + dy while (tiles.has(key(px, py))) { px += dx py += dy } addTile(ex.next, px, py) setCurrent(ex.next.id, px, py) } } stage.addEventListener("pointerdown", (e) => { if (e.target.closest(".nav-btn")) return drag = { x: e.clientX, y: e.clientY, px: panX, py: panY, moved: false, tile: e.target.closest(".tile") } world.style.transition = "none" stage.setPointerCapture(e.pointerId) }) stage.addEventListener("pointermove", (e) => { if (!drag) return const dx = e.clientX - drag.x const dy = e.clientY - drag.y if (!drag.moved && Math.hypot(dx, dy) > 5) drag.moved = true if (drag.moved) { panX = drag.px + dx panY = drag.py + dy applyPan() } }) stage.addEventListener("pointerup", (e) => { if (!drag) return const wasDrag = drag.moved const tile = drag.tile drag = null world.style.transition = "" if (wasDrag || !tile) return const x = Number(tile.dataset.x) const y = Number(tile.dataset.y) const info = tiles.get(key(x, y)).info if (cur && cur.id === info.id) return setCurrent(info.id, x, y) }) addEventListener("keydown", (e) => { const map = { ArrowUp: "N", ArrowRight: "E", ArrowDown: "S", ArrowLeft: "W" } const dir = map[e.key] if (dir) { e.preventDefault() move(dir) } }) addEventListener("resize", center) startBtn.addEventListener("click", async () => { splash.classList.add("hide") try { const r = await fetch("/api/random") const data = await r.json() addTile(data.photo, 0, 0) setCurrent(data.photo.id, 0, 0) } catch { flashText("could not reach the library") } })