diff options
Diffstat (limited to 'public')
| -rw-r--r-- | public/app.css | 150 | ||||
| -rw-r--r-- | public/app.js | 281 | ||||
| -rw-r--r-- | public/index.html | 40 |
3 files changed, 471 insertions, 0 deletions
diff --git a/public/app.css b/public/app.css new file mode 100644 index 0000000..b72761c --- /dev/null +++ b/public/app.css @@ -0,0 +1,150 @@ +:root { + --ink: #111; + --paper: #faf8f5; + --line: #ddd6cc; + --accent: #b3000a; + --mono: "SF Mono", ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace; + --serif: Georgia, "Times New Roman", serif; +} +* { box-sizing: border-box; } +html { background: var(--paper); } +body { + margin: 0; + color: var(--ink); + font-family: var(--mono); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} +.mast { + max-width: 980px; + margin: 0 auto; + padding: 48px 24px 8px; +} +.mast h1 { + font-family: var(--serif); + font-weight: 400; + font-size: 44px; + letter-spacing: -0.02em; + margin: 0 0 4px; +} +.mast h1 .dot { color: var(--accent); } +.tag { margin: 0 0 22px; color: #555; font-size: 13px; } +.modes { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; } +.modes button { + font: inherit; + background: none; + border: 1px solid var(--line); + border-radius: 999px; + padding: 5px 14px; + color: var(--ink); + cursor: pointer; +} +.modes button.on { background: var(--ink); color: var(--paper); border-color: var(--ink); } +.status { margin: 0; color: #888; font-size: 12px; min-height: 18px; } + +.stream { max-width: 980px; margin: 0 auto; padding: 12px 24px 40px; } + +.thread { margin: 34px 0 0; } +.thread-head { + display: flex; + align-items: baseline; + gap: 10px; + margin-bottom: 10px; +} +.thread-head h2 { + font-family: var(--serif); + font-weight: 400; + font-size: 22px; + margin: 0; +} +.thread-head .count { color: var(--accent); font-size: 12px; } +.strip { + display: flex; + gap: 6px; + overflow-x: auto; + padding-bottom: 8px; + scrollbar-width: thin; +} +.strip a { flex: 0 0 auto; display: block; } +.strip img { + display: block; + width: 140px; + height: 140px; + object-fit: cover; + border-radius: 3px; + background: #eee; + transition: transform 120ms ease; +} +.strip a:hover img { transform: scale(1.03); } +.swatch { + display: block; + height: 3px; + background: #ccc; + border-radius: 0 0 3px 3px; + margin-top: 2px; +} + +.lightbox { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.82); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; +} +.lightbox[hidden] { display: none; } +.lightbox-inner { + display: flex; + gap: 24px; + max-width: 1180px; + width: calc(100% - 40px); + background: var(--paper); + border-radius: 4px; + padding: 18px; + position: relative; + align-items: flex-start; +} +.lightbox-inner img { + max-width: min(760px, 72%); + max-height: 82vh; + object-fit: contain; + border-radius: 2px; + background: #000; +} +.lightbox-side { flex: 1; min-width: 220px; padding-right: 30px; } +.lightbox-side h2 { font-family: var(--serif); font-weight: 400; font-size: 24px; margin: 0 0 4px; } +.dim { color: #666; } +.conns { margin-top: 16px; } +.conns h3 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: #888; margin: 0 0 8px; } +.conns .chip { + display: inline-block; + border: 1px solid var(--line); + border-radius: 999px; + padding: 4px 12px; + margin: 0 6px 6px 0; + font-size: 12px; + cursor: pointer; +} +.conns .chip:hover { border-color: var(--accent); color: var(--accent); } +.conns .chip b { color: var(--accent); font-weight: 600; } +.close { + position: absolute; + top: 8px; + right: 12px; + border: none; + background: none; + font-size: 26px; + cursor: pointer; + color: #555; +} +.close:hover { color: var(--ink); } + +.foot { + max-width: 980px; + margin: 0 auto; + padding: 10px 24px 40px; + color: #aaa; + font-size: 11px; +} 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() diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..e59101c --- /dev/null +++ b/public/index.html @@ -0,0 +1,40 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>pathways — photo viewer</title> + <link rel="stylesheet" href="/app.css"> +</head> +<body> + <header class="mast"> + <h1>pathways<span class="dot">.</span></h1> + <p class="tag">a way through the library, along the things photos share.</p> + <nav id="modes" class="modes" aria-label="path type"> + <button data-mode="label" class="on">labels</button> + <button data-mode="keyword">keywords</button> + <button data-mode="camera">cameras</button> + <button data-mode="lens">lenses</button> + <button data-mode="colour">colours</button> + </nav> + <p id="status" class="status"></p> + </header> + + <main id="stream" class="stream"></main> + + <div id="lightbox" class="lightbox" hidden> + <div class="lightbox-inner"> + <img id="lightbox-img" alt=""> + <div class="lightbox-side"> + <h2 id="lightbox-title"></h2> + <p id="lightbox-date" class="dim"></p> + <div id="lightbox-conns" class="conns"></div> + </div> + <button id="lightbox-close" class="close" aria-label="close">×</button> + </div> + </div> + + <footer class="foot">pathways · built over the photoprism database · no tracking</footer> + <script src="/app.js" defer></script> +</body> +</html> |
