From a3a42955ba57a6ae052c7d49d58623e0ed04d653 Mon Sep 17 00:00:00 2001 From: dax Date: Wed, 5 Aug 2026 09:48:04 +0100 Subject: 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 --- public/app.css | 1 - public/app.js | 235 +++++++++++++++++++++++++----------------------------- public/index.html | 4 +- 3 files changed, 110 insertions(+), 130 deletions(-) (limited to 'public') 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 @@ pathways - +
@@ -29,6 +29,6 @@
- + -- cgit v1.3.1