closes #33
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user