"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()