aboutsummaryrefslogtreecommitdiff
path: root/public
diff options
context:
space:
mode:
authordax <me@dax.ist>2026-08-05 09:48:04 +0100
committerdax <me@dax.ist>2026-08-05 09:48:04 +0100
commita3a42955ba57a6ae052c7d49d58623e0ed04d653 (patch)
tree1fbe0960ae0e829c43c624cbf13b9f294236503f /public
parente0663c2f0681662fb599bbf0ac0d45ab8385aa79 (diff)
prospective ghost only over empty slots: show single dimmed preview at the hovered slot off any placed tile, tap ghost or arrows to place; remove all buttons/dropzones on current tile
Diffstat (limited to 'public')
-rw-r--r--public/app.css1
-rw-r--r--public/app.js235
-rw-r--r--public/index.html4
3 files changed, 110 insertions, 130 deletions
diff --git a/public/app.css b/public/app.css
index 1e51b9b..acfa24a 100644
--- a/public/app.css
+++ b/public/app.css
@@ -58,7 +58,6 @@ html, body {
transition: opacity .15s;
}
.preview.hot { opacity: .85; }
-.preview.hidden { visibility: hidden; }
.preview img {
width: 100%;
height: 100%;
diff --git a/public/app.js b/public/app.js
index 4a55cb9..ed1c6d6 100644
--- a/public/app.js
+++ b/public/app.js
@@ -34,7 +34,7 @@ let panY = 0
let scale = 1
let userZoomed = false
let drag = null
-let previewsShown = false
+let hoveredSlot = null
const TOUCH = typeof window.matchMedia === "function" && window.matchMedia("(hover: none)").matches
@@ -198,6 +198,7 @@ function setCurrent(id, rect, recenter = true) {
if (recenter) {
if (!userZoomed) scale = worldScale()
center()
+ hoveredSlot = null
}
getExits(id)
if (prevId && prevId !== id) renderNav(prevId)
@@ -238,7 +239,10 @@ async function getExits(id) {
const exits = data.exits || []
exitsCache.set(id, exits)
renderNav(id)
- renderTileZones(id)
+ if (!drag) {
+ const s = findSlotAt(lastCursor.wx, lastCursor.wy)
+ if (s) { hoveredSlot = s; refreshPreviews() }
+ }
return exits
} catch {
return null
@@ -324,7 +328,6 @@ function adjacentTile(id, dir) {
}
function traverseFrom(id, dir) {
- previewsShown = false
followEnabled = false
const back = adjacentTile(id, dir)
if (back) {
@@ -337,125 +340,93 @@ function traverseFrom(id, dir) {
setCurrent(chosen.id, chosen.rect)
}
-const zones = new Map()
-function clearTileZones(id) {
- const entry = zones.get(id)
- if (entry) {
- for (const z of entry.els) world.removeChild(z)
- if (entry.preview) world.removeChild(entry.preview)
- zones.delete(id)
- }
-}
-
-function clearAllZones() {
- for (const id of [...zones.keys()]) clearTileZones(id)
-}
+const slotUI = { preview: null, previewImg: null, dropzone: null, key: null, loadTimer: 0 }
+const lastCursor = { wx: 0, wy: 0 }
-function resetPreview() {
- if (!cur) return
- const entry = zones.get(cur.id)
- if (entry && entry.reset) entry.reset()
+function hideSlot() {
+ if (slotUI.preview && slotUI.preview.parentNode) world.removeChild(slotUI.preview)
+ if (slotUI.dropzone && slotUI.dropzone.parentNode) world.removeChild(slotUI.dropzone)
+ slotUI.key = null
}
-function renderTileZones(id) {
- if (!cur || id !== cur.id) return
- if (!tiles.has(id) || !exitsCache.has(id)) return
- const cx = (window.innerWidth / 2 - panX) / scale
- const cy = (window.innerHeight / 2 - panY) / scale
- const dirs = []
- for (const dir of ["N", "E", "S", "W"]) {
- if (adjacentTile(id, dir)) continue
- const p = choosePlacement(id, dir)
- if (!p) continue
- const d = Math.hypot(p.rect.x + p.rect.w / 2 - cx, p.rect.y + p.rect.h / 2 - cy)
- dirs.push({ dir, p, d })
+function showSlot(slot) {
+ if (!tiles.has(slot.id) || !exitsCache.has(slot.id) || !choosePlacement(slot.id, slot.dir)) {
+ hideSlot()
+ return
+ }
+ const p = choosePlacement(slot.id, slot.dir)
+ const key = slot.id + ":" + slot.dir
+ if (slotUI.key === key) return
+ hideSlot()
+ slotUI.key = key
+ if (!slotUI.preview) {
+ slotUI.preview = document.createElement("div")
+ slotUI.preview.className = "preview"
+ slotUI.previewImg = document.createElement("img")
+ slotUI.previewImg.alt = ""
+ slotUI.preview.appendChild(slotUI.previewImg)
+ const spinner = document.createElement("div")
+ spinner.className = "spinner"
+ slotUI.preview.appendChild(spinner)
+ slotUI.dropzone = document.createElement("div")
+ slotUI.dropzone.className = "dropzone"
+ slotUI.dropzone.addEventListener("pointerdown", (e) => e.stopPropagation())
}
- const key = dirs.map((x) => x.dir + x.p.info.id + "@" + x.p.rect.x + "," + x.p.rect.y).join("|")
- let best = null
- for (const x of dirs) if (!best || x.d < best.d) best = x
- const existing = zones.get(id)
- if (existing && existing.key === key) return
- clearTileZones(id)
- if (!best) return
- const els = []
- const preview = document.createElement("div")
- preview.className = "preview hidden"
- const previewImg = document.createElement("img")
- previewImg.alt = ""
- preview.appendChild(previewImg)
- const spinner = document.createElement("div")
- spinner.className = "spinner"
- preview.appendChild(spinner)
- let hotDir = null
- let loadTimer = 0
const doneLoading = () => {
- clearTimeout(loadTimer)
- preview.classList.remove("loading", "slow")
+ clearTimeout(slotUI.loadTimer)
+ slotUI.preview.classList.remove("loading", "slow")
}
- previewImg.onload = doneLoading
- previewImg.onerror = doneLoading
- const showAt = (x) => {
- previewsShown = true
- preview.classList.remove("hidden")
- preview.style.left = x.p.rect.x + "px"
- preview.style.top = x.p.rect.y + "px"
- preview.style.width = x.p.rect.w + "px"
- preview.style.height = x.p.rect.h + "px"
- const next = new URL(x.p.info.thumb, location.href).href
- if (previewImg.src === next && previewImg.complete) {
- doneLoading()
- return
- }
- clearTimeout(loadTimer)
- preview.classList.add("loading")
- preview.classList.remove("slow")
- previewImg.src = next
- loadTimer = setTimeout(() => {
- if (preview.classList.contains("loading")) preview.classList.add("slow")
+ slotUI.previewImg.onload = doneLoading
+ slotUI.previewImg.onerror = doneLoading
+ slotUI.preview.style.left = p.rect.x + "px"
+ slotUI.preview.style.top = p.rect.y + "px"
+ slotUI.preview.style.width = p.rect.w + "px"
+ slotUI.preview.style.height = p.rect.h + "px"
+ slotUI.dropzone.style.left = p.rect.x + "px"
+ slotUI.dropzone.style.top = p.rect.y + "px"
+ slotUI.dropzone.style.width = p.rect.w + "px"
+ slotUI.dropzone.style.height = p.rect.h + "px"
+ slotUI.dropzone.onclick = () => traverseFrom(slot.id, slot.dir)
+ const next = new URL(p.info.thumb, location.href).href
+ if (slotUI.previewImg.src === next && slotUI.previewImg.complete) {
+ doneLoading()
+ } else {
+ clearTimeout(slotUI.loadTimer)
+ slotUI.preview.classList.add("loading")
+ slotUI.preview.classList.remove("slow")
+ slotUI.previewImg.src = next
+ slotUI.loadTimer = setTimeout(() => {
+ if (slotUI.preview.classList.contains("loading")) slotUI.preview.classList.add("slow")
}, 1000)
}
- const reset = () => {
- hotDir = null
- preview.classList.remove("hot")
- if (best) showAt(best)
- }
- for (const x of dirs) {
- const z = document.createElement("div")
- z.className = "dropzone"
- z.dataset.dir = x.dir
- z.style.left = x.p.rect.x + "px"
- z.style.top = x.p.rect.y + "px"
- z.style.width = x.p.rect.w + "px"
- z.style.height = x.p.rect.h + "px"
- z.addEventListener("pointerdown", (e) => e.stopPropagation())
- z.addEventListener("click", () => traverseFrom(id, x.dir))
- if (!TOUCH) {
- z.addEventListener("pointermove", () => {
- if (hotDir !== x.dir) {
- hotDir = x.dir
- showAt(x)
- preview.classList.add("hot")
- }
- })
- z.addEventListener("pointerleave", () => {
- if (hotDir === x.dir) {
- hotDir = null
- preview.classList.remove("hot")
- }
- })
- }
- world.appendChild(z)
- els.push(z)
- }
- world.appendChild(preview)
- if (previewsShown && best) showAt(best)
- zones.set(id, { els, key, preview, reset })
+ world.appendChild(slotUI.dropzone)
+ world.appendChild(slotUI.preview)
}
function refreshPreviews() {
- if (!cur) { clearAllZones(); return }
- for (const [id, zs] of zones) if (id !== cur.id) clearTileZones(id)
- renderTileZones(cur.id)
+ if (hoveredSlot) showSlot(hoveredSlot)
+ else hideSlot()
+}
+
+function findSlotAt(wx, wy) {
+ let best = null
+ let bestD = Infinity
+ for (const t of tiles.values()) {
+ if (!exitsCache.has(t.info.id)) continue
+ const r = t.rect
+ const m = Math.max(r.w, r.h) * 2 + GAP
+ if (wx < r.x - m || wx > r.x + r.w + m || wy < r.y - m || wy > r.y + r.h + m) continue
+ for (const dir of ["N", "E", "S", "W"]) {
+ if (adjacentTile(t.info.id, dir)) continue
+ const p = choosePlacement(t.info.id, dir)
+ if (!p) continue
+ const q = p.rect
+ if (wx < q.x || wx > q.x + q.w || wy < q.y || wy > q.y + q.h) continue
+ const d = Math.hypot(wx - (q.x + q.w / 2), wy - (q.y + q.h / 2))
+ if (d < bestD) { bestD = d; best = { id: t.info.id, dir, rect: q } }
+ }
+ }
+ return best
}
let lastFollow = 0
@@ -506,7 +477,8 @@ stage.addEventListener("pointerdown", (e) => {
if (e.target.closest("#import-state")) return
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
try { stage.setPointerCapture(e.pointerId) } catch { /* ignore */ }
- resetPreview()
+ hoveredSlot = null
+ hideSlot()
world.style.transition = "none"
if (pointers.size === 2) {
followEnabled = true
@@ -520,10 +492,8 @@ stage.addEventListener("pointerdown", (e) => {
})
stage.addEventListener("pointermove", (e) => {
- if (!previewsShown) {
- const entry = zones.get(cur && cur.id)
- if (entry && entry.reset) entry.reset()
- }
+ lastCursor.wx = (e.clientX - panX) / scale
+ lastCursor.wy = (e.clientY - panY) / scale
if (pointers.has(e.pointerId)) {
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (pointers.size === 2 && pinchDist > 0) {
@@ -536,15 +506,24 @@ stage.addEventListener("pointermove", (e) => {
return
}
}
- 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()
- maybeFollow()
+ if (drag) {
+ 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()
+ maybeFollow()
+ }
+ return
+ }
+ const slot = findSlotAt(lastCursor.wx, lastCursor.wy)
+ const key = slot ? slot.id + ":" + slot.dir : null
+ const curKey = hoveredSlot && hoveredSlot.id + ":" + hoveredSlot.dir
+ if (key !== curKey) {
+ hoveredSlot = slot
+ refreshPreviews()
}
})
@@ -569,7 +548,6 @@ stage.addEventListener("pointerup", (e) => {
const id = Number(tile.dataset.id)
followEnabled = false
if (cur && cur.id === id) return
- previewsShown = false
setCurrent(id, tiles.get(id).rect)
})
@@ -601,10 +579,13 @@ stage.addEventListener("wheel", (e) => {
else if (e.deltaMode === 2) { dx *= window.innerWidth; dy *= window.innerHeight }
world.style.transition = "none"
if (e.ctrlKey) {
+ hoveredSlot = null
+ hideSlot()
const rect = stage.getBoundingClientRect()
zoomAt(e.clientX - rect.left, e.clientY - rect.top, Math.exp(-dy * 0.01))
} else {
- resetPreview()
+ hoveredSlot = null
+ hideSlot()
panX -= dx
panY -= dy
applyPan()
diff --git a/public/index.html b/public/index.html
index 9475e4e..5746273 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=56">
+ <link rel="stylesheet" href="/app.css?v=58">
</head>
<body>
<main id="stage">
@@ -29,6 +29,6 @@
<span id="progress-text"></span>
</div>
<div id="toast" role="status" aria-live="polite"></div>
- <script src="/app.js?v=56"></script>
+ <script src="/app.js?v=58"></script>
</body>
</html>