From a32e5b41b785e7334af6f796adc6a1b5216a5cc9 Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 4 Aug 2026 06:55:33 +0000 Subject: pathways: native-aspect free-form mosaic, no teleports (walk to next unplaced), arrows on every photo, arrows inside tiles, drop path text --- public/app.css | 58 ++------------- public/app.js | 215 +++++++++++++++++++++++++++++++++--------------------- public/index.html | 1 - server.mjs | 25 +++++-- 4 files changed, 158 insertions(+), 141 deletions(-) diff --git a/public/app.css b/public/app.css index cdb2226..cf44f2a 100644 --- a/public/app.css +++ b/public/app.css @@ -71,8 +71,8 @@ html, body { .nav-btn { position: absolute; - width: 46px; - height: 46px; + width: 40px; + height: 40px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, .28); background: rgba(255, 255, 255, .1); @@ -81,14 +81,14 @@ html, body { display: grid; place-items: center; box-shadow: 0 2px 12px rgba(0, 0, 0, .35); - opacity: .16; + opacity: .18; transition: opacity .15s, background .15s, color .15s, box-shadow .15s; } -.nav-btn svg { width: 20px; height: 20px; display: block; } -.nav-zone-N .nav-btn { top: 0; left: 50%; translate: -50% -50%; } -.nav-zone-E .nav-btn { top: 50%; right: 0; translate: 50% -50%; } -.nav-zone-S .nav-btn { bottom: 0; left: 50%; translate: -50% 50%; } -.nav-zone-W .nav-btn { top: 50%; left: 0; translate: -50% -50%; } +.nav-btn svg { width: 18px; height: 18px; display: block; } +.nav-zone-N .nav-btn { top: 10px; left: 50%; translate: -50% 0; } +.nav-zone-E .nav-btn { top: 50%; right: 10px; translate: 0 -50%; } +.nav-zone-S .nav-btn { bottom: 10px; left: 50%; translate: -50% 0; } +.nav-zone-W .nav-btn { top: 50%; left: 10px; translate: 0 -50%; } .nav-zone:hover .nav-btn { opacity: 1; background: rgba(255, 255, 255, .22); @@ -97,47 +97,6 @@ html, body { box-shadow: 0 4px 18px rgba(0, 0, 0, .45); } -.nav-label { - position: absolute; - left: 50%; - translate: -50%; - white-space: nowrap; - font-size: 12px; - font-weight: 500; - letter-spacing: .02em; - color: var(--ink); - background: rgba(18, 19, 24, .92); - border: 1px solid rgba(255, 255, 255, .14); - border-radius: 999px; - padding: 3px 10px; - 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; } - -#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; -} -#flash.show { opacity: 1; } - #splash { position: fixed; inset: 0; @@ -171,5 +130,4 @@ html, body { @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 9041575..1a8118b 100644 --- a/public/app.js +++ b/public/app.js @@ -4,32 +4,41 @@ const $ = (s) => document.querySelector(s) const stage = $("#stage") const world = $("#world") const splash = $("#splash") -const flash = $("#flash") const startBtn = $("#start") const GAP = 10 -const MAX_TILE = 900 -let TILE = Math.round(Math.min(window.innerWidth, window.innerHeight) * 0.92) -if (TILE > MAX_TILE) TILE = MAX_TILE const ARROW_SVG = { N: '', E: '', S: '', W: '', } -const DELTA = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] } +let U = 300 const tiles = new Map() -const coordOf = new Map() +const rectOf = new Map() +const photoInfo = new Map() +const exitsCache = new Map() +const exitPending = new Map() let cur = null -let curExits = [] -let fetchSeq = 0 let panX = 0 let panY = 0 let scale = 1 let drag = null -const key = (x, y) => `${x},${y}` +function aspect(info) { + return info.h > 0 ? info.w / info.h : 1 +} + +function tileW(info) { + const a = aspect(info) + return a >= 1 ? U * a : U +} + +function tileH(info) { + const a = aspect(info) + return a >= 1 ? U : U / a +} function worldScale() { const n = tiles.size @@ -43,23 +52,50 @@ function applyPan() { 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 - scale * cx - panY = window.innerHeight / 2 - scale * cy + const r = cur.rect + panX = window.innerWidth / 2 - scale * (r.x + r.w / 2) + panY = window.innerHeight / 2 - scale * (r.y + r.h / 2) applyPan() } -function addTile(info, x, y) { +function overlaps(a, b) { + return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y +} + +function collides(rect) { + for (const t of tiles.values()) if (overlaps(rect, t.rect)) return true + return false +} + +function baseRect(w, h, from, dir) { + if (dir === "N") return { x: from.x + (from.w - w) / 2, y: from.y - h - GAP, w, h } + if (dir === "S") return { x: from.x + (from.w - w) / 2, y: from.y + from.h + GAP, w, h } + if (dir === "E") return { x: from.x + from.w + GAP, y: from.y + (from.h - h) / 2, w, h } + return { x: from.x - w - GAP, y: from.y + (from.h - h) / 2, w, h } +} + +function placeRect(w, h, from, dir) { + const base = baseRect(w, h, from, dir) + if (!collides(base)) return base + const horiz = dir === "N" || dir === "S" + for (let k = 1; k < 60; k++) { + const off = k * (w + GAP) + const a = horiz ? { ...base, x: base.x + off } : { ...base, y: base.y + off } + if (!collides(a)) return a + const b = horiz ? { ...base, x: base.x - off } : { ...base, y: base.y - off } + if (!collides(b)) return b + } + return base +} + +function addTile(info, rect) { 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" + el.style.left = rect.x + "px" + el.style.top = rect.y + "px" + el.style.width = rect.w + "px" + el.style.height = rect.h + "px" const img = document.createElement("img") img.src = info.thumb img.alt = info.title || "photo" @@ -67,89 +103,96 @@ function addTile(info, x, y) { el.appendChild(img) world.appendChild(el) requestAnimationFrame(() => el.classList.add("lock")) - tiles.set(key(x, y), { el, info }) - coordOf.set(info.id, { x, y }) + tiles.set(info.id, { el, info, rect }) + rectOf.set(info.id, rect) + renderNav(info.id) return el } -function setCurrent(id, x, y) { +function setCurrent(id, rect) { 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 = [] + cur = { id, rect } + tiles.get(id).el.classList.add("current") scale = worldScale() - renderNav() center() - fetchExits(id) -} - -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) + getExits(id) } -function renderNav() { - if (!cur) return - const tileEl = tiles.get(key(cur.x, cur.y)).el - tileEl.querySelectorAll(".nav").forEach((n) => n.remove()) +function renderNav(id) { + const t = tiles.get(id) + if (!t) return + t.el.querySelectorAll(".nav").forEach((n) => n.remove()) + const exits = exitsCache.get(id) + if (!exits) return const nav = document.createElement("div") nav.className = "nav" - for (const ex of curExits) { + for (const ex of exits) { const zone = document.createElement("div") zone.className = `nav-zone nav-zone-${ex.dir}` const b = document.createElement("button") b.className = "nav-btn" b.dataset.dir = ex.dir b.innerHTML = ARROW_SVG[ex.dir] - const lab = document.createElement("span") - lab.className = "nav-label" - lab.textContent = ex.path.name - b.appendChild(lab) b.addEventListener("pointerdown", (e) => e.stopPropagation()) - b.addEventListener("click", (e) => { e.stopPropagation(); move(ex.dir) }) + b.addEventListener("click", (e) => { e.stopPropagation(); moveFrom(id, ex.dir) }) zone.appendChild(b) nav.appendChild(zone) } - tileEl.appendChild(nav) + t.el.appendChild(nav) } -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 */ } +async function getExits(id) { + if (exitsCache.has(id)) return exitsCache.get(id) + if (exitPending.has(id)) return exitPending.get(id) + const p = (async () => { + try { + const r = await fetch(`/api/exits/${id}`) + if (!r.ok) return null + const data = await r.json() + for (const ph of Object.values(data.photos || {})) photoInfo.set(ph.id, ph) + for (const ex of data.exits || []) if (ex.next) photoInfo.set(ex.next.id, ex.next) + const exits = data.exits || [] + exitsCache.set(id, exits) + renderNav(id) + return exits + } catch { + return null + } + })() + exitPending.set(id, p) + p.finally(() => exitPending.delete(id)) + return p } -async function move(dir) { - const ex = curExits.find((e) => e.dir === dir) +async function moveFrom(id, dir) { + const exits = exitsCache.get(id) + const ex = exits && exits.find((e) => e.dir === dir) if (!ex) return - flashText(ex.path.name) - const [dx, dy] = DELTA[dir] - const nx = cur.x + dx - const ny = cur.y + dy - const occupant = tiles.get(key(nx, ny)) - if (occupant) { - setCurrent(occupant.info.id, nx, ny) - return - } - const at = coordOf.get(ex.next.id) - if (at) { - setCurrent(ex.next.id, at.x, at.y) + const from = tiles.get(id).rect + let targetId = null + if (!rectOf.has(ex.next.id)) { + targetId = ex.next.id } else { - addTile(ex.next, nx, ny) - setCurrent(ex.next.id, nx, ny) + for (const wid of ex.walk) { + if (!rectOf.has(wid)) { + targetId = wid + break + } + } } + if (targetId == null) return + const info = photoInfo.get(targetId) || (tiles.get(targetId) && tiles.get(targetId).info) + if (!info) return + const rect = placeRect(tileW(info), tileH(info), from, dir) + addTile(info, rect) + setCurrent(targetId, rect) } +stage.addEventListener("pointerover", (e) => { + const tileEl = e.target.closest(".tile") + if (tileEl) getExits(Number(tileEl.dataset.id)) +}) + 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") } @@ -176,11 +219,10 @@ stage.addEventListener("pointerup", (e) => { 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) + const id = Number(tile.dataset.id) + const info = tiles.get(id).info + if (cur && cur.id === id) return + setCurrent(id, tiles.get(id).rect) }) addEventListener("keydown", (e) => { @@ -188,7 +230,7 @@ addEventListener("keydown", (e) => { const dir = map[e.key] if (dir) { e.preventDefault() - move(dir) + if (cur) moveFrom(cur.id, dir) } }) @@ -199,9 +241,12 @@ startBtn.addEventListener("click", async () => { 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") - } + const photo = data.photo + photoInfo.set(photo.id, photo) + const minDim = Math.min(window.innerWidth, window.innerHeight) + U = Math.max(100, Math.round((minDim * 0.92) / Math.max(aspect(photo), 1))) + const rect = { x: 0, y: 0, w: tileW(photo), h: tileH(photo) } + addTile(photo, rect) + setCurrent(photo.id, rect) + } catch { /* ignore */ } }) diff --git a/public/index.html b/public/index.html index 67ac3ff..d30ce09 100644 --- a/public/index.html +++ b/public/index.html @@ -16,7 +16,6 @@

four paths, one per direction. walk them.

-
diff --git a/server.mjs b/server.mjs index d5479fb..3c10134 100644 --- a/server.mjs +++ b/server.mjs @@ -20,7 +20,7 @@ const MIN_PATH = 3 const pool = mysql.createPool(DB) -const THUMB_SIZE = "720x720_fit" +const THUMB_SIZE = "1280x1024_fit" const BIG_SIZE = "1920x1200_fit" const thumb = (hash, size = THUMB_SIZE) => { if (!hash) return null @@ -33,7 +33,7 @@ let state = null async function load() { const [photos] = await pool.query( `SELECT p.id, p.photo_title, p.photo_year, p.photo_month, p.photo_day, - f.file_hash + f.file_hash, f.file_width, f.file_height 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 @@ -51,6 +51,8 @@ async function load() { date: p.photo_year ? `${p.photo_year}-${String(p.photo_month || 1).padStart(2, "0")}-${String(p.photo_day || 1).padStart(2, "0")}` : "", + w: p.file_width || 0, + h: p.file_height || 0, }) photoOrder.push(p.id) } @@ -123,10 +125,15 @@ async function load() { const step = used.get(p.key) || 0 used.set(p.key, step + 1) const idx = p.ids.indexOf(pid) + const cyc = p.ids + const start = (idx + 1 + step) % cyc.length + const walk = [] + for (let k = 0; k < 60 && walk.length < cyc.length; k++) walk.push(cyc[(start + k) % cyc.length]) list.push({ dir: DIRS[i], path: { type: p.type, name: p.name, count: p.count }, - nextId: p.ids[(idx + 1 + step) % p.ids.length], + nextId: cyc[start], + walk, }) } exits.set(pid, list) @@ -162,7 +169,7 @@ function json(res, code, data) { async function photoConnections(id) { const [ph] = await pool.query( `SELECT p.id, p.photo_title, p.photo_year, p.photo_month, p.photo_day, - f.file_hash + f.file_hash, f.file_width, f.file_height FROM photos p JOIN files f ON f.photo_id = p.id AND f.file_primary = 1 AND f.file_missing = 0 WHERE p.id = ?`, @@ -207,6 +214,8 @@ async function photoConnections(id) { 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")}` : "", + w: p.file_width || 0, + h: p.file_height || 0, }, connections: conns, } @@ -232,9 +241,15 @@ const server = http.createServer(async (req, res) => { const id = Number(exitMatch[1]) const e = state.exits.get(id) if (!e) return json(res, 404, { error: "not found" }) + const photos = {} + for (const x of e) for (const wid of x.walk) { + const ph = state.byId.get(wid) + if (ph) photos[wid] = ph + } 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) })), + exits: e.map((x) => ({ dir: x.dir, path: x.path, next: state.byId.get(x.nextId), walk: x.walk })), + photos, }) } const photoMatch = path.match(/^\/api\/photo\/(\d+)$/) -- cgit v1.3.1