Verbindungsübersicht mit Raumtopologie und PNG-Export erweitert
closes #31
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 = [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${planSize.width} ${planSize.height}" width="${planSize.width}" height="${planSize.height}">`,
|
||||
`<image href="${escapeXml(floorHref)}" x="0" y="0" width="${planSize.width}" height="${planSize.height}" opacity="0.2" preserveAspectRatio="none"/>`,
|
||||
serializer.serializeToString(overlay),
|
||||
'</svg>'
|
||||
].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, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
</form>
|
||||
|
||||
<section class="infra-plan">
|
||||
<div class="infra-plan-header">
|
||||
<div class="infra-plan-header">
|
||||
<h2>Stockwerkskarte</h2>
|
||||
<div class="infra-plan-tools">
|
||||
<button type="button" class="button button-small" data-infra-toggle="rooms">Räume</button>
|
||||
<button type="button" class="button button-small" data-infra-toggle="connections">Kabel</button>
|
||||
<button type="button" class="button button-small" data-infra-download="png">PNG</button>
|
||||
<button type="button" class="button button-small" data-infra-zoom="in">+</button>
|
||||
<button type="button" class="button button-small" data-infra-zoom="out">-</button>
|
||||
<button type="button" class="button button-small" data-infra-zoom="reset">Reset</button>
|
||||
@@ -155,13 +255,15 @@ if ($editorFloor) {
|
||||
<div id="infra-floor-canvas"
|
||||
class="infra-floor-canvas"
|
||||
data-patchpanels="<?php echo htmlspecialchars(json_encode($editorPatchPanels, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>"
|
||||
data-outlets="<?php echo htmlspecialchars(json_encode($editorOutlets, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>">
|
||||
data-outlets="<?php echo htmlspecialchars(json_encode($editorOutlets, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>"
|
||||
data-rooms="<?php echo htmlspecialchars(json_encode($editorRooms, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>"
|
||||
data-links="<?php echo htmlspecialchars(json_encode($editorLinks, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<div class="infra-floor-scene" id="infra-floor-scene">
|
||||
<img src="<?php echo htmlspecialchars((string)$editorFloor['svg_url']); ?>" class="infra-floor-svg" alt="Stockwerksplan">
|
||||
<svg id="infra-floor-overlay" class="infra-floor-overlay" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="floor-plan-hint">Blau: Patchpanel | Grün: Wandbuchse. Hover zeigt Name, Raum und Ports (Browser-Tooltip).</p>
|
||||
<p class="floor-plan-hint">Blau: Patchpanel | Grün: Wandbuchse. Knoten sind greifbar und verschiebbar. Räume und Kabel lassen sich ein-/ausblenden.</p>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user