diff --git a/NEXT.md b/NEXT.md index a6a985e..f36171d 100644 --- a/NEXT.md +++ b/NEXT.md @@ -1,10 +1,10 @@ # NEXT_STEPS ## Aktive Aufgaben (priorisiert) -- [ ] [#20] //TODO Gesamt-Topologie-Wand im dashboard ist schwarze -- [ ] [#19] //TODO gerät nicht löschbar wegen ports, ports sind aber nicht löschbar -- [ ] [#18] //TODO wandbuchsen direkt beim erstellen schon an patchpanel bindfen -- [ ] [#17] //TODO infrastruktur karten zoombar, um objekte besser positionieren zu können, steps soll aber immernoch 1 bleiben +- [x] [#20] Gesamt-Topologie-Wand im dashboard ist schwarze +- [x] [#19] gerät nicht löschbar wegen ports, ports sind aber nicht löschbar +- [x] [#18] wandbuchsen direkt beim erstellen schon an patchpanel bindfen +- [x] [#17] infrastruktur karten zoombar, um objekte besser positionieren zu können, steps soll aber immernoch 1 bleiben ## Verifikation (Status unklar, nicht als erledigt markieren ohne Reproduktion + Commit) - [x] [#15] Neue Verbindung: Netzwerkdose auswählbar (Regressionstest in UI durchgeführt) diff --git a/app/assets/css/floor-infrastructure-edit.css b/app/assets/css/floor-infrastructure-edit.css index 2b3a129..240cfdb 100644 --- a/app/assets/css/floor-infrastructure-edit.css +++ b/app/assets/css/floor-infrastructure-edit.css @@ -30,6 +30,10 @@ flex-direction: column; gap: 6px; } +.floor-plan-toolbar { + display: flex; + gap: 6px; +} .floor-plan-canvas { position: relative; width: 100%; diff --git a/app/assets/js/floor-infrastructure-edit.js b/app/assets/js/floor-infrastructure-edit.js index 8db1d3f..0802c15 100644 --- a/app/assets/js/floor-infrastructure-edit.js +++ b/app/assets/js/floor-infrastructure-edit.js @@ -29,8 +29,14 @@ document.addEventListener('DOMContentLoaded', () => { let markerX = 0; let markerY = 0; let dragging = false; + let panning = false; + let panStart = null; let dragOffsetX = 0; let dragOffsetY = 0; + let viewX = 0; + let viewY = 0; + let viewWidth = DEFAULT_PLAN_SIZE.width; + let viewHeight = DEFAULT_PLAN_SIZE.height; const activeMarker = document.createElementNS(SVG_NS, 'rect'); activeMarker.classList.add('active-marker'); @@ -45,7 +51,7 @@ document.addEventListener('DOMContentLoaded', () => { const planSize = { ...DEFAULT_PLAN_SIZE }; const updateOverlayViewBox = () => { - overlay.setAttribute('viewBox', `0 0 ${planSize.width} ${planSize.height}`); + overlay.setAttribute('viewBox', `${viewX} ${viewY} ${viewWidth} ${viewHeight}`); }; const updatePositionLabel = (x, y) => { @@ -72,17 +78,57 @@ document.addEventListener('DOMContentLoaded', () => { }; const toOverlayPoint = (clientX, clientY) => { - const pt = overlay.createSVGPoint(); - pt.x = clientX; - pt.y = clientY; - const ctm = overlay.getScreenCTM(); - if (!ctm) { + const rect = overlay.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { return null; } - const transformed = pt.matrixTransform(ctm.inverse()); + const ratioX = (clientX - rect.left) / rect.width; + const ratioY = (clientY - rect.top) / rect.height; + const transformed = { + x: viewX + (ratioX * viewWidth), + y: viewY + (ratioY * viewHeight) + }; return { x: transformed.x, y: transformed.y }; }; + const clampView = () => { + const minWidth = Math.max(30, planSize.width * 0.1); + const minHeight = Math.max(30, planSize.height * 0.1); + viewWidth = clamp(viewWidth, minWidth, planSize.width); + viewHeight = clamp(viewHeight, minHeight, planSize.height); + viewX = clamp(viewX, 0, Math.max(0, planSize.width - viewWidth)); + viewY = clamp(viewY, 0, Math.max(0, planSize.height - viewHeight)); + }; + + const applyView = () => { + clampView(); + updateOverlayViewBox(); + }; + + const zoomAt = (clientX, clientY, factor) => { + const point = toOverlayPoint(clientX, clientY); + if (!point) { + return; + } + const ratioX = (point.x - viewX) / viewWidth; + const ratioY = (point.y - viewY) / viewHeight; + const nextWidth = viewWidth * factor; + const nextHeight = viewHeight * factor; + viewX = point.x - (ratioX * nextWidth); + viewY = point.y - (ratioY * nextHeight); + viewWidth = nextWidth; + viewHeight = nextHeight; + applyView(); + }; + + const resetView = () => { + viewX = 0; + viewY = 0; + viewWidth = planSize.width; + viewHeight = planSize.height; + applyView(); + }; + const updateFromInputs = () => { setMarkerPosition(Number(xField.value) || 0, Number(yField.value) || 0); }; @@ -195,6 +241,7 @@ document.addEventListener('DOMContentLoaded', () => { const panelPlacementFields = document.getElementById('panel-placement-fields'); const panelFloorPlanGroup = document.getElementById('panel-floor-plan-group'); const panelFloorMissingHint = document.getElementById('panel-floor-missing-hint'); + const outletBindPatchpanelSelect = document.getElementById('outlet-bind-patchpanel-port-id'); const buildingOptions = panelBuildingSelect ? Array.from(panelBuildingSelect.options).filter((option) => option.value !== '') : []; const floorOptions = panelFloorSelect ? Array.from(panelFloorSelect.options).filter((option) => option.value !== '') : []; @@ -208,6 +255,32 @@ document.addEventListener('DOMContentLoaded', () => { return Number(roomOption?.dataset?.floorId || 0); }; + const filterPatchpanelBindOptions = () => { + if (!outletBindPatchpanelSelect) { + return; + } + const currentFloorId = getCurrentFloorId(); + const options = Array.from(outletBindPatchpanelSelect.options).filter((option) => option.value !== ''); + let firstMatch = ''; + let selectedStillVisible = false; + + options.forEach((option) => { + const optionFloorId = Number(option.dataset.floorId || 0); + const matchesFloor = !currentFloorId || optionFloorId === currentFloorId; + option.hidden = !matchesFloor; + if (matchesFloor && !option.disabled && !firstMatch) { + firstMatch = option.value; + } + if (matchesFloor && option.selected) { + selectedStillVisible = true; + } + }); + + if (!selectedStillVisible && firstMatch && !outletBindPatchpanelSelect.value) { + outletBindPatchpanelSelect.value = firstMatch; + } + }; + const renderReferenceMarkers = () => { clearRoomHighlight(); clearReferenceMarkers(); @@ -256,9 +329,10 @@ document.addEventListener('DOMContentLoaded', () => { floorPlanSvg.hidden = true; planSize.width = DEFAULT_PLAN_SIZE.width; planSize.height = DEFAULT_PLAN_SIZE.height; - updateOverlayViewBox(); + resetView(); } renderReferenceMarkers(); + filterPatchpanelBindOptions(); }; if (floorPlanSvg) { @@ -291,7 +365,7 @@ document.addEventListener('DOMContentLoaded', () => { if (parts.length === 4 && parts.every((value) => Number.isFinite(value))) { planSize.width = Math.max(1, parts[2]); planSize.height = Math.max(1, parts[3]); - updateOverlayViewBox(); + resetView(); renderReferenceMarkers(); updateFromInputs(); return; @@ -307,13 +381,13 @@ document.addEventListener('DOMContentLoaded', () => { planSize.width = DEFAULT_PLAN_SIZE.width; planSize.height = DEFAULT_PLAN_SIZE.height; } - updateOverlayViewBox(); + resetView(); renderReferenceMarkers(); updateFromInputs(); } catch (error) { planSize.width = DEFAULT_PLAN_SIZE.width; planSize.height = DEFAULT_PLAN_SIZE.height; - updateOverlayViewBox(); + resetView(); renderReferenceMarkers(); updateFromInputs(); } @@ -382,6 +456,7 @@ document.addEventListener('DOMContentLoaded', () => { activeMarker.addEventListener('pointerdown', (event) => { event.preventDefault(); dragging = true; + panning = false; const point = toOverlayPoint(event.clientX, event.clientY); if (!point) { return; @@ -403,12 +478,18 @@ document.addEventListener('DOMContentLoaded', () => { }); const stopDrag = (event) => { - if (!dragging) { - return; + if (dragging) { + dragging = false; + if (activeMarker.hasPointerCapture(event.pointerId)) { + activeMarker.releasePointerCapture(event.pointerId); + } } - dragging = false; - if (activeMarker.hasPointerCapture(event.pointerId)) { - activeMarker.releasePointerCapture(event.pointerId); + if (panning) { + panning = false; + panStart = null; + if (overlay.hasPointerCapture(event.pointerId)) { + overlay.releasePointerCapture(event.pointerId); + } } }; @@ -417,6 +498,19 @@ document.addEventListener('DOMContentLoaded', () => { }); overlay.addEventListener('pointerdown', (event) => { + if (event.shiftKey || event.button === 1) { + event.preventDefault(); + panning = true; + dragging = false; + panStart = { + clientX: event.clientX, + clientY: event.clientY, + viewX, + viewY + }; + overlay.setPointerCapture(event.pointerId); + return; + } if (event.target !== overlay) { return; } @@ -427,6 +521,32 @@ document.addEventListener('DOMContentLoaded', () => { setMarkerPosition(point.x - markerWidth / 2, point.y - markerHeight / 2); }); + overlay.addEventListener('pointermove', (event) => { + if (!panning || !panStart) { + return; + } + const rect = overlay.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return; + } + const scaleX = viewWidth / rect.width; + const scaleY = viewHeight / rect.height; + const dx = (event.clientX - panStart.clientX) * scaleX; + const dy = (event.clientY - panStart.clientY) * scaleY; + viewX = panStart.viewX - dx; + viewY = panStart.viewY - dy; + applyView(); + }); + + overlay.addEventListener('pointerup', stopDrag); + overlay.addEventListener('pointercancel', stopDrag); + + overlay.addEventListener('wheel', (event) => { + event.preventDefault(); + const factor = event.deltaY < 0 ? 0.9 : 1.1; + zoomAt(event.clientX, event.clientY, factor); + }, { passive: false }); + [xField, yField].forEach((input) => { input.addEventListener('input', () => { updateFromInputs(); @@ -464,8 +584,26 @@ document.addEventListener('DOMContentLoaded', () => { }); } + document.querySelectorAll('[data-floor-plan-zoom]').forEach((button) => { + button.addEventListener('click', () => { + const action = button.getAttribute('data-floor-plan-zoom'); + if (action === 'in') { + const rect = overlay.getBoundingClientRect(); + zoomAt(rect.left + (rect.width / 2), rect.top + (rect.height / 2), 0.85); + return; + } + if (action === 'out') { + const rect = overlay.getBoundingClientRect(); + zoomAt(rect.left + (rect.width / 2), rect.top + (rect.height / 2), 1.15); + return; + } + resetView(); + }); + }); + updateOverlayViewBox(); updateFromInputs(); + filterPatchpanelBindOptions(); if (panelLocationSelect) { filterBuildingOptions(); diff --git a/app/modules/dashboard/list.php b/app/modules/dashboard/list.php index 1ae9296..525f72f 100644 --- a/app/modules/dashboard/list.php +++ b/app/modules/dashboard/list.php @@ -245,7 +245,7 @@ foreach ($rackLinksByKey as $entry) {
Hierarchie: Standort → Gebaeude → Stockwerk → Rack → Geraet. Linien zeigen Rack-Verbindungen (dicker = mehr Links).