"use strict" const $ = (s) => document.querySelector(s) const stage = $("#stage") const world = $("#world") const importState = $("#import-state") const importEl = $("#import") const fileInput = $("#file") const dropOverlay = $("#drop") const toastEl = $("#toast") const progressEl = $("#progress") const progressFill = $("#progress-fill") const progressText = $("#progress-text") addEventListener("error", (e) => { toast(`error: ${e.message || "script failed"}`, true) }) const GAP = 10 const FIT_TOLERANCE = 350 const MIN_ZOOM = 0.05 const MAX_ZOOM = 5 const EPS = 0.5 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 let previewsShown = false const TOUCH = typeof window.matchMedia === "function" && window.matchMedia("(hover: none)").matches 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 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) placementCache.clear() adjacencyCache.clear() renderNav(info.id) return el } function setCurrent(id, rect, recenter = true) { const prevId = cur && cur.id for (const t of tiles.values()) t.el.classList.remove("current") cur = { id, rect } tiles.get(id).el.classList.add("current") if (recenter) { if (!userZoomed) scale = worldScale() center() } getExits(id) if (prevId && prevId !== id) renderNav(prevId) renderNav(id) refreshPreviews() } 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 if (cur && cur.id === id) return if (TOUCH) 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}` zone.addEventListener("pointerdown", (e) => e.stopPropagation()) zone.addEventListener("click", (e) => { e.stopPropagation(); traverseFrom(id, ex.dir) }) 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) renderTileZones(id) return exits } catch { return null } })() exitPending.set(id, p) p.finally(() => exitPending.delete(id)) return p } const placementCache = new Map() const adjacencyCache = new Map() function choosePlacement(id, dir) { const key = id + ":" + dir if (placementCache.has(key)) return placementCache.get(key) 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 const out = { id: chosen.id, info: chosen.info, rect: chosen.rect } placementCache.set(key, out) return out } function adjacentTile(id, dir) { const key = id + ":" + dir if (adjacencyCache.has(key)) return adjacencyCache.get(key) 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 } } const out = best ? best.info.id : null adjacencyCache.set(key, out) return out } function traverseFrom(id, dir) { previewsShown = false followEnabled = false const back = adjacentTile(id, dir) if (back) { setCurrent(back, tiles.get(back).rect) return } const chosen = choosePlacement(id, dir) if (!chosen) return addTile(chosen.info, chosen.rect) setCurrent(chosen.id, chosen.rect) } const zones = new Map() function clearTileZones(id) { const entry = zones.get(id) if (entry) { for (const z of entry.els) world.removeChild(z) if (entry.preview) world.removeChild(entry.preview) zones.delete(id) } } function clearAllZones() { for (const id of [...zones.keys()]) clearTileZones(id) } function resetPreview() { if (!cur) return const entry = zones.get(cur.id) if (entry && entry.reset) entry.reset() } function renderTileZones(id) { if (!cur || id !== cur.id) return if (!tiles.has(id) || !exitsCache.has(id)) return const cx = (window.innerWidth / 2 - panX) / scale const cy = (window.innerHeight / 2 - panY) / scale const dirs = [] for (const dir of ["N", "E", "S", "W"]) { if (adjacentTile(id, dir)) continue const p = choosePlacement(id, dir) if (!p) continue const d = Math.hypot(p.rect.x + p.rect.w / 2 - cx, p.rect.y + p.rect.h / 2 - cy) dirs.push({ dir, p, d }) } const key = dirs.map((x) => x.dir + x.p.info.id + "@" + x.p.rect.x + "," + x.p.rect.y).join("|") let best = null for (const x of dirs) if (!best || x.d < best.d) best = x const existing = zones.get(id) if (existing && existing.key === key) return clearTileZones(id) if (!best) return const els = [] const preview = document.createElement("div") preview.className = "preview hidden" const previewImg = document.createElement("img") previewImg.alt = "" preview.appendChild(previewImg) const spinner = document.createElement("div") spinner.className = "spinner" preview.appendChild(spinner) let hotDir = null let loadTimer = 0 const doneLoading = () => { clearTimeout(loadTimer) preview.classList.remove("loading", "slow") } previewImg.onload = doneLoading previewImg.onerror = doneLoading const showAt = (x) => { previewsShown = true preview.classList.remove("hidden") preview.style.left = x.p.rect.x + "px" preview.style.top = x.p.rect.y + "px" preview.style.width = x.p.rect.w + "px" preview.style.height = x.p.rect.h + "px" const next = new URL(x.p.info.thumb, location.href).href if (previewImg.src === next && previewImg.complete) { doneLoading() return } clearTimeout(loadTimer) preview.classList.add("loading") preview.classList.remove("slow") previewImg.src = next loadTimer = setTimeout(() => { if (preview.classList.contains("loading")) preview.classList.add("slow") }, 1000) } const reset = () => { hotDir = null preview.classList.remove("hot") if (best) showAt(best) } for (const x of dirs) { const z = document.createElement("div") z.className = "dropzone" z.dataset.dir = x.dir z.style.left = x.p.rect.x + "px" z.style.top = x.p.rect.y + "px" z.style.width = x.p.rect.w + "px" z.style.height = x.p.rect.h + "px" z.addEventListener("pointerdown", (e) => e.stopPropagation()) z.addEventListener("click", () => traverseFrom(id, x.dir)) if (!TOUCH) { z.addEventListener("pointermove", () => { if (hotDir !== x.dir) { hotDir = x.dir showAt(x) preview.classList.add("hot") } }) z.addEventListener("pointerleave", () => { if (hotDir === x.dir) { hotDir = null preview.classList.remove("hot") } }) } world.appendChild(z) els.push(z) } world.appendChild(preview) if (previewsShown && best) showAt(best) zones.set(id, { els, key, preview, reset }) } function refreshPreviews() { if (!cur) { clearAllZones(); return } for (const [id, zs] of zones) if (id !== cur.id) clearTileZones(id) renderTileZones(cur.id) } let lastFollow = 0 let followEnabled = true function followCurrent() { if (!tiles.size || !cur) return const cx = (window.innerWidth / 2 - panX) / scale const cy = (window.innerHeight / 2 - panY) / scale const view = { x: cx - window.innerWidth / 2 / scale, y: cy - window.innerHeight / 2 / scale, w: window.innerWidth / scale, h: window.innerHeight / scale, } let best = null let bestD = Infinity for (const t of tiles.values()) { const r = t.rect if (!overlaps(view, r)) continue const px = Math.max(r.x, Math.min(cx, r.x + r.w)) const py = Math.max(r.y, Math.min(cy, r.y + r.h)) const d = Math.hypot(cx - px, cy - py) if (d < bestD) { bestD = d; best = t } } if (!best || best.info.id === cur.id) return setCurrent(best.info.id, best.rect, false) } function maybeFollow() { const now = performance.now() if (now - lastFollow < 200) return lastFollow = now const before = cur && cur.id if (followEnabled) followCurrent() if (cur && cur.id === before) refreshPreviews() } stage.addEventListener("pointerover", (e) => { const tileEl = e.target.closest(".tile") if (tileEl) getExits(Number(tileEl.dataset.id)) }) const pointers = new Map() let pinchDist = 0 stage.addEventListener("pointerdown", (e) => { if (e.target.closest(".nav-zone")) return if (e.target.closest("#import-state")) return pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) try { stage.setPointerCapture(e.pointerId) } catch { /* ignore */ } resetPreview() world.style.transition = "none" if (pointers.size === 2) { followEnabled = true drag = null const [a, b] = [...pointers.values()] pinchDist = Math.hypot(b.x - a.x, b.y - a.y) return } followEnabled = false drag = { x: e.clientX, y: e.clientY, px: panX, py: panY, moved: false, tile: e.target.closest(".tile") } }) stage.addEventListener("pointermove", (e) => { if (!previewsShown) { const entry = zones.get(cur && cur.id) if (entry && entry.reset) entry.reset() } if (pointers.has(e.pointerId)) { pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) if (pointers.size === 2 && pinchDist > 0) { const [a, b] = [...pointers.values()] const d = Math.hypot(b.x - a.x, b.y - a.y) if (d > 0) { zoomAt((a.x + b.x) / 2, (a.y + b.y) / 2, d / pinchDist) pinchDist = d } return } } 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() maybeFollow() } }) stage.addEventListener("pointerup", (e) => { const wasPinch = pointers.size >= 2 pointers.delete(e.pointerId) if (wasPinch && pointers.size === 1) { const p = [...pointers.values()][0] drag = { x: p.x, y: p.y, px: panX, py: panY, moved: true, tile: null } } pinchDist = 0 if (!drag) return const wasDrag = drag.moved const tile = drag.tile drag = null world.style.transition = "" if (wasDrag || !tile) { if (wasDrag && followEnabled) followCurrent() if (!wasDrag && !tile && e.pointerType === "touch") bgTap() return } const id = Number(tile.dataset.id) followEnabled = false if (cur && cur.id === id) return previewsShown = false setCurrent(id, tiles.get(id).rect) }) stage.addEventListener("pointercancel", (e) => { pointers.delete(e.pointerId) pinchDist = 0 drag = null world.style.transition = "" }) let lastBgTap = -Infinity function bgTap() { const now = performance.now() if (now - lastBgTap < 350) { lastBgTap = 0 resetZoom() return } lastBgTap = now } let zoomTimer = 0 stage.addEventListener("wheel", (e) => { e.preventDefault() followEnabled = true 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 { resetPreview() panX -= dx panY -= dy applyPan() maybeFollow() } 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) let started = false function showImportState() { importState.hidden = false stage.classList.add("blocked") } function hideImportState() { importState.hidden = true stage.classList.remove("blocked") } async function initDirectHint() { try { const r = await fetch("/api/health") const data = await r.json() const d = data.direct || {} const urls = [d.magic, d.lan, d.tailscale].filter(Boolean) if (!urls.length) return if (urls.some((u) => { try { return new URL(u).host === location.host } catch { return false } })) return const link = $("#direct-link") link.textContent = d.magic link.href = d.magic $("#direct-hint").hidden = false } catch { /* ignore */ } } async function beginWalk() { if (started) return started = true try { const r = await fetch("/api/random") const data = await r.json() if (!data.photo) { showImportState() return } hideImportState() 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 */ } } ;(async () => { try { const r = await fetch("/api/paths") const data = await r.json() if (data.total) await beginWalk() else showImportState() } catch { showImportState() } initDirectHint() })() let toastTimer = 0 function toast(msg, isError = false) { toastEl.textContent = msg toastEl.classList.toggle("err", isError) toastEl.classList.add("show") clearTimeout(toastTimer) toastTimer = setTimeout(() => toastEl.classList.remove("show"), isError ? 7000 : 3500) } function plural(n) { return n === 1 ? "photo" : "photos" } function setProgress(mode, pct, text) { progressEl.hidden = false progressText.textContent = text if (mode === "indeterminate") { progressEl.classList.add("indeterminate") } else { progressEl.classList.remove("indeterminate") progressFill.style.width = pct + "%" } } function hideProgress() { progressEl.hidden = true progressEl.classList.remove("indeterminate") progressFill.style.width = "0%" } function uploadWithProgress(form, onPct, onUploaded) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.open("POST", "/api/import") xhr.upload.addEventListener("progress", (e) => { if (e.lengthComputable) onPct(Math.min(100, Math.round((e.loaded / e.total) * 100))) }) xhr.upload.addEventListener("load", () => onUploaded()) xhr.addEventListener("load", () => { let data = {} try { data = JSON.parse(xhr.responseText) } catch { /* ignore */ } if (xhr.status >= 200 && xhr.status < 300) resolve(data) else reject(new Error(data.error || `import failed (${xhr.status})`)) }) xhr.addEventListener("error", () => reject(new Error("upload failed (network error)"))) xhr.send(form) }) } async function importFiles(list) { const files = Array.from(list || []).filter((f) => f.size > 0) if (!files.length) return const form = new FormData() for (const f of files) form.append("photos", f, f.name) importEl.classList.add("busy") setProgress("determinate", 0, `uploading ${files.length} ${plural(files.length)}… 0%`) try { const data = await uploadWithProgress( form, (pct) => setProgress("determinate", pct, `uploading ${files.length} ${plural(files.length)}… ${pct}%`), () => setProgress("indeterminate", 100, "indexing…") ) if (!data.ok) throw new Error(data.error || "import failed") exitsCache.clear() exitPending.clear() hideProgress() hideImportState() await beginWalk() toast(`imported ${data.imported} ${plural(data.imported)} - keep walking to find them`) } catch (err) { hideProgress() toast(err.message || "import failed", true) } finally { importEl.classList.remove("busy") fileInput.value = "" } } function hasFiles(e) { return Array.from(e.dataTransfer?.types || []).includes("Files") } fileInput.addEventListener("change", () => importFiles(fileInput.files)) let dragDepth = 0 addEventListener("dragenter", (e) => { if (!hasFiles(e)) return e.preventDefault() dragDepth++ dropOverlay.classList.add("show") }) addEventListener("dragover", (e) => { if (!hasFiles(e)) return e.preventDefault() e.dataTransfer.dropEffect = "copy" }) addEventListener("dragleave", () => { dragDepth = Math.max(0, dragDepth - 1) if (dragDepth === 0) dropOverlay.classList.remove("show") }) addEventListener("drop", (e) => { if (!e.dataTransfer) return e.preventDefault() dragDepth = 0 dropOverlay.classList.remove("show") importFiles(e.dataTransfer.files) })