aboutsummaryrefslogtreecommitdiff
path: root/public/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/app.js')
-rw-r--r--public/app.js215
1 files changed, 130 insertions, 85 deletions
diff --git a/public/app.js b/public/app.js
index 9041575..1a8118b 100644
--- a/public/app.js
+++ b/public/app.js
@@ -4,32 +4,41 @@ const $ = (s) => document.querySelector(s)
const stage = $("#stage")
const world = $("#world")
const splash = $("#splash")
-const flash = $("#flash")
const startBtn = $("#start")
const GAP = 10
-const MAX_TILE = 900
-let TILE = Math.round(Math.min(window.innerWidth, window.innerHeight) * 0.92)
-if (TILE > MAX_TILE) TILE = MAX_TILE
const ARROW_SVG = {
N: '<svg viewBox="0 0 24 24"><path d="M5 15l7-8 7 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
E: '<svg viewBox="0 0 24 24"><path d="M9 5l8 7-8 7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
S: '<svg viewBox="0 0 24 24"><path d="M5 9l7 8 7-8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
W: '<svg viewBox="0 0 24 24"><path d="M15 5l-8 7 8 7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
}
-const DELTA = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] }
+let U = 300
const tiles = new Map()
-const coordOf = new Map()
+const rectOf = new Map()
+const photoInfo = new Map()
+const exitsCache = new Map()
+const exitPending = new Map()
let cur = null
-let curExits = []
-let fetchSeq = 0
let panX = 0
let panY = 0
let scale = 1
let drag = null
-const key = (x, y) => `${x},${y}`
+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
@@ -43,23 +52,50 @@ function applyPan() {
function center() {
if (!cur) return
- const cx = cur.x * (TILE + GAP) + TILE / 2
- const cy = cur.y * (TILE + GAP) + TILE / 2
- panX = window.innerWidth / 2 - scale * cx
- panY = window.innerHeight / 2 - scale * cy
+ 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 addTile(info, x, y) {
+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 placeRect(w, h, from, dir) {
+ const base = baseRect(w, h, from, dir)
+ if (!collides(base)) return base
+ const horiz = dir === "N" || dir === "S"
+ for (let k = 1; k < 60; k++) {
+ const off = k * (w + GAP)
+ const a = horiz ? { ...base, x: base.x + off } : { ...base, y: base.y + off }
+ if (!collides(a)) return a
+ const b = horiz ? { ...base, x: base.x - off } : { ...base, y: base.y - off }
+ if (!collides(b)) return b
+ }
+ return base
+}
+
+function addTile(info, rect) {
const el = document.createElement("div")
el.className = "tile"
el.dataset.id = info.id
- el.dataset.x = x
- el.dataset.y = y
- el.style.left = x * (TILE + GAP) + "px"
- el.style.top = y * (TILE + GAP) + "px"
- el.style.width = TILE + "px"
- el.style.height = TILE + "px"
+ 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"
@@ -67,89 +103,96 @@ function addTile(info, x, y) {
el.appendChild(img)
world.appendChild(el)
requestAnimationFrame(() => el.classList.add("lock"))
- tiles.set(key(x, y), { el, info })
- coordOf.set(info.id, { x, y })
+ tiles.set(info.id, { el, info, rect })
+ rectOf.set(info.id, rect)
+ renderNav(info.id)
return el
}
-function setCurrent(id, x, y) {
+function setCurrent(id, rect) {
for (const t of tiles.values()) t.el.classList.remove("current")
- cur = { id, x, y }
- tiles.get(key(x, y)).el.classList.add("current")
- curExits = []
+ cur = { id, rect }
+ tiles.get(id).el.classList.add("current")
scale = worldScale()
- renderNav()
center()
- fetchExits(id)
-}
-
-function flashText(text) {
- flash.textContent = text
- flash.classList.remove("show")
- void flash.offsetWidth
- flash.classList.add("show")
- clearTimeout(flashText._t)
- flashText._t = setTimeout(() => flash.classList.remove("show"), 1600)
+ getExits(id)
}
-function renderNav() {
- if (!cur) return
- const tileEl = tiles.get(key(cur.x, cur.y)).el
- tileEl.querySelectorAll(".nav").forEach((n) => n.remove())
+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 curExits) {
+ 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]
- const lab = document.createElement("span")
- lab.className = "nav-label"
- lab.textContent = ex.path.name
- b.appendChild(lab)
b.addEventListener("pointerdown", (e) => e.stopPropagation())
- b.addEventListener("click", (e) => { e.stopPropagation(); move(ex.dir) })
+ b.addEventListener("click", (e) => { e.stopPropagation(); moveFrom(id, ex.dir) })
zone.appendChild(b)
nav.appendChild(zone)
}
- tileEl.appendChild(nav)
+ t.el.appendChild(nav)
}
-async function fetchExits(id) {
- const seq = ++fetchSeq
- try {
- const r = await fetch(`/api/exits/${id}`)
- if (!r.ok) return
- const data = await r.json()
- if (seq !== fetchSeq || !cur || cur.id !== id) return
- curExits = data.exits || []
- renderNav()
- } catch { /* ignore */ }
+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 move(dir) {
- const ex = curExits.find((e) => e.dir === dir)
+async function moveFrom(id, dir) {
+ const exits = exitsCache.get(id)
+ const ex = exits && exits.find((e) => e.dir === dir)
if (!ex) return
- flashText(ex.path.name)
- const [dx, dy] = DELTA[dir]
- const nx = cur.x + dx
- const ny = cur.y + dy
- const occupant = tiles.get(key(nx, ny))
- if (occupant) {
- setCurrent(occupant.info.id, nx, ny)
- return
- }
- const at = coordOf.get(ex.next.id)
- if (at) {
- setCurrent(ex.next.id, at.x, at.y)
+ const from = tiles.get(id).rect
+ let targetId = null
+ if (!rectOf.has(ex.next.id)) {
+ targetId = ex.next.id
} else {
- addTile(ex.next, nx, ny)
- setCurrent(ex.next.id, nx, ny)
+ 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") }
@@ -176,11 +219,10 @@ stage.addEventListener("pointerup", (e) => {
drag = null
world.style.transition = ""
if (wasDrag || !tile) return
- const x = Number(tile.dataset.x)
- const y = Number(tile.dataset.y)
- const info = tiles.get(key(x, y)).info
- if (cur && cur.id === info.id) return
- setCurrent(info.id, x, y)
+ 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) => {
@@ -188,7 +230,7 @@ addEventListener("keydown", (e) => {
const dir = map[e.key]
if (dir) {
e.preventDefault()
- move(dir)
+ if (cur) moveFrom(cur.id, dir)
}
})
@@ -199,9 +241,12 @@ startBtn.addEventListener("click", async () => {
try {
const r = await fetch("/api/random")
const data = await r.json()
- addTile(data.photo, 0, 0)
- setCurrent(data.photo.id, 0, 0)
- } catch {
- flashText("could not reach the library")
- }
+ const photo = data.photo
+ photoInfo.set(photo.id, photo)
+ const minDim = Math.min(window.innerWidth, window.innerHeight)
+ U = Math.max(100, Math.round((minDim * 0.92) / Math.max(aspect(photo), 1)))
+ const rect = { x: 0, y: 0, w: tileW(photo), h: tileH(photo) }
+ addTile(photo, rect)
+ setCurrent(photo.id, rect)
+ } catch { /* ignore */ }
})