document.addEventListener('DOMContentLoaded', () => { const SVG_NS = 'http://www.w3.org/2000/svg'; const DEFAULT_PLAN_SIZE = { width: 2000, height: 1000 }; const filterForm = document.getElementById('infra-filter-form'); const floorSelect = document.getElementById('infra-floor-select'); if (filterForm && floorSelect) { floorSelect.addEventListener('change', () => { filterForm.submit(); }); } const canvas = document.getElementById('infra-floor-canvas'); 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 || !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 visibility = { rooms: true, connections: true }; 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 nodeByKey = () => { const map = new Map(); nodes.forEach((node) => map.set(node.key, node)); return map; }; const camera = { scale: 1, tx: 0, ty: 0 }; 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 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 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(); }; const resetCamera = () => { camera.scale = 1; camera.tx = 0; camera.ty = 0; applyCamera(); }; const clearOverlay = () => { while (overlay.firstChild) { overlay.removeChild(overlay.firstChild); } }; 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, baseY: camera.ty }; canvas.classList.add('is-dragging'); canvas.setPointerCapture(event.pointerId); }; 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; } if (!panDrag) { return; } camera.tx = panDrag.baseX + (event.clientX - panDrag.x); camera.ty = panDrag.baseY + (event.clientY - panDrag.y); applyCamera(); }; const stopDrag = (event) => { if (!nodeDrag && !panDrag) { return; } nodeDrag = null; panDrag = null; canvas.classList.remove('is-dragging'); if (event && typeof event.pointerId === 'number') { canvas.releasePointerCapture(event.pointerId); } }; const downloadAsPng = () => { const serializer = new XMLSerializer(); const floorHref = floorSvg.getAttribute('src') || ''; const sceneSvg = [ ``, ``, serializer.serializeToString(overlay), '' ].join(''); 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; } 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(); }); }); 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) { applyCamera(); render(); return; } try { const response = await fetch(svgUrl, { credentials: 'same-origin' }); if (!response.ok) { throw new Error('SVG not available'); } const raw = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(raw, 'image/svg+xml'); const root = doc.documentElement; if (!root || root.nodeName.toLowerCase() === 'parsererror') { throw new Error('Invalid SVG'); } const vb = String(root.getAttribute('viewBox') || '').trim(); if (vb) { const parts = vb.split(/\s+/).map((value) => Number(value)); if (parts.length === 4 && parts.every((value) => Number.isFinite(value))) { planSize.width = Math.max(1, parts[2]); planSize.height = Math.max(1, parts[3]); } } 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; } } } catch (error) { planSize.width = DEFAULT_PLAN_SIZE.width; planSize.height = DEFAULT_PLAN_SIZE.height; } 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') || ''); }); function safeJsonParse(value) { if (!value) { return []; } try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : []; } catch (error) { return []; } } function buildTooltipLines(entry, type) { if (type === 'patchpanel') { const lines = [ `Patchpanel: ${entry.name || '-'}`, `Ports: ${Number(entry.port_count) || 0}`, `Position: ${Number(entry.x) || 0} x ${Number(entry.y) || 0}` ]; if (entry.comment) { lines.push(`Kommentar: ${entry.comment}`); } return lines; } const roomLabel = `${entry.room_name || '-'}${entry.room_number ? ` (${entry.room_number})` : ''}`; const lines = [ `Wandbuchse: ${entry.name || '-'}`, `Raum: ${roomLabel}`, `Position: ${Number(entry.x) || 0} x ${Number(entry.y) || 0}` ]; if (entry.port_names) { lines.push(`Ports: ${entry.port_names}`); } if (entry.comment) { lines.push(`Kommentar: ${entry.comment}`); } return lines; } function escapeXml(value) { return String(value || '') .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>'); }