diff --git a/app/assets/css/locations-list.css b/app/assets/css/locations-list.css index 74cc70c..4841dab 100644 --- a/app/assets/css/locations-list.css +++ b/app/assets/css/locations-list.css @@ -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; +} diff --git a/app/assets/js/locations-list.js b/app/assets/js/locations-list.js index 4b7caa0..f4e2404 100644 --- a/app/assets/js/locations-list.js +++ b/app/assets/js/locations-list.js @@ -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(); + }); })(); diff --git a/app/modules/locations/list.php b/app/modules/locations/list.php index 60d44c3..e870c8f 100644 --- a/app/modules/locations/list.php +++ b/app/modules/locations/list.php @@ -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,11 +200,59 @@ foreach ($rooms as $room) { -
Keine Standorte gefunden.
+ +