"use strict" const $ = (s) => document.querySelector(s) const stage = $("#stage") const world = $("#world") const splash = $("#splash") const startBtn = $("#start") const GAP = 10 const FIT_TOLERANCE = 350 const ARROW_SVG = { N: '', E: '', S: '', W: '', } let U = 300 const tiles = new Map() const rectOf = new Map() const photoInfo = new Map() const exitsCache = new Map() const exitPending = new Map() let cur = null let panX = 0 let panY = 0 let scale = 1 let drag = null function aspect(info) { return info.h > 0 ? info.w / info.h : 1 } function tileW(info) { const a = aspect(info) return a >= 1 ? U * a : U } function tileH(info) { const a = aspect(info) return a >= 1 ? U : U / a } function worldScale() { const n = tiles.size if (n <= 1) return 1 return Math.max(0.5, 1 - (n - 1) * 0.04) } function applyPan() { world.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})` } function center() { if (!cur) return const r = cur.rect panX = window.innerWidth / 2 - scale * (r.x + r.w / 2) panY = window.innerHeight / 2 - scale * (r.y + r.h / 2) applyPan() } function overlaps(a, b) { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y } function collides(rect) { for (const t of tiles.values()) if (overlaps(rect, t.rect)) return true return false } function baseRect(w, h, from, dir) { if (dir === "N") return { x: from.x + (from.w - w) / 2, y: from.y - h - GAP, w, h } if (dir === "S") return { x: from.x + (from.w - w) / 2, y: from.y + from.h + GAP, w, h } if (dir === "E") return { x: from.x + from.w + GAP, y: from.y + (from.h - h) / 2, w, h } return { x: from.x - w - GAP, y: from.y + (from.h - h) / 2, w, h } } function sideGap(rect, dx, dy) { let g = Infinity for (const t of tiles.values()) { const r = t.rect if (dx !== 0) { if (r.y + r.h <= rect.y || r.y >= rect.y + rect.h) continue const gap = dx < 0 ? rect.x - (r.x + r.w) : r.x - (rect.x + rect.w) if (gap >= 0 && gap < g) g = gap } else { if (r.x + r.w <= rect.x || r.x >= rect.x + rect.w) continue const gap = dy < 0 ? rect.y - (r.y + r.h) : r.y - (rect.y + rect.h) if (gap >= 0 && gap < g) g = gap } } return g } function packGap(rect, w, h) { const cap = Math.max(w, h) * 2 const g = [ sideGap(rect, 0, -1), sideGap(rect, 0, 1), sideGap(rect, -1, 0), sideGap(rect, 1, 0), ] let sum = 0 for (const v of g) sum += v === Infinity ? cap : v return sum } function bestPlacement(w, h, from, dir) { const base = baseRect(w, h, from, dir) if (!collides(base)) return { rect: base, cost: 0 } const xs = new Set([base.x]) const ys = new Set([base.y]) for (const t of tiles.values()) { const r = t.rect xs.add(r.x - w - GAP) xs.add(r.x + r.w + GAP) xs.add(r.x + (r.w - w) / 2) ys.add(r.y - h - GAP) ys.add(r.y + r.h + GAP) ys.add(r.y + (r.h - h) / 2) } let best = null let bestCost = Infinity for (const x of xs) { for (const y of ys) { const rect = { x, y, w, h } if (collides(rect)) continue const dist = Math.abs(x - base.x) + Math.abs(y - base.y) const cost = dist + 0.2 * packGap(rect, w, h) if (cost < bestCost) { bestCost = cost best = rect } } } return best ? { rect: best, cost: bestCost } : null } function placeRect(w, h, from, dir) { const p = bestPlacement(w, h, from, dir) return p ? p.rect : null } function addTile(info, rect) { const el = document.createElement("div") el.className = "tile" el.dataset.id = info.id el.style.left = rect.x + "px" el.style.top = rect.y + "px" el.style.width = rect.w + "px" el.style.height = rect.h + "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(info.id, { el, info, rect }) rectOf.set(info.id, rect) renderNav(info.id) return el } function setCurrent(id, rect) { for (const t of tiles.values()) t.el.classList.remove("current") cur = { id, rect } tiles.get(id).el.classList.add("current") scale = worldScale() center() getExits(id) } function renderNav(id) { const t = tiles.get(id) if (!t) return t.el.querySelectorAll(".nav").forEach((n) => n.remove()) const exits = exitsCache.get(id) if (!exits) return const nav = document.createElement("div") nav.className = "nav" for (const ex of exits) { 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] b.addEventListener("pointerdown", (e) => e.stopPropagation()) b.addEventListener("click", (e) => { e.stopPropagation(); moveFrom(id, ex.dir) }) zone.appendChild(b) nav.appendChild(zone) } t.el.appendChild(nav) } async function getExits(id) { if (exitsCache.has(id)) return exitsCache.get(id) if (exitPending.has(id)) return exitPending.get(id) const p = (async () => { try { const r = await fetch(`/api/exits/${id}`) if (!r.ok) return null const data = await r.json() for (const ph of Object.values(data.photos || {})) photoInfo.set(ph.id, ph) for (const ex of data.exits || []) if (ex.next) photoInfo.set(ex.next.id, ex.next) const exits = data.exits || [] exitsCache.set(id, exits) renderNav(id) return exits } catch { return null } })() exitPending.set(id, p) p.finally(() => exitPending.delete(id)) return p } async function moveFrom(id, dir) { const exits = exitsCache.get(id) const ex = exits && exits.find((e) => e.dir === dir) if (!ex) return const from = tiles.get(id).rect const seen = new Set() const candIds = [] const addCand = (cid) => { if (cid && !seen.has(cid) && !rectOf.has(cid)) { seen.add(cid) candIds.push(cid) } } addCand(ex.next && ex.next.id) for (const wid of ex.walk) { if (candIds.length >= 4) break addCand(wid) } const scored = [] for (const cid of candIds) { const info = photoInfo.get(cid) || (tiles.get(cid) && tiles.get(cid).info) if (!info) continue const p = bestPlacement(tileW(info), tileH(info), from, dir) if (p) scored.push({ id: cid, info, rect: p.rect, cost: p.cost }) } if (!scored.length) return scored.sort((a, b) => a.cost - b.cost) const best = scored[0] const next = ex.next && !rectOf.has(ex.next.id) ? scored.find((s) => s.id === ex.next.id) : null const chosen = next && next.cost - best.cost <= FIT_TOLERANCE ? next : best addTile(chosen.info, chosen.rect) setCurrent(chosen.id, chosen.rect) } stage.addEventListener("pointerover", (e) => { const tileEl = e.target.closest(".tile") if (tileEl) getExits(Number(tileEl.dataset.id)) }) 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 id = Number(tile.dataset.id) const info = tiles.get(id).info if (cur && cur.id === id) return setCurrent(id, tiles.get(id).rect) }) addEventListener("keydown", (e) => { const map = { ArrowUp: "N", ArrowRight: "E", ArrowDown: "S", ArrowLeft: "W" } const dir = map[e.key] if (dir) { e.preventDefault() if (cur) moveFrom(cur.id, dir) } }) addEventListener("resize", center) startBtn.addEventListener("click", async () => { splash.classList.add("hide") try { const r = await fetch("/api/random") const data = await r.json() const photo = data.photo photoInfo.set(photo.id, photo) const a = aspect(photo) const wf = a >= 1 ? a : 1 const hf = a >= 1 ? 1 : 1 / a const uW = (window.innerWidth * 0.92) / wf const uH = (window.innerHeight * 0.92) / hf U = Math.max(100, Math.min(640, Math.round(Math.min(uW, uH)))) const rect = { x: 0, y: 0, w: tileW(photo), h: tileH(photo) } addTile(photo, rect) setCurrent(photo.id, rect) } catch { /* ignore */ } })