"use strict"
const $ = (s) => document.querySelector(s)
const stage = $("#stage")
const world = $("#world")
const splash = $("#splash")
const flash = $("#flash")
const startBtn = $("#start")
const GAP = 10
const MAX_TILE = 900
let TILE = Math.round(Math.min(window.innerWidth, window.innerHeight) * 0.92)
if (TILE > MAX_TILE) TILE = MAX_TILE
const ARROW_SVG = {
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 scale = 1
let drag = null
const key = (x, y) => `${x},${y}`
function worldScale() {
const n = tiles.size
if (n <= 1) return 1
return Math.max(0.5, 1 - (n - 1) * 0.03)
}
function applyPan() {
world.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`
}
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 - scale * cx
panY = window.innerHeight / 2 - scale * 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 = []
scale = worldScale()
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 zone = document.createElement("div")
zone.className = `nav-zone nav-zone-${ex.dir}`
const b = document.createElement("button")
b.className = "nav-btn"
b.dataset.dir = ex.dir
b.innerHTML = ARROW_SVG[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) })
zone.appendChild(b)
nav.appendChild(zone)
}
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 nx = cur.x + dx
const ny = cur.y + dy
const occupant = tiles.get(key(nx, ny))
if (occupant) {
setCurrent(occupant.info.id, nx, ny)
return
}
const at = coordOf.get(ex.next.id)
if (at) {
setCurrent(ex.next.id, at.x, at.y)
} else {
addTile(ex.next, nx, ny)
setCurrent(ex.next.id, nx, ny)
}
}
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")
}
})