diff --git a/app/assets/css/floor-infrastructure-list.css b/app/assets/css/floor-infrastructure-list.css index 2d77cf4..63361af 100644 --- a/app/assets/css/floor-infrastructure-list.css +++ b/app/assets/css/floor-infrastructure-list.css @@ -65,27 +65,25 @@ .infra-floor-scene { position: absolute; - inset: 0; - transform-origin: center center; + left: 0; + top: 0; + transform-origin: 0 0; will-change: transform; } .infra-floor-svg { position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: contain; - object-position: center center; + left: 0; + top: 0; + object-fit: fill; pointer-events: none; - opacity: 0.85; + opacity: 0.22; } .infra-floor-overlay { position: absolute; - inset: 0; - width: 100%; - height: 100%; + left: 0; + top: 0; z-index: 2; } @@ -93,13 +91,47 @@ pointer-events: auto; } -.infra-overlay-marker.patchpanel { +.infra-room-shape { + fill: rgba(60, 102, 164, 0.06); + stroke: rgba(60, 102, 164, 0.55); + stroke-width: 1.4; +} + +.infra-room-label { + fill: rgba(34, 59, 95, 0.9); + font-size: 18px; + font-weight: 700; + text-anchor: middle; + dominant-baseline: middle; + pointer-events: none; +} + +.infra-connection-path { + fill: none; + stroke: rgba(212, 97, 54, 0.8); + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 6 5; +} + +.infra-node { + cursor: move; +} + +.infra-node-label { + font-size: 13px; + fill: #223a61; + font-weight: 600; + pointer-events: none; +} + +.infra-node--patchpanel .infra-node-shape { fill: rgba(13, 110, 253, 0.25); stroke: #0d6efd; stroke-width: 2; } -.infra-overlay-marker.outlet { +.infra-node--outlet .infra-node-shape { fill: rgba(25, 135, 84, 0.25); stroke: #198754; stroke-width: 2; diff --git a/app/assets/js/floor-infrastructure-list.js b/app/assets/js/floor-infrastructure-list.js index e350aee..459f4ca 100644 --- a/app/assets/js/floor-infrastructure-list.js +++ b/app/assets/js/floor-infrastructure-list.js @@ -14,73 +14,105 @@ document.addEventListener('DOMContentLoaded', () => { const overlay = document.getElementById('infra-floor-overlay'); const scene = document.getElementById('infra-floor-scene'); const floorSvg = canvas ? canvas.querySelector('.infra-floor-svg') : null; - if (!canvas || !overlay || !floorSvg || !scene) { + if (!canvas || !overlay || !scene || !floorSvg) { return; } const patchPanels = safeJsonParse(canvas.dataset.patchpanels); const outlets = safeJsonParse(canvas.dataset.outlets); + const rooms = safeJsonParse(canvas.dataset.rooms); + const links = safeJsonParse(canvas.dataset.links); + const planSize = { ...DEFAULT_PLAN_SIZE }; - - const updateOverlayViewBox = () => { - overlay.setAttribute('viewBox', `0 0 ${planSize.width} ${planSize.height}`); + const visibility = { + rooms: true, + connections: true }; - const createMarker = (entry, type) => { - const x = Number(entry.x); - const y = Number(entry.y); - if (!Number.isFinite(x) || !Number.isFinite(y)) { - return; - } + const nodes = []; + patchPanels.forEach((entry) => { + nodes.push({ + key: `patchpanel:${Number(entry.id || 0)}`, + type: 'patchpanel', + id: Number(entry.id || 0), + label: String(entry.name || 'Patchpanel'), + x: Number(entry.x || 0), + y: Number(entry.y || 0), + width: Math.max(8, Number(entry.width || 20)), + height: Math.max(4, Number(entry.height || 5)), + tooltipLines: buildTooltipLines(entry, 'patchpanel') + }); + }); + outlets.forEach((entry) => { + nodes.push({ + key: `outlet:${Number(entry.id || 0)}`, + type: 'outlet', + id: Number(entry.id || 0), + label: String(entry.name || 'Wandbuchse'), + x: Number(entry.x || 0), + y: Number(entry.y || 0), + width: 10, + height: 10, + tooltipLines: buildTooltipLines(entry, 'outlet') + }); + }); - const marker = document.createElementNS(SVG_NS, 'rect'); - marker.classList.add('infra-overlay-marker', type); - marker.setAttribute('x', String(Math.round(x))); - marker.setAttribute('y', String(Math.round(y))); - - if (type === 'patchpanel') { - marker.setAttribute('width', String(Math.max(1, Number(entry.width) || 20))); - marker.setAttribute('height', String(Math.max(1, Number(entry.height) || 5))); - } else { - marker.setAttribute('width', '10'); - marker.setAttribute('height', '10'); - } - - const tooltipLines = buildTooltipLines(entry, type); - if (tooltipLines.length > 0) { - const titleNode = document.createElementNS(SVG_NS, 'title'); - titleNode.textContent = tooltipLines.join('\n'); - marker.appendChild(titleNode); - } - - overlay.appendChild(marker); + const nodeByKey = () => { + const map = new Map(); + nodes.forEach((node) => map.set(node.key, node)); + return map; }; - patchPanels.forEach((entry) => createMarker(entry, 'patchpanel')); - outlets.forEach((entry) => createMarker(entry, 'outlet')); - const camera = { scale: 1, tx: 0, ty: 0 }; - const SCALE_MIN = 0.6; - const SCALE_MAX = 3.5; - const SCALE_STEP = 0.15; - let drag = null; + const SCALE_MIN = 0.65; + const SCALE_MAX = 4; + const SCALE_STEP = 0.14; + const PAN_STEP = 16; + + let panDrag = null; + let nodeDrag = null; const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); - const applyCamera = () => { - scene.style.transform = `translate(${camera.tx}px, ${camera.ty}px) scale(${camera.scale})`; + const getFit = () => { + const canvasWidth = Math.max(1, canvas.clientWidth || 1); + const canvasHeight = Math.max(1, canvas.clientHeight || 1); + const scale = Math.min(canvasWidth / planSize.width, canvasHeight / planSize.height); + const baseX = (canvasWidth - (planSize.width * scale)) / 2; + const baseY = (canvasHeight - (planSize.height * scale)) / 2; + return { scale, baseX, baseY }; }; - const zoomAtCenter = (factor) => { - const nextScale = clamp(camera.scale * factor, SCALE_MIN, SCALE_MAX); - if (Math.abs(nextScale - camera.scale) < 0.0001) { - return; - } - camera.scale = nextScale; + const applyCamera = () => { + const fit = getFit(); + const totalScale = fit.scale * camera.scale; + const tx = fit.baseX + camera.tx; + const ty = fit.baseY + camera.ty; + scene.style.transform = `translate(${tx}px, ${ty}px) scale(${totalScale})`; + }; + + const toPlanPoint = (clientX, clientY) => { + const rect = canvas.getBoundingClientRect(); + const fit = getFit(); + const totalScale = fit.scale * camera.scale; + const x = (clientX - rect.left - fit.baseX - camera.tx) / totalScale; + const y = (clientY - rect.top - fit.baseY - camera.ty) / totalScale; + return { x, y }; + }; + + const zoomAtPoint = (clientX, clientY, factor) => { + const before = toPlanPoint(clientX, clientY); + camera.scale = clamp(camera.scale * factor, SCALE_MIN, SCALE_MAX); + applyCamera(); + const after = toPlanPoint(clientX, clientY); + const fit = getFit(); + const totalScale = fit.scale * camera.scale; + camera.tx += (after.x - before.x) * totalScale; + camera.ty += (after.y - before.y) * totalScale; applyCamera(); }; @@ -91,13 +123,241 @@ document.addEventListener('DOMContentLoaded', () => { applyCamera(); }; - canvas.addEventListener('wheel', (event) => { - event.preventDefault(); - zoomAtCenter(event.deltaY < 0 ? (1 + SCALE_STEP) : (1 - SCALE_STEP)); - }, { passive: false }); + const clearOverlay = () => { + while (overlay.firstChild) { + overlay.removeChild(overlay.firstChild); + } + }; - canvas.addEventListener('pointerdown', (event) => { - drag = { + const createSvg = (name) => document.createElementNS(SVG_NS, name); + + const parsePolygonPoints = (raw) => { + const text = String(raw || '').trim(); + if (!text) { + return []; + } + return text + .split(/\s+/) + .map((pair) => pair.split(',')) + .filter((pair) => pair.length === 2) + .map((pair) => ({ x: Number(pair[0]), y: Number(pair[1]) })) + .filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y)); + }; + + const getRoomCenter = (room) => { + const polygon = parsePolygonPoints(room.polygon_points); + if (polygon.length >= 3) { + let minX = polygon[0].x; + let minY = polygon[0].y; + let maxX = polygon[0].x; + let maxY = polygon[0].y; + polygon.forEach((point) => { + minX = Math.min(minX, point.x); + minY = Math.min(minY, point.y); + maxX = Math.max(maxX, point.x); + maxY = Math.max(maxY, point.y); + }); + return { x: minX + ((maxX - minX) / 2), y: minY + ((maxY - minY) / 2) }; + } + + const width = Number(room.width || 0); + const height = Number(room.height || 0); + const x = Number(room.x || 0); + const y = Number(room.y || 0); + return { x: x + (width / 2), y: y + (height / 2) }; + }; + + const build45Path = (from, to, laneIndex) => { + const dx = to.x - from.x; + const dy = to.y - from.y; + const sx = dx === 0 ? 1 : Math.sign(dx); + const sy = dy === 0 ? 1 : Math.sign(dy); + const shortest = Math.max(8, Math.min(Math.abs(dx), Math.abs(dy)) / 2); + const laneOffset = laneIndex * 6; + const diag = shortest + laneOffset; + + const p1 = { x: from.x + (sx * diag), y: from.y + (sy * diag) }; + const p2 = { x: to.x - (sx * diag), y: to.y - (sy * diag) }; + + return `M ${from.x} ${from.y} L ${p1.x} ${p1.y} L ${p2.x} ${p2.y} L ${to.x} ${to.y}`; + }; + + const drawRoomsLayer = (root) => { + if (!visibility.rooms) { + return; + } + const roomLayer = createSvg('g'); + roomLayer.setAttribute('class', 'infra-room-layer'); + + rooms.forEach((room) => { + const polygon = parsePolygonPoints(room.polygon_points); + if (polygon.length >= 3) { + const shape = createSvg('polygon'); + shape.setAttribute('points', polygon.map((point) => `${point.x},${point.y}`).join(' ')); + shape.setAttribute('class', 'infra-room-shape'); + roomLayer.appendChild(shape); + } else { + const width = Math.max(0, Number(room.width || 0)); + const height = Math.max(0, Number(room.height || 0)); + if (width > 0 && height > 0) { + const rect = createSvg('rect'); + rect.setAttribute('x', String(Number(room.x || 0))); + rect.setAttribute('y', String(Number(room.y || 0))); + rect.setAttribute('width', String(width)); + rect.setAttribute('height', String(height)); + rect.setAttribute('class', 'infra-room-shape'); + roomLayer.appendChild(rect); + } + } + + const center = getRoomCenter(room); + const roomLabel = String(room.number || '').trim() || String(room.name || ''); + if (roomLabel !== '') { + const text = createSvg('text'); + text.setAttribute('x', String(center.x)); + text.setAttribute('y', String(center.y)); + text.setAttribute('class', 'infra-room-label'); + text.textContent = roomLabel; + roomLayer.appendChild(text); + } + }); + + root.appendChild(roomLayer); + }; + + const drawConnectionsLayer = (root, nodeMap) => { + if (!visibility.connections) { + return; + } + const layer = createSvg('g'); + layer.setAttribute('class', 'infra-connection-layer'); + + links.forEach((link, index) => { + const fromNode = nodeMap.get(String(link.from_key || '')); + const toNode = nodeMap.get(String(link.to_key || '')); + if (!fromNode || !toNode) { + return; + } + + const fromCenter = { + x: fromNode.x + (fromNode.width / 2), + y: fromNode.y + (fromNode.height / 2) + }; + const toCenter = { + x: toNode.x + (toNode.width / 2), + y: toNode.y + (toNode.height / 2) + }; + + const path = createSvg('path'); + path.setAttribute('d', build45Path(fromCenter, toCenter, index % 4)); + path.setAttribute('class', 'infra-connection-path'); + path.setAttribute('stroke-width', String(Math.min(6, 1.5 + Number(link.count || 1)))); + + const title = createSvg('title'); + title.textContent = `${fromNode.label} ↔ ${toNode.label} (${Number(link.count || 1)})`; + path.appendChild(title); + + layer.appendChild(path); + }); + + root.appendChild(layer); + }; + + const drawNodesLayer = (root) => { + const layer = createSvg('g'); + layer.setAttribute('class', 'infra-node-layer'); + + nodes.forEach((node) => { + const group = createSvg('g'); + group.setAttribute('class', `infra-node infra-node--${node.type}`); + group.setAttribute('data-node-key', node.key); + + if (node.type === 'patchpanel') { + const rect = createSvg('rect'); + rect.setAttribute('x', String(node.x)); + rect.setAttribute('y', String(node.y)); + rect.setAttribute('width', String(node.width)); + rect.setAttribute('height', String(node.height)); + rect.setAttribute('rx', '1.5'); + rect.setAttribute('class', 'infra-node-shape'); + group.appendChild(rect); + } else { + const rect = createSvg('rect'); + rect.setAttribute('x', String(node.x)); + rect.setAttribute('y', String(node.y)); + rect.setAttribute('width', String(node.width)); + rect.setAttribute('height', String(node.height)); + rect.setAttribute('rx', '2'); + rect.setAttribute('class', 'infra-node-shape'); + group.appendChild(rect); + } + + const label = createSvg('text'); + label.setAttribute('x', String(node.x + node.width + 6)); + label.setAttribute('y', String(node.y + Math.max(10, node.height))); + label.setAttribute('class', 'infra-node-label'); + label.textContent = node.label; + group.appendChild(label); + + if (node.tooltipLines.length > 0) { + const title = createSvg('title'); + title.textContent = node.tooltipLines.join('\n'); + group.appendChild(title); + } + + layer.appendChild(group); + }); + + root.appendChild(layer); + }; + + const render = () => { + clearOverlay(); + const root = createSvg('g'); + drawRoomsLayer(root); + drawConnectionsLayer(root, nodeByKey()); + drawNodesLayer(root); + overlay.appendChild(root); + }; + + const syncToggleState = () => { + document.querySelectorAll('[data-infra-toggle]').forEach((button) => { + const type = button.getAttribute('data-infra-toggle'); + const active = type === 'rooms' ? visibility.rooms : visibility.connections; + button.classList.toggle('button-primary', active); + }); + }; + + const startNodeDrag = (event, node) => { + event.preventDefault(); + event.stopPropagation(); + const point = toPlanPoint(event.clientX, event.clientY); + nodeDrag = { + key: node.key, + offsetX: point.x - node.x, + offsetY: point.y - node.y + }; + canvas.classList.add('is-dragging'); + canvas.setPointerCapture(event.pointerId); + }; + + const onPointerDown = (event) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const nodeElement = target.closest('[data-node-key]'); + if (nodeElement) { + const key = String(nodeElement.getAttribute('data-node-key') || ''); + const node = nodes.find((entry) => entry.key === key); + if (node) { + startNodeDrag(event, node); + } + return; + } + + panDrag = { x: event.clientX, y: event.clientY, baseX: camera.tx, @@ -105,51 +365,167 @@ document.addEventListener('DOMContentLoaded', () => { }; canvas.classList.add('is-dragging'); canvas.setPointerCapture(event.pointerId); - }); + }; - canvas.addEventListener('pointermove', (event) => { - if (!drag) { + const onPointerMove = (event) => { + if (nodeDrag) { + const point = toPlanPoint(event.clientX, event.clientY); + const node = nodes.find((entry) => entry.key === nodeDrag.key); + if (!node) { + return; + } + node.x = clamp(point.x - nodeDrag.offsetX, 0, Math.max(0, planSize.width - node.width)); + node.y = clamp(point.y - nodeDrag.offsetY, 0, Math.max(0, planSize.height - node.height)); + render(); return; } - camera.tx = drag.baseX + (event.clientX - drag.x); - camera.ty = drag.baseY + (event.clientY - drag.y); + + if (!panDrag) { + return; + } + camera.tx = panDrag.baseX + (event.clientX - panDrag.x); + camera.ty = panDrag.baseY + (event.clientY - panDrag.y); applyCamera(); - }); + }; const stopDrag = (event) => { - if (!drag) { + if (!nodeDrag && !panDrag) { return; } - drag = null; + nodeDrag = null; + panDrag = null; canvas.classList.remove('is-dragging'); if (event && typeof event.pointerId === 'number') { canvas.releasePointerCapture(event.pointerId); } }; - canvas.addEventListener('pointerup', stopDrag); - canvas.addEventListener('pointercancel', stopDrag); + const downloadAsPng = () => { + const serializer = new XMLSerializer(); + const floorHref = floorSvg.getAttribute('src') || ''; + const sceneSvg = [ + ``, + ``, + serializer.serializeToString(overlay), + '' + ].join(''); - document.querySelectorAll('[data-infra-zoom]').forEach((button) => { - button.addEventListener('click', () => { - const action = button.getAttribute('data-infra-zoom'); - if (action === 'in') { - zoomAtCenter(1 + SCALE_STEP); + const blob = new Blob([sceneSvg], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const img = new Image(); + img.onload = () => { + const out = document.createElement('canvas'); + out.width = Math.round(planSize.width); + out.height = Math.round(planSize.height); + const ctx = out.getContext('2d'); + if (!ctx) { + URL.revokeObjectURL(url); return; } - if (action === 'out') { - zoomAtCenter(1 - SCALE_STEP); - return; - } - resetCamera(); + ctx.drawImage(img, 0, 0); + const a = document.createElement('a'); + a.href = out.toDataURL('image/png'); + a.download = 'stockwerks-topologie.png'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + window.alert('PNG-Export fehlgeschlagen.'); + }; + img.src = url; + }; + + const bindEvents = () => { + canvas.addEventListener('wheel', (event) => { + event.preventDefault(); + const factor = event.deltaY < 0 ? (1 + SCALE_STEP) : (1 - SCALE_STEP); + zoomAtPoint(event.clientX, event.clientY, factor); + }, { passive: false }); + + canvas.addEventListener('pointerdown', onPointerDown); + canvas.addEventListener('pointermove', onPointerMove); + canvas.addEventListener('pointerup', stopDrag); + canvas.addEventListener('pointercancel', stopDrag); + canvas.addEventListener('pointerleave', stopDrag); + + document.querySelectorAll('[data-infra-zoom]').forEach((button) => { + button.addEventListener('click', () => { + const action = button.getAttribute('data-infra-zoom'); + const rect = canvas.getBoundingClientRect(); + const cx = rect.left + (rect.width / 2); + const cy = rect.top + (rect.height / 2); + if (action === 'in') { + zoomAtPoint(cx, cy, 1 + SCALE_STEP); + return; + } + if (action === 'out') { + zoomAtPoint(cx, cy, 1 - SCALE_STEP); + return; + } + resetCamera(); + }); }); - }); - applyCamera(); + document.querySelectorAll('[data-infra-toggle]').forEach((button) => { + button.addEventListener('click', () => { + const type = button.getAttribute('data-infra-toggle'); + if (type === 'rooms') { + visibility.rooms = !visibility.rooms; + } else if (type === 'connections') { + visibility.connections = !visibility.connections; + } + syncToggleState(); + render(); + }); + }); + + document.querySelectorAll('[data-infra-download="png"]').forEach((button) => { + button.addEventListener('click', downloadAsPng); + }); + + document.addEventListener('keydown', (event) => { + if (event.key === '0') { + resetCamera(); + return; + } + if (event.key.toLowerCase() === 'r') { + visibility.rooms = !visibility.rooms; + syncToggleState(); + render(); + return; + } + if (event.key.toLowerCase() === 'k') { + visibility.connections = !visibility.connections; + syncToggleState(); + render(); + return; + } + + if (event.key === 'ArrowLeft') { + camera.tx += PAN_STEP; + applyCamera(); + } else if (event.key === 'ArrowRight') { + camera.tx -= PAN_STEP; + applyCamera(); + } else if (event.key === 'ArrowUp') { + camera.ty += PAN_STEP; + applyCamera(); + } else if (event.key === 'ArrowDown') { + camera.ty -= PAN_STEP; + applyCamera(); + } + }); + + window.addEventListener('resize', applyCamera); + }; const loadPlanDimensions = async (svgUrl) => { if (!svgUrl) { - updateOverlayViewBox(); + applyCamera(); + render(); return; } try { @@ -171,28 +547,39 @@ 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(); - return; + } + } else { + const width = Number(root.getAttribute('width')); + const height = Number(root.getAttribute('height')); + if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { + planSize.width = width; + planSize.height = height; } } - - const width = Number(root.getAttribute('width')); - const height = Number(root.getAttribute('height')); - if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { - planSize.width = width; - planSize.height = height; - } else { - planSize.width = DEFAULT_PLAN_SIZE.width; - planSize.height = DEFAULT_PLAN_SIZE.height; - } - updateOverlayViewBox(); } catch (error) { planSize.width = DEFAULT_PLAN_SIZE.width; planSize.height = DEFAULT_PLAN_SIZE.height; - updateOverlayViewBox(); } + + overlay.setAttribute('viewBox', `0 0 ${planSize.width} ${planSize.height}`); + scene.style.width = `${planSize.width}px`; + scene.style.height = `${planSize.height}px`; + floorSvg.style.width = `${planSize.width}px`; + floorSvg.style.height = `${planSize.height}px`; + overlay.style.width = `${planSize.width}px`; + overlay.style.height = `${planSize.height}px`; + + nodes.forEach((node) => { + node.x = clamp(node.x, 0, Math.max(0, planSize.width - node.width)); + node.y = clamp(node.y, 0, Math.max(0, planSize.height - node.height)); + }); + + applyCamera(); + render(); }; + bindEvents(); + syncToggleState(); loadPlanDimensions(floorSvg.getAttribute('src') || ''); }); @@ -235,3 +622,11 @@ function buildTooltipLines(entry, type) { } return lines; } + +function escapeXml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} diff --git a/app/modules/floor_infrastructure/list.php b/app/modules/floor_infrastructure/list.php index 79df32f..a472b14 100644 --- a/app/modules/floor_infrastructure/list.php +++ b/app/modules/floor_infrastructure/list.php @@ -64,6 +64,8 @@ foreach ($floors as $floor) { $editorFloor = ($floorId > 0 && isset($floorMap[$floorId])) ? $floorMap[$floorId] : null; $editorPatchPanels = []; $editorOutlets = []; +$editorRooms = []; +$editorLinks = []; if ($editorFloor) { foreach ($patchPanels as $panel) { @@ -97,6 +99,101 @@ if ($editorFloor) { 'comment' => (string)($outlet['comment'] ?? '') ]; } + + foreach ($sql->get( + "SELECT id, name, number, x, y, width, height, polygon_points + FROM rooms + WHERE floor_id = ? + ORDER BY name", + "i", + [$floorId] + ) as $room) { + $editorRooms[] = [ + 'id' => (int)($room['id'] ?? 0), + 'name' => (string)($room['name'] ?? ''), + 'number' => (string)($room['number'] ?? ''), + 'x' => (int)($room['x'] ?? 0), + 'y' => (int)($room['y'] ?? 0), + 'width' => (int)($room['width'] ?? 0), + 'height' => (int)($room['height'] ?? 0), + 'polygon_points' => (string)($room['polygon_points'] ?? ''), + ]; + } + + $outletIdByPort = []; + foreach ($sql->get( + "SELECT nop.id AS port_id, nop.outlet_id + FROM network_outlet_ports nop + JOIN network_outlets o ON o.id = nop.outlet_id + JOIN rooms r ON r.id = o.room_id + WHERE r.floor_id = ?", + "i", + [$floorId] + ) as $row) { + $portId = (int)($row['port_id'] ?? 0); + $outletId = (int)($row['outlet_id'] ?? 0); + if ($portId > 0 && $outletId > 0) { + $outletIdByPort[$portId] = $outletId; + } + } + + $patchPanelIdByPort = []; + foreach ($sql->get( + "SELECT fpp.id AS port_id, fpp.patchpanel_id + FROM floor_patchpanel_ports fpp + JOIN floor_patchpanels fp ON fp.id = fpp.patchpanel_id + WHERE fp.floor_id = ?", + "i", + [$floorId] + ) as $row) { + $portId = (int)($row['port_id'] ?? 0); + $patchPanelId = (int)($row['patchpanel_id'] ?? 0); + if ($portId > 0 && $patchPanelId > 0) { + $patchPanelIdByPort[$portId] = $patchPanelId; + } + } + + $resolveNodeKey = static function (string $endpointType, int $endpointId) use ($outletIdByPort, $patchPanelIdByPort): ?string { + if ($endpointId <= 0) { + return null; + } + $type = strtolower(trim($endpointType)); + if ($type === 'outlet' || $type === 'network_outlet_ports') { + $outletId = (int)($outletIdByPort[$endpointId] ?? 0); + return $outletId > 0 ? ('outlet:' . $outletId) : null; + } + if ($type === 'patchpanel' || $type === 'floor_patchpanel_ports') { + $patchPanelId = (int)($patchPanelIdByPort[$endpointId] ?? 0); + return $patchPanelId > 0 ? ('patchpanel:' . $patchPanelId) : null; + } + return null; + }; + + $linksByKey = []; + foreach ($sql->get( + "SELECT id, port_a_type, port_a_id, port_b_type, port_b_id + FROM connections", + "", + [] + ) as $row) { + $fromKey = $resolveNodeKey((string)($row['port_a_type'] ?? ''), (int)($row['port_a_id'] ?? 0)); + $toKey = $resolveNodeKey((string)($row['port_b_type'] ?? ''), (int)($row['port_b_id'] ?? 0)); + if ($fromKey === null || $toKey === null || $fromKey === $toKey) { + continue; + } + + $edgeKey = ($fromKey < $toKey) ? ($fromKey . '|' . $toKey) : ($toKey . '|' . $fromKey); + if (!isset($linksByKey[$edgeKey])) { + $linksByKey[$edgeKey] = [ + 'from_key' => $fromKey, + 'to_key' => $toKey, + 'count' => 0, + 'sample_connection_id' => (int)($row['id'] ?? 0) + ]; + } + $linksByKey[$edgeKey]['count']++; + } + $editorLinks = array_values($linksByKey); } ?> @@ -133,9 +230,12 @@ if ($editorFloor) {
-
+

Stockwerkskarte

+ + + @@ -155,13 +255,15 @@ if ($editorFloor) {
+ data-outlets="" + data-rooms="" + data-links="">
Stockwerksplan
-

Blau: Patchpanel | Grün: Wandbuchse. Hover zeigt Name, Raum und Ports (Browser-Tooltip).

+

Blau: Patchpanel | Grün: Wandbuchse. Knoten sind greifbar und verschiebbar. Räume und Kabel lassen sich ein-/ausblenden.