diff options
| author | historia <[not public]> | 2026-06-29 22:31:11 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-29 22:31:11 -0400 |
| commit | 069d3c1c81d71042706372df153254487a765dfc (patch) | |
| tree | d83a4a3954d2b8304f49d83e365f4c2b075b91eb /internal/admin/static/map.js | |
| parent | 6c5271fb085b4571a10f106391974c249cd84317 (diff) | |
| download | thehouseoficarus-069d3c1c81d71042706372df153254487a765dfc.tar.gz | |
feat: wip web admin for mapping and crud
Diffstat (limited to 'internal/admin/static/map.js')
| -rw-r--r-- | internal/admin/static/map.js | 775 |
1 files changed, 775 insertions, 0 deletions
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js new file mode 100644 index 0000000..3443339 --- /dev/null +++ b/internal/admin/static/map.js @@ -0,0 +1,775 @@ +var CELL = 100; +var currentZ = 0, currentDir = '', selectedRoom = null, mapData = null; +var panX = 0, panY = 0, scale = 1; +var dragging = false, startX = 0, startY = 0, prevX = 0, prevY = 0; +var dragRoom = false; +var linkDrag = false, linkFromId = null, linkTargetId = null, linkGhostDir = null; +var delDrag = false, delFromId = null, delTargetId = null; +var roomMap = {}, occupied = {}; +var gridDirs = [ + {dx:0,dy:-1,dir:'north'},{dx:0,dy:1,dir:'south'}, + {dx:1,dy:0,dir:'east'},{dx:-1,dy:0,dir:'west'}, + {dx:1,dy:-1,dir:'northeast'},{dx:-1,dy:-1,dir:'northwest'}, + {dx:1,dy:1,dir:'southeast'},{dx:-1,dy:1,dir:'southwest'} +]; +var saveTimer = null; +var upTarget = {}, downTarget = {}; + +function changeZ(dz) { + if (dz === 0) currentZ = 0; else currentZ += dz; + loadMap(); +} + +function changeDir(dir) { + currentDir = dir; + loadMap(); +} + +function loadMap() { + $('#zLabel').textContent = 'Z=' + currentZ; + var url = '/api/map?z=' + currentZ; + if (currentDir) url += '&dir=' + encodeURIComponent(currentDir); + API.get(url).then(function(data) { + mapData = data; + if (!currentDir && data.dir) { + currentDir = data.dir; + } + updateDirSelect(); + renderMap(data); + }).catch(function(e) { + notify('Failed to load map: ' + e.message, 'error'); + }); +} + +function renderMap(data) { + var svg = $('#mapSvg'); + var container = $('#mapContainer'); + var W = container.clientWidth; + var H = container.clientHeight; + + roomMap = {}; + if (data.rooms) data.rooms.forEach(function(r) { roomMap[r.id] = r; }); + + occupied = {}; + if (data.rooms) data.rooms.forEach(function(r) { occupied[r.x+','+r.y] = r.id; }); + + upTarget = {}; downTarget = {}; + if (data.upLinks) data.upLinks.forEach(function(l) { upTarget[l.from] = l.to; }); + if (data.downLinks) data.downLinks.forEach(function(l) { downTarget[l.from] = l.to; }); + + var svgNS = 'http://www.w3.org/2000/svg'; + var g = ''; + + if (data.links) { + data.links.forEach(function(l) { + var fr = roomMap[l.from], tr = roomMap[l.to]; + if (!fr || !tr) return; + var x1 = fr.x * CELL + CELL/2, y1 = fr.y * CELL + CELL/2; + var x2 = tr.x * CELL + CELL/2, y2 = tr.y * CELL + CELL/2; + var c = fr.color ? resolveColor(fr.color) : '#666'; + c = c.replace('#',''); + g += '<line x1="'+x1+'" y1="'+y1+'" x2="'+x2+'" y2="'+y2+'" stroke="#'+c+'" stroke-width="6" opacity="0.35"'+ (l.bidirectional ? '' : ' stroke-dasharray="4,3"')+' vector-effect="non-scaling-stroke"/>'; + }); + } + + var hasUp = {}, hasDown = {}; + if (data.upLinks) data.upLinks.forEach(function(l) { hasUp[l.from] = true; }); + if (data.downLinks) data.downLinks.forEach(function(l) { hasDown[l.from] = true; }); + + var roomHTML = ''; + if (data.rooms) { + data.rooms.forEach(function(r) { + var x = r.x * CELL + 8, y = r.y * CELL + 8, w = CELL-16, h = CELL-16; + var fill = resolveColor(r.color); + var txtColor = hexLuminance(fill) > 0.35 ? '#111' : '#fff'; + var sel = selectedRoom === r.id; + roomHTML += '<rect class="rm" x="'+x+'" y="'+y+'" width="'+w+'" height="'+h+'" rx="5" fill="'+fill+'" stroke="'+(sel?'#fff':fill)+'" stroke-width="'+(sel?3:1.5)+'" data-id="'+r.id+'" data-name="'+esc(r.name)+'"/>'; + roomHTML += wrapText(r.name, x+w/2, y+h/2, w, txtColor); + + var btnY = y+h-4; + var topY = y+4; + if (hasUp[r.id] && upTarget[r.id]) { + roomHTML += '<g class="ud-hover"><rect class="nav-btn-bg" x="'+(x+3)+'" y="'+(topY+2)+'" width="14" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="nav-btn" x="'+(x+10)+'" y="'+(topY+14)+'" text-anchor="middle" fill="'+txtColor+'" data-action="goto" data-dz="1" data-target="'+upTarget[r.id]+'" title="Go up to #'+upTarget[r.id]+'">\u2191</text></g>'; + } + if (hasDown[r.id] && downTarget[r.id]) { + roomHTML += '<g class="ud-hover"><rect class="nav-btn-bg" x="'+(x+w-17)+'" y="'+(topY+2)+'" width="14" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="nav-btn" x="'+(x+w-10)+'" y="'+(topY+14)+'" text-anchor="middle" fill="'+txtColor+'" data-action="goto" data-dz="-1" data-target="'+downTarget[r.id]+'" title="Go down to #'+downTarget[r.id]+'">\u2193</text></g>'; + } + + if (sel) { + var btnX = x + 6, btnBW = 18; + roomHTML += '<g class="ud-hover"><rect class="new-btn-bg" x="'+(btnX-2)+'" y="'+(btnY-12)+'" width="'+btnBW+'" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="ud-btn" x="'+(btnX+btnBW/2-2)+'" y="'+btnY+'" text-anchor="middle" font-size="10" fill="'+txtColor+'" data-dir="up" title="Create room up">+\u2191</text></g>'; + roomHTML += '<g class="ud-hover"><rect class="new-btn-bg" x="'+(x+w-20)+'" y="'+(btnY-12)+'" width="'+btnBW+'" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="ud-btn" x="'+(x+w-11)+'" y="'+btnY+'" text-anchor="middle" font-size="10" fill="'+txtColor+'" data-dir="down" title="Create room down">+\u2193</text></g>'; + } + }); + } + + var ghostHTML = ''; + if (selectedRoom && roomMap[selectedRoom]) { + var sr = roomMap[selectedRoom]; + gridDirs.forEach(function(d) { + var nx = sr.x + d.dx, ny = sr.y + d.dy; + var key = nx+','+ny; + if (!occupied[key]) { + ghostHTML += '<rect class="ghost ghost-hover" x="'+(nx*CELL+8)+'" y="'+(ny*CELL+8)+'" width="'+(CELL-16)+'" height="'+(CELL-16)+'" rx="5" fill="rgba(100,200,255,0.12)" stroke="rgba(100,200,255,0.25)" stroke-width="1.5" stroke-dasharray="4,4" data-dir="'+d.dir+'" data-gx="'+nx+'" data-gy="'+ny+'"/>'; + ghostHTML += '<text x="'+(nx*CELL+CELL/2)+'" y="'+(ny*CELL+CELL/2+4)+'" text-anchor="middle" font-size="11" fill="rgba(255,255,255,0.35)" pointer-events="none" font-family="monospace">+</text>'; + } + }); + } + + var innerHTML = g + roomHTML + ghostHTML; + var vb = [(-panX - W/2)/scale, (-panY - H/2)/scale, W/scale, H/scale]; + var svgHTML = '<svg width="'+W+'" height="'+H+'" viewBox="'+vb.join(' ')+'" xmlns="'+svgNS+'"><g>'+innerHTML+'</g></svg>'; + svg.innerHTML = svgHTML; + + svg.addEventListener('contextmenu', function(e) { e.preventDefault(); }); + + svg.onmousedown = function(e) { + var onRoom = e.target.classList.contains('rm'); + var onGhost = e.target.classList.contains('ghost'); + var onUdBtn = e.target.classList.contains('ud-btn'); + var onNavBtn = e.target.classList.contains('nav-btn'); + + if (onNavBtn) { + e.stopPropagation(); + var dz = parseInt(e.target.getAttribute('data-dz')); + var tid = parseInt(e.target.getAttribute('data-target')); + currentZ += dz; + selectedRoom = tid; + loadMap(); + selectRoom(tid); + return; + } + + if (e.button === 2 && onRoom) { + delFromId = parseInt(e.target.getAttribute('data-id')); + delDrag = false; delTargetId = null; + dragging = true; + startX = e.clientX; startY = e.clientY; + prevX = e.clientX; prevY = e.clientY; + dragRoom = true; linkFromId = null; linkDrag = false; linkTargetId = null; linkGhostDir = null; + clearLinkHighlight(); + return; + } + + if (e.button !== 0) return; + + if (onUdBtn) { + e.stopPropagation(); + var dir = e.target.getAttribute('data-dir'); + createRoom(selectedRoom, dir); + return; + } + + dragRoom = onRoom || onGhost; + dragging = true; + startX = e.clientX; startY = e.clientY; + prevX = e.clientX; prevY = e.clientY; + linkFromId = onRoom ? parseInt(e.target.getAttribute('data-id')) : null; + linkDrag = false; + linkTargetId = null; + linkGhostDir = null; + delDrag = false; delFromId = null; delTargetId = null; + clearLinkHighlight(); + }; + + svg.onmousemove = function(e) { + if (!dragging) return; + var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4; + + if (delFromId && moved && !delDrag) { + delDrag = true; + } + if (delDrag && moved) { + updateDelDragTarget(e); + return; + } + + if (!dragRoom && !delDrag && !delFromId) { + panX += e.clientX - prevX; + panY += e.clientY - prevY; + prevX = e.clientX; prevY = e.clientY; + updateView(); + } + + if (linkFromId && moved) { + linkDrag = true; + updateLinkDragTarget(e); + } + }; + + svg.onmouseup = function(e) { + if (!dragging) return; + + var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4; + + if (delFromId && !delDrag && !moved) { + showRoomContextMenu(startX, startY, delFromId); + clearDelHighlight(); + dragging = false; dragRoom = false; delFromId = null; delTargetId = null; delDrag = false; + return; + } + + if (delDrag && delFromId && delTargetId) { + deleteLink(delFromId, delTargetId); + clearDelHighlight(); + dragging = false; dragRoom = false; delDrag = false; delFromId = null; delTargetId = null; + return; + } + + if (linkDrag && linkFromId) { + if (linkTargetId) { + createLink(linkFromId, linkTargetId); + } else if (linkGhostDir) { + createRoom(linkFromId, linkGhostDir); + } + } else if (!moved && !delDrag) { + if (e.target.classList.contains('rm')) { + selectRoom(parseInt(e.target.getAttribute('data-id'))); + } else if (e.target.classList.contains('ghost')) { + var dir = e.target.getAttribute('data-dir'); + createRoom(selectedRoom, dir); + } + } + + clearLinkHighlight(); + clearDelHighlight(); + dragging = false; dragRoom = false; + linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null; + delDrag = false; delFromId = null; delTargetId = null; + }; + + svg.onwheel = function(e) { + e.preventDefault(); + var zf = e.deltaY < 0 ? 1.1 : 0.9; + scale *= zf; + if (scale < 0.1) scale = 0.1; + if (scale > 5) scale = 5; + updateView(); + }; + + svg.querySelectorAll('rect.rm').forEach(function(r) { + r.addEventListener('mousemove', function(ev) { + var tip = $('#tooltip'); + tip.style.display = 'block'; + tip.style.left = (ev.clientX+12)+'px'; + tip.style.top = (ev.clientY+12)+'px'; + tip.textContent = '#'+r.getAttribute('data-id')+' '+r.getAttribute('data-name'); + }); + r.addEventListener('mouseleave', function() { + $('#tooltip').style.display = 'none'; + }); + }); +} + +function wrapText(name, cx, cy, maxWidth, color) { + if (!name) name = ''; + color = color || '#fff'; + var charW = 6; + var maxLen = Math.max(4, Math.floor((maxWidth - 4) / charW)); + var lines = []; + for (var i = 0; i < name.length; i += maxLen) { + lines.push(name.substring(i, i + maxLen)); + } + if (lines.length > 3) lines = lines.slice(0, 3); + var dy = -(lines.length - 1) * 6; + return lines.map(function(l, i) { + return '<text x="'+cx+'" y="'+(cy+dy+i*12)+'" text-anchor="middle" font-size="10" fill="'+color+'" pointer-events="none" font-family="monospace">'+esc(l)+'</text>'; + }).join(''); +} + +function updateView() { + var svg = document.querySelector('#mapSvg svg'); + if (!svg) return; + var W = svg.parentElement.clientWidth; + var H = svg.parentElement.clientHeight; + var vb = [(-panX - W/2)/scale, (-panY - H/2)/scale, W/scale, H/scale]; + svg.setAttribute('viewBox', vb.join(' ')); +} + +function selectRoom(id) { + selectedRoom = id; + loadMap(); + API.get('/api/rooms/' + id).then(function(data) { + renderSidePanel(data); + }).catch(function(e) { + notify('Failed to load room: '+e.message, 'error'); + }); +} + +function renderSidePanel(data) { + var panel = $('#panelContent'); + if (!data || !data.room) { + panel.innerHTML = '<p style="color:#888;text-align:center;margin-top:40px">Room not found</p>'; + return; + } + var r = data.room; + var file = data.file || ''; + var html = '<div class="form-group"><label>File</label><span style="font-size:11px;color:#888">' + esc(file) + '</span></div>'; + + html += '<div class="form-group"><label>ID</label><input id="roomID" type="number" value="' + r.id + '" onchange="autoSave()"></div>'; + html += '<div class="form-group"><label>Name</label><input id="roomName" value="' + escAttr(r.name || '') + '" oninput="autoSave()"></div>'; + html += '<div class="form-group color-field"><label>Color</label><span class="color-swatch" style="background:'+resolveColor(r.color)+'"></span><input id="roomColor" value="' + escAttr(r.color || '') + '" oninput="autoSave()"></div>'; + html += '<div class="form-group"><label>Directory</label><select id="roomDir" onchange="autoSave()"></select></div>'; + var descText = ''; + if (Array.isArray(r.description)) { + descText = r.description.map(function(d) { return d.text || ''; }).join('\n\n'); + } else if (typeof r.description === 'string') { + descText = r.description; + } + html += '<div class="form-group"><label>Description</label><textarea id="roomDesc" rows="4" oninput="autoSave()">' + esc(descText) + '</textarea></div>'; + html += '<div class="form-group"><label>Hazard</label><input id="roomHazard" value="' + escAttr(r.hazard || '') + '" oninput="autoSave()"></div>'; + html += '<div class="form-group"><label><input type="checkbox" id="roomBlockTransport" onchange="autoSave()"' + (r.block_transport ? ' checked' : '') + '> Block Transport</label></div>'; + + panel.innerHTML = html; + setTimeout(function() { + var cf = document.querySelector('#panelContent .color-field'); + if (cf) { + bindColorField(cf); + var ci = cf.querySelector('input'); + if (ci) ci.addEventListener('input', function() { autoSave(); }); + } + loadRoomDirs(function(dirs) { + var sel = $('#roomDir'); + if (!sel) return; + var current = (file || '').replace(/^data\/rooms\/?/, '').split('/')[0] || ''; + dirs.forEach(function(d) { + var opt = document.createElement('option'); + opt.value = d; + opt.textContent = d || '(root)'; + if (d === current) opt.selected = true; + sel.appendChild(opt); + }); + }); + }, 50); +} + +function autoSave() { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(function() { doSavePanel(); }, 600); +} + +function doSavePanel() { + var id = selectedRoom; + if (!id) return; + API.get('/api/rooms/' + id).then(function(data) { + var r = data.room || {}; + r.id = id; + var el = $('#roomName'); if (el) r.name = el.value; + el = $('#roomColor'); if (el) r.color = el.value; + el = $('#roomDesc'); if (el && el.value) r.description = [{text: el.value}]; + el = $('#roomHazard'); if (el) r.hazard = el.value; + el = $('#roomBlockTransport'); if (el) r.block_transport = el.checked; + + var newID = parseInt($('#roomID').value) || id; + var newDir = $('#roomDir').value; + var currentDir = (data.file || '').replace(/^data\/rooms\/?/, '').split('/')[0] || ''; + + var save = function() { + API.put('/api/rooms/' + id, r).then(function() { + updateUndoBar(); + }).catch(function(e) { notify('Save failed: '+e.message, 'error'); }); + }; + + if (newDir !== currentDir && newDir !== undefined) { + API.post('/api/rooms/move', {id: id, dir: newDir}).then(function() { + if (newID !== id) { + API.post('/api/rooms/rename', {id: id, new_id: newID}).then(function() { + selectRoom(newID); + }).catch(function(e) { notify('Rename failed: '+e.message, 'error'); save(); }); + } else { save(); } + }).catch(function(e) { notify('Move failed: '+e.message, 'error'); save(); }); + } else if (newID !== id) { + API.post('/api/rooms/rename', {id: id, new_id: newID}).then(function() { + selectRoom(newID); + }).catch(function(e) { notify('Rename failed: '+e.message, 'error'); save(); }); + } else { save(); } + }); +} + +function deleteRoom(id) { + if (!confirm('Delete room #' + id + '?')) return; + if (mapData && mapData.rooms) { + var neighbors = []; + if (mapData.links) { + mapData.links.forEach(function(l) { + if (l.from === id && l.to !== id) neighbors.push(l.to); + if (l.to === id && l.from !== id) neighbors.push(l.from); + }); + } + if (neighbors.length > 1) { + var remaining = mapData.rooms.filter(function(r) { return r.id !== id; }); + var adj = {}; + remaining.forEach(function(r) { adj[r.id] = []; }); + if (mapData.links) { + mapData.links.forEach(function(l) { + if (l.from === id || l.to === id) return; + if (adj[l.from]) adj[l.from].push(l.to); + if (adj[l.to]) adj[l.to].push(l.from); + }); + } + if (remaining.length > 0) { + var visited = {}, queue = [remaining[0].id]; + visited[remaining[0].id] = true; + while (queue.length > 0) { + var cur = queue.shift(); + (adj[cur] || []).forEach(function(n) { if (!visited[n]) { visited[n] = true; queue.push(n); } }); + } + if (Object.keys(visited).length < remaining.length) { + if (!confirm('Deleting room #'+id+' will split the map into disconnected areas. Continue?')) return; + } + } + } + } + API.del('/api/rooms/' + id).then(function() { + notify('Room #' + id + ' deleted', 'success'); + updateUndoBar(); + selectedRoom = null; + $('#panelContent').innerHTML = '<p style="color:#888;text-align:center;margin-top:40px">Room deleted</p>'; + loadMap(); + }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); }); +} + +function createRoom(fromID, dir) { + API.get('/api/next-room-id?from=' + fromID).then(function(r) { + var newID = r.id; + var name = 'Room #' + newID; + var body = { name: name, link_from: fromID, link_dir: dir }; + var srcRoom = roomMap[fromID]; + if (srcRoom && srcRoom.color) body.color = srcRoom.color; + API.post('/api/rooms', body).then(function(resp) { + var createdId = (resp && resp.room && resp.room.id) || newID; + notify('Room #' + createdId + ' created', 'success'); + updateUndoBar(); + selectRoom(createdId); + }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); }); + }); +} + +function showRoomContextMenu(x, y, id) { + var overlay = document.createElement('div'); + overlay.className = 'cm-overlay'; + overlay.onclick = function() { overlay.remove(); menu.remove(); }; + + var menu = document.createElement('div'); + menu.className = 'cm-menu'; + menu.style.left = x + 'px'; + menu.style.top = y + 'px'; + + var delBtn = document.createElement('button'); + delBtn.textContent = 'Delete Room'; + delBtn.className = 'danger'; + delBtn.onclick = function() { + overlay.remove(); menu.remove(); + deleteRoom(id); + }; + menu.appendChild(delBtn); + + var selBtn = document.createElement('button'); + selBtn.textContent = 'Room Details'; + selBtn.onclick = function() { + overlay.remove(); menu.remove(); + selectRoom(id); + }; + menu.appendChild(selBtn); + + document.body.appendChild(overlay); + document.body.appendChild(menu); +} + +function mouseToGrid(e) { + var svgEl = document.querySelector('#mapSvg svg'); + if (!svgEl) return null; + var rect = svgEl.getBoundingClientRect(); + var mx = e.clientX - rect.left; + var my = e.clientY - rect.top; + if (mx < 0 || my < 0 || mx > rect.width || my > rect.height) return null; + var vb = svgEl.getAttribute('viewBox'); + if (!vb) return null; + var parts = vb.split(' '); + var vbX = parseFloat(parts[0]), vbY = parseFloat(parts[1]); + var vbW = parseFloat(parts[2]), vbH = parseFloat(parts[3]); + var wx = vbX + mx * vbW / rect.width; + var wy = vbY + my * vbH / rect.height; + return { + x: Math.floor(wx / CELL), + y: Math.floor(wy / CELL) + }; +} + +function gridDeltaToDir(dx, dy) { + for (var i = 0; i < gridDirs.length; i++) { + if (gridDirs[i].dx === dx && gridDirs[i].dy === dy) return gridDirs[i].dir; + } + return null; +} + +function updateLinkDragTarget(e) { + clearLinkHighlight(); + linkTargetId = null; + linkGhostDir = null; + + var fromRoom = mapData.rooms ? mapData.rooms.find(function(r){return r.id === linkFromId;}) : null; + if (!fromRoom) return; + + var gp = mouseToGrid(e); + if (!gp) return; + + var neighbor = occupied[gp.x+','+gp.y]; + var gdx = gp.x - fromRoom.x; + var gdy = gp.y - fromRoom.y; + if (Math.abs(gdx) > 1 || Math.abs(gdy) > 1) return; + if (gdx === 0 && gdy === 0) return; + var dir = gridDeltaToDir(gdx, gdy); + if (!dir) return; + + if (neighbor && neighbor !== linkFromId) { + linkTargetId = neighbor; + linkGhostDir = null; + applyLinkHighlight(linkFromId, linkTargetId); + } else if (!neighbor) { + linkGhostDir = dir; + linkTargetId = null; + applyGhostLinkHighlight(fromRoom, gp.x, gp.y); + } else { + linkGhostDir = null; + } +} + +function applyGhostLinkHighlight(fromRoom, gx, gy) { + clearLinkHighlight(); + linkTargetId = null; + + var fromEl = document.querySelector('.rm[data-id="'+linkFromId+'"]'); + if (!fromEl) return; + fromEl.setAttribute('stroke', '#0ff'); + fromEl.setAttribute('stroke-width', '3'); + + var g = document.querySelector('#mapSvg svg g'); + if (!g) return; + var svgNS = 'http://www.w3.org/2000/svg'; + var line = document.createElementNS(svgNS, 'line'); + line.id = '__hl_line'; + line.setAttribute('x1', fromRoom.x * CELL + CELL/2); + line.setAttribute('y1', fromRoom.y * CELL + CELL/2); + line.setAttribute('x2', gx * CELL + CELL/2); + line.setAttribute('y2', gy * CELL + CELL/2); + line.setAttribute('stroke', '#0ff'); + line.setAttribute('stroke-width', '3'); + line.setAttribute('stroke-dasharray', '6,3'); + line.setAttribute('pointer-events', 'none'); + g.appendChild(line); +} + +function applyLinkHighlight(fromId, toId) { + clearLinkHighlight(); + + var fromEl = document.querySelector('.rm[data-id="'+fromId+'"]'); + var toEl = document.querySelector('.rm[data-id="'+toId+'"]'); + var fromRoom = roomMap[fromId]; + var toRoom = roomMap[toId]; + if (!fromEl || !toEl || !fromRoom || !toRoom) return; + + fromEl.setAttribute('stroke', '#0ff'); + fromEl.setAttribute('stroke-width', '3'); + toEl.setAttribute('stroke', '#f0f'); + toEl.setAttribute('stroke-width', '3'); + + var g = document.querySelector('#mapSvg svg g'); + if (!g) return; + var svgNS = 'http://www.w3.org/2000/svg'; + var line = document.createElementNS(svgNS, 'line'); + line.id = '__hl_line'; + line.setAttribute('x1', fromRoom.x * CELL + CELL/2); + line.setAttribute('y1', fromRoom.y * CELL + CELL/2); + line.setAttribute('x2', toRoom.x * CELL + CELL/2); + line.setAttribute('y2', toRoom.y * CELL + CELL/2); + line.setAttribute('stroke', '#f0f'); + line.setAttribute('stroke-width', '3'); + line.setAttribute('stroke-dasharray', '6,3'); + line.setAttribute('pointer-events', 'none'); + g.appendChild(line); +} + +function clearLinkHighlight() { + var line = document.getElementById('__hl_line'); + if (line) line.remove(); + + if (linkFromId) { + var el = document.querySelector('.rm[data-id="'+linkFromId+'"]'); + if (el) { + el.setAttribute('stroke', selectedRoom === linkFromId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a')); + el.setAttribute('stroke-width', selectedRoom === linkFromId ? '3' : '1.5'); + } + } + if (linkTargetId) { + var el = document.querySelector('.rm[data-id="'+linkTargetId+'"]'); + if (el) { + el.setAttribute('stroke', selectedRoom === linkTargetId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a')); + el.setAttribute('stroke-width', selectedRoom === linkTargetId ? '3' : '1.5'); + } + } +} + +function createLink(fromId, toId) { + API.post('/api/rooms/link', {from: fromId, to: toId}).then(function() { + notify('Linked rooms #'+fromId+' '+toId, 'success'); + updateUndoBar(); + loadMap(); + if (selectedRoom) { + API.get('/api/rooms/' + selectedRoom).then(function(data) { + renderSidePanel(data); + }); + } + }).catch(function(e) { + notify('Link failed: ' + e.message, 'error'); + }); +} + +function updateDelDragTarget(e) { + clearDelHighlight(); + delTargetId = null; + + var fromRoom = mapData.rooms ? mapData.rooms.find(function(r){return r.id === delFromId;}) : null; + if (!fromRoom) return; + + var gp = mouseToGrid(e); + if (!gp) return; + + var neighbor = occupied[gp.x+','+gp.y]; + if (!neighbor || neighbor === delFromId) return; + + var gdx = gp.x - fromRoom.x; + var gdy = gp.y - fromRoom.y; + if (Math.abs(gdx) > 1 || Math.abs(gdy) > 1) return; + if (gdx === 0 && gdy === 0) return; + if (!gridDeltaToDir(gdx, gdy)) return; + + delTargetId = neighbor; + applyDelHighlight(delFromId, delTargetId); +} + +function applyDelHighlight(fromId, toId) { + clearDelHighlight(); + + var fromEl = document.querySelector('.rm[data-id="'+fromId+'"]'); + var toEl = document.querySelector('.rm[data-id="'+toId+'"]'); + var fromRoom = roomMap[fromId]; + var toRoom = roomMap[toId]; + if (!fromEl || !toEl || !fromRoom || !toRoom) return; + + fromEl.setAttribute('stroke', '#f55'); + fromEl.setAttribute('stroke-width', '3'); + toEl.setAttribute('stroke', '#f55'); + toEl.setAttribute('stroke-width', '3'); + + var g = document.querySelector('#mapSvg svg g'); + if (!g) return; + var svgNS = 'http://www.w3.org/2000/svg'; + var line = document.createElementNS(svgNS, 'line'); + line.id = '__dl_line'; + line.setAttribute('x1', fromRoom.x * CELL + CELL/2); + line.setAttribute('y1', fromRoom.y * CELL + CELL/2); + line.setAttribute('x2', toRoom.x * CELL + CELL/2); + line.setAttribute('y2', toRoom.y * CELL + CELL/2); + line.setAttribute('stroke', '#f55'); + line.setAttribute('stroke-width', '3'); + line.setAttribute('stroke-dasharray', '6,3'); + line.setAttribute('pointer-events', 'none'); + g.appendChild(line); +} + +function clearDelHighlight() { + var line = document.getElementById('__dl_line'); + if (line) line.remove(); + + if (delFromId) { + var el = document.querySelector('.rm[data-id="'+delFromId+'"]'); + if (el) { + el.setAttribute('stroke', selectedRoom === delFromId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a')); + el.setAttribute('stroke-width', selectedRoom === delFromId ? '3' : '1.5'); + } + } + if (delTargetId) { + var el = document.querySelector('.rm[data-id="'+delTargetId+'"]'); + if (el) { + el.setAttribute('stroke', selectedRoom === delTargetId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a')); + el.setAttribute('stroke-width', selectedRoom === delTargetId ? '3' : '1.5'); + } + } +} + +function deleteLink(fromId, toId) { + if (mapData && mapData.links) { + var checkOrphan = function(roomId, otherId) { + var room = roomMap[roomId]; + if (!room) return false; + var otherLinks = mapData.links.filter(function(l) { + return (l.from === roomId || l.to === roomId) && !((l.from === roomId && l.to === otherId) || (l.from === otherId && l.to === roomId)); + }); + if (otherLinks.length === 0) { + if (!confirm('Removing this link will orphan room #'+roomId+' and it will disappear from the map. Continue?')) return false; + } + return true; + }; + if (!checkOrphan(toId, fromId)) return; + if (!checkOrphan(fromId, toId)) return; + } + + fetch('/api/rooms/link', { + method: 'DELETE', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({from: fromId, to: toId}), + credentials: 'same-origin' + }).then(function(r) { + if (!r.ok) throw new Error('unlink failed'); + return r.json(); + }).then(function() { + notify('Removed link #'+fromId+' '+toId, 'success'); + updateUndoBar(); + loadMap(); + if (selectedRoom) { + API.get('/api/rooms/' + selectedRoom).then(function(data) { + renderSidePanel(data); + }); + } + }).catch(function(e) { + notify('Delete link failed: ' + e.message, 'error'); + }); +} + +function esc(s) { + if (!s) return ''; + return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); +} + +function escAttr(s) { + if (!s) return ''; + return String(s).replace(/&/g,'&').replace(/"/g,'"'); +} + +window.refreshPage = function() { loadMap(); if (selectedRoom) selectRoom(selectedRoom); }; + +function loadRoomDirs(cb) { + API.get('/api/room-dirs').then(function(dirs) { + var sel = $('#dirSelect'); + if (sel && sel.options.length === 0) { + dirs.forEach(function(d) { + var opt = document.createElement('option'); + opt.value = d; + opt.textContent = d || '(root)'; + sel.appendChild(opt); + }); + } + updateDirSelect(); + if (cb) cb(dirs); + }); +} + +function updateDirSelect() { + var sel = $('#dirSelect'); + if (sel && currentDir !== undefined) { + sel.value = currentDir; + } +} + +document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function() { loadMap(); }); }); |
