aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md57
-rw-r--r--public/app.css273
-rw-r--r--public/app.js399
-rw-r--r--public/index.html39
-rw-r--r--server.mjs144
5 files changed, 439 insertions, 473 deletions
diff --git a/README.md b/README.md
index 346e910..36e7b53 100644
--- a/README.md
+++ b/README.md
@@ -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>
diff --git a/server.mjs b/server.mjs
index 0c1dac6..d5479fb 100644
--- a/server.mjs
+++ b/server.mjs
@@ -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) {