"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)