"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 MIN_ZOOM = 0.05
const MAX_ZOOM = 5
const EPS = 0.5
const MAX_ZONE_TILES = 24
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 userZoomed = false
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 zoomAt(mx, my, factor) {
const ns = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, scale * factor))
if (ns === scale) return
const wx = (mx - panX) / scale
const wy = (my - panY) / scale
panX = mx - wx * ns
panY = my - wy * ns
scale = ns
userZoomed = true
applyPan()
}
function resetZoom() {
userZoomed = false
scale = worldScale()
center()
}
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")
if (!userZoomed) scale = worldScale()
center()
getExits(id)
refreshAllNav()
}
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 isCurrent = cur && cur.id === id
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}`
zone.addEventListener("pointermove", () => {
if (adjacentTile(id, ex.dir)) return
zone.classList.add("preview")
cancelHideGhost()
showGhost(choosePlacement(id, ex.dir))
})
zone.addEventListener("pointerleave", () => {
zone.classList.remove("preview")
scheduleHideGhost()
})
zone.addEventListener("pointerdown", (e) => e.stopPropagation())
zone.addEventListener("click", (e) => { e.stopPropagation(); traverseFrom(id, ex.dir) })
if (!adjacentTile(id, ex.dir) && isCurrent) {
const chev = document.createElement("div")
chev.className = "nav-chev"
chev.innerHTML = ARROW_SVG[ex.dir]
zone.appendChild(chev)
}
nav.appendChild(zone)
}
t.el.appendChild(nav)
}
function refreshAllNav() {
for (const id of tiles.keys()) if (exitsCache.has(id)) renderNav(id)
}
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)
renderTileZones(id)
return exits
} catch {
return null
}
})()
exitPending.set(id, p)
p.finally(() => exitPending.delete(id))
return p
}
function choosePlacement(id, dir) {
const exits = exitsCache.get(id)
const ex = exits && exits.find((e) => e.dir === dir)
if (!ex) return null
const from = tiles.get(id).rect
const seen = new Set()
const claimed = new Set()
if (exits) for (const e of exits) if (e.dir !== dir && e.next) claimed.add(e.next.id)
const candIds = []
const addCand = (cid) => {
if (cid && !claimed.has(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) {
for (const [cid, info] of photoInfo) {
if (rectOf.has(cid) || cid === id) continue
const p = bestPlacement(tileW(info), tileH(info), from, dir)
if (p) scored.push({ id: cid, info, rect: p.rect, cost: p.cost + (claimed.has(cid) ? 1e12 : 0) })
}
}
if (!scored.length) return null
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
return { id: chosen.id, info: chosen.info, rect: chosen.rect }
}
function adjacentTile(id, dir) {
const r = tiles.get(id).rect
let best = null
let bestDist = Infinity
for (const t of tiles.values()) {
if (t.info.id === id) continue
const q = t.rect
let adj = false
if (dir === "N") adj = Math.abs(q.y + q.h + GAP - r.y) <= EPS && q.x < r.x + r.w && q.x + q.w > r.x
else if (dir === "S") adj = Math.abs(q.y - (r.y + r.h + GAP)) <= EPS && q.x < r.x + r.w && q.x + q.w > r.x
else if (dir === "E") adj = Math.abs(q.x - (r.x + r.w + GAP)) <= EPS && q.y < r.y + r.h && q.y + q.h > r.y
else adj = Math.abs(q.x + q.w + GAP - r.x) <= EPS && q.y < r.y + r.h && q.y + q.h > r.y
if (!adj) continue
const d = dir === "N" || dir === "S"
? Math.abs(q.x + q.w / 2 - (r.x + r.w / 2))
: Math.abs(q.y + q.h / 2 - (r.y + r.h / 2))
if (d < bestDist) { bestDist = d; best = t }
}
return best ? best.info.id : null
}
function traverseFrom(id, dir) {
const back = adjacentTile(id, dir)
if (back) {
hideGhost()
setCurrent(back, tiles.get(back).rect)
return
}
const chosen = choosePlacement(id, dir)
if (!chosen) return
hideGhost()
addTile(chosen.info, chosen.rect)
setCurrent(chosen.id, chosen.rect)
refreshAllNav()
refreshAllZones()
}
let ghost = null
let ghostKey = ""
function showGhost(p) {
if (!p) return
const key = p.id + "|" + p.rect.x + "," + p.rect.y
if (ghost && ghostKey === key) return
hideGhost()
ghostKey = key
const el = document.createElement("div")
el.className = "tile ghost"
el.style.left = p.rect.x + "px"
el.style.top = p.rect.y + "px"
el.style.width = p.rect.w + "px"
el.style.height = p.rect.h + "px"
const img = document.createElement("img")
img.src = p.info.thumb
img.alt = ""
el.appendChild(img)
world.appendChild(el)
ghost = el
}
function hideGhost() {
if (ghost) {
world.removeChild(ghost)
ghost = null
}
ghostKey = ""
}
let hideGhostTimer = 0
function scheduleHideGhost() {
clearTimeout(hideGhostTimer)
hideGhostTimer = setTimeout(hideGhost, 80)
}
function cancelHideGhost() {
clearTimeout(hideGhostTimer)
}
const zones = new Map()
function clearTileZones(id) {
const zs = zones.get(id)
if (zs) {
for (const z of zs) world.removeChild(z)
zones.delete(id)
}
}
function clearAllZones() {
for (const id of [...zones.keys()]) clearTileZones(id)
}
function renderTileZones(id) {
clearTileZones(id)
if (!tiles.has(id) || !exitsCache.has(id)) return
const els = []
for (const dir of ["N", "E", "S", "W"]) {
if (adjacentTile(id, dir)) continue
const p = choosePlacement(id, dir)
if (!p) continue
const z = document.createElement("div")
z.className = "dropzone"
z.style.left = p.rect.x + "px"
z.style.top = p.rect.y + "px"
z.style.width = p.rect.w + "px"
z.style.height = p.rect.h + "px"
z.addEventListener("pointermove", () => { cancelHideGhost(); showGhost(p) })
z.addEventListener("pointerleave", () => scheduleHideGhost())
z.addEventListener("pointerdown", (e) => e.stopPropagation())
z.addEventListener("click", () => traverseFrom(id, dir))
world.appendChild(z)
els.push(z)
}
if (els.length) zones.set(id, els)
}
function refreshAllZones() {
clearAllZones()
if (tiles.size > MAX_ZONE_TILES && cur) {
renderTileZones(cur.id)
return
}
for (const id of tiles.keys()) if (exitsCache.has(id)) renderTileZones(id)
}
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-zone")) return
hideGhost()
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)
})
let zoomTimer = 0
stage.addEventListener("wheel", (e) => {
e.preventDefault()
let dx = e.deltaX
let dy = e.deltaY
if (e.deltaMode === 1) { dx *= 16; dy *= 16 }
else if (e.deltaMode === 2) { dx *= window.innerWidth; dy *= window.innerHeight }
world.style.transition = "none"
if (e.ctrlKey) {
const rect = stage.getBoundingClientRect()
zoomAt(e.clientX - rect.left, e.clientY - rect.top, Math.exp(-dy * 0.01))
} else {
panX -= dx
panY -= dy
applyPan()
}
clearTimeout(zoomTimer)
zoomTimer = setTimeout(() => { world.style.transition = "" }, 120)
}, { passive: false })
stage.addEventListener("dblclick", (e) => {
if (e.target.closest(".tile")) return
resetZoom()
})
addEventListener("keydown", (e) => {
const map = { ArrowUp: "N", ArrowRight: "E", ArrowDown: "S", ArrowLeft: "W" }
const dir = map[e.key]
if (dir) {
e.preventDefault()
if (cur) traverseFrom(cur.id, dir)
} else if (e.key === "0" || e.key === "f" || e.key === "F") {
e.preventDefault()
resetZoom()
}
})
addEventListener("resize", center)
const MIN_READ = 1500
const splashShownAt = Date.now()
let started = false
let splashTimer = 0
function start() {
if (started) return
started = true
clearTimeout(splashTimer)
splash.classList.add("hide")
;(async () => {
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 */ }
})()
}
function noteActivity() {
if (started) return
const left = MIN_READ - (Date.now() - splashShownAt)
if (left <= 0) start()
else {
clearTimeout(splashTimer)
splashTimer = setTimeout(start, left)
}
}
startBtn.addEventListener("click", start)
addEventListener("pointermove", noteActivity)
addEventListener("pointerdown", noteActivity)
addEventListener("keydown", noteActivity)
addEventListener("wheel", noteActivity)
addEventListener("touchstart", noteActivity)