"use strict"
const $ = (s) => document.querySelector(s)
const stage = $("#stage")
const world = $("#world")
const splash = $("#splash")
const startBtn = $("#start")
const GAP = 10
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 placeRect(w, h, from, dir) {
const base = baseRect(w, h, from, dir)
if (!collides(base)) return base
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
}
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
let targetId = null
if (!rectOf.has(ex.next.id)) {
targetId = ex.next.id
} else {
for (const wid of ex.walk) {
if (!rectOf.has(wid)) {
targetId = wid
break
}
}
}
if (targetId == null) return
const info = photoInfo.get(targetId) || (tiles.get(targetId) && tiles.get(targetId).info)
if (!info) return
const rect = placeRect(tileW(info), tileH(info), from, dir)
if (!rect) return
addTile(info, rect)
setCurrent(targetId, 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 */ }
})