diff options
| -rw-r--r-- | README.md | 57 | ||||
| -rw-r--r-- | public/app.css | 273 | ||||
| -rw-r--r-- | public/app.js | 399 | ||||
| -rw-r--r-- | public/index.html | 39 | ||||
| -rw-r--r-- | server.mjs | 144 |
5 files changed, 439 insertions, 473 deletions
@@ -1,30 +1,30 @@ # pathways -A photo viewer that organises a PhotoPrism library into paths — shared labels, -keywords, cameras, lenses, and dominant colours — instead of a flat grid or -albums. +A photo viewer that walks your library as a maze. You start on one random +picture; every photo secretly holds up to four paths — its biggest memberships +across labels, keywords, camera, and lens — one per direction. Press a +direction (arrow keys, or the buttons that appear when you hover) and the next +photo of that path locks into the grid beside you, in the direction you moved. +The visited pictures accumulate into a mosaic you can pan around and step back +through. The live instance lives at <https://pathways.dax.ist> and reads the PhotoPrism database directly (read-only) on the same machine. PhotoPrism's admin UI stays behind basic auth at <https://prism.dax.ist>; pathways is public. -## How it works +## How the paths work -- `server.mjs` — a single Node process (`nodejs_22`, no build step). It pools - the MariaDB `photoprism` database, groups the primary files into *paths* - (facets with ≥3 photos) and exposes three endpoints: - - `GET /api/paths` — every photo (id, thumb, title, date) plus the paths - - `GET /api/photo/:id` — one photo plus its connections across labels, - keywords, camera, and lens - - `GET /api/health` — liveness probe -- `public/` — the front end. Progressive reveal on scroll, a lightbox with - connection chips that jump you to the linked path, and a dominant-colour - swatch under every thumbnail. In colour mode the paths themselves are - computed client-side from each photo's dominant hue. - -Thumbnails are served as static files by Caddy from -`/srv/photoprism/storage/cache/thumbnails/{a}/{b}/{c}/{hash}_{size}.jpg` -(`720x720_fit` for the grid, `1920x1200_fit` in the lightbox). +- Every photo's paths are its member groups: label(s), keyword(s), camera, + lens. They're ranked by member count; the top four (one per direction) become + its exits. +- Paths with a single member are skipped; a photo with fewer than four usable + paths is topped up with a "library" path (all photos), so every direction + always leads somewhere. +- Each path is an ordered cycle (by taken date, then id): walking "grey" goes + grey → grey → … and wraps around. Assignment is deterministic, so revisiting + a photo always offers the same four exits. +- If a step lands on a photo already placed in the mosaic, focus just jumps to + its existing tile — the mosaic never holds two copies. ## Run it @@ -38,13 +38,26 @@ DB_HOST=127.0.0.1 DB_USER=photoprism DB_PASSWORD=… DB_NAME=photoprism \ On NixOS the box runs it as a systemd unit (`systemd.services.pathways`), started after `mysql.service`, as user `dax`. +## API + +- `GET /api/paths` — every photo (id, thumb, title, date) plus the facets + (groups with ≥3 members) +- `GET /api/random` — one random photo +- `GET /api/exits/:id` — a photo plus its four exits; each exit has the path + it follows (`type`, `name`, `count`) and the `next` photo in that path's cycle +- `GET /api/health` — liveness probe + +Thumbnails are served as static files by Caddy from +`/srv/photoprism/storage/cache/thumbnails/{a}/{b}/{c}/{hash}_{size}.jpg` +(`720x720_fit` for tiles). + ## Layout ``` server.mjs API + static file serving -public/index.html viewer page -public/app.css styling -public/app.js viewer logic (paths, colours, lightbox) +public/index.html splash + the walkable stage +public/app.css tiles, hover exits, lock-in animation +public/app.js grid state, exits, pan/click/keyboard, cycling walk ``` The thumbnail path sharding matches PhotoPrism: directory = first three diff --git a/public/app.css b/public/app.css index b72761c..308868f 100644 --- a/public/app.css +++ b/public/app.css @@ -1,150 +1,175 @@ :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; + --bg: #0e0f13; + --tile: #1b1d24; + --ring: #7dd3fc; + --ink: #e6e8ee; + --muted: #8b90a0; } + * { box-sizing: border-box; } -html { background: var(--paper); } -body { + +html, body { margin: 0; + height: 100%; + background: var(--bg); color: var(--ink); - font-family: var(--mono); - font-size: 14px; - line-height: 1.5; - -webkit-font-smoothing: antialiased; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + overflow: hidden; } -.mast { - max-width: 980px; - margin: 0 auto; - padding: 48px 24px 8px; + +#stage { + position: fixed; + inset: 0; + overflow: hidden; + touch-action: none; + user-select: none; } -.mast h1 { - font-family: var(--serif); - font-weight: 400; - font-size: 44px; - letter-spacing: -0.02em; - margin: 0 0 4px; + +#world { + position: absolute; + left: 0; + top: 0; + transform: translate(0, 0); + transition: transform .5s cubic-bezier(.22, 1, .36, 1); + will-change: transform; } -.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); + +.tile { + position: absolute; + border-radius: 6px; + overflow: hidden; + background: var(--tile); + box-shadow: 0 2px 10px rgba(0, 0, 0, .45); cursor: pointer; + transition: box-shadow .2s; } -.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; } +.tile img { width: 100%; height: 100%; object-fit: cover; display: block; } +.tile.lock { animation: lock .5s cubic-bezier(.22, 1, .36, 1); } -.thread { margin: 34px 0 0; } -.thread-head { - display: flex; - align-items: baseline; - gap: 10px; - margin-bottom: 10px; +@keyframes lock { + 0% { transform: scale(.2); opacity: 0; } + 100% { transform: scale(1); opacity: 1; } } -.thread-head h2 { - font-family: var(--serif); - font-weight: 400; - font-size: 22px; - margin: 0; + +.tile.current { + box-shadow: 0 0 0 2px var(--ring), 0 0 26px 2px rgba(125, 211, 252, .4); } -.thread-head .count { color: var(--accent); font-size: 12px; } -.strip { - display: flex; - gap: 6px; - overflow-x: auto; - padding-bottom: 8px; - scrollbar-width: thin; + +.nav { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0; + transition: opacity .15s; } -.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; +.tile.current:hover .nav { opacity: 1; } + +.nav-btn { + position: absolute; + pointer-events: auto; + width: 34px; + height: 34px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, .25); + background: rgba(14, 15, 19, .85); + color: var(--ink); + font-size: 15px; + line-height: 1; + cursor: pointer; + display: grid; + place-items: center; + transition: transform .12s, border-color .12s; } -.strip a:hover img { transform: scale(1.03); } -.swatch { - display: block; - height: 3px; - background: #ccc; - border-radius: 0 0 3px 3px; - margin-top: 2px; +.nav-btn:hover { border-color: var(--ring); transform: scale(1.12); } +.nav-btn[data-dir="N"] { top: 0; left: 50%; translate: -50% -50%; } +.nav-btn[data-dir="E"] { top: 50%; right: 0; translate: 50% -50%; } +.nav-btn[data-dir="S"] { bottom: 0; left: 50%; translate: -50% 50%; } +.nav-btn[data-dir="W"] { top: 50%; left: 0; translate: -50% -50%; } + +.nav-label { + position: absolute; + left: 50%; + translate: -50%; + white-space: nowrap; + font-size: 11px; + letter-spacing: .04em; + color: var(--ink); + background: rgba(14, 15, 19, .92); + border: 1px solid rgba(255, 255, 255, .18); + border-radius: 4px; + padding: 2px 7px; + opacity: 0; + transition: opacity .12s; + pointer-events: none; } +.nav-btn[data-dir="N"] .nav-label { bottom: calc(100% + 8px); } +.nav-btn[data-dir="S"] .nav-label { top: calc(100% + 8px); } +.nav-btn[data-dir="E"] .nav-label { top: 50%; left: calc(100% + 10px); translate: 0 -50%; } +.nav-btn[data-dir="W"] .nav-label { top: 50%; right: calc(100% + 10px); translate: 0 -50%; } +.nav-btn:hover .nav-label { opacity: 1; } -.lightbox { +#hint { position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.82); - display: flex; - align-items: center; - justify-content: center; - z-index: 50; + bottom: 12px; + left: 50%; + translate: -50%; + margin: 0; + font-size: 12px; + color: var(--muted); + letter-spacing: .05em; + pointer-events: none; } -.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; + +#flash { + position: fixed; + top: 18px; + left: 50%; + translate: -50%; + font-size: 14px; + letter-spacing: .06em; + color: var(--ink); + background: rgba(14, 15, 19, .88); + border: 1px solid rgba(255, 255, 255, .22); + border-radius: 6px; + padding: 6px 14px; + opacity: 0; + pointer-events: none; + transition: opacity .25s; } -.lightbox-inner img { - max-width: min(760px, 72%); - max-height: 82vh; - object-fit: contain; - border-radius: 2px; - background: #000; +#flash.show { opacity: 1; } + +#splash { + position: fixed; + inset: 0; + display: grid; + place-content: center; + justify-items: center; + gap: 14px; + text-align: center; + background: var(--bg); + z-index: 10; + transition: opacity .4s, visibility .4s; } -.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); +#splash.hide { opacity: 0; visibility: hidden; pointer-events: none; } +#splash h1 { margin: 0; font-size: 44px; font-weight: 300; letter-spacing: .18em; } +#splash h1 .dot { color: var(--ring); } +#splash p { max-width: 34ch; color: var(--muted); margin: 0; font-size: 15px; line-height: 1.6; } +#start { + margin-top: 10px; + padding: 10px 34px; + font-size: 15px; + letter-spacing: .12em; + border: 1px solid rgba(255, 255, 255, .3); 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; + background: transparent; + color: var(--ink); cursor: pointer; - color: #555; + transition: border-color .15s, background .15s; } -.close:hover { color: var(--ink); } +#start:hover { border-color: var(--ring); background: rgba(125, 211, 252, .08); } -.foot { - max-width: 980px; - margin: 0 auto; - padding: 10px 24px 40px; - color: #aaa; - font-size: 11px; +@media (prefers-reduced-motion: reduce) { + #world { transition: none; } + .tile.lock { animation: none; } + #flash { transition: none; } } 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() +}) diff --git a/public/index.html b/public/index.html index e59101c..4059428 100644 --- a/public/index.html +++ b/public/index.html @@ -3,38 +3,21 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>pathways — photo viewer</title> + <meta name="color-scheme" content="dark"> + <title>pathways</title> <link rel="stylesheet" href="/app.css"> </head> <body> - <header class="mast"> + <main id="stage"> + <div id="world"></div> + <p id="hint">hover the current photo for its four paths · arrow keys walk · drag to pan</p> + </main> + <div id="splash"> <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> + <p>Every photo holds four paths — one for each direction. Walk them, and the pictures lock into a mosaic behind you.</p> + <button id="start">start</button> </div> - - <footer class="foot">pathways · built over the photoprism database · no tracking</footer> - <script src="/app.js" defer></script> + <div id="flash"></div> + <script src="/app.js"></script> </body> </html> @@ -37,76 +37,102 @@ async function load() { FROM photos p JOIN files f ON f.photo_id = p.id AND f.file_primary = 1 AND f.file_missing = 0 WHERE p.deleted_at IS NULL - ORDER BY p.taken_at_local` + ORDER BY p.taken_at_local, p.id` ) const byId = new Map() + const photoOrder = [] for (const p of photos) { byId.set(p.id, { id: p.id, thumb: thumb(p.file_hash), + full: thumb(p.file_hash, BIG_SIZE), title: p.photo_title || "", date: p.photo_year ? `${p.photo_year}-${String(p.photo_month || 1).padStart(2, "0")}-${String(p.photo_day || 1).padStart(2, "0")}` : "", }) + photoOrder.push(p.id) } + const rank = new Map(photoOrder.map((id, i) => [id, i])) - const facetQueries = [ - { - type: "label", - label: "Labels", - q: `SELECT pl.label_id AS k, l.label_name AS name, COUNT(*) AS n - FROM photos_labels pl JOIN labels l ON l.id = pl.label_id - GROUP BY pl.label_id HAVING n >= ? ORDER BY n DESC`, - member: `SELECT label_id AS k, photo_id FROM photos_labels`, - }, - { - type: "keyword", - label: "Keywords", - q: `SELECT pk.keyword_id AS k, kw.keyword AS name, COUNT(*) AS n - FROM photos_keywords pk JOIN keywords kw ON kw.id = pk.keyword_id - GROUP BY pk.keyword_id HAVING n >= ? ORDER BY n DESC`, - member: `SELECT keyword_id AS k, photo_id FROM photos_keywords`, - }, - { - type: "camera", - label: "Cameras", - q: `SELECT p.camera_id AS k, CONCAT(c.camera_make, ' ', c.camera_model) AS name, COUNT(*) AS n - FROM photos p JOIN cameras c ON c.id = p.camera_id - WHERE p.camera_id <> 1 - GROUP BY p.camera_id HAVING n >= ? ORDER BY n DESC`, - member: `SELECT camera_id AS k, id AS photo_id FROM photos WHERE camera_id <> 1`, - }, - { - type: "lens", - label: "Lenses", - q: `SELECT p.lens_id AS k, l.lens_model AS name, COUNT(*) AS n - FROM photos p JOIN lenses l ON l.id = p.lens_id - WHERE p.lens_id <> 1 - GROUP BY p.lens_id HAVING n >= ? ORDER BY n DESC`, - member: `SELECT lens_id AS k, id AS photo_id FROM photos WHERE lens_id <> 1`, - }, - ] + const paths = new Map() + const photoPaths = new Map() - const facets = [] - for (const f of facetQueries) { - const [rows] = await pool.query(f.q, [MIN_PATH]) - const [members] = await pool.query(f.member) - const groups = new Map() - for (const m of members) { - if (!groups.has(m.k)) groups.set(m.k, []) - groups.get(m.k).push(m.photo_id) - } + function addRows(rows, type, label) { + const grouped = new Map() for (const r of rows) { - const ids = (groups.get(r.k) || []).filter((id) => byId.has(id)) - if (ids.length >= MIN_PATH) { - facets.push({ type: f.type, label: f.label, name: r.name, count: ids.length, ids }) + if (!byId.has(r.pid)) continue + if (!grouped.has(r.k)) grouped.set(r.k, { key: `${type}:${r.k}`, type, label, name: r.name, ids: [] }) + grouped.get(r.k).ids.push(r.pid) + } + for (const g of grouped.values()) { + g.ids.sort((a, b) => rank.get(a) - rank.get(b)) + g.count = g.ids.length + paths.set(g.key, g) + for (const pid of g.ids) { + if (!photoPaths.has(pid)) photoPaths.set(pid, []) + photoPaths.get(pid).push(g) } } } - return { builtAt: new Date().toISOString(), photos: Object.fromEntries(byId), facets } + const [lab] = await pool.query( + `SELECT pl.label_id AS k, l.label_name AS name, pl.photo_id AS pid + FROM photos_labels pl JOIN labels l ON l.id = pl.label_id`) + addRows(lab, "label", "Labels") + + const [kw] = await pool.query( + `SELECT pk.keyword_id AS k, kw.keyword AS name, pk.photo_id AS pid + FROM photos_keywords pk JOIN keywords kw ON kw.id = pk.keyword_id`) + addRows(kw, "keyword", "Keywords") + + const [cam] = await pool.query( + `SELECT p.camera_id AS k, CONCAT(c.camera_make, ' ', c.camera_model) AS name, p.id AS pid + FROM photos p JOIN cameras c ON c.id = p.camera_id + WHERE p.camera_id <> 1 AND p.camera_id IS NOT NULL`) + addRows(cam, "camera", "Cameras") + + const [len] = await pool.query( + `SELECT p.lens_id AS k, l.lens_model AS name, p.id AS pid + FROM photos p JOIN lenses l ON l.id = p.lens_id + WHERE p.lens_id <> 1 AND p.lens_id IS NOT NULL`) + addRows(len, "lens", "Lenses") + + const facets = [] + for (const type of ["label", "keyword", "camera", "lens"]) { + const list = Array.from(paths.values()) + .filter((g) => g.type === type && g.count >= MIN_PATH) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) + for (const g of list) facets.push({ type: g.type, label: g.label, name: g.name, count: g.count, ids: g.ids }) + } + + const library = { key: "library:all", type: "library", label: "Library", name: "library", ids: photoOrder, count: photoOrder.length } + const DIRS = ["N", "E", "S", "W"] + const exits = new Map() + for (const pid of photoOrder) { + const own = (photoPaths.get(pid) || []) + .filter((p) => p.count >= 2) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name) || a.type.localeCompare(b.type)) + const chosen = own.slice(0, 4) + while (chosen.length < 4) chosen.push(library) + const used = new Map() + const list = [] + for (let i = 0; i < 4; i++) { + const p = chosen[i] + const step = used.get(p.key) || 0 + used.set(p.key, step + 1) + const idx = p.ids.indexOf(pid) + list.push({ + dir: DIRS[i], + path: { type: p.type, name: p.name, count: p.count }, + nextId: p.ids[(idx + 1 + step) % p.ids.length], + }) + } + exits.set(pid, list) + } + + return { builtAt: new Date().toISOString(), photos: Object.fromEntries(byId), facets, photoOrder, byId, exits } } async function refresh() { @@ -193,7 +219,23 @@ const server = http.createServer(async (req, res) => { try { if (path === "/api/paths") { if (!state) await refresh() - return json(res, 200, state) + return json(res, 200, { builtAt: state.builtAt, photos: state.photos, facets: state.facets }) + } + if (path === "/api/random") { + if (!state) await refresh() + const id = state.photoOrder[Math.floor(Math.random() * state.photoOrder.length)] + return json(res, 200, { photo: state.byId.get(id) }) + } + const exitMatch = path.match(/^\/api\/exits\/(\d+)$/) + if (exitMatch) { + if (!state) await refresh() + const id = Number(exitMatch[1]) + const e = state.exits.get(id) + if (!e) return json(res, 404, { error: "not found" }) + return json(res, 200, { + photo: state.byId.get(id), + exits: e.map((x) => ({ dir: x.dir, path: x.path, next: state.byId.get(x.nextId) })), + }) } const photoMatch = path.match(/^\/api\/photo\/(\d+)$/) if (photoMatch) { |
