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 = [
+ `'
+ ].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) {