aboutsummaryrefslogtreecommitdiff
path: root/public
diff options
context:
space:
mode:
authordax <me@dax.ist>2026-08-04 10:54:06 +0000
committerdax <me@dax.ist>2026-08-04 10:54:06 +0000
commit803f72eb7162f8018ecfd9cbd65ac3d86b7ba076 (patch)
tree11be1708a16e10747cc86253b8c9cf113acaadd6 /public
parent43ec71f91b70fd65e180a4e2f6a4df80dad04b27 (diff)
Previews appear as you scroll: current tile follows pan, tappable destination previews
- Current tile follows the viewport while dragging (touch) or scrolling (wheel), throttled ~200ms, snapping to the nearest visible tile on gesture end. setCurrent(id, rect, recenter) so follow never fights the user's pan (no recenter, no zoom change). - Persistent destination previews: the current tile's free exits now render the destination photo (ghost) at each spot, always visible on mobile and desktop, replacing the old all-tile invisible dropzones and the hover-only dropzone ghost. Tapping/clicking a preview walks there. - Desktop hover ghost kept for non-current tiles only (the current tile's previews are already persistent, so no duplicate). - Dropzones/previews are current-tile-only; ghost preview on nav zones of the current tile suppressed. - Removed MAX_ZONE_TILES (unused after current-only previews). Tests: domtest 49/49 (rewritten ghost checks for persistent previews + follow-scroll), touchtest 19/19, exhaust 9/9, splash 6/6, fuzz clean, hoverlive against live data: 0 stray ghosts, 31 previews across 13 tiles.
Diffstat (limited to 'public')
-rw-r--r--public/app.css13
-rw-r--r--public/app.js65
-rw-r--r--public/index.html4
3 files changed, 66 insertions, 16 deletions
diff --git a/public/app.css b/public/app.css
index 6d5e992..5640b1c 100644
--- a/public/app.css
+++ b/public/app.css
@@ -49,7 +49,18 @@ html, body {
pointer-events: none;
box-shadow: 0 0 0 2px rgba(125, 211, 252, .65);
}
-.dropzone { position: absolute; }
+.dropzone { position: absolute; cursor: pointer; }
+.dropzone .preview {
+ width: 100%;
+ height: 100%;
+ opacity: .5;
+ pointer-events: none;
+ border-radius: 6px;
+ box-shadow: 0 0 0 2px rgba(125, 211, 252, .5);
+ transition: opacity .15s;
+}
+.dropzone:hover .preview,
+.dropzone:active .preview { opacity: .85; }
@keyframes lock {
0% { transform: scale(.2); opacity: 0; }
diff --git a/public/app.js b/public/app.js
index 1c920ae..21b3e49 100644
--- a/public/app.js
+++ b/public/app.js
@@ -11,7 +11,6 @@ const FIT_TOLERANCE = 350
const MIN_ZOOM = 0.05
const MAX_ZOOM = 5
const EPS = 0.5
-const MAX_ZONE_TILES = 24
let U = 300
const tiles = new Map()
@@ -180,16 +179,19 @@ function addTile(info, rect) {
return el
}
-function setCurrent(id, rect) {
+function setCurrent(id, rect, recenter = true) {
const prevId = cur && cur.id
for (const t of tiles.values()) t.el.classList.remove("current")
cur = { id, rect }
tiles.get(id).el.classList.add("current")
- if (!userZoomed) scale = worldScale()
- center()
+ if (recenter) {
+ if (!userZoomed) scale = worldScale()
+ center()
+ }
getExits(id)
if (prevId && prevId !== id) renderNav(prevId)
renderNav(id)
+ refreshPreviews()
}
function renderNav(id) {
@@ -208,6 +210,7 @@ function renderNav(id) {
if (!TOUCH) {
zone.addEventListener("pointermove", () => {
if (adjacentTile(id, ex.dir)) return
+ if (cur && cur.id === id) return
cancelHideGhost()
showGhost(choosePlacement(id, ex.dir))
})
@@ -337,7 +340,6 @@ function traverseFrom(id, dir) {
hideGhost()
addTile(chosen.info, chosen.rect)
setCurrent(chosen.id, chosen.rect)
- refreshAllZones()
}
let ghost = null
@@ -394,7 +396,7 @@ function clearAllZones() {
function renderTileZones(id) {
clearTileZones(id)
- if (TOUCH) return
+ if (!cur || id !== cur.id) return
if (!tiles.has(id) || !exitsCache.has(id)) return
const els = []
for (const dir of ["N", "E", "S", "W"]) {
@@ -407,8 +409,14 @@ function renderTileZones(id) {
z.style.top = p.rect.y + "px"
z.style.width = p.rect.w + "px"
z.style.height = p.rect.h + "px"
- z.addEventListener("pointermove", () => { cancelHideGhost(); showGhost(p) })
- z.addEventListener("pointerleave", () => scheduleHideGhost())
+ const prev = document.createElement("div")
+ prev.className = "preview"
+ const img = document.createElement("img")
+ img.src = p.info.thumb
+ img.alt = ""
+ img.loading = "lazy"
+ prev.appendChild(img)
+ z.appendChild(prev)
z.addEventListener("pointerdown", (e) => e.stopPropagation())
z.addEventListener("click", () => traverseFrom(id, dir))
world.appendChild(z)
@@ -417,13 +425,41 @@ function renderTileZones(id) {
if (els.length) zones.set(id, els)
}
-function refreshAllZones() {
+function refreshPreviews() {
clearAllZones()
- if (tiles.size > MAX_ZONE_TILES && cur) {
- renderTileZones(cur.id)
- return
+ if (cur) renderTileZones(cur.id)
+}
+
+let lastFollow = 0
+function followCurrent() {
+ if (!tiles.size || !cur) return
+ const cx = (window.innerWidth / 2 - panX) / scale
+ const cy = (window.innerHeight / 2 - panY) / scale
+ const view = {
+ x: cx - window.innerWidth / 2 / scale,
+ y: cy - window.innerHeight / 2 / scale,
+ w: window.innerWidth / scale,
+ h: window.innerHeight / scale,
+ }
+ let best = null
+ let bestD = Infinity
+ for (const t of tiles.values()) {
+ const r = t.rect
+ if (!overlaps(view, r)) continue
+ const px = Math.max(r.x, Math.min(cx, r.x + r.w))
+ const py = Math.max(r.y, Math.min(cy, r.y + r.h))
+ const d = Math.hypot(cx - px, cy - py)
+ if (d < bestD) { bestD = d; best = t }
}
- for (const id of tiles.keys()) if (exitsCache.has(id)) renderTileZones(id)
+ if (!best || best.info.id === cur.id) return
+ setCurrent(best.info.id, best.rect, false)
+}
+
+function maybeFollow() {
+ const now = performance.now()
+ if (now - lastFollow < 200) return
+ lastFollow = now
+ followCurrent()
}
stage.addEventListener("pointerover", (e) => {
@@ -470,6 +506,7 @@ stage.addEventListener("pointermove", (e) => {
panX = drag.px + dx
panY = drag.py + dy
applyPan()
+ maybeFollow()
}
})
@@ -487,6 +524,7 @@ stage.addEventListener("pointerup", (e) => {
drag = null
world.style.transition = ""
if (wasDrag || !tile) {
+ if (wasDrag) followCurrent()
if (!wasDrag && !tile && e.pointerType === "touch") bgTap()
return
}
@@ -528,6 +566,7 @@ stage.addEventListener("wheel", (e) => {
panX -= dx
panY -= dy
applyPan()
+ maybeFollow()
}
clearTimeout(zoomTimer)
zoomTimer = setTimeout(() => { world.style.transition = "" }, 120)
diff --git a/public/index.html b/public/index.html
index 0beff50..1a30ce1 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=21">
+ <link rel="stylesheet" href="/app.css?v=22">
</head>
<body>
<main id="stage">
@@ -16,6 +16,6 @@
<p>four paths, one per direction. walk them.</p>
<button id="start">start</button>
</div>
- <script src="/app.js?v=21"></script>
+ <script src="/app.js?v=22"></script>
</body>
</html>