diff options
Diffstat (limited to 'internal/admin/static')
| -rw-r--r-- | internal/admin/static/admin.css | 4 | ||||
| -rw-r--r-- | internal/admin/static/map.js | 347 |
2 files changed, 350 insertions, 1 deletions
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index aa80488..393fafd 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -55,6 +55,10 @@ body{font-family:monospace;background:var(--bg);color:var(--text);display:flex;f .link-mode-badge.oneway{background:rgba(80,50,20,0.92);color:#fca;border:1px solid #a64} #mapSvg:focus{outline:none} #mapSvg,#mapSvg *{user-select:none;-webkit-user-select:none;-moz-user-select:none} +.selection-box{fill:rgba(100,200,255,0.1);stroke:#5cf;stroke-width:1.5;stroke-dasharray:5,5;pointer-events:none} +.selection-box.subtract{fill:rgba(255,80,80,0.1);stroke:#f55} +.rm.multisel{cursor:pointer} +.bulk-swatch{background:#888!important} .notification{position:fixed;bottom:16px;left:16px;background:var(--panel);border:1px solid var(--border);padding:10px 16px;border-radius:4px;font-size:12px;z-index:100;animation:fadeIn .2s} .notification.error{border-color:var(--danger)} .notification.success{border-color:var(--success)} diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index aeb2d57..1f67275 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -9,6 +9,8 @@ var ctrlHeld = false; var delOneWay = false; var delDrag = false, delFromId = null, delTargetId = null; var roomMap = {}, occupied = {}; +var multiSelectedIds = new Set(); +var multiDrag = false, multiSubtract = false, multiStartSVGX = 0, multiStartSVGY = 0; var dirChangedByUser = false; var currentPanelTab = 0; var gridDirs = [ @@ -33,6 +35,10 @@ function isCtrlKey(e) { } function onMapMouseMove(e) { + if (multiDrag) { + updateSelectionBox(e); + return; + } if (!dragging) return; var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4; @@ -65,6 +71,13 @@ function onMapMouseUp(e) { document.removeEventListener('mouseup', onMapMouseUp); _mapMouseCleanup = false; + if (multiDrag) { + finalizeMultiSelect(e); + multiDrag = false; multiSubtract = false; dragging = false; dragRoom = false; + if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); + return; + } + var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4; if (delFromId && !delDrag && !moved) { @@ -113,7 +126,9 @@ function cancelMapDrag() { clearLinkHighlight(); clearDelHighlight(); hideDragBadge(); + removeSelectionBox(); dragging = false; dragRoom = false; + multiDrag = false; multiSubtract = false; linkDrag = false; linkFromId = null; linkTargetId = null; linkTargetDir = null; linkGhostDir = null; linkDragOneWay = false; delDrag = false; delFromId = null; delTargetId = null; delOneWay = false; if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); @@ -153,6 +168,7 @@ function refreshDragMode(ctrlDown) { function changeZ(dz) { if (dz === 0) currentZ = 0; else currentZ += dz; panX = 0; panY = 0; scale = 1; + clearMultiSelectSilent(); loadMap(); } @@ -161,6 +177,7 @@ function changeDir(dir) { dirChangedByUser = true; selectedRoom = null; panX = 0; panY = 0; scale = 1; + clearMultiSelectSilent(); loadMap(); } @@ -279,7 +296,12 @@ function renderMap(data) { 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+'"/>'; + var multi = multiSelectedIds.has(r.id); + var stroke = '#fff', strokeW = 3; + if (sel && !multi) { stroke = '#fff'; strokeW = 3; } + else if (multi) { stroke = '#5cf'; strokeW = 3.5; } + else { stroke = fill; strokeW = 1.5; } + roomHTML += '<rect class="rm'+(multi?' multisel':'')+'" x="'+x+'" y="'+y+'" width="'+w+'" height="'+h+'" rx="5" fill="'+fill+'" stroke="'+stroke+'" stroke-width="'+strokeW+'" data-id="'+r.id+'"/>'; var dimColor = hexLuminance(fill) > 0.35 ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.4)'; roomHTML += '<text x="'+(x+w/2)+'" y="'+(y+14)+'" text-anchor="middle" font-size="8" fill="'+dimColor+'" font-family="monospace" pointer-events="none">#'+r.id+'</text>'; @@ -331,6 +353,7 @@ function renderMap(data) { svg.setAttribute('tabindex', '0'); svg.addEventListener('keydown', function(e) { if (isCtrlKey(e)) { ctrlHeld = true; if (dragging) refreshDragMode(true); } + if (e.key === 'Escape' && multiSelectedIds.size > 0) { clearMultiSelect(); e.preventDefault(); } }); svg.addEventListener('keyup', function(e) { if (isCtrlKey(e)) { ctrlHeld = false; if (dragging) refreshDragMode(false); } @@ -345,6 +368,10 @@ function renderMap(data) { var onUdBtn = e.target.classList.contains('ud-btn'); var onNavBtn = e.target.classList.contains('nav-btn'); + if (!e.shiftKey && multiSelectedIds.size > 0) { + clearMultiSelect(); + } + if (onNavBtn) { e.stopPropagation(); var dz = parseInt(e.target.getAttribute('data-dz')); @@ -354,6 +381,26 @@ function renderMap(data) { return; } + if (e.shiftKey && e.button === 2) { + e.preventDefault(); + multiDrag = true; + multiSubtract = true; + dragging = true; + if (svg.focus) svg.focus(); + startX = e.clientX; startY = e.clientY; + prevX = e.clientX; prevY = e.clientY; + var coords = screenToSVGCoords(e); + multiStartSVGX = coords.svgX; multiStartSVGY = coords.svgY; + dragRoom = true; + linkFromId = null; linkDrag = false; linkTargetId = null; linkGhostDir = null; + delDrag = false; delFromId = null; delTargetId = null; + clearLinkHighlight(); clearDelHighlight(); + document.addEventListener('mousemove', onMapMouseMove); + document.addEventListener('mouseup', onMapMouseUp); + _mapMouseCleanup = true; + return; + } + if (e.button === 2 && onRoom) { delFromId = parseInt(e.target.getAttribute('data-id')); delDrag = false; delTargetId = null; delOneWay = false; @@ -378,6 +425,24 @@ function renderMap(data) { return; } + if (e.shiftKey) { + multiDrag = true; + dragging = true; + if (svg.focus) svg.focus(); + startX = e.clientX; startY = e.clientY; + prevX = e.clientX; prevY = e.clientY; + var coords = screenToSVGCoords(e); + multiStartSVGX = coords.svgX; multiStartSVGY = coords.svgY; + dragRoom = true; + linkFromId = null; linkDrag = false; linkTargetId = null; linkGhostDir = null; + delDrag = false; delFromId = null; delTargetId = null; + clearLinkHighlight(); clearDelHighlight(); + document.addEventListener('mousemove', onMapMouseMove); + document.addEventListener('mouseup', onMapMouseUp); + _mapMouseCleanup = true; + return; + } + dragRoom = onRoom || onGhost; dragging = true; if (svg.focus) svg.focus(); @@ -450,6 +515,8 @@ function selectRoom(id) { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } var prev = selectedRoom; selectedRoom = id; + multiSelectedIds.clear(); + removeSelectionBox(); if (mapData && prev !== id) { renderMap(mapData); @@ -2885,6 +2952,284 @@ function reloadRoomDirs(cb) { }); } +function screenToSVGCoords(e) { + var svgEl = $('#mapSvg'); + if (!svgEl) return null; + var rect = svgEl.getBoundingClientRect(); + var mx = e.clientX - rect.left; + var my = e.clientY - rect.top; + 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 svgX = vbX + mx * vbW / rect.width; + var svgY = vbY + my * vbH / rect.height; + return { + svgX: svgX, svgY: svgY, + gridX: Math.floor(svgX / CELL), + gridY: Math.floor(svgY / CELL) + }; +} + +function updateSelectionBox(e) { + var coords = screenToSVGCoords(e); + if (!coords) return; + + var g = document.querySelector('#mapSvg g'); + if (!g) return; + + var x1 = Math.min(multiStartSVGX, coords.svgX); + var y1 = Math.min(multiStartSVGY, coords.svgY); + var x2 = Math.max(multiStartSVGX, coords.svgX); + var y2 = Math.max(multiStartSVGY, coords.svgY); + + var box = document.getElementById('__multi_box'); + if (!box) { + var ns = 'http://www.w3.org/2000/svg'; + box = document.createElementNS(ns, 'rect'); + box.id = '__multi_box'; + box.classList.add('selection-box'); + g.appendChild(box); + } + box.setAttribute('x', x1); + box.setAttribute('y', y1); + box.setAttribute('width', x2 - x1); + box.setAttribute('height', y2 - y1); + if (multiSubtract) { + box.classList.add('subtract'); + } else { + box.classList.remove('subtract'); + } + + var startCoords = {gridX: Math.floor(multiStartSVGX/CELL), gridY: Math.floor(multiStartSVGY/CELL)}; + var gx1 = Math.min(startCoords.gridX, coords.gridX); + var gy1 = Math.min(startCoords.gridY, coords.gridY); + var gx2 = Math.max(startCoords.gridX, coords.gridX); + var gy2 = Math.max(startCoords.gridY, coords.gridY); + + var allRms = document.querySelectorAll('.rm'); + for (var r = 0; r < allRms.length; r++) { + var rm = allRms[r]; + var rid = parseInt(rm.getAttribute('data-id')); + if (multiSelectedIds.has(rid)) { + rm.setAttribute('stroke', '#5cf'); + rm.setAttribute('stroke-width', '3.5'); + } else { + var fill = rm.getAttribute('fill'); + rm.setAttribute('stroke', fill); + rm.setAttribute('stroke-width', '1.5'); + } + } + + for (var gx = gx1; gx <= gx2; gx++) { + for (var gy = gy1; gy <= gy2; gy++) { + var rId = occupied[gx+','+gy]; + if (rId === undefined) continue; + var rm = document.querySelector('.rm[data-id="'+rId+'"]'); + if (!rm) continue; + if (multiSubtract) { + if (multiSelectedIds.has(rId)) { + rm.setAttribute('stroke', '#f55'); + rm.setAttribute('stroke-width', '3.5'); + } + } else { + if (!multiSelectedIds.has(rId)) { + rm.setAttribute('stroke', '#5cf'); + rm.setAttribute('stroke-width', '3.5'); + } + } + } + } +} + +function removeSelectionBox() { + var box = document.getElementById('__multi_box'); + if (box) box.remove(); +} + +function finalizeMultiSelect(e) { + var coords = screenToSVGCoords(e); + removeSelectionBox(); + + if (!coords) return; + + var startCoords = {gridX: Math.floor(multiStartSVGX/CELL), gridY: Math.floor(multiStartSVGY/CELL)}; + var gx1 = Math.min(startCoords.gridX, coords.gridX); + var gy1 = Math.min(startCoords.gridY, coords.gridY); + var gx2 = Math.max(startCoords.gridX, coords.gridX); + var gy2 = Math.max(startCoords.gridY, coords.gridY); + + var changed = false; + for (var gx = gx1; gx <= gx2; gx++) { + for (var gy = gy1; gy <= gy2; gy++) { + var key = gx+','+gy; + if (occupied[key] !== undefined) { + if (multiSubtract) { + if (multiSelectedIds.delete(occupied[key])) changed = true; + } else { + if (!multiSelectedIds.has(occupied[key])) { + multiSelectedIds.add(occupied[key]); + changed = true; + } + } + } + } + } + + if (multiSelectedIds.size === 0) { + clearMultiSelect(); + return; + } + + selectedRoom = null; + if (mapData) renderMap(mapData); + renderBulkPanel(); +} + +function clearMultiSelect() { + multiSelectedIds.clear(); + multiSubtract = false; + removeSelectionBox(); + if (mapData) renderMap(mapData); + var panel = $('#panelContent'); + if (panel) panel.innerHTML = '<p style="color:#888;text-align:center;margin-top:40px">Click a room to view details</p>'; +} + +function clearMultiSelectSilent() { + multiSelectedIds.clear(); + multiSubtract = false; + removeSelectionBox(); + var panel = $('#panelContent'); + if (panel) panel.innerHTML = '<p style="color:#888;text-align:center;margin-top:40px">Click a room to view details</p>'; +} + +function renderBulkPanel() { + if (multiSelectedIds.size === 0) return; + var panel = $('#panelContent'); + if (!panel) return; + + var ids = Array.from(multiSelectedIds); + var rooms = ids.map(function(id) { return roomMap[id]; }).filter(Boolean); + + var colorMixed = false, firstColor = rooms.length > 0 ? (rooms[0].color || '') : ''; + var blockNone = true, blockAll = true; + var dirMixedSet = new Set(); + var hazardsByRoom = {}; + + rooms.forEach(function(r) { + if ((r.color || '') !== firstColor) colorMixed = true; + if (r.block_transport) blockNone = false; else blockAll = false; + if (r.dir) dirMixedSet.add(r.dir); + var h = r.hazard || ''; + if (h) { + if (!hazardsByRoom[h]) hazardsByRoom[h] = []; + hazardsByRoom[h].push(r.id); + } + }); + + var hazardNames = Object.keys(hazardsByRoom); + + var html = '<h2 style="font-size:15px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid var(--border)">' + ids.length + ' rooms selected</h2>'; + html += '<div class="panel-tab-content">'; + + var colorVal = colorMixed ? '' : firstColor; + var swatchBg = colorVal ? resolveColor(colorVal) : '#888'; + html += '<div class="form-group color-field"><label>Color</label><span class="color-swatch" style="background:' + swatchBg + '"></span><input id="bulkRoomColor" value="' + escAttr(colorVal) + '" oninput="bulkColorChanged()" placeholder="' + (colorMixed ? 'Mixed' : 'Color') + '"></div>'; + + html += '<div class="form-group"><label><input type="checkbox" id="bulkBlockTransport" onchange="bulkBlockChanged()"' + (blockAll ? ' checked' : '') + '> Block Transport</label></div>'; + + html += '<div class="form-group"><label>Directory</label><select id="bulkRoomDir" onchange="doBulkSave({dir: this.value})"><option value="">Mixed\u2026</option></select></div>'; + + html += '<div class="section-search">'; + html += '<input type="text" class="search-input" placeholder="Search hazards..." oninput="searchBulkHazards(this)">'; + html += '<div class="search-results" style="display:none"></div>'; + html += '</div>'; + + if (hazardNames.length > 0) { + hazardNames.forEach(function(h) { + html += '<div class="entry-row"><div class="entry-fields"><span class="entry-id">' + esc(h) + '</span></div><button class="btn btn-sm btn-danger" onclick="doBulkSave({hazard: \'\'}, ' + JSON.stringify(hazardsByRoom[h]) + ')">X</button></div>'; + }); + } + + html += '</div>'; + panel.innerHTML = html; + + var preloadDirs = dirMixedSet.size === 1 ? dirMixedSet.values().next().value : null; + + setTimeout(function() { + var cf = document.querySelector('#panelContent .color-field'); + if (cf) { + bindColorField(cf); + var ci = cf.querySelector('input'); + if (ci) ci.addEventListener('blur', function() { bulkColorChanged(); }); + } + + var blockCB = document.getElementById('bulkBlockTransport'); + if (blockCB) { + if (blockNone) { blockCB.checked = false; blockCB.indeterminate = false; } + else if (blockAll) { blockCB.checked = true; blockCB.indeterminate = false; } + else { blockCB.indeterminate = true; } + } + + loadRoomDirs(function(dirs) { + var sel = $('#bulkRoomDir'); + if (!sel) return; + sel.innerHTML = ''; + var mixedOpt = document.createElement('option'); + mixedOpt.value = ''; + mixedOpt.textContent = 'Mixed\u2026'; + if (!preloadDirs) mixedOpt.selected = true; + sel.appendChild(mixedOpt); + dirs.forEach(function(d) { + var opt = document.createElement('option'); + opt.value = d; + opt.textContent = d || '(root)'; + if (preloadDirs && d === preloadDirs) opt.selected = true; + sel.appendChild(opt); + }); + }); + }, 50); +} + +function bulkColorChanged() { + if (window._colorPickerActive) return; + var el = $('#bulkRoomColor'); + if (!el) return; + var v = el.value; + if (v.length === 2 || v.length === 0) { + doBulkSave({color: v}); + } +} + +function bulkBlockChanged() { + var el = document.getElementById('bulkBlockTransport'); + if (!el) return; + doBulkSave({block_transport: el.checked}); +} + +function searchBulkHazards(input) { + searchAndShow(input, 'hazards', function(r) { doBulkSave({hazard: r.id}); }); +} + +function doBulkSave(patch, customIds) { + var ids = customIds || Array.from(multiSelectedIds); + if (ids.length === 0) return; + + if (patch.hasOwnProperty('hazard') && patch.hazard === null) delete patch.hazard; + if (patch.hasOwnProperty('dir') && (patch.dir === '' || patch.dir === null)) delete patch.dir; + if (!patch.hasOwnProperty('hazard') && !patch.hasOwnProperty('block_transport') && !patch.hasOwnProperty('color') && !patch.hasOwnProperty('dir')) return; + + API.post('/api/rooms/bulk-edit', {patch: patch, ids: ids}).then(function(res) { + if (res.error) { notify(res.error, 'error'); return; } + notify('Updated ' + res.count + ' rooms', 'success'); + updateUndoBar(); + loadMap().then(function() { renderBulkPanel(); }); + }).catch(function(e) { + notify('Bulk update failed: ' + extractError(e), 'error'); + }); +} + document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function() { loadMap(); }); initPanelResize(); window.addEventListener('keydown', function(e) { if (isCtrlKey(e)) { ctrlHeld = true; if (dragging) refreshDragMode(true); } }); window.addEventListener('keyup', function(e) { if (isCtrlKey(e)) { ctrlHeld = false; if (dragging) refreshDragMode(false); } }); |
