aboutsummaryrefslogtreecommitdiff
path: root/public/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/app.js')
-rw-r--r--public/app.js399
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()
+})