import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
const COLORS = ['#c0392b', '#8e44ad', '#16a085', '#7f8c8d', '#d35400', '#2c3e50', '#27ae60', '#b9770e']
const clamp = (v, a, b) => Math.min(b, Math.max(a, v))
// Map with pan (drag the background), zoom (wheel / pinch) and draggable tokens. Coordinates are image pixels.
// Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise.
export function MapView({ map, src, tokens, onMove, selected, onSelect, grid: showGrid, children }) {
const box = useRef(null)
const [view, setView] = useState({ tx: 0, ty: 0, s: 1 })
const [drag, setDrag] = useState(null)
const viewRef = useRef(view)
viewRef.current = view
const pointers = useRef(new Map())
const fitted = useRef('')
const fit = useCallback(() => {
const r = box.current?.getBoundingClientRect()
if (!r || !r.width || !map) return
const s = Math.min(r.width / map.w, r.height / map.h) * 0.96
setView({ s, tx: (r.width - map.w * s) / 2, ty: (r.height - map.h * s) / 2 })
}, [map?.id, map?.w, map?.h]) // eslint-disable-line
useEffect(() => { // fit once per map, as soon as the box has a size
if (!map || fitted.current === map.id) return
const t = setInterval(() => { if (box.current?.getBoundingClientRect().width) { fit(); fitted.current = map.id; clearInterval(t) } }, 50)
return () => clearInterval(t)
}, [map?.id, fit]) // eslint-disable-line
const zoomAt = (mx, my, f) => setView((v) => {
const s = clamp(v.s * f, 0.05, 8)
return { s, tx: mx - (mx - v.tx) * (s / v.s), ty: my - (my - v.ty) * (s / v.s) }
})
useEffect(() => {
const el = box.current
if (!el) return
const wheel = (e) => {
e.preventDefault()
const r = el.getBoundingClientRect()
zoomAt(e.clientX - r.left, e.clientY - r.top, Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015)))
}
el.addEventListener('wheel', wheel, { passive: false })
return () => el.removeEventListener('wheel', wheel)
}, [])
const down = (e) => {
if (e.target.closest('.maptoken, .mapui')) return
box.current.setPointerCapture(e.pointerId)
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (pointers.current.size === 1) onSelect?.(null)
}
const move = (e) => {
const p = pointers.current.get(e.pointerId)
if (!p) return
const r = box.current.getBoundingClientRect()
if (pointers.current.size === 2) { // pinch
const [a, b] = [...pointers.current.values()]
const before = Math.hypot(a.x - b.x, a.y - b.y)
const o = pointers.current.get(e.pointerId)
const now = { ...o, x: e.clientX, y: e.clientY }
pointers.current.set(e.pointerId, now)
const [c, d] = [...pointers.current.values()]
const after = Math.hypot(c.x - d.x, c.y - d.y)
if (before > 0) zoomAt((c.x + d.x) / 2 - r.left, (c.y + d.y) / 2 - r.top, after / before)
return
}
const dx = e.clientX - p.x, dy = e.clientY - p.y
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
setView((v) => ({ ...v, tx: v.tx + dx, ty: v.ty + dy }))
}
const up = (e) => pointers.current.delete(e.pointerId)
const toWorld = (ev) => {
const r = box.current.getBoundingClientRect(), v = viewRef.current
return { x: (ev.clientX - r.left - v.tx) / v.s, y: (ev.clientY - r.top - v.ty) / v.s,
inside: ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom }
}
const startToken = (e, t) => {
e.stopPropagation()
if (!t.can_move) { onSelect?.(t.id); return }
e.preventDefault()
const from = { x: e.clientX, y: e.clientY }
let moved = false, last = 0
const clampPos = (p) => ({ x: clamp(p.x, 0, map.w), y: clamp(p.y, 0, map.h) })
const mv = (ev) => {
if (!moved && Math.hypot(ev.clientX - from.x, ev.clientY - from.y) < 4) return
moved = true
const p = toWorld(ev)
setDrag({ id: t.id, ...clampPos(p), out: !p.inside })
if (t.x != null && p.inside && Date.now() - last > 120) { last = Date.now(); const c = clampPos(p); onMove(t.id, c.x, c.y) }
}
const end = (ev) => {
window.removeEventListener('pointermove', mv); window.removeEventListener('pointerup', end); window.removeEventListener('pointercancel', end)
if (!moved) { setDrag(null); onSelect?.(t.id); return }
const p = toWorld(ev)
if (!p.inside) { setDrag(null); return }
const c = clampPos(p)
Promise.resolve(onMove(t.id, c.x, c.y)).finally(() => setDrag(null)) // keep the local position until the server has it
}
window.addEventListener('pointermove', mv); window.addEventListener('pointerup', end); window.addEventListener('pointercancel', end)
}
const unplaced = tokens.filter((t) => t.x == null && t.can_move)
const d = (t) => t.size * map.grid
return (
{showGrid &&
}
{tokens.map((t) => {
const dr = drag?.id === t.id ? drag : null
const x = dr ? dr.x : t.x, y = dr ? dr.y : t.y
if (x == null || (dr && dr.out)) return null
return (
startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '')}>
{(t.name || '?').trim().slice(0, 2)}
{t.name}
)
})}
{unplaced.length > 0 && (
Drag onto the map:
{unplaced.map((t) => (
startToken(e, t)} title="Drag onto the map">{t.name}
))}
)}
{ const r = box.current.getBoundingClientRect(); zoomAt(r.width / 2, r.height / 2, 1.3) }} aria-label="Zoom in">+
{ const r = box.current.getBoundingClientRect(); zoomAt(r.width / 2, r.height / 2, 1 / 1.3) }} aria-label="Zoom out">-
⤢
{children}
)
}
// Name, size, colour and visibility of one token (used in the live table and in the map editor)
export function TokenForm({ t, onChange, onDelete, onDuplicate }) {
return (
onChange({ name: e.target.value })} />
Size onChange({ size: Number(e.target.value) || 1 })} /> sq.
{COLORS.map((c) => onChange({ color: c })} />)}
onChange({ hidden: e.target.checked })} /> Hidden from players
{onDuplicate && Duplicate }
Remove
)
}
const uid = () => Math.random().toString(36).slice(2, 12)
// Full-screen editor for a map in the DM's library: set the grid, place enemies and NPCs in advance.
export function MapEditor({ id, onClose }) {
const [m, setM] = useState(null)
const [tokens, setTokens] = useState([])
const [name, setName] = useState('')
const [grid, setGrid] = useState(40)
const [showGrid, setShowGrid] = useState(true)
const [sel, setSel] = useState(null)
const [err, setErr] = useState('')
const [dirty, setDirty] = useState(false)
useEffect(() => {
fetch(`/api/maps/${id}`).then((r) => r.json()).then((d) => { setM(d); setTokens(d.tokens.map((t) => ({ ...t, kind: 'npc', can_move: true }))); setName(d.name); setGrid(d.grid) })
}, [id])
if (!m) return null
const cur = tokens.find((t) => t.id === sel)
const patch = (tid, p) => { setTokens((ts) => ts.map((t) => (t.id === tid ? { ...t, ...p } : t))); setDirty(true) }
const add = () => {
const n = tokens.length + 1
const t = { id: uid(), kind: 'npc', can_move: true, name: `Enemy ${n}`, x: m.w / 2 + (n % 5) * m.grid, y: m.h / 2 + (n % 3) * m.grid, size: 1, color: COLORS[0], hidden: false }
setTokens([...tokens, t]); setSel(t.id); setDirty(true)
}
const save = async () => {
setErr('')
const r = await fetch(`/api/maps/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, grid: Number(grid), tokens: tokens.map(({ id: i, name: n, x, y, size, color, hidden }) => ({ id: i, name: n, x, y, size, color, hidden })) }) })
if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Could not save'); return false }
setDirty(false); return true
}
const close = async () => { if (!dirty || window.confirm('Close without saving your changes?')) onClose() }
const gridNum = Number(grid) >= 4 ? Number(grid) : m.grid
return createPortal(
{ patch(tid, { x, y }); return Promise.resolve() }} />
, document.body)
}
// DM: the map library (in the "Lobby actions" tab)
export function MapLibrary({ activeId, lobbyId, act }) {
const [list, setList] = useState([])
const [err, setErr] = useState('')
const [editing, setEditing] = useState(null)
const [busy, setBusy] = useState(false)
const load = useCallback(() => fetch('/api/maps').then((r) => (r.ok ? r.json() : [])).then(setList).catch(() => {}), [])
useEffect(() => { load() }, [load, editing])
const upload = async (e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
setErr(''); setBusy(true)
const r = await fetch(`/api/maps?name=${encodeURIComponent(f.name.replace(/\.[^.]+$/, ''))}`, { method: 'POST', headers: { 'Content-Type': f.type || 'application/octet-stream' }, body: f })
setBusy(false)
if (!r.ok) { setErr((await r.json().catch(() => ({}))).detail || 'Upload failed'); return }
load()
}
return (
Maps
{busy ? 'Uploading…' : '+ Upload map (PNG, JPEG, WebP)'}
{err &&
{err}
}
{list.map((m) => (
{m.name} {m.w}×{m.h}, {m.tokens} token{m.tokens === 1 ? '' : 's'}
{m.id === activeId
? act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: null })}>Clear table
: (!activeId || window.confirm('Replace the current map? Tokens on it are reset to the prepared ones.')) && act(`/api/lobbies/${lobbyId}/map`, 'PUT', { map_id: m.id })}>Show on table }
setEditing(m.id)}>Edit
window.confirm(`Delete the map "${m.name}"?`) && fetch(`/api/maps/${m.id}`, { method: 'DELETE' }).then(load)}>Delete
))}
{!list.length && No maps yet. Upload an image, then prepare enemies in the editor. }
{editing &&
setEditing(null)} />}
)
}