diff options
Diffstat (limited to 'public/app.js')
| -rw-r--r-- | public/app.js | 281 |
1 files changed, 281 insertions, 0 deletions
diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..406b6ce --- /dev/null +++ b/public/app.js @@ -0,0 +1,281 @@ +"use strict" + +const $ = (sel) => document.querySelector(sel) +const stream = $("#stream") +const status = $("#status") +const modes = $("#modes") + +const PHOTOS = new Map() // id -> {thumb, title, date} +const FACETS = new Map() // type -> facet[] +const COLOURS = new Map() // photo id -> [r,g,b] + +let currentMode = "label" +let revealed = 0 +const PER_BATCH = 4 + +/* ---- tiny observable-style stream ---- */ + +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) + }, + } +} + +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 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 + const img = document.createElement("img") + 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 +} + +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 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` + } +} + +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) + } + 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 +} + +function closeLightbox() { + $("#lightbox").hidden = true +} + +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 }) + } +} + +/* ---- 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() +} + +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() + }) +} + +modes.addEventListener("click", (e) => { + const b = e.target.closest("button") + if (!b) return + const mode = b.dataset.mode + setMode(mode) + if (mode === "colour") buildColourMode() +}) + +$("#lightbox-close").addEventListener("click", closeLightbox) +$("#lightbox").addEventListener("click", (e) => { if (e.target === $("#lightbox")) closeLightbox() }) +document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeLightbox() }) + +/* ---- boot ---- */ + +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) + } + stream.appendChild(sentinel) + renderChunk() +} + +boot() |
