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