diff options
Diffstat (limited to 'public/app.js')
| -rw-r--r-- | public/app.js | 399 |
1 files changed, 151 insertions, 248 deletions
diff --git a/public/app.js b/public/app.js index 406b6ce..7a9f015 100644 --- a/public/app.js +++ b/public/app.js @@ -1,281 +1,184 @@ "use strict" -const $ = (sel) => document.querySelector(sel) -const stream = $("#stream") -const status = $("#status") -const modes = $("#modes") +const $ = (s) => document.querySelector(s) +const stage = $("#stage") +const world = $("#world") +const splash = $("#splash") +const flash = $("#flash") +const startBtn = $("#start") -const PHOTOS = new Map() // id -> {thumb, title, date} -const FACETS = new Map() // type -> facet[] -const COLOURS = new Map() // photo id -> [r,g,b] +const GAP = 8 +let TILE = window.matchMedia("(max-width: 700px)").matches ? 100 : 160 +const ARROW = { N: "↑", E: "→", S: "↓", W: "←" } +const DELTA = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] } -let currentMode = "label" -let revealed = 0 -const PER_BATCH = 4 +const tiles = new Map() +const coordOf = new Map() +let cur = null +let curExits = [] +let fetchSeq = 0 +let panX = 0 +let panY = 0 +let drag = null -/* ---- tiny observable-style stream ---- */ +const key = (x, y) => `${x},${y}` -const observable = (subscribe) => { - const listeners = [] - subscribe({ - next: (v) => listeners.slice().forEach((fn) => fn(v)), - error: (e) => listeners.slice().forEach((fn) => fn && fn(e)), - }) - return { - subscribe: (fn) => { - listeners.push(fn) - return () => listeners.splice(listeners.indexOf(fn), 1) - }, - } +function applyPan() { + world.style.transform = `translate(${panX}px, ${panY}px)` } -const fromEvent = (target, event) => - observable(({ next }) => target.addEventListener(event, next)) - -const debounce = (ms) => (source) => - observable(({ next }) => { - let t - return source.subscribe((v) => { - clearTimeout(t) - t = setTimeout(() => next(v), ms) - }) - }) - -const map = (fn) => (source) => - observable(({ next }) => source.subscribe((v) => next(fn(v)))) - -/* ---- dominant colour from a thumbnail ---- */ - -function colourFrom(img) { - const size = 24 - const canvas = document.createElement("canvas") - canvas.width = size - canvas.height = size - const ctx = canvas.getContext("2d") - try { - ctx.drawImage(img, 0, 0, size, size) - const { data } = ctx.getImageData(0, 0, size, size) - let r = 0, g = 0, b = 0, n = 0 - for (let i = 0; i < data.length; i += 16) { - r += data[i]; g += data[i + 1]; b += data[i + 2]; n++ - } - return [Math.round(r / n), Math.round(g / n), Math.round(b / n)] - } catch { - return [128, 128, 128] - } +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 - cx + panY = window.innerHeight / 2 - cy + applyPan() } -function rgbToHue([r, g, b]) { - r /= 255; g /= 255; b /= 255 - const max = Math.max(r, g, b), min = Math.min(r, g, b) - const d = max - min - if (d === 0) return -1 - let h - if (max === r) h = ((g - b) / d) % 6 - else if (max === g) h = (b - r) / d + 2 - else h = (r - g) / d + 4 - h = Math.round(h * 60) - return h < 0 ? h + 360 : h -} - -const HUE_NAMES = [ - "red", "orange", "yellow", "yellow", "green", "green", - "green", "cyan", "blue", "blue", "violet", "magenta", "red", -] - -async function colourFor(id, src) { - if (COLOURS.has(id)) return COLOURS.get(id) - return new Promise((resolve) => { - const img = new Image() - img.crossOrigin = "anonymous" - img.onload = () => { const c = colourFrom(img); COLOURS.set(id, c); resolve(c) } - img.onerror = () => { const c = [128, 128, 128]; COLOURS.set(id, c); resolve(c) } - img.src = src - }) -} - -/* ---- rendering ---- */ - -function thumbEl(photo, { full = false } = {}) { - const a = document.createElement("a") - a.href = `#photo-${photo.id}` - a.dataset.id = photo.id +function addTile(info, x, y) { + 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" const img = document.createElement("img") + img.src = info.thumb + img.alt = info.title || "photo" img.loading = "lazy" - img.alt = photo.title || "photo" - img.src = photo.thumb - a.appendChild(img) - const sw = document.createElement("span") - sw.className = "swatch" - a.appendChild(sw) - colourFor(photo.id, photo.thumb).then(([r, g, b]) => { - sw.style.background = `rgb(${r},${g},${b})` - }) - a.addEventListener("click", (e) => { e.preventDefault(); openLightbox(photo.id) }) - return a + el.appendChild(img) + world.appendChild(el) + requestAnimationFrame(() => el.classList.add("lock")) + tiles.set(key(x, y), { el, info }) + coordOf.set(info.id, { x, y }) + return el } -function threadEl(facet) { - const sec = document.createElement("section") - sec.className = "thread" - sec.id = `thread-${facet.type}-${facet.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}` - const head = document.createElement("div") - head.className = "thread-head" - const h = document.createElement("h2") - h.textContent = facet.name - const c = document.createElement("span") - c.className = "count" - c.textContent = `${facet.count}` - head.append(h, c) - const strip = document.createElement("div") - strip.className = "strip" - for (const id of facet.ids) { - const p = PHOTOS.get(id) - if (p) strip.appendChild(thumbEl(p)) - } - sec.append(head, strip) - return sec +function setCurrent(id, x, y) { + 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 = [] + renderNav() + center() + fetchExits(id) } -function renderChunk() { - const facets = FACETS.get(currentMode) || [] - const next = facets.slice(revealed, revealed + PER_BATCH) - const frag = document.createDocumentFragment() - for (const f of next) frag.appendChild(threadEl(f)) - stream.appendChild(frag) - revealed += next.length - if (revealed >= facets.length) { - status.textContent = `${facets.length} paths shown` - sentinel.unobserve() - } else { - status.textContent = `${revealed} of ${facets.length} paths` - } +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) } -const sentinel = document.createElement("div") -sentinel.id = "sentinel" - -const reveal$ = map(() => renderChunk())(debounce(60)(fromEvent(window, "scroll"))) -reveal$.subscribe(() => {}) -new IntersectionObserver((entries) => { - if (entries[0].isIntersecting) renderChunk() -}).observe(sentinel) - -/* ---- colour paths ---- */ - -const SWATCH_NAMES = [ - "red", "orange", "gold", "yellow", "lime", "green", - "teal", "cyan", "azure", "blue", "violet", "magenta", -] - -function colourPaths(ids) { - const buckets = new Map() - for (const id of ids) { - const c = COLOURS.get(id) - if (!c) continue - const hue = rgbToHue(c) - const bucket = hue < 0 ? 0 : Math.round(hue / 30) % 12 - if (!buckets.has(bucket)) buckets.set(bucket, []) - buckets.get(bucket).push(id) +function renderNav() { + if (!cur) return + const tileEl = tiles.get(key(cur.x, cur.y)).el + tileEl.querySelectorAll(".nav").forEach((n) => n.remove()) + const nav = document.createElement("div") + nav.className = "nav" + for (const ex of curExits) { + const b = document.createElement("button") + b.className = "nav-btn" + b.dataset.dir = ex.dir + b.textContent = ARROW[ex.dir] + const lab = document.createElement("span") + lab.className = "nav-label" + lab.textContent = `${ex.path.name} · ${ex.path.count}` + b.appendChild(lab) + b.addEventListener("pointerdown", (e) => e.stopPropagation()) + b.addEventListener("click", (e) => { e.stopPropagation(); move(ex.dir) }) + nav.appendChild(b) } - const out = [] - for (const [bucket, bucketIds] of buckets) { - if (bucketIds.length < 3) continue - out.push({ type: "colour", label: "Colours", name: SWATCH_NAMES[bucket], count: bucketIds.length, ids: bucketIds }) - } - return out.sort((a, b) => b.count - a.count) -} - -/* ---- lightbox ---- */ - -async function openLightbox(id) { - const box = $("#lightbox") - const resp = await fetch(`/api/photo/${id}`) - if (!resp.ok) return - const data = await resp.json() - $("#lightbox-img").src = data.photo.full - $("#lightbox-title").textContent = data.photo.title || "untitled" - $("#lightbox-date").textContent = data.photo.date - const conns = $("#lightbox-conns") - conns.innerHTML = "" - const h = document.createElement("h3") - h.textContent = "connections" - conns.appendChild(h) - for (const c of data.connections) { - const chip = document.createElement("span") - chip.className = "chip" - chip.textContent = `${c.name} · ` - const b = document.createElement("b") - b.textContent = `${c.n}` - chip.appendChild(b) - chip.addEventListener("click", () => { closeLightbox(); jumpTo(c.type, c.name) }) - conns.appendChild(chip) - } - box.hidden = false + tileEl.appendChild(nav) } -function closeLightbox() { - $("#lightbox").hidden = true +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 */ } } -function jumpTo(type, name) { - setMode(type) - const t = FACETS.get(type).find((f) => f.name === name) - if (!t) return - const el = document.getElementById(`thread-${type}-${t.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`) - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "start" }) - el.firstElementChild.animate([{ opacity: 0.4 }, { opacity: 1 }], { duration: 700 }) +async function move(dir) { + const ex = curExits.find((e) => e.dir === dir) + if (!ex) return + flashText(`${ex.path.name} · ${ex.path.count}`) + const [dx, dy] = DELTA[dir] + const nx = cur.x + dx + const ny = cur.y + dy + const at = coordOf.get(ex.next.id) + if (at) { + setCurrent(ex.next.id, at.x, at.y) + } else { + addTile(ex.next, nx, ny) + setCurrent(ex.next.id, nx, ny) } } -/* ---- mode switching ---- */ - -function setMode(mode) { - if (mode === currentMode && revealed > 0) return - currentMode = mode - revealed = 0 - stream.innerHTML = "" - stream.appendChild(sentinel) - for (const b of modes.children) b.classList.toggle("on", b.dataset.mode === mode) - renderChunk() -} +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) +}) -function buildColourMode() { - if (FACETS.has("colour")) return - const ids = Array.from(PHOTOS.keys()) - Promise.all(ids.map((id) => colourFor(id, PHOTOS.get(id).thumb))).then(() => { - FACETS.set("colour", colourPaths(ids)) - if (currentMode === "colour") renderChunk() - }) -} +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() + } +}) -modes.addEventListener("click", (e) => { - const b = e.target.closest("button") - if (!b) return - const mode = b.dataset.mode - setMode(mode) - if (mode === "colour") buildColourMode() +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 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) }) -$("#lightbox-close").addEventListener("click", closeLightbox) -$("#lightbox").addEventListener("click", (e) => { if (e.target === $("#lightbox")) closeLightbox() }) -document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeLightbox() }) +addEventListener("keydown", (e) => { + const map = { ArrowUp: "N", ArrowRight: "E", ArrowDown: "S", ArrowLeft: "W" } + const dir = map[e.key] + if (dir) { + e.preventDefault() + move(dir) + } +}) -/* ---- boot ---- */ +addEventListener("resize", center) -async function boot() { - status.textContent = "finding paths…" - const resp = await fetch("/api/paths") - if (!resp.ok) { status.textContent = "could not reach the library"; return } - const data = await resp.json() - for (const [id, p] of Object.entries(data.photos)) PHOTOS.set(Number(id), p) - for (const f of data.facets) { - if (!FACETS.has(f.type)) FACETS.set(f.type, []) - FACETS.get(f.type).push(f) +startBtn.addEventListener("click", async () => { + splash.classList.add("hide") + 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") } - stream.appendChild(sentinel) - renderChunk() -} - -boot() +}) |
