This commit is contained in:
2026-03-31 10:22:22 +02:00
parent dec5a0bcce
commit f95563b233
3 changed files with 519 additions and 8 deletions

View File

@@ -71,6 +71,19 @@
background: #218838;
}
.button-secondary {
background: #6c757d;
}
.button-secondary:hover {
background: #5a6268;
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.button-small {
padding: 4px 8px;
font-size: 0.85em;
@@ -185,3 +198,98 @@
padding: 4px 10px;
font-size: 0.8rem;
}
.floor-preview-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.85);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 1100;
visibility: hidden;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.floor-preview-overlay.is-visible {
visibility: visible;
opacity: 1;
pointer-events: auto;
}
.floor-preview-card {
width: min(960px, 100%);
max-height: calc(100vh - 32px);
background: #fff;
border-radius: 12px;
box-shadow: 0 25px 60px rgba(15, 23, 42, 0.35);
display: flex;
flex-direction: column;
overflow: hidden;
}
.floor-preview-header,
.floor-preview-footer {
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid #edf2f7;
}
.floor-preview-footer {
border-top: 1px solid #edf2f7;
border-bottom: none;
justify-content: flex-end;
}
.floor-preview-label {
font-size: 0.8rem;
letter-spacing: 0.1em;
text-transform: uppercase;
margin: 0;
color: #6c757d;
}
.floor-preview-header h3 {
margin: 2px 0 0;
font-size: 1.35rem;
}
.floor-preview-body {
padding: 16px 24px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
overflow-y: auto;
flex: 1 1 auto;
}
.floor-preview-body canvas {
width: 100%;
max-width: 900px;
background: #0a0c10;
border-radius: 10px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.floor-preview-empty {
margin: 0;
color: #6c757d;
font-size: 0.95rem;
}
.floor-preview-close {
background: transparent;
color: #1f2a37;
border: 1px solid #dfe3ea;
}
.floor-preview-close:hover {
background: #f4f5f7;
}

View File

@@ -92,5 +92,334 @@
});
}
document.addEventListener('DOMContentLoaded', bindDeleteButtons);
function initFloorPreview() {
const overlay = document.querySelector('[data-floor-preview-overlay]');
if (!overlay) {
return;
}
const titleElement = overlay.querySelector('[data-floor-preview-title]');
const canvas = overlay.querySelector('[data-floor-preview-canvas]');
const messageElement = overlay.querySelector('[data-floor-preview-message]');
const closeButton = overlay.querySelector('[data-floor-preview-close]');
const downloadButton = overlay.querySelector('.js-floor-preview-download');
const ctx = canvas ? canvas.getContext('2d') : null;
const palette = ['#f94144', '#f3722c', '#f9844a', '#f9c74f', '#90be6d', '#43aa8b', '#577590', '#277da1', '#845ef7', '#ffb703', '#ff85c0'];
const DEFAULT_VIEWBOX = { x: 0, y: 0, width: 1200, height: 700 };
let currentFloorName = 'Stockwerk';
let latestRenderId = 0;
const parseRoomPayload = (payload) => {
if (!payload) {
return [];
}
try {
const parsed = JSON.parse(payload);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.map((room) => ({
number: typeof room.number === 'string' ? room.number : (room.number != null ? String(room.number) : ''),
name: typeof room.name === 'string' ? room.name : (room.name != null ? String(room.name) : ''),
polygon: Array.isArray(room.polygon) ? room.polygon
.map((point) => ({
x: Number(point && point.x),
y: Number(point && point.y)
}))
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
: [],
bounds: {
x: Number(room.bounds && room.bounds.x),
y: Number(room.bounds && room.bounds.y),
width: Number(room.bounds && room.bounds.width),
height: Number(room.bounds && room.bounds.height)
}
}));
} catch (error) {
return [];
}
};
const safeViewBox = (viewBox) => {
if (!viewBox || !Number.isFinite(viewBox.width) || !Number.isFinite(viewBox.height) || viewBox.width <= 0 || viewBox.height <= 0) {
return { ...DEFAULT_VIEWBOX };
}
return {
x: Number.isFinite(viewBox.x) ? viewBox.x : 0,
y: Number.isFinite(viewBox.y) ? viewBox.y : 0,
width: viewBox.width,
height: viewBox.height
};
};
const readViewBox = (svgRoot) => {
const rawViewBox = (svgRoot.getAttribute('viewBox') || '').trim();
if (rawViewBox) {
const parts = rawViewBox.split(/\s+/).map(Number);
if (parts.length === 4 && parts.every(Number.isFinite)) {
return {
x: parts[0],
y: parts[1],
width: parts[2],
height: parts[3]
};
}
}
const width = Number(svgRoot.getAttribute('width'));
const height = Number(svgRoot.getAttribute('height'));
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
return { x: 0, y: 0, width, height };
}
return { ...DEFAULT_VIEWBOX };
};
const computeMetrics = (viewBox, canvasWidth, canvasHeight) => {
const width = viewBox.width || 1;
const height = viewBox.height || 1;
const scale = Math.min(canvasWidth / width, canvasHeight / height);
const drawWidth = width * scale;
const drawHeight = height * scale;
return {
scale,
offsetX: (canvasWidth - drawWidth) / 2,
offsetY: (canvasHeight - drawHeight) / 2,
drawWidth,
drawHeight
};
};
const toCanvasPoint = (point, viewBox, metrics) => ({
x: metrics.offsetX + (point.x - viewBox.x) * metrics.scale,
y: metrics.offsetY + (point.y - viewBox.y) * metrics.scale
});
const getRoomShape = (room) => {
if (Array.isArray(room.polygon) && room.polygon.length >= 3) {
return room.polygon;
}
const bounds = room.bounds || {};
if (!Number.isFinite(bounds.x) || !Number.isFinite(bounds.y) || !Number.isFinite(bounds.width) || !Number.isFinite(bounds.height)) {
return [];
}
return [
{ x: bounds.x, y: bounds.y },
{ x: bounds.x + bounds.width, y: bounds.y },
{ x: bounds.x + bounds.width, y: bounds.y + bounds.height },
{ x: bounds.x, y: bounds.y + bounds.height }
];
};
const computeBounds = (points) => {
if (!points.length) {
return null;
}
let minX = points[0].x;
let minY = points[0].y;
points.forEach((point) => {
if (point.x < minX) {
minX = point.x;
}
if (point.y < minY) {
minY = point.y;
}
});
return { minX, minY };
};
const drawRooms = (rooms, viewBox, metrics) => {
rooms.forEach((room, index) => {
const shape = getRoomShape(room);
if (!shape.length) {
return;
}
const path = new Path2D();
shape.forEach((point, pointIndex) => {
const canvasPoint = toCanvasPoint(point, viewBox, metrics);
if (pointIndex === 0) {
path.moveTo(canvasPoint.x, canvasPoint.y);
} else {
path.lineTo(canvasPoint.x, canvasPoint.y);
}
});
path.closePath();
ctx.save();
ctx.globalAlpha = 0.78;
ctx.fillStyle = palette[index % palette.length];
ctx.fill(path);
ctx.globalAlpha = 1;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.85)';
ctx.lineWidth = 2;
ctx.stroke(path);
const bounds = computeBounds(shape);
const labelPos = bounds ? toCanvasPoint({ x: bounds.minX, y: bounds.minY }, viewBox, metrics) : { x: metrics.offsetX, y: metrics.offsetY };
ctx.fillStyle = '#ffffff';
ctx.textBaseline = 'top';
ctx.font = '600 16px "Segoe UI", sans-serif';
const numberLabel = room.number ? `${room.number}` : '-';
ctx.fillText(numberLabel, labelPos.x + 6, labelPos.y + 6);
ctx.font = '14px "Segoe UI", sans-serif';
const nameLabel = room.name || '—';
ctx.fillText(nameLabel, labelPos.x + 6, labelPos.y + 26);
ctx.restore();
});
};
const loadImage = (src) => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = reject;
image.src = src;
});
const loadFloorSvg = async (url) => {
if (!url) {
throw new Error('Keine SVG-URL');
}
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) {
throw new Error('SVG konnte nicht geladen werden');
}
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('SVG ungültig');
}
const viewBox = safeViewBox(readViewBox(root));
const encodedSvg = encodeURIComponent(raw);
const dataUri = `data:image/svg+xml;charset=utf-8,${encodedSvg}`;
return { dataUri, viewBox };
};
const renderPreview = async (rooms, floorSvgUrl) => {
if (!canvas || !ctx) {
return false;
}
latestRenderId += 1;
const renderId = latestRenderId;
const canvasWidth = 980;
const canvasHeight = 640;
canvas.width = canvasWidth;
canvas.height = canvasHeight;
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.fillStyle = '#05070d';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
if (!rooms.length) {
canvas.hidden = true;
if (messageElement) {
messageElement.textContent = 'Keine Raeume vorhanden.';
messageElement.hidden = false;
}
downloadButton?.setAttribute('disabled', 'disabled');
return false;
}
canvas.hidden = false;
if (messageElement) {
messageElement.hidden = true;
messageElement.textContent = '';
}
downloadButton?.setAttribute('disabled', 'disabled');
let viewBox = { ...DEFAULT_VIEWBOX };
let metrics = computeMetrics(viewBox, canvasWidth, canvasHeight);
if (floorSvgUrl) {
try {
const { dataUri, viewBox: svgViewBox } = await loadFloorSvg(floorSvgUrl);
if (renderId !== latestRenderId) {
return false;
}
viewBox = svgViewBox;
metrics = computeMetrics(viewBox, canvasWidth, canvasHeight);
const floorImage = await loadImage(dataUri);
if (renderId !== latestRenderId) {
return false;
}
ctx.drawImage(floorImage, metrics.offsetX, metrics.offsetY, metrics.drawWidth, metrics.drawHeight);
} catch (error) {
if (renderId !== latestRenderId) {
return false;
}
if (messageElement) {
messageElement.textContent = 'Stockwerkskarte konnte nicht geladen werden.';
messageElement.hidden = false;
}
}
}
drawRooms(rooms, viewBox, metrics);
downloadButton?.removeAttribute('disabled');
return true;
};
const openPreview = async (floorName, rooms, floorSvgUrl) => {
currentFloorName = floorName || 'Stockwerk';
if (titleElement) {
titleElement.textContent = currentFloorName;
}
overlay.classList.add('is-visible');
overlay.setAttribute('aria-hidden', 'false');
try {
await renderPreview(rooms, floorSvgUrl);
} catch (error) {
if (messageElement) {
messageElement.textContent = 'Vorschau konnte nicht geladen werden.';
messageElement.hidden = false;
}
}
};
const closePreview = () => {
overlay.classList.remove('is-visible');
overlay.setAttribute('aria-hidden', 'true');
};
document.querySelectorAll('.js-preview-floor').forEach((button) => {
button.addEventListener('click', () => {
const rooms = parseRoomPayload(button.dataset.floorRooms);
const floorName = button.dataset.floorName || button.dataset.floorId || 'Stockwerk';
const floorSvgUrl = button.dataset.floorSvg || '';
void openPreview(floorName, rooms, floorSvgUrl);
});
});
overlay.addEventListener('click', (event) => {
if (event.target === overlay) {
closePreview();
}
});
closeButton?.addEventListener('click', closePreview);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closePreview();
}
});
downloadButton?.addEventListener('click', () => {
if (!canvas) {
return;
}
const fallbackName = currentFloorName.toLowerCase().replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '') || 'stockwerk';
const anchor = document.createElement('a');
anchor.href = canvas.toDataURL('image/png');
anchor.download = `stockwerk-${fallbackName}.png`;
anchor.click();
});
}
document.addEventListener('DOMContentLoaded', () => {
bindDeleteButtons();
initFloorPreview();
});
})();

View File

@@ -37,7 +37,7 @@ $buildings = $sql->get(
);
$floors = $sql->get(
"SELECT f.id, f.building_id, f.name, f.level,
"SELECT f.id, f.building_id, f.name, f.level, f.svg_path,
COUNT(r.id) AS room_count
FROM floors f
LEFT JOIN rooms r ON r.floor_id = f.id
@@ -47,8 +47,15 @@ $floors = $sql->get(
[]
);
foreach ($floors as &$floor) {
$rawPath = trim((string)($floor['svg_path'] ?? ''));
$floor['svg_url'] = $rawPath !== '' ? '/' . ltrim($rawPath, '/\\') : '';
}
unset($floor);
$rooms = $sql->get(
"SELECT r.id, r.floor_id, r.name, r.number, r.comment,
r.x, r.y, r.width, r.height, r.polygon_points,
COUNT(no.id) AS outlet_count
FROM rooms r
LEFT JOIN network_outlets no ON no.room_id = r.id
@@ -193,7 +200,55 @@ foreach ($rooms as $room) {
<?php endif; ?>
<span class="hierarchy-meta"> | <?php echo (int)$floor['room_count']; ?> Raeume</span>
</td>
<?php
$roomPreviewData = [];
foreach ($floorRooms as $room) {
$rawPolygon = trim((string)($room['polygon_points'] ?? ''));
$polygonPoints = [];
if ($rawPolygon !== '') {
$decodedPoints = json_decode($rawPolygon, true);
if (is_array($decodedPoints)) {
foreach ($decodedPoints as $point) {
$x = $point['x'] ?? null;
$y = $point['y'] ?? null;
if (is_numeric($x) && is_numeric($y)) {
$polygonPoints[] = [
'x' => (float)$x,
'y' => (float)$y,
];
}
}
}
}
$roomPreviewData[] = [
'id' => (int)$room['id'],
'number' => (string)($room['number'] ?? ''),
'name' => (string)($room['name'] ?? ''),
'polygon' => $polygonPoints,
'bounds' => [
'x' => isset($room['x']) ? (float)$room['x'] : null,
'y' => isset($room['y']) ? (float)$room['y'] : null,
'width' => isset($room['width']) ? (float)$room['width'] : null,
'height' => isset($room['height']) ? (float)$room['height'] : null,
],
];
}
$roomPreviewPayload = json_encode($roomPreviewData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$roomPreviewPayload = ($roomPreviewPayload === false) ? '[]' : $roomPreviewPayload;
$roomPreviewPayload = htmlspecialchars($roomPreviewPayload, ENT_QUOTES, 'UTF-8');
$hasRoomsForPreview = !empty($roomPreviewData);
?>
<td class="actions hierarchy-actions">
<button type="button"
class="button button-small button-secondary js-preview-floor"
data-floor-id="<?php echo (int)$floor['id']; ?>"
data-floor-name="<?php echo htmlspecialchars((string)$floor['name']); ?>"
data-floor-rooms="<?php echo $roomPreviewPayload; ?>"
data-floor-svg="<?php echo htmlspecialchars((string)($floor['svg_url'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>"
<?php if (!$hasRoomsForPreview): ?>disabled title="Keine Raeume vorhanden"<?php endif; ?>>
Stockwerk als PNG
</button>
<a href="?module=floors&action=edit&id=<?php echo (int)$floor['id']; ?>" class="button button-small">Bearbeiten</a>
<button type="button" class="button button-small button-danger js-delete-floor" data-floor-id="<?php echo (int)$floor['id']; ?>">Loeschen</button>
<a href="?module=rooms&action=edit&floor_id=<?php echo (int)$floor['id']; ?>" class="button button-small">+ Raum</a>
@@ -247,7 +302,26 @@ foreach ($rooms as $room) {
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<?php else: ?>
<p>Keine Standorte gefunden.</p>
<?php endif; ?>
</section>
<div class="floor-preview-overlay" data-floor-preview-overlay aria-hidden="true">
<div class="floor-preview-card">
<div class="floor-preview-header">
<div>
<p class="floor-preview-label">Stockwerk-Vorschau</p>
<h3 data-floor-preview-title>Stockwerk</h3>
</div>
<button type="button" class="button button-small floor-preview-close" data-floor-preview-close>Schließen</button>
</div>
<div class="floor-preview-body">
<canvas data-floor-preview-canvas width="900" height="600" aria-label="Stockwerk mit Raeumen"></canvas>
<p class="floor-preview-empty" data-floor-preview-message hidden>Keine Raeume vorhanden.</p>
</div>
<div class="floor-preview-footer">
<button type="button" class="button js-floor-preview-download" disabled>Als PNG speichern</button>
</div>
</div>
</div>