aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordax <me@dax.ist>2026-08-04 06:55:33 +0000
committerdax <me@dax.ist>2026-08-04 06:55:33 +0000
commita32e5b41b785e7334af6f796adc6a1b5216a5cc9 (patch)
treec64639f2ae502ad80a071fe8a18f2322cc64ae13
parent6e64a126967a70f67c33f60fa438fa67023ca419 (diff)
pathways: native-aspect free-form mosaic, no teleports (walk to next unplaced), arrows on every photo, arrows inside tiles, drop path text
-rw-r--r--public/app.css58
-rw-r--r--public/app.js215
-rw-r--r--public/index.html1
-rw-r--r--server.mjs25
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: '<svg viewBox="0 0 24 24"><path d="M5 15l7-8 7 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
E: '<svg viewBox="0 0 24 24"><path d="M9 5l8 7-8 7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
S: '<svg viewBox="0 0 24 24"><path d="M5 9l7 8 7-8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
W: '<svg viewBox="0 0 24 24"><path d="M15 5l-8 7 8 7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
}
-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 @@
<p>four paths, one per direction. walk them.</p>
<button id="start">start</button>
</div>
- <div id="flash"></div>
<script src="/app.js"></script>
</body>
</html>
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+)$/)