"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 slackScore(rect, slide) {
let lo = -Infinity
let hi = Infinity
for (const t of tiles.values()) {
const r = t.rect
if (slide === "x") {
if (r.y + r.h <= rect.y - GAP || rect.y + rect.h <= r.y - GAP) continue
if (r.x + r.w <= rect.x) lo = Math.max(lo, r.x + r.w)
else if (r.x >= rect.x + rect.w) hi = Math.min(hi, r.x)
} else {
if (r.x + r.w <= rect.x - GAP || rect.x + rect.w <= r.x - GAP) continue
if (r.y + r.h <= rect.y) lo = Math.max(lo, r.y + r.h)
else if (r.y >= rect.y + rect.h) hi = Math.min(hi, r.y)
}
}
const start = slide === "x" ? rect.x : rect.y
const end = slide === "x" ? rect.x + rect.w : rect.y + rect.h
const gLo = lo === -Infinity ? 0 : start - lo
const gHi = hi === Infinity ? 0 : hi - end
return Math.max(0, gLo) + Math.max(0, gHi)
}
function placeRect(w, h, from, dir) {
const base = baseRect(w, h, from, dir)
if (!collides(base)) return base
const horiz = dir === "N" || dir === "S"
const slide = horiz ? "x" : "y"
const fixed = horiz ? base.y : base.x
const slide0 = horiz ? base.x : base.y
const at = (c) => (horiz ? { x: c, y: fixed, w, h } : { x: fixed, y: c, w, h })
let nearest = null
for (let k = 1; k < 1000; k++) {
for (const off of [k * 2, -k * 2]) {
if (!collides(at(slide0 + off))) {
nearest = at(slide0 + off)
break
}
}
if (nearest) break
}
if (!nearest) return base
const cands = new Map()
cands.set(horiz ? nearest.x : nearest.y, nearest)
for (const t of tiles.values()) {
const r = t.rect
const lines = horiz
? [r.x - w - GAP, r.x + r.w + GAP, r.x + (r.w - w) / 2]
: [r.y - h - GAP, r.y + r.h + GAP, r.y + (r.h - h) / 2]
for (const c of lines) {
const rect = at(c)
if (!collides(rect)) cands.set(c, rect)
}
}
let best = null
let bestScore = Infinity
for (const [c, rect] of cands) {
const score = slackScore(rect, slide) + 0.35 * Math.abs(c - slide0)
if (score < bestScore) {
best = rect
bestScore = score
}
}
return best || nearest
}
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)
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 */ }
})