aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordax <me@dax.ist>2026-08-04 08:44:00 +0000
committerdax <me@dax.ist>2026-08-04 08:44:00 +0000
commitfe060ee95e000004bcdea9e8788d92cea33eaa80 (patch)
treeec4909939e6a2fad2942ddf946b1874a87239a79
parent3b57a64799205f3b8c3ef2a4b74e1a967d36d1e8 (diff)
pathways: round-trip navigation (right then left returns to the same photo), traversal from any tile with a free space, back arrows hide their ghost, per-tile dropzones capped for large mosaics
-rw-r--r--public/app.js81
-rw-r--r--public/index.html4
2 files changed, 67 insertions, 18 deletions
diff --git a/public/app.js b/public/app.js
index aa2eef3..e4fa727 100644
--- a/public/app.js
+++ b/public/app.js
@@ -10,6 +10,8 @@ const GAP = 10
const FIT_TOLERANCE = 350
const MIN_ZOOM = 0.05
const MAX_ZOOM = 5
+const EPS = 0.5
+const MAX_ZONE_TILES = 24
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>',
@@ -191,7 +193,6 @@ function setCurrent(id, rect) {
tiles.get(id).el.classList.add("current")
if (!userZoomed) scale = worldScale()
center()
- renderDropzones(id)
getExits(id)
}
@@ -211,8 +212,10 @@ function renderNav(id) {
b.dataset.dir = ex.dir
b.innerHTML = ARROW_SVG[ex.dir]
b.addEventListener("pointerdown", (e) => e.stopPropagation())
- b.addEventListener("click", (e) => { e.stopPropagation(); moveFrom(id, ex.dir) })
- b.addEventListener("pointerenter", () => showGhost(choosePlacement(id, ex.dir)))
+ b.addEventListener("click", (e) => { e.stopPropagation(); traverseFrom(id, ex.dir) })
+ b.addEventListener("pointerenter", () => {
+ if (!adjacentTile(id, ex.dir)) showGhost(choosePlacement(id, ex.dir))
+ })
b.addEventListener("pointerleave", () => hideGhost())
zone.appendChild(b)
nav.appendChild(zone)
@@ -233,7 +236,7 @@ async function getExits(id) {
const exits = data.exits || []
exitsCache.set(id, exits)
renderNav(id)
- if (cur && cur.id === id) renderDropzones(id)
+ renderTileZones(id)
return exits
} catch {
return null
@@ -277,12 +280,40 @@ function choosePlacement(id, dir) {
return { id: chosen.id, info: chosen.info, rect: chosen.rect }
}
-function moveFrom(id, dir) {
+function adjacentTile(id, dir) {
+ const r = tiles.get(id).rect
+ let best = null
+ let bestDist = Infinity
+ for (const t of tiles.values()) {
+ if (t.info.id === id) continue
+ const q = t.rect
+ let adj = false
+ if (dir === "N") adj = Math.abs(q.y + q.h + GAP - r.y) <= EPS && q.x < r.x + r.w && q.x + q.w > r.x
+ else if (dir === "S") adj = Math.abs(q.y - (r.y + r.h + GAP)) <= EPS && q.x < r.x + r.w && q.x + q.w > r.x
+ else if (dir === "E") adj = Math.abs(q.x - (r.x + r.w + GAP)) <= EPS && q.y < r.y + r.h && q.y + q.h > r.y
+ else adj = Math.abs(q.x + q.w + GAP - r.x) <= EPS && q.y < r.y + r.h && q.y + q.h > r.y
+ if (!adj) continue
+ const d = dir === "N" || dir === "S"
+ ? Math.abs(q.x + q.w / 2 - (r.x + r.w / 2))
+ : Math.abs(q.y + q.h / 2 - (r.y + r.h / 2))
+ if (d < bestDist) { bestDist = d; best = t }
+ }
+ return best ? best.info.id : null
+}
+
+function traverseFrom(id, dir) {
+ const back = adjacentTile(id, dir)
+ if (back) {
+ hideGhost()
+ setCurrent(back, tiles.get(back).rect)
+ return
+ }
const chosen = choosePlacement(id, dir)
if (!chosen) return
hideGhost()
addTile(chosen.info, chosen.rect)
setCurrent(chosen.id, chosen.rect)
+ refreshAllZones()
}
let ghost = null
@@ -311,17 +342,25 @@ function hideGhost() {
}
}
-let dropzones = []
-function clearDropzones() {
- for (const z of dropzones) world.removeChild(z)
- dropzones = []
+const zones = new Map()
+function clearTileZones(id) {
+ const zs = zones.get(id)
+ if (zs) {
+ for (const z of zs) world.removeChild(z)
+ zones.delete(id)
+ }
+}
+
+function clearAllZones() {
+ for (const id of [...zones.keys()]) clearTileZones(id)
}
-function renderDropzones(id) {
- clearDropzones()
- const t = tiles.get(id)
- if (!t) return
+function renderTileZones(id) {
+ clearTileZones(id)
+ if (!tiles.has(id) || !exitsCache.has(id)) return
+ const els = []
for (const dir of ["N", "E", "S", "W"]) {
+ if (adjacentTile(id, dir)) continue
const p = choosePlacement(id, dir)
if (!p) continue
const z = document.createElement("div")
@@ -333,10 +372,20 @@ function renderDropzones(id) {
z.addEventListener("pointerenter", () => showGhost(p))
z.addEventListener("pointerleave", () => hideGhost())
z.addEventListener("pointerdown", (e) => e.stopPropagation())
- z.addEventListener("click", () => moveFrom(id, dir))
+ z.addEventListener("click", () => traverseFrom(id, dir))
world.appendChild(z)
- dropzones.push(z)
+ els.push(z)
+ }
+ if (els.length) zones.set(id, els)
+}
+
+function refreshAllZones() {
+ clearAllZones()
+ if (tiles.size > MAX_ZONE_TILES && cur) {
+ renderTileZones(cur.id)
+ return
}
+ for (const id of tiles.keys()) if (exitsCache.has(id)) renderTileZones(id)
}
stage.addEventListener("pointerover", (e) => {
@@ -407,7 +456,7 @@ addEventListener("keydown", (e) => {
const dir = map[e.key]
if (dir) {
e.preventDefault()
- if (cur) moveFrom(cur.id, dir)
+ if (cur) traverseFrom(cur.id, dir)
} else if (e.key === "0" || e.key === "f" || e.key === "F") {
e.preventDefault()
resetZoom()
diff --git a/public/index.html b/public/index.html
index 7f378d3..51e3ef3 100644
--- a/public/index.html
+++ b/public/index.html
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>pathways</title>
- <link rel="stylesheet" href="/app.css?v=8">
+ <link rel="stylesheet" href="/app.css?v=9">
</head>
<body>
<main id="stage">
@@ -16,6 +16,6 @@
<p>four paths, one per direction. walk them.<br><span class="hint">drag or scroll to pan · pinch to zoom · double-click empty space or press 0 to fit</span></p>
<button id="start">start</button>
</div>
- <script src="/app.js?v=8"></script>
+ <script src="/app.js?v=9"></script>
</body>
</html>