From 069d3c1c81d71042706372df153254487a765dfc Mon Sep 17 00:00:00 2001
From: historia <[not public]>
Date: Mon, 29 Jun 2026 22:31:11 -0400
Subject: feat: wip web admin for mapping and crud
---
internal/admin/static/map.js | 775 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 775 insertions(+)
create mode 100644 internal/admin/static/map.js
(limited to 'internal/admin/static/map.js')
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 += '
Room not found
'; + return; + } + var r = data.room; + var file = data.file || ''; + var html = 'Room deleted
'; + 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,'"'); +} + +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(); }); }); -- cgit v1.2.3